unified.py 62 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165
  1. """Unified cylinder-temperature evolution detector.
  2. One pass over the hourly wide table produces:
  3. * always-on same-stage difference features;
  4. * short-term (1h/3h/6h) same-stage relative rise/fall;
  5. * 7#/8#/9# 三级 high-frequency fluctuation (separate table);
  6. * independent channel anomalies (shutdown / sudden drop / frozen / dead);
  7. * confirmed hourly levels, event segments and one evolution-cycle row per
  8. (run cycle x stage).
  9. Pressure and vibration are intentionally *not* touched here.
  10. """
  11. from __future__ import annotations
  12. from collections import defaultdict, deque
  13. from datetime import datetime, timedelta
  14. from statistics import median
  15. import math
  16. import numpy as np
  17. from .algorithm import (
  18. _assign_cycles,
  19. _baseline,
  20. _global_offsets,
  21. _mad,
  22. _median,
  23. _snapshot,
  24. channel_qc,
  25. clean_rows,
  26. linear_slope,
  27. output_sort_key,
  28. write_csv,
  29. )
  30. from .config import (
  31. TEMP_BASELINE_HOURS,
  32. TEMP_MIN_HISTORY_HOURS,
  33. TEMP_MIN_STEADY,
  34. TEMP_SENSOR_MAX,
  35. TEMP_SENSOR_MIN,
  36. UnitConfig,
  37. )
  38. SEV_LABELS = {0: "正常", 1: "观察", 2: "轻微", 3: "异常", 4: "严重异常"}
  39. LEVELS = ("观察", "轻微", "异常", "严重异常")
  40. EVENT_LEVELS = ("轻微", "异常", "严重异常")
  41. DIRECTIONS = ("偏高", "偏低", "混合")
  42. def _fmt(value, digits=2):
  43. return "" if value is None else round(float(value), digits)
  44. def _part_and_point(label, config: UnitConfig) -> tuple[str, str]:
  45. """Derive the fault-table/3D part label and PKS point from a cylinder label."""
  46. text = str(label or "").strip()
  47. if not text:
  48. return "", ""
  49. point = next((column for column, name in config.labels.items() if name == text), "")
  50. part = text
  51. for suffix in ("排气温度", "温度"):
  52. if part.endswith(suffix):
  53. part = part[: -len(suffix)]
  54. break
  55. return part, point
  56. def _valid(value):
  57. return value is not None and TEMP_SENSOR_MIN <= value <= TEMP_SENSOR_MAX and value > 0
  58. def _value_at(history: deque, target: datetime, tolerance_hours: float = 0.6):
  59. for moment, value in reversed(history):
  60. delta = abs((moment - target).total_seconds()) / 3600.0
  61. if delta <= tolerance_hours:
  62. return value
  63. if moment < target - timedelta(hours=tolerance_hours + 0.1):
  64. break
  65. return None
  66. def _sev_level(value, mild, abnormal, severe) -> int:
  67. if value is None:
  68. return 0
  69. if value >= severe:
  70. return 4
  71. if value >= abnormal:
  72. return 3
  73. if value >= mild:
  74. return 2
  75. return 0
  76. def _sev_rate(value, observe, mild, abnormal, severe) -> int:
  77. if value is None:
  78. return 0
  79. if value >= severe:
  80. return 4
  81. if value >= abnormal:
  82. return 3
  83. if value >= mild:
  84. return 2
  85. if value >= observe:
  86. return 1
  87. return 0
  88. def _max_candidate(candidates: list[tuple[int, str, str]]):
  89. """Pick the highest severity, preferring level over short on ties."""
  90. candidates = [item for item in candidates if item[0] > 0]
  91. if not candidates:
  92. return 0, "", ""
  93. return max(candidates, key=lambda item: (item[0], 1 if item[1] == "level" else 0))
  94. # ---------------------------------------------------------------------------
  95. # channel anomalies
  96. # ---------------------------------------------------------------------------
  97. def mark_channel_anomalies(rows: list[dict], config: UnitConfig, thresholds: dict) -> list[dict]:
  98. ambient = thresholds.get("channel_ambient", 45.0)
  99. drop_delta = thresholds.get("channel_drop_delta", 8.0)
  100. min_drop = thresholds.get("channel_min_drop", 20.0)
  101. zero_min = int(thresholds.get("channel_zero_min", 5))
  102. recover_hours = int(thresholds.get("channel_recover_hours", 3))
  103. frozen_hours = int(thresholds.get("channel_frozen_hours", 12))
  104. frozen_eps = thresholds.get("channel_frozen_eps", 0.1)
  105. peer_variation = thresholds.get("channel_peer_variation", 3.0)
  106. anomalies: list[dict] = []
  107. stage_max = []
  108. for row in rows:
  109. values = {}
  110. for stage, columns in config.stages.items():
  111. nums = [row.get(f"{column}__avg") for column in columns if _valid(row.get(f"{column}__avg"))]
  112. values[stage] = max(nums) if nums else None
  113. stage_max.append(values)
  114. for stage, columns in config.stages.items():
  115. for column in columns:
  116. series = [
  117. (index, row["hour_start"], row.get(f"{column}__avg"))
  118. for index, row in enumerate(rows)
  119. if row.get(f"{column}__avg") is not None
  120. and TEMP_SENSOR_MIN <= row.get(f"{column}__avg") <= TEMP_SENSOR_MAX
  121. ]
  122. for position, (index, moment, value) in enumerate(series):
  123. peak = stage_max[index].get(stage)
  124. if peak is None or peak < TEMP_MIN_STEADY:
  125. continue
  126. previous = [item[2] for item in series[max(0, position - 6):position]
  127. if moment - item[1] <= timedelta(hours=6) and item[2] > 0]
  128. following = [item[2] for item in series[position + 1:position + 7]
  129. if item[1] - moment <= timedelta(hours=6) and item[2] > 0]
  130. reference_values = previous + following
  131. reference = median(reference_values) if len(reference_values) >= 3 else None
  132. next_values = [item[2] for item in series[position + 1:position + 1 + recover_hours]
  133. if item[1] - moment <= timedelta(hours=recover_hours)]
  134. recovers = any(value2 >= max(60.0, (reference or 60.0) - 10) for value2 in next_values)
  135. minraw = rows[index].get(f"{column}__minraw")
  136. zeros = rows[index].get(f"{column}__zeros") or 0
  137. if minraw is not None and minraw <= ambient and zeros >= zero_min and peak >= 60:
  138. kind = "停机/通道异常" if recovers else "通道坏道"
  139. rows[index][f"{column}__dropout"] = True
  140. anomalies.append(_anomaly_record(config, rows[index], column, kind, minraw, reference))
  141. continue
  142. if minraw is not None and reference is not None and minraw < reference - min_drop and recovers and peak >= 60:
  143. rows[index][f"{column}__dropout"] = True
  144. anomalies.append(_anomaly_record(config, rows[index], column, "通道骤降", minraw, reference))
  145. continue
  146. if value <= ambient and reference is not None and value < reference - 20 and peak >= 60:
  147. kind = "停机/通道异常" if recovers else "通道坏道"
  148. rows[index][f"{column}__dropout"] = True
  149. anomalies.append(_anomaly_record(config, rows[index], column, kind, value, reference))
  150. elif value > 0 and reference is not None and value < reference - drop_delta:
  151. if recovers:
  152. rows[index][f"{column}__dropout"] = True
  153. anomalies.append(_anomaly_record(config, rows[index], column, "通道骤降", value, reference))
  154. # else: persistent relative low -> keep as process low
  155. # Rows whose hourly average is entirely empty (all samples 0) are
  156. # not in ``series``; still record them from minraw / zero count.
  157. for index, row in enumerate(rows):
  158. if row.get(f"{column}__dropout"):
  159. continue
  160. minraw = row.get(f"{column}__minraw")
  161. zeros = row.get(f"{column}__zeros") or 0
  162. peak = stage_max[index].get(stage)
  163. if minraw is not None and minraw <= ambient and zeros >= zero_min and peak and peak >= 60:
  164. row[f"{column}__dropout"] = True
  165. anomalies.append(_anomaly_record(config, row, column, "停机/通道异常", minraw, None))
  166. # frozen channel: identical for >= N hours while peers vary
  167. run_start = 0
  168. for position in range(1, len(series) + 1):
  169. if position == len(series) or abs(series[position][2] - series[position - 1][2]) > frozen_eps:
  170. run_length = position - run_start
  171. if run_length >= frozen_hours:
  172. peer_values = []
  173. for item in series[run_start:position]:
  174. index = item[0]
  175. for peer in columns:
  176. if peer == column:
  177. continue
  178. peer_value = rows[index].get(f"{peer}__avg")
  179. if _valid(peer_value):
  180. peer_values.append(peer_value)
  181. if peer_values and max(peer_values) - min(peer_values) >= peer_variation:
  182. for item in series[run_start:position]:
  183. index = item[0]
  184. rows[index][f"{column}__dropout"] = True
  185. anomalies.append(_anomaly_record(
  186. config, rows[index], column, "通道冻值", item[2], median(peer_values)
  187. ))
  188. run_start = position
  189. return anomalies
  190. def _anomaly_record(config: UnitConfig, row: dict, column: str, kind: str, value, peer) -> dict:
  191. meta = config.point_meta.get(column, {})
  192. return {
  193. "机组": config.name,
  194. "批次": row["import_batch_id"],
  195. "点位": column,
  196. "点位描述": meta.get("label", column),
  197. "级": meta.get("stage", ""),
  198. "缸": meta.get("cylinder", ""),
  199. "异常类型": kind,
  200. "小时": row["hour_start"],
  201. "值": value,
  202. "同级基准": peer,
  203. }
  204. def build_channel_segments(anomalies: list[dict], thresholds: dict) -> list[dict]:
  205. group: dict[tuple, list[dict]] = defaultdict(list)
  206. for item in anomalies:
  207. group[(item["机组"], item["批次"], item["点位"])].append(item)
  208. segments = []
  209. for (unit, batch, column), items in group.items():
  210. items.sort(key=lambda item: item["小时"])
  211. current = []
  212. for item in items:
  213. if not current or item["小时"] - current[-1]["小时"] <= timedelta(hours=6):
  214. current.append(item)
  215. else:
  216. segments.append(_channel_segment(current, unit, batch, column))
  217. current = [item]
  218. if current:
  219. segments.append(_channel_segment(current, unit, batch, column))
  220. return sorted(segments, key=output_sort_key)
  221. def _channel_segment(items: list[dict], unit: str, batch: int, column: str) -> dict:
  222. priority = {"停机/通道异常": 4, "通道坏道": 3, "通道冻值": 2, "通道骤降": 1}
  223. kind = max({item["异常类型"] for item in items}, key=lambda value: priority.get(value, 0))
  224. first, last = items[0], items[-1]
  225. values = [item["值"] for item in items if item["值"] is not None]
  226. peers = [item["同级基准"] for item in items if item["同级基准"] is not None]
  227. duration = int((last["小时"] - first["小时"]).total_seconds() / 3600) + 1
  228. significant = (
  229. duration >= 2
  230. or kind in ("通道坏道", "通道冻值")
  231. or (kind == "停机/通道异常" and values and min(values) <= 0)
  232. )
  233. return {
  234. "机组": unit,
  235. "批次": batch,
  236. "点位": column,
  237. "点位描述": first["点位描述"],
  238. "级": first["级"],
  239. "缸": first["缸"],
  240. "异常类型": kind,
  241. "开始小时": first["小时"].strftime("%Y-%m-%d %H:%M:%S"),
  242. "结束小时": last["小时"].strftime("%Y-%m-%d %H:%M:%S"),
  243. "持续自然小时数": duration,
  244. "有效小时数": len(items),
  245. "是否显著": "是" if significant else "否",
  246. "最小值": _fmt(min(values)) if values else "",
  247. "最大值": _fmt(max(values)) if values else "",
  248. "中位数": _fmt(median(values)) if values else "",
  249. "同级基准中位": _fmt(median(peers)) if peers else "",
  250. "原因": {
  251. "停机/通道异常": "单通道突然接近环境温度并恢复,按停机/采集异常处理,不参与温度故障",
  252. "通道骤降": "单通道短时骤降并恢复,按通道异常处理,不参与同级温差",
  253. "通道坏道": "单通道长期接近环境温度,按坏道处理",
  254. "通道冻值": "单通道长期不变而同级其他缸在变化,按冻值处理",
  255. }.get(kind, ""),
  256. "_dt": first["小时"],
  257. }
  258. # ---------------------------------------------------------------------------
  259. # unified features
  260. # ---------------------------------------------------------------------------
  261. def compute_features(rows: list[dict], config: UnitConfig, bad_channels: set[str],
  262. thresholds: dict) -> list[dict]:
  263. valid = list(rows)
  264. global_spread, global_peer = _global_offsets(valid, config)
  265. history: dict[str, deque] = {column: deque() for column in config.columns}
  266. history_peer: dict[str, deque] = {column: deque() for column in config.columns}
  267. history_std: dict[str, deque] = {column: deque() for column in config.columns}
  268. history_spread: dict[str, deque] = {stage: deque() for stage in config.stages}
  269. last_dt: dict[str, datetime | None] = {column: None for column in config.columns}
  270. rise_count: dict[str, int] = {column: 0 for column in config.columns}
  271. features: list[dict] = []
  272. for row in valid:
  273. dt: datetime = row["hour_start"]
  274. window_start = dt - timedelta(hours=TEMP_BASELINE_HOURS)
  275. for values in (list(history.values()) + list(history_peer.values())
  276. + list(history_std.values()) + list(history_spread.values())):
  277. while values and values[0][0] < window_start:
  278. values.popleft()
  279. snapshots = {}
  280. stage_medians = {}
  281. for stage, columns in config.stages.items():
  282. usable = [column for column in columns if column not in bad_channels]
  283. snapshot = _snapshot(row, usable)
  284. if not snapshot:
  285. continue
  286. snapshots[stage] = snapshot
  287. stage_medians[stage] = median(snapshot[0].values())
  288. for stage, (values, stage_median, spread, peer) in snapshots.items():
  289. spread_values = [item[1] for item in history_spread[stage]]
  290. spread_base = _median(spread_values) if len(spread_values) >= TEMP_MIN_HISTORY_HOURS else global_spread.get(stage)
  291. spread_delta = spread - spread_base if spread is not None and spread_base is not None else None
  292. per_column: dict[str, dict] = {}
  293. for column, value in values.items():
  294. own = [item[1] for item in history[column]]
  295. tod = [value for moment, value in history[column] if moment.hour == dt.hour]
  296. if len(tod) >= 3:
  297. base = median(tod)
  298. scale = max(1.4826 * _mad(tod, base), abs(base) * 0.005, 0.5)
  299. else:
  300. base, scale = _baseline(own)
  301. delta = value - base if base is not None else None
  302. z = delta / scale if delta is not None and scale else None
  303. peer_values = [item[1] for item in history_peer[column]]
  304. peer_base = _median(peer_values) if len(peer_values) >= TEMP_MIN_HISTORY_HOURS else global_peer.get(column, 0.0)
  305. peer_dev = peer[column]
  306. peer_anom = peer_dev - peer_base if peer_base is not None else None
  307. change = {}
  308. for hours in (1, 3, 6):
  309. past = _value_at(history[column], dt - timedelta(hours=hours))
  310. change[hours] = value - past if past is not None else None
  311. slope = linear_slope(own[-5:] + [value]) if len(own) >= 2 else None
  312. if delta is not None and delta > 1.0:
  313. rise_count[column] = rise_count[column] + 1 if last_dt[column] and dt - last_dt[column] == timedelta(hours=1) else 1
  314. else:
  315. rise_count[column] = 0
  316. std_value = row.get(f"{column}__std")
  317. std_history = [item[1] for item in history_std[column] if item[1] is not None]
  318. std_base = _median(std_history) if len(std_history) >= TEMP_MIN_HISTORY_HOURS else None
  319. hf_ratio = (std_value / max(std_base, thresholds["hf_base_floor"])) if (std_value is not None and std_base is not None) else None
  320. hf_z = None
  321. if std_value is not None and std_base is not None and len(std_history) >= TEMP_MIN_HISTORY_HOURS:
  322. log_values = [math.log(value + 0.01) for value in std_history]
  323. log_center = median(log_values)
  324. log_scale = max(1.4826 * median([abs(value - log_center) for value in log_values]), 0.1)
  325. hf_z = (math.log(std_value + 0.01) - log_center) / log_scale
  326. recent_values = own[-6:]
  327. recent_median = median(recent_values) if recent_values else None
  328. rise_values = [
  329. item[1] for item in history[column]
  330. if item[0] >= dt - timedelta(hours=thresholds["rise_baseline_hours"])
  331. ]
  332. rise_base = median(rise_values) if len(rise_values) >= thresholds["rise_min_history_hours"] else None
  333. rise_value = value - rise_base if rise_base is not None else None
  334. per_column[column] = {
  335. "value": value, "base": base, "delta": delta, "z": z,
  336. "peer_dev": peer_dev, "peer_anom": peer_anom,
  337. "d1": change[1], "d3": change[3], "d6": change[6],
  338. "slope": slope, "rise": rise_count[column],
  339. "std": std_value, "std_base": std_base, "hf_ratio": hf_ratio, "hf_z": hf_z,
  340. "recent_median": recent_median, "rise_base": rise_base, "rise_value": rise_value,
  341. }
  342. median_rate = {}
  343. for hours in (1, 3, 6):
  344. rate_values = [item[f"d{hours}"] for item in per_column.values() if item[f"d{hours}"] is not None]
  345. median_rate[hours] = median(rate_values) if rate_values else None
  346. for column, item in per_column.items():
  347. for hours in (1, 3, 6):
  348. peer_rate = item[f"d{hours}"] - median_rate[hours] if item[f"d{hours}"] is not None and median_rate[hours] is not None else None
  349. item[f"peer_d{hours}"] = peer_rate
  350. # ---- grading ----
  351. deltas = [item["delta"] for item in per_column.values() if item["delta"] is not None]
  352. zs = [item["z"] for item in per_column.values() if item["z"] is not None]
  353. peer_high = max((item["peer_anom"] for item in per_column.values()
  354. if item["peer_anom"] is not None), default=None)
  355. min_peer = min((item["peer_anom"] for item in per_column.values()
  356. if item["peer_anom"] is not None), default=0.0)
  357. peer_low = abs(min(0.0, min_peer))
  358. rate_max = {hours: max((item[f"peer_d{hours}"] for item in per_column.values()
  359. if item[f"peer_d{hours}"] is not None
  360. and item["recent_median"] is not None
  361. and item["value"] > item["recent_median"]), default=None)
  362. for hours in (1, 3, 6)}
  363. rate_min = {hours: min((item[f"peer_d{hours}"] for item in per_column.values()
  364. if item[f"peer_d{hours}"] is not None
  365. and item["recent_median"] is not None
  366. and item["value"] < item["recent_median"]), default=None)
  367. for hours in (1, 3, 6)}
  368. # A drop followed by a rebound (or a rise followed by a fall) is a
  369. # load/transition/channel artifact, not a one-sided short-term fault.
  370. rebound = (
  371. (rate_max[1] is not None and rate_min[3] is not None and rate_max[1] >= 4 and rate_min[3] <= -4)
  372. or (rate_min[1] is not None and rate_max[3] is not None and rate_min[1] <= -4 and rate_max[3] >= 4)
  373. or (rate_max[1] is not None and rate_min[6] is not None and rate_max[1] >= 4 and rate_min[6] <= -4)
  374. or (rate_min[1] is not None and rate_max[6] is not None and rate_min[1] <= -4 and rate_max[6] >= 4)
  375. )
  376. if rebound:
  377. rate_max = {hours: None for hours in (1, 3, 6)}
  378. rate_min = {hours: None for hours in (1, 3, 6)}
  379. peak_peer_column = max(
  380. (column for column in per_column if per_column[column]["peer_anom"] is not None),
  381. key=lambda column: per_column[column]["peer_anom"], default=None)
  382. low_peer_column = min(
  383. (column for column in per_column if per_column[column]["peer_anom"] is not None),
  384. key=lambda column: per_column[column]["peer_anom"], default=None)
  385. spread_high_column = max(values, key=values.get) if values else None
  386. spread_low_column = min(values, key=values.get) if values else None
  387. hf_columns = [column for column in per_column if per_column[column]["hf_ratio"] is not None]
  388. hf_column = max(hf_columns, key=lambda column: per_column[column]["hf_ratio"], default=None)
  389. hf_ratio = per_column[hf_column]["hf_ratio"] if hf_column else None
  390. hf_std = per_column[hf_column]["std"] if hf_column else None
  391. hf_base = per_column[hf_column]["std_base"] if hf_column else None
  392. hf_zs = [per_column[column]["hf_z"] for column in per_column if per_column[column]["hf_z"] is not None]
  393. hf_z = max(hf_zs) if hf_zs else None
  394. hf_sev = 0
  395. if hf_std is not None and hf_std >= thresholds["hf_min_std"]:
  396. hf_sev = max(
  397. _sev_level(hf_ratio, thresholds["hf_ratio_mild"], thresholds["hf_ratio_abnormal"], thresholds["hf_ratio_severe"]),
  398. _sev_level(hf_z, thresholds["hf_z_mild"], thresholds["hf_z_abnormal"], thresholds["hf_z_severe"]),
  399. )
  400. hf_reason = ""
  401. if hf_sev:
  402. label = config.labels.get(hf_column, "") if hf_column else ""
  403. if hf_ratio is not None and hf_std is not None and hf_base is not None:
  404. hf_reason = f"{label}高频波动×{hf_ratio:.1f}(基线{hf_base:.2f},实测{hf_std:.2f})"
  405. elif hf_z is not None:
  406. hf_reason = f"{label}高频波动z={hf_z:.1f}"
  407. rise_values = [item["rise_value"] for item in per_column.values() if item["rise_value"] is not None]
  408. stage_rise = median(rise_values) if rise_values else None
  409. relative_rises = {
  410. column: item["rise_value"] - stage_rise
  411. for column, item in per_column.items()
  412. if item["rise_value"] is not None and stage_rise is not None
  413. }
  414. if len(values) >= 2:
  415. rise_single_column = max(relative_rises, key=relative_rises.get, default=None)
  416. rise_single = relative_rises[rise_single_column] if rise_single_column else None
  417. rise_stage_value = stage_rise
  418. else:
  419. rise_single_column = max(
  420. (column for column in per_column if per_column[column]["rise_value"] is not None),
  421. key=lambda column: per_column[column]["rise_value"], default=None)
  422. rise_single = per_column[rise_single_column]["rise_value"] if rise_single_column else None
  423. rise_stage_value = rise_single
  424. previous_values = []
  425. for column in per_column:
  426. previous = _value_at(history[column], dt - timedelta(hours=1))
  427. if previous is not None:
  428. previous_values.append(previous)
  429. previous_stage_median = median(previous_values) if previous_values else None
  430. stage_rising = previous_stage_median is not None and stage_median > previous_stage_median
  431. rise_flat = stage_rise is not None and abs(stage_rise) <= thresholds["rise_stage_flat_limit"]
  432. # Only a single-cylinder rise *relative to its stage peers* is a
  433. # fault candidate. A whole-stage rise is the normal daily load
  434. # ramp; it is kept as a feature, not an event.
  435. if len(values) >= 2:
  436. rise_single_sev = _sev_level(
  437. rise_single, thresholds["rise_single_mild"], thresholds["rise_single_abnormal"], thresholds["rise_single_severe"]
  438. ) if (stage_rising and rise_flat) else 0
  439. rise_sev = rise_single_sev
  440. if rise_sev:
  441. rise_type = "单缸抬升"
  442. rise_label = config.labels.get(rise_single_column, "") if rise_single_column else ""
  443. rise_reason = f"6h整级抬升{rise_stage_value:+.1f}℃;{rise_label}相对同级{rise_single:+.1f}℃"
  444. else:
  445. rise_type = ""
  446. rise_label = ""
  447. rise_reason = ""
  448. else:
  449. rise_sev = 0
  450. rise_type = ""
  451. rise_label = ""
  452. rise_reason = ""
  453. def peer_reason(column, anomaly, sign):
  454. if column is None or anomaly is None:
  455. return ""
  456. item = per_column[column]
  457. dev = item["peer_dev"]
  458. base = dev - item["peer_anom"]
  459. label = config.labels.get(column, column)
  460. return f"{label}较同级中位{dev:+.1f}℃(平时{base:+.1f}℃),较平时{sign}{anomaly:.1f}℃"
  461. def spread_reason():
  462. if spread is None or spread_delta is None:
  463. return ""
  464. high = config.labels.get(spread_high_column, "")
  465. low = config.labels.get(spread_low_column, "")
  466. base = f"{spread_base:.1f}" if spread_base is not None else "-"
  467. return f"级差{spread:.1f}℃({high}-{low},平时{base}℃)较平时+{spread_delta:.1f}℃"
  468. if len(values) >= 2:
  469. high_candidates = [
  470. (_sev_level(peer_high, thresholds["mild_peer"], thresholds["abnormal_peer"], thresholds["severe_peer"]), "level", peer_reason(peak_peer_column, peer_high, "+")),
  471. (_sev_level(spread_delta, thresholds["mild_spread"], thresholds["abnormal_spread"], thresholds["severe_spread"]), "level", spread_reason()),
  472. ]
  473. low_candidates = [
  474. (_sev_level(peer_low, thresholds["mild_peer_low"], thresholds["abnormal_peer_low"], thresholds["severe_peer_low"]), "level", peer_reason(low_peer_column, peer_low, "-")),
  475. ]
  476. for hours in (1, 3, 6):
  477. if rate_max[hours] is not None:
  478. high_candidates.append((_sev_rate(rate_max[hours], thresholds[f"observe_rate{hours}"],
  479. thresholds[f"mild_rate{hours}"], thresholds[f"abnormal_rate{hours}"],
  480. thresholds[f"severe_rate{hours}"]), "short",
  481. f"{hours}h同级升{rate_max[hours]:.1f}℃"))
  482. if rate_min[hours] is not None:
  483. low_candidates.append((_sev_rate(-rate_min[hours], thresholds[f"observe_rate{hours}"],
  484. thresholds[f"mild_rate{hours}"], thresholds[f"abnormal_rate{hours}"],
  485. thresholds[f"severe_rate{hours}"]), "short",
  486. f"{hours}h同级降{-rate_min[hours]:.1f}℃"))
  487. high_sev, high_source, high_reason = _max_candidate(high_candidates)
  488. low_sev, low_source, low_reason = _max_candidate(low_candidates)
  489. else:
  490. # Single-cylinder stage: no same-stage peers. The old
  491. # cross-stage residual feature is intentionally removed; only
  492. # high-frequency fluctuation is kept for the tertiary stage.
  493. high_sev = low_sev = 0
  494. high_source = low_source = ""
  495. high_reason = low_reason = ""
  496. if high_sev and low_sev:
  497. base_direction, base_severity, base_source = "混合", max(high_sev, low_sev), "混合"
  498. base_reason = ";".join(item for item in (high_reason, low_reason) if item)
  499. elif high_sev:
  500. base_direction, base_severity, base_source, base_reason = "偏高", high_sev, high_source, high_reason
  501. elif low_sev:
  502. base_direction, base_severity, base_source, base_reason = "偏低", low_sev, low_source, low_reason
  503. else:
  504. base_direction, base_severity, base_source, base_reason = "", 0, "", ""
  505. direction, severity, source, reason = base_direction, base_severity, base_source, base_reason
  506. if base_severity == 0 and rise_sev > 0:
  507. direction, severity, source, reason = "偏高", rise_sev, "rise", rise_reason
  508. if severity == 0:
  509. feature_type = ""
  510. elif source == "hf":
  511. feature_type = "高频波动"
  512. elif source == "rise":
  513. feature_type = rise_type
  514. elif direction == "混合":
  515. feature_type = "混合"
  516. elif source == "short":
  517. feature_type = "短时突升" if direction == "偏高" else "短时突降"
  518. else:
  519. feature_type = "持续偏高" if direction == "偏高" else "持续偏低"
  520. confidence = "高" if source == "level" else "中"
  521. strongest = max((column for column in per_column if per_column[column]["delta"] is not None),
  522. key=lambda column: per_column[column]["delta"], default=None)
  523. high_values = [config.limits.get(column, {}).get("high") for column in values]
  524. high_values = [value for value in high_values if value is not None]
  525. high_high_values = [config.limits.get(column, {}).get("high_high") for column in values]
  526. high_high_values = [value for value in high_high_values if value is not None]
  527. features.append({
  528. "机组": config.name,
  529. "批次": row["import_batch_id"],
  530. "周期编号": row["_cycle_id"],
  531. "小时": dt.strftime("%Y-%m-%d %H:%M:%S"),
  532. "级": stage,
  533. "有效缸数": len(values),
  534. "运行覆盖率": round(row.get("coverage") or 0.0, 4),
  535. "最高温度": _fmt(max(values.values())),
  536. "最低温度": _fmt(min(values.values())),
  537. "最高温度缸": config.labels.get(max(values, key=values.get), ""),
  538. "级差": _fmt(spread),
  539. "级差基线": _fmt(spread_base),
  540. "级差变化": _fmt(spread_delta),
  541. "最大自身偏离": _fmt(max(deltas) if deltas else None),
  542. "最大自身偏离缸": config.labels.get(strongest, "") if strongest else "",
  543. "最大同级偏离": _fmt(peer_high),
  544. "最大同级偏离缸": config.labels.get(peak_peer_column, "") if peak_peer_column else "",
  545. "最大同级偏低": _fmt(peer_low if peer_low else None),
  546. "最大同级偏低缸": config.labels.get(low_peer_column, "") if low_peer_column else "",
  547. "最大稳健z": _fmt(max(zs) if zs else None),
  548. "高频波动倍数": _fmt(hf_ratio),
  549. "高频波动z": _fmt(hf_z),
  550. "高频标准差": _fmt(hf_std),
  551. "高频基线标准差": _fmt(hf_base),
  552. "高频波动缸": config.labels.get(hf_column, "") if hf_column else "",
  553. "6小时整级抬升": _fmt(rise_stage_value),
  554. "6小时单缸抬升": _fmt(rise_single),
  555. "6小时单缸相对抬升": _fmt(relative_rises.get(rise_single_column)) if rise_single_column else "",
  556. "6小时基线": _fmt(per_column[rise_single_column]["rise_base"]) if rise_single_column else "",
  557. "抬升类型": rise_type,
  558. "抬升缸": rise_label,
  559. "最强缸": config.labels.get(strongest, "") if strongest else "",
  560. "最高报警阈值": _fmt(min(high_values)) if high_values else "",
  561. "高高报警阈值": _fmt(min(high_high_values)) if high_high_values else "",
  562. "超告警缸": "、".join(
  563. config.labels.get(column, column) for column in values
  564. if (config.limits.get(column, {}).get("high") is not None
  565. and values[column] >= config.limits[column]["high"])
  566. ),
  567. "超高高缸": "、".join(
  568. config.labels.get(column, column) for column in values
  569. if (config.limits.get(column, {}).get("high_high") is not None
  570. and values[column] >= config.limits[column]["high_high"])
  571. ),
  572. "近6小时斜率": _fmt(per_column[strongest]["slope"], 4) if strongest else "",
  573. "连续上升小时数": per_column[strongest]["rise"] if strongest else 0,
  574. "同级相对1h": _fmt(rate_max[1] if rate_max[1] is not None and (rate_max[1] or 0) >= (abs(rate_min[1] or 0)) else rate_min[1]),
  575. "同级相对3h": _fmt(rate_max[3] if rate_max[3] is not None and (rate_max[3] or 0) >= (abs(rate_min[3] or 0)) else rate_min[3]),
  576. "同级相对6h": _fmt(rate_max[6] if rate_max[6] is not None and (rate_max[6] or 0) >= (abs(rate_min[6] or 0)) else rate_min[6]),
  577. "各缸温度": ";".join(f"{config.labels.get(column, column)}={value:.2f}" for column, value in values.items()),
  578. "各缸自身偏离": ";".join(f"{config.labels.get(column, column)}={per_column[column]['delta']:+.2f}"
  579. for column in values if per_column[column]["delta"] is not None),
  580. "各缸同级偏离": ";".join(f"{config.labels.get(column, column)}={per_column[column]['peer_dev']:+.2f}"
  581. for column in values),
  582. "方向": direction,
  583. "特征类型": feature_type,
  584. "来源": source,
  585. "置信度": confidence,
  586. "原始等级": SEV_LABELS[severity],
  587. "判定依据": reason,
  588. "温度等级": "",
  589. "是否事件": "",
  590. "首次短时触发": "",
  591. "首次持续触发": "",
  592. "关联通道异常": "",
  593. "_raw_sev": severity,
  594. "_direction": direction,
  595. "_source": source,
  596. "_hf_sev": hf_sev,
  597. "_hf_ratio": hf_ratio,
  598. "_hf_z": hf_z,
  599. "_hf_std": hf_std,
  600. "_hf_base": hf_base,
  601. "_hf_column": hf_column,
  602. "_rise_sev": rise_sev,
  603. "_rise_type": rise_type,
  604. "_rise_stage": rise_stage_value,
  605. "_rise_single": rise_single,
  606. "_type": feature_type,
  607. "_confidence": confidence,
  608. "_dt": dt,
  609. "_strongest": strongest,
  610. })
  611. # append histories after all computations for this stage
  612. for column, value in values.items():
  613. history[column].append((dt, value))
  614. history_peer[column].append((dt, peer[column]))
  615. history_std[column].append((dt, per_column[column]["std"]))
  616. last_dt[column] = dt
  617. if spread is not None:
  618. history_spread[stage].append((dt, spread))
  619. return features
  620. # ---------------------------------------------------------------------------
  621. # confirmation / segments / cycles
  622. # ---------------------------------------------------------------------------
  623. def confirm_levels(features: list[dict], thresholds: dict) -> None:
  624. confirm_hours = int(thresholds.get("confirm_hours", 3))
  625. event_hours = int(thresholds.get("event_hours", 2))
  626. hf_persist = int(thresholds.get("hf_persist_hours", 3))
  627. rise_persist = int(thresholds.get("rise_persist_hours", 2))
  628. grouped: dict[tuple, list[dict]] = defaultdict(list)
  629. for row in features:
  630. grouped[(row["机组"], row["批次"], row["级"])].append(row)
  631. for rows in grouped.values():
  632. rows.sort(key=lambda row: row["_dt"])
  633. index = 0
  634. while index < len(rows):
  635. if rows[index]["_raw_sev"] == 0:
  636. rows[index]["温度等级"] = "正常"
  637. rows[index]["是否事件"] = "否"
  638. index += 1
  639. continue
  640. end = index
  641. while end + 1 < len(rows) and rows[end + 1]["_raw_sev"] > 0:
  642. if rows[end + 1]["_dt"] - rows[end]["_dt"] != timedelta(hours=1):
  643. break
  644. end += 1
  645. run = rows[index:end + 1]
  646. run_length = len(run)
  647. level_rows = [row for row in run if row["_source"] == "level" and row["_raw_sev"] > 0]
  648. short_rows = [row for row in run if row["_source"] == "short" and row["_raw_sev"] > 0]
  649. hf_rows = [row for row in run if row["_source"] == "hf" and row["_raw_sev"] > 0]
  650. rise_rows = [row for row in run if row["_source"] == "rise" and row["_raw_sev"] > 0]
  651. max_level = max((row["_raw_sev"] for row in level_rows), default=0)
  652. max_short = max((row["_raw_sev"] for row in short_rows), default=0)
  653. strong_short = [row for row in short_rows if row["_raw_sev"] >= 2]
  654. short_streak = best_short_streak = 0
  655. previous_direction = None
  656. for row in strong_short:
  657. if row["_direction"] == previous_direction and previous_direction in ("偏高", "偏低"):
  658. short_streak += 1
  659. else:
  660. short_streak = 1
  661. previous_direction = row["_direction"]
  662. best_short_streak = max(best_short_streak, short_streak)
  663. max_hf = max((row["_raw_sev"] for row in hf_rows), default=0)
  664. strong_hf = [row for row in hf_rows if row["_raw_sev"] >= 2]
  665. max_rise = max((row["_raw_sev"] for row in rise_rows), default=0)
  666. strong_rise = [row for row in rise_rows if row["_raw_sev"] >= 2]
  667. final = 0
  668. if max_level:
  669. if run_length >= confirm_hours:
  670. final = max(final, max_level)
  671. elif run_length >= event_hours:
  672. if max_level >= 3:
  673. final = max(final, 3)
  674. elif max_level >= 2:
  675. final = max(final, 2)
  676. elif max_level >= 2:
  677. final = max(final, 1)
  678. if max_short >= 2 and best_short_streak >= 2:
  679. final = max(final, max_short)
  680. if max_hf >= 2 and len(strong_hf) >= hf_persist:
  681. final = max(final, max_hf)
  682. if max_rise >= 2 and len(strong_rise) >= rise_persist:
  683. final = max(final, max_rise)
  684. if final == 0:
  685. final = 1
  686. peak = max(run, key=lambda row: (row["_raw_sev"], 1 if row["_source"] == "level" else 0))
  687. active_directions = {row["_direction"] for row in run if row["_direction"] and row["_raw_sev"] >= 2}
  688. if "混合" in active_directions or len(active_directions) > 1:
  689. direction = "混合"
  690. else:
  691. direction = peak["_direction"] or ""
  692. first_short = next((row["小时"] for row in run
  693. if row["_source"] == "short" and row["_raw_sev"] >= 2), "")
  694. first_level = next((row["小时"] for row in run
  695. if row["_source"] in ("level", "rise") and row["_raw_sev"] >= 2), "")
  696. for row in run:
  697. row["温度等级"] = SEV_LABELS[final]
  698. row["是否事件"] = "是" if final >= 2 else "否"
  699. row["方向"] = direction
  700. row["首次短时触发"] = first_short
  701. row["首次持续触发"] = first_level
  702. if final >= 2 and direction == "混合":
  703. row["特征类型"] = "混合"
  704. elif final >= 2 and row["_type"]:
  705. row["特征类型"] = row["_type"]
  706. index = end + 1
  707. def build_observations(features: list[dict], segments: list[dict], config: UnitConfig) -> list[dict]:
  708. """One row per 1h observation hour, linked to the next event within 24h."""
  709. events: dict[tuple, list[dict]] = defaultdict(list)
  710. for segment in segments:
  711. if segment["是否事件"] == "是":
  712. events[(segment["机组"], segment["级"])].append(segment)
  713. rows = []
  714. for row in features:
  715. if row["温度等级"] != "观察":
  716. continue
  717. candidates = []
  718. for segment in events.get((row["机组"], row["级"]), []):
  719. delta = (segment["_dt"] - row["_dt"]).total_seconds() / 3600
  720. if 0 <= delta <= 24:
  721. candidates.append(segment)
  722. event = min(candidates, key=lambda item: item["_dt"]) if candidates else None
  723. rows.append({
  724. "机组": row["机组"], "批次": row["批次"], "周期编号": row["周期编号"],
  725. "小时": row["小时"], "级": row["级"],
  726. "方向": row["方向"], "特征类型": row["特征类型"], "置信度": row["置信度"],
  727. "最高温度": row["最高温度"], "级差": row["级差"], "级差变化": row["级差变化"],
  728. "最大同级偏离": row["最大同级偏离"], "最大同级偏低": row["最大同级偏低"],
  729. "最大同级相对1h": row["同级相对1h"], "最大同级相对3h": row["同级相对3h"],
  730. "最大同级相对6h": row["同级相对6h"],
  731. "判定依据": row["判定依据"],
  732. "关联周期编号": event["周期编号"] if event else "",
  733. "关联事件开始": event["开始小时"] if event else "",
  734. "关联事件等级": event["温度阶段"] if event else "",
  735. "是否升级为事件": "是" if event else "否",
  736. "_dt": row["_dt"],
  737. })
  738. return sorted(rows, key=output_sort_key)
  739. def build_segments(features: list[dict], config: UnitConfig, channel_segments: list[dict],
  740. thresholds: dict, min_hours: int = 1) -> list[dict]:
  741. channel_by_key: dict[tuple, set[str]] = defaultdict(set)
  742. for segment in channel_segments:
  743. channel_by_key[(segment["机组"], segment["级"])].add(segment["异常类型"])
  744. grouped: dict[tuple, list[dict]] = defaultdict(list)
  745. for row in features:
  746. if row["温度等级"] in EVENT_LEVELS:
  747. grouped[(row["机组"], row["批次"], row["级"], row["温度等级"])].append(row)
  748. segments = []
  749. for (unit, batch, stage, level), rows in grouped.items():
  750. rows.sort(key=lambda row: row["_dt"])
  751. current = []
  752. for row in rows:
  753. if not current or row["_dt"] - current[-1]["_dt"] <= timedelta(hours=thresholds.get("merge_gap_hours", 6)):
  754. current.append(row)
  755. else:
  756. segments.append(_build_segment(current, unit, batch, stage, level, channel_by_key, config))
  757. current = [row]
  758. if current:
  759. segments.append(_build_segment(current, unit, batch, stage, level, channel_by_key, config))
  760. segments = [segment for segment in segments if segment["有效运行小时数"] >= min_hours]
  761. return sorted(segments, key=output_sort_key)
  762. def _build_segment(rows: list[dict], unit: str, batch: int, stage: str, level: str,
  763. channel_by_key: dict, config: UnitConfig) -> dict:
  764. first, last = rows[0], rows[-1]
  765. peak = max(rows, key=lambda row: (row["_raw_sev"], 1 if row["_source"] == "level" else 0))
  766. peak_label = peak.get("最强缸") or peak.get("最高温度缸") or ""
  767. spread_rows = [row for row in rows if row["级差"] not in ("", None)]
  768. spread_peak = max(spread_rows, key=lambda row: row["级差"]) if spread_rows else None
  769. peer_rows = [row for row in rows if row["最大同级偏离"] not in ("", None)]
  770. peer_peak = max(peer_rows, key=lambda row: row["最大同级偏离"]) if peer_rows else None
  771. low_rows = [row for row in rows if row["最大同级偏低"] not in ("", None)]
  772. low_peak = max(low_rows, key=lambda row: row["最大同级偏低"]) if low_rows else None
  773. short_times = [row["小时"] for row in rows if row["_source"] == "short" and row["_raw_sev"] >= 2]
  774. level_times = [row["小时"] for row in rows if row["_source"] == "level" and row["_raw_sev"] >= 2]
  775. directions = {row["方向"] for row in rows if row["方向"]}
  776. direction = "混合" if "混合" in directions or len(directions) > 1 else (next(iter(directions), ""))
  777. return {
  778. "机组": unit,
  779. "批次": batch,
  780. "周期编号": first["周期编号"],
  781. "级": stage,
  782. "温度阶段": level,
  783. "阶段部位": _part_and_point(peak_label, config)[0],
  784. "阶段点位": _part_and_point(peak_label, config)[1],
  785. "是否事件": "是" if level in EVENT_LEVELS else "否",
  786. "是否形成确认等级": "是" if level in ("异常", "严重异常") else "否",
  787. "方向": direction or peak["_direction"],
  788. "特征类型": "混合" if direction == "混合" else peak["_type"],
  789. "置信度": peak["_confidence"],
  790. "开始小时": first["小时"],
  791. "结束小时": last["小时"],
  792. "持续自然小时数": int((last["_dt"] - first["_dt"]).total_seconds() / 3600) + 1,
  793. "有效运行小时数": len(rows),
  794. "峰值时刻": peak["小时"],
  795. "峰值缸": peak_label,
  796. "最高温度": max((row["最高温度"] for row in rows if row["最高温度"] not in ("", None)), default=""),
  797. "最大自身偏离": peak["最大自身偏离"],
  798. "最大级差": spread_peak["级差"] if spread_peak else "",
  799. "最大级差基线": spread_peak["级差基线"] if spread_peak else "",
  800. "最大级差变化": spread_peak["级差变化"] if spread_peak else "",
  801. "最大同级偏离": peer_peak["最大同级偏离"] if peer_peak else "",
  802. "最大同级偏低": low_peak["最大同级偏低"] if low_peak else "",
  803. "最大稳健z": peak["最大稳健z"],
  804. "最大同级相对1h": max((row["同级相对1h"] for row in rows if row["同级相对1h"] not in ("", None)), default=""),
  805. "最大同级相对3h": max((row["同级相对3h"] for row in rows if row["同级相对3h"] not in ("", None)), default=""),
  806. "最大同级相对6h": max((row["同级相对6h"] for row in rows if row["同级相对6h"] not in ("", None)), default=""),
  807. "首次短时触发": min(short_times) if short_times else "",
  808. "首次持续触发": min(level_times) if level_times else "",
  809. "阶段最高报警阈值": min((row["最高报警阈值"] for row in rows if row["最高报警阈值"] not in ("", None)), default=""),
  810. "阶段高高报警阈值": min((row["高高报警阈值"] for row in rows if row["高高报警阈值"] not in ("", None)), default=""),
  811. "阶段高报警小时数": sum(1 for row in rows if row.get("超告警缸") not in ("", None)),
  812. "阶段高高报警小时数": sum(1 for row in rows if row.get("超高高缸") not in ("", None)),
  813. "关联通道异常": "、".join(sorted(channel_by_key.get((unit, stage), set()))),
  814. "阶段触发依据": ";".join(
  815. list(dict.fromkeys(
  816. row["判定依据"] for row in rows if row["判定依据"]
  817. ))
  818. [:3]),
  819. "_dt": first["_dt"],
  820. }
  821. def build_hf_segments(features: list[dict], config: UnitConfig, thresholds: dict) -> list[dict]:
  822. """High-frequency fluctuation events, tertiary stage only."""
  823. hf_persist = int(thresholds.get("hf_persist_hours", 3))
  824. rise_persist = int(thresholds.get("rise_persist_hours", 2))
  825. grouped: dict[tuple, list[dict]] = defaultdict(list)
  826. for row in features:
  827. if row["级"] != "三级" or row.get("_hf_sev", 0) <= 0:
  828. continue
  829. grouped[(row["机组"], row["批次"], row["级"])].append(row)
  830. raw_segments = []
  831. for (unit, batch, stage), rows in grouped.items():
  832. rows.sort(key=lambda row: row["_dt"])
  833. current = []
  834. for row in rows:
  835. if not current or row["_dt"] - current[-1]["_dt"] == timedelta(hours=1):
  836. current.append(row)
  837. else:
  838. if len(current) >= hf_persist:
  839. raw_segments.append(_build_hf_segment(current, unit, batch, stage, config))
  840. current = [row]
  841. if len(current) >= hf_persist:
  842. raw_segments.append(_build_hf_segment(current, unit, batch, stage, config))
  843. raw_segments.sort(key=lambda row: (row["机组"], row["批次"], row["_level"], row["_dt"]))
  844. merged = []
  845. for segment in raw_segments:
  846. if (merged and merged[-1]["机组"] == segment["机组"] and merged[-1]["批次"] == segment["批次"]
  847. and merged[-1]["_level"] == segment["_level"]
  848. and segment["_dt"] - merged[-1]["_dt"] <= timedelta(hours=6)):
  849. merged[-1]["结束小时"] = segment["结束小时"]
  850. merged[-1]["有效运行小时数"] += segment["有效运行小时数"]
  851. merged[-1]["持续自然小时数"] += segment["持续自然小时数"]
  852. merged[-1]["最高温度"] = max(merged[-1]["最高温度"], segment["最高温度"])
  853. merged[-1]["高频波动倍数"] = max(merged[-1]["高频波动倍数"], segment["高频波动倍数"])
  854. merged[-1]["高频波动z"] = max(merged[-1]["高频波动z"], segment["高频波动z"])
  855. merged[-1]["高频标准差"] = max(merged[-1]["高频标准差"], segment["高频标准差"])
  856. merged[-1]["阶段触发依据"] = segment["阶段触发依据"]
  857. else:
  858. merged.append(segment)
  859. return sorted(merged, key=output_sort_key)
  860. def _build_hf_segment(rows: list[dict], unit: str, batch: int, stage: str, config: UnitConfig) -> dict:
  861. peak = max(rows, key=lambda row: (row["_hf_sev"], row.get("_hf_ratio") or 0))
  862. level = peak["_hf_sev"]
  863. column = peak.get("_hf_column") or ""
  864. label = config.labels.get(column, "")
  865. ratio = peak.get("_hf_ratio") or 0
  866. z_value = peak.get("_hf_z")
  867. std_value = peak.get("_hf_std") or 0
  868. base_value = peak.get("_hf_base") or 0
  869. reason = f"{label}高频波动×{ratio:.1f}(基线{base_value:.2f},实测{std_value:.2f})"
  870. if z_value is not None:
  871. reason += f",z={z_value:.1f}"
  872. return {
  873. "机组": unit,
  874. "批次": batch,
  875. "周期编号": rows[0]["周期编号"],
  876. "级": stage,
  877. "温度阶段": SEV_LABELS[level],
  878. "阶段部位": _part_and_point(label, config)[0],
  879. "阶段点位": _part_and_point(label, config)[1],
  880. "是否事件": "是",
  881. "是否形成确认等级": "是" if SEV_LABELS[level] in ("异常", "严重异常") else "否",
  882. "方向": "波动",
  883. "特征类型": "高频波动",
  884. "置信度": "中",
  885. "开始小时": rows[0]["小时"],
  886. "结束小时": rows[-1]["小时"],
  887. "持续自然小时数": int((rows[-1]["_dt"] - rows[0]["_dt"]).total_seconds() / 3600) + 1,
  888. "有效运行小时数": len(rows),
  889. "峰值时刻": peak["小时"],
  890. "峰值缸": label,
  891. "最高温度": max((row["最高温度"] for row in rows if row["最高温度"] not in ("", None)), default=""),
  892. "高频波动倍数": round(ratio, 2),
  893. "高频波动z": round(z_value, 2) if z_value is not None else "",
  894. "高频标准差": round(std_value, 2),
  895. "高频基线标准差": round(base_value, 2),
  896. "高频波动缸": label,
  897. "阶段触发依据": reason,
  898. "_dt": rows[0]["_dt"],
  899. "_level": level,
  900. }
  901. def summarize_cycles(features: list[dict], segments: list[dict], config: UnitConfig) -> list[dict]:
  902. grouped: dict[tuple, list[dict]] = defaultdict(list)
  903. for row in features:
  904. grouped[(row["机组"], row["批次"], row["周期编号"], row["级"])].append(row)
  905. result = []
  906. for (unit, batch, cycle_id, stage), rows in grouped.items():
  907. rows.sort(key=lambda row: row["_dt"])
  908. stage_segments = [segment for segment in segments
  909. if segment["周期编号"] == cycle_id and segment["级"] == stage]
  910. sequence = []
  911. previous = None
  912. for row in rows:
  913. label = row["温度等级"]
  914. if label != previous:
  915. sequence.append(label)
  916. previous = label
  917. if len(sequence) > 1 and sequence[-1] == "正常":
  918. sequence[-1] = "恢复"
  919. peak = max(rows, key=lambda row: (row["_raw_sev"], 1 if row["_source"] == "level" else 0))
  920. summary = {
  921. "机组": unit,
  922. "批次": batch,
  923. "周期编号": cycle_id,
  924. "级": stage,
  925. "周期开始小时": rows[0]["小时"],
  926. "周期结束小时": rows[-1]["小时"],
  927. "有效运行小时数": len(rows),
  928. "阶段演化顺序": "→".join(sequence) if sequence else "正常",
  929. "方向": peak["方向"] if peak["_raw_sev"] else "",
  930. "特征类型": peak["_type"] if peak["_raw_sev"] else "",
  931. "置信度": peak["置信度"] if peak["_raw_sev"] else "",
  932. "峰值时刻": peak["小时"] if peak["_raw_sev"] else "",
  933. "峰值缸": peak["最强缸"] if peak["_raw_sev"] else "",
  934. "首次短时触发": min((row["首次短时触发"] for row in rows if row["首次短时触发"]), default=""),
  935. "首次持续触发": min((row["首次持续触发"] for row in rows if row["首次持续触发"]), default=""),
  936. "关联通道异常": "、".join(sorted({segment["关联通道异常"] for segment in stage_segments if segment["关联通道异常"]})),
  937. }
  938. for level in LEVELS:
  939. selected = [segment for segment in stage_segments if segment["温度阶段"] == level]
  940. summary[f"{level}阶段开始小时"] = selected[0]["开始小时"] if selected else ""
  941. summary[f"{level}阶段结束小时"] = selected[-1]["结束小时"] if selected else ""
  942. summary[f"{level}阶段运行小时数"] = sum(segment["有效运行小时数"] for segment in selected)
  943. summary[f"是否达到{level}"] = "是" if selected else "否"
  944. summary["周期最高温度"] = max((row["最高温度"] for row in rows if row["最高温度"] not in ("", None)), default="")
  945. summary["周期最大级差"] = max((row["级差"] for row in rows if row["级差"] not in ("", None)), default="")
  946. summary["周期最大级差变化"] = max((row["级差变化"] for row in rows if row["级差变化"] not in ("", None)), default="")
  947. summary["周期最大同级偏离"] = max((row["最大同级偏离"] for row in rows if row["最大同级偏离"] not in ("", None)), default="")
  948. summary["周期最大同级偏低"] = max((row["最大同级偏低"] for row in rows if row["最大同级偏低"] not in ("", None)), default="")
  949. result.append(summary)
  950. return result
  951. # ---------------------------------------------------------------------------
  952. # outputs
  953. # ---------------------------------------------------------------------------
  954. FEATURE_FIELDS = [
  955. "机组", "批次", "周期编号", "小时", "级", "有效缸数", "运行覆盖率",
  956. "最高温度", "最低温度", "最高温度缸", "最高报警阈值", "高高报警阈值", "超告警缸", "超高高缸",
  957. "级差", "级差基线", "级差变化",
  958. "最大自身偏离", "最大自身偏离缸", "最大同级偏离", "最大同级偏离缸",
  959. "最大同级偏低", "最大同级偏低缸", "最大稳健z",
  960. "高频波动倍数", "高频波动z", "高频标准差", "高频基线标准差", "高频波动缸",
  961. "6小时整级抬升", "6小时单缸抬升", "6小时单缸相对抬升", "6小时基线", "抬升类型", "抬升缸", "最强缸",
  962. "近6小时斜率", "连续上升小时数", "同级相对1h", "同级相对3h", "同级相对6h",
  963. "各缸温度", "各缸自身偏离", "各缸同级偏离",
  964. "方向", "特征类型", "来源", "置信度",
  965. "温度等级", "原始等级", "是否事件", "首次短时触发", "首次持续触发", "关联通道异常", "判定依据",
  966. ]
  967. CANONICAL_FIELDS = [
  968. "机组", "批次", "周期编号", "级", "温度阶段", "阶段部位", "阶段点位",
  969. "开始小时", "结束小时", "持续自然小时数", "有效运行小时数",
  970. "峰值时刻", "峰值缸", "最高温度",
  971. "最大自身偏离", "最大级差", "最大级差基线", "最大级差变化",
  972. "最大同级偏离", "最大同级偏低", "最大稳健z",
  973. "最大同级相对1h", "最大同级相对3h", "最大同级相对6h",
  974. "首次短时触发", "首次持续触发",
  975. "阶段最高报警阈值", "阶段高高报警阈值",
  976. "阶段高报警小时数", "阶段高高报警小时数",
  977. "方向", "特征类型", "置信度",
  978. "高频波动倍数", "高频波动z", "高频标准差", "高频基线标准差", "高频波动缸",
  979. "去趋势残差标准差", "每小时反复升降次数",
  980. "是否形成确认等级", "关联通道异常", "阶段触发依据",
  981. ]
  982. SEGMENT_FIELDS = CANONICAL_FIELDS
  983. HF_FIELDS = CANONICAL_FIELDS
  984. HF_CANDIDATE_FIELDS = CANONICAL_FIELDS + ["是否确认"]
  985. CYCLE_FIELDS = [
  986. "机组", "批次", "周期编号", "级", "周期开始小时", "周期结束小时", "有效运行小时数",
  987. "阶段演化顺序", "方向", "特征类型", "置信度", "峰值时刻", "峰值缸",
  988. "首次短时触发", "首次持续触发", "关联通道异常",
  989. "观察阶段开始小时", "观察阶段结束小时", "观察阶段运行小时数", "是否达到观察",
  990. "轻微阶段开始小时", "轻微阶段结束小时", "轻微阶段运行小时数", "是否达到轻微",
  991. "异常阶段开始小时", "异常阶段结束小时", "异常阶段运行小时数", "是否达到异常",
  992. "严重异常阶段开始小时", "严重异常阶段结束小时", "严重异常阶段运行小时数", "是否达到严重异常",
  993. "周期最高温度", "周期最大级差", "周期最大级差变化", "周期最大同级偏离", "周期最大同级偏低",
  994. ]
  995. CHANNEL_FIELDS = [
  996. "机组", "批次", "点位", "点位描述", "级", "缸", "异常类型",
  997. "开始小时", "结束小时", "持续自然小时数", "有效小时数", "是否显著",
  998. "最小值", "最大值", "中位数", "同级基准中位", "原因",
  999. ]
  1000. QC_FIELDS = [
  1001. "机组", "点位", "点位描述", "运行小时数", "最小值", "最大值", "中位数",
  1002. "不同值个数", "最长不变小时", "零值比例", "越界比例", "判定", "原因",
  1003. ]
  1004. SAME_STAGE_FIELDS = [
  1005. "机组", "批次", "周期编号", "小时", "级", "有效缸数",
  1006. "级差", "级差基线", "级差变化", "最大同级偏离", "最大同级偏低",
  1007. "各缸温度", "各缸同级偏离",
  1008. ]
  1009. SHORT_FIELDS = [
  1010. "机组", "批次", "周期编号", "小时", "级",
  1011. "同级相对1h", "同级相对3h", "同级相对6h", "方向", "最强缸",
  1012. ]
  1013. OBSERVATION_FIELDS = [
  1014. "机组", "批次", "周期编号", "小时", "级", "方向", "特征类型", "置信度",
  1015. "最高温度", "级差", "级差变化", "最大同级偏离", "最大同级偏低",
  1016. "最大同级相对1h", "最大同级相对3h", "最大同级相对6h",
  1017. "判定依据", "关联周期编号", "关联事件开始", "关联事件等级", "是否升级为事件",
  1018. ]
  1019. def write_results(output_dir, qc, features, segments, cycles, channel_segments, observations,
  1020. hf_segments, hf_candidates=None):
  1021. output_dir.mkdir(parents=True, exist_ok=True)
  1022. candidates = hf_candidates if hf_candidates is not None else hf_segments
  1023. canonical = clean_rows(segments) + clean_rows(hf_segments)
  1024. canonical.sort(key=output_sort_key)
  1025. canonical_hf = [row for row in canonical if row.get("特征类型") == "高频波动"]
  1026. write_csv(output_dir / "温度小时特征.csv", clean_rows(features), FEATURE_FIELDS, {field: field for field in FEATURE_FIELDS})
  1027. write_csv(output_dir / "温度异常段.csv", canonical, CANONICAL_FIELDS, {field: field for field in CANONICAL_FIELDS})
  1028. write_csv(output_dir / "温度观察段.csv", clean_rows(observations), OBSERVATION_FIELDS, {field: field for field in OBSERVATION_FIELDS})
  1029. write_csv(output_dir / "温度高频波动段.csv", canonical_hf, HF_FIELDS, {field: field for field in HF_FIELDS})
  1030. write_csv(output_dir / "温度高频候选.csv", clean_rows(candidates), HF_CANDIDATE_FIELDS, {field: field for field in HF_CANDIDATE_FIELDS})
  1031. write_csv(output_dir / "温度演化周期.csv", clean_rows(cycles), CYCLE_FIELDS, {field: field for field in CYCLE_FIELDS})
  1032. write_csv(output_dir / "温度通道异常段.csv", clean_rows(channel_segments), CHANNEL_FIELDS, {field: field for field in CHANNEL_FIELDS})
  1033. write_csv(output_dir / "温度通道质控.csv", clean_rows(qc), QC_FIELDS, {field: field for field in QC_FIELDS})
  1034. same_stage = [{**row} for row in features]
  1035. write_csv(output_dir / "同级温差特征.csv", clean_rows(same_stage), SAME_STAGE_FIELDS, {field: field for field in SAME_STAGE_FIELDS})
  1036. short = [{**row} for row in features]
  1037. write_csv(output_dir / "温度短时特征.csv", clean_rows(short), SHORT_FIELDS, {field: field for field in SHORT_FIELDS})
  1038. def run(rows: list[dict], config: UnitConfig, thresholds: dict | None = None, min_hours: int = 1):
  1039. merged = {**config.thresholds, **(thresholds or {})}
  1040. valid = [row for row in rows if row.get("valid")]
  1041. bad_channels, qc = channel_qc(valid, config)
  1042. anomalies = mark_channel_anomalies(valid, config, merged)
  1043. channel_segments = build_channel_segments(anomalies, merged)
  1044. valid = _assign_cycles(valid, config)
  1045. features = compute_features(valid, config, bad_channels, merged)
  1046. confirm_levels(features, merged)
  1047. segments = build_segments(features, config, channel_segments, merged, min_hours)
  1048. observations = build_observations(features, segments, config)
  1049. hf_segments = build_hf_segments(features, config, merged)
  1050. cycles = summarize_cycles(features, segments, config)
  1051. return qc, features, segments, cycles, channel_segments, observations, hf_segments
  1052. __all__ = ["run", "write_results"]