make_trend_dashboard.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. #!/usr/bin/env python3
  2. """Render actual sampled waveforms for selected events without changing the original page."""
  3. from __future__ import annotations
  4. import argparse
  5. import csv
  6. from datetime import datetime, timedelta
  7. from pathlib import Path
  8. import numpy as np
  9. from make_event_dashboard import html
  10. from extract_from_db import connect, fetch_cycles
  11. from indicator_features.indicator_features import (
  12. CYLINDER_BORE_MM, _plateau_levels, smooth, volume_from_angle,
  13. )
  14. from new_unit_events import collect_events
  15. EVENTS = [
  16. ("7号 2026-05-21", "7", "2026-05-21", "高密度特征_7号_2026.csv", 30, 30),
  17. ("8号 2026-03-29/04-01 合并事件", "8", "2026-03-30", "高密度特征_8号_2026.csv", 30, 30),
  18. ("8号 2025同期(后段延长至60天)", "8", "2025-03-30", "高密度特征_8号_2025同期.csv", 30, 60),
  19. ("7号 2025-08-08(7月前段 / 11月后段)", "7", "2025-08-08", "高密度特征_7号_2025气阀事件.csv", 30, 110),
  20. ("7号 2025-10-07(7月前段 / 11月后段)", "7", "2025-10-07", "高密度特征_7号_2025气阀事件.csv", 90, 50),
  21. ("7号 2025停机维修合并过程(4~7月回溯 / 11月恢复)", "7", "2025-07-28", "高密度特征_7号_2025维修前回溯.csv", 120, 120),
  22. ]
  23. BAD = {("7", "四", "盖侧"), ("7", "六", "盖侧"), ("8", "六", "盖侧")}
  24. def build_data(root: Path):
  25. bins = 90
  26. data = {"angle": (np.arange(bins) * 360 / bins).tolist(), "machines": {}}
  27. connection = connect()
  28. cycle_cache = {}
  29. try:
  30. for label, unit, date, filename, pre_days, post_days in EVENTS:
  31. t0 = datetime.fromisoformat(date)
  32. with (root / filename).open(encoding="utf-8-sig", newline="") as handle:
  33. rows = list(csv.DictReader(handle))
  34. if filename in ("高密度特征_7号_2025维修前回溯.csv", "高密度特征_7号_2025气阀事件.csv"):
  35. with (root / "高密度特征_7号_2025事件后11月.csv").open(encoding="utf-8-sig", newline="") as handle:
  36. rows.extend(csv.DictReader(handle))
  37. rows = [r for r in rows if
  38. t0 - timedelta(days=pre_days) <= datetime.fromisoformat(r["sample_time"]) < t0 + timedelta(days=post_days + 1)
  39. and float(r.get("p_span") or 0) >= 0.3
  40. and (unit, r["cylinder"], r["side"]) not in BAD]
  41. points = sorted({(r["cylinder"], r["side"]) for r in rows})
  42. # Select one cylinder per event. A dropdown exposes both chambers without
  43. # presenting unrelated cylinders as peers or duplicate fault samples.
  44. cylinder = "一" if any(c == "一" for c, _ in points) else "三"
  45. chosen = [r for r in rows if r["cylinder"] == cylinder]
  46. if filename == "高密度特征_7号_2025维修前回溯.csv":
  47. chosen = [r for r in rows if (r["cylinder"], r["side"]) in
  48. {("三", "盖侧"), ("三", "轴侧"), ("四", "轴侧"), ("五", "盖侧") }]
  49. ids = [int(r["wave_file_id"]) for r in chosen]
  50. missing = [i for i in ids if i not in cycle_cache]
  51. if missing:
  52. cycle_cache.update(fetch_cycles(connection, missing, 100))
  53. event = {"label": label, "sides": {}}
  54. for cylinder, side in sorted({(r["cylinder"], r["side"]) for r in chosen}):
  55. samples = []
  56. for row in chosen:
  57. if (row["cylinder"], row["side"]) != (cylinder, side):
  58. continue
  59. signal = cycle_cache.get(int(row["wave_file_id"]))
  60. if signal is None or len(signal) < 60 or not np.all(np.isfinite(signal)):
  61. continue
  62. p = smooth(signal, 21)
  63. suction, discharge, _ = _plateau_levels(p)
  64. span = discharge - suction
  65. if span < 0.3:
  66. continue
  67. indices = np.arange(bins) * len(p) / bins
  68. absolute = np.interp(indices, np.arange(len(p)), p)
  69. profile = (absolute - suction) / span
  70. volume = np.interp(indices, np.arange(len(p)), volume_from_angle(len(p), CYLINDER_BORE_MM[cylinder]))
  71. time = datetime.fromisoformat(row["sample_time"])
  72. samples.append({
  73. "date": row["sample_time"], "day": (time.date() - t0.date()).days,
  74. "phase": "修前" if time < t0 else "修后",
  75. "profile": profile.round(6).tolist(), "absolute": absolute.round(6).tolist(),
  76. "volume": volume.round(6).tolist(),
  77. })
  78. samples.sort(key=lambda s: s["date"])
  79. if not samples:
  80. continue
  81. pre = [s for s in samples if s["phase"] == "修前"]
  82. post = [s for s in samples if s["phase"] == "修后"]
  83. reference = pre or post
  84. if filename == "高密度特征_7号_2025维修前回溯.csv":
  85. reference = [s for s in samples if s["date"][:7] == "2025-04"] or reference
  86. template = np.median([s["profile"] for s in reference], axis=0)
  87. for sample in samples:
  88. sample["distance"] = round(float(np.linalg.norm(np.asarray(sample["profile"]) - template)), 5)
  89. def median(items, key):
  90. return np.median([s[key] for s in items], axis=0).round(6).tolist() if items else []
  91. event["sides"][f"{cylinder}缸{side}"] = {
  92. "samples": samples, "counts": {"pre": len(pre), "post": len(post)},
  93. "preMedian": median(pre, "profile"), "postMedian": median(post, "profile"),
  94. "preAbsoluteMedian": median(pre, "absolute"), "postAbsoluteMedian": median(post, "absolute"),
  95. "trend": [{"date": s["date"], "day": s["day"], "distance": s["distance"]} for s in samples],
  96. }
  97. if filename == "高密度特征_7号_2025维修前回溯.csv":
  98. # Compare the last running period with restart, but measure the
  99. # lookback trend against the earliest available April period.
  100. july = [s for s in samples if s["date"][:7] == "2025-07"]
  101. event["sides"][f"{cylinder}缸{side}"]["preMedian"] = median(july, "profile")
  102. event["sides"][f"{cylinder}缸{side}"]["preAbsoluteMedian"] = median(july, "absolute")
  103. coverage = sorted({s["date"][:10] for d in event["sides"].values() for s in d["samples"]})
  104. if coverage:
  105. event["label"] += f" · 实际功图 {coverage[0]} 至 {coverage[-1]}"
  106. data["machines"][label] = event
  107. data["machines"].update(collect_events(connection, per_day=6, bins=bins))
  108. finally:
  109. connection.close()
  110. return data
  111. def make_html(data):
  112. page = html(data)
  113. # Reuse the style and actual-waveform plots without editing the source dashboard.
  114. page = page.replace("一缸 P-V 功图事件前后对比", "事件功图扩展对比")
  115. page = page.replace("图例在图外,可逐时间点勾选", "图例在图外,按四类勾选")
  116. page = page.replace("P-V图例可按时间点单独勾选", "功图按前段/后段及中位线分组勾选")
  117. page = page.replace("<div id=\"app\"></div>",
  118. '<label>选择事件:<select id="event-select"></select></label>'
  119. '<p class="note">每次只展示一个选项。两个旧选项保留7月与11月的三缸两侧对照,'
  120. '使用7月模板计算形状距离;10月选项的前段实际也是7月,不是10月采样。'
  121. '两个旧选项共用同一批周期,仅横轴相对日期不同,不能作为独立维修证据。'
  122. '7号2025年的维修合并为一次停机过程,7月28日仅作末次运行后的时间分界,不是维修日期。'
  123. '该过程粗蓝线为7月末运行段中位,红线为11月恢复段中位;细蓝线包含4~7月回溯周期。'
  124. '回溯选项的形状距离以4月模板为参照,其他选项仍以事件前模板为参照,数值不可混比。'
  125. '新增4号和6号事件先扫描全部有效测点;本页保留全部测点,候选排名按扣除全机共同变化后的局部残差计算。'
  126. '6号4月22日至26日视为同一维修过程,过程内周期不进入前后模板。'
  127. '5号因当前数据库没有压力功图而暂不展示。展示测点并非已确认异常缸,缺测期间不推断变化发生时刻。</p>'
  128. '<div id="app"></div>')
  129. page = page.replace("<script>renderPage(DATA);</script>", """<script>
  130. renderPage(DATA);
  131. const sections=Array.from(document.querySelectorAll('.machine'));
  132. const selector=document.getElementById('event-select');
  133. Object.keys(DATA.machines).forEach((name,index)=>{const option=document.createElement('option');option.value=index;option.textContent=name;selector.append(option)});
  134. function selectEvent(){sections.forEach((section,index)=>section.hidden=index!==Number(selector.value));requestAnimationFrame(()=>charts.forEach(chart=>chart.resize()))}
  135. selector.addEventListener('change',selectEvent);selectEvent();
  136. </script>""")
  137. return page
  138. def main():
  139. parser = argparse.ArgumentParser(description="生成实际周期的事件功图扩展网页")
  140. parser.add_argument("--root", default="cache/indicator")
  141. parser.add_argument("--out", required=True)
  142. args = parser.parse_args()
  143. data = build_data(Path(args.root))
  144. out = Path(args.out)
  145. out.parent.mkdir(parents=True, exist_ok=True)
  146. out.write_text(make_html(data), encoding="utf-8")
  147. print(f"对比网页 -> {out}")
  148. for event in data["machines"].values():
  149. print(event["label"], {point: d["counts"] for point, d in event["sides"].items()})
  150. if __name__ == "__main__":
  151. main()