temperature.py 18 KB

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