temperature.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. # -*- coding: utf-8 -*-
  2. from abc import ABCMeta, abstractmethod
  3. from typing import Dict, Tuple
  4. import arrow
  5. import numpy as np
  6. import pandas as pd
  7. from httpx import AsyncClient
  8. from loguru import logger
  9. from app.models.domain.feedback import FeedbackValue
  10. from app.resources.params import TEMPERATURE_TARGET_WEIGHT
  11. from app.schemas.target import TemperatureTarget
  12. from app.services.platform import DataPlatformService
  13. from app.services.transfer import SpaceInfoService, Season
  14. from app.utils.date import get_time_str, TIME_FMT
  15. class StepSizeCalculator:
  16. """
  17. Calculate adjustment step size of environment target when a user send feedback.
  18. """
  19. def __init__(self, weight: Dict):
  20. self.weight = weight
  21. def run(
  22. self,
  23. realtime_temperature: float,
  24. comfortable_temperature: float,
  25. feedback: FeedbackValue
  26. ) -> float:
  27. base_step_size = 1.8 / (1 + np.exp((comfortable_temperature - realtime_temperature) / 2))
  28. return self.weight.get(feedback.value) * base_step_size
  29. class NewTargetBuilder(metaclass=ABCMeta):
  30. """
  31. Calculate a new target value.
  32. """
  33. @abstractmethod
  34. def build(self) -> float:
  35. raise NotImplementedError
  36. class NewTemperatureTargetBuilder(NewTargetBuilder):
  37. """
  38. Calculate a new temperature target value.
  39. """
  40. def __init__(self, realtime_temperature: float, actual_target: float, step_size: float):
  41. self.realtime_temperature = realtime_temperature
  42. self.actual_target = actual_target
  43. self.step_size = step_size
  44. def build(self) -> float:
  45. new_actual_target = self.actual_target
  46. if self.step_size > 0:
  47. if self.realtime_temperature + self.step_size > self.actual_target:
  48. new_actual_target = self.realtime_temperature + self.step_size
  49. elif self.step_size < 0:
  50. if self.realtime_temperature + self.step_size < self.actual_target:
  51. new_actual_target = self.realtime_temperature + self.step_size
  52. return new_actual_target
  53. class Clipper:
  54. """
  55. Return a number which is in the range of [min, max].
  56. """
  57. def __init__(self, upper_limit: float = 28.0, lower_limit: float = 22.0):
  58. self.upper_limit = upper_limit
  59. self.lower_limit = lower_limit
  60. def cut(self, num: float) -> float:
  61. num = min(num, self.upper_limit)
  62. num = max(num, self.lower_limit)
  63. return num
  64. class TemporaryTargetInit:
  65. """
  66. Initialize temporary temperature target.
  67. """
  68. def __init__(self, step_size: float, default_target: float = 24.0):
  69. self.step_size = step_size
  70. self.default_target = default_target
  71. def build(
  72. self,
  73. extent: float,
  74. season: Season,
  75. realtime_temperature: float,
  76. ) -> Tuple[float, float]:
  77. if np.isnan(realtime_temperature):
  78. upper_bound, lower_bound = self.default_target + 1.0, self.default_target - 1.0
  79. else:
  80. actual_target = np.NAN
  81. if season == Season.cooling:
  82. actual_target = realtime_temperature - self.step_size
  83. elif season == Season.heating:
  84. actual_target = realtime_temperature + self.step_size
  85. clipper = Clipper()
  86. actual_target = clipper.cut(actual_target)
  87. upper_bound, lower_bound = actual_target + (extent / 2), actual_target - (extent / 2)
  88. return lower_bound, upper_bound
  89. class GlobalTargetBaseBuilder(metaclass=ABCMeta):
  90. """
  91. Generate global target and format it for sending to TransferServer.
  92. """
  93. @abstractmethod
  94. def build(self, new_actual_target: float) -> Dict:
  95. raise NotImplementedError
  96. class SimpleGlobalTemperatureTargetBuilder(GlobalTargetBaseBuilder):
  97. """
  98. Set all day temperature target same.
  99. """
  100. def __init__(self, current_global_target: TemperatureTarget):
  101. self.current_global_target = current_global_target
  102. def build(self, new_actual_target: float) -> Dict:
  103. result = {}
  104. half_extent = self.current_global_target.extent / 2
  105. for time_index in self.current_global_target.target_schedule['temperatureMin'].keys():
  106. result.update({time_index: [new_actual_target - half_extent, new_actual_target + half_extent]})
  107. return result
  108. class ExpSmoothingTemperatureTargetBuilder(GlobalTargetBaseBuilder):
  109. """
  110. Exponential smooth previous changes and set them as new global target.
  111. """
  112. def __init__(self, current_global_target: TemperatureTarget, previous_changes: pd.DataFrame):
  113. self.current_global_target = current_global_target
  114. self.previous_changes = previous_changes
  115. def build(self, new_actual_target: float) -> Dict:
  116. now_time = arrow.get(get_time_str(), TIME_FMT).time().strftime('%H%M%S')
  117. half_extent = self.current_global_target.extent / 2
  118. previous_changes = pd.concat([
  119. pd.DataFrame({'timestamp': [now_time], 'value': [new_actual_target]}),
  120. self.previous_changes,
  121. ])
  122. previous_changes.reset_index(inplace=True)
  123. previous_changes['weight1'] = previous_changes['index'].apply(lambda x: (1 / (x + 1)) ** 3)
  124. new_targets = {}
  125. time_index = self.current_global_target.target_schedule['temperatureMin'].keys()
  126. for item in time_index:
  127. previous_changes['delta'] = previous_changes['timestamp'].apply(
  128. lambda x: abs(arrow.get(str(x), 'HHmmss') - arrow.get(item, 'HHmmss')).seconds // (15 * 60)
  129. )
  130. previous_changes['weight2'] = previous_changes['delta'].apply(lambda x: 0.5 ** x)
  131. previous_changes['weight'] = previous_changes['weight1'] * previous_changes['weight2']
  132. temp_target = ((previous_changes['value'] * previous_changes['weight']).sum()
  133. / previous_changes['weight'].sum())
  134. new_targets.update({item: [temp_target - half_extent, temp_target + half_extent]})
  135. return new_targets
  136. class TemporaryTargetBuilder:
  137. """
  138. Generate global target and format it for sending to TransferServer.
  139. """
  140. def __init__(self, lower_target: float, upper_target: float):
  141. self.lower_target = lower_target
  142. self.upper_target = upper_target
  143. def build(self) -> Dict:
  144. now_str = get_time_str()
  145. time_index = arrow.get(arrow.get(now_str, TIME_FMT).shift(minutes=15).timestamp
  146. // (15 * 60) * (15 * 60)).time().strftime('%H%M%S')
  147. return {time_index: [self.lower_target, self.upper_target]}
  148. class Carrier(metaclass=ABCMeta):
  149. """
  150. Fetch all you need data by one http client.
  151. """
  152. @abstractmethod
  153. async def fetch_all(self) -> None:
  154. raise NotImplementedError
  155. class Packer(metaclass=ABCMeta):
  156. """
  157. Arrange raw data for using.
  158. """
  159. @abstractmethod
  160. def run(self) -> Dict:
  161. raise NotImplementedError
  162. class AdjustmentController(metaclass=ABCMeta):
  163. """
  164. Fetch some data, assemble target adjustment related functions and classes, send the new target to transfer server,
  165. and return a flag which denote whether transfer server need to request room/control.
  166. """
  167. @abstractmethod
  168. async def run(self) -> bool:
  169. raise NotImplementedError
  170. class TemperatureTargetCarrier(Carrier):
  171. """
  172. Fetch all the data that temperature target adjustment will use.
  173. """
  174. def __init__(self, project_id: str, object_id: str):
  175. self.project_id = project_id
  176. self.object_id = object_id
  177. self.result = {}
  178. async def fetch_all(self) -> None:
  179. async with AsyncClient() as client:
  180. transfer = SpaceInfoService(client, self.project_id, self.object_id)
  181. platform = DataPlatformService(client, self.project_id)
  182. realtime_temperature = await platform.get_realtime_temperature(self.object_id)
  183. all_day_targets = await transfer.get_custom_target()
  184. current_target = await transfer.get_current_temperature_target()
  185. is_customized = await transfer.is_customized()
  186. is_temporary = await transfer.is_temporary()
  187. season = await transfer.get_season()
  188. self.result = {
  189. 'realtime_temperature': realtime_temperature,
  190. 'all_day_targets': all_day_targets,
  191. 'current_target': current_target,
  192. 'is_customized': is_customized,
  193. 'is_temporary': is_temporary,
  194. 'season': season
  195. }
  196. async def get_result(self) -> Dict:
  197. await self.fetch_all()
  198. return self.result
  199. class TemperatureTargetV2Carrier(TemperatureTargetCarrier):
  200. """
  201. Add previous adjustment result to result.
  202. """
  203. async def fetch_previous_changes(self) -> None:
  204. async with AsyncClient() as client:
  205. transfer = SpaceInfoService(client, self.project_id, self.object_id)
  206. previous_changes = await transfer.env_database_get()
  207. self.result.update({'previous_changes': previous_changes['temperature']})
  208. async def get_result(self) -> Dict:
  209. await self.fetch_all()
  210. await self.fetch_previous_changes()
  211. return self.result
  212. class TemperatureTargetPacker:
  213. """
  214. Arrange raw data for temperature target adjustment.
  215. """
  216. def __init__(self, data):
  217. self.result = data
  218. def get_temperature_target(self):
  219. all_day_targets = self.result['all_day_targets']
  220. if len(all_day_targets) > 0:
  221. extent = all_day_targets['temperatureMax'].iloc[0] - all_day_targets['temperatureMin'].iloc[0]
  222. else:
  223. extent = 2.0
  224. temperature_all_day_targets = all_day_targets[['temperatureMin', 'temperatureMax']].copy().to_dict()
  225. target_params = {
  226. 'is_customized': self.result['is_customized'],
  227. 'is_temporary': self.result['is_temporary'],
  228. 'target_schedule': temperature_all_day_targets,
  229. 'extent': extent
  230. }
  231. target = TemperatureTarget(**target_params)
  232. self.result.update({'target': target})
  233. def get_result(self) -> Dict:
  234. self.get_temperature_target()
  235. return self.result
  236. class TargetDeliver:
  237. """
  238. Send target adjustment result to transfer.
  239. """
  240. def __init__(self, project_id: str, space_id: str):
  241. self.project_id = project_id
  242. self.space_id = space_id
  243. async def send(self, controlled_result: Dict):
  244. async with AsyncClient() as client:
  245. transfer = SpaceInfoService(client, self.project_id, self.space_id)
  246. if controlled_result['need_switch_off']:
  247. await transfer.set_temporary_custom()
  248. if controlled_result['new_temporary_target']:
  249. await transfer.set_custom_target('temperature', controlled_result['new_temporary_target'], '0')
  250. if controlled_result['new_global_target']:
  251. await transfer.set_custom_target('temperature', controlled_result['new_global_target'], '1')
  252. if controlled_result['new_actual_target'] > 0 and controlled_result['need_run_room_control']:
  253. await transfer.env_database_set('temperature', controlled_result['new_actual_target'])
  254. class TemperatureTargetController:
  255. """
  256. Primary flow of temperature target adjustment for Sequoia.
  257. """
  258. def __init__(self, data: Dict):
  259. self.data = data
  260. self.result = {}
  261. def run(self, feedback: FeedbackValue):
  262. need_switch_off = False
  263. new_temporary_target = {}
  264. new_global_target = {}
  265. new_actual_target = 0
  266. if feedback == FeedbackValue.switch_off:
  267. need_switch_off = True
  268. need_run_room_control = True
  269. elif feedback == FeedbackValue.switch_on:
  270. need_run_room_control = True
  271. if not self.data['is_customized']:
  272. new_lower, new_upper = TemporaryTargetInit(1, 24).build(
  273. self.data['extent'],
  274. self.data['season'],
  275. self.data['realtime_temperature']
  276. )
  277. new_temporary_target = TemporaryTargetBuilder(new_lower, new_upper)
  278. elif (feedback == FeedbackValue.a_little_hot
  279. or feedback == FeedbackValue.a_little_cold
  280. or feedback == FeedbackValue.so_hot
  281. or feedback == FeedbackValue.so_cold):
  282. step_size = StepSizeCalculator(TEMPERATURE_TARGET_WEIGHT).run(
  283. self.data['realtime_temperature'],
  284. 25.0,
  285. feedback
  286. )
  287. logger.debug(f'step size: {step_size}')
  288. new_actual_target = NewTemperatureTargetBuilder(
  289. self.data['realtime_temperature'],
  290. self.data['current_target'],
  291. step_size
  292. ).build()
  293. if new_actual_target != self.data['current_target']:
  294. new_global_target = SimpleGlobalTemperatureTargetBuilder(self.data['target']).build(new_actual_target)
  295. need_run_room_control = True
  296. else:
  297. need_run_room_control = False
  298. else:
  299. need_run_room_control = False
  300. self.result.update({
  301. 'need_switch_off': need_switch_off,
  302. 'new_temporary_target': new_temporary_target,
  303. 'new_global_target': new_global_target,
  304. 'new_actual_target': new_actual_target,
  305. 'need_run_room_control': need_run_room_control
  306. })
  307. def get_result(self) -> Dict:
  308. return self.result
  309. class TemperatureTargetControllerV2:
  310. """
  311. Primary flow of temperature target adjustment for Zhonghai.
  312. """
  313. def __init__(self, data: Dict):
  314. self.data = data
  315. self.result = {}
  316. def run(self, feedback: FeedbackValue):
  317. need_switch_off = False
  318. new_temporary_target = {}
  319. new_global_target = {}
  320. new_actual_target = 0
  321. if feedback == FeedbackValue.switch_off:
  322. need_switch_off = True
  323. need_run_room_control = True
  324. elif feedback == FeedbackValue.switch_on:
  325. need_run_room_control = True
  326. if not self.data['is_customized']:
  327. new_lower, new_upper = TemporaryTargetInit(1, 24).build(
  328. self.data['extent'],
  329. self.data['season'],
  330. self.data['realtime_temperature']
  331. )
  332. new_temporary_target = TemporaryTargetBuilder(new_lower, new_upper)
  333. elif (feedback == FeedbackValue.a_little_hot
  334. or feedback == FeedbackValue.a_little_cold
  335. or feedback == FeedbackValue.so_hot
  336. or feedback == FeedbackValue.so_cold):
  337. step_size = StepSizeCalculator(TEMPERATURE_TARGET_WEIGHT).run(
  338. self.data['realtime_temperature'],
  339. 25.0,
  340. feedback
  341. )
  342. logger.debug(f'step size: {step_size}')
  343. logger.debug(f'realtime: {self.data["realtime_temperature"]}')
  344. logger.debug(f'current target: {self.data["current_target"]}')
  345. new_actual_target = NewTemperatureTargetBuilder(
  346. self.data['realtime_temperature'],
  347. self.data['current_target'],
  348. step_size
  349. ).build()
  350. if new_actual_target != self.data['current_target']:
  351. new_global_target = ExpSmoothingTemperatureTargetBuilder(
  352. self.data['target'],
  353. self.data['previous_changes']
  354. ).build(new_actual_target)
  355. logger.debug(f'new global target: {new_global_target}')
  356. need_run_room_control = True
  357. else:
  358. need_run_room_control = False
  359. else:
  360. need_run_room_control = False
  361. self.result.update({
  362. 'need_switch_off': need_switch_off,
  363. 'new_temporary_target': new_temporary_target,
  364. 'new_global_target': new_global_target,
  365. 'new_actual_target': new_actual_target,
  366. 'need_run_room_control': need_run_room_control
  367. })
  368. def get_result(self) -> Dict:
  369. return self.result
  370. @logger.catch()
  371. async def temperature_target_control_v1(project_id: str, space_id: str, feedback: FeedbackValue) -> bool:
  372. temperature_target_raw_data = await TemperatureTargetCarrier(project_id, space_id).get_result()
  373. temperature_target_data = TemperatureTargetPacker(temperature_target_raw_data).get_result()
  374. controller = TemperatureTargetController(temperature_target_data)
  375. controller.run(feedback)
  376. controlled_result = controller.get_result()
  377. await TargetDeliver(project_id, space_id).send(controlled_result)
  378. return controlled_result['need_run_room_control']
  379. @logger.catch()
  380. async def temperature_target_control_v2(project_id: str, space_id: str, feedback: FeedbackValue) -> bool:
  381. temperature_target_raw_data = await TemperatureTargetV2Carrier(project_id, space_id).get_result()
  382. temperature_target_data = TemperatureTargetPacker(temperature_target_raw_data).get_result()
  383. controller = TemperatureTargetControllerV2(temperature_target_data)
  384. controller.run(feedback)
  385. controlled_result = controller.get_result()
  386. await TargetDeliver(project_id, space_id).send(controlled_result)
  387. return controlled_result['need_run_room_control']