pressure_algorithm.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  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. RESET_GAP_HOURS,
  32. )
  33. from models.common.stats import consecutive_decline_hours, linear_slope, median_value
  34. def assign_hourly_cycles(rows: list[dict]):
  35. """Create oil-pressure cycles directly from valid running hours."""
  36. grouped = defaultdict(list)
  37. for row in rows:
  38. if row["运行覆盖率"] >= MIN_HOUR_COVERAGE and row["oil_avg"] is not None:
  39. grouped[row["批次"]].append(row)
  40. for batch, series in grouped.items():
  41. series.sort(key=lambda row: row["_dt"])
  42. cycle_number = 0
  43. previous = None
  44. for row in series:
  45. gap_hours = None if previous is None else (row["_dt"] - previous["_dt"]).total_seconds() / 3600
  46. # A one-hour pressure recovery is not a restart. It can be a
  47. # sensor dropout or part of the same fault episode. Start a new
  48. # cycle only after a long gap in valid running hours.
  49. if previous is None or (gap_hours is not None and gap_hours > RESET_GAP_HOURS):
  50. cycle_number += 1
  51. row["_cycle_id"] = f"{row['机组']}-P{cycle_number:02d}"
  52. previous = row
  53. return grouped
  54. def classify_oil_hourly_segments(rows: list[dict], configs: dict[int, dict]):
  55. """Classify pressure by amplitude zone first, then confirm by duration.
  56. The configured low/low-low limits define the engineering zones. A short
  57. excursion is retained as mild; only a run of three or more consecutive
  58. valid hours in the low zone is confirmed abnormal, and the low-low zone is
  59. confirmed severe. No fixed 5/10/20 percent thresholds are used.
  60. """
  61. cycles = assign_hourly_cycles(rows)
  62. output = []
  63. for batch, batch_series in cycles.items():
  64. config = configs[batch]
  65. by_cycle = defaultdict(list)
  66. for row in batch_series:
  67. by_cycle[row["_cycle_id"]].append(row)
  68. for cycle_id, series in sorted(by_cycle.items(), key=lambda item: item[1][0]["_dt"]):
  69. series.sort(key=lambda item: item["_dt"])
  70. baseline_values = [row["oil_avg"] for row in series[:HOURLY_BASELINE_HOURS]]
  71. cycle_base = median_value(baseline_values)
  72. low = config["oil"]["low"]
  73. low_low = config["oil"]["low_low"]
  74. candidates = []
  75. history = []
  76. for row in series:
  77. current = row["oil_avg"]
  78. trend = linear_slope([item["oil_avg"] for item in history[-5:]] + [current])
  79. decline = consecutive_decline_hours(history, row)
  80. deviation = (cycle_base - current) / cycle_base if cycle_base else 0.0
  81. low_ratio = (row["oil_low_count"] or 0) / max(row["运行样本数"], 1)
  82. low_low_ratio = (row["oil_low_low_count"] or 0) / max(row["运行样本数"], 1)
  83. low_zone = current <= low
  84. low_low_zone = current <= low_low
  85. downward = (
  86. decline >= 3
  87. or (trend is not None and trend < -0.0005)
  88. )
  89. # Relative decline is a data-derived evolution signal. The
  90. # engineering alarm limits remain higher priority, while the
  91. # 5/10/20% bands describe progression before the alarm limit.
  92. severe_candidate = low_low_zone or (deviation >= 0.20 and downward)
  93. abnormal_candidate = low_zone or (deviation >= 0.10 and downward)
  94. mild_candidate = deviation >= 0.05 and downward
  95. if severe_candidate:
  96. raw = "严重异常"
  97. elif abnormal_candidate:
  98. raw = "异常"
  99. elif mild_candidate:
  100. raw = "轻微"
  101. else:
  102. raw = "正常"
  103. candidates.append({
  104. "row": row, "raw": raw, "trend": trend, "decline": decline,
  105. "deviation": deviation, "low_ratio": low_ratio, "low_low_ratio": low_low_ratio,
  106. "low_zone": low_zone, "low_low_zone": low_low_zone,
  107. })
  108. history.append(row)
  109. index = 0
  110. while index < len(candidates):
  111. raw = candidates[index]["raw"]
  112. if raw == "正常":
  113. final = "正常"
  114. end = index
  115. else:
  116. end = index
  117. while end + 1 < len(candidates) and candidates[end + 1]["raw"] == raw:
  118. end += 1
  119. run_length = end - index + 1
  120. # Alarm limits have highest priority for the hourly
  121. # candidate and immediate alarm fields. Long-term stage
  122. # labels still require persistence: a one/two-hour
  123. # low-low excursion is a short severe warning, not a
  124. # confirmed long-term severe stage.
  125. if run_length >= OIL_CONFIRM_HOURS:
  126. final = raw
  127. elif raw == "严重异常":
  128. final = "正常"
  129. else:
  130. final = "轻微"
  131. for item in candidates[index:end + 1]:
  132. row = item["row"]
  133. level_reason = {
  134. "轻微": "相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时",
  135. "异常": "相对周期基线下降至少10%并持续下降,或低报警区间连续达到3小时",
  136. "严重异常": "相对周期基线下降至少20%并持续下降,或低低报警区间连续达到3小时",
  137. "正常": "未形成长期确认等级" if raw != "正常" else "未达到压力异常条件",
  138. }[final]
  139. if item["low_low_zone"]:
  140. level_reason += f";低低报警样本比例{item['low_low_ratio']:.1%}"
  141. elif item["low_zone"]:
  142. level_reason += f";低报警样本比例{item['low_ratio']:.1%}"
  143. output.append({
  144. "机组": config["unit"], "批次": batch, "周期编号": cycle_id,
  145. "小时": row["小时"], "运行样本数": row["运行样本数"], "运行覆盖率": row["运行覆盖率"],
  146. "小时油压均值": row["oil_avg"], "小时油压最小值": row["oil_min"], "小时油压最大值": row["oil_max"],
  147. "周期基线油压": cycle_base, "近期24有效小时基线油压": "",
  148. "相对周期基线变化": (row["oil_avg"] / cycle_base - 1) if cycle_base else None,
  149. "相对近期基线变化": "", "近6有效小时油压斜率": item["trend"],
  150. "连续下降有效小时数": item["decline"], "历史有效运行小时数": index,
  151. "低报警样本比例": item["low_ratio"], "低低报警样本比例": item["low_low_ratio"],
  152. "低报警阈值": low, "低低报警阈值": low_low,
  153. "是否低报警小时": "是" if item["low_zone"] else "否",
  154. "是否低低报警小时": "是" if item["low_low_zone"] else "否",
  155. "低低报警连续小时数": 0,
  156. "压力等级": final, "原始小时等级": raw,
  157. "立即报警": "严重预警" if item["low_low_zone"] else "低压预警" if item["low_zone"] else "",
  158. "正常波动标记": "是" if raw != "正常" and final == "轻微" else "否",
  159. "是否形成确认等级": "是" if final in ("异常", "严重异常") else "否",
  160. "小时判定依据": f"油压{row['oil_avg']:.3f}MPa,周期基线{cycle_base:.3f}MPa,"
  161. f"相对周期基线变化{((row['oil_avg'] / cycle_base - 1) if cycle_base else 0):+.1%},"
  162. f"{level_reason}",
  163. "_dt": row["_dt"],
  164. })
  165. index = end + 1
  166. return sorted(output, key=lambda row: (row["批次"], row["_dt"]))
  167. def build_oil_hour_stage_segments(hourly_levels: list[dict]):
  168. """Merge confirmed levels and short unconfirmed triggers into segments."""
  169. grouped = defaultdict(list)
  170. for row in hourly_levels:
  171. if row["压力等级"] in ("轻微", "异常", "严重异常"):
  172. level = row["压力等级"]
  173. elif row["压力等级"] == "轻微" and row.get("是否形成确认等级") == "否":
  174. level = "轻微"
  175. else:
  176. continue
  177. grouped[(row["批次"], row["周期编号"], level)].append(row)
  178. segments = []
  179. for (batch, cycle_id, level), series in grouped.items():
  180. series.sort(key=lambda row: row["_dt"])
  181. current = []
  182. for row in series:
  183. if not current or (
  184. row["_dt"] - current[-1]["_dt"] <= timedelta(hours=OIL_STAGE_MERGE_GAP_HOURS)
  185. and row["压力等级"] == current[-1]["压力等级"]
  186. ):
  187. current.append(row)
  188. else:
  189. segments.append(_build_oil_hour_stage_segment(current, level))
  190. current = [row]
  191. if current:
  192. segments.append(_build_oil_hour_stage_segment(current, level))
  193. return sorted(segments, key=lambda row: (row["批次"], row["开始小时"]))
  194. def _build_oil_hour_stage_segment(series: list[dict], level: str):
  195. first = series[0]
  196. last = series[-1]
  197. first_reason = series[0]["小时判定依据"]
  198. return {
  199. "机组": first["机组"],
  200. "批次": first["批次"],
  201. "周期编号": first["周期编号"],
  202. "压力阶段": level,
  203. "开始小时": first["小时"],
  204. "结束小时": last["小时"],
  205. "持续自然小时数": int((last["_dt"] - first["_dt"]).total_seconds() / 3600) + 1,
  206. "有效运行小时数": len(series),
  207. "周期基线油压": first["周期基线油压"],
  208. "正常波动带": first.get("正常波动带", ""),
  209. "阶段开始小时油压": first["小时油压均值"],
  210. "阶段结束小时油压": last["小时油压均值"],
  211. "阶段最低小时油压": min(row["小时油压均值"] for row in series),
  212. "阶段开始相对周期基线变化": first["相对周期基线变化"],
  213. "阶段结束相对周期基线变化": last["相对周期基线变化"],
  214. "阶段最大连续下降有效小时数": max(row["连续下降有效小时数"] for row in series),
  215. "阶段低报警小时数": sum(row["是否低报警小时"] == "是" for row in series),
  216. "阶段低低报警小时数": sum(row["是否低低报警小时"] == "是" for row in series),
  217. "阶段触发依据": (
  218. f"油压{first['小时油压均值']:.3f}~{last['小时油压均值']:.3f}MPa,"
  219. f"最低{min(row['小时油压均值'] for row in series):.3f}MPa,"
  220. f"连续{len(series)}个有效运行小时;{first_reason}"
  221. ),
  222. "是否形成确认等级": "是" if level != "轻微" or any(row.get("是否形成确认等级") == "是" for row in series) else "否",
  223. }
  224. def summarize_hourly_oil_cycles(hourly_levels: list[dict], hourly_segments: list[dict]):
  225. by_cycle = defaultdict(list)
  226. for row in hourly_levels:
  227. if row["压力等级"] != "数据不足":
  228. by_cycle[row["周期编号"]].append(row)
  229. segments_by_cycle = defaultdict(list)
  230. for segment in hourly_segments:
  231. segments_by_cycle[segment["周期编号"]].append(segment)
  232. result = []
  233. for cycle_id, series in sorted(by_cycle.items(), key=lambda item: item[1][0]["_dt"]):
  234. series.sort(key=lambda row: row["_dt"])
  235. segments = segments_by_cycle[cycle_id]
  236. levels = ["正常波动", "轻微", "异常", "严重异常"]
  237. fields = {
  238. "机组": series[0]["机组"], "批次": series[0]["批次"], "周期编号": cycle_id,
  239. "周期开始小时": series[0]["小时"], "周期结束小时": series[-1]["小时"],
  240. "周期有效运行小时数": len(series), "周期基线油压": series[0]["周期基线油压"],
  241. "正常波动带": series[0].get("正常波动带", ""),
  242. "阶段演化顺序": "→".join(segment["压力阶段"] for segment in segments),
  243. "周期最低小时油压": min(row["小时油压均值"] for row in series),
  244. "是否达到轻微": "是" if any(row["压力等级"] == "轻微" for row in series) else "否",
  245. "是否达到异常": "是" if any(row["压力等级"] == "异常" for row in series) else "否",
  246. "是否达到严重异常": "是" if any(row["压力等级"] == "严重异常" for row in series) else "否",
  247. "周期短时低压小时数": sum(row["立即报警"] != "" and row["压力等级"] == "正常" for row in series),
  248. "正常波动段数": sum(segment["压力阶段"] == "正常波动" for segment in segments),
  249. "正常波动小时数": sum(segment["有效运行小时数"] for segment in segments if segment["压力阶段"] == "正常波动"),
  250. }
  251. for level in levels:
  252. selected = [segment for segment in segments if segment["压力阶段"] == level]
  253. fields[f"{level}阶段开始小时"] = selected[0]["开始小时"] if selected else ""
  254. fields[f"{level}阶段结束小时"] = selected[-1]["结束小时"] if selected else ""
  255. fields[f"{level}阶段运行小时数"] = sum(segment["有效运行小时数"] for segment in selected)
  256. result.append(fields)
  257. return result
  258. HOURLY_LEVEL_FIELDS = [
  259. "机组", "批次", "周期编号", "小时", "运行样本数", "运行覆盖率", "小时油压均值",
  260. "小时油压最小值", "小时油压最大值", "周期基线油压", "近期24有效小时基线油压",
  261. "相对周期基线变化", "相对近期基线变化", "近6有效小时油压斜率", "连续下降有效小时数",
  262. "历史有效运行小时数", "低报警样本比例", "低低报警样本比例", "低报警阈值", "低低报警阈值",
  263. "是否低报警小时", "是否低低报警小时", "压力等级", "原始小时等级", "立即报警",
  264. "正常波动标记", "低低报警连续小时数", "是否纳入等级判定", "小时判定依据",
  265. ]
  266. HOUR_STAGE_FIELDS = [
  267. "机组", "批次", "周期编号", "压力阶段", "开始小时", "结束小时", "持续自然小时数",
  268. "有效运行小时数", "周期基线油压", "正常波动带", "阶段开始小时油压", "阶段结束小时油压", "阶段最低小时油压",
  269. "阶段开始相对周期基线变化", "阶段结束相对周期基线变化", "阶段最大连续下降有效小时数",
  270. "阶段低报警小时数", "阶段低低报警小时数", "是否形成确认等级", "阶段触发依据",
  271. ]
  272. CYCLE_FIELDS = [
  273. "机组", "批次", "周期编号", "周期开始小时", "周期结束小时", "周期有效运行小时数",
  274. "周期基线油压", "正常波动带", "阶段演化顺序", "周期最低小时油压", "是否达到轻微", "是否达到异常",
  275. "是否达到严重异常", "周期短时低压小时数", "正常波动段数", "正常波动小时数", "轻微阶段开始小时", "轻微阶段结束小时",
  276. "轻微阶段运行小时数", "异常阶段开始小时", "异常阶段结束小时", "异常阶段运行小时数",
  277. "严重异常阶段开始小时", "严重异常阶段结束小时", "严重异常阶段运行小时数",
  278. ]
  279. def run(rows: list[dict], configs: dict[int, dict]):
  280. """Return ``(hourly_levels, hour_segments, cycles)`` for pressure."""
  281. hourly_levels = classify_oil_hourly_segments(rows, configs)
  282. hour_segments = build_oil_hour_stage_segments(hourly_levels)
  283. cycles = summarize_hourly_oil_cycles(hourly_levels, hour_segments)
  284. return hourly_levels, hour_segments, cycles
  285. def write_results(output_dir: Path, hourly_levels, hour_segments, cycles):
  286. output_dir = Path(output_dir)
  287. io.write_csv(output_dir / "润滑油压力小时等级.csv", io.clean_rows(hourly_levels), HOURLY_LEVEL_FIELDS, {field: field for field in HOURLY_LEVEL_FIELDS})
  288. io.write_csv(output_dir / "润滑油压力小时阶段段.csv", io.clean_rows(hour_segments), HOUR_STAGE_FIELDS, {field: field for field in HOUR_STAGE_FIELDS})
  289. io.write_csv(output_dir / "润滑油压力演化周期.csv", io.clean_rows(cycles), CYCLE_FIELDS, {field: field for field in CYCLE_FIELDS})
  290. def load_hourly(args, selected_batches, unit_by_batch, configs):
  291. if args.hourly_csv:
  292. selected_set = set(selected_batches)
  293. return [row for row in queries.load_hourly_csv(Path(args.hourly_csv)) if row["批次"] in selected_set]
  294. start, end = queries.parse_dt(args.start), queries.parse_dt(args.end)
  295. return queries.fetch_all_hourly(start, end, args.chunk_days, unit_by_batch, configs, selected_batches)
  296. def main() -> None:
  297. parser = argparse.ArgumentParser(description="PKS 润滑油压力演化算法(独立于振动)")
  298. parser.add_argument("--device", nargs="+", default=list(devices.DEVICE_CODES), help="压缩机编号,如 7# 8# 9#")
  299. parser.add_argument("--start", default="2025-01-01 00:00:00")
  300. parser.add_argument("--end", default="2026-09-01 00:00:00")
  301. parser.add_argument("--chunk-days", type=int, default=14)
  302. parser.add_argument("--hourly-csv", default=None)
  303. parser.add_argument("--output-dir", default=str(paths.PRESSURE_RESULTS))
  304. args = parser.parse_args()
  305. devices_selected = devices.normalize_devices(args.device)
  306. device_batches = devices.load_device_batches(devices_selected)
  307. unit_by_batch = devices.batch_to_unit(device_batches)
  308. configs = db.load_alarm_config(unit_by_batch)
  309. selected_batches = sorted(unit_by_batch)
  310. rows = load_hourly(args, selected_batches, unit_by_batch, configs)
  311. hourly_levels, hour_segments, cycles = run(rows, configs)
  312. write_results(Path(args.output_dir), hourly_levels, hour_segments, cycles)
  313. print(f"设备:{devices_selected}")
  314. print(f"小时特征:{len(rows):,} 行")
  315. print(f"润滑油压力小时等级:{len(hourly_levels):,} 行")
  316. print(f"润滑油压力小时阶段段:{len(hour_segments):,} 段")
  317. print(f"润滑油压力周期:{len(cycles):,} 个")
  318. print(f"输出目录:{args.output_dir}")
  319. if __name__ == "__main__":
  320. main()