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 only after actual subsequent below-threshold observations support a continuous
`hold_seconds` interval. `min_peak` is a generic detector setting, along with `hold_seconds` interval. `min_peak` is a generic detector setting, along with
`drop_ratio` (0 < ratio < 1), `hold_seconds` (>= 0), and an explicit positive `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 confirmation. An interval longer than the configured maximum sample gap
restarts a reset candidate, so a timestamp gap is not restarts a reset candidate, so a timestamp gap is not
continuous-below evidence. Reset values can be negative rather than zero, 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. source's continuity assumption explicit.
K7 roll length, variable `bf81c547-dccf-4709-aee2-0f78366d1dfc` (m), is the K7 roll length, variable `bf81c547-dccf-4709-aee2-0f78366d1dfc` (m), is the
first real-world validation case. The tested capture is continuous on a clean reference validation case. With `min_peak=10`, `drop_ratio=0.75`,
10-second grid through its latest available sample; its shorter-than-requested `hold_seconds=60`, and `max_sample_gap_seconds=20`, its continuous 10-second
result was caused by an end time queried in the future, not observed time-series capture produced exactly four plausible peaks of approximately 49.98 m. Its
gaps. The raw capture remains ignored and deterministic tests use synthetic 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. fixtures only.
## Central domain context ## 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 maximum and emits it only after the value is below
`drop_ratio * current_peak` continuously for `hold_seconds`. Configuration is `drop_ratio * current_peak` continuously for `hold_seconds`. Configuration is
`min_peak`, `drop_ratio` (strictly between 0 and 1), non-negative `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 timestamps, never a sample count; an interval longer than the configured
maximum sample gap restarts a reset candidate, maximum sample gap restarts a reset candidate,
so a timestamp gap alone is not evidence that a signal remained below threshold. 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 `max_sample_gap_seconds` is required rather than defaulted, so the source's
continuity assumption is explicit for each detector configuration. continuity assumption is explicit for each detector configuration.
Reset values can be negative and need not be zero. Equal timestamps are Reset values need not approach zero (and can be negative), and peak magnitude
accepted in arrival order but add no elapsed hold time; backwards timestamps can vary materially between cycles. Equal timestamps are accepted in arrival
are rejected. order but add no elapsed hold time; backwards timestamps are rejected.
The first real-world validation case is K7 roll length (m), variable Real-world validations use ignored local captures and are not part of the test
`bf81c547-dccf-4709-aee2-0f78366d1dfc`. When the ignored local capture is suite. K7 roll length (m), variable `bf81c547-dccf-4709-aee2-0f78366d1dfc`, is
present, run `python scripts/validate_k7_roll_length.py` to inspect detected the clean reference case: its 10-second capture produced exactly four plausible
peaks without adding the raw capture to tests or version control. 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 `docker compose up -d timescaledb` is an optional local database design for a
future persistence milestone. It is not required for the bootstrap tests. 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 The detector uses timestamps rather than a sample count. An interval longer
than the configured positive `max_sample_gap_seconds` restarts a reset candidate, 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 so a timestamp alone does not establish that a signal was continuously below
threshold. Equal threshold. These parameters (`min_peak`, `drop_ratio`, `hold_seconds`, and
timestamps are processed in arrival order but contribute no elapsed time, and `max_sample_gap_seconds`) are selected per signal rather than treated as global
backwards timestamps are rejected. Reset values are not assumed to be zero. 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 The generic configurable sawtooth detector is implemented: it retains the
current maximum, closes a cycle after a configurable below-peak fraction has current maximum, closes a cycle after a configurable below-peak fraction has
actual sample support for a configurable elapsed duration, and emits one 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 ## 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())