temperature_transition_service.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. """Temperature event waveform data for the review page.
  2. The labels come from ``_tmp_temperature/out/温度异常段.csv``; the waveform is
  3. queried live from ``pks_long_sample`` at 5-second resolution.
  4. """
  5. from __future__ import annotations
  6. import csv
  7. import math
  8. import re
  9. from datetime import datetime, timedelta
  10. from pathlib import Path
  11. from statistics import median
  12. from typing import Any
  13. from ..db import get_connection
  14. ROOT = Path(__file__).resolve().parents[3]
  15. TEMPERATURE_EVENTS_CSV = ROOT / "_tmp_temperature" / "out" / "温度异常段.csv"
  16. TEMPERATURE_OBS_CSV = ROOT / "_tmp_temperature" / "out" / "温度观察段.csv"
  17. STEP_SECONDS = 5
  18. PAD_HOURS = 6
  19. CYLINDERS: dict[int, dict[str, list[tuple[str, str]]]] = {
  20. 4: {
  21. "一级": [("YSJ_9", "一级1#"), ("YSJ_10", "一级2#"), ("YSJ_11", "一级3#")],
  22. "二级": [("YSJ_13", "二级1#"), ("YSJ_14", "二级2#"), ("YSJ_15", "二级3#")],
  23. },
  24. 5: {
  25. "一级": [("YSJ_18", "一级1#"), ("YSJ_20", "一级2#"), ("YSJ_22", "一级3#")],
  26. "二级": [("YSJ_17", "二级1#"), ("YSJ_19", "二级2#"), ("YSJ_21", "二级3#")],
  27. },
  28. 6: {
  29. "一级": [("YSJ_18", "一级1#"), ("YSJ_20", "一级2#"), ("YSJ_22", "一级3#")],
  30. "二级": [("YSJ_17", "二级1#"), ("YSJ_19", "二级2#"), ("YSJ_21", "二级3#")],
  31. },
  32. 7: {
  33. "一级": [("YSJ_16", "一级2列"), ("YSJ_17", "一级4列"), ("YSJ_18", "一级6列")],
  34. "二级": [("YSJ_19", "二级3列"), ("YSJ_20", "二级5列")],
  35. "三级": [("YSJ_21", "三级1列")],
  36. },
  37. 8: {
  38. "一级": [("YSJ_16", "一级2列"), ("YSJ_17", "一级4列"), ("YSJ_18", "一级6列")],
  39. "二级": [("YSJ_19", "二级3列"), ("YSJ_20", "二级5列")],
  40. "三级": [("YSJ_21", "三级1列")],
  41. },
  42. 9: {
  43. "一级": [("YSJ_16", "一级2列"), ("YSJ_17", "一级4列"), ("YSJ_18", "一级6列")],
  44. "二级": [("YSJ_19", "二级3列"), ("YSJ_20", "二级5列")],
  45. "三级": [("YSJ_21", "三级1列")],
  46. },
  47. }
  48. COLORS = ["#1f6feb", "#e8890c", "#2e9e5b", "#8e44ad", "#c0392b", "#16a085"]
  49. BAND = "rgba(255,193,7,0.16)"
  50. def _to_float(value: Any) -> float | None:
  51. if value is None:
  52. return None
  53. text = str(value).strip()
  54. if not text or text.lower() == "nan":
  55. return None
  56. try:
  57. number = float(text)
  58. except ValueError:
  59. return None
  60. return number if math.isfinite(number) else None
  61. def _temp_value(value: Any) -> float | None:
  62. """Cylinder temperature: treat 0 as a missing/dropout sample for plotting."""
  63. number = _to_float(value)
  64. if number is None or number == 0:
  65. return None
  66. return number
  67. def _parse_time(value: str) -> datetime:
  68. return datetime.strptime(value.strip(), "%Y-%m-%d %H:%M:%S")
  69. def _fmt_time(value: datetime) -> str:
  70. return value.strftime("%Y-%m-%d %H:%M:%S")
  71. def _unit_number(value: str) -> int:
  72. match = re.search(r"(\d+)", value or "")
  73. return int(match.group(1)) if match else 4
  74. def _padded(values: list[float]) -> tuple[float, float]:
  75. if not values:
  76. return 0.0, 1.0
  77. low, high = min(values), max(values)
  78. if high - low < 1e-9:
  79. return low - 1.0, high + 1.0
  80. span = high - low
  81. return low - span * 0.08, high + span * 0.08
  82. def _limits(unit: int, columns: list[str]) -> dict[str, dict[str, float | None]]:
  83. result: dict[str, dict[str, float | None]] = {
  84. column: {"high": None, "high_high": None} for column in columns
  85. }
  86. with get_connection() as connection:
  87. with connection.cursor() as cursor:
  88. cursor.execute(
  89. "SELECT ItemName, AlarmType1, AlarmLimit1, AlarmType2, AlarmLimit2, "
  90. "AlarmType3, AlarmLimit3, AlarmType4, AlarmLimit4 "
  91. "FROM site_point WHERE ItemName LIKE %s",
  92. (f"YSJ{unit}\\_%",),
  93. )
  94. for row in cursor.fetchall():
  95. name = row.get("ItemName") or ""
  96. index = name.split("_")[-1]
  97. column = f"YSJ_{index}"
  98. if column not in result:
  99. continue
  100. high = high_high = None
  101. for item in range(1, 5):
  102. kind = str(row.get(f"AlarmType{item}") or "")
  103. limit = _to_float(row.get(f"AlarmLimit{item}"))
  104. if not kind or limit is None:
  105. continue
  106. if "HighHigh" in kind:
  107. high_high = limit if high_high is None else max(high_high, limit)
  108. elif "High" in kind and high is None:
  109. high = limit
  110. result[column] = {"high": high, "high_high": high_high}
  111. return result
  112. def _read_csv(path: Path) -> list[dict[str, str]]:
  113. if not path.exists():
  114. return []
  115. with path.open(encoding="utf-8-sig", newline="") as handle:
  116. return list(csv.DictReader(handle))
  117. def _load_faults() -> list[dict[str, Any]]:
  118. with get_connection() as connection:
  119. with connection.cursor() as cursor:
  120. cursor.execute(
  121. "SELECT fault_date, fault_category, fault_description, unit_name "
  122. "FROM compressor_fault ORDER BY fault_date"
  123. )
  124. return list(cursor.fetchall())
  125. def _fault_matches(unit: str, start: datetime, end: datetime, faults: list[dict[str, Any]]) -> list[str]:
  126. unit_name = unit.replace("号机", "号机组")
  127. low = (start - timedelta(days=7)).date()
  128. high = (end + timedelta(days=1)).date()
  129. matches = []
  130. for row in faults:
  131. if (row.get("unit_name") or "") != unit_name:
  132. continue
  133. date = row.get("fault_date")
  134. if date is None or date < low or date > high:
  135. continue
  136. text = row.get("fault_description") or row.get("fault_category") or ""
  137. matches.append(f"{date} {text}")
  138. return matches
  139. def _event_matches(unit: str, stage: str, start: datetime, end: datetime,
  140. event_rows: list[dict[str, str]]) -> list[str]:
  141. matches = []
  142. for row in event_rows:
  143. if (row.get("特征类型") or "") == "高频波动":
  144. continue
  145. if (row.get("机组") or "") != unit or (row.get("级") or "") != stage:
  146. continue
  147. try:
  148. event_start = _parse_time(row.get("开始小时") or "")
  149. event_end = _parse_time(row.get("结束小时") or "") + timedelta(hours=1)
  150. except ValueError:
  151. continue
  152. if event_start <= end + timedelta(hours=1) and event_end >= start - timedelta(hours=1):
  153. matches.append(
  154. f"{event_start.strftime('%m-%d %H:%M')} "
  155. f"{row.get('温度阶段') or ''}-{row.get('特征类型') or ''}"
  156. )
  157. return matches
  158. def _observation_matches(unit: str, stage: str, start: datetime, end: datetime,
  159. observation_rows: list[dict[str, str]]) -> list[str]:
  160. matches = []
  161. for row in observation_rows:
  162. if (row.get("机组") or "") != unit or (row.get("级") or "") != stage:
  163. continue
  164. try:
  165. moment = _parse_time(row.get("小时") or "")
  166. except ValueError:
  167. continue
  168. if start - timedelta(hours=1) <= moment <= end + timedelta(hours=1):
  169. matches.append(
  170. f"{moment.strftime('%m-%d %H:%M')} "
  171. f"{row.get('方向') or ''}-{row.get('特征类型') or ''}"
  172. )
  173. return matches
  174. def _labels() -> list[dict[str, Any]]:
  175. event_rows = _read_csv(TEMPERATURE_EVENTS_CSV)
  176. observation_rows = _read_csv(TEMPERATURE_OBS_CSV)
  177. faults = _load_faults()
  178. items: list[dict[str, Any]] = []
  179. for index, row in enumerate(event_rows):
  180. unit = (row.get("机组") or "").strip()
  181. start_text = row.get("开始小时") or ""
  182. end_text = row.get("结束小时") or ""
  183. level = row.get("温度阶段") or ""
  184. feature = row.get("特征类型") or ""
  185. grade = row.get("级") or ""
  186. is_hf = feature == "高频波动"
  187. items.append({
  188. "key": f"event:{index}",
  189. "title": f"{unit}-{grade}-{level}-{feature}-{start_text[:16]}",
  190. "kind": "temperature_hf" if is_hf else "temperature_event",
  191. "kindLabel": "高频波动" if is_hf else "温度事件",
  192. "device": unit,
  193. "deviceCode": f"{_unit_number(unit)}#",
  194. "batch": row.get("批次") or "",
  195. "cycle": row.get("周期编号") or "",
  196. "grade": grade,
  197. "stage": level,
  198. "transition": "",
  199. "level": level,
  200. "feature": feature,
  201. "direction": row.get("方向") or "",
  202. "start": start_text,
  203. "end": end_text,
  204. "duration": _to_float(row.get("持续自然小时数")),
  205. "maxSpread": None if is_hf else _to_float(row.get("最大级差变化")),
  206. "maxPeer": None if is_hf else _to_float(row.get("最大同级偏离")),
  207. "maxPeerLow": None if is_hf else _to_float(row.get("最大同级偏低")),
  208. "relatedChannel": row.get("关联通道异常") or "",
  209. "evidence": row.get("阶段触发依据") or "",
  210. "oldEvent": "高频波动" if is_hf else f"{level}-{feature}",
  211. "note": "",
  212. })
  213. for item in items:
  214. try:
  215. start = _parse_time(item["start"])
  216. end = _parse_time(item["end"])
  217. except ValueError:
  218. start = end = datetime.min
  219. unit = item["device"]
  220. grade = item["grade"]
  221. matches_faults = _fault_matches(unit, start, end, faults) if start != datetime.min else []
  222. matches_events = _event_matches(unit, grade, start, end, event_rows) if start != datetime.min else []
  223. matches_observations = _observation_matches(unit, grade, start, end, observation_rows) if start != datetime.min else []
  224. item.update({
  225. "faultHit": "是" if matches_faults else "否",
  226. "faultDescriptions": ";".join(matches_faults),
  227. "eventHit": "是" if matches_events else "否",
  228. "eventDescriptions": ";".join(matches_events),
  229. "observationHit": "是" if matches_observations else "否",
  230. "observationDescriptions": ";".join(matches_observations),
  231. })
  232. items.sort(key=lambda item: item.get("start") or "")
  233. return items
  234. def _summary(items: list[dict[str, Any]]) -> dict[str, Any]:
  235. def count(key: str) -> dict[str, int]:
  236. result: dict[str, int] = {}
  237. for item in items:
  238. name = str(item.get(key) or "")
  239. if not name:
  240. continue
  241. result[name] = result.get(name, 0) + 1
  242. return dict(sorted(result.items()))
  243. return {
  244. "total": len(items),
  245. "byKind": count("kindLabel"),
  246. "byDevice": count("device"),
  247. "byTransition": count("transition"),
  248. "byGrade": count("grade"),
  249. "byLevel": count("level"),
  250. }
  251. def _query(unit: int, batch: int, start: datetime, end: datetime) -> list[dict[str, Any]]:
  252. stages = CYLINDERS[unit]
  253. temp_columns = [column for values in stages.values() for column, _ in values]
  254. proxies = {
  255. 4: ["YSJ_31", "YSJ_37", "YSJ_38", "YSJ_2"],
  256. 5: ["YSJ_5", "YSJ_6", "YSJ_3", "YSJ_10", "YSJ_11", "YSJ_12", "YSJ_13", "YSJ_14"],
  257. 6: ["YSJ_5", "YSJ_6", "YSJ_3", "YSJ_10", "YSJ_11", "YSJ_12", "YSJ_13", "YSJ_14"],
  258. 7: ["YSJ_41"],
  259. 8: ["YSJ_41"],
  260. 9: ["YSJ_41"],
  261. }[unit]
  262. columns = list(dict.fromkeys(temp_columns + proxies))
  263. expression = ",\n ".join(f"AVG(`{column}`) AS `{column}`" for column in columns)
  264. sql = (
  265. "SELECT FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV %s) * %s) AS t,\n "
  266. + expression
  267. + "\nFROM pks_long_sample\n"
  268. "WHERE import_batch_id = %s AND sample_time >= %s AND sample_time <= %s\n"
  269. "GROUP BY FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV %s) * %s)\n"
  270. "ORDER BY t"
  271. )
  272. with get_connection() as connection:
  273. with connection.cursor() as cursor:
  274. cursor.execute(
  275. sql,
  276. (STEP_SECONDS, STEP_SECONDS, batch, start, end, STEP_SECONDS, STEP_SECONDS),
  277. )
  278. return cursor.fetchall()
  279. def _series_points(values: list[float | None]) -> list[list[Any]]:
  280. return [[index, value] for index, value in enumerate(values)]
  281. def _subplot(title: str, unit: str, series: list[dict[str, Any]], ref_lines: list[dict[str, Any]],
  282. y_min: float, y_max: float) -> dict[str, Any]:
  283. return {
  284. "title": title,
  285. "unit": unit,
  286. "band": BAND,
  287. "refLines": ref_lines,
  288. "yMin": round(y_min, 4),
  289. "yMax": round(y_max, 4),
  290. "series": series,
  291. }
  292. def _build_chart(label: dict[str, Any]) -> dict[str, Any]:
  293. unit = _unit_number(label["device"])
  294. batch = int(label.get("batch") or 0)
  295. start = _parse_time(label["start"])
  296. end = _parse_time(label["end"]) + timedelta(hours=1)
  297. range_start = start - timedelta(hours=PAD_HOURS)
  298. range_end = end + timedelta(hours=PAD_HOURS)
  299. rows = _query(unit, batch, range_start, range_end)
  300. if not rows:
  301. return {
  302. "key": label["key"], "title": label["title"],
  303. "subtitle": f"{label.get('kindLabel') or '标签'} {_fmt_time(start)} ~ {_fmt_time(end)} | 无数据",
  304. "stage": label["stage"], "kind": label["kind"],
  305. "x": {"max": 0, "times": [], "gaps": [], "shutdowns": []},
  306. "fault": {"start": 0, "end": 0}, "yellowBands": [], "subplots": [], "empty": True,
  307. "relatedChannel": label.get("relatedChannel") or "",
  308. "faultHit": label.get("faultHit") or "否",
  309. "faultDescriptions": label.get("faultDescriptions") or "",
  310. "evidence": label.get("evidence") or "",
  311. "breakdown": {},
  312. }
  313. # Insert visual gaps for long stops; keep 5s resolution otherwise.
  314. display: list[dict[str, Any] | None] = []
  315. gap_indexes: list[int] = []
  316. shutdowns: list[dict[str, Any]] = []
  317. previous = None
  318. gap_seconds = max(90, STEP_SECONDS * 3)
  319. gap_slots = 4
  320. for row in rows:
  321. moment = row["t"]
  322. if previous is not None and (moment - previous).total_seconds() > gap_seconds:
  323. left = len(display)
  324. display.extend([None] * gap_slots)
  325. right = len(display) - 1
  326. gap_indexes.append((left + right) // 2)
  327. shutdowns.append({
  328. "shutdown": previous.strftime("%Y-%m-%d %H:%M:%S"),
  329. "startup": moment.strftime("%Y-%m-%d %H:%M:%S"),
  330. "startIndex": left,
  331. "endIndex": right,
  332. })
  333. display.append(row)
  334. previous = moment
  335. times = [item["t"].strftime("%Y-%m-%d %H:%M:%S") if item else None for item in display]
  336. # Stage values and same-stage metrics at each timestamp.
  337. stages = CYLINDERS[unit]
  338. stage_values: dict[str, list[list[float | None]]] = {}
  339. for stage, columns in stages.items():
  340. stage_values[stage] = [
  341. [_temp_value(item.get(column)) if item else None for column, _ in columns]
  342. for item in display
  343. ]
  344. spread: list[float | None] = []
  345. peer_high: list[float | None] = []
  346. peer_low: list[float | None] = []
  347. for index, item in enumerate(display):
  348. if item is None:
  349. spread.append(None)
  350. peer_high.append(None)
  351. peer_low.append(None)
  352. continue
  353. values = [
  354. value
  355. for stage in stages
  356. for value in stage_values[stage][index]
  357. if value is not None
  358. ]
  359. if not values:
  360. spread.append(None)
  361. peer_high.append(None)
  362. peer_low.append(None)
  363. continue
  364. spread.append(max(values) - min(values))
  365. highs, lows = [], []
  366. for stage in stages:
  367. stage_vals = [value for value in stage_values[stage][index] if value is not None]
  368. if len(stage_vals) < 2:
  369. continue
  370. center = median(stage_vals)
  371. highs.append(max(stage_vals) - center)
  372. lows.append(center - min(stage_vals))
  373. peer_high.append(max(highs) if highs else None)
  374. peer_low.append(max(lows) if lows else None)
  375. limits = _limits(unit, [column for values in stages.values() for column, _ in values])
  376. subplots: list[dict[str, Any]] = []
  377. for stage_index, (stage, columns) in enumerate(stages.items()):
  378. series = []
  379. all_values = []
  380. for index, (column, name) in enumerate(columns):
  381. values = [
  382. stage_values[stage][point][index] if point is not None else None
  383. for point in range(len(display))
  384. ]
  385. all_values.extend(value for value in values if value is not None)
  386. series.append({
  387. "name": name,
  388. "color": COLORS[index % len(COLORS)],
  389. "data": _series_points(values),
  390. })
  391. limits_for_stage = [limits[column] for column, _ in columns]
  392. high_values = [item["high"] for item in limits_for_stage if item["high"] is not None]
  393. high_high_values = [item["high_high"] for item in limits_for_stage if item["high_high"] is not None]
  394. refs = []
  395. if high_values:
  396. refs.append({"value": min(high_values), "label": "高报"})
  397. if high_high_values:
  398. refs.append({"value": min(high_high_values), "label": "高高报"})
  399. low, high = _padded(all_values)
  400. subplots.append(_subplot(
  401. f"{stage}缸排气温度", "℃", series, refs,
  402. min(low, 0 if low < 0 else low), high,
  403. ))
  404. # Same-stage metrics.
  405. metric_values = [value for value in spread + peer_high + peer_low if value is not None]
  406. low, high = _padded(metric_values)
  407. subplots.append(_subplot(
  408. "同级温差指标", "℃",
  409. [
  410. {"name": "级差", "color": "#1f6feb", "data": _series_points(spread)},
  411. {"name": "同级偏高", "color": "#e8890c", "data": _series_points(peer_high)},
  412. {"name": "同级偏低", "color": "#2e9e5b", "data": _series_points(peer_low)},
  413. ],
  414. [{"value": 4, "label": "轻微"}, {"value": 8, "label": "异常"}, {"value": 15, "label": "严重异常"}],
  415. low, max(high, 16),
  416. ))
  417. # Run-state / operating signals.
  418. def column_values(column: str) -> list[float | None]:
  419. return [_to_float(item.get(column)) if item else None for item in display]
  420. aux_series: list[tuple[str, list[float | None], str]] = []
  421. if unit == 4:
  422. body = []
  423. for first, second in zip(column_values("YSJ_37"), column_values("YSJ_38")):
  424. candidates = [value for value in (first, second) if value is not None]
  425. body.append(max(candidates) if candidates else None)
  426. inlet = column_values("YSJ_2")
  427. run_state = []
  428. for body_value, inlet_value in zip(body, inlet):
  429. candidates = [value for value in (body_value, inlet_value) if value is not None]
  430. run_state.append((1.0 if max(candidates) > 0.5 else 0.0) if candidates else None)
  431. aux_series = [
  432. ("运行状态", run_state, "#8e44ad"),
  433. ("机身振动", body, "#e8890c"),
  434. ("一级进气压力", inlet, "#2e9e5b"),
  435. ]
  436. elif unit in (5, 6):
  437. body = []
  438. for values in zip(*(column_values(column) for column in ("YSJ_10", "YSJ_11", "YSJ_12", "YSJ_13"))):
  439. candidates = [value for value in values if value is not None]
  440. body.append(max(candidates) if candidates else None)
  441. for title, columns, colors in [
  442. ("机身振动", [("YSJ_10", "机身1"), ("YSJ_11", "机身2"), ("YSJ_12", "机身3"), ("YSJ_13", "机身4")], COLORS),
  443. ("排气压力", [("YSJ_5", "一级排气"), ("YSJ_6", "二级排气")], ["#1f6feb", "#e8890c"]),
  444. ("调节阀开度", [("YSJ_14", "调节阀")], ["#2e9e5b"]),
  445. ]:
  446. for index, (column, name) in enumerate(columns):
  447. aux_series.append((name, column_values(column), colors[index % len(colors)]))
  448. else:
  449. aux_series = [("转速", column_values("YSJ_41"), "#8e44ad")]
  450. if aux_series:
  451. def _normalize(values: list[float | None]) -> list[float | None]:
  452. present = [value for value in values if value is not None]
  453. if not present:
  454. return values
  455. low, high = min(present), max(present)
  456. if high - low < 1e-9:
  457. return [None if value is None else 0.5 for value in values]
  458. return [None if value is None else (value - low) / (high - low) for value in values]
  459. normalized = [
  460. {"name": f"{name}(归一)", "color": color, "data": _series_points(_normalize(values))}
  461. for name, values, color in aux_series
  462. ]
  463. subplots.append(_subplot(
  464. "开关机辅助信号(归一化)", "", normalized, [], -0.05, 1.05,
  465. ))
  466. fault_start = 0
  467. fault_end = len(display) - 1
  468. seen = False
  469. for index, item in enumerate(display):
  470. if item is None:
  471. continue
  472. if not seen and item["t"] >= start:
  473. fault_start = index
  474. seen = True
  475. if item["t"] <= end:
  476. fault_end = index
  477. if not seen:
  478. fault_start = 0
  479. # Detail for the selected stage at the peak of the transition/event band.
  480. breakdown: dict[str, Any] = {}
  481. grade = label.get("grade") or next(iter(stages))
  482. if grade in stages:
  483. columns = stages[grade]
  484. band = [index for index in range(fault_start, fault_end + 1) if display[index] is not None]
  485. if band:
  486. peak = max(
  487. band,
  488. key=lambda index: max(spread[index] or 0.0, peer_high[index] or 0.0, peer_low[index] or 0.0),
  489. )
  490. values = []
  491. for offset, (column, name) in enumerate(columns):
  492. value = stage_values[grade][peak][offset]
  493. if value is not None:
  494. values.append((name, value))
  495. if values:
  496. center = median([value for _, value in values])
  497. cylinders = [
  498. {"name": name, "value": value, "dev": value - center}
  499. for name, value in values
  500. ]
  501. high = max(cylinders, key=lambda item: item["dev"])
  502. low = min(cylinders, key=lambda item: item["dev"])
  503. breakdown = {
  504. "time": times[peak] or "",
  505. "stage": grade,
  506. "median": center,
  507. "cylinders": cylinders,
  508. "high": high,
  509. "low": low,
  510. "spread": high["value"] - low["value"],
  511. "single": len(cylinders) < 2,
  512. }
  513. return {
  514. "key": label["key"],
  515. "title": label["title"],
  516. "subtitle": (
  517. f"{label.get('kindLabel') or '标签'} {_fmt_time(start)} ~ {_fmt_time(end)} | "
  518. f"展示 {_fmt_time(range_start)} ~ {_fmt_time(range_end)} | "
  519. f"{label.get('oldEvent') or ''}"
  520. ),
  521. "stage": label["stage"],
  522. "kind": label["kind"],
  523. "x": {
  524. "max": len(display) - 1,
  525. "times": times,
  526. "gaps": gap_indexes,
  527. "shutdowns": shutdowns,
  528. },
  529. "fault": {"start": fault_start, "end": fault_end},
  530. "yellowBands": [],
  531. "subplots": subplots,
  532. "empty": False,
  533. "relatedChannel": label.get("relatedChannel") or "",
  534. "faultHit": label.get("faultHit") or "否",
  535. "faultDescriptions": label.get("faultDescriptions") or "",
  536. "evidence": label.get("evidence") or "",
  537. "breakdown": breakdown,
  538. }
  539. class TemperatureTransitionService:
  540. def labels(self) -> dict[str, Any]:
  541. items = _labels()
  542. return {"items": items, "summary": _summary(items)}
  543. def series(self, key: str) -> dict[str, Any]:
  544. for item in _labels():
  545. if item["key"] == key:
  546. return _build_chart(item)
  547. raise KeyError(key)
  548. temperature_transition_service = TemperatureTransitionService()