pressure_algorithm.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. #!/usr/bin/env python3
  2. """PKS lubricating-oil-pressure evolution algorithm (pressure half of the scanner).
  3. This is the pressure-only reconstruction of
  4. ``backend/scan_pks_fault_evolution.py``. Only the code path that actually
  5. produces the exported pressure results is retained, copied verbatim so the
  6. output stays identical to the original scanner. Database/query/IO/statistics
  7. helpers live in :mod:`models.common`.
  8. CLI
  9. ---
  10. ::
  11. python models/generalized/pressure_algorithm.py \
  12. --start 2025-01-01 --end 2026-09-01 \
  13. --hourly-csv models/datas/hourly/pks_hourly_full.csv \
  14. --output-dir models/results/pressure
  15. """
  16. from __future__ import annotations
  17. import argparse
  18. import sys
  19. from collections import defaultdict
  20. from datetime import timedelta
  21. from pathlib import Path
  22. if __package__ in (None, ""):
  23. sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
  24. from models.common import db, devices, io, paths, queries
  25. from models.common.constants import (
  26. HOURLY_BASELINE_HOURS,
  27. MIN_HOUR_COVERAGE,
  28. HOUR_ALARM_SAMPLE_RATIO,
  29. OIL_CONFIRM_HOURS,
  30. OIL_STAGE_MERGE_GAP_HOURS,
  31. OIL_PRECURSOR_DECLINE_HOURS,
  32. OIL_PRECURSOR_SLOPE,
  33. OIL_STARTUP_CANDIDATE_HOURS,
  34. OIL_STARTUP_OBSERVE_HOURS,
  35. OIL_STARTUP_RECOVERY_RATIO,
  36. OIL_STARTUP_STABLE_HOURS,
  37. RESET_GAP_HOURS,
  38. )
  39. from models.common.stats import consecutive_decline_hours, linear_slope, median_value
  40. def _zero_ratio(row: dict) -> float | None:
  41. ratio = row.get("oil_zero_ratio")
  42. if ratio is not None:
  43. return float(ratio)
  44. zero_count = row.get("oil_zero_count")
  45. running = row.get("运行样本数")
  46. if zero_count is None or not running:
  47. return None
  48. return float(zero_count) / float(running)
  49. def _dropout_invalid_hours(rows: list[dict]) -> set[tuple[int, datetime]]:
  50. """Hours with >=5% zero oil samples next to a nearly all-zero hour."""
  51. full_zero: set[tuple[int, datetime]] = set()
  52. for row in rows:
  53. ratio = _zero_ratio(row)
  54. if ratio is not None and ratio >= 0.5:
  55. full_zero.add((row["批次"], row["_dt"]))
  56. invalid: set[tuple[int, datetime]] = set()
  57. for row in rows:
  58. ratio = _zero_ratio(row)
  59. if ratio is None or ratio < 0.05:
  60. continue
  61. moment = row["_dt"]
  62. if (
  63. (row["批次"], moment - timedelta(hours=1)) in full_zero
  64. or (row["批次"], moment + timedelta(hours=1)) in full_zero
  65. ):
  66. invalid.add((row["批次"], moment))
  67. return invalid
  68. def _contiguous_slope(history: list[dict], current: dict, hours: int = 6):
  69. sequence = [current]
  70. newer = current
  71. for older in reversed(history):
  72. if newer["_dt"] - older["_dt"] != timedelta(hours=1):
  73. break
  74. if older["oil_avg"] is None:
  75. break
  76. sequence.append(older)
  77. newer = older
  78. if len(sequence) >= hours:
  79. break
  80. sequence.reverse()
  81. if len(sequence) < 3:
  82. return None
  83. return linear_slope([row["oil_avg"] for row in sequence])
  84. def assign_hourly_cycles(rows: list[dict]):
  85. """Create oil-pressure cycles directly from valid running hours."""
  86. invalid = _dropout_invalid_hours(rows)
  87. grouped = defaultdict(list)
  88. for row in rows:
  89. if (
  90. row["运行覆盖率"] >= MIN_HOUR_COVERAGE
  91. and row["oil_avg"] is not None
  92. and row["oil_avg"] > 0
  93. and (row["批次"], row["_dt"]) not in invalid
  94. ):
  95. grouped[row["批次"]].append(row)
  96. for batch, series in grouped.items():
  97. series.sort(key=lambda row: row["_dt"])
  98. cycle_number = 0
  99. previous = None
  100. for row in series:
  101. gap_hours = None if previous is None else (row["_dt"] - previous["_dt"]).total_seconds() / 3600
  102. # A one-hour pressure recovery is not a restart. It can be a
  103. # sensor dropout or part of the same fault episode. Start a new
  104. # cycle only after a long gap in valid running hours.
  105. if previous is None or (gap_hours is not None and gap_hours > RESET_GAP_HOURS):
  106. cycle_number += 1
  107. row["_cycle_id"] = f"{row['机组']}-P{cycle_number:02d}"
  108. previous = row
  109. return grouped
  110. def classify_oil_hourly_segments(rows: list[dict], configs: dict[int, dict]):
  111. """Classify pressure by amplitude zone first, then confirm by duration.
  112. The configured low/low-low limits define the engineering zones. A short
  113. excursion is retained as mild; only a run of three or more consecutive
  114. valid hours in the low zone is confirmed abnormal, and the low-low zone is
  115. confirmed severe. No fixed 5/10/20 percent thresholds are used.
  116. """
  117. cycles = assign_hourly_cycles(rows)
  118. output = []
  119. for batch, batch_series in cycles.items():
  120. config = configs[batch]
  121. by_cycle = defaultdict(list)
  122. for row in batch_series:
  123. by_cycle[row["_cycle_id"]].append(row)
  124. for cycle_id, series in sorted(by_cycle.items(), key=lambda item: item[1][0]["_dt"]):
  125. series.sort(key=lambda item: item["_dt"])
  126. baseline_values = [row["oil_avg"] for row in series[:HOURLY_BASELINE_HOURS]]
  127. cycle_base = median_value(baseline_values)
  128. low = config["oil"]["low"]
  129. low_low = config["oil"]["low_low"]
  130. candidates = []
  131. history = []
  132. for row in series:
  133. current = row["oil_avg"]
  134. trend = _contiguous_slope(history, row)
  135. decline = consecutive_decline_hours(history, row)
  136. deviation = (cycle_base - current) / cycle_base if cycle_base else 0.0
  137. low_ratio = (row["oil_low_count"] or 0) / max(row["运行样本数"], 1)
  138. low_low_ratio = (row["oil_low_low_count"] or 0) / max(row["运行样本数"], 1)
  139. low_zone = current <= low
  140. low_low_zone = current <= low_low
  141. downward = (
  142. decline >= 3
  143. or (trend is not None and trend < -0.0005)
  144. )
  145. # Relative decline is a data-derived evolution signal. The
  146. # engineering alarm limits remain higher priority, while the
  147. # 5/10/20% bands describe progression before the alarm limit.
  148. severe_candidate = low_low_zone or (deviation >= 0.20 and downward)
  149. abnormal_candidate = low_zone or (deviation >= 0.10 and downward)
  150. mild_candidate = deviation >= 0.05 and downward
  151. if severe_candidate:
  152. raw = "严重异常"
  153. elif abnormal_candidate:
  154. raw = "异常"
  155. elif mild_candidate:
  156. raw = "轻微"
  157. else:
  158. raw = "正常"
  159. candidates.append({
  160. "row": row, "raw": raw, "trend": trend, "decline": decline,
  161. "deviation": deviation, "low_ratio": low_ratio, "low_low_ratio": low_low_ratio,
  162. "low_zone": low_zone, "low_low_zone": low_low_zone,
  163. })
  164. history.append(row)
  165. index = 0
  166. while index < len(candidates):
  167. raw = candidates[index]["raw"]
  168. if raw == "正常":
  169. final = "正常"
  170. end = index
  171. else:
  172. end = index
  173. while end + 1 < len(candidates) and candidates[end + 1]["raw"] == raw:
  174. end += 1
  175. run_length = end - index + 1
  176. # Alarm limits have highest priority for the hourly
  177. # candidate and immediate alarm fields. Long-term stage
  178. # labels still require persistence: a one/two-hour
  179. # low-low excursion is a short severe warning, not a
  180. # confirmed long-term severe stage.
  181. if run_length >= OIL_CONFIRM_HOURS:
  182. final = raw
  183. elif raw == "严重异常":
  184. final = "正常"
  185. else:
  186. final = "轻微"
  187. for item in candidates[index:end + 1]:
  188. row = item["row"]
  189. level_reason = {
  190. "轻微": "相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时",
  191. "异常": "相对周期基线下降至少10%并持续下降,或低报警区间连续达到3小时",
  192. "严重异常": "相对周期基线下降至少20%并持续下降,或低低报警区间连续达到3小时",
  193. "正常": "未形成长期确认等级" if raw != "正常" else "未达到压力异常条件",
  194. }[final]
  195. if item["low_low_zone"]:
  196. level_reason += f";低低报警样本比例{item['low_low_ratio']:.1%}"
  197. elif item["low_zone"]:
  198. level_reason += f";低报警样本比例{item['low_ratio']:.1%}"
  199. output.append({
  200. "机组": config["unit"], "批次": batch, "周期编号": cycle_id,
  201. "小时": row["小时"], "运行样本数": row["运行样本数"], "运行覆盖率": row["运行覆盖率"],
  202. "小时油压均值": row["oil_avg"], "小时油压最小值": row["oil_min"], "小时油压最大值": row["oil_max"],
  203. "周期基线油压": cycle_base, "近期24有效小时基线油压": "",
  204. "相对周期基线变化": (row["oil_avg"] / cycle_base - 1) if cycle_base else None,
  205. "相对近期基线变化": "", "近6有效小时油压斜率": item["trend"],
  206. "连续下降有效小时数": item["decline"], "历史有效运行小时数": index,
  207. "低报警样本比例": item["low_ratio"], "低低报警样本比例": item["low_low_ratio"],
  208. "低报警阈值": low, "低低报警阈值": low_low,
  209. "是否低报警小时": "是" if item["low_zone"] else "否",
  210. "是否低低报警小时": "是" if item["low_low_zone"] else "否",
  211. "低低报警连续小时数": 0,
  212. "压力等级": final, "原始小时等级": raw,
  213. "立即报警": "严重预警" if item["low_low_zone"] else "低压预警" if item["low_zone"] else "",
  214. "正常波动标记": "是" if raw != "正常" and final == "轻微" else "否",
  215. "是否形成确认等级": "是" if final in ("异常", "严重异常") else "否",
  216. "小时判定依据": f"油压{row['oil_avg']:.3f}MPa,周期基线{cycle_base:.3f}MPa,"
  217. f"相对周期基线变化{((row['oil_avg'] / cycle_base - 1) if cycle_base else 0):+.1%},"
  218. f"{level_reason}",
  219. "_dt": row["_dt"],
  220. })
  221. index = end + 1
  222. return sorted(output, key=lambda row: (row["批次"], row["_dt"]))
  223. def build_oil_hour_stage_segments(hourly_levels: list[dict]):
  224. """Merge confirmed levels and short unconfirmed triggers into segments."""
  225. grouped = defaultdict(list)
  226. for row in hourly_levels:
  227. if row["压力等级"] in ("轻微", "异常", "严重异常"):
  228. level = row["压力等级"]
  229. elif row["压力等级"] == "轻微" and row.get("是否形成确认等级") == "否":
  230. level = "轻微"
  231. else:
  232. continue
  233. grouped[(row["批次"], row["周期编号"], level)].append(row)
  234. segments = []
  235. for (batch, cycle_id, level), series in grouped.items():
  236. series.sort(key=lambda row: row["_dt"])
  237. current = []
  238. for row in series:
  239. if not current or (
  240. row["_dt"] - current[-1]["_dt"] <= timedelta(hours=OIL_STAGE_MERGE_GAP_HOURS)
  241. and row["压力等级"] == current[-1]["压力等级"]
  242. ):
  243. current.append(row)
  244. else:
  245. segments.append(_build_oil_hour_stage_segment(current, level))
  246. current = [row]
  247. if current:
  248. segments.append(_build_oil_hour_stage_segment(current, level))
  249. return sorted(segments, key=lambda row: (row["批次"], row["开始小时"]))
  250. def extend_oil_segments_with_precursor(hourly_levels: list[dict], segments: list[dict]) -> list[dict]:
  251. """Move a mild stage start earlier when a confirmed slow decline precedes it."""
  252. by_cycle = defaultdict(list)
  253. for row in hourly_levels:
  254. if row["压力等级"] != "数据不足":
  255. by_cycle[(row["批次"], row["周期编号"])].append(row)
  256. for rows in by_cycle.values():
  257. rows.sort(key=lambda row: row["_dt"])
  258. result = []
  259. for segment in segments:
  260. if segment["压力阶段"] != "轻微":
  261. result.append(segment)
  262. continue
  263. rows = by_cycle.get((segment["批次"], segment["周期编号"]), [])
  264. start_index = next(
  265. (index for index, row in enumerate(rows) if row["小时"] == segment["开始小时"]),
  266. None,
  267. )
  268. end_index = next(
  269. (index for index, row in enumerate(rows) if row["小时"] == segment["结束小时"]),
  270. None,
  271. )
  272. if start_index is None or end_index is None or start_index == 0:
  273. result.append(segment)
  274. continue
  275. extension = []
  276. anchor = rows[start_index]
  277. index = start_index - 1
  278. while index >= 0:
  279. row = rows[index]
  280. deviation = row["相对周期基线变化"]
  281. decline = row["连续下降有效小时数"]
  282. slope = row["近6有效小时油压斜率"]
  283. if (anchor["_dt"] - row["_dt"]) != timedelta(hours=1):
  284. break
  285. if row["压力等级"] != "正常":
  286. break
  287. if deviation is None or not (0 <= -deviation < 0.05):
  288. break
  289. if decline is None or decline < OIL_PRECURSOR_DECLINE_HOURS:
  290. break
  291. if slope is None or slope >= OIL_PRECURSOR_SLOPE:
  292. break
  293. extension.insert(0, row)
  294. anchor = row
  295. index -= 1
  296. if not extension:
  297. result.append(segment)
  298. continue
  299. base_series = [
  300. row for row in rows[start_index:end_index + 1]
  301. if row["压力等级"] == segment["压力阶段"]
  302. ]
  303. extended = _build_oil_hour_stage_segment(extension + base_series, segment["压力阶段"])
  304. first = extension[0]
  305. extended["阶段触发依据"] = (
  306. f"前置下降:连续下降{first['连续下降有效小时数']}个有效小时,"
  307. f"6小时斜率{first['近6有效小时油压斜率']:.5f},相对周期基线下降不足5%;"
  308. + extended["阶段触发依据"]
  309. )
  310. result.append(extended)
  311. return sorted(result, key=lambda row: (row["批次"], row["开始小时"]))
  312. def filter_startup_recovery_segments(hourly_levels: list[dict], segments: list[dict]):
  313. """Remove early-cycle labels only when pressure later stabilises near baseline.
  314. A fixed post-startup exclusion window is deliberately not used: the label is
  315. removed only if at least ``OIL_STARTUP_STABLE_HOURS`` consecutive valid hours
  316. recover to ``OIL_STARTUP_RECOVERY_RATIO`` of the cycle baseline after the
  317. candidate segment. The transient hours are also returned so training can
  318. exclude them from the normal class.
  319. """
  320. by_cycle = defaultdict(list)
  321. for row in hourly_levels:
  322. if row["压力等级"] != "数据不足":
  323. by_cycle[(row["批次"], row["周期编号"])].append(row)
  324. for rows in by_cycle.values():
  325. rows.sort(key=lambda row: row["_dt"])
  326. removed = []
  327. excluded_hours: set[tuple[int, datetime]] = set()
  328. kept = []
  329. for segment in segments:
  330. key = (segment["批次"], segment["周期编号"])
  331. rows = by_cycle.get(key, [])
  332. if not rows:
  333. kept.append(segment)
  334. continue
  335. cycle_start = rows[0]["_dt"]
  336. start_dt = queries.parse_dt(segment["开始小时"])
  337. if (start_dt - cycle_start).total_seconds() / 3600 > OIL_STARTUP_CANDIDATE_HOURS:
  338. kept.append(segment)
  339. continue
  340. end_index = next(
  341. (index for index, row in enumerate(rows) if row["小时"] == segment["结束小时"]),
  342. None,
  343. )
  344. if end_index is None:
  345. kept.append(segment)
  346. continue
  347. baseline = segment.get("周期基线油压")
  348. baseline = float(baseline) if baseline not in (None, "") else None
  349. if not baseline or baseline <= 0:
  350. kept.append(segment)
  351. continue
  352. stable_start = None
  353. streak = 0
  354. stable_run_start = None
  355. previous_dt = rows[end_index]["_dt"]
  356. for row in rows[end_index + 1:]:
  357. if (row["_dt"] - cycle_start).total_seconds() / 3600 > OIL_STARTUP_OBSERVE_HOURS:
  358. break
  359. if row["_dt"] - previous_dt > timedelta(hours=1):
  360. streak = 0
  361. stable_run_start = None
  362. previous_dt = row["_dt"]
  363. pressure = row.get("小时油压均值")
  364. is_stable = (
  365. row.get("压力等级") == "正常"
  366. and pressure is not None
  367. and pressure / baseline >= OIL_STARTUP_RECOVERY_RATIO
  368. and row.get("是否低报警小时") != "是"
  369. )
  370. if is_stable:
  371. if streak == 0:
  372. stable_run_start = row["_dt"]
  373. streak += 1
  374. if streak >= OIL_STARTUP_STABLE_HOURS:
  375. stable_start = stable_run_start
  376. break
  377. else:
  378. streak = 0
  379. stable_run_start = None
  380. if stable_start is None:
  381. kept.append(segment)
  382. continue
  383. removed.append({
  384. **segment,
  385. "稳定恢复开始小时": stable_start.strftime("%Y-%m-%d %H:%M:%S"),
  386. "连续稳定小时数": streak,
  387. "开机恢复判定": (
  388. f"开机后{OIL_STARTUP_CANDIDATE_HOURS}小时内出现;"
  389. f"段后连续{streak}个有效小时恢复至周期基线"
  390. f"{OIL_STARTUP_RECOVERY_RATIO:.0%}以上且无低报警;"
  391. "判定为开机恢复瞬态,不纳入故障标签"
  392. ),
  393. })
  394. for row in rows:
  395. if row["_dt"] >= stable_start:
  396. break
  397. excluded_hours.add((row["批次"], row["_dt"]))
  398. return kept, removed, excluded_hours
  399. def _build_oil_hour_stage_segment(series: list[dict], level: str):
  400. first = series[0]
  401. last = series[-1]
  402. first_reason = series[0]["小时判定依据"]
  403. return {
  404. "机组": first["机组"],
  405. "批次": first["批次"],
  406. "周期编号": first["周期编号"],
  407. "压力阶段": level,
  408. "开始小时": first["小时"],
  409. "结束小时": last["小时"],
  410. "持续自然小时数": int((last["_dt"] - first["_dt"]).total_seconds() / 3600) + 1,
  411. "有效运行小时数": len(series),
  412. "周期基线油压": first["周期基线油压"],
  413. "正常波动带": first.get("正常波动带", ""),
  414. "阶段开始小时油压": first["小时油压均值"],
  415. "阶段结束小时油压": last["小时油压均值"],
  416. "阶段最低小时油压": min(row["小时油压均值"] for row in series),
  417. "阶段开始相对周期基线变化": first["相对周期基线变化"],
  418. "阶段结束相对周期基线变化": last["相对周期基线变化"],
  419. "阶段最大连续下降有效小时数": max(row["连续下降有效小时数"] for row in series),
  420. "阶段低报警小时数": sum(row["是否低报警小时"] == "是" for row in series),
  421. "阶段低低报警小时数": sum(row["是否低低报警小时"] == "是" for row in series),
  422. "阶段触发依据": (
  423. f"油压{first['小时油压均值']:.3f}~{last['小时油压均值']:.3f}MPa,"
  424. f"最低{min(row['小时油压均值'] for row in series):.3f}MPa,"
  425. f"连续{len(series)}个有效运行小时;{first_reason}"
  426. ),
  427. "是否形成确认等级": "是" if level != "轻微" or any(row.get("是否形成确认等级") == "是" for row in series) else "否",
  428. }
  429. def summarize_hourly_oil_cycles(hourly_levels: list[dict], hourly_segments: list[dict]):
  430. by_cycle = defaultdict(list)
  431. for row in hourly_levels:
  432. if row["压力等级"] != "数据不足":
  433. by_cycle[row["周期编号"]].append(row)
  434. segments_by_cycle = defaultdict(list)
  435. for segment in hourly_segments:
  436. segments_by_cycle[segment["周期编号"]].append(segment)
  437. result = []
  438. for cycle_id, series in sorted(by_cycle.items(), key=lambda item: item[1][0]["_dt"]):
  439. series.sort(key=lambda row: row["_dt"])
  440. segments = segments_by_cycle[cycle_id]
  441. levels = ["正常波动", "轻微", "异常", "严重异常"]
  442. fields = {
  443. "机组": series[0]["机组"], "批次": series[0]["批次"], "周期编号": cycle_id,
  444. "周期开始小时": series[0]["小时"], "周期结束小时": series[-1]["小时"],
  445. "周期有效运行小时数": len(series), "周期基线油压": series[0]["周期基线油压"],
  446. "正常波动带": series[0].get("正常波动带", ""),
  447. "阶段演化顺序": "→".join(segment["压力阶段"] for segment in segments),
  448. "周期最低小时油压": min(row["小时油压均值"] for row in series),
  449. "是否达到轻微": "是" if any(row["压力等级"] == "轻微" for row in series) else "否",
  450. "是否达到异常": "是" if any(row["压力等级"] == "异常" for row in series) else "否",
  451. "是否达到严重异常": "是" if any(row["压力等级"] == "严重异常" for row in series) else "否",
  452. "周期短时低压小时数": sum(row["立即报警"] != "" and row["压力等级"] == "正常" for row in series),
  453. "正常波动段数": sum(segment["压力阶段"] == "正常波动" for segment in segments),
  454. "正常波动小时数": sum(segment["有效运行小时数"] for segment in segments if segment["压力阶段"] == "正常波动"),
  455. }
  456. for level in levels:
  457. selected = [segment for segment in segments if segment["压力阶段"] == level]
  458. fields[f"{level}阶段开始小时"] = selected[0]["开始小时"] if selected else ""
  459. fields[f"{level}阶段结束小时"] = selected[-1]["结束小时"] if selected else ""
  460. fields[f"{level}阶段运行小时数"] = sum(segment["有效运行小时数"] for segment in selected)
  461. result.append(fields)
  462. return result
  463. HOURLY_LEVEL_FIELDS = [
  464. "机组", "批次", "周期编号", "小时", "运行样本数", "运行覆盖率", "小时油压均值",
  465. "小时油压最小值", "小时油压最大值", "周期基线油压", "近期24有效小时基线油压",
  466. "相对周期基线变化", "相对近期基线变化", "近6有效小时油压斜率", "连续下降有效小时数",
  467. "历史有效运行小时数", "低报警样本比例", "低低报警样本比例", "低报警阈值", "低低报警阈值",
  468. "是否低报警小时", "是否低低报警小时", "压力等级", "原始小时等级", "立即报警",
  469. "正常波动标记", "低低报警连续小时数", "是否纳入等级判定", "小时判定依据",
  470. ]
  471. HOUR_STAGE_FIELDS = [
  472. "机组", "批次", "周期编号", "压力阶段", "开始小时", "结束小时", "持续自然小时数",
  473. "有效运行小时数", "周期基线油压", "正常波动带", "阶段开始小时油压", "阶段结束小时油压", "阶段最低小时油压",
  474. "阶段开始相对周期基线变化", "阶段结束相对周期基线变化", "阶段最大连续下降有效小时数",
  475. "阶段低报警小时数", "阶段低低报警小时数", "是否形成确认等级", "阶段触发依据",
  476. ]
  477. CYCLE_FIELDS = [
  478. "机组", "批次", "周期编号", "周期开始小时", "周期结束小时", "周期有效运行小时数",
  479. "周期基线油压", "正常波动带", "阶段演化顺序", "周期最低小时油压", "是否达到轻微", "是否达到异常",
  480. "是否达到严重异常", "周期短时低压小时数", "正常波动段数", "正常波动小时数", "轻微阶段开始小时", "轻微阶段结束小时",
  481. "轻微阶段运行小时数", "异常阶段开始小时", "异常阶段结束小时", "异常阶段运行小时数",
  482. "严重异常阶段开始小时", "严重异常阶段结束小时", "严重异常阶段运行小时数",
  483. ]
  484. def run(rows: list[dict], configs: dict[int, dict], return_startup=False):
  485. """Return ``(hourly_levels, hour_segments, cycles)`` for pressure."""
  486. hourly_levels = classify_oil_hourly_segments(rows, configs)
  487. hour_segments = build_oil_hour_stage_segments(hourly_levels)
  488. hour_segments = extend_oil_segments_with_precursor(hourly_levels, hour_segments)
  489. hour_segments, removed, excluded = filter_startup_recovery_segments(hourly_levels, hour_segments)
  490. cycles = summarize_hourly_oil_cycles(hourly_levels, hour_segments)
  491. if return_startup:
  492. return hourly_levels, hour_segments, cycles, removed, excluded
  493. return hourly_levels, hour_segments, cycles
  494. def write_results(output_dir: Path, hourly_levels, hour_segments, cycles, extra_fields=()):
  495. output_dir = Path(output_dir)
  496. stage_fields = [field for field in HOUR_STAGE_FIELDS if field not in extra_fields] + list(extra_fields)
  497. io.write_csv(output_dir / "润滑油压力小时等级.csv", io.clean_rows(hourly_levels), HOURLY_LEVEL_FIELDS, {field: field for field in HOURLY_LEVEL_FIELDS})
  498. io.write_csv(output_dir / "润滑油压力小时阶段段.csv", io.clean_rows(hour_segments), stage_fields, {field: field for field in stage_fields})
  499. io.write_csv(output_dir / "润滑油压力演化周期.csv", io.clean_rows(cycles), CYCLE_FIELDS, {field: field for field in CYCLE_FIELDS})
  500. def write_startup_exclusions(output_dir: Path, hourly_levels, removed, excluded):
  501. output_dir = Path(output_dir)
  502. unit_by_batch = {row["批次"]: row["机组"] for row in hourly_levels}
  503. excluded_rows = [
  504. {
  505. "机组": unit_by_batch.get(batch, ""),
  506. "批次": batch,
  507. "小时": moment.strftime("%Y-%m-%d %H:%M:%S"),
  508. "排除原因": "开机恢复瞬态",
  509. }
  510. for batch, moment in sorted(excluded, key=lambda item: (item[0], item[1]))
  511. ]
  512. io.write_csv(
  513. output_dir / "压力开机恢复剔除小时.csv",
  514. excluded_rows,
  515. ["机组", "批次", "小时", "排除原因"],
  516. {field: field for field in ("机组", "批次", "小时", "排除原因")},
  517. )
  518. removed_fields = [
  519. "机组", "批次", "周期编号", "压力阶段", "开始小时", "结束小时",
  520. "周期基线油压", "稳定恢复开始小时", "连续稳定小时数", "开机恢复判定",
  521. ]
  522. io.write_csv(
  523. output_dir / "压力开机恢复剔除标签.csv",
  524. removed,
  525. removed_fields,
  526. {field: field for field in removed_fields},
  527. )
  528. def load_hourly(args, selected_batches, unit_by_batch, configs):
  529. if args.hourly_csv:
  530. selected_set = set(selected_batches)
  531. return [row for row in queries.load_hourly_csv(Path(args.hourly_csv)) if row["批次"] in selected_set]
  532. start, end = queries.parse_dt(args.start), queries.parse_dt(args.end)
  533. return queries.fetch_all_hourly(start, end, args.chunk_days, unit_by_batch, configs, selected_batches)
  534. def main() -> None:
  535. parser = argparse.ArgumentParser(description="PKS 润滑油压力演化算法(独立于振动)")
  536. parser.add_argument("--device", nargs="+", default=list(devices.DEVICE_CODES), help="压缩机编号,如 7# 8# 9#")
  537. parser.add_argument("--start", default="2025-01-01 00:00:00")
  538. parser.add_argument("--end", default="2026-09-01 00:00:00")
  539. parser.add_argument("--chunk-days", type=int, default=14)
  540. parser.add_argument("--hourly-csv", default=None)
  541. parser.add_argument("--output-dir", default=str(paths.PRESSURE_RESULTS))
  542. args = parser.parse_args()
  543. devices_selected = devices.normalize_devices(args.device)
  544. device_batches = devices.load_device_batches(devices_selected)
  545. unit_by_batch = devices.batch_to_unit(device_batches)
  546. configs = db.load_alarm_config(unit_by_batch)
  547. selected_batches = sorted(unit_by_batch)
  548. rows = load_hourly(args, selected_batches, unit_by_batch, configs)
  549. hourly_levels, hour_segments, cycles, removed, excluded = run(rows, configs, return_startup=True)
  550. output_dir = Path(args.output_dir)
  551. write_results(output_dir, hourly_levels, hour_segments, cycles)
  552. write_startup_exclusions(output_dir, hourly_levels, removed, excluded)
  553. print(f"设备:{devices_selected}")
  554. print(f"小时特征:{len(rows):,} 行")
  555. print(f"润滑油压力小时等级:{len(hourly_levels):,} 行")
  556. print(f"润滑油压力小时阶段段:{len(hour_segments):,} 段")
  557. print(f"开机恢复瞬态剔除:{len(removed)} 段 / {len(excluded)} 小时")
  558. print(f"润滑油压力周期:{len(cycles):,} 个")
  559. print(f"输出目录:{args.output_dir}")
  560. if __name__ == "__main__":
  561. main()