algorithm.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698
  1. """Cylinder-temperature feature extraction, grading and event segmentation.
  2. The scan is deliberately rule based: it reuses the pressure/vibration skeleton
  3. (valid running hours -> robust baseline -> relative deviation -> confirmed
  4. levels -> merged events) but adds the signals that matter for cylinder body
  5. temperature:
  6. * each cylinder's own recent baseline (level + robust z);
  7. * same-stage spread, compared with its own recent normal spread;
  8. * each cylinder's deviation from the stage median, compared with its own
  9. historic offset (removes the chronic "this cylinder always runs hotter"
  10. bias);
  11. * engineering high / high-high limits from ``site_point``.
  12. Thresholds are configurable so they can be calibrated against labelled faults.
  13. """
  14. from __future__ import annotations
  15. from collections import defaultdict, deque
  16. from datetime import datetime, timedelta
  17. from statistics import median
  18. from .config import (
  19. TEMP_BASELINE_HOURS,
  20. TEMP_CONFIRM_HOURS,
  21. TEMP_DROPOUT_REF,
  22. TEMP_MERGE_GAP_HOURS,
  23. TEMP_MIN_HISTORY_HOURS,
  24. TEMP_MIN_STEADY,
  25. TEMP_RESTART_GAP_HOURS,
  26. TEMP_SENSOR_MAX,
  27. TEMP_SENSOR_MIN,
  28. UnitConfig,
  29. )
  30. def linear_slope(values):
  31. pairs = [(index, value) for index, value in enumerate(values) if value is not None]
  32. if len(pairs) < 3:
  33. return None
  34. xbar = sum(x for x, _ in pairs) / len(pairs)
  35. ybar = sum(y for _, y in pairs) / len(pairs)
  36. denominator = sum((x - xbar) ** 2 for x, _ in pairs)
  37. if denominator == 0:
  38. return None
  39. return sum((x - xbar) * (y - ybar) for x, y in pairs) / denominator
  40. def unit_sort_key(value):
  41. import re
  42. match = re.search(r"[4-9]", str(value or ""))
  43. return int(match.group()) if match else 999
  44. def output_sort_key(row: dict):
  45. time_field = next(
  46. (field for field in ("小时", "开始小时", "周期开始小时", "开始日期", "日期") if row.get(field)),
  47. None,
  48. )
  49. time_value = str(row.get(time_field, "")) if time_field else ""
  50. return (unit_sort_key(row.get("机组")), time_value, int(row.get("批次") or 0), str(row.get("周期编号", "")))
  51. def clean_rows(rows):
  52. cleaned = []
  53. for row in rows:
  54. item = {}
  55. for key, value in row.items():
  56. if key.startswith("_"):
  57. continue
  58. if isinstance(value, float):
  59. item[key] = round(value, 6)
  60. else:
  61. item[key] = value
  62. cleaned.append(item)
  63. return cleaned
  64. def write_csv(path, rows, fields, headers):
  65. import csv
  66. from pathlib import Path
  67. path = Path(path)
  68. path.parent.mkdir(parents=True, exist_ok=True)
  69. rows = sorted(rows, key=output_sort_key)
  70. with path.open("w", encoding="utf-8-sig", newline="") as handle:
  71. writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
  72. writer.writerow({field: headers.get(field, field) for field in fields})
  73. writer.writerows(rows)
  74. LEVELS = ("轻微", "异常", "严重")
  75. def _fmt(value, digits=2):
  76. return "" if value is None else round(float(value), digits)
  77. def _median(values):
  78. values = [value for value in values if value is not None]
  79. return median(values) if values else None
  80. def _mad(values, center):
  81. return median(abs(value - center) for value in values) if values else 0.0
  82. def _baseline(values) -> tuple[float | None, float | None]:
  83. values = [value for value in values if value is not None]
  84. if len(values) < TEMP_MIN_HISTORY_HOURS:
  85. return None, None
  86. center = median(values)
  87. # Temperature sensors can be very stable; floor the scale at 0.5 degC and
  88. # 0.5% of the level so that z stays meaningful without exploding.
  89. scale = max(1.4826 * _mad(values, center), abs(center) * 0.005, 0.5)
  90. return center, scale
  91. def _longest_constant_run(values) -> int:
  92. longest = current = 1
  93. for previous, value in zip(values, values[1:]):
  94. if round(previous, 3) == round(value, 3):
  95. current += 1
  96. longest = max(longest, current)
  97. else:
  98. current = 1
  99. return longest if values else 0
  100. def channel_qc(rows: list[dict], config: UnitConfig) -> tuple[set[str], list[dict]]:
  101. """Flag frozen / dead / out-of-range cylinder channels."""
  102. bad: set[str] = set()
  103. report: list[dict] = []
  104. for column in config.columns:
  105. values = [row.get(f"{column}__avg") for row in rows if row.get(f"{column}__avg") is not None]
  106. label = config.labels.get(column, column)
  107. if not values:
  108. bad.add(column)
  109. report.append({"机组": config.name, "点位": column, "点位描述": label,
  110. "运行小时数": 0, "判定": "无数据"})
  111. continue
  112. count = len(values)
  113. zero_ratio = sum(1 for value in values if value == 0) / count
  114. out_ratio = sum(1 for value in values if value < TEMP_SENSOR_MIN or value > TEMP_SENSOR_MAX) / count
  115. distinct = len({round(value, 3) for value in values})
  116. constant_run = _longest_constant_run(values)
  117. reasons = []
  118. if zero_ratio > 0.20:
  119. reasons.append(f"零值比例{zero_ratio:.1%}")
  120. if out_ratio > 0.05:
  121. reasons.append(f"越界比例{out_ratio:.1%}")
  122. if distinct <= 3 and count > 50:
  123. reasons.append(f"仅{distinct}个不同值")
  124. if constant_run > 168:
  125. reasons.append(f"连续{constant_run}小时不变")
  126. if reasons:
  127. bad.add(column)
  128. report.append({
  129. "机组": config.name,
  130. "点位": column,
  131. "点位描述": label,
  132. "运行小时数": count,
  133. "最小值": _fmt(min(values)),
  134. "最大值": _fmt(max(values)),
  135. "中位数": _fmt(median(values)),
  136. "不同值个数": distinct,
  137. "最长不变小时": constant_run,
  138. "零值比例": round(zero_ratio, 4),
  139. "越界比例": round(out_ratio, 4),
  140. "判定": "坏道" if reasons else "正常",
  141. "原因": ";".join(reasons),
  142. })
  143. return bad, report
  144. def _assign_cycles(rows: list[dict], config: UnitConfig) -> list[dict]:
  145. """Attach a per-batch run-cycle id; gaps > 12h start a new cycle."""
  146. ordered = sorted(rows, key=lambda row: (row["import_batch_id"], row["hour_start"]))
  147. cycle = 0
  148. previous_batch = None
  149. previous_dt = None
  150. for row in ordered:
  151. batch = row["import_batch_id"]
  152. dt = row["hour_start"]
  153. if (
  154. previous_batch is None
  155. or batch != previous_batch
  156. or previous_dt is None
  157. or dt - previous_dt > timedelta(hours=TEMP_RESTART_GAP_HOURS)
  158. ):
  159. cycle += 1
  160. row["_cycle_id"] = f"{config.unit}-T{cycle:03d}"
  161. previous_batch, previous_dt = batch, dt
  162. return ordered
  163. def _snapshot(row: dict, columns: list[str]) -> tuple[dict, float, float | None, dict] | None:
  164. raw = {}
  165. for column in columns:
  166. if row.get(f"{column}__dropout"):
  167. continue
  168. value = row.get(f"{column}__avg")
  169. if value is None:
  170. continue
  171. if value <= 0 or value < TEMP_SENSOR_MIN or value > TEMP_SENSOR_MAX:
  172. continue
  173. raw[column] = value
  174. if not raw:
  175. return None
  176. if max(raw.values()) < TEMP_MIN_STEADY:
  177. return None
  178. # When the stage is clearly hot, a channel reading ambient is a stopped or
  179. # dead sensor, not a real cylinder; drop it before stage statistics.
  180. if max(raw.values()) >= TEMP_DROPOUT_REF:
  181. values = {column: value for column, value in raw.items() if value >= TEMP_MIN_STEADY}
  182. else:
  183. values = raw
  184. if not values:
  185. return None
  186. stage_median = median(values.values())
  187. spread = max(values.values()) - min(values.values()) if len(values) >= 2 else None
  188. peer = {column: value - stage_median for column, value in values.items()}
  189. return values, stage_median, spread, peer
  190. def _mark_dropouts(rows: list[dict], config: UnitConfig) -> None:
  191. """Mark abrupt downward channel glitches (a channel dropping alone).
  192. A real load cut moves all cylinders together and the whole stage falls
  193. below the steady floor, so it is filtered elsewhere. A single channel that
  194. suddenly reads far below its own neighbours (and below 60 degC) is a sensor
  195. / acquisition dropout and must not inflate the same-stage spread.
  196. """
  197. for column in config.columns:
  198. series = []
  199. for index, row in enumerate(rows):
  200. value = row.get(f"{column}__avg")
  201. if value is None or value <= 0 or value < TEMP_SENSOR_MIN or value > TEMP_SENSOR_MAX:
  202. continue
  203. series.append((index, row["hour_start"], value))
  204. for position, (index, moment, value) in enumerate(series):
  205. previous = [item[2] for item in series[max(0, position - 6):position]
  206. if moment - item[1] <= timedelta(hours=24)]
  207. following = [item[2] for item in series[position + 1:position + 7]
  208. if item[1] - moment <= timedelta(hours=24)]
  209. reference = previous + following
  210. if len(reference) < 3:
  211. continue
  212. center = median(reference)
  213. # A transient drop of >15 degC that recovers within the local
  214. # window is an acquisition dropout; a persistent drop keeps the
  215. # reference low as well and therefore survives.
  216. if value < center - 15:
  217. rows[index][f"{column}__dropout"] = True
  218. def _global_offsets(rows: list[dict], config: UnitConfig) -> tuple[dict, dict]:
  219. spreads: dict[str, list] = defaultdict(list)
  220. peers: dict[str, list] = defaultdict(list)
  221. for row in rows:
  222. for stage, columns in config.stages.items():
  223. snapshot = _snapshot(row, columns)
  224. if not snapshot:
  225. continue
  226. _, _, spread, peer = snapshot
  227. if spread is not None:
  228. spreads[stage].append(spread)
  229. for column, value in peer.items():
  230. peers[column].append(value)
  231. return (
  232. {stage: _median(values) for stage, values in spreads.items()},
  233. {column: _median(values) for column, values in peers.items()},
  234. )
  235. def _grade(values: dict, metrics: dict, config: UnitConfig, thresholds: dict) -> tuple[str, list[str]]:
  236. """Grade one stage-hour from same-stage relative signals only.
  237. Engineering PVHigh/PVHighHigh are deliberately *not* used as triggers: they
  238. are the plant's existing absolute alarms, and a hot summer day pushes every
  239. cylinder above them. The added value here is the same-stage difference.
  240. """
  241. reasons: list[str] = []
  242. severe = abnormal = mild = False
  243. spread_delta = metrics.get("spread_delta")
  244. if spread_delta is not None:
  245. if spread_delta >= thresholds["severe_spread"]:
  246. severe = True
  247. reasons.append(f"级差较平时+{spread_delta:.1f}℃")
  248. elif spread_delta >= thresholds["abnormal_spread"]:
  249. abnormal = True
  250. reasons.append(f"级差较平时+{spread_delta:.1f}℃")
  251. elif spread_delta >= thresholds["mild_spread"]:
  252. mild = True
  253. reasons.append(f"级差较平时+{spread_delta:.1f}℃")
  254. peer_high = metrics.get("max_peer_anom")
  255. if peer_high is not None:
  256. if peer_high >= thresholds["severe_peer"]:
  257. severe = True
  258. reasons.append(f"同级偏高较平时+{peer_high:.1f}℃")
  259. elif peer_high >= thresholds["abnormal_peer"]:
  260. abnormal = True
  261. reasons.append(f"同级偏高较平时+{peer_high:.1f}℃")
  262. elif peer_high >= thresholds["mild_peer"]:
  263. mild = True
  264. reasons.append(f"同级偏高较平时+{peer_high:.1f}℃")
  265. peer_low = metrics.get("max_peer_low")
  266. if peer_low is not None:
  267. if peer_low >= thresholds["severe_peer_low"]:
  268. severe = True
  269. reasons.append(f"同级偏低较平时-{peer_low:.1f}℃")
  270. elif peer_low >= thresholds["abnormal_peer_low"]:
  271. abnormal = True
  272. reasons.append(f"同级偏低较平时-{peer_low:.1f}℃")
  273. elif peer_low >= thresholds["mild_peer_low"]:
  274. mild = True
  275. reasons.append(f"同级偏低较平时-{peer_low:.1f}℃")
  276. # Own-baseline level/z is load-driven, so it is reported but not used as a
  277. # trigger; a single-cylinder stage (7#/8#/9# 三级) has no same-stage peer and
  278. # therefore stays quiet unless the same-stage logic above can fire.
  279. if severe:
  280. return "严重", reasons
  281. if abnormal:
  282. return "异常", reasons
  283. if mild:
  284. return "轻微", reasons
  285. return "正常", reasons
  286. def compute_features(
  287. rows: list[dict],
  288. config: UnitConfig,
  289. bad_channels: set[str],
  290. thresholds: dict | None = None,
  291. ) -> list[dict]:
  292. thresholds = {**config.thresholds, **(thresholds or {})}
  293. valid = [row for row in _assign_cycles(rows, config) if row.get("valid")]
  294. _mark_dropouts(valid, config)
  295. global_spread, global_peer = _global_offsets(valid, config)
  296. history: dict[str, deque] = {column: deque() for column in config.columns}
  297. history_peer: dict[str, deque] = {column: deque() for column in config.columns}
  298. history_spread: dict[str, deque] = {stage: deque() for stage in config.stages}
  299. rise_count: dict[str, int] = {column: 0 for column in config.columns}
  300. last_dt: dict[str, datetime | None] = {column: None for column in config.columns}
  301. last_stage_dt: dict[str, datetime | None] = {stage: None for stage in config.stages}
  302. features: list[dict] = []
  303. for row in valid:
  304. dt: datetime = row["hour_start"]
  305. window_start = dt - timedelta(hours=TEMP_BASELINE_HOURS)
  306. for column, values in history.items():
  307. while values and values[0][0] < window_start:
  308. values.popleft()
  309. for column, values in history_peer.items():
  310. while values and values[0][0] < window_start:
  311. values.popleft()
  312. for values in history_spread.values():
  313. while values and values[0][0] < window_start:
  314. values.popleft()
  315. for stage, columns in config.stages.items():
  316. usable = [column for column in columns if column not in bad_channels]
  317. snapshot = _snapshot(row, usable)
  318. if not snapshot:
  319. continue
  320. values, stage_median, spread, peer = snapshot
  321. spread_values = [value for _, value in history_spread[stage]]
  322. spread_base = _median(spread_values) if len(spread_values) >= TEMP_MIN_HISTORY_HOURS else global_spread.get(stage)
  323. spread_delta = None
  324. if spread is not None and spread_base is not None:
  325. spread_delta = spread - spread_base
  326. per_column: dict[str, dict] = {}
  327. for column, value in values.items():
  328. own_values = [item for _, item in history[column]]
  329. # Daily load cycles move every cylinder together. A time-of-day
  330. # baseline removes that common ramp, which is essential for a
  331. # single-cylinder stage (7#/8#/9# 三级) that has no same-stage peer.
  332. tod_values = [item for moment, item in history[column] if moment.hour == dt.hour]
  333. if len(tod_values) >= 3:
  334. base = median(tod_values)
  335. scale = max(1.4826 * _mad(tod_values, base), abs(base) * 0.005, 0.5)
  336. else:
  337. base, scale = _baseline(own_values)
  338. delta = value - base if base is not None else None
  339. z = delta / scale if (delta is not None and scale) else None
  340. peer_values = [item for _, item in history_peer[column]]
  341. peer_base = _median(peer_values) if len(peer_values) >= TEMP_MIN_HISTORY_HOURS else global_peer.get(column, 0.0)
  342. peer_dev = peer[column]
  343. peer_anom = peer_dev - peer_base if peer_base is not None else None
  344. series = own_values[-5:] + [value]
  345. slope = linear_slope(series) if len(series) >= 3 else None
  346. if delta is not None and delta > 1.0:
  347. rise_count[column] = rise_count[column] + 1 if last_dt[column] and dt - last_dt[column] == timedelta(hours=1) else 1
  348. else:
  349. rise_count[column] = 0
  350. per_column[column] = {
  351. "value": value, "base": base, "delta": delta, "z": z,
  352. "peer_dev": peer_dev, "peer_anom": peer_anom,
  353. "slope": slope, "rise": rise_count[column],
  354. }
  355. history[column].append((dt, value))
  356. history_peer[column].append((dt, peer_dev))
  357. last_dt[column] = dt
  358. if spread is not None:
  359. history_spread[stage].append((dt, spread))
  360. deltas = [item["delta"] for item in per_column.values() if item["delta"] is not None]
  361. zs = [item["z"] for item in per_column.values() if item["z"] is not None]
  362. peer_anoms = [item["peer_anom"] for item in per_column.values() if item["peer_anom"] is not None]
  363. insufficient = not deltas
  364. max_delta = max(deltas) if deltas else None
  365. max_z = max(zs) if zs else None
  366. max_peer_high = max(peer_anoms) if peer_anoms else None
  367. max_peer_low = abs(min(peer_anoms)) if peer_anoms else None
  368. strongest = max(
  369. (column for column in per_column if per_column[column]["delta"] is not None),
  370. key=lambda column: per_column[column]["delta"],
  371. default=None,
  372. )
  373. peak_peer_column = max(
  374. (column for column in per_column if per_column[column]["peer_anom"] is not None),
  375. key=lambda column: per_column[column]["peer_anom"],
  376. default=None,
  377. )
  378. low_peer_column = min(
  379. (column for column in per_column if per_column[column]["peer_anom"] is not None),
  380. key=lambda column: per_column[column]["peer_anom"],
  381. default=None,
  382. )
  383. slope = per_column[strongest]["slope"] if strongest else None
  384. rise = per_column[strongest]["rise"] if strongest else None
  385. high_values = [config.limits.get(column, {}).get("high") for column in values]
  386. high_values = [value for value in high_values if value is not None]
  387. high_high_values = [config.limits.get(column, {}).get("high_high") for column in values]
  388. high_high_values = [value for value in high_high_values if value is not None]
  389. metrics = {
  390. "spread": spread, "spread_base": spread_base, "spread_delta": spread_delta,
  391. "max_delta": max_delta, "max_z": max_z, "max_peer_anom": max_peer_high,
  392. "slope": slope,
  393. }
  394. if insufficient:
  395. raw = "数据不足"
  396. reasons = ["有效基线不足24小时"]
  397. else:
  398. raw, reasons = _grade(values, metrics, config, thresholds)
  399. features.append({
  400. "机组": config.name,
  401. "批次": row["import_batch_id"],
  402. "周期编号": row["_cycle_id"],
  403. "小时": dt.strftime("%Y-%m-%d %H:%M:%S"),
  404. "级": stage,
  405. "有效缸数": len(values),
  406. "运行覆盖率": round(row.get("coverage") or 0.0, 4),
  407. "最高温度": _fmt(max(values.values())),
  408. "最低温度": _fmt(min(values.values())),
  409. "最高温度缸": config.labels.get(max(values, key=values.get), ""),
  410. "级差": _fmt(spread),
  411. "级差基线": _fmt(spread_base),
  412. "级差变化": _fmt(spread_delta),
  413. "最大自身偏离": _fmt(max_delta),
  414. "最大自身偏离缸": config.labels.get(strongest, "") if strongest else "",
  415. "最大同级偏离": _fmt(max_peer_high),
  416. "最大同级偏离缸": config.labels.get(peak_peer_column, "") if peak_peer_column else "",
  417. "最大同级偏低": _fmt(max_peer_low),
  418. "最大同级偏低缸": config.labels.get(low_peer_column, "") if low_peer_column else "",
  419. "最大稳健z": _fmt(max_z),
  420. "最强缸": config.labels.get(strongest, "") if strongest else "",
  421. "最高报警阈值": _fmt(min(high_values)) if high_values else "",
  422. "高高报警阈值": _fmt(min(high_high_values)) if high_high_values else "",
  423. "超告警缸": "、".join(
  424. config.labels.get(column, column) for column in values
  425. if (config.limits.get(column, {}).get("high") is not None
  426. and values[column] >= config.limits[column]["high"])
  427. ),
  428. "超高高缸": "、".join(
  429. config.labels.get(column, column) for column in values
  430. if (config.limits.get(column, {}).get("high_high") is not None
  431. and values[column] >= config.limits[column]["high_high"])
  432. ),
  433. "近6小时斜率": _fmt(slope, 4),
  434. "连续上升小时数": rise or 0,
  435. "各缸温度": ";".join(f"{config.labels.get(column, column)}={value:.2f}" for column, value in values.items()),
  436. "各缸自身偏离": ";".join(f"{config.labels.get(column, column)}={per_column[column]['delta']:+.2f}"
  437. for column in values if per_column[column]["delta"] is not None),
  438. "各缸同级偏离": ";".join(f"{config.labels.get(column, column)}={per_column[column]['peer_dev']:+.2f}"
  439. for column in values),
  440. "原始等级": raw,
  441. "判定依据": ";".join(reasons),
  442. "_dt": dt,
  443. "_strongest": strongest,
  444. })
  445. _confirm_levels(features, thresholds)
  446. return features
  447. def _confirm_levels(features: list[dict], thresholds: dict) -> None:
  448. confirm_hours = int(thresholds.get("confirm_hours", TEMP_CONFIRM_HOURS))
  449. grouped: dict[tuple, list[dict]] = defaultdict(list)
  450. for row in features:
  451. grouped[(row["机组"], row["批次"], row["级"])].append(row)
  452. order = {"轻微": 1, "异常": 2, "严重": 3}
  453. for rows in grouped.values():
  454. rows.sort(key=lambda row: row["_dt"])
  455. index = 0
  456. while index < len(rows):
  457. raw = rows[index]["原始等级"]
  458. if raw in ("正常", "数据不足"):
  459. rows[index]["温度等级"] = raw
  460. index += 1
  461. continue
  462. end = index
  463. while end + 1 < len(rows):
  464. candidate = rows[end + 1]["原始等级"]
  465. if candidate in ("正常", "数据不足"):
  466. break
  467. if rows[end + 1]["_dt"] - rows[end]["_dt"] != timedelta(hours=1):
  468. break
  469. end += 1
  470. run = rows[index:end + 1]
  471. run_length = len(run)
  472. highest = max((row["原始等级"] for row in run), key=lambda value: order[value])
  473. if run_length >= confirm_hours:
  474. final = highest
  475. elif highest == "严重":
  476. final = "严重" if any(row["超高高缸"] for row in run) else "异常"
  477. else:
  478. final = "轻微"
  479. for row in run:
  480. row["温度等级"] = final
  481. index = end + 1
  482. def build_segments(features: list[dict], config: UnitConfig, min_hours: int = 2) -> list[dict]:
  483. grouped: dict[tuple, list[dict]] = defaultdict(list)
  484. for row in features:
  485. if row["温度等级"] in LEVELS:
  486. grouped[(row["机组"], row["批次"], row["级"], row["温度等级"])].append(row)
  487. segments: list[dict] = []
  488. for (unit, batch, stage, level), rows in grouped.items():
  489. rows.sort(key=lambda row: row["_dt"])
  490. current: list[dict] = []
  491. for row in rows:
  492. if not current or row["_dt"] - current[-1]["_dt"] <= timedelta(hours=TEMP_MERGE_GAP_HOURS):
  493. current.append(row)
  494. else:
  495. segments.append(_build_segment(current, unit, batch, stage, level, config))
  496. current = [row]
  497. if current:
  498. segments.append(_build_segment(current, unit, batch, stage, level, config))
  499. segments = [segment for segment in segments if segment["有效运行小时数"] >= min_hours]
  500. return sorted(segments, key=output_sort_key)
  501. def _build_segment(rows: list[dict], unit: str, batch: int, stage: str, level: str, config: UnitConfig) -> dict:
  502. first, last = rows[0], rows[-1]
  503. strongest = [row for row in rows if row["最强缸"]]
  504. peak_row = max(rows, key=lambda row: row["最大自身偏离"] or -1e9)
  505. spread_rows = [row for row in rows if row["级差"] not in ("", None)]
  506. peak_spread_row = max(spread_rows, key=lambda row: row["级差"]) if spread_rows else None
  507. peer_rows = [row for row in rows if row["最大同级偏离"] not in ("", None)]
  508. peak_peer_row = max(peer_rows, key=lambda row: row["最大同级偏离"]) if peer_rows else None
  509. z_rows = [row for row in rows if row["最大稳健z"] not in ("", None)]
  510. peak_z_row = max(z_rows, key=lambda row: row["最大稳健z"]) if z_rows else None
  511. return {
  512. "机组": unit,
  513. "批次": batch,
  514. "周期编号": first["周期编号"],
  515. "级": stage,
  516. "温度阶段": level,
  517. "开始小时": first["小时"],
  518. "结束小时": last["小时"],
  519. "持续自然小时数": int((last["_dt"] - first["_dt"]).total_seconds() / 3600) + 1,
  520. "有效运行小时数": len(rows),
  521. "峰值时刻": peak_row["小时"],
  522. "峰值缸": peak_row["最强缸"],
  523. "最高温度": max(row["最高温度"] for row in rows if row["最高温度"] not in ("", None)),
  524. "最大自身偏离": peak_row["最大自身偏离"],
  525. "最大级差": peak_spread_row["级差"] if peak_spread_row else "",
  526. "最大级差基线": peak_spread_row["级差基线"] if peak_spread_row else "",
  527. "最大级差变化": peak_spread_row["级差变化"] if peak_spread_row else "",
  528. "最大同级偏离": peak_peer_row["最大同级偏离"] if peak_peer_row else "",
  529. "最大稳健z": peak_z_row["最大稳健z"] if peak_z_row else "",
  530. "阶段最高报警阈值": min((row["最高报警阈值"] for row in rows if row["最高报警阈值"] not in ("", None)), default=""),
  531. "阶段高高报警阈值": min((row["高高报警阈值"] for row in rows if row["高高报警阈值"] not in ("", None)), default=""),
  532. "阶段超告警缸": "、".join(sorted({item for row in rows for item in str(row["超告警缸"]).split("、") if item})),
  533. "阶段超高高缸": "、".join(sorted({item for row in rows for item in str(row["超高高缸"]).split("、") if item})),
  534. "最强缸": max(set(row["最强缸"] for row in strongest), key=lambda value: sum(1 for row in strongest if row["最强缸"] == value)) if strongest else "",
  535. "阶段触发依据": ";".join(row["判定依据"] for row in rows[:3] if row["判定依据"]),
  536. "_dt": first["_dt"],
  537. }
  538. def summarize_cycles(features: list[dict], segments: list[dict], config: UnitConfig) -> list[dict]:
  539. by_cycle: dict[tuple, list[dict]] = defaultdict(list)
  540. for row in features:
  541. by_cycle[(row["机组"], row["批次"], row["周期编号"], row["级"])].append(row)
  542. result: list[dict] = []
  543. for (unit, batch, cycle_id, stage), rows in by_cycle.items():
  544. rows.sort(key=lambda row: row["_dt"])
  545. stage_segments = [segment for segment in segments
  546. if segment["周期编号"] == cycle_id and segment["级"] == stage]
  547. summary = {
  548. "机组": unit,
  549. "批次": batch,
  550. "周期编号": cycle_id,
  551. "级": stage,
  552. "周期开始小时": rows[0]["小时"],
  553. "周期结束小时": rows[-1]["小时"],
  554. "有效运行小时数": len(rows),
  555. "阶段演化顺序": "→".join(segment["温度阶段"] for segment in stage_segments) or "正常",
  556. }
  557. for level in LEVELS:
  558. selected = [segment for segment in stage_segments if segment["温度阶段"] == level]
  559. summary[f"{level}阶段开始小时"] = selected[0]["开始小时"] if selected else ""
  560. summary[f"{level}阶段结束小时"] = selected[-1]["结束小时"] if selected else ""
  561. summary[f"{level}阶段运行小时数"] = sum(segment["有效运行小时数"] for segment in selected)
  562. summary["周期最高温度"] = max((row["最高温度"] for row in rows if row["最高温度"] not in ("", None)), default="")
  563. summary["周期最大级差"] = max((row["级差"] for row in rows if row["级差"] not in ("", None)), default="")
  564. summary["周期最大级差变化"] = max((row["级差变化"] for row in rows if row["级差变化"] not in ("", None)), default="")
  565. summary["周期最大自身偏离"] = max((row["最大自身偏离"] for row in rows if row["最大自身偏离"] not in ("", None)), default="")
  566. result.append(summary)
  567. return result
  568. def run(rows: list[dict], config: UnitConfig, thresholds: dict | None = None, min_hours: int = 2):
  569. valid = [row for row in rows if row.get("valid")]
  570. bad_channels, qc_report = channel_qc(valid, config)
  571. features = compute_features(rows, config, bad_channels, thresholds)
  572. segments = build_segments(features, config, min_hours)
  573. cycles = summarize_cycles(features, segments, config)
  574. return bad_channels, qc_report, features, segments, cycles
  575. FEATURE_FIELDS = [
  576. "机组", "批次", "周期编号", "小时", "级", "有效缸数", "运行覆盖率",
  577. "最高温度", "最低温度", "最高温度缸", "最高报警阈值", "高高报警阈值", "超告警缸", "超高高缸",
  578. "级差", "级差基线", "级差变化",
  579. "最大自身偏离", "最大自身偏离缸", "最大同级偏离", "最大同级偏离缸", "最大同级偏低", "最大同级偏低缸", "最大稳健z", "最强缸",
  580. "近6小时斜率", "连续上升小时数",
  581. "各缸温度", "各缸自身偏离", "各缸同级偏离",
  582. "温度等级", "原始等级", "判定依据",
  583. ]
  584. SEGMENT_FIELDS = [
  585. "机组", "批次", "周期编号", "级", "温度阶段", "开始小时", "结束小时",
  586. "持续自然小时数", "有效运行小时数", "峰值时刻", "峰值缸", "最高温度",
  587. "最大自身偏离", "最大级差", "最大级差基线", "最大级差变化", "最大同级偏离", "最大稳健z",
  588. "阶段最高报警阈值", "阶段高高报警阈值", "阶段超告警缸", "阶段超高高缸", "最强缸", "阶段触发依据",
  589. ]
  590. CYCLE_FIELDS = [
  591. "机组", "批次", "周期编号", "级", "周期开始小时", "周期结束小时", "有效运行小时数", "阶段演化顺序",
  592. "轻微阶段开始小时", "轻微阶段结束小时", "轻微阶段运行小时数",
  593. "异常阶段开始小时", "异常阶段结束小时", "异常阶段运行小时数",
  594. "严重阶段开始小时", "严重阶段结束小时", "严重阶段运行小时数",
  595. "周期最高温度", "周期最大级差", "周期最大级差变化", "周期最大自身偏离",
  596. ]
  597. QC_FIELDS = [
  598. "机组", "点位", "点位描述", "运行小时数", "最小值", "最大值", "中位数",
  599. "不同值个数", "最长不变小时", "零值比例", "越界比例", "判定", "原因",
  600. ]
  601. def write_results(output_dir, qc_report, features, segments, cycles):
  602. output_dir.mkdir(parents=True, exist_ok=True)
  603. write_csv(output_dir / "温度小时特征.csv", clean_rows(features), FEATURE_FIELDS, {field: field for field in FEATURE_FIELDS})
  604. write_csv(output_dir / "温度异常段.csv", clean_rows(segments), SEGMENT_FIELDS, {field: field for field in SEGMENT_FIELDS})
  605. write_csv(output_dir / "温度演化周期.csv", clean_rows(cycles), CYCLE_FIELDS, {field: field for field in CYCLE_FIELDS})
  606. write_csv(output_dir / "温度通道质控.csv", clean_rows(qc_report), QC_FIELDS, {field: field for field in QC_FIELDS})
  607. __all__ = [
  608. "LEVELS", "channel_qc", "compute_features", "build_segments", "summarize_cycles", "run",
  609. "write_results", "FEATURE_FIELDS", "SEGMENT_FIELDS", "CYCLE_FIELDS", "QC_FIELDS",
  610. ]