Document real-world peak detector validation

This commit is contained in:
2026-09-04 08:49:12 +02:00
parent 5068c95158
commit 1b8fe600d5
5 changed files with 135 additions and 17 deletions
+21 -5
View File
@@ -26,7 +26,8 @@ after a value falls below `drop_ratio * current_peak`, it emits that maximum
only after actual subsequent below-threshold observations support a continuous
`hold_seconds` interval. `min_peak` is a generic detector setting, along with
`drop_ratio` (0 < ratio < 1), `hold_seconds` (>= 0), and an explicit positive
`max_sample_gap_seconds`. Elapsed time, never a sample count, determines
`max_sample_gap_seconds`. They are selected per signal, rather than being
globally fixed process constants. Elapsed time, never a sample count, determines
confirmation. An interval longer than the configured maximum sample gap
restarts a reset candidate, so a timestamp gap is not
continuous-below evidence. Reset values can be negative rather than zero,
@@ -35,10 +36,25 @@ The maximum gap is required rather than inferred or defaulted, making each
source's continuity assumption explicit.
K7 roll length, variable `bf81c547-dccf-4709-aee2-0f78366d1dfc` (m), is the
first real-world validation case. The tested capture is continuous on a
10-second grid through its latest available sample; its shorter-than-requested
result was caused by an end time queried in the future, not observed time-series
gaps. The raw capture remains ignored and deterministic tests use synthetic
clean reference validation case. With `min_peak=10`, `drop_ratio=0.75`,
`hold_seconds=60`, and `max_sample_gap_seconds=20`, its continuous 10-second
capture produced exactly four plausible peaks of approximately 49.98 m. Its
shorter-than-requested result was caused by an end time queried in the future,
not observed time-series gaps.
Bento 2 roll weight is the second, more realistic robustness validation case:
machine `6201f3de-b940-45d7-930c-1481c5ae9e1d`, variable
`059c0015-9aff-4270-a4ce-f21b83f99378` (kg). Its observed cadence is 10
seconds, but it has long plateaus, reset values roughly 300–470 kg, and
materially varying peaks. With `min_peak=800`, `drop_ratio=0.75`,
`hold_seconds=60`, and `max_sample_gap_seconds=20`, the extended capture
yielded 13 plausible completed peaks: 936, 919, 935, 930, 930, 920, 909,
1195, 921, 930, 939, 912, and 914 kg (from 00:46:50Z through 03:06:30Z on
2026-09-04). The approximately 1195 kg peak is retained as a real
process/mechanical condition, not special-cased as a defect; it is emitted
only after its following reset has enough below-threshold observations. A
reset is not expected to approach zero, and peak values are not assumed to be
constant. Raw captures remain ignored and deterministic tests use synthetic
fixtures only.
## Central domain context
+22 -8
View File
@@ -69,20 +69,34 @@ roll weight, and comparable sawtooth/batch signals. It retains the current
maximum and emits it only after the value is below
`drop_ratio * current_peak` continuously for `hold_seconds`. Configuration is
`min_peak`, `drop_ratio` (strictly between 0 and 1), non-negative
`hold_seconds`, and positive `max_sample_gap_seconds`. The hold uses elapsed
`hold_seconds`, and positive `max_sample_gap_seconds`. These are per-signal
configuration parameters, not globally fixed process constants. The hold uses elapsed
timestamps, never a sample count; an interval longer than the configured
maximum sample gap restarts a reset candidate,
so a timestamp gap alone is not evidence that a signal remained below threshold.
`max_sample_gap_seconds` is required rather than defaulted, so the source's
continuity assumption is explicit for each detector configuration.
Reset values can be negative and need not be zero. Equal timestamps are
accepted in arrival order but add no elapsed hold time; backwards timestamps
are rejected.
Reset values need not approach zero (and can be negative), and peak magnitude
can vary materially between cycles. Equal timestamps are accepted in arrival
order but add no elapsed hold time; backwards timestamps are rejected.
The first real-world validation case is K7 roll length (m), variable
`bf81c547-dccf-4709-aee2-0f78366d1dfc`. When the ignored local capture is
present, run `python scripts/validate_k7_roll_length.py` to inspect detected
peaks without adding the raw capture to tests or version control.
Real-world validations use ignored local captures and are not part of the test
suite. K7 roll length (m), variable `bf81c547-dccf-4709-aee2-0f78366d1dfc`, is
the clean reference case: its 10-second capture produced exactly four plausible
peaks of about 49.98 m with `min_peak=10`, `drop_ratio=0.75`,
`hold_seconds=60`, and `max_sample_gap_seconds=20`. Run
`python scripts/validate_k7_roll_length.py` when that capture is available.
Bento 2 roll weight (kg), machine `6201f3de-b940-45d7-930c-1481c5ae9e1d`,
variable `059c0015-9aff-4270-a4ce-f21b83f99378`, is the more realistic
robustness case. Its observed 10-second signal has plateaus, reset values of
roughly 300–470 kg, and materially varying peak heights. With
`min_peak=800`, `drop_ratio=0.75`, `hold_seconds=60`, and
`max_sample_gap_seconds=20`, the extended capture produces 13 plausible
completed peaks, including a legitimate approximately 1195 kg cycle. Run
`python scripts/validate_bento2_roll_weight.py` when the ignored capture is
available. Neither capture should be added to version control or automated
tests.
`docker compose up -d timescaledb` is an optional local database design for a
future persistence milestone. It is not required for the bootstrap tests.
+6 -3
View File
@@ -52,6 +52,9 @@ This is calculation-domain logic, with no ENLYZE transport dependency.
The detector uses timestamps rather than a sample count. An interval longer
than the configured positive `max_sample_gap_seconds` restarts a reset candidate,
so a timestamp alone does not establish that a signal was continuously below
threshold. Equal
timestamps are processed in arrival order but contribute no elapsed time, and
backwards timestamps are rejected. Reset values are not assumed to be zero.
threshold. These parameters (`min_peak`, `drop_ratio`, `hold_seconds`, and
`max_sample_gap_seconds`) are selected per signal rather than treated as global
process constants. Equal timestamps are processed in arrival order but
contribute no elapsed time, and backwards timestamps are rejected. Reset values
are not assumed to approach zero, and peak values may vary materially between
cycles.
+4 -1
View File
@@ -31,7 +31,10 @@ tests against sanitized fixtures.
The generic configurable sawtooth detector is implemented: it retains the
current maximum, closes a cycle after a configurable below-peak fraction has
actual sample support for a configurable elapsed duration, and emits one
completed peak. Its pure state can later be persisted for incremental use.
completed peak. Its pure state can later be persisted for incremental use. It
has been validated with a clean K7 roll-length reference signal and a more
variable Bento 2 roll-weight robustness signal; the latter includes plateaus,
non-zero resets, and varying peak heights without detector special cases.
## Later
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Validate the peak detector against an ignored local Bento 2 capture.
This developer utility reads no network data and does not modify the capture.
Run it from the repository root when
data/raw/enlyze/bento2-roll-weight-extended.raw.json is available.
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from production_analytics.calculations import (
PeakCycleDetector,
PeakDetectorConfig,
TimeSeriesSample,
)
CAPTURE = Path("data/raw/enlyze/bento2-roll-weight-extended.raw.json")
VARIABLE_UUID = "059c0015-9aff-4270-a4ce-f21b83f99378"
EXPECTED_PEAKS = (
("2026-09-04T00:46:50+00:00", 936.0),
("2026-09-04T00:57:10+00:00", 919.0),
("2026-09-04T01:07:20+00:00", 935.0),
("2026-09-04T01:17:20+00:00", 930.0),
("2026-09-04T01:27:20+00:00", 930.0),
("2026-09-04T01:37:30+00:00", 920.0),
("2026-09-04T01:48:00+00:00", 909.0),
("2026-09-04T02:14:20+00:00", 1195.0),
("2026-09-04T02:24:10+00:00", 921.0),
("2026-09-04T02:35:40+00:00", 930.0),
("2026-09-04T02:46:10+00:00", 939.0),
("2026-09-04T02:56:20+00:00", 912.0),
("2026-09-04T03:06:30+00:00", 914.0),
)
def main() -> int:
if not CAPTURE.is_file():
print(f"Bento 2 capture is not available: {CAPTURE}")
return 1
payload = json.loads(CAPTURE.read_text(encoding="utf-8"))
data = payload["response"]["body"]["data"]
value_index = data["columns"].index(VARIABLE_UUID)
samples = (
TimeSeriesSample(
datetime.fromisoformat(record[0].replace("Z", "+00:00")),
float(record[value_index]),
)
for record in data["records"]
if record[value_index] is not None
)
detector = PeakCycleDetector(
PeakDetectorConfig(
min_peak=800.0,
drop_ratio=0.75,
hold_seconds=60.0,
max_sample_gap_seconds=20.0,
)
)
peaks = list(detector.process_many(samples))
observed = tuple((peak.peak_timestamp.isoformat(), peak.value) for peak in peaks)
print(f"Detected peaks: {len(peaks)}")
for peak in peaks:
print(
f"peak={peak.value:.0f} kg at {peak.peak_timestamp.isoformat()} "
f"confirmed={peak.confirmed_at.isoformat()}"
)
if observed != EXPECTED_PEAKS:
print("Validation failed: detected peaks differ from the expected extended-window result.")
return 2
print("Validation passed: detected peaks match the 13 expected completed peaks.")
return 0
if __name__ == "__main__":
raise SystemExit(main())