Переглянути джерело

add new temperature target adjustment

chenhaiyang 4 роки тому
батько
коміт
f4f83a58ea

+ 36 - 4
app/api/routers/targets.py

@@ -3,14 +3,15 @@
 from fastapi import APIRouter, HTTPException, Query
 from loguru import logger
 
-from app.controllers.targets.targets_v1 import readjust_all_target
+from app.controllers.targets.target import readjust_all_target
+from app.controllers.targets.temperature import TemperatureAdjustmentController
 from app.models.domain.targets import TargetReadjustResponse
 from app.utils.date import get_time_str
 
 router = APIRouter()
 
 
-@router.get('/adjust', response_model=TargetReadjustResponse, tags=['targets'])
+@router.get('/adjust', response_model=TargetReadjustResponse)
 async def readjust_target(
         projectId: str = Query(..., max_length=50, regex='^Pj'),
         roomId: str = Query(..., max_length=50, regex='^Sp'),
@@ -19,7 +20,11 @@ async def readjust_target(
     try:
         if not time:
             time = get_time_str()
-        need_run_room_control = await readjust_all_target(projectId, roomId, time)
+        if projectId == 'Pj1101050030':
+            controller = TemperatureAdjustmentController(projectId, roomId, wechat_time=time)
+            need_run_room_control = await controller.run()
+        else:
+            need_run_room_control = await readjust_all_target(projectId, roomId, time)
     except Exception as e:
         logger.error(e)
         raise HTTPException(
@@ -36,7 +41,7 @@ async def readjust_target(
     return response
 
 
-@router.get('/adjust/test', response_model=TargetReadjustResponse, tags=['targets'])
+@router.get('/adjust/test', response_model=TargetReadjustResponse)
 async def test_readjust_target(
         project_id: str = Query(..., max_length=50, regex='^Pj'),
         space_id: str = Query(..., max_length=50, regex='^Sp')
@@ -64,3 +69,30 @@ async def test_readjust_target(
         'time': get_time_str()
     }
     return response
+
+
+@router.get('/adjust/v2', response_model=TargetReadjustResponse)
+async def readjust_target_v2(
+        project_id: str = Query(..., max_length=50, regex='^Pj'),
+        space_id: str = Query(..., max_length=50, regex='^Sp')
+):
+    test_feedback = {
+        'a little hot': 1
+    }
+    try:
+        controller = TemperatureAdjustmentController(project_id, space_id, raw_feedback=test_feedback)
+        need_run_room_control = await controller.run()
+    except Exception as e:
+        logger.error(e)
+        raise HTTPException(
+            status_code=500,
+            detail='Oops, something wrong has happened!'
+        )
+
+    response = {
+        'projectId': project_id,
+        'roomId': space_id,
+        'flag': 1 if need_run_room_control else 0,
+        'time': get_time_str()
+    }
+    return response

+ 253 - 8
app/controllers/targets/temperature.py

@@ -1,27 +1,272 @@
 # -*- coding: utf-8 -*-
 
-from typing import Dict, Optional
+from abc import ABCMeta, abstractmethod
+from typing import Dict, Optional, Tuple
 
+import arrow
 import numpy as np
-import pandas as pd
+from httpx import AsyncClient
+from loguru import logger
 
-from app.services.transfer import Season
+from app.resources.params import TEMPERATURE_TARGET_WEIGHT
 from app.schemas.feedback import Feedback
+from app.schemas.target import TemperatureTarget
+from app.services.platform import DataPlatformService
+from app.services.transfer import SpaceInfoService, Season
+from app.utils.date import get_time_str, TIME_FMT
 
 
 class StepSizeCalculator:
     """
     Calculate adjustment step size of environment target when a user send feedback.
     """
+
     def __init__(self, weight: Dict):
         self.weight = weight
 
-    def calculate_step_size(
+    def run(
             self,
-            space_realtime_temperature: float,
-            space_comfortable_temperature: float,
+            realtime_temperature: float,
+            comfortable_temperature: float,
             feedback: Feedback
+    ) -> float:
+        base_step_size = 1.8 / (1 + np.exp((comfortable_temperature - realtime_temperature) / 2))
+        return self.weight.get(feedback.value) * base_step_size
+
+
+class NewTargetBuilder(metaclass=ABCMeta):
+    """
+    Calculate a new target value.
+    """
+
+    @abstractmethod
+    def build(self) -> float:
+        raise NotImplementedError
+
+
+class NewTemperatureTargetBuilder(NewTargetBuilder):
+    """
+    Calculate a new temperature target value.
+    """
+
+    def __init__(self, realtime_temperature: float, actual_target: float, step_size: float):
+        self.realtime_temperature = realtime_temperature
+        self.actual_target = actual_target
+        self.step_size = step_size
+
+    def build(self) -> float:
+        new_actual_target = self.actual_target
+        if self.step_size > 0:
+            if self.realtime_temperature + self.step_size > self.actual_target:
+                new_actual_target = self.realtime_temperature + self.step_size
+        elif self.step_size < 0:
+            if self.realtime_temperature + self.step_size < self.actual_target:
+                new_actual_target = self.realtime_temperature + self.step_size
+        return new_actual_target
+
+
+class Clipper:
+    """
+    Return a number which is in the range of [min, max].
+    """
+
+    def __init__(self, upper_limit: float = 28.0, lower_limit: float = 22.0):
+        self.upper_limit = upper_limit
+        self.lower_limit = lower_limit
+
+    def cut(self, num: float) -> float:
+        num = min(num, self.upper_limit)
+        num = max(num, self.lower_limit)
+        return num
+
+
+class TemporaryTargetInit:
+    """
+    Initialize temporary temperature target.
+    """
+
+    def __init__(self, step_size: float, default_target: float = 24.0):
+        self.step_size = step_size
+        self.default_target = default_target
+
+    def build(
+            self,
+            extent: float,
+            season: Season,
+            realtime_temperature: float,
+    ) -> Tuple[float, float]:
+        if np.isnan(realtime_temperature):
+            upper_bound, lower_bound = self.default_target + 1.0, self.default_target - 1.0
+        else:
+            actual_target = np.NAN
+            if season == Season.cooling:
+                actual_target = realtime_temperature - self.step_size
+            elif season == Season.heating:
+                actual_target = realtime_temperature + self.step_size
+
+            clipper = Clipper()
+            actual_target = clipper.cut(actual_target)
+            upper_bound, lower_bound = actual_target + (extent / 2), actual_target - (extent / 2)
+
+        return lower_bound, upper_bound
+
+
+class GlobalTargetBaseBuilder(metaclass=ABCMeta):
+    """
+    Generate global target and format it for sending to TransferServer.
+    """
+
+    @abstractmethod
+    def build(self, new_actual_target: float) -> Dict:
+        raise NotImplementedError
+
+
+class SimpleGlobalTemperatureTargetBuilder(GlobalTargetBaseBuilder):
+    """
+    Set all day temperature target same.
+    """
+
+    def __init__(self, current_global_target: TemperatureTarget):
+        self.current_global_target = current_global_target
+
+    def build(self, new_actual_target: float) -> Dict:
+        result = {}
+        half_extent = self.current_global_target.extent / 2
+        for time_index in self.current_global_target.target_schedule['temperatureMin'].keys():
+            result.update({time_index: [new_actual_target - half_extent, new_actual_target + half_extent]})
+
+        return result
+
+
+class TemporaryTargetBuilder:
+    """
+    Generate global target and format it for sending to TransferServer.
+    """
+
+    def __init__(self, lower_target: float, upper_target: float):
+        self.lower_target = lower_target
+        self.upper_target = upper_target
+
+    def build(self) -> Dict:
+        now_str = get_time_str()
+        time_index = arrow.get(arrow.get(now_str, TIME_FMT).shift(minutes=15).timestamp
+                               // (15 * 60) * (15 * 60)).time().strftime('%H%M%S')
+        return {time_index: [self.lower_target, self.upper_target]}
+
+
+class AdjustmentController(metaclass=ABCMeta):
+    """
+    Fetch some data, assemble target adjustment related functions and classes, send the new target to transfer server,
+    and return a flag which denote whether transfer server need to request room/control.
+    """
+
+    @abstractmethod
+    async def run(self) -> bool:
+        raise NotImplementedError
+
+
+class TemperatureAdjustmentController(AdjustmentController):
+    """
+    Test strategy for Sequoia.
+    """
+
+    def __init__(
+            self,
+            project_id: str,
+            space_id: str,
+            wechat_time: Optional[str] = None,
+            raw_feedback: Optional[Dict] = None
     ):
-        base_step_size = 1.8 / (1 + np.exp((space_comfortable_temperature - space_realtime_temperature) / 2))
-        return 
+        self.project_id = project_id
+        self.space_id = space_id
+        self.wechat_time = wechat_time
+        self.raw_feedback = raw_feedback
+
+    @logger.catch()
+    async def run(self) -> bool:
+        # Fetch all data from data platform and transfer server.
+        async with AsyncClient() as client:
+            transfer = SpaceInfoService(client, self.project_id, self.space_id)
+            platform = DataPlatformService(client, self.project_id)
+
+            realtime_temperature = await platform.get_realtime_temperature(self.space_id)
+            logger.debug(f'realtime temperature: {realtime_temperature}')
+            all_day_targets = await transfer.get_custom_target()
+            current_target = await transfer.get_current_temperature_target()
+            logger.debug(f'current target: {current_target}')
+            if self.wechat_time:
+                self.raw_feedback = await transfer.get_feedback(self.wechat_time)
+                logger.debug(f'raw feedback: {self.raw_feedback}')
+            is_customized = await transfer.is_customized()
+            logger.debug(f'is customized? {is_customized}')
+            is_temporary = await transfer.is_temporary()
+            logger.debug(f'is temporary? {is_temporary}')
+            season = await transfer.get_season()
+
+        # Arrange data.
+        feedback = Feedback('nice')
+        for k, v in self.raw_feedback.items():
+            if v > 0:
+                feedback = Feedback(k)
+                break
+        logger.debug(feedback)
+
+        if len(all_day_targets) > 0:
+            extent = all_day_targets['temperatureMax'].iloc[0] - all_day_targets['temperatureMin'].iloc[0]
+        else:
+            extent = 2.0
+
+        temperature_all_day_targets = all_day_targets[['temperatureMin', 'temperatureMax']].copy()
+        target_params = {
+            'is_customized': is_customized,
+            'is_temporary': is_temporary,
+            'target_schedule': temperature_all_day_targets.to_dict(),
+            'extent': extent
+        }
+        target = TemperatureTarget(**target_params)
+
+        # Primary flow
+        need_switch_off = False
+        new_temporary_target = {}
+        new_global_target = {}
+        new_actual_target = 0
+        if feedback == Feedback.switch_off:
+            need_switch_off = True
+            need_run_room_control = True
+        elif feedback == Feedback.switch_on:
+            need_run_room_control = True
+            if not is_customized:
+                new_lower, new_upper = TemporaryTargetInit(1, 24).build(extent, season, realtime_temperature)
+                new_temporary_target = TemporaryTargetBuilder(new_lower, new_upper)
+        elif (feedback == Feedback.a_little_hot
+              or feedback == Feedback.a_little_cold
+              or feedback == Feedback.so_hot
+              or feedback == Feedback.so_cold):
+            step_size = StepSizeCalculator(TEMPERATURE_TARGET_WEIGHT).run(realtime_temperature, 25.0, feedback)
+            logger.debug(f'step size: {step_size}')
+            new_actual_target = NewTemperatureTargetBuilder(realtime_temperature, current_target, step_size).build()
+            if new_actual_target != current_target:
+                new_global_target = SimpleGlobalTemperatureTargetBuilder(target).build(new_actual_target)
+                need_run_room_control = True
+            else:
+                need_run_room_control = False
+        else:
+            need_run_room_control = False
+
+        # Send adjustment result to transfer server
+        async with AsyncClient() as client:
+            transfer = SpaceInfoService(client, self.project_id, self.space_id)
+            if need_switch_off:
+                await transfer.set_temporary_custom()
+                logger.debug(f'turn off')
+            if new_temporary_target:
+                await transfer.set_custom_target('temperature', new_temporary_target, '0')
+                logger.debug(f'new temporary targets: {new_temporary_target}')
+            if new_global_target:
+                await transfer.set_custom_target('temperature', new_global_target, '1')
+                logger.debug(f'new global targets: {new_global_target}')
+            if new_actual_target != current_target:
+                await transfer.env_database_set('temperature', new_actual_target)
+                logger.debug(f'new actual target: {new_actual_target}')
 
+        return need_run_room_control

+ 7 - 0
app/resources/params.py

@@ -8,6 +8,13 @@ TEMPERATURE_RELATED_FEEDBACK_WEIGHT = {
     'switch on': 0.1,
 }
 
+TEMPERATURE_TARGET_WEIGHT = {
+    'a little cold': 1,
+    'so cold': 2,
+    'a little hot': -1,
+    'so hot': -2,
+}
+
 CO2_RELATED_FEEDBACK_WEIGHT = {
     'noisy or blowy': 100,
     'so stuffy': -100,

+ 18 - 0
app/schemas/feedback.py

@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+
+from enum import Enum
+
+
+class Feedback(Enum):
+    a_little_cold = 'a little cold'
+    so_cold = 'so cold'
+    a_little_hot = 'a little hot'
+    so_hot = 'so hot'
+    noisy_or_blowy = 'noisy or blowy'
+    so_stuffy = 'so stuffy'
+    more_sunshine = 'more sunshine'
+    less_sunshine = 'less sunshine'
+    send_a_repairman = 'send a repairman'
+    switch_off = 'switch off'
+    nice = 'nice'
+    switch_on = 'switch on'

+ 11 - 0
app/schemas/project.py

@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+
+from pydantic import BaseModel
+
+from app.services.transfer import Season
+
+
+class Project(BaseModel):
+    id: str
+    season: Season
+    temperature_target_range: float

+ 2 - 1
app/schemas/space.py

@@ -9,4 +9,5 @@ class Space(BaseModel):
     id: str
     realtime_temperature: float
     equipment: Optional[List]
-    temperature_target: float
+    temperature_target: Optional[float]
+    comfortable_temperature: Optional[float]

+ 12 - 0
app/schemas/target.py

@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+
+from typing import Dict
+
+from pydantic import BaseModel
+
+
+class TemperatureTarget(BaseModel):
+    is_customized: bool
+    is_temporary: bool
+    target_schedule: Dict
+    extent: float