mirror of
https://github.com/ruvnet/RuView
synced 2026-07-28 18:21:42 +00:00
e6062977c9
Closes the two documentation gaps from #1456 (follow-up to #1401): - docs/calibration-guide.md: what calibrate/enroll/train-room actually enforce, grounded in v2/crates/wifi-densepose-calibration and wifi-densepose-cli source (not just ADR-135/151 aspirational prose). Covers the hard 600-frame baseline minimum, per-anchor quality gate thresholds, the unsolved pet/small-motion presence-detection gap, and what the empty-room baseline capture actually needs (steady vs silent). Flags that ADR-135's drift_score/BaselineDrift staleness system is not implemented in code — only bank.rs's baseline_id STALE check is real. - docs/trust-and-engine-errors.md: exact trigger conditions for engine_error_count vs the separate, non-sticky `demoted` privacy-class flag, where both are exposed (/health/ready and /api/v1/status share a handler), the real diagnostic gap (no per-cause breakdown, log line is the closest thing), the WDP_GUARD_INTERVAL_US recovery path for persistent clock-drift demotion, and an honest "no code path found" answer on whether a converted HuggingFace model explains engine errors. Also adds both docs to the README documentation table. No code changes.
301 lines
16 KiB
Markdown
301 lines
16 KiB
Markdown
# Calibration & Room Training Guide
|
||
|
||
This guide explains what actually happens — and what is actually *enforced* —
|
||
when you run `wifi-densepose calibrate`, `enroll`, and `train-room`. It is
|
||
written for the person setting up a room, not for developers.
|
||
|
||
Everything below was checked against the real Rust implementation in
|
||
`v2/crates/wifi-densepose-calibration/`, `v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs`,
|
||
and `v2/crates/wifi-densepose-cli/`, not just the design ADRs. Where the design
|
||
documents (ADR-135, ADR-151) describe something that isn't actually built yet,
|
||
this guide says so explicitly.
|
||
|
||
## The three-step pipeline
|
||
|
||
```
|
||
wifi-densepose calibrate --port <PORT> # Stage 1: empty-room baseline (no people)
|
||
wifi-densepose enroll --room <NAME> # Stage 2+3: 8 guided anchors (~4 minutes)
|
||
wifi-densepose train-room --room <NAME> # Stage 4: fit the specialist bank
|
||
wifi-densepose room-status --room <NAME> # check what trained / what's stale
|
||
wifi-densepose room-watch --room <NAME> # live inference
|
||
```
|
||
|
||
`calibrate` must run first — `enroll` refuses to start without a baseline file
|
||
(`--baseline ./baseline.bin` by default), and `train-room` refuses to start
|
||
without an enrollment file. Each step writes a file the next step reads; there
|
||
is no way to skip a step.
|
||
|
||
---
|
||
|
||
## 1. Is there a minimum amount of data required?
|
||
|
||
**Yes, and for the empty-room baseline it is a hard, enforced minimum — not a
|
||
recommendation.**
|
||
|
||
`wifi-densepose calibrate` will not produce a baseline file with fewer than
|
||
**600 recorded frames** (the default for every PHY tier: HT20, HT40, HE20,
|
||
HE40). This is `DEFAULT_MIN_FRAMES = 600` in
|
||
`v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs:48`, and it is
|
||
checked in `CalibrationRecorder::finalize()`
|
||
(`ruvsense/calibration.rs:532-538`): if fewer than `config.min_frames` frames
|
||
were recorded, `finalize()` returns
|
||
`CalibrationError::InsufficientFrames { got, need }` and calibration fails
|
||
outright — there is no partial/degraded baseline. This is pinned by a unit
|
||
test (`finalize_requires_min_frames`, same file) so it isn't accidental
|
||
behavior.
|
||
|
||
**Important subtlety:** the CLI's `--duration-s` flag (default 30 seconds)
|
||
and the 600-frame minimum are checked *independently*. The capture loop in
|
||
`v2/crates/wifi-densepose-cli/src/calibrate.rs:135-183` stops as soon as
|
||
**either** the duration timer expires **or** 600 frames have been recorded,
|
||
whichever comes first. If your node streams CSI slower than the assumed 20 Hz
|
||
(e.g. congested WiFi, a busier ESP32), 30 seconds may not be enough to reach
|
||
600 frames, and `calibrate` will fail with an explicit
|
||
`"insufficient frames: have X, need 600"` error rather than silently
|
||
producing a short baseline. If you hit this, raise `--duration-s` rather than
|
||
overriding `--min-frames`.
|
||
|
||
You *can* override the 600-frame floor with `--min-frames <N>` (0 = use the
|
||
tier default). The code prints an explicit warning when you do:
|
||
|
||
> `[calibrate] WARN: --min-frames=N overrides ADR-135 tier default (600 for
|
||
> ht20). This relaxes the phase-concentration guarantee; do not use in
|
||
> production.`
|
||
|
||
(`v2/crates/wifi-densepose-cli/src/calibrate.rs:112-119`). Treat this as a
|
||
debugging escape hatch, not a supported way to shorten setup.
|
||
|
||
The CLI also independently rejects `--duration-s` below 10 seconds
|
||
(`"Fewer frames produce unreliable phase-concentration estimates"`,
|
||
`calibrate.rs:341-348`) and prints (but does not block on) a warning above 300
|
||
seconds.
|
||
|
||
**Guided enrollment (`enroll`) has a much lower, per-anchor floor.** Each of
|
||
the 8 guided anchors (`empty`, `stand_still`, `sit`, `lie_down`,
|
||
`breathe_slow`, `breathe_normal`, `small_move`, `sleep_posture`) is captured
|
||
for a fixed duration baked into the code — 20 seconds for the static/motion
|
||
anchors, 30 seconds for the two breathing anchors and `sleep_posture`
|
||
(`AnchorLabel::duration_s()`, `v2/crates/wifi-densepose-calibration/src/anchor.rs:98-104`).
|
||
This is **not** a CLI flag — you cannot currently shorten or lengthen an
|
||
individual anchor capture from the command line.
|
||
|
||
Underneath that fixed duration, the anchor is only *accepted* if it clears a
|
||
quality gate (`AnchorQualityGate`, `v2/crates/wifi-densepose-calibration/src/enrollment.rs:43-53`):
|
||
|
||
| Threshold | Default | What it checks |
|
||
|---|---|---|
|
||
| `min_frames` | **60 frames** | Anchor is rejected if fewer than 60 frames were captured — mainly catches "the ESP32 stopped streaming" mid-capture, not a real duration requirement (60 frames is a fraction of a second of streaming at typical rates) |
|
||
| `min_presence_z` | 1.5 | For anchors that expect a person, the mean amplitude z-score must exceed this or the anchor is rejected as "no person detected" |
|
||
| `empty_max_z` | 1.0 | For the `empty` anchor, the z-score must stay under this or it's rejected as "room not empty" |
|
||
| `max_still_motion` | 0.6 (60%) | For still anchors, motion-flagged frame fraction above this is rejected as "too much motion" |
|
||
| `min_move_motion` | 0.3 (30%) | For `small_move`, motion-flagged fraction below this is rejected as "not enough motion" |
|
||
|
||
A rejected anchor is re-prompted, up to `--attempts` times (default **2**).
|
||
If an anchor is still rejected after all attempts, `enroll` moves on without
|
||
it and logs `"moving on without '<label>'"` — enrollment does **not** abort;
|
||
you end up with a partial anchor set.
|
||
|
||
**`train-room` itself enforces almost nothing.** It only bails if the
|
||
enrollment file has *zero* accepted anchors at all
|
||
(`v2/crates/wifi-densepose-cli/src/room.rs:246-248`, `"no accepted anchors …
|
||
re-run enroll"`). There is no minimum anchor count beyond that. What actually
|
||
happens with a partial anchor set is that individual specialists silently
|
||
fail to train and are simply absent from the resulting bank — for example
|
||
(from `v2/crates/wifi-densepose-calibration/src/specialist.rs`):
|
||
|
||
- **presence** needs the `empty` anchor plus at least one anchor where a
|
||
person was expected present — missing either, `PresenceSpecialist::train()`
|
||
returns `None` and presence detection is unavailable in that bank.
|
||
- **anomaly** needs at least 2 anchors total, of any kind.
|
||
- **restlessness** needs `sleep_posture` (or `lie_down` as a fallback) *and*
|
||
`small_move`.
|
||
- **posture** needs at least one anchor that establishes a posture
|
||
(`stand_still`, `sit`, `lie_down`, or `sleep_posture`).
|
||
|
||
So a "successful" `train-room` run can still produce a bank missing one or
|
||
more specialists if enrollment didn't collect the anchors those specialists
|
||
need. `room-status` (`v2/crates/wifi-densepose-cli/src/room.rs`) is the way
|
||
to check what actually trained.
|
||
|
||
### What we could not verify
|
||
|
||
The ADR-151 design document (§2.2) claims total guided enrollment is
|
||
"~4 minutes of wall-clock" — that arithmetic checks out against the coded
|
||
per-anchor durations (5 × 20s + 3 × 30s = 190s ≈ 3.2 min, plus a 3-second
|
||
countdown before each anchor ≈ +24s, so ~3.5–4 minutes is consistent with the
|
||
code). But we found **no integration test or measurement showing that this
|
||
duration is sufficient for reliable specialist accuracy** — the ADR's own
|
||
status section says the full `baseline → enroll → train-room → infer` loop is
|
||
proven only against **deterministic synthetic CSI** (`tests/full_loop.rs`),
|
||
not yet run start-to-finish on real hardware in an empty room. Treat the
|
||
default durations as reasonable code defaults, not as a validated minimum for
|
||
real-world accuracy.
|
||
|
||
---
|
||
|
||
## 2. Recommended duration if there's no hard minimum
|
||
|
||
Where a hard minimum *does* exist (the 600-frame baseline, the 60-frame
|
||
per-anchor floor), it's documented above. Beyond that:
|
||
|
||
- **Baseline capture**: the CLI default (`--duration-s 30`) is the number to
|
||
use; it's what the 600-frame minimum is designed around at the assumed
|
||
20 Hz sensing rate. ADR-135 §2.3 argues 30 s is the shortest duration that
|
||
keeps the phase-concentration estimate's standard deviation under
|
||
0.02 rad², citing published circular-statistics error bounds — but this is
|
||
a paper-derived justification for the *default value*, not a code-enforced
|
||
floor beyond the 600-frame check itself.
|
||
- **Enrollment anchors**: use the built-in per-anchor durations (20s/30s) —
|
||
there's currently no way to change them from the CLI anyway.
|
||
|
||
---
|
||
|
||
## 3. Will a pet get classified as "occupied"?
|
||
|
||
**Honest answer: the code has no way to distinguish a pet (or any small/animal-scale
|
||
motion) from a person.** This is a real limitation, not a solved problem —
|
||
flagging it here rather than guessing.
|
||
|
||
Presence detection (`PresenceSpecialist`,
|
||
`v2/crates/wifi-densepose-calibration/src/specialist.rs:100-198`) is trained
|
||
purely from two scalar channels measured during enrollment:
|
||
|
||
- **variance** of the CSI amplitude series, thresholded at the midpoint
|
||
between the `empty` anchor's variance and the mean variance of the
|
||
person-present anchors;
|
||
- **mean shift** — `|mean − empty_mean|`, thresholded at half the
|
||
empty→occupied mean distance.
|
||
|
||
Presence fires if **either** channel crosses its threshold. Both thresholds
|
||
are learned entirely from the amplitude statistics of your enrollment
|
||
anchors — there is no body-size, RCS (radar cross-section), Doppler-signature,
|
||
or any other physical feature in this code that separates "a full-grown
|
||
adult moved" from "a cat walked past" or "a dog jumped on the couch." If a
|
||
pet's motion perturbs the CSI amplitude by roughly the same amount as the
|
||
`small_move` anchor did during your enrollment, `PresenceSpecialist` will read
|
||
it as occupied, because that's mechanically what the threshold measures.
|
||
|
||
The closest thing to a safeguard is `AnomalySpecialist`
|
||
(`specialist.rs:386-448`), a generic novelty detector that flags a live
|
||
window as "anomalous" when it's far (in embedding distance) from every
|
||
enrolled anchor prototype. It is **not** a validated pet filter — it will
|
||
flag *any* statistically unusual signal as anomalous or normal depending on
|
||
how close it happens to land to your anchors, with no guarantee it
|
||
distinguishes species or motion source. A pet whose motion pattern happens
|
||
to resemble the `small_move` anchor would not be flagged as anomalous at all.
|
||
|
||
**Practical takeaway for a homeowner with pets:** expect presence/posture
|
||
readings to occasionally trigger on pet motion, especially larger animals or
|
||
motion near the sensor. There is currently no configuration option or code
|
||
path to suppress this.
|
||
|
||
---
|
||
|
||
## 4. Does the empty-room baseline need "typical" conditions (HVAC running) or true silence?
|
||
|
||
The short answer, grounded in how the baseline is actually computed: **a
|
||
stationary, continuously-running interferer (a fan, HVAC blower, humidifier)
|
||
that is present for the *entire* capture window becomes part of what "empty"
|
||
means, and gets subtracted out naturally** — that's a direct consequence of
|
||
how the statistics are computed, not a documented feature you have to
|
||
configure.
|
||
|
||
`CalibrationRecorder` uses Welford's online algorithm to accumulate a running
|
||
mean and variance per subcarrier over however many frames you feed it
|
||
(`ruvsense/calibration.rs`). If a fan is running steadily the whole time you
|
||
capture the baseline, its contribution is baked into `amp_mean`/`amp_variance`
|
||
for every frame equally, so the resulting baseline already represents "empty
|
||
room with the fan on" — and at runtime, `BaselineCalibration::subtract()`
|
||
removes exactly that reference, so a room in the same steady state reads as
|
||
quiet. The design intent documented in ADR-135 §1.1 is explicit about this:
|
||
the whole point of baseline subtraction is to remove "hardware-induced gain
|
||
bias and environment-fixed multipath" so downstream motion detectors aren't
|
||
tripped by things that are always there.
|
||
|
||
**What actually matters is consistency, not silence**: capture the baseline
|
||
under whatever background conditions the room will normally be in during
|
||
real use (HVAC/fans running as usual), and try to keep the room in that same
|
||
steady state for the entire capture window. What the code cannot correct
|
||
for is a background condition that **changes partway through** the capture
|
||
(e.g. HVAC cycles on 15 seconds into a 30-second capture) — that would bias
|
||
the Welford mean/variance toward an in-between state that matches neither
|
||
"HVAC off" nor "HVAC on" well.
|
||
|
||
There is a **real-time guard during capture** that can catch gross problems:
|
||
`--abort-z-threshold` (default `2.0`) aborts the capture if the per-frame
|
||
amplitude z-score median stays above that threshold for 20 consecutive
|
||
banner intervals (`v2/crates/wifi-densepose-cli/src/calibrate.rs:82-83,
|
||
163-178`). This is designed to catch someone walking through mid-capture, not
|
||
necessarily short-duration mechanical noise — we found no test exercising it
|
||
against an HVAC-cycling scenario specifically, so how it behaves for
|
||
"appliance turns on mid-capture" is unverified.
|
||
|
||
### What we could not verify — and a design gap worth knowing about
|
||
|
||
ADR-135 §2.5 describes a much more sophisticated staleness-detection system:
|
||
a `drift_score` computed from ongoing z-scores, a `BaselineDrift` event fired
|
||
after sustained drift, and a `baseline_stale` flag published over the
|
||
sensing WebSocket. **We searched the actual `calibration.rs` implementation
|
||
and none of that exists in code** — there is no `drift_score` field, no
|
||
`BaselineDrift` event, and no `baseline_stale` flag anywhere in
|
||
`v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs`. That part of
|
||
ADR-135 is aspirational design, not shipped behavior.
|
||
|
||
What *is* implemented, at a different layer, is a much simpler check on the
|
||
**trained specialist bank** (not the raw baseline): `SpecialistBank` stores
|
||
the `baseline_id` it was trained against, and `SpecialistBank::is_stale()`
|
||
(`v2/crates/wifi-densepose-calibration/src/bank.rs:102-104`) returns `true`
|
||
whenever the *current* baseline's id doesn't match the id the bank was
|
||
trained on. Re-running `calibrate` always produces a new baseline id, so
|
||
**any** recalibration — whether because of furniture moving, a genuinely
|
||
stale reference, or just re-running the command — immediately marks every
|
||
previously trained specialist bank stale, and you'll need to re-run `enroll`
|
||
and `train-room` afterward. There is no partial/graded staleness signal
|
||
(no "how stale"), only this all-or-nothing id comparison.
|
||
|
||
**Practical guidance:**
|
||
|
||
1. Calibrate with the room in its normal, steady background state (HVAC,
|
||
fans, fridge compressor, etc. running as they normally would) and keep
|
||
that state constant for the whole `--duration-s` window.
|
||
2. If you significantly change background conditions later (move furniture,
|
||
add a permanent appliance, change HVAC routine) or notice the sensing
|
||
quality degrade, re-run `calibrate` — this is an explicit, operator-driven
|
||
step; there is no code path that recalibrates for you.
|
||
3. Re-running `calibrate` invalidates every specialist bank trained against
|
||
the old baseline (via the `baseline_id` mismatch above) — plan to re-run
|
||
`enroll` and `train-room` right after.
|
||
|
||
---
|
||
|
||
## Quick reference: commands and defaults actually in the code
|
||
|
||
```bash
|
||
# Stage 1 — empty-room baseline. Room must be empty for the whole window.
|
||
wifi-densepose calibrate \
|
||
--udp-port 5005 --duration-s 30 --tier ht20 --output ./baseline.bin
|
||
# Hard requirement: >= 600 recorded frames, or calibration fails.
|
||
|
||
# Stage 2+3 — guided enrollment (8 fixed anchors, ~4 minutes total)
|
||
wifi-densepose enroll --baseline ./baseline.bin --room living-room \
|
||
--output ./enrollment.json --attempts 2
|
||
|
||
# Stage 4 — train the specialist bank from whatever anchors were accepted
|
||
wifi-densepose train-room --enrollment ./enrollment.json \
|
||
--output ./room-bank.json
|
||
|
||
# Check what actually trained (and whether the bank is stale)
|
||
wifi-densepose room-status --room living-room
|
||
```
|
||
|
||
Source references for everything above:
|
||
- `v2/crates/wifi-densepose-cli/src/calibrate.rs`
|
||
- `v2/crates/wifi-densepose-cli/src/room.rs`
|
||
- `v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs`
|
||
- `v2/crates/wifi-densepose-calibration/src/enrollment.rs`
|
||
- `v2/crates/wifi-densepose-calibration/src/anchor.rs`
|
||
- `v2/crates/wifi-densepose-calibration/src/specialist.rs`
|
||
- `v2/crates/wifi-densepose-calibration/src/bank.rs`
|
||
- `docs/adr/ADR-135-empty-room-baseline-calibration.md`
|
||
- `docs/adr/ADR-151-room-calibration-specialist-training.md`
|