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.
16 KiB
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
emptyanchor plus at least one anchor where a person was expected present — missing either,PresenceSpecialist::train()returnsNoneand presence detection is unavailable in that bank. - anomaly needs at least 2 anchors total, of any kind.
- restlessness needs
sleep_posture(orlie_downas a fallback) andsmall_move. - posture needs at least one anchor that establishes a posture
(
stand_still,sit,lie_down, orsleep_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
emptyanchor'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:
- 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-swindow. - 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. - Re-running
calibrateinvalidates every specialist bank trained against the old baseline (via thebaseline_idmismatch above) — plan to re-runenrollandtrain-roomright after.
Quick reference: commands and defaults actually in the code
# 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.rsv2/crates/wifi-densepose-cli/src/room.rsv2/crates/wifi-densepose-signal/src/ruvsense/calibration.rsv2/crates/wifi-densepose-calibration/src/enrollment.rsv2/crates/wifi-densepose-calibration/src/anchor.rsv2/crates/wifi-densepose-calibration/src/specialist.rsv2/crates/wifi-densepose-calibration/src/bank.rsdocs/adr/ADR-135-empty-room-baseline-calibration.mddocs/adr/ADR-151-room-calibration-specialist-training.md