event_bins.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. #!/usr/bin/env python3
  2. """Same-point before/after comparison of the per-angular-bin and whole-cycle features.
  3. Reads the long CSV produced by ``angle_bins.py`` and, for each valve/piston repair
  4. event, compares the **same measurement point** between the running period before
  5. the event and the running period after it. Only same-point comparisons are made
  6. (never across cylinders).
  7. Two feature families (see ``bins_common.py``):
  8. * per-bin features : level/shape split into whole-point shift (all bins move =
  9. load / sensor) and local deviation (only some bins move = valve / local fault);
  10. * cycle features : suction/discharge plateaus and valve-event angles/slopes.
  11. Running periods come from ``run_segments.py`` (``wave_file.rpm > 0``); the pre
  12. window is carried back over earlier running segments until it has enough days
  13. (short post windows are used as-is). Output is a ranked list per event plus a
  14. detail CSV. Read-only.
  15. """
  16. from __future__ import annotations
  17. import argparse
  18. import csv
  19. from collections import defaultdict
  20. from datetime import datetime
  21. from pathlib import Path
  22. import numpy as np
  23. from bins_common import (
  24. ANGLE_METRICS, BIN_METRICS, CYCLE_METRICS, CYCLE_VOLATILE, VOLATILE, apply_normalization,
  25. )
  26. from run_segments import build_segments, unit_segments
  27. def circ_median(values):
  28. radians = np.radians(np.asarray(values, dtype=float))
  29. return float(np.degrees(np.arctan2(np.mean(np.sin(radians)), np.mean(np.cos(radians)))) % 360.0)
  30. def circ_diff(a, b):
  31. return ((b - a + 180.0) % 360.0) - 180.0
  32. Z = 3.0
  33. FLOOR_FRAC = 0.2 # per-bin scale floor = 20% of that metric's median bin scale
  34. MIN_SPAN = 0.3 # a dead/stuck channel has a near-zero per-cycle pressure span
  35. ANGLE_SCALE_FLOOR = 1.0 # degrees; a constant valve-angle must not inflate z
  36. # 故障汇总.xlsx 中的气阀/活塞事件(unit, date, kind)
  37. EVENTS = [
  38. ("7", "2025-01-10", "活塞"),
  39. ("7", "2025-08-08", "气阀"),
  40. ("7", "2025-10-07", "气阀"),
  41. ("7", "2026-05-21", "气阀"),
  42. ("8", "2025-02-17", "气阀"),
  43. ("8", "2025-12-06", "活塞"),
  44. ("8", "2026-03-29", "气阀"),
  45. ("8", "2026-04-01", "气阀"),
  46. ("9", "2026-01-18", "活塞"),
  47. ]
  48. def to_float(value):
  49. try:
  50. result = float(value)
  51. except (TypeError, ValueError):
  52. return None
  53. return result if np.isfinite(result) else None
  54. def load(path: str) -> list[dict]:
  55. rows = []
  56. for row in csv.DictReader(open(path, encoding="utf-8-sig")):
  57. try:
  58. row["time"] = datetime.fromisoformat(row["sample_time"])
  59. except ValueError:
  60. continue
  61. row["bin"] = int(row["bin"])
  62. row["bin_start"] = float(row["bin_start"])
  63. row["p_span"] = to_float(row.get("p_span"))
  64. for name in BIN_METRICS + CYCLE_METRICS:
  65. row[name] = to_float(row.get(name))
  66. rows.append(row)
  67. return rows
  68. def robust_scale(values: np.ndarray) -> float:
  69. if values.size == 0:
  70. return 0.0
  71. center = float(np.median(values))
  72. scale = float(np.median(np.abs(values - center))) * 1.4826
  73. if scale <= 1e-12:
  74. scale = float(np.std(values))
  75. return scale
  76. def cycle_count(rows: list[dict]) -> int:
  77. return len({r["wave_file_id"] for r in rows})
  78. def median_span(rows: list[dict]) -> float:
  79. spans = [r["p_span"] for r in rows if r.get("p_span") is not None]
  80. return float(np.median(spans)) if spans else 0.0
  81. def fallback_segments(rows: list[dict], merge_gap: int) -> list[tuple]:
  82. days = sorted({r["time"].date() for r in rows})
  83. return build_segments(days, merge_gap)
  84. def collect_before(rows, t0, segments, min_days):
  85. segs = sorted([s for s in segments if s[0] <= t0.date()], key=lambda s: s[0], reverse=True)
  86. chosen, days = [], set()
  87. for start, end in segs:
  88. part = [r for r in rows if start <= r["time"].date() <= end and r["time"] < t0]
  89. chosen.extend(part)
  90. days |= {r["time"].date() for r in part}
  91. if len(days) >= min_days:
  92. break
  93. return sorted(chosen, key=lambda r: r["time"]), days
  94. def collect_after(rows, t0, segments, min_days, max_gap):
  95. """First running segment after the event; extend only over small gaps."""
  96. segs = sorted([s for s in segments if s[1] >= t0.date()], key=lambda s: s[0])
  97. chosen, days = [], set()
  98. prev_end = None
  99. for start, end in segs:
  100. if prev_end is not None and (start - prev_end).days > max_gap:
  101. break
  102. part = [r for r in rows if start <= r["time"].date() <= end and r["time"] >= t0]
  103. chosen.extend(part)
  104. days |= {r["time"].date() for r in part}
  105. prev_end = end
  106. if len(days) >= min_days:
  107. break
  108. return sorted(chosen, key=lambda r: r["time"]), days
  109. def window_rows(rows, t0, segments, pre_days, post_days, max_gap):
  110. pre, pre_set = collect_before(rows, t0, segments, pre_days)
  111. post, post_set = collect_after(rows, t0, segments, post_days, max_gap)
  112. return pre, post, pre_set, post_set
  113. def compare_bins(pre, post, min_cycles):
  114. """Per-bin level/shape comparison (BIN_METRICS)."""
  115. pre_bins = defaultdict(lambda: defaultdict(list))
  116. post_bins = defaultdict(lambda: defaultdict(list))
  117. bin_start_of = {}
  118. for row in pre:
  119. bin_start_of[row["bin"]] = row["bin_start"]
  120. for name in BIN_METRICS:
  121. if row[name] is not None:
  122. pre_bins[row["bin"]][name].append(row[name])
  123. for row in post:
  124. for name in BIN_METRICS:
  125. if row[name] is not None:
  126. post_bins[row["bin"]][name].append(row[name])
  127. all_bins = sorted(set(pre_bins) & set(post_bins))
  128. changes = []
  129. for name in BIN_METRICS:
  130. items = []
  131. for b in all_bins:
  132. a = np.asarray(pre_bins[b][name])
  133. c = np.asarray(post_bins[b][name])
  134. if a.size < min_cycles or c.size < min_cycles:
  135. continue
  136. pre_med = float(np.median(a))
  137. post_med = float(np.median(c))
  138. items.append({
  139. "bin": b, "bin_start": bin_start_of.get(b, b * 10.0), "metric": name,
  140. "pre_med": pre_med, "post_med": post_med, "delta": post_med - pre_med,
  141. "scale": robust_scale(a), "pre_n": int(a.size), "post_n": int(c.size),
  142. })
  143. if len(items) < 4:
  144. continue
  145. scales = [it["scale"] for it in items if it["scale"] > 1e-12]
  146. point_scale = float(np.median(scales)) if scales else 0.0
  147. floor = FLOOR_FRAC * point_scale
  148. pre_center = float(np.median([it["pre_med"] for it in items]))
  149. global_delta = float(np.median([it["delta"] for it in items]))
  150. global_z = global_delta / point_scale if point_scale > 1e-12 else None
  151. for it in items:
  152. denom = max(it["scale"], floor)
  153. it["local_delta"] = it["delta"] - global_delta
  154. it["local_z"] = it["local_delta"] / denom if denom > 1e-12 else None
  155. it["rel"] = it["local_delta"] / abs(pre_center) if abs(pre_center) > 1e-9 else None
  156. it["global_delta"] = global_delta
  157. it["global_z"] = global_z
  158. it["pre_center"] = pre_center
  159. changes.extend(items)
  160. return changes
  161. def compare_cycle(pre, post, min_cycles):
  162. """Whole-cycle (valve timing / plateau) comparison, one record per metric."""
  163. records = []
  164. for name in CYCLE_METRICS:
  165. a = np.asarray([r[name] for r in pre if r[name] is not None])
  166. b = np.asarray([r[name] for r in post if r[name] is not None])
  167. if a.size < min_cycles or b.size < min_cycles:
  168. continue
  169. if name in ANGLE_METRICS: # circular
  170. pre_med = circ_median(a)
  171. post_med = circ_median(b)
  172. delta = circ_diff(pre_med, post_med)
  173. residual = np.asarray([circ_diff(pre_med, v) for v in a])
  174. scale = max(robust_scale(residual), ANGLE_SCALE_FLOOR)
  175. rel = None
  176. else:
  177. pre_med = float(np.median(a))
  178. post_med = float(np.median(b))
  179. delta = post_med - pre_med
  180. scale = robust_scale(a)
  181. rel = delta / abs(pre_med) if abs(pre_med) > 1e-9 else None
  182. records.append({
  183. "metric": name, "pre_med": pre_med, "post_med": post_med, "delta": delta,
  184. "scale": scale, "z": delta / scale if scale > 1e-12 else None,
  185. "rel": rel, "pre_n": int(a.size), "post_n": int(b.size),
  186. })
  187. return records
  188. def trend_bins(pre, min_cycles):
  189. series = defaultdict(lambda: defaultdict(list))
  190. bin_start_of = {}
  191. for row in pre:
  192. bin_start_of[row["bin"]] = row["bin_start"]
  193. for name in BIN_METRICS:
  194. if row[name] is not None:
  195. series[row["bin"]][name].append((row["time"], row[name]))
  196. results = []
  197. for name in BIN_METRICS:
  198. items = []
  199. for b, per_name in series.items():
  200. points = sorted(per_name.get(name, []))
  201. if len(points) < max(min_cycles, 8):
  202. continue
  203. x = np.asarray([(p[0] - points[0][0]).total_seconds() / 86400.0 for p in points])
  204. y = np.asarray([p[1] for p in points])
  205. if np.ptp(x) <= 0:
  206. continue
  207. slope = float(np.polyfit(x, y, 1)[0])
  208. items.append({
  209. "bin": b, "bin_start": bin_start_of.get(b, b * 10.0), "metric": name,
  210. "total_drift": slope * float(np.ptp(x)), "scale": robust_scale(y), "n": len(points),
  211. })
  212. if len(items) < 4:
  213. continue
  214. scales = [it["scale"] for it in items if it["scale"] > 1e-12]
  215. floor = FLOOR_FRAC * (float(np.median(scales)) if scales else 0.0)
  216. global_drift = float(np.median([it["total_drift"] for it in items]))
  217. for it in items:
  218. denom = max(it["scale"], floor)
  219. it["local_drift"] = it["total_drift"] - global_drift
  220. it["local_mad"] = it["local_drift"] / denom if denom > 1e-12 else None
  221. results.extend(items)
  222. return results
  223. def trend_cycle(pre, min_cycles):
  224. results = []
  225. for name in CYCLE_METRICS:
  226. if name in ANGLE_METRICS: # circular; skip linear trend
  227. continue
  228. points = sorted((r["time"], r[name]) for r in pre if r[name] is not None)
  229. if len(points) < max(min_cycles, 8):
  230. continue
  231. x = np.asarray([(p[0] - points[0][0]).total_seconds() / 86400.0 for p in points])
  232. y = np.asarray([p[1] for p in points])
  233. if np.ptp(x) <= 0:
  234. continue
  235. slope = float(np.polyfit(x, y, 1)[0])
  236. scale = robust_scale(y)
  237. drift = slope * float(np.ptp(x))
  238. results.append({"metric": name, "total_drift": drift, "scale": scale,
  239. "mad": drift / scale if scale > 1e-12 else None, "n": len(points)})
  240. return results
  241. def point_name(unit, cylinder, side):
  242. return f"{unit}号机组{cylinder}缸压力{side}"
  243. def bin_label(bin_start, bin_deg):
  244. return f"{bin_start:g}-{bin_start + bin_deg:g}°"
  245. def main() -> None:
  246. parser = argparse.ArgumentParser(description="同点 分箱+整周期 运行段前后对比 + 趋势")
  247. parser.add_argument("--unit", required=True, choices=("7", "8", "9"))
  248. parser.add_argument("--csv", nargs="+", required=True, help="angle_bins.py 长表 CSV(可多个)")
  249. parser.add_argument("--pre-days", type=int, default=15, help="前段至少累计的运行日(不足也可)")
  250. parser.add_argument("--post-days", type=int, default=15, help="后段目标运行日(不足也可)")
  251. parser.add_argument("--trend-days", type=int, default=30, help="故障前趋势至少累计的运行日")
  252. parser.add_argument("--max-gap", type=int, default=30, help="后段累加允许跨越的最大停机天数")
  253. parser.add_argument("--merge-gap", type=int, default=2, help="运行段桥接允许的停机天数")
  254. parser.add_argument("--min-cycles", type=int, default=3, help="每段最少周期数")
  255. parser.add_argument("--z", type=float, default=3.0, help="变化阈值(MAD 倍数)")
  256. parser.add_argument("--normalize", choices=("none", "span"), default="none",
  257. help="span: 用本周期 p_span 归一再比(压工况)")
  258. parser.add_argument("--top", type=int, default=15, help="每张表打印条数")
  259. parser.add_argument("--out", default="", help="明细 CSV 输出路径")
  260. args = parser.parse_args()
  261. global Z
  262. Z = args.z
  263. rows = []
  264. for path in args.csv:
  265. part = load(path)
  266. print(f" {path}: {len(part):,} 行")
  267. rows.extend(part)
  268. apply_normalization(rows, args.normalize)
  269. if args.normalize != "none":
  270. print(f"归一化: {args.normalize}")
  271. starts = sorted({r["bin_start"] for r in rows})
  272. bin_deg = round(starts[1] - starts[0], 6) if len(starts) >= 2 else 10.0
  273. print(f"载入 {len(rows):,} 行,测点 {len({r['point_name'] for r in rows})},角度箱宽 {bin_deg:g}°")
  274. try:
  275. segments = unit_segments(args.unit, args.merge_gap)
  276. print(f"运行段(rpm>0): {len(segments)} 个")
  277. except Exception as error:
  278. print(f"运行段取 DB 失败({error}),改用数据日期间隔")
  279. segments = fallback_segments(rows, args.merge_gap)
  280. detail_rows: list[dict] = []
  281. for unit, date, kind in EVENTS:
  282. if unit != args.unit:
  283. continue
  284. t0 = datetime.fromisoformat(date)
  285. points = sorted({(r["cylinder"], r["side"]) for r in rows})
  286. bin_changes, cycle_changes, bin_trends, cycle_trends = [], [], [], []
  287. coverage = None
  288. for cylinder, side in points:
  289. point_rows = [r for r in rows if r["cylinder"] == cylinder and r["side"] == side]
  290. pre, post, pre_set, post_set = window_rows(point_rows, t0, segments, args.pre_days, args.post_days, args.max_gap)
  291. if cycle_count(pre) < args.min_cycles or cycle_count(post) < args.min_cycles:
  292. continue
  293. if median_span(pre) < MIN_SPAN or median_span(post) < MIN_SPAN:
  294. continue
  295. if coverage is None:
  296. coverage = (min(r["time"] for r in pre), max(r["time"] for r in pre), pre_set,
  297. min(r["time"] for r in post), max(r["time"] for r in post), post_set)
  298. point = f"{cylinder}缸{side}"
  299. label = point_name(unit, cylinder, side)
  300. pre_dates = sorted({r["time"] for r in pre})
  301. post_dates = sorted({r["time"] for r in post})
  302. wmeta = {
  303. "pre_start": str(pre_dates[0].date()), "pre_end": str(pre_dates[-1].date()),
  304. "post_start": str(post_dates[0].date()), "post_end": str(post_dates[-1].date()),
  305. "gap_days": (post_dates[0].date() - pre_dates[-1].date()).days,
  306. "pre_span_days": (pre_dates[-1].date() - pre_dates[0].date()).days,
  307. }
  308. changes = compare_bins(pre, post, args.min_cycles)
  309. strong_bins = defaultdict(int)
  310. for c in changes:
  311. if c["local_z"] is not None and abs(c["local_z"]) >= Z:
  312. strong_bins[c["bin"]] += 1
  313. for c in changes:
  314. if c["local_z"] is None or abs(c["local_z"]) < Z:
  315. continue
  316. bin_changes.append({
  317. "point": point, "label": label, "bin": c["bin"], "bin_start": c["bin_start"],
  318. "type": "局部波动" if c["metric"] in VOLATILE else "局部均值",
  319. "metric": c["metric"], "pre": c["pre_med"], "post": c["post_med"],
  320. "delta": c["local_delta"], "mad": c["local_z"], "rel": c["rel"],
  321. "candidate": strong_bins[c["bin"]] >= 2,
  322. })
  323. for name in BIN_METRICS:
  324. items = [c for c in changes if c["metric"] == name]
  325. if not items or items[0]["global_z"] is None or abs(items[0]["global_z"]) < Z:
  326. continue
  327. pre_c = items[0]["pre_center"]
  328. bin_changes.append({
  329. "point": point, "label": label, "bin": -1, "bin_start": -1.0,
  330. "type": "整体波动" if name in VOLATILE else "整体水平", "metric": name,
  331. "pre": pre_c, "post": pre_c + items[0]["global_delta"], "delta": items[0]["global_delta"],
  332. "mad": items[0]["global_z"], "rel": None, "candidate": False,
  333. })
  334. for c in compare_cycle(pre, post, args.min_cycles):
  335. if c["z"] is None or abs(c["z"]) < Z:
  336. continue
  337. cycle_changes.append({
  338. "point": point, "label": label,
  339. "type": "整周期波动" if c["metric"] in CYCLE_VOLATILE else "整周期水平",
  340. "metric": c["metric"], "pre": c["pre_med"], "post": c["post_med"],
  341. "delta": c["delta"], "mad": c["z"], "rel": c["rel"],
  342. })
  343. for t in trend_bins(pre, args.min_cycles):
  344. if t.get("local_mad") is None or abs(t["local_mad"]) < Z:
  345. continue
  346. bin_trends.append({
  347. "point": point, "label": label, "bin": t["bin"], "bin_start": t["bin_start"],
  348. "type": "局部趋势" + ("波动" if t["metric"] in VOLATILE else "均值"),
  349. "metric": t["metric"], "drift": t["local_drift"], "mad": t["local_mad"],
  350. "n": t["n"],
  351. })
  352. for t in trend_cycle(pre, args.min_cycles):
  353. if t["mad"] is None or abs(t["mad"]) < Z:
  354. continue
  355. cycle_trends.append({
  356. "point": point, "label": label,
  357. "type": "整周期趋势" + ("波动" if t["metric"] in CYCLE_VOLATILE else "水平"),
  358. "metric": t["metric"], "drift": t["total_drift"], "mad": t["mad"], "n": t["n"],
  359. })
  360. print(f"\n{'=' * 100}")
  361. print(f"== {unit}号机 {date} {kind}")
  362. if coverage:
  363. print(f" 前段 {coverage[0]:%Y-%m-%d}~{coverage[1]:%Y-%m-%d}({len(coverage[2])}运行日)"
  364. f" 后段 {coverage[3]:%Y-%m-%d}~{coverage[4]:%Y-%m-%d}({len(coverage[5])}运行日)")
  365. if not (bin_changes or cycle_changes or bin_trends or cycle_trends):
  366. print(" 事件前后无足够运行数据")
  367. continue
  368. bin_changes.sort(key=lambda r: (r["type"].startswith("整体"), -abs(r["mad"])))
  369. if bin_changes:
  370. print(f" 【分箱前后变化】{len(bin_changes)} 条(★=同角度箱≥2指标一致)")
  371. print(f" {'#':>3} {'测点':<9} {'角度箱':<10} {'类型':<8} {'指标':<11} {'前':>9} {'后':>9} {'变化':>9} {'相对':>8}")
  372. for i, r in enumerate(bin_changes[:args.top], 1):
  373. binst = "全箱" if r["bin"] < 0 else bin_label(r["bin_start"], bin_deg)
  374. rel = f"{r['rel'] * 100:+.0f}%" if r["rel"] is not None else "-"
  375. print(f" {i:>3} {r['point']:<9} {binst:<10} {r['type']:<8} {r['metric']:<11} "
  376. f"{r['pre']:>9.4g} {r['post']:>9.4g} {r['mad']:>+8.1f}M {rel:>8} {'★' if r['candidate'] else ''}")
  377. if cycle_changes:
  378. cycle_changes.sort(key=lambda r: -abs(r["mad"]))
  379. print(f" 【整周期/阀时序 前后变化】{len(cycle_changes)} 条")
  380. print(f" {'#':>3} {'测点':<9} {'类型':<10} {'指标':<15} {'前':>10} {'后':>10} {'变化':>9} {'相对':>8}")
  381. for i, r in enumerate(cycle_changes[:args.top], 1):
  382. rel = f"{r['rel'] * 100:+.0f}%" if r["rel"] is not None else "-"
  383. print(f" {i:>3} {r['point']:<9} {r['type']:<10} {r['metric']:<15} "
  384. f"{r['pre']:>10.4g} {r['post']:>10.4g} {r['mad']:>+8.1f}M {rel:>8}")
  385. if bin_trends:
  386. bin_trends.sort(key=lambda r: -abs(r["mad"]))
  387. print(f" 【故障前分箱趋势】{len(bin_trends)} 条")
  388. print(f" {'#':>3} {'测点':<9} {'角度箱':<10} {'类型':<12} {'指标':<11} {'总变化':>11} {'变化':>9}")
  389. for i, r in enumerate(bin_trends[:args.top], 1):
  390. print(f" {i:>3} {r['point']:<9} {bin_label(r['bin_start'], bin_deg):<10} {r['type']:<12} "
  391. f"{r['metric']:<11} {r['drift']:>+11.4g} {r['mad']:>+8.1f}M")
  392. if cycle_trends:
  393. cycle_trends.sort(key=lambda r: -abs(r["mad"]))
  394. print(f" 【故障前整周期趋势】{len(cycle_trends)} 条")
  395. print(f" {'#':>3} {'测点':<9} {'类型':<14} {'指标':<15} {'总变化':>12} {'变化':>9}")
  396. for i, r in enumerate(cycle_trends[:args.top], 1):
  397. print(f" {i:>3} {r['point']:<9} {r['type']:<14} {r['metric']:<15} "
  398. f"{r['drift']:>+12.4g} {r['mad']:>+8.1f}M")
  399. for r in bin_changes:
  400. detail_rows.append({
  401. **wmeta,
  402. "unit": unit, "date": date, "kind": kind, "family": "bin", "point": r["point"],
  403. "label": r["label"], "bin": r["bin"], "bin_start": r["bin_start"], "type": r["type"],
  404. "metric": r["metric"], "pre": r["pre"], "post": r["post"], "delta": r["delta"],
  405. "mad": r["mad"], "rel": r["rel"] if r["rel"] is not None else "",
  406. "candidate": int(r["candidate"]),
  407. })
  408. for r in cycle_changes:
  409. detail_rows.append({
  410. **wmeta,
  411. "unit": unit, "date": date, "kind": kind, "family": "cycle", "point": r["point"],
  412. "label": r["label"], "bin": "", "bin_start": "", "type": r["type"],
  413. "metric": r["metric"], "pre": r["pre"], "post": r["post"], "delta": r["delta"],
  414. "mad": r["mad"], "rel": r["rel"] if r["rel"] is not None else "", "candidate": "",
  415. })
  416. for r in bin_trends:
  417. detail_rows.append({
  418. **wmeta,
  419. "unit": unit, "date": date, "kind": kind, "family": "bin_trend", "point": r["point"],
  420. "label": r["label"], "bin": r["bin"], "bin_start": r["bin_start"], "type": r["type"],
  421. "metric": r["metric"], "pre": "", "post": "", "delta": r["drift"], "mad": r["mad"],
  422. "rel": "", "candidate": "",
  423. })
  424. for r in cycle_trends:
  425. detail_rows.append({
  426. **wmeta,
  427. "unit": unit, "date": date, "kind": kind, "family": "cycle_trend", "point": r["point"],
  428. "label": r["label"], "bin": "", "bin_start": "", "type": r["type"],
  429. "metric": r["metric"], "pre": "", "post": "", "delta": r["drift"], "mad": r["mad"],
  430. "rel": "", "candidate": "",
  431. })
  432. if args.out and detail_rows:
  433. out = Path(args.out)
  434. out.parent.mkdir(parents=True, exist_ok=True)
  435. with out.open("w", encoding="utf-8-sig", newline="") as handle:
  436. writer = csv.DictWriter(handle, fieldnames=list(detail_rows[0].keys()))
  437. writer.writeheader()
  438. writer.writerows(detail_rows)
  439. print(f"\n明细 -> {out}({len(detail_rows)} 行)")
  440. print(f"时间:{datetime.now():%Y-%m-%d %H:%M:%S}")
  441. if __name__ == "__main__":
  442. main()