"""全时段单图: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"""