local_evolution_analysis.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. #!/usr/bin/env python3
  2. """Find directional 10-degree local feature evolution around repair events."""
  3. from __future__ import annotations
  4. import argparse
  5. import csv
  6. import math
  7. import sys
  8. from collections import defaultdict
  9. from datetime import date, datetime
  10. from pathlib import Path
  11. import numpy as np
  12. BACKEND = Path(__file__).resolve().parents[1]
  13. sys.path.insert(0, str(BACKEND))
  14. from indicator_features.extract_from_db import ( # noqa: E402
  15. connect, fetch_cycles, fetch_file_rows, point_name, sample_per_day,
  16. )
  17. from indicator_features.indicator_features import _plateau_levels, smooth # noqa: E402
  18. CYLINDERS = ("一", "二", "三", "四", "五", "六")
  19. SIDES = ("盖侧", "轴侧")
  20. METRICS = ("p50", "slope", "std", "iqr", "d_rms", "d2_abs_max")
  21. METRIC_CN = {
  22. "p50": "局部中位压力", "slope": "局部斜率", "std": "标准差",
  23. "iqr": "四分位距", "d_rms": "导数RMS", "d2_abs_max": "最大二阶导",
  24. }
  25. FAMILY = {"p50": "位置", "slope": "斜率", "std": "波动", "iqr": "波动",
  26. "d_rms": "导数", "d2_abs_max": "导数"}
  27. BAD = {
  28. "4": {("二", "盖侧"), ("四", "盖侧"), ("六", "盖侧")},
  29. "6": {("二", "盖侧"), ("四", "盖侧"), ("六", "盖侧")},
  30. "7": {("四", "盖侧"), ("六", "盖侧")},
  31. }
  32. EVENTS = [
  33. {
  34. "id": "7号_2025维修过程", "unit": "7", "label": "7号2025停机维修过程",
  35. "start": "2025-04-29", "end": "2026-02-17", "event": "2025-08-08",
  36. "pre": [("2025-04-29", "2025-05-04"), ("2025-06-11", "2025-06-14"), ("2025-07-25", "2025-07-28")],
  37. "pre_segments": [("2025-04-29", "2025-05-04"), ("2025-06-11", "2025-06-14"), ("2025-07-25", "2025-07-28")],
  38. "early": [("2025-04-29", "2025-05-04")], "late": [("2025-07-25", "2025-07-28")],
  39. "post_base": [("2025-11-19", "2025-11-21")],
  40. "post_check": [("2026-02-10", "2026-02-17")],
  41. "mode": "跨运行段", "markers": "2025-08-08;2025-10-07",
  42. },
  43. {
  44. "id": "7号_2026-05-21", "unit": "7", "label": "7号2026-05-21气阀事件",
  45. "start": "2026-02-10", "end": "2026-06-07", "event": "2026-05-21",
  46. "pre": [("2026-05-06", "2026-05-21")],
  47. "pre_segments": [("2026-02-10", "2026-02-17"), ("2026-03-12", "2026-04-05"), ("2026-05-06", "2026-05-21")],
  48. "early": [("2026-05-06", "2026-05-09")], "late": [("2026-05-18", "2026-05-21")],
  49. "post_base": [("2026-05-31", "2026-06-04")],
  50. "post_check": [("2026-06-04", "2026-06-07")],
  51. "mode": "连续运行段", "markers": "2026-05-21",
  52. },
  53. {
  54. "id": "4号_2026-06-10", "unit": "4", "label": "4号2026-06-10气阀事件",
  55. "start": "2026-04-21", "end": "2026-07-31", "event": "2026-06-10",
  56. "pre": [("2026-05-29", "2026-05-31"), ("2026-06-06", "2026-06-10")],
  57. "pre_segments": [("2026-04-21", "2026-05-02"), ("2026-05-29", "2026-05-31"), ("2026-06-06", "2026-06-10")],
  58. "early": [("2026-06-06", "2026-06-08")], "late": [("2026-06-08", "2026-06-10")],
  59. "post_base": [("2026-06-11", "2026-06-15")],
  60. "post_check": [("2026-06-21", "2026-06-22"), ("2026-06-30", "2026-07-08"),
  61. ("2026-07-11", "2026-07-14"), ("2026-07-22", "2026-07-31")],
  62. "mode": "短运行段", "markers": "2026-06-10",
  63. },
  64. {
  65. "id": "6号_2026-04-22_26", "unit": "6", "label": "6号2026-04-22~26气阀维修过程",
  66. "start": "2026-04-12", "end": "2026-08-01", "event": "2026-04-22",
  67. "pre": [("2026-04-12", "2026-04-22")],
  68. "pre_segments": [("2026-04-12", "2026-04-22")],
  69. "early": [("2026-04-12", "2026-04-15")], "late": [("2026-04-19", "2026-04-22")],
  70. "post_base": [("2026-05-10", "2026-05-17")],
  71. "post_check": [("2026-05-17", "2026-05-31"), ("2026-06-21", "2026-07-02"),
  72. ("2026-07-24", "2026-08-01")],
  73. "mode": "连续运行段", "markers": "2026-04-22;2026-04-26",
  74. },
  75. ]
  76. def in_ranges(day: str, ranges) -> bool:
  77. return any(start <= day < end for start, end in ranges)
  78. def robust_scale(values) -> float:
  79. values = np.asarray(values, dtype=float)
  80. if values.size == 0:
  81. return 0.0
  82. scale = float(np.median(np.abs(values - np.median(values))) * 1.4826)
  83. return scale if scale > 1e-12 else float(np.std(values))
  84. def rankdata(values):
  85. values = np.asarray(values)
  86. order = np.argsort(values, kind="mergesort")
  87. ranks = np.empty(len(values), dtype=float)
  88. start = 0
  89. while start < len(values):
  90. end = start + 1
  91. while end < len(values) and values[order[end]] == values[order[start]]:
  92. end += 1
  93. ranks[order[start:end]] = (start + end - 1) / 2
  94. start = end
  95. return ranks
  96. def spearman(values) -> float:
  97. if len(values) < 3:
  98. return float("nan")
  99. x = np.arange(len(values), dtype=float)
  100. y = rankdata(values)
  101. if np.std(y) <= 1e-12:
  102. return 0.0
  103. return float(np.corrcoef(x, y)[0, 1])
  104. def normalized_bins(signal):
  105. signal = np.asarray(signal, dtype=float)
  106. signal = signal[np.isfinite(signal)]
  107. if len(signal) < 60:
  108. return None
  109. pressure = smooth(signal, 21)
  110. suction, discharge, _ = _plateau_levels(pressure)
  111. span = discharge - suction
  112. if not np.isfinite(span) or span < .3:
  113. return None
  114. normalized = (pressure - suction) / span
  115. angles = np.arange(len(normalized), dtype=float) * 360 / len(normalized)
  116. result = []
  117. for index in range(36):
  118. mask = (angles >= index * 10) & (angles < (index + 1) * 10)
  119. x, y = angles[mask], normalized[mask]
  120. if len(y) < 5:
  121. return None
  122. slope = float(np.polyfit(x, y, 1)[0])
  123. dy = np.diff(y)
  124. d2 = np.diff(dy)
  125. result.append({
  126. "bin": index, "bin_start": index * 10,
  127. "p50": float(np.median(y)), "slope": slope, "std": float(np.std(y)),
  128. "iqr": float(np.percentile(y, 75) - np.percentile(y, 25)),
  129. "d_rms": float(np.sqrt(np.mean(dy ** 2))),
  130. "d2_abs_max": float(np.max(np.abs(d2))) if len(d2) else 0.0,
  131. })
  132. return result
  133. def extract(output: Path, per_day: int):
  134. grouped = defaultdict(list)
  135. connection = connect()
  136. fields = ["事件", "机组", "测点", "缸", "腔", "文件ID", "采样时间", "日期", "角度箱"] + list(METRICS)
  137. output.parent.mkdir(parents=True, exist_ok=True)
  138. with output.open("w", encoding="utf-8-sig", newline="") as handle:
  139. writer = csv.DictWriter(handle, fieldnames=fields)
  140. writer.writeheader()
  141. try:
  142. for event in EVENTS:
  143. periods = 0
  144. for cylinder in CYLINDERS:
  145. for side in SIDES:
  146. if (cylinder, side) in BAD[event["unit"]]:
  147. continue
  148. files = fetch_file_rows(connection, point_name(event["unit"], cylinder, side), event["start"], event["end"])
  149. picked = sample_per_day(files, per_day)
  150. cycles = fetch_cycles(connection, [int(row["id"]) for row in picked], 100) if picked else {}
  151. for row in picked:
  152. features = normalized_bins(cycles.get(int(row["id"]), np.array([])))
  153. if features is None:
  154. continue
  155. periods += 1
  156. day = str(row["sample_time"])[:10]
  157. point = f"{cylinder}缸{side}"
  158. for item in features:
  159. record = {
  160. "事件": event["id"], "机组": event["unit"], "测点": point,
  161. "缸": cylinder, "腔": side, "文件ID": int(row["id"]),
  162. "采样时间": str(row["sample_time"]), "日期": day,
  163. "角度箱": item["bin_start"], **{metric: item[metric] for metric in METRICS},
  164. }
  165. writer.writerow(record)
  166. for metric in METRICS:
  167. grouped[(event["id"], point, day, item["bin_start"], metric)].append(item[metric])
  168. print(f"{event['label']}: 有效周期 {periods:,}", flush=True)
  169. finally:
  170. connection.close()
  171. return grouped
  172. def load_extracted(path: Path):
  173. grouped = defaultdict(list)
  174. with path.open(encoding="utf-8-sig", newline="") as handle:
  175. for row in csv.DictReader(handle):
  176. for metric in METRICS:
  177. grouped[(row["事件"], row["测点"], row["日期"], int(row["角度箱"]), metric)].append(float(row[metric]))
  178. return grouped
  179. def baseline_stats(series, ranges):
  180. daily_values = [daily for day, daily, _ in series if in_ranges(day, ranges)]
  181. cycle_values = [value for day, _, values in series if in_ranges(day, ranges) for value in values]
  182. center = float(np.median(daily_values)) if daily_values else float("nan")
  183. # Keep dates equal-weighted for the center, but estimate natural variation
  184. # from cycles so a 2-3 day baseline cannot produce near-zero scales.
  185. scale = robust_scale(cycle_values)
  186. return center, scale, len(daily_values), len(cycle_values)
  187. def merge_bands(candidates):
  188. grouped = defaultdict(list)
  189. for row in candidates:
  190. if row["可信等级"] == "低":
  191. continue
  192. start = int(row["角度范围"].split("-")[0])
  193. grouped[(row["事件"], row["测点"], row["故障主方向"], row["可信等级"])].append((start, row))
  194. bands = []
  195. for (event, point, direction, confidence), values in grouped.items():
  196. values.sort()
  197. runs = []
  198. for start, row in values:
  199. if runs and start == runs[-1][-1][0] + 10:
  200. runs[-1].append((start, row))
  201. else:
  202. runs.append([(start, row)])
  203. # Merge the circular 350-360 and 0-10 runs.
  204. if len(runs) > 1 and runs[0][0][0] == 0 and runs[-1][-1][0] == 350:
  205. runs[0] = runs[-1] + runs[0]
  206. runs.pop()
  207. for run in runs:
  208. starts = [item[0] for item in run]
  209. rows = [item[1] for item in run]
  210. if starts[0] > starts[-1]:
  211. band = f"{starts[0]}-360°及0-{starts[-1] + 10}°"
  212. else:
  213. band = f"{starts[0]}-{starts[-1] + 10}°"
  214. bands.append({
  215. "事件": event, "测点": point, "角度带": band, "故障主方向": direction,
  216. "包含10度箱数": len(rows), "可信等级": confidence,
  217. "最高评分": max(row["候选评分"] for row in rows),
  218. "演化指标数": max(row["演化指标数"] for row in rows),
  219. "长期演化指标数": max(row["长期演化指标数"] for row in rows),
  220. "恢复指标数": max(row["恢复指标数"] for row in rows),
  221. "长期稳定恢复指标数": max(row["长期稳定恢复指标数"] for row in rows),
  222. "长期恢复比例平均": max((row["长期恢复比例平均"] for row in rows
  223. if row["长期恢复比例平均"] != ""), default=""),
  224. "局部幅度平均Z": max(row["局部幅度平均Z"] for row in rows),
  225. "局部幅度综合RMS": max(row["局部幅度综合RMS"] for row in rows),
  226. "最早持续日期": min((row["最早持续日期"] for row in rows if row["最早持续日期"]), default=""),
  227. "代表指标": max(rows, key=lambda row: row["候选评分"])["指标摘要"],
  228. })
  229. bands.sort(key=lambda row: (row["事件"], -row["最高评分"], -row["包含10度箱数"]))
  230. return bands
  231. def analyze(grouped, daily_output: Path, candidate_output: Path, repeat_output: Path, band_output: Path,
  232. segment_output: Path):
  233. daily_raw = {key: float(np.median(values)) for key, values in grouped.items()}
  234. counts = {key: len(values) for key, values in grouped.items()}
  235. event_by_id = {event["id"]: event for event in EVENTS}
  236. points_by_event = defaultdict(set)
  237. for event_id, point, _, _, _ in daily_raw:
  238. points_by_event[event_id].add(point)
  239. series_index = defaultdict(list)
  240. for (event_id, point, day, angle, metric), values in grouped.items():
  241. series_index[(event_id, point, angle, metric)].append((day, daily_raw[(event_id, point, day, angle, metric)], values))
  242. stats = {}
  243. for event in EVENTS:
  244. for point in points_by_event[event["id"]]:
  245. for metric in METRICS:
  246. scales = []
  247. provisional = {}
  248. for angle in range(0, 360, 10):
  249. series = series_index[(event["id"], point, angle, metric)]
  250. post = baseline_stats(series, event["post_base"])
  251. early = baseline_stats(series, event["early"])
  252. provisional[angle] = (post, early)
  253. scales.extend([post[1], early[1]])
  254. positive = [scale for scale in scales if np.isfinite(scale) and scale > 1e-12]
  255. floor = .2 * float(np.median(positive)) if positive else 1e-9
  256. for angle, (post, early) in provisional.items():
  257. stats[(event["id"], point, angle, metric)] = {
  258. "post_center": post[0], "post_scale": max(post[1], floor), "post_days": post[2],
  259. "post_cycles": post[3],
  260. "early_center": early[0], "early_scale": max(early[1], floor), "early_days": early[2],
  261. "early_cycles": early[3],
  262. }
  263. daily_rows = []
  264. for key, value in daily_raw.items():
  265. event_id, point, day, angle, metric = key
  266. stat = stats[(event_id, point, angle, metric)]
  267. z_post = (value - stat["post_center"]) / stat["post_scale"] if np.isfinite(stat["post_center"]) else float("nan")
  268. z_early = (value - stat["early_center"]) / stat["early_scale"] if np.isfinite(stat["early_center"]) else float("nan")
  269. if not (np.isfinite(z_post) and np.isfinite(z_early)):
  270. continue
  271. daily_rows.append({
  272. "事件": event_id, "测点": point, "日期": day, "角度箱": angle,
  273. "指标": metric, "指标中文": METRIC_CN[metric], "特征族": FAMILY[metric],
  274. "每日中位": value, "当日周期数": counts[key],
  275. "维修后基线中心": stat["post_center"], "维修后基线尺度": stat["post_scale"],
  276. "维修后基线日数": stat["post_days"], "相对维修后Z": z_post,
  277. "维修后基线周期数": stat["post_cycles"],
  278. "早期基线中心": stat["early_center"], "早期基线尺度": stat["early_scale"],
  279. "早期基线日数": stat["early_days"], "早期基线周期数": stat["early_cycles"],
  280. "相对早期Z": z_early,
  281. })
  282. common = defaultdict(list)
  283. for row in daily_rows:
  284. if np.isfinite(row["相对维修后Z"]):
  285. common[(row["事件"], row["日期"], row["角度箱"], row["指标"])].append(row["相对维修后Z"])
  286. common = {key: float(np.median(values)) for key, values in common.items()}
  287. for row in daily_rows:
  288. public = common.get((row["事件"], row["日期"], row["角度箱"], row["指标"]), float("nan"))
  289. row["全机公共Z"] = public
  290. row["局部残差Z"] = row["相对维修后Z"] - public
  291. segment_rows = []
  292. for event in EVENTS:
  293. ranges = [("故障前运行段", start, end) for start, end in event["pre_segments"]]
  294. ranges += [("维修后基线", start, end) for start, end in event["post_base"]]
  295. ranges += [("维修后长期验证", start, end) for start, end in event["post_check"]]
  296. event_rows = [row for row in daily_rows if row["事件"] == event["id"]]
  297. keys = sorted({(row["测点"], row["角度箱"], row["指标"], row["指标中文"], row["特征族"])
  298. for row in event_rows})
  299. for segment_type, start, end in ranges:
  300. for point, angle, metric, metric_cn, family in keys:
  301. selected = [row for row in event_rows if row["测点"] == point and row["角度箱"] == angle
  302. and row["指标"] == metric and start <= row["日期"] < end]
  303. if not selected:
  304. continue
  305. segment_rows.append({
  306. "事件": event["id"], "阶段类型": segment_type, "阶段开始": start, "阶段结束": end,
  307. "测点": point, "角度箱": angle, "指标": metric, "指标中文": metric_cn, "特征族": family,
  308. "有效日期数": len({row["日期"] for row in selected}),
  309. "周期数": sum(int(row["当日周期数"]) for row in selected),
  310. "阶段每日中位的中位": float(np.median([row["每日中位"] for row in selected])),
  311. "相对维修后Z中位": float(np.median([row["相对维修后Z"] for row in selected])),
  312. "全机公共Z中位": float(np.median([row["全机公共Z"] for row in selected])),
  313. "局部残差Z中位": float(np.median([row["局部残差Z"] for row in selected])),
  314. })
  315. daily_output.parent.mkdir(parents=True, exist_ok=True)
  316. with daily_output.open("w", encoding="utf-8-sig", newline="") as handle:
  317. writer = csv.DictWriter(handle, fieldnames=list(daily_rows[0]))
  318. writer.writeheader(); writer.writerows(daily_rows)
  319. with segment_output.open("w", encoding="utf-8-sig", newline="") as handle:
  320. writer = csv.DictWriter(handle, fieldnames=list(segment_rows[0]))
  321. writer.writeheader(); writer.writerows(segment_rows)
  322. rows_index = defaultdict(list)
  323. for row in daily_rows:
  324. rows_index[(row["事件"], row["测点"], row["角度箱"], row["指标"])].append(row)
  325. metric_candidates = []
  326. for key, rows in rows_index.items():
  327. event_id, point, angle, metric = key
  328. event = event_by_id[event_id]
  329. rows.sort(key=lambda row: row["日期"])
  330. pre = [row for row in rows if in_ranges(row["日期"], event["pre"])]
  331. late = [row for row in rows if in_ranges(row["日期"], event["late"])]
  332. check = [row for row in rows if in_ranges(row["日期"], event["post_check"])]
  333. if len(pre) < 3 or not late:
  334. continue
  335. late_z = float(np.median([row["相对维修后Z"] for row in late]))
  336. late_residual = float(np.median([row["局部残差Z"] for row in late]))
  337. direction = 1 if late_z >= 0 else -1
  338. directed_late_residual = direction * late_residual
  339. directed = [direction * row["相对维修后Z"] for row in pre]
  340. directed_local = [direction * row["局部残差Z"] for row in pre]
  341. rho = spearman(directed)
  342. last_days = directed[-min(5, len(directed)):]
  343. persistent = sum(value >= 3 for value in last_days)
  344. local_persistent = sum(value >= 2 for value in directed_local[-min(5, len(directed_local)):])
  345. late_early_z = float(np.median([direction * row["相对早期Z"] for row in late]))
  346. check_z = float(np.median([abs(row["相对维修后Z"]) for row in check])) if check else float("nan")
  347. check_segments = []
  348. for start, end in event["post_check"]:
  349. segment_check = [row for row in rows if start <= row["日期"] < end]
  350. if segment_check:
  351. check_segments.append(float(np.median([abs(row["相对维修后Z"]) for row in segment_check])))
  352. recovery_flags = [value <= 2 or value <= .5 * abs(late_z) for value in check_segments]
  353. recovered = bool(recovery_flags and recovery_flags[0])
  354. long_recovery_ratio = float(np.mean(recovery_flags)) if recovery_flags else float("nan")
  355. long_recovery_stable = bool(recovery_flags and all(recovery_flags))
  356. segment_values = []
  357. segment_local_values = []
  358. segment_labels = []
  359. for start, end in event["pre_segments"]:
  360. segment_rows = [row for row in rows if start <= row["日期"] < end]
  361. if not segment_rows:
  362. continue
  363. segment_values.append(float(np.median([direction * row["相对维修后Z"] for row in segment_rows])))
  364. segment_local_values.append(float(np.median([direction * row["局部残差Z"] for row in segment_rows])))
  365. segment_labels.append(f"{start}~{end}")
  366. long_rho = spearman(segment_local_values) if len(segment_local_values) >= 3 else float("nan")
  367. long_change = segment_local_values[-1] - segment_local_values[0] if len(segment_local_values) >= 2 else float("nan")
  368. long_evolution = len(segment_local_values) >= 2 and long_change >= 2 and (
  369. len(segment_local_values) == 2 or long_rho >= .5
  370. )
  371. onset = ""
  372. for index in range(len(pre)):
  373. window = directed[index:index + 3]
  374. if len(window) >= 2 and sum(value >= 3 for value in window) >= 2:
  375. onset = pre[index]["日期"]
  376. break
  377. support = abs(late_z) >= 3 and directed_late_residual >= 2
  378. evolution = (persistent >= 2 and rho >= .35) or late_early_z >= 2
  379. score = min(abs(late_residual), 12) + min(abs(late_z), 12) * .5
  380. score += 3 if evolution else 0
  381. score += 3 if recovered else 0
  382. score += min(persistent, 3) + min(local_persistent, 3)
  383. if event["mode"] != "连续运行段":
  384. score -= 2
  385. if min(rows[0]["维修后基线日数"], rows[0]["早期基线日数"]) < 3:
  386. score -= 2
  387. metric_candidates.append({
  388. "事件": event_id, "模式": event["mode"], "测点": point, "角度起点": angle,
  389. "指标": metric, "指标中文": METRIC_CN[metric], "特征族": FAMILY[metric],
  390. "故障方向": "升高" if direction > 0 else "降低", "维修前末段Z": late_z,
  391. "维修前末段局部残差Z": late_residual, "定向局部幅度Z": directed_late_residual,
  392. "末段相对早期定向Z": late_early_z,
  393. "故障前Spearman": rho, "末5日超3Z天数": persistent,
  394. "末5日局部超2Z天数": local_persistent, "首次持续日期": onset,
  395. "独立维修后验证Z": check_z, "维修后恢复": int(recovered),
  396. "后期验证运行段数": len(check_segments),
  397. "后期各段绝对Z": ";".join(f"{value:.3f}" for value in check_segments),
  398. "长期恢复比例": long_recovery_ratio,
  399. "长期恢复稳定": int(long_recovery_stable),
  400. "基础支持": int(support), "演化支持": int(evolution), "单指标评分": score,
  401. "长期运行段数": len(segment_local_values), "长期段间Spearman": long_rho,
  402. "长期段间局部变化Z": long_change, "长期演化支持": int(long_evolution),
  403. "长期各段定向Z": ";".join(f"{label}:{value:.3f}" for label, value in zip(segment_labels, segment_local_values)),
  404. "维修后基线日数": rows[0]["维修后基线日数"], "早期基线日数": rows[0]["早期基线日数"],
  405. })
  406. groups = defaultdict(list)
  407. for row in metric_candidates:
  408. if row["基础支持"]:
  409. groups[(row["事件"], row["测点"], row["角度起点"])].append(row)
  410. candidates = []
  411. for (event_id, point, angle), items in groups.items():
  412. families = {item["特征族"] for item in items}
  413. directions = defaultdict(list)
  414. for item in items:
  415. directions[item["故障方向"]].append(item)
  416. direction, aligned = max(directions.items(), key=lambda pair: len(pair[1]))
  417. evolution_n = sum(item["演化支持"] for item in items)
  418. long_evolution_n = sum(item["长期演化支持"] for item in items)
  419. recovery_n = sum(item["维修后恢复"] for item in items)
  420. stable_recovery_n = sum(item["长期恢复稳定"] for item in items)
  421. recovery_ratios = [item["长期恢复比例"] for item in items if np.isfinite(item["长期恢复比例"])]
  422. directed_amplitudes = [item["定向局部幅度Z"] for item in items]
  423. amplitude_mean = float(np.mean(directed_amplitudes))
  424. amplitude_rms = float(np.sqrt(np.mean(np.square(directed_amplitudes))))
  425. amplitude_median = float(np.median(directed_amplitudes))
  426. score = sum(max(0, item["单指标评分"]) for item in items) + 2 * len(families) + 2 * evolution_n + 2 * recovery_n
  427. score += 2 * long_evolution_n + min(amplitude_mean, 8) + min(amplitude_rms, 10)
  428. candidates.append({
  429. "事件": event_id, "模式": items[0]["模式"], "测点": point,
  430. "角度范围": f"{angle}-{angle + 10}°", "故障主方向": direction,
  431. "支持指标数": len(items), "特征族数": len(families),
  432. "演化指标数": evolution_n, "恢复指标数": recovery_n,
  433. "长期稳定恢复指标数": stable_recovery_n,
  434. "长期恢复比例平均": round(float(np.mean(recovery_ratios)), 3) if recovery_ratios else "",
  435. "长期演化指标数": long_evolution_n,
  436. "局部幅度平均Z": round(amplitude_mean, 3),
  437. "局部幅度中位Z": round(amplitude_median, 3),
  438. "局部幅度综合RMS": round(amplitude_rms, 3),
  439. "候选评分": round(score, 3),
  440. "最早持续日期": min((item["首次持续日期"] for item in items if item["首次持续日期"]), default=""),
  441. "指标摘要": ";".join(
  442. f"{item['指标中文']}{'↑' if item['故障方向']=='升高' else '↓'}"
  443. f"(局部{item['维修前末段局部残差Z']:.1f}Z,ρ={item['故障前Spearman']:.2f},恢复={item['维修后恢复']})"
  444. for item in sorted(items, key=lambda item: -item["单指标评分"])
  445. ),
  446. "可信等级": "高" if items[0]["模式"] == "连续运行段" and len(families) >= 2 and evolution_n >= 2 and recovery_n >= 2 and amplitude_mean >= 2
  447. else "中" if items[0]["模式"] == "连续运行段" and len(families) >= 2 and evolution_n >= 2 and recovery_n >= 1
  448. else "短期线索" if items[0]["模式"] == "短运行段" and len(families) >= 2 and evolution_n >= 2 and recovery_n >= 1 and amplitude_mean >= 2
  449. else "跨段线索" if items[0]["模式"] == "跨运行段" and len(families) >= 2 and long_evolution_n >= 2 and amplitude_mean >= 2
  450. else "低",
  451. })
  452. candidates.sort(key=lambda row: (row["事件"], -row["候选评分"]))
  453. with candidate_output.open("w", encoding="utf-8-sig", newline="") as handle:
  454. writer = csv.DictWriter(handle, fieldnames=list(candidates[0]))
  455. writer.writeheader(); writer.writerows(candidates)
  456. bands = merge_bands(candidates)
  457. with band_output.open("w", encoding="utf-8-sig", newline="") as handle:
  458. writer = csv.DictWriter(handle, fieldnames=list(bands[0]))
  459. writer.writeheader(); writer.writerows(bands)
  460. repeats = []
  461. by_band = defaultdict(list)
  462. for row in candidates:
  463. if row["可信等级"] in ("高", "中", "短期线索", "跨段线索"):
  464. by_band[row["角度范围"]].append(row)
  465. for band, items in by_band.items():
  466. events = sorted({item["事件"] for item in items})
  467. if len(events) < 2:
  468. continue
  469. repeats.append({
  470. "角度范围": band, "事件数": len(events), "事件": ";".join(events),
  471. "测点": ";".join(sorted({item["测点"] for item in items})),
  472. "最高评分": max(item["候选评分"] for item in items),
  473. "共同特征": ";".join(item["指标摘要"] for item in sorted(items, key=lambda row: -row["候选评分"])[:4]),
  474. })
  475. repeats.sort(key=lambda row: (-row["事件数"], -row["最高评分"]))
  476. with repeat_output.open("w", encoding="utf-8-sig", newline="") as handle:
  477. fields = ["角度范围", "事件数", "事件", "测点", "最高评分", "共同特征"]
  478. writer = csv.DictWriter(handle, fieldnames=fields); writer.writeheader(); writer.writerows(repeats)
  479. return metric_candidates, candidates, repeats, bands
  480. def write_report(path: Path, metric_candidates, candidates, repeats, bands):
  481. lines = ["# 局部10度特征演化分析", "", "## 方法", "",
  482. "- 不使用整周期欧氏距离。每测点每天均匀抽取最多12个周期。",
  483. "- 每周期先平滑并按吸排气平台归一化,再在原始键相角度上划分36个10度箱。",
  484. "- 核心特征:局部中位压力、斜率、标准差、四分位距、导数RMS、最大二阶导。",
  485. "- 同时使用运行段早期基线和维修后稳定基线;逐日结果扣除全机同角度、同指标的公共变化。",
  486. "- 候选强调故障前定向演化、局部残差和独立维修后日期的反向恢复。",
  487. "- 幅度评估不增加原始特征:对支持指标的定向局部残差计算平均值、中位数和综合RMS。",
  488. "- 恢复分为事件后首个验证段的短期恢复,以及所有后续运行段都满足的长期稳定恢复。", ""]
  489. for event in EVENTS:
  490. rows = [row for row in candidates if row["事件"] == event["id"]]
  491. high = [row for row in rows if row["可信等级"] == "高"]
  492. medium = [row for row in rows if row["可信等级"] in ("中", "短期线索", "跨段线索")]
  493. lines += [f"## {event['label']}", "", f"- 分析模式:{event['mode']}。", f"- 高可信候选:{len(high)};其他可复核线索:{len(medium)}。"]
  494. if event["id"] == "7号_2025维修过程":
  495. lines.append("- 维修后基线仅2个日期且中间长期缺测,只能解释为跨段状态线索,不能证明连续故障前演化。")
  496. for index, row in enumerate((high + medium)[:12], 1):
  497. lines.append(
  498. f"{index}. {row['测点']} {row['角度范围']},{row['故障主方向']},评分{row['候选评分']:.1f},"
  499. f"{row['可信等级']}可信;幅度平均{row['局部幅度平均Z']:.1f}Z,综合RMS{row['局部幅度综合RMS']:.1f},"
  500. f"近期演化{row['演化指标数']}项,长期跨段演化{row['长期演化指标数']}项,"
  501. f"长期稳定恢复{row['长期稳定恢复指标数']}项;{row['指标摘要']}"
  502. )
  503. event_bands = [row for row in bands if row["事件"] == event["id"]]
  504. if event_bands:
  505. lines.append("- 相邻角度合并后的主要变化带:" + ";".join(
  506. f"{row['测点']} {row['角度带']}({row['故障主方向']},平均{row['局部幅度平均Z']:.1f}Z,RMS{row['局部幅度综合RMS']:.1f})"
  507. for row in event_bands[:10]
  508. ) + "。")
  509. if not high and not medium:
  510. lines.append("- 未找到同时满足局部残差、演化或维修后恢复条件的中高可信候选。")
  511. lines.append("")
  512. lines += ["## 跨事件重复角度", ""]
  513. if repeats:
  514. for row in repeats[:20]:
  515. lines.append(f"- {row['角度范围']}:{row['事件数']}个事件;{row['事件']};涉及{row['测点']}。")
  516. else:
  517. lines.append("- 暂无完全相同10度箱在至少两个事件中形成中高可信候选。")
  518. lines += ["", "## 限制", "", "- 角度为键相原始坐标,没有自动相位旋转;不同测点的物理过程区间可能不同。",
  519. "- 基线日期较少时稳健尺度仍可能不稳定,结果保留原始方向、日数和独立恢复验证供后续复核。",
  520. "- 当前候选是维修相关局部特征,不自动等于通用气阀故障规则。"]
  521. path.write_text("\n".join(lines) + "\n", encoding="utf-8")
  522. def main():
  523. parser = argparse.ArgumentParser(description="10度局部特征双基线演化分析")
  524. parser.add_argument("--out-dir", default="cache/indicator/局部演化分析")
  525. parser.add_argument("--per-day", type=int, default=12)
  526. parser.add_argument("--reuse", action="store_true", help="复用已抽取的逐周期CSV")
  527. args = parser.parse_args()
  528. out = Path(args.out_dir); out.mkdir(parents=True, exist_ok=True)
  529. cycle_path = out / "局部特征_逐周期.csv"
  530. grouped = load_extracted(cycle_path) if args.reuse and cycle_path.exists() else extract(cycle_path, args.per_day)
  531. metric, candidates, repeats, bands = analyze(
  532. grouped, out / "局部特征_逐日.csv", out / "局部演化候选.csv", out / "跨事件重复角度.csv",
  533. out / "局部演化角度带.csv", out / "局部特征_逐运行段.csv",
  534. )
  535. write_report(out / "局部演化结论.md", metric, candidates, repeats, bands)
  536. print(f"逐日组合 {len(grouped):,},单指标候选 {len(metric):,},角度候选 {len(candidates):,},角度带 {len(bands):,},重复角度 {len(repeats):,}")
  537. print(f"结果 -> {out}")
  538. if __name__ == "__main__":
  539. main()