temperature.py 19 KB

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