temperature.py 19 KB

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