temperature.py 20 KB

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