|
@@ -1,27 +1,272 @@
|
|
# -*- coding: utf-8 -*-
|
|
# -*- 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 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.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:
|
|
class StepSizeCalculator:
|
|
"""
|
|
"""
|
|
Calculate adjustment step size of environment target when a user send feedback.
|
|
Calculate adjustment step size of environment target when a user send feedback.
|
|
"""
|
|
"""
|
|
|
|
+
|
|
def __init__(self, weight: Dict):
|
|
def __init__(self, weight: Dict):
|
|
self.weight = weight
|
|
self.weight = weight
|
|
|
|
|
|
- def calculate_step_size(
|
|
|
|
|
|
+ def run(
|
|
self,
|
|
self,
|
|
- space_realtime_temperature: float,
|
|
|
|
- space_comfortable_temperature: float,
|
|
|
|
|
|
+ realtime_temperature: float,
|
|
|
|
+ comfortable_temperature: float,
|
|
feedback: Feedback
|
|
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
|