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:
+12
-5
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user