summary_features.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. #!/usr/bin/env python3
  2. """Aggregate the per-event change tables into one ranked summary.
  3. Reads one or more ``event_bins.py`` detail CSVs and groups the per-bin and
  4. whole-cycle changes by ``(event, point, bin)`` so several metrics that move in
  5. the same bin are seen as one candidate. Ranks by the summed |MAD| and prints a
  6. compact metric list per candidate. This is the table to hand to later
  7. summarisation.
  8. """
  9. from __future__ import annotations
  10. import argparse
  11. import csv
  12. from collections import defaultdict
  13. from pathlib import Path
  14. VOLATILE = ("std", "mad", "range", "roughness", "d_abs_mean", "d_abs_max", "d_rms",
  15. "d2_rms", "zero_cross", "n_extrema", "d2_abs_max", "tv", "mean_cross",
  16. "mobility", "complexity")
  17. def to_float(value):
  18. try:
  19. return float(value)
  20. except (TypeError, ValueError):
  21. return None
  22. def main() -> None:
  23. parser = argparse.ArgumentParser(description="事件候选汇总(跨指标合并)")
  24. parser.add_argument("--csv", nargs="+", required=True)
  25. parser.add_argument("--threshold", type=float, default=2.0, help="计入的 |MAD| 下限")
  26. parser.add_argument("--max-gap", type=int, default=0, help="只保留前后段相隔 <= N 天的(0=不限)")
  27. parser.add_argument("--max-pre-span", type=int, default=0, help="只保留前段跨度 <= N 天的(0=不限)")
  28. parser.add_argument("--top", type=int, default=40)
  29. parser.add_argument("--out", default="")
  30. args = parser.parse_args()
  31. groups = defaultdict(list)
  32. for path in args.csv:
  33. for row in csv.DictReader(open(path, encoding="utf-8-sig")):
  34. if row.get("family") not in ("bin", "cycle"):
  35. continue
  36. mad = to_float(row.get("mad"))
  37. if mad is None or abs(mad) < args.threshold:
  38. continue
  39. bin_start = row.get("bin_start") or ""
  40. key = (row["unit"], row["date"], row["kind"], row["family"], row["point"], bin_start)
  41. groups[key].append({
  42. "metric": row["metric"], "type": row.get("type", ""), "mad": mad,
  43. "delta": to_float(row.get("delta")), "rel": to_float(row.get("rel")),
  44. "pre": to_float(row.get("pre")), "post": to_float(row.get("post")),
  45. "candidate": row.get("candidate") == "1",
  46. "gap_days": to_float(row.get("gap_days")),
  47. "pre_span_days": to_float(row.get("pre_span_days")),
  48. "pre_end": row.get("pre_end", ""), "post_start": row.get("post_start", ""),
  49. })
  50. records = []
  51. for key, items in groups.items():
  52. unit, date, kind, family, point, bin_start = key
  53. gap = items[0]["gap_days"]
  54. pre_span = items[0].get("pre_span_days")
  55. if args.max_gap and (gap is None or gap > args.max_gap):
  56. continue
  57. if args.max_pre_span and (pre_span is None or pre_span > args.max_pre_span):
  58. continue
  59. score = sum(abs(i["mad"]) for i in items)
  60. strongest = max(items, key=lambda i: abs(i["mad"]))
  61. if bin_start in ("", None):
  62. scope = "整周期"
  63. elif float(bin_start) < 0:
  64. scope = "整体(全角度)"
  65. else:
  66. scope = "局部(单角度箱)"
  67. records.append({
  68. "unit": unit, "date": date, "kind": kind, "family": family, "point": point,
  69. "bin_start": bin_start, "scope": scope,
  70. "n_metrics": len(items), "score": score,
  71. "max_mad": abs(strongest["mad"]), "candidate": any(i["candidate"] for i in items),
  72. "gap_days": gap, "pre_span_days": pre_span,
  73. "pre_end": items[0]["pre_end"], "post_start": items[0]["post_start"],
  74. "items": items,
  75. })
  76. records.sort(key=lambda r: -r["score"])
  77. print(f"候选组 {len(records)}(每个 = 事件×测点×角度箱/整周期,跨指标合并;|MAD|>={args.threshold}"
  78. + (f";前后相隔<={args.max_gap}天" if args.max_gap else "") + ")")
  79. print(f"{'#':>3} {'机组':<4} {'事件':<16} {'测点':<9} {'角度箱':<10} {'范围':<10} {'指标数':>5} {'总|MAD|':>8} {'相隔':>4} {'前跨度':>5} 指标(前→后方向)")
  80. out_rows = []
  81. for i, r in enumerate(records[:args.top], 1):
  82. binst = "整周期" if r["bin_start"] == "" else (
  83. f"{float(r['bin_start']):g}-{float(r['bin_start']) + 10:g}°")
  84. parts = []
  85. for it in sorted(r["items"], key=lambda x: -abs(x["mad"])):
  86. arrow = "↑" if (it["delta"] or 0) > 0 else "↓"
  87. parts.append(f"{it['metric']}{arrow}{abs(it['mad']):.0f}")
  88. summary = ", ".join(parts[:8])
  89. tag = "★" if r["candidate"] else ""
  90. gap = "" if r["gap_days"] is None else f"{int(r['gap_days'])}"
  91. pspan = "" if r["pre_span_days"] is None else f"{int(r['pre_span_days'])}"
  92. print(f"{i:>3} {r['unit']+'号':<4} {r['date'][5:]+' '+r['kind']:<16} {r['point']:<9} {binst:<10} "
  93. f"{r['scope']:<10} {r['n_metrics']:>5} {r['score']:>8.0f} {gap:>4} {pspan:>5} {summary} {tag}")
  94. out_rows.append({
  95. "unit": r["unit"], "date": r["date"], "kind": r["kind"], "family": r["family"],
  96. "point": r["point"], "bin_start": r["bin_start"], "scope": r["scope"],
  97. "n_metrics": r["n_metrics"],
  98. "score": round(r["score"], 2), "max_mad": round(r["max_mad"], 2),
  99. "gap_days": r["gap_days"], "pre_span_days": r["pre_span_days"],
  100. "pre_end": r["pre_end"], "post_start": r["post_start"],
  101. "candidate": int(r["candidate"]), "metrics": ";".join(
  102. f"{it['metric']}{'up' if (it['delta'] or 0) > 0 else 'dn'}{abs(it['mad']):.1f}"
  103. for it in sorted(r["items"], key=lambda x: -abs(x["mad"]))),
  104. })
  105. if args.out and out_rows:
  106. out = Path(args.out)
  107. out.parent.mkdir(parents=True, exist_ok=True)
  108. with out.open("w", encoding="utf-8-sig", newline="") as handle:
  109. writer = csv.DictWriter(handle, fieldnames=list(out_rows[0].keys()))
  110. writer.writeheader()
  111. writer.writerows(out_rows)
  112. print(f"\n汇总 -> {out}({len(out_rows)} 组)")
  113. if __name__ == "__main__":
  114. main()