diff --git a/PROJECT_KNOWLEDGE.md b/PROJECT_KNOWLEDGE.md index 74c67c9..c887500 100644 --- a/PROJECT_KNOWLEDGE.md +++ b/PROJECT_KNOWLEDGE.md @@ -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 diff --git a/README.md b/README.md index 45d6876..9565946 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index e463dd9..cc0f9f6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 69df6f4..c254e40 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/scripts/validate_bento2_roll_weight.py b/scripts/validate_bento2_roll_weight.py new file mode 100644 index 0000000..079693d --- /dev/null +++ b/scripts/validate_bento2_roll_weight.py @@ -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())