pseudo_online_detection.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. #!/usr/bin/env python3
  2. """Pseudo-online local-feature detection with past-only baselines."""
  3. from __future__ import annotations
  4. import csv
  5. import argparse
  6. import math
  7. import sys
  8. from collections import defaultdict, deque
  9. from datetime import date, datetime, timedelta
  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. CYLINDERS, SIDES, connect, fetch_cycles, fetch_file_rows, point_name, sample_per_day,
  16. )
  17. from indicator_features.local_evolution_analysis import ( # noqa: E402
  18. FAMILY, METRICS, METRIC_CN, normalized_bins, robust_scale,
  19. )
  20. BAD = {
  21. "4": {("二", "盖侧"), ("四", "盖侧"), ("六", "盖侧")},
  22. "6": {("二", "盖侧"), ("四", "盖侧"), ("六", "盖侧")},
  23. "7": {("四", "盖侧"), ("六", "盖侧")},
  24. }
  25. FAILURE_TEMPLATES = {
  26. "7": {
  27. ("一缸盖侧",240,1),("一缸盖侧",250,1),("一缸盖侧",260,1),
  28. ("二缸盖侧",140,-1),("三缸轴侧",160,1),("三缸轴侧",170,1),
  29. ("三缸盖侧",20,1),("三缸盖侧",30,1),("三缸盖侧",310,-1),("三缸盖侧",320,-1),
  30. },
  31. "4": {("四缸轴侧",60,-1)},
  32. "6": {
  33. ("三缸轴侧",160,1),("三缸轴侧",170,1),("三缸轴侧",120,1),("三缸轴侧",130,1),
  34. ("一缸轴侧",290,1),("一缸盖侧",220,-1),("三缸轴侧",50,1),
  35. },
  36. }
  37. EVENTS = [
  38. {"id": "7号_2025维修过程", "unit": "7", "event": "2025-08-08",
  39. "baseline": ("2025-04-29", "2025-06-14"), "detect": ("2025-07-25", "2025-07-28"),
  40. "note": "事件附近缺测,仅验证7月底运行段状态"},
  41. {"id": "7号_2026-05-21", "unit": "7", "event": "2026-05-21",
  42. "baseline": ("2026-02-10", "2026-04-05"), "detect": ("2026-05-06", "2026-05-22"), "note": ""},
  43. {"id": "4号_2026-06-10", "unit": "4", "event": "2026-06-10",
  44. "baseline": ("2026-04-21", "2026-05-31"), "detect": ("2026-06-06", "2026-06-11"), "note": ""},
  45. {"id": "6号_2026-04-22_26", "unit": "6", "event": "2026-04-22",
  46. "baseline": ("2026-04-12", "2026-04-15"), "detect": ("2026-04-15", "2026-04-27"),
  47. "note": "历史不足,仅3个基线日期"},
  48. ]
  49. UNITS = ("4", "6", "7", "8", "9")
  50. MERGE_GAP = 2
  51. HEALTH_SEGMENTS = {
  52. "7": [("2026-03-12", "2026-04-05")],
  53. "8": [("2026-04-18", "2026-05-07")],
  54. "9": [("2026-04-01", "2026-04-19")],
  55. }
  56. def calendar_segments(days):
  57. segments=[]
  58. for day in sorted(days):
  59. if segments and (day-segments[-1][1]).days <= MERGE_GAP+1:
  60. segments[-1][1]=day
  61. else:
  62. segments.append([day,day])
  63. return [(start,end) for start,end in segments]
  64. def date_ranges(event):
  65. start,end=event["detect"]
  66. cursor=date.fromisoformat(start);stop=date.fromisoformat(end)
  67. while cursor<stop:
  68. yield cursor.isoformat()
  69. cursor+=timedelta(days=1)
  70. def process_unit(connection, unit, start, end, per_day_baseline=3):
  71. points={}
  72. running_days=set()
  73. for cylinder in CYLINDERS:
  74. for side in SIDES:
  75. if (cylinder,side) in BAD.get(unit,set()):continue
  76. files=fetch_file_rows(connection,point_name(unit,cylinder,side),start,end)
  77. running_days.update(row["sample_time"].date() for row in files if row.get("rpm") and row["rpm"]>0)
  78. baseline_files=sample_per_day(files,per_day_baseline)
  79. cycles=fetch_cycles(connection,[int(row["id"]) for row in baseline_files],100) if baseline_files else {}
  80. baseline_records=[]
  81. for row in baseline_files:
  82. values=stats_for_cycle(cycles.get(int(row["id"]),np.array([])))
  83. if values is None:continue
  84. time=row["sample_time"] if isinstance(row["sample_time"],datetime) else datetime.fromisoformat(str(row["sample_time"]))
  85. baseline_records.append({"time":time,"day":time.date().isoformat(),"values":values})
  86. if baseline_records:
  87. points[f"{cylinder}缸{side}"]={"baseline_records":baseline_records,"detection_records":[]}
  88. return points,calendar_segments(running_days)
  89. def stats_for_cycle(signal):
  90. bins = normalized_bins(signal)
  91. if bins is None:
  92. return None
  93. return {(item["bin_start"], metric): item[metric] for item in bins for metric in METRICS}
  94. def point_rows(connection, event, cylinder, side, baseline_per_day=3):
  95. point = point_name(event["unit"], cylinder, side)
  96. baseline_files = sample_per_day(
  97. fetch_file_rows(connection, point, event["baseline"][0], event["baseline"][1]), baseline_per_day
  98. )
  99. detection_files = fetch_file_rows(connection, point, event["detect"][0], event["detect"][1])
  100. cycles = fetch_cycles(connection, [int(row["id"]) for row in baseline_files], 100) if baseline_files else {}
  101. baseline, detection_meta = [], []
  102. baseline_ids = {int(row["id"]) for row in baseline_files}
  103. for row in baseline_files + detection_files:
  104. file_id = int(row["id"])
  105. time = row["sample_time"] if isinstance(row["sample_time"], datetime) else datetime.fromisoformat(str(row["sample_time"]))
  106. if file_id in baseline_ids:
  107. values = stats_for_cycle(cycles.get(file_id, np.array([])))
  108. if values is None: continue
  109. baseline.append({"time":time,"day":time.date().isoformat(),"slot":time.hour//6,"values":values})
  110. else:
  111. detection_meta.append({"id":file_id,"time":time,"day":time.date().isoformat(),"slot":time.hour//6})
  112. return baseline, detection_meta
  113. def build_baseline(records):
  114. per_key_day = defaultdict(list)
  115. per_key_cycles = defaultdict(list)
  116. for record in records:
  117. for key, value in record["values"].items():
  118. per_key_day[(key, record["day"])].append(value)
  119. per_key_cycles[key].append(value)
  120. daily = defaultdict(list)
  121. for (key, _), values in per_key_day.items():
  122. daily[key].append(float(np.median(values)))
  123. provisional = {}
  124. for key in [(angle, metric) for angle in range(0, 360, 10) for metric in METRICS]:
  125. days = daily.get(key, [])
  126. cycles = per_key_cycles.get(key, [])
  127. provisional[key] = {
  128. "center": float(np.median(days)) if days else float("nan"),
  129. "scale": max(robust_scale(days), robust_scale(cycles)),
  130. "days": len(days), "cycles": len(cycles),
  131. }
  132. for metric in METRICS:
  133. scales = [provisional[(angle, metric)]["scale"] for angle in range(0, 360, 10)
  134. if provisional[(angle, metric)]["scale"] > 1e-12]
  135. floor = .2 * float(np.median(scales)) if scales else 1e-9
  136. for angle in range(0, 360, 10):
  137. provisional[(angle, metric)]["scale"] = max(provisional[(angle, metric)]["scale"], floor)
  138. return provisional
  139. def aggregate(records, group_field):
  140. grouped = defaultdict(list)
  141. for record in records:
  142. group = record[group_field] if group_field != "day_slot" else f"{record['day']}#{record['slot']}"
  143. for key, value in record["values"].items():
  144. grouped[(group, key)].append(value)
  145. return {(group, key): float(np.median(values)) for (group, key), values in grouped.items()}
  146. def merge_bands(alerts):
  147. groups = defaultdict(list)
  148. for row in alerts:
  149. groups[(row["事件"], row["日期"], row["测点"], row["等级"])].append(row)
  150. output = []
  151. for key, rows in groups.items():
  152. rows.sort(key=lambda row: int(row["角度箱"]))
  153. runs = []
  154. for row in rows:
  155. angle = int(row["角度箱"])
  156. if runs and angle == int(runs[-1][-1]["角度箱"]) + 10:
  157. runs[-1].append(row)
  158. else:
  159. runs.append([row])
  160. if len(runs) > 1 and int(runs[0][0]["角度箱"]) == 0 and int(runs[-1][-1]["角度箱"]) == 350:
  161. runs[0] = runs[-1] + runs[0]; runs.pop()
  162. for run in runs:
  163. starts = [int(row["角度箱"]) for row in run]
  164. band = f"{starts[0]}-360°及0-{starts[-1]+10}°" if starts[0] > starts[-1] else f"{starts[0]}-{starts[-1]+10}°"
  165. best = max(run, key=lambda row: float(row["综合RMS"]))
  166. output.append({
  167. "事件": key[0], "日期": key[1], "测点": key[2], "等级": key[3], "角度带": band,
  168. "角度箱数": len(run), "最大支持特征数": max(int(row["支持特征数"]) for row in run),
  169. "最大特征族数": max(int(row["支持特征族数"]) for row in run),
  170. "最大幅度平均Z": max(float(row["幅度平均Z"]) for row in run),
  171. "最大综合RMS": max(float(row["综合RMS"]) for row in run),
  172. "最大异常子窗数": max(int(row["异常6小时子窗数"]) for row in run),
  173. "代表特征": best["特征摘要"],
  174. })
  175. return output
  176. def run(out_dir: Path, selected_ids=None, slow_channel=False):
  177. all_detail, all_bands, validations = [], [], []
  178. reuse_dir=Path(str(out_dir)+"_reuse")
  179. reuse_dir.mkdir(parents=True,exist_ok=True)
  180. connection = connect()
  181. try:
  182. for event in EVENTS:
  183. if selected_ids and event["id"] not in selected_ids:
  184. continue
  185. point_data = {}
  186. for cylinder in CYLINDERS:
  187. for side in SIDES:
  188. if (cylinder, side) in BAD[event["unit"]]:
  189. continue
  190. baseline, detection_meta = point_rows(connection, event, cylinder, side)
  191. if baseline and detection_meta:
  192. point_data[f"{cylinder}缸{side}"]={"baseline":build_baseline(baseline),"baseline_records":baseline,
  193. "detection_meta":detection_meta}
  194. early_baselines={}
  195. for point,data in point_data.items():
  196. early_days=sorted({record["day"] for record in data["baseline_records"]})[:3]
  197. early_records=[record for record in data["baseline_records"] if record["day"] in early_days]
  198. early_baselines[point]=build_baseline(early_records) if early_records else {}
  199. detection_days=sorted({record["day"] for data in point_data.values() for record in data["detection_meta"]})
  200. history=defaultdict(deque)
  201. slow_history=defaultdict(deque)
  202. event_alerts=[]
  203. for day in detection_days:
  204. raw_by_point={}
  205. slot_z=defaultdict(dict)
  206. for point,data in point_data.items():
  207. meta=[record for record in data["detection_meta"] if record["day"]==day]
  208. ids=[record["id"] for record in meta]
  209. cycles=fetch_cycles(connection,ids,100) if ids else {}
  210. records=[]
  211. for record in meta:
  212. values=stats_for_cycle(cycles.get(record["id"],np.array([])))
  213. if values is not None:
  214. records.append({**record,"values":values})
  215. if not records:continue
  216. window_z={}
  217. for angle in range(0,360,10):
  218. for metric in METRICS:
  219. key=(angle,metric);base=data["baseline"][key]
  220. if not np.isfinite(base["center"]):continue
  221. vals=[rec["values"][key] for rec in records if key in rec["values"]]
  222. if not vals:continue
  223. window_z[key]=(float(np.median(vals))-base["center"])/base["scale"]
  224. for slot in range(4):
  225. slot_vals=[rec["values"][key] for rec in records if rec["slot"]==slot and key in rec["values"]]
  226. if slot_vals:slot_z[(slot,key)][point]= (float(np.median(slot_vals))-base["center"])/base["scale"]
  227. raw_by_point[point]=window_z
  228. common={}
  229. for (slot,key),values in slot_z.items():common[(slot,key)]=float(np.median(list(values.values())))
  230. common24={}
  231. for key in {(a,m) for point in raw_by_point for a,m in raw_by_point[point]}:
  232. vals=[raw_by_point[point][key] for point in raw_by_point if key in raw_by_point[point]]
  233. common24[key]=float(np.median(vals)) if vals else 0.0
  234. by_candidate=defaultdict(list)
  235. for point,window_z in raw_by_point.items():
  236. for (angle,metric),z in window_z.items():
  237. row={"point":point,"angle":angle,"metric":metric,"z":z,"local":z-common24[(angle,metric)],"base":point_data[point]["baseline"][(angle,metric)]}
  238. by_candidate[(point,angle)].append(row)
  239. abnormal_bins_by_point=defaultdict(set)
  240. for (point,angle),rows in by_candidate.items():
  241. if any(abs(row["local"])>=6 for row in rows):
  242. abnormal_bins_by_point[point].add(angle)
  243. channel_fault={point for point,bins in abnormal_bins_by_point.items() if len(bins)>=18}
  244. for (point,angle),rows in sorted(by_candidate.items(),key=lambda item:(item[0][0],item[0][1])):
  245. supported = [row for row in rows if abs(row["local"]) >= 2]
  246. families = {FAMILY[row["metric"]] for row in supported}
  247. amplitudes = [abs(row["local"]) for row in supported]
  248. mean_amp = float(np.mean(amplitudes)) if amplitudes else 0.0
  249. rms = float(np.sqrt(np.mean(np.square(amplitudes)))) if amplitudes else 0.0
  250. abnormal_slots=0
  251. for slot in range(4):
  252. slot_values=[]
  253. for row in rows:
  254. slot_key=(slot,(angle,row["metric"]))
  255. if point in slot_z.get(slot_key,{}):
  256. slot_values.append(abs(slot_z[slot_key][point]-common.get(slot_key,0.0)))
  257. if sum(value>=2 for value in slot_values)>=2:abnormal_slots+=1
  258. candidate = len(families) >= 2 and mean_amp >= 2 and rms >= 3 and abnormal_slots >= 2
  259. hist = history[(point, angle)]; hist.append(candidate)
  260. if len(hist) > 5: hist.popleft()
  261. persistent = len(hist) >= 3 and sum(hist) >= 3
  262. slow_rows=[]
  263. for row in rows:
  264. metric=row["metric"];key=(angle,metric)
  265. early=early_baselines[point].get(key)
  266. if not early or not np.isfinite(early["center"]):continue
  267. current_value=row["z"]*row["base"]["scale"]+row["base"]["center"]
  268. scale=max(early["scale"],row["base"]["scale"]*.2)
  269. slow_z=(current_value-early["center"])/scale
  270. slow_rows.append({**row,"slow_z":slow_z})
  271. slow_public_values=defaultdict(list)
  272. for other_point,other_rows in by_candidate.items():
  273. other_angle=other_point[1]
  274. if other_angle!=angle:continue
  275. for other_row in other_rows:
  276. other_early=early_baselines[other_point[0]].get((angle,other_row["metric"]))
  277. if not other_early or not np.isfinite(other_early["center"]):continue
  278. value=other_row["z"]*other_row["base"]["scale"]+other_row["base"]["center"]
  279. scale=max(other_early["scale"],other_row["base"]["scale"]*.2)
  280. slow_public_values[other_row["metric"]].append((value-other_early["center"])/scale)
  281. slow_supported_rows=[]
  282. for row in slow_rows:
  283. public=float(np.median(slow_public_values[row["metric"]])) if slow_public_values[row["metric"]] else 0.0
  284. row["slow_local"]=row["slow_z"]-public
  285. if abs(row["slow_local"])>=2:slow_supported_rows.append(row)
  286. slow_families={FAMILY[row["metric"]] for row in slow_supported_rows}
  287. slow_amplitudes=[abs(row["slow_local"]) for row in slow_supported_rows]
  288. slow_mean=float(np.mean(slow_amplitudes)) if slow_amplitudes else 0.0
  289. slow_rms=float(np.sqrt(np.mean(np.square(slow_amplitudes)))) if slow_amplitudes else 0.0
  290. slow_candidate=len(slow_families)>=2 and slow_mean>=2 and slow_rms>=3
  291. slow_hist=slow_history[(point,angle)];slow_hist.append(slow_candidate)
  292. if len(slow_hist)>5:slow_hist.popleft()
  293. slow_persistent=len(slow_hist)>=3 and sum(slow_hist)>=3
  294. raw_supported=supported or slow_supported_rows
  295. direction_values=[row["local"] for row in supported] or [row["slow_local"] for row in slow_supported_rows]
  296. direction=1 if direction_values and np.median(direction_values)>=0 else -1
  297. signature_match=any(tp==point and ta==angle and td==direction
  298. for tp,ta,td in FAILURE_TEMPLATES.get(event["unit"],set()))
  299. candidate_level=(candidate or slow_candidate)
  300. level="通道质控" if point in channel_fault else "持续预警" if ((persistent and candidate) or (slow_persistent and slow_candidate)) else "候选" if candidate_level else "观察" if raw_supported else "正常"
  301. if level in ("候选","持续预警") and not signature_match:level="未分类异常"
  302. base_days=min(row["base"]["days"] for row in rows)
  303. detail={
  304. "事件":event["id"],"机组":event["unit"],"事件日期":event["event"],"日期":day,
  305. "测点":point,"角度箱":angle,"等级":level,
  306. "支持特征数":len(supported),"支持特征族数":len(families),
  307. "幅度平均Z":round(mean_amp,4),"幅度中位Z":round(float(np.median(amplitudes)) if amplitudes else 0,4),
  308. "综合RMS":round(rms,4),"异常6小时子窗数":abnormal_slots,
  309. "最近5窗候选数":sum(hist),"基线有效日数":base_days,
  310. "慢通道支持特征数":len(slow_supported_rows),"慢通道特征族数":len(slow_families),
  311. "慢通道幅度平均Z":round(slow_mean,4),"慢通道综合RMS":round(slow_rms,4),
  312. "慢通道最近5窗候选数":sum(slow_hist),"慢通道持续":int(slow_persistent),
  313. "历史事件签名匹配":int(signature_match),
  314. "基线置信":"低" if base_days<7 else "中" if base_days<20 else "高",
  315. "特征摘要":";".join(f"{METRIC_CN[row['metric']]}{'↑' if row['local']>0 else '↓'}{abs(row['local']):.1f}Z" for row in sorted(supported,key=lambda x:-abs(x['local']))),
  316. "慢通道特征摘要":";".join(f"{METRIC_CN[row['metric']]}{'↑' if row['slow_local']>0 else '↓'}{abs(row['slow_local']):.1f}Z" for row in sorted(slow_supported_rows,key=lambda x:-abs(x['slow_local']))),
  317. "备注":event["note"],
  318. }
  319. all_detail.append(detail)
  320. if level not in ("正常","通道质控"):event_alerts.append(detail)
  321. bands = merge_bands(event_alerts); all_bands.extend(bands)
  322. event_date = datetime.fromisoformat(event["event"]).date()
  323. grouped_bands = defaultdict(list)
  324. for row in bands: grouped_bands[(row["测点"], row["角度带"])].append(row)
  325. for (point, band), rows in grouped_bands.items():
  326. rows.sort(key=lambda row: row["日期"])
  327. candidates = [row for row in rows if row["等级"] in ("候选", "持续预警", "未分类异常")]
  328. persistent = [row for row in rows if row["等级"] == "持续预警"]
  329. if not candidates: continue
  330. first = datetime.fromisoformat(candidates[0]["日期"]).date()
  331. validations.append({
  332. "事件": event["id"], "事件日期": event["event"], "测点": point, "角度带": band,
  333. "首次候选日期": str(first), "提前日数": (event_date-first).days,
  334. "首次持续预警日期": persistent[0]["日期"] if persistent else "",
  335. "候选窗口数": len(candidates), "持续预警窗口数": len(persistent),
  336. "未分类异常窗口数": sum(row["等级"] == "未分类异常" for row in candidates),
  337. "最大幅度平均Z": max(float(row["最大幅度平均Z"]) for row in candidates),
  338. "最大综合RMS": max(float(row["最大综合RMS"]) for row in candidates),
  339. "备注": event["note"],
  340. })
  341. if all_detail and any(row["事件"]==event["id"] for row in all_detail):
  342. event_detail=[row for row in all_detail if row["事件"]==event["id"]]
  343. event_band=[row for row in all_bands if row["事件"]==event["id"]]
  344. event_validation=[row for row in validations if row["事件"]==event["id"]]
  345. for filename,records in (("伪在线_窗口检测明细.csv",event_detail),
  346. ("伪在线_角度带告警.csv",event_band),
  347. ("伪在线_事件验证.csv",event_validation)):
  348. if records:
  349. with (reuse_dir/filename).open("w",encoding="utf-8-sig",newline="") as handle:
  350. writer=csv.DictWriter(handle,fieldnames=list(records[0]));writer.writeheader();writer.writerows(records)
  351. print(event["id"], "测点", len(point_data), "明细", len([r for r in all_detail if r['事件']==event['id']]),
  352. "角度带", len(bands), flush=True)
  353. finally:
  354. connection.close()
  355. out_dir.mkdir(parents=True, exist_ok=True)
  356. def write(name, rows):
  357. if not rows: return
  358. with (out_dir/name).open("w", encoding="utf-8-sig", newline="") as handle:
  359. writer=csv.DictWriter(handle,fieldnames=list(rows[0]));writer.writeheader();writer.writerows(rows)
  360. write("伪在线_窗口检测明细.csv", all_detail)
  361. write("伪在线_角度带告警.csv", all_bands)
  362. write("伪在线_事件验证.csv", validations)
  363. false_rows=[]
  364. for event in EVENTS:
  365. rows=[row for row in all_bands if row["事件"]==event["id"]]
  366. total_days=len({row["日期"] for row in all_detail if row["事件"]==event["id"]})
  367. for point in sorted({row["测点"] for row in rows}):
  368. selected=[row for row in rows if row["测点"]==point]
  369. false_rows.append({"事件":event["id"],"测点":point,"检测有效日数":total_days,
  370. "观察或以上角度带窗口":len(selected),"候选或以上窗口":sum(row["等级"] in ('候选','持续预警') for row in selected),
  371. "持续预警窗口":sum(row["等级"]=='持续预警' for row in selected)})
  372. write("伪在线_误报统计.csv",false_rows)
  373. return all_detail, all_bands, validations
  374. def run_health_segments(out_dir: Path, units=("7", "8", "9")):
  375. """Replay unlabelled controls; triggers are not confirmed false positives."""
  376. detail_rows=[]
  377. connection=None
  378. cache_dir=out_dir/"unlabelled_control_features_v1"
  379. cache_dir.mkdir(parents=True,exist_ok=True)
  380. keys=[(angle,metric) for angle in range(0,360,10) for metric in METRICS]
  381. try:
  382. for unit in units:
  383. unit_detail_start=len(detail_rows)
  384. for segment_start,segment_end in HEALTH_SEGMENTS.get(unit,[]):
  385. point_data={}
  386. for cylinder in CYLINDERS:
  387. for side in SIDES:
  388. if (cylinder,side) in BAD.get(unit,set()) or (unit=="8" and (cylinder,side)==("六","盖侧")):continue
  389. cache_path=cache_dir/f"{unit}_{cylinder}_{side}_{segment_start}_{segment_end}.npz"
  390. baseline=[];daily=[];slots=[];cycle_count=0
  391. if cache_path.exists():
  392. with np.load(cache_path,allow_pickle=False) as cached:
  393. if list(cached["metrics"])!=list(METRICS):
  394. raise ValueError(f"Feature schema mismatch: {cache_path}")
  395. for kind,records in (("baseline",baseline),("daily",daily),("slots",slots)):
  396. for day,slot,values in zip(cached[f"{kind}_days"],cached[f"{kind}_slots"],cached[f"{kind}_values"]):
  397. records.append({"day":str(day),"slot":int(slot),"values":{key:float(value) for key,value in zip(keys,values) if np.isfinite(value)}})
  398. cycle_count=int(cached["cycle_count"].item())
  399. else:
  400. if connection is None:connection=connect()
  401. files=fetch_file_rows(connection,point_name(unit,cylinder,side),segment_start,segment_end)
  402. by_day=defaultdict(list)
  403. for row in files:
  404. time=row["sample_time"] if isinstance(row["sample_time"],datetime) else datetime.fromisoformat(str(row["sample_time"]))
  405. by_day[time.date().isoformat()].append({**row,"sample_time":time})
  406. valid_days=0
  407. for day,day_files in sorted(by_day.items()):
  408. records=[]
  409. day_files.sort(key=lambda row:(row["sample_time"],int(row["id"])))
  410. for offset in range(0,len(day_files),100):
  411. batch=day_files[offset:offset+100]
  412. cycles=fetch_cycles(connection,[int(row["id"]) for row in batch],100)
  413. for row in batch:
  414. values=stats_for_cycle(cycles.get(int(row["id"]),np.array([])))
  415. if values is None:continue
  416. values={key:value for key,value in values.items() if key in keys and np.isfinite(value)}
  417. if not values:continue
  418. time=row["sample_time"]
  419. records.append({"sample_time":time,"day":day,"slot":time.hour//6,"values":values})
  420. del cycles
  421. if not records:continue
  422. valid_days+=1
  423. if valid_days<=3:
  424. baseline.extend(sample_per_day(records,3))
  425. continue
  426. cycle_count+=len(records)
  427. daily_values=aggregate(records,"day")
  428. daily.append({"day":day,"slot":-1,"values":{key:value for (group,key),value in daily_values.items()}})
  429. slot_values=aggregate(records,"day_slot")
  430. for slot in sorted({record["slot"] for record in records}):
  431. slots.append({"day":day,"slot":slot,"values":{key:value for (group,key),value in slot_values.items() if group==f"{day}#{slot}"}})
  432. payload={"metrics":np.asarray(METRICS),"cycle_count":np.asarray(cycle_count)}
  433. for kind,records in (("baseline",baseline),("daily",daily),("slots",slots)):
  434. payload[f"{kind}_days"]=np.asarray([record["day"] for record in records],dtype="U10")
  435. payload[f"{kind}_slots"]=np.asarray([record["slot"] for record in records],dtype=int)
  436. payload[f"{kind}_values"]=np.asarray([[record["values"].get(key,np.nan) for key in keys] for record in records],dtype=float).reshape(-1,len(keys))
  437. temporary=cache_path.with_suffix(".tmp.npz")
  438. np.savez_compressed(temporary,**payload)
  439. temporary.replace(cache_path)
  440. if baseline and daily:
  441. point_data[f"{cylinder}缸{side}"]={"baseline":build_baseline(baseline),"daily":daily,"slots":slots,"cycle_count":cycle_count}
  442. if not point_data:continue
  443. z_rows=[];slot_z=defaultdict(dict)
  444. for point,data in point_data.items():
  445. for record in data["daily"]:
  446. for key,value in record["values"].items():
  447. base=data["baseline"][key]
  448. if not np.isfinite(base["center"]):continue
  449. z=(value-base["center"])/base["scale"]
  450. z_rows.append({"point":point,"day":record["day"],"angle":key[0],"metric":key[1],"z":z,"baseline":base})
  451. for record in data["slots"]:
  452. for key,value in record["values"].items():
  453. base=data["baseline"][key]
  454. if not np.isfinite(base["center"]):continue
  455. slot_z[(record["day"],record["slot"],key)][point]=(value-base["center"])/base["scale"]
  456. public=defaultdict(list)
  457. for row in z_rows:public[(row["day"],row["angle"],row["metric"])].append(row["z"])
  458. public={key:float(np.median(values)) for key,values in public.items()}
  459. slot_public={key:float(np.median(list(values.values()))) for key,values in slot_z.items()}
  460. grouped=defaultdict(list)
  461. for row in z_rows:
  462. row["local"]=row["z"]-public[(row["day"],row["angle"],row["metric"])]
  463. grouped[(row["point"],row["day"],row["angle"])].append(row)
  464. history=defaultdict(deque)
  465. for (point,day,angle),rows in sorted(grouped.items(),key=lambda item:(item[0][1],item[0][0],item[0][2])):
  466. supported=[row for row in rows if abs(row["local"])>=2]
  467. families={FAMILY[row["metric"]] for row in supported}
  468. amps=[abs(row["local"]) for row in supported]
  469. avg=float(np.mean(amps)) if amps else 0.0
  470. rms=float(np.sqrt(np.mean(np.square(amps)))) if amps else 0.0
  471. abnormal_slots=0
  472. for slot in range(4):
  473. slot_vals=[]
  474. for row in rows:
  475. key=(day,slot,(angle,row["metric"]))
  476. if point in slot_z.get(key,{}):slot_vals.append(abs(slot_z[key][point]-slot_public[key]))
  477. if sum(v>=2 for v in slot_vals)>=2:abnormal_slots+=1
  478. candidate=len(families)>=2 and avg>=2 and rms>=3 and abnormal_slots>=2
  479. hist=history[(point,angle)];hist.append(candidate)
  480. if len(hist)>5:hist.popleft()
  481. persistent=len(hist)>=3 and sum(hist)>=3
  482. level="持续预警" if persistent and candidate else "候选" if candidate else "观察" if supported else "正常"
  483. base_days=min(row["baseline"]["days"] for row in rows)
  484. detail_rows.append({
  485. "机组":unit,"运行段":f"{segment_start}~{segment_end}","日期":day,"测点":point,
  486. "样本标签":"unlabelled control",
  487. "备注":"2026-04-18距2026-04-01维修17天,不保证健康" if unit=="8" else "未确认健康,触发不能直接认定为误报",
  488. "角度箱":angle,"等级":level,"支持特征数":len(supported),"支持特征族数":len(families),
  489. "幅度平均Z":round(avg,4),"幅度中位Z":round(float(np.median(amps)) if amps else 0,4),
  490. "综合RMS":round(rms,4),"异常6小时子窗数":abnormal_slots,"最近5窗候选数":sum(hist),
  491. "基线有效日数":base_days,"基线置信":"低" if base_days<7 else "中" if base_days<20 else "高",
  492. "特征摘要":";".join(f"{METRIC_CN[r['metric']]}{'↑' if r['local']>0 else '↓'}{abs(r['local']):.1f}Z" for r in sorted(supported,key=lambda x:-abs(x['local']))),
  493. })
  494. print(f"unlabelled control {unit}号 {segment_start}~{segment_end}: 测点{len(point_data)},全量检测周期{sum(x['cycle_count'] for x in point_data.values()):,}",flush=True)
  495. unit_rows=detail_rows[unit_detail_start:]
  496. if unit_rows:
  497. out_dir.mkdir(parents=True,exist_ok=True)
  498. with (out_dir/f"伪在线_未标注对照触发明细_{unit}号.csv").open("w",encoding="utf-8-sig",newline="") as handle:
  499. writer=csv.DictWriter(handle,fieldnames=list(unit_rows[0]));writer.writeheader();writer.writerows(unit_rows)
  500. finally:
  501. if connection is not None:connection.close()
  502. out_dir.mkdir(parents=True,exist_ok=True)
  503. with (out_dir/"伪在线_未标注对照触发明细.csv").open("w",encoding="utf-8-sig",newline="") as handle:
  504. writer=csv.DictWriter(handle,fieldnames=list(detail_rows[0]) if detail_rows else ["机组","样本标签"]);writer.writeheader();writer.writerows(detail_rows)
  505. summary=[]
  506. by_unit=defaultdict(list)
  507. for row in detail_rows:by_unit[row["机组"]].append(row)
  508. for unit,rows in by_unit.items():
  509. days=len({row["日期"] for row in rows});points=len({row["测点"] for row in rows})
  510. summary.append({"机组":unit,"样本标签":"unlabelled control","对照检测有效日数":days,"测点数":points,"窗口点箱记录":len(rows),
  511. "观察记录":sum(row["等级"]=="观察" for row in rows),
  512. "候选记录":sum(row["等级"]=="候选" for row in rows),
  513. "持续预警记录":sum(row["等级"]=="持续预警" for row in rows),
  514. "候选及以上日测点箱数":len({(row["日期"],row["测点"],row["角度箱"]) for row in rows if row["等级"] in ("候选","持续预警")}),
  515. "持续预警日测点箱数":len({(row["日期"],row["测点"],row["角度箱"]) for row in rows if row["等级"]=="持续预警"})})
  516. with (out_dir/"伪在线_未标注对照触发统计.csv").open("w",encoding="utf-8-sig",newline="") as handle:
  517. writer=csv.DictWriter(handle,fieldnames=list(summary[0]) if summary else ["机组","样本标签"]);writer.writeheader();writer.writerows(summary)
  518. return detail_rows,summary
  519. def report(out_dir, detail, bands, validations):
  520. lines=["# 伪在线故障检测验证", "", "## 方法", "",
  521. "- 历史基线每天粗采最多3周期;24小时检测窗口处理全部有效周期。",
  522. "- 周期平台归一化、测点自身历史稳健Z标准化、单机公共变化扣除。",
  523. "- 检测不使用维修后数据;维修日期仅用于计算提前量。", ""]
  524. for event in EVENTS:
  525. rows=[row for row in validations if row["事件"]==event["id"]]
  526. rows.sort(key=lambda row:(-int(row["持续预警窗口数"]),-float(row["最大综合RMS"])))
  527. lines += [f"## {event['id']}", "", f"- {event['note'] or '按事件前数据回放'}。", f"- 形成候选的测点角度带:{len(rows)}。"]
  528. for i,row in enumerate(rows[:20],1):
  529. lines.append(f"{i}. {row['测点']} {row['角度带']}:首次候选{row['首次候选日期']},提前{row['提前日数']}天,持续预警{row['持续预警窗口数']}窗,最大RMS{float(row['最大综合RMS']):.1f}。")
  530. lines.append("")
  531. lines += ["## 使用限制", "", "- 当前按有数据的自然日形成0~24小时窗口;下一版可改为每小时滚动24小时。",
  532. "- 6号基线只有3个日期,所有结果应降级解释。", "- 7号2025事件附近缺测,只能验证运行段状态变化,不能证明事件提前量。"]
  533. (out_dir/"伪在线检测结论.md").write_text("\n".join(lines)+"\n",encoding="utf-8")
  534. if __name__ == "__main__":
  535. parser=argparse.ArgumentParser(description="局部特征伪在线回放")
  536. parser.add_argument("--out-dir",default="cache/indicator/伪在线检测")
  537. parser.add_argument("--event",action="append",choices=[event["id"] for event in EVENTS])
  538. parser.add_argument("--healthy",action="store_true",help="回放无已知故障的健康运行段")
  539. parser.add_argument("--unit",action="append",choices=UNITS,help="限制机组,可重复传入")
  540. args=parser.parse_args()
  541. output=Path(args.out_dir)
  542. if args.healthy:
  543. run_health_segments(output,tuple(args.unit or ("7","8","9")))
  544. else:
  545. detail,bands,validations=run(output,set(args.event or []))
  546. report(output,detail,bands,validations)
  547. print("结果 ->",output)