temperature.py 23 KB

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