histogram aggregation + parser extension for BW interval fields

Three layered changes that together make histogram charts visually
match BW's printout (one bar per interval, not per codec block):

1. bw_ascii_report parser captures histogram fields it previously
   dropped:
     - Histogram Start/Stop Time + Date → datetime
     - Number of Intervals + Interval Size (string + parsed seconds)
     - <Channel> Peak Time + Peak Date → datetime (per-channel)
     - Peak Vector Sum Date (combined with PVS Time → datetime;
       clears the bogus seconds parse that interpreted "22:33:52"
       as 22.0)
   New _parse_iso_date() handles BW's ISO format for histograms
   (waveforms use "May 8, 2026" long form).  New _parse_interval_size()
   handles "1 minute" / "5 minutes" / "15 seconds" etc.

2. _bw_report_to_dict() projects the new fields into a new
   bw_report.histogram block in the sidecar.

3. /db/events/{id}/waveform.json wraps the existing path 1 (HDF5)
   output with _maybe_aggregate_histogram(): when the event is a
   histogram AND the sidecar has bw_report.histogram.n_intervals,
   group the codec's per-block samples into N intervals via
   max-per-group and return the aggregated array.  time_axis gains
   histogram_aggregated / n_intervals / interval_size_s / interval_times
   fields.

Frontend (both modal chart in sfm_webapp.html + standalone event
browser) uses interval_times as x-axis labels when provided (BW-style
HH:MM:SS), falls back to interval index.

Defensive: aggregation is no-op when the sidecar lacks the histogram
block (events ingested before this change).  Activates automatically
on prod once a watcher re-forward populates new sidecars.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 20:23:05 +00:00
parent ad2b553c7b
commit d21e3b5298
6 changed files with 254 additions and 10 deletions
+12 -5
View File
@@ -656,11 +656,18 @@ function renderWaveform(data) {
chartsDiv.appendChild(wrap);
// Waveform: per-sample time in ms relative to trigger (negative for pretrig).
// Histogram: interval index (1..N); sample_rate-based time math doesn't
// apply to per-interval peaks.
const times = isHistogram
? values.map((_, i) => i + 1)
: values.map((_, i) => t0Ms + i * dtMs);
// Histogram: when the server has aggregated to BW-reported intervals AND
// provides per-interval timestamps, use those as x-axis labels (HH:MM:SS).
// Falls back to interval index.
let times;
if (isHistogram) {
const intervalTimes = ta.interval_times || [];
times = (intervalTimes.length === values.length)
? intervalTimes
: values.map((_, i) => i + 1);
} else {
times = values.map((_, i) => t0Ms + i * dtMs);
}
// Downsample for rendering
const MAX_POINTS = 4000;
+85 -1
View File
@@ -2237,6 +2237,89 @@ def db_event_report_pdf(event_id: str):
)
def _maybe_aggregate_histogram(plot: dict, store, serial: str, filename: str, row: dict) -> dict:
"""For histogram events, aggregate the codec's per-block samples into
the BW-reported number of intervals. No-op for waveforms or when
we don't have the histogram metadata (interval count + size) in the
sidecar's bw_report block.
Why: the histogram codec emits one value per internal block (~1 per
second), but BW's printout shows one bar per configured interval
(typically 1-15 minutes). For a 1-minute-interval event the codec
gives ~60 blocks per BW bar. Aggregating max-per-group makes the
SFM chart + PDF visually match BW's display.
"""
record_type = row.get("record_type") or ""
if not record_type.lower().startswith("hist"):
return plot
# Read interval count + size from the sidecar's bw_report.histogram block
try:
import json as _json
sidecar_path = store.sidecar_path_for(serial, filename)
if not sidecar_path.exists():
return plot
sc = _json.loads(sidecar_path.read_text())
hist = (sc.get("bw_report") or {}).get("histogram") or {}
n_intervals = hist.get("n_intervals")
interval_size_s = hist.get("interval_size_s")
start_iso = hist.get("start")
except Exception:
return plot
if not n_intervals or n_intervals < 1:
return plot
# Aggregate each channel's values into n_intervals groups, max-per-group
channels = plot.get("channels") or {}
aggregated_channels: dict = {}
for ch, chd in channels.items():
vals = chd.get("values") or []
if not vals:
aggregated_channels[ch] = chd
continue
# Distribute len(vals) samples across n_intervals groups; uneven
# remainders get distributed across the first few groups.
per_group = len(vals) // n_intervals
remainder = len(vals) % n_intervals
agg: list = []
offset = 0
for i in range(n_intervals):
grp_size = per_group + (1 if i < remainder else 0)
if grp_size > 0:
grp = vals[offset:offset + grp_size]
# Max of absolute values (peaks are magnitudes).
agg.append(max((abs(v) for v in grp if v is not None), default=0))
offset += grp_size
else:
agg.append(0)
aggregated_channels[ch] = {**chd, "values": agg}
# Build per-interval timestamp labels for the x-axis if we have start time
interval_times: list = []
if start_iso and interval_size_s:
try:
import datetime as _dt
start = _dt.datetime.fromisoformat(start_iso)
for i in range(int(n_intervals)):
# Show the END of each interval (BW convention — the
# peak reported is for samples taken THROUGH that time)
end = start + _dt.timedelta(seconds=(i + 1) * interval_size_s)
interval_times.append(end.strftime("%H:%M:%S"))
except Exception:
pass
# Override the time_axis to reflect intervals (not samples).
plot_aggr = {**plot, "channels": aggregated_channels}
plot_aggr["time_axis"] = {
**(plot.get("time_axis") or {}),
"histogram_aggregated": True,
"n_intervals": int(n_intervals),
"interval_size_s": interval_size_s,
"interval_times": interval_times,
}
return plot_aggr
@app.get("/db/events/{event_id}/waveform.json")
def db_event_waveform_json(event_id: str) -> dict:
"""
@@ -2268,7 +2351,8 @@ def db_event_waveform_json(event_id: str) -> dict:
h5_path = store.hdf5_path_for(serial, filename)
if h5_path.exists():
try:
return event_hdf5.plot_json_from_hdf5(h5_path, event_id=event_id)
plot = event_hdf5.plot_json_from_hdf5(h5_path, event_id=event_id)
return _maybe_aggregate_histogram(plot, store, serial, filename, row)
except Exception as exc:
log.warning("HDF5 read failed (%s); falling back to A5 path", exc)
+12 -4
View File
@@ -2684,10 +2684,18 @@ function _renderScWaveform(data) {
chartsDiv.appendChild(wrap);
// Waveform: per-sample time in ms relative to trigger (negative for pretrig).
// Histogram: interval index (1..N); time math doesn't apply to per-interval peaks.
const times = isHistogram
? values.map((_, i) => i + 1)
: values.map((_, i) => t0Ms + i * dtMs);
// Histogram: when the server has aggregated to BW-reported intervals AND
// provides per-interval timestamps, use those as x-axis labels (HH:MM:SS).
// Falls back to interval index.
let times;
if (isHistogram) {
const intervalTimes = ta.interval_times || [];
times = (intervalTimes.length === values.length)
? intervalTimes
: values.map((_, i) => i + 1);
} else {
times = values.map((_, i) => t0Ms + i * dtMs);
}
// Downsample for rendering when very long.
const MAX = 3000;