| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221 |
- """全时段单图:15 分钟槽位拼接 60ms 首周期波形 + 小时标签背景。"""
- from __future__ import annotations
- import json
- from datetime import timedelta
- from pathlib import Path
- import numpy as np
- import pandas as pd
- from . import config, data, hourly
- SLOT_MINUTES = 15
- WAVEFORM_POINTS = 20
- COLORS = {
- "一缸": "#c0392b",
- "二缸": "#e67e22",
- "三缸": "#16a085",
- "四缸": "#2980b9",
- "五缸": "#8e44ad",
- "六缸": "#7f8c8d",
- }
- def _slot_of(moment: pd.Timestamp, start: pd.Timestamp) -> int:
- return int((pd.Timestamp(moment) - start).total_seconds() // (SLOT_MINUTES * 60))
- def _baseline_rms(meta: dict) -> float:
- return float(10 ** float(meta.get("rms_log", [0.0, 1.0])[0]))
- def export_timeline(frame: pd.DataFrame, intervals: pd.DataFrame, baseline: dict) -> None:
- frame = frame.copy()
- frame["sample_time"] = pd.to_datetime(frame["sample_time"])
- frame = frame.dropna(subset=["cylinder"]).sort_values(["cylinder", "sample_time"])
- if frame.empty:
- return
- start = frame["sample_time"].min().floor(f"{SLOT_MINUTES}min")
- end = frame["sample_time"].max().ceil(f"{SLOT_MINUTES}min")
- slot_count = int((end - start).total_seconds() // (SLOT_MINUTES * 60)) + 1
- frame["slot"] = ((frame["sample_time"] - start).dt.total_seconds() // (SLOT_MINUTES * 60)).astype(int)
- frame = frame.drop_duplicates(["cylinder", "slot"], keep="first").sort_values(["cylinder", "slot"])
- rows_by_cylinder = {
- name: frame[frame["cylinder"] == name].sort_values("slot") for name in config.CYLINDERS
- }
- all_ids = [int(item) for item in frame["id"].tolist()]
- print(f"[timeline] slots={slot_count:,}, files={len(all_ids):,}, fetching waveforms ...", flush=True)
- samples = data.fetch_samples(all_ids, chunk_size=200)
- series: dict[str, list] = {}
- for name, group in rows_by_cylinder.items():
- points: list[list] = []
- previous_slot = None
- scale = _baseline_rms(baseline.get(name, {})) or 1.0
- for row in group.itertuples(index=False):
- slot = int(row.slot)
- if previous_slot is not None and slot > previous_slot + 1:
- points.append([previous_slot + 1, None])
- signal = samples.get(int(row.id))
- if signal is None or signal.size == 0:
- previous_slot = slot
- continue
- chosen = np.linspace(0, signal.size - 1, WAVEFORM_POINTS).astype(int)
- for index, sample_index in enumerate(chosen):
- x = slot + index / (WAVEFORM_POINTS - 1)
- y = float(signal[sample_index]) / scale
- points.append([round(x, 4), round(y, 4)])
- previous_slot = slot
- series[name] = points
- print(f"[timeline] {name}: slots={len(group):,}, points={len(points):,}", flush=True)
- hourly_labels = hourly.build_hourly(intervals)
- if not hourly_labels.empty:
- hourly_labels["start_slot"] = hourly_labels["start_hour"].map(lambda value: _slot_of(value, start))
- hourly_labels["end_slot"] = hourly_labels["end_hour"].map(lambda value: _slot_of(value, start))
- confirmed_areas = []
- observation_areas = []
- if not hourly_labels.empty:
- for row in hourly_labels.itertuples(index=False):
- area = [{"xAxis": int(row.start_slot)}, {"xAxis": int(row.end_slot)}]
- if row.confirmed:
- confirmed_areas.append(area)
- else:
- observation_areas.append(area)
- # 15 分钟斑马纹:只画奇数槽位。
- stripes = [
- [{"xAxis": slot}, {"xAxis": slot + 1}]
- for slot in range(1, slot_count, 2)
- ]
- # 初始聚焦到分数最高的标签附近,避免全时段一次性太密;底部滑块可缩放到全时段。
- focus_slot = slot_count // 2
- if not intervals.empty:
- peak = intervals.loc[intervals["max_score"].idxmax()]
- focus_slot = _slot_of(peak["max_time"], start)
- half_window = 48
- zoom_start_slot = max(0, focus_slot - half_window)
- zoom_end_slot = min(slot_count, focus_slot + half_window)
- zoom_start = round(zoom_start_slot / max(slot_count, 1) * 100, 2)
- zoom_end = round(zoom_end_slot / max(slot_count, 1) * 100, 2)
- start_ms = int(start.timestamp() * 1000)
- chart_series = [
- {
- "name": "__stripes",
- "type": "line",
- "data": [],
- "silent": True,
- "z": 0,
- "markArea": {
- "silent": True,
- "itemStyle": {"color": "rgba(245,245,245,0.95)"},
- "data": stripes,
- },
- },
- {
- "name": "__observation",
- "type": "line",
- "data": [],
- "silent": True,
- "z": 1,
- "markArea": {
- "silent": True,
- "itemStyle": {"color": "rgba(241,196,15,0.22)"},
- "data": observation_areas,
- },
- },
- {
- "name": "__confirmed",
- "type": "line",
- "data": [],
- "silent": True,
- "z": 1,
- "markArea": {
- "silent": True,
- "itemStyle": {"color": "rgba(231,76,60,0.22)"},
- "data": confirmed_areas,
- },
- },
- ]
- for name in config.CYLINDERS:
- chart_series.append(
- {
- "name": f"cyl{config.CYLINDER_TO_INDEX[name]}",
- "type": "line",
- "data": series[name],
- "showSymbol": False,
- "sampling": "lttb",
- "large": True,
- "largeThreshold": 2000,
- "connectNulls": False,
- "lineStyle": {"width": 1.0},
- "itemStyle": {"color": COLORS[name]},
- "z": 3,
- }
- )
- option = {
- "animation": False,
- "tooltip": {"trigger": "axis", "axisPointer": {"type": "line"}},
- "legend": {"top": 0, "data": [f"cyl{i}" for i in range(1, 7)]},
- "grid": {"left": 55, "right": 25, "top": 35, "bottom": 65},
- "xAxis": {
- "type": "value",
- "min": 0,
- "max": slot_count,
- "interval": 24,
- "axisLabel": {
- "formatter": "__XFORMAT__",
- "fontSize": 10,
- },
- "splitLine": {"show": False},
- },
- "yAxis": {"type": "value", "name": "waveform / baseline RMS", "scale": True},
- "dataZoom": [
- {"type": "inside", "start": zoom_start, "end": zoom_end},
- {"type": "slider", "start": zoom_start, "end": zoom_end, "height": 20, "bottom": 15},
- ],
- "series": chart_series,
- }
- formatter_js = (
- "function(v){const d=new Date(" + str(start_ms) + "+v*15*60*1000);"
- "const p=n=>String(n).padStart(2,'0');"
- "return p(d.getMonth()+1)+'-'+p(d.getDate())+' '+p(d.getHours())+':00';}"
- )
- option["xAxis"]["axisLabel"]["formatter"] = "__FORMATTER__"
- payload = json.dumps(option, ensure_ascii=False)
- payload = payload.replace('"__FORMATTER__"', formatter_js)
- out_dir = config.OUTPUT_DIR / "html"
- out_dir.mkdir(parents=True, exist_ok=True)
- html = f"""<!doctype html>
- <html lang="zh-CN"><head><meta charset="utf-8"><title>5号机缸体振动全时段</title>
- <style>
- body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;margin:18px;color:#222}}
- h1{{font-size:21px;margin:0 0 6px}} .note{{color:#666;font-size:13px;line-height:1.6;margin-bottom:10px}}
- #chart{{height:720px;border:1px solid #eee;border-radius:6px}}
- </style></head><body>
- <h1>5号机全时段 · 15 分钟槽位拼接首周期波形</h1>
- <div class="note">每个 15 分钟槽位画该文件 0~60ms 首周期波形(归一化);浅灰/白交替 = 15 分钟槽位斑马纹;红色背景 = 多特征确认小时标签;黄色背景 = 观察小时标签;相邻小时已合并。初始聚焦最高分标签,底部滑块可看全时段。</div>
- <div id="chart"></div>
- <script src="echarts.min.js"></script>
- <script>
- const chart = echarts.init(document.getElementById('chart'));
- const option = {payload};
- chart.setOption(option);
- window.addEventListener('resize', () => chart.resize());
- </script></body></html>"""
- (out_dir / "timeline.html").write_text(html, encoding="utf-8")
- (out_dir / "index.html").write_text(
- """<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>5号机缸体振动</title>
- <style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;margin:24px}a{color:#1a73e8}</style>
- </head><body><h1>5号机缸体振动</h1><p><a href="timeline.html">打开全时段 15 分钟槽位波形</a></p></body></html>""",
- encoding="utf-8",
- )
- print(f"[html] {out_dir / 'timeline.html'}")
|