temperature.py 17 KB

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