temperature.py 20 KB

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