temperature.py 19 KB

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