temperature.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  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. targets = await transfer.get_custom_target()
  192. all_day_targets = targets.get('normal_targets')
  193. current_target = await transfer.get_current_temperature_target()
  194. is_customized = await transfer.is_customized()
  195. is_temporary = await transfer.is_temporary()
  196. season = await duoduo.get_season()
  197. self.result = {
  198. 'realtime_temperature': realtime_temperature,
  199. 'all_day_targets': all_day_targets,
  200. 'current_target': current_target,
  201. 'is_customized': is_customized,
  202. 'is_temporary': is_temporary,
  203. 'season': season
  204. }
  205. async def get_result(self) -> Dict:
  206. await self.fetch_all()
  207. return self.result
  208. class TemperatureTargetV2Carrier(TemperatureTargetCarrier):
  209. """
  210. Add previous adjustment result to result.
  211. """
  212. async def fetch_previous_changes(self) -> None:
  213. async with AsyncClient() as client:
  214. transfer = SpaceInfoService(client, self.project_id, self.object_id)
  215. previous_changes = await transfer.env_database_get()
  216. self.result.update({'previous_changes': previous_changes['temperature']})
  217. async def get_result(self) -> Dict:
  218. await self.fetch_all()
  219. await self.fetch_previous_changes()
  220. return self.result
  221. class TemperatureTargetPacker:
  222. """
  223. Arrange raw data for temperature target adjustment.
  224. """
  225. def __init__(self, data):
  226. self.result = data
  227. def get_temperature_target(self):
  228. all_day_targets = self.result['all_day_targets']
  229. if len(all_day_targets) > 0:
  230. extent = all_day_targets['temperatureMax'].iloc[0] - all_day_targets['temperatureMin'].iloc[0]
  231. temperature_all_day_targets = all_day_targets[['temperatureMin', 'temperatureMax']].copy().to_dict()
  232. else:
  233. extent = 2.0
  234. temperature_all_day_targets = {}
  235. target_params = {
  236. 'is_customized': self.result['is_customized'],
  237. 'is_temporary': self.result['is_temporary'],
  238. 'target_schedule': temperature_all_day_targets,
  239. 'extent': extent
  240. }
  241. target = TemperatureTarget(**target_params)
  242. self.result.update({'target': target})
  243. def get_result(self) -> Dict:
  244. self.get_temperature_target()
  245. return self.result
  246. class TargetDeliver:
  247. """
  248. Send target adjustment result to transfer.
  249. """
  250. def __init__(self, project_id: str, space_id: str):
  251. self.project_id = project_id
  252. self.space_id = space_id
  253. async def send(self, controlled_result: Dict):
  254. async with AsyncClient() as client:
  255. transfer = SpaceInfoService(client, self.project_id, self.space_id)
  256. if controlled_result['need_switch_off']:
  257. await transfer.set_temporary_custom()
  258. if controlled_result['new_temporary_target']:
  259. await transfer.set_custom_target('temperature', controlled_result['new_temporary_target'], '0')
  260. if controlled_result['new_global_target']:
  261. await transfer.set_custom_target('temperature', controlled_result['new_global_target'], '1')
  262. if controlled_result['new_actual_target'] > 0 and controlled_result['need_run_room_control']:
  263. await transfer.env_database_set('temperature', controlled_result['new_actual_target'])
  264. class WeightFlagDeliver:
  265. """
  266. Change a space temporary weight when the space receives a feedback about temperature.
  267. """
  268. def __init__(self, db: Session, feedback: FeedbackValue):
  269. self.db = db
  270. self.feedback = feedback
  271. def is_temperature_feedback(self) -> bool:
  272. if (self.feedback == FeedbackValue.a_little_hot
  273. or self.feedback == FeedbackValue.so_hot
  274. or self.feedback == FeedbackValue.a_little_cold
  275. or self.feedback == FeedbackValue.so_cold):
  276. flag = True
  277. else:
  278. flag = False
  279. return flag
  280. def save(self, space: str):
  281. if self.is_temperature_feedback():
  282. weights = get_weights_by_space(self.db, space_id=space)
  283. for weight in weights:
  284. weight_in = SpaceWeightUpdate(temporary_weight=1.0)
  285. update_weight(self.db, db_weight=weight, weight_in=weight_in)
  286. class TemperatureTargetController:
  287. """
  288. Primary flow of temperature target adjustment for Sequoia.
  289. """
  290. def __init__(self, data: Dict):
  291. self.data = data
  292. self.result = {}
  293. def run(self, feedback: FeedbackValue):
  294. need_switch_off = False
  295. new_temporary_target = {}
  296. new_global_target = {}
  297. new_actual_target = 0
  298. if feedback == FeedbackValue.switch_off:
  299. need_switch_off = True
  300. need_run_room_control = True
  301. elif feedback == FeedbackValue.switch_on:
  302. need_run_room_control = True
  303. if not self.data['is_customized']:
  304. new_lower, new_upper = TemporaryTargetInit(1, 24).build(
  305. self.data['extent'],
  306. self.data['season'],
  307. self.data['realtime_temperature']
  308. )
  309. new_temporary_target = TemporaryTargetBuilder(new_lower, new_upper).build()
  310. elif (feedback == FeedbackValue.a_little_hot
  311. or feedback == FeedbackValue.a_little_cold
  312. or feedback == FeedbackValue.so_hot
  313. or feedback == FeedbackValue.so_cold):
  314. step_size = StepSizeCalculator(TEMPERATURE_TARGET_WEIGHT).run(
  315. self.data['realtime_temperature'],
  316. 25.0,
  317. feedback
  318. )
  319. new_actual_target = NewTemperatureTargetBuilder(
  320. self.data['realtime_temperature'],
  321. self.data['current_target'],
  322. step_size
  323. ).build()
  324. need_run_room_control = True
  325. if new_actual_target != self.data['current_target']:
  326. new_global_target = SimpleGlobalTemperatureTargetBuilder(self.data['target']).build(new_actual_target)
  327. else:
  328. need_run_room_control = False
  329. self.result.update({
  330. 'need_switch_off': need_switch_off,
  331. 'new_temporary_target': new_temporary_target,
  332. 'new_global_target': new_global_target,
  333. 'new_actual_target': new_actual_target,
  334. 'need_run_room_control': need_run_room_control
  335. })
  336. def get_result(self) -> Dict:
  337. return self.result
  338. class TemperatureTargetControllerV2:
  339. """
  340. Primary flow of temperature target adjustment for Zhonghai.
  341. """
  342. def __init__(self, data: Dict):
  343. self.data = data
  344. self.result = {}
  345. def run(self, feedback: FeedbackValue):
  346. need_switch_off = False
  347. new_temporary_target = {}
  348. new_global_target = {}
  349. new_actual_target = 0
  350. if feedback == FeedbackValue.switch_off:
  351. need_switch_off = True
  352. need_run_room_control = True
  353. elif feedback == FeedbackValue.switch_on:
  354. need_run_room_control = True
  355. if not self.data['target'].is_customized:
  356. new_lower, new_upper = TemporaryTargetInit(1, 24).build(
  357. self.data['target'].extent,
  358. self.data['season'],
  359. self.data['realtime_temperature']
  360. )
  361. new_temporary_target = TemporaryTargetBuilder(new_lower, new_upper).build()
  362. elif (feedback == FeedbackValue.a_little_hot
  363. or feedback == FeedbackValue.a_little_cold
  364. or feedback == FeedbackValue.so_hot
  365. or feedback == FeedbackValue.so_cold):
  366. step_size = StepSizeCalculator(TEMPERATURE_TARGET_WEIGHT).run(
  367. self.data['realtime_temperature'],
  368. 25.0,
  369. feedback
  370. )
  371. new_actual_target = NewTemperatureTargetBuilder(
  372. self.data['realtime_temperature'],
  373. self.data['current_target'],
  374. step_size
  375. ).build()
  376. need_run_room_control = True
  377. if new_actual_target != self.data['current_target']:
  378. new_global_target = ExpSmoothingTemperatureTargetBuilder(
  379. self.data['target'],
  380. self.data['previous_changes']
  381. ).build(new_actual_target)
  382. else:
  383. need_run_room_control = False
  384. self.result.update({
  385. 'need_switch_off': need_switch_off,
  386. 'new_temporary_target': new_temporary_target,
  387. 'new_global_target': new_global_target,
  388. 'new_actual_target': new_actual_target,
  389. 'need_run_room_control': need_run_room_control
  390. })
  391. def get_result(self) -> Dict:
  392. return self.result
  393. @logger.catch()
  394. async def temperature_target_control_v1(project_id: str, space_id: str, feedback: FeedbackValue, db: Session) -> bool:
  395. temperature_target_raw_data = await TemperatureTargetCarrier(project_id, space_id).get_result()
  396. temperature_target_data = TemperatureTargetPacker(temperature_target_raw_data).get_result()
  397. controller = TemperatureTargetController(temperature_target_data)
  398. controller.run(feedback)
  399. controlled_result = controller.get_result()
  400. await TargetDeliver(project_id, space_id).send(controlled_result)
  401. WeightFlagDeliver(db, feedback).save(space_id)
  402. return controlled_result['need_run_room_control']
  403. @logger.catch()
  404. async def temperature_target_control_v2(project_id: str, space_id: str, feedback: FeedbackValue) -> bool:
  405. temperature_target_raw_data = await TemperatureTargetV2Carrier(project_id, space_id).get_result()
  406. temperature_target_data = TemperatureTargetPacker(temperature_target_raw_data).get_result()
  407. controller = TemperatureTargetControllerV2(temperature_target_data)
  408. controller.run(feedback)
  409. controlled_result = controller.get_result()
  410. await TargetDeliver(project_id, space_id).send(controlled_result)
  411. return controlled_result['need_run_room_control']
  412. @logger.catch()
  413. async def get_target_after_feedback(project_id: str, space_id: str, feedback: FeedbackValue) -> float:
  414. if project_id == 'Pj1101050030':
  415. temperature_target_raw_data = await TemperatureTargetCarrier(project_id, space_id).get_result()
  416. else:
  417. temperature_target_raw_data = await TemperatureTargetV2Carrier(project_id, space_id).get_result()
  418. temperature_target_data = TemperatureTargetPacker(temperature_target_raw_data).get_result()
  419. if project_id == 'Pj1101050030':
  420. controller = TemperatureTargetController(temperature_target_data)
  421. else:
  422. controller = TemperatureTargetControllerV2(temperature_target_data)
  423. controller.run(feedback)
  424. controlled_result = controller.get_result()
  425. return controlled_result.get('new_actual_target')