Merge origin/main into contrib/mesh-aligned-rate-gate

Both sides appended to the end of Kconfig.projbuild -- upstream added the
thermal monitoring menu, this branch adds the two CSI gate options. Kept both.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
This commit is contained in:
Joeandclaude-flow committed 2026-09-07 12:29:56 -04:00
commit 521b29ea21
57 files changed
+7785 -235

No files matched your search

+6
View File
@@ -311,3 +311,9 @@ v2/crates/wifi-densepose-sensing-server/data/
# ADR-324: wasm-bindgen output for ruview-offaxis is generated locally
# (see the crate README); never commit generated artifacts.
v2/crates/ruview-offaxis/pkg/
# fal.ai visual-teacher LoRA training data/weights -- regenerable, never
# commit training images or model weights (ADR-353)
scripts/fal-visual-teacher/training-data/
scripts/fal-visual-teacher/training-data.zip
scripts/fal-visual-teacher/output/
+7
View File
@@ -0,0 +1,7 @@
{
"endpoint": "fal-ai/flux-lora",
"lora_url": "https://v3b.fal.media/files/b/0aa8c60d/OjaBs6Gi8pMhZqMo79iym_pytorch_lora_weights.safetensors",
"prompt": "ruviewstyle room visualization, a living room with a glowing cyan pose skeleton overlay, wifi signal arcs radiating across the space, a vitals readout panel, dark tech aesthetic, synthetic sensor visualization",
"scale": 1.0,
"source_image_url": "https://v3b.fal.media/files/b/0aa8cf67/JH1qyC3GCKQ9ojkrAUXe9.png"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

+162
View File
@@ -0,0 +1,162 @@
# ADR-353: fal.ai synthetic visual teacher LoRA
- **Status**: Accepted
- **Date**: 2026-09-02
- **Deciders**: ruv
- **Owners**: RuView visualization / neural-styling maintainers
- **Tags**: fal, lora, synthetic-visualization, display-only, cost-control
- **Parent**: None
- **Extends**: None
- **Supersedes**: None
## Decision
RuView will use one real, fal.ai-trained LoRA fine-tune (a "synthetic visual
teacher") to steer fal.ai image/video generation toward a consistent
"RuView-style" room visualization — glowing pose overlays, WiFi signal arcs,
vitals readout panels, dark tech aesthetic — instead of relying on ad hoc
prompt engineering against a stock model.
**The architecture boundary is non-negotiable:**
> The implementation boundary is now clear: fal.ai will train an optional
> synthetic visual teacher, while RuView's measured RF, pose, vitals,
> identity, and confidence remain local and authoritative.
Concretely:
- The trained model produced here is ONLY ever allowed to generate
supplementary synthetic visual/pixel output for display or broadcast.
- It must NEVER be a source of, or influence, any measured/derived value: RF/
CSI measurements, pose estimates, vitals (heart rate/breathing rate),
identity, or confidence/quality scores. Those stay local, deterministic,
and authoritative — the existing `wifi-densepose-engine` /
`ruview-twin` / `ruview-witness` stack — completely untouched by this work.
- Any output this model produces must be clearly framed as SYNTHETIC per this
repo's rule: "Never present WiFi sensing as camera-grade."
- This model has no read or write path into the RF/pose/vitals/identity
pipeline. It consumes only a text prompt (and optionally a reference
image) and returns pixels for display.
**Evidence status:** the training data extraction, training submission
script, and validation script are real and committed. The training run
completed and validation succeeded — trained model id, actual spend, and the
validation sample are recorded in "Result" below. Actual-vs-billed cost
reconciliation against the fal.ai account dashboard remains UNMEASURED (the
figure below is a documented-pricing estimate).
## Context
A concurrent workstream (`ruview-fal-room-viz` / `ruview-room-viz-h3-upgrade`)
generated real fal.ai room-visualization videos across several style presets
(architectural, abstract, branded, and an "h3" refresh: architectural,
cinematic, minimal, abstract) using stock fal.ai video models driven by
prompt engineering alone. A separate concurrently-running agent
("realtime-dual-renderer") is building a neural-styling layer that needs a
*specific* trained model id to call, rather than continuing to rely on prompt
engineering against a generic model.
This ADR's job is narrow: turn the real captured style presets into a real
trained fal.ai LoRA, verify it actually works, and hand off the model id. It
does not implement the renderer integration — that is
`realtime-dual-renderer`'s scope — and it does not touch the measured
RF/pose/vitals/identity/confidence pipeline in any way.
## Training data
Source: the real fal.ai-generated room-visualization MP4s already captured
this session (read from `/tmp/room-viz-*/room-viz.mp4` on `ruvultra`, one per
style preset — not committed to the repo; the repo has no camera or person
data in these clips, they are synthetic renders). `scripts/fal-visual-teacher/extract_frames.py`
extracts one JPEG frame every 1.5 seconds from each of the 7 real style-preset
videos (architectural, abstract, branded, h3-architectural, h3-cinematic,
h3-minimal, h3-abstract), producing 27 frames total, each paired with a
caption `.txt` describing the shared RuView visual motifs. Frames and the
training zip are gitignored (`scripts/fal-visual-teacher/training-data/`,
`training-data.zip`) — they are regenerable from the source MP4s and are not
committed, consistent with the repo's no-generated-artifacts rule.
## Training endpoint and pricing (verified 2026-09-02)
- **Endpoint**: `fal-ai/flux-lora-fast-training` — a real, currently-listed
fal.ai LoRA training endpoint for FLUX.1, verified against
`https://fal.ai/models/fal-ai/flux-lora-fast-training/api`.
- **Pricing**: $2 per training run at the default 1000 steps, scaling
linearly with steps (verified against fal.ai's own pricing text on the
model page).
- **Mode**: submitted with `is_style: true` (style LoRA, not
subject/character LoRA) and explicit per-frame caption files, so fal.ai's
auto-captioning/segmentation is skipped in favor of our own captions.
- **Inference endpoint** (validation only): `fal-ai/flux-lora`, $0.035 per
megapixel, accepting `loras: [{ path, scale }]` pointing at the trained
`diffusers_lora_file` URL.
## Budget
- **Original cap**: $15 total (this task's cap only; not shared with other
concurrent tasks' budgets this session).
- **Raised cap**: the user explicitly raised this task's cap to **$100** on
2026-09-02, mid-run, as real headroom for a larger/better run or retries —
not an instruction to spend all of it.
- **Planned spend**: ~$2 for the training run (1000 steps, default) + ~$0.04
per validation inference image.
- **Actual spend**: ~$2.04 (one 1000-step training run at $2/run + one
landscape_16_9 validation image at $0.035/megapixel, rounded up to 1
megapixel). This is a documented-pricing estimate (`CLAIMED`), not yet
reconciled against the fal.ai account billing dashboard (`MEASURED`).
Comfortably under both the original $15 cap and the raised $100 cap.
- **Why no larger follow-up run**: the first validation image already showed
a clean, on-style result (glowing pose overlay, WiFi arcs, vitals panels,
dark aesthetic, all present and legible) on the first attempt. Spending
further into the raised budget for marginal improvement was judged not
worth it against a result that already meets the bar; the fallback path in
"Consequences" was not needed.
## Result
- **Trained model id / LoRA weights URL**:
`https://v3b.fal.media/files/b/0aa8c60d/OjaBs6Gi8pMhZqMo79iym_pytorch_lora_weights.safetensors`
(131 MB `diffusers_lora_file`, paired `config.json` at
`https://v3b.fal.media/files/b/0aa8c60d/-GU6dd_2ZFqq18I0CuPOB_config.json`).
Use with `fal-ai/flux-lora` (or any fal.ai FLUX.1 endpoint accepting a
`loras: [{ path, scale }]` array) — trigger word `ruviewstyle`. Full
training record: `scripts/fal-visual-teacher/output/training-result.json`
(gitignored — regenerate by re-running `train_lora.py`, or copy the URL
above).
- **Training request id**: `01a060b2-c940-7972-abf9-ea48847eb2ae` (fal.ai
`fal-ai/flux-lora-fast-training`, 1000 steps, `is_style: true`, 27 training
images, completed in 376s on an H100/H200 worker).
- **Actual spend**: ~$2.04 (see Budget above).
- **Validation sample**: `assets/fal-visual-teacher-sample.png` (+ sibling
`assets/fal-visual-teacher-sample.json` with the exact prompt, LoRA scale
1.0, and source fal.ai URL) — a real `fal-ai/flux-lora` inference call
using the trained LoRA, generated 2026-09-02. The image shows a glowing
cyan pose skeleton, WiFi signal arcs, vitals-style readout panels, and a
HUD-style TV overlay in a dark living room — confirming the LoRA learned
and reproduces the intended RuView visual motifs.
- **Consumer**: `realtime-dual-renderer` (separate worktree
`~/projects/ruview-worktrees/realtime-render`) — notified directly with the
model id above.
## Consequences
A real trained LoRA gives the display layer a specific, repeatable style
target instead of prompt engineering against a stock model, at a bounded and
small real cost ($2 training + cents per generated image). Because the model
only ever produces pixels for a prompt, and has no schema path into any
measured/derived RuView value, it cannot silently become a second source of
truth for pose, vitals, identity, or confidence — that boundary is structural
(no shared types, no shared data path), not just documented convention.
If the trained LoRA does not meaningfully improve style consistency over the
stock model (a genuine risk with 27 training images and default steps), the
fallback is to keep using prompt engineering against the stock model and note
that outcome honestly here rather than claim a training win that isn't real.
## References
- `scripts/fal-visual-teacher/extract_frames.py` — real frame extraction
- `scripts/fal-visual-teacher/train_lora.py` — real training submission
- `scripts/fal-visual-teacher/validate.py` — real validation inference
- [fal.ai Flux LoRA Fast Training](https://fal.ai/models/fal-ai/flux-lora-fast-training)
- [fal.ai FLUX.1 with LoRAs (inference)](https://fal.ai/models/fal-ai/flux-lora)
@@ -0,0 +1,89 @@
# ADR-354: fal.ai forecast governance and spend enforcement now real, in the vendored RuForecast submodule
- **Status**: Accepted
- **Date**: 2026-09-02
- **Deciders**: ruv
- **Owners**: RuView forecast training, security, and release maintainers
- **Tags**: forecasting, training, fal, governance, submodule, cost-control
- **Parent**: ADR-349
- **Extends**: ADR-348, ADR-349
- **Supersedes**: None
## Decision
ADR-349 specified two governance controls for RuView Forecast's fal.ai
hosted path: operator-verified dataset/schema/policy/recipe authorization,
and an explicit local spend-approval record bound to the request digest and
maximum units. A 9-agent code review of the training stack merged as PR
#1766 found that neither control was actually wired into the code path that
builds a submittable, fal-destined training contract — `FalGovernanceVerifier`
existed with zero callers outside its own tests, and no spend-approval type
existed anywhere in the codebase.
That code no longer lives directly in this repository. Between the review
and this fix, `v2/crates/ruview-forecast-{core,model,train}` was extracted
into its own repository, `github.com/ruvnet/RuForecast`, and is now vendored
back into `v2/crates/ruforecast` as a git submodule (crates renamed to
`ruforecast-{core,model,train}`). This ADR records two things at the RuView
level:
1. **The governance and spend-approval gaps are fixed**, in
`ruvnet/RuForecast` PR #2 (branch `fix/governance-and-spend-enforcement`).
The full technical decision record — what changed, why, and the
requirements/acceptance table — lives in that repository's own
[ADR-350](https://github.com/ruvnet/RuForecast/blob/main/docs/adr/ADR-350-fal-governance-and-spend-enforcement.md),
which this ADR defers to rather than duplicating. In summary: a
fal-destined `TrainSpec` can no longer be constructed without a real,
operator-signed, single-use-verified governance receipt; a hosted
reservation can no longer be built without a real, operator-signed spend
approval bound to the exact request digest and cost ceiling; and several
related correctness/resilience gaps on the same trust boundary
(unauthenticated Direct Server webhook, no execution timeout, mismatched
local/hosted artifact budgets, cancellation permanently wedging a job)
were fixed in the same pass.
2. **The submodule pointer in this repository is bumped** to the commit
containing that fix, once `ruvnet/RuForecast`'s own CI is green on that
PR and it merges. This RuView-side change is deliberately small: a
pointer bump plus this ADR, not a re-implementation or re-review of code
that now lives, is tested, and is reviewed in its own repository.
## Context
RuView's own copies of ADR-348 and ADR-349 predate the extraction and still
describe the crates as if they lived directly under `v2/crates/`. They are
left as-is here (not corrected path-by-path) since the canonical, actively
maintained copies of the forecast-specific ADRs now live in
`ruvnet/RuForecast`'s own `docs/adr/`, which was seeded with copies of
ADR-348/349 at extraction time and now also carries ADR-350. Consult that
repository for the forecast subsystem's own decision history going forward;
this repository's ADR series should record RuView-level integration
decisions about the vendored submodule (like this one), not attempt to
re-host the submodule's internal history.
This repo's CLAUDE.md requires an ADR for load-bearing upstream changes.
The governance-enforcement fix is load-bearing for RuView even though none
of its source lines are: it changes what RuView's vendored forecast
subsystem actually requires before it will reach real, billable fal.ai
infrastructure on behalf of a RuView deployment.
## Consequences
Once the submodule pointer here is bumped, any RuView-side code, CLI
wrapper, or documentation that assumes the old, unenforced fal.ai
submission path (a bare `SourceState::synthetic` claim with no verified
governance receipt or spend approval) will no longer compile or run against
the vendored crate — `TrainSpec::new_fal_synthetic` and
`ReservedSyntheticSubmission::reserve` both gained new required parameters.
A repository-wide search found no such RuView-side caller today; this ADR
records the change in case one is added later without checking the
submodule's current API first.
This pointer bump must not merge before `ruvnet/RuForecast` PR #2 merges and
its CI is green — see that PR for status.
## References
- [ADR-349](./ADR-349-governed-local-and-fal-forecast-training.md) (this repository's copy; canonical copy is in `ruvnet/RuForecast`)
- [ruvnet/RuForecast PR #2](https://github.com/ruvnet/RuForecast/pull/2)
- [ruvnet/RuForecast ADR-350](https://github.com/ruvnet/RuForecast/blob/main/docs/adr/ADR-350-fal-governance-and-spend-enforcement.md)
- [`v2/crates/ruforecast`](../../v2/crates/ruforecast) (submodule)
@@ -0,0 +1,134 @@
# ADR 355: Local empty room bootstrap prior
Status: Accepted
Date: 2026-09-04
## Context
Most installations are occupied when RuView first starts. An uncalibrated
signal heuristic can interpret HVAC, clock error, neighboring motion, or a
short covariance window as a person. It can also surface plausible numeric
vitals before a room model establishes occupant attribution.
One empty home is not representative training data for all homes and contains
no positive human examples. It is useful only as a recent negative prior for
the same installation. It cannot define a universal human signal model.
The field model was memory only. The server also used a relative `data/`
directory, which is unreliable for embedded application hosts.
## Decision
RuView may persist a completed empty room field model as a local bootstrap
prior only after two server controlled stages:
1. Normal calibration reaches both 1,000 accepted frames and 600 seconds.
The frame target represents twenty complete fifty frame runtime windows.
A caller may select one currently live ESP32 source. The server checks its
freshness before replacing an active bootstrap prior, binds collection to
that source, and rejects frames from other radios. When no source is
selected, the first accepted ESP32 source preserves legacy behavior.
2. Promotion observes 12 new samples spaced by at least one second. Each
sample has a four second bounded acquisition timeout. At least 10 must
resolve empty, all ticks must be fresh, and none may publish numeric vital
signs.
The stored image contains aggregate means, environmental modes, mode energies,
noise statistics, sorted scalar residual references, configuration, one source
node ID, and a server generated model ID. It excludes raw CSI, waveforms,
device addresses, room names, pose, identity, credentials, and positive human
labels. Runtime matching compares a fifty frame mean with the learned residual
distribution. Its score is empirical background conformance, not an absence
probability.
The image is bound to a SHA 256 digest of the stable installation ID, carries
an integrity digest over the exact stored payload bytes, is capped at 1 MiB,
and inherits the field model expiry.
Files with an invalid schema, dimensions, values, node set, installation
binding, digest, size, or lifetime fail closed.
Restoring an image creates a field model in `bootstrap_only` mode. It may
reduce an uncalibrated false person count, but it cannot authorize calibrated
evidence or numeric heart and breathing rates. Explicit calibration replaces
it with fresh statistics. Cancelling an unfinished capture restores a valid
prior. Administrator scoped reset removes it.
A restored image may receive one operator confirmed held out empty refinement.
The server measures 12 fresh, source bound runtime residuals at one second
spacing and requires zero stale samples and zero numeric vital samples. It
persists only the scalar residuals. The new boundary is the larger of the
existing threshold and 110 percent of the held out 95th percentile, capped at
125 percent of the prior threshold. The one use marker persists in the model,
so repeated requests cannot ratchet the threshold upward.
The status endpoint reports the learned runtime window, residual threshold,
reference count, current background conformance, and the negative only
authority boundary. A restored model never resumes collection and never
persists raw calibration frames.
The server accepts `--data-dir` or `RUVIEW_DATA_DIR`, allowing an application
host to provide a protected state directory.
## Consequences
Startup can use recent measured background structure instead of only generic
heuristics. No accuracy lift is claimed until occupied and empty held out data
from multiple installations is recorded.
The largest failure mode is overfitting to a temporarily quiet home and
suppressing a weak occupant. Controls are low authority, 24 hour expiry,
installation binding, vital abstention, explicit recalibration, and reset.
A human signal model still requires positive, consented, leakage separated
occupied sequences. Empty data is never relabeled as human training.
## Implementation
1. `wifi-densepose-signal::ruvsense::field_model` exports and validates
`FieldModelSnapshotV1`.
2. `wifi-densepose-sensing-server::bootstrap_baseline` owns schema, integrity,
storage, lifetime, and the held out gate.
3. `POST /api/v1/calibration/bootstrap/promote` measures the validation window
and stores the image only on pass.
4. `GET /api/v1/calibration/status` reports bootstrap authority and explicitly
denies calibrated evidence and numeric vital authority.
5. Numeric vitals fail closed unless an explicit fresh calibration resolves
exactly one occupant at sufficient signal quality and confidence.
6. The legacy `edge_vitals` WebSocket message applies the same publication
gate as `sensing_update`. When the bootstrap background matches, it emits
absent state, zero people, null numeric rates, and an explicit abstention
reason instead of forwarding raw edge candidates.
7. `POST /api/v1/calibration/start?source_node_id=N` selects a live source for
deterministic single link capture. A missing or stale selection fails
without disabling the active bootstrap prior.
8. `POST /api/v1/calibration/bootstrap/refine?confirmed_empty=true&source_node_id=N`
performs the bounded one time held out refinement. It requires administrator
authority when transport authentication is enabled.
## Measured installation evidence
One physical empty home run on source Node 5 collected 1,762 accepted frames
over 655 seconds and produced 35 privacy reduced reference windows. Promotion
observed 12 of 12 fresh empty samples, zero stale samples, and zero numeric
vital samples. After process restart, 12 of 12 fresh samples matched the empty
background and contained no numeric vital signs. Background conformance scores
ranged from 50.0 to 64.3 percent with a median of 54.3 percent.
This is measured negative evidence for one installation. No occupied positive
holdout was recorded, so it does not prove person recall, person counting, or
cross home accuracy improvement.
## Acceptance test
With a physically empty room, complete calibration on stable nodes, promote
the single source model, and restart the server with the same installation ID
and data directory. Status must report `binding_mode=bootstrap_only`, zero
collection frames, and false authority for calibrated evidence and numeric
vitals. Twelve fresh empty samples must contain no numeric vital signs.
Starting a new room calibration must replace the prior and begin at frame zero.
When several radios are live, selecting Node 5 must leave Nodes 3 and 7 out of
the source set for the entire capture.
After restart, a confirmed empty holdout may refine the restored boundary once.
The stored snapshot must reject a second refinement and must continue to deny
calibrated evidence and numeric vital authority.
@@ -0,0 +1,151 @@
# ADR 356: Rate truthful CSI and reliability gated room adaptation
Status: Accepted
Date: 2026-09-05
## Context
RuView mixed three different notions of time. ESP32 firmware requested a packet
rate, the server observed a delivered rate, and several server paths still used
fixed 2 Hz, 10 Hz, or 20 Hz assumptions. One feature named
`dominant_freq_hz` was not a temporal frequency at all. It was the index of the
strongest subcarrier multiplied by 0.05. A second defect compared a newly
inserted frame with itself, forcing that motion delta to zero.
These errors can move a spectral peak into the breathing or heartbeat band and
produce plausible but false values. They also make models sensitive to WiFi
traffic and processor scheduling rather than physical motion.
ESP-IDF documents that `wifi_csi_info_t.first_word_invalid` marks the first four
CSI bytes invalid because of a hardware limitation. It also documents that the
CSI receive callback runs in the WiFi task and should hand work to a lower
priority task rather than perform lengthy processing. RuView serialized the
invalid bytes unchanged and fed the original buffer to edge processing.
Recent work suggests four useful directions with different maturity:
1. OpenCSI represents each link as a dimensionless residual against its own
quiet period temporal standard deviation and exposes baseline maturity. Its
reported transfer results are limited to binary presence.
2. MORIC derives motion centered delay and Doppler views to reduce dependence
on static environment specific CSI structure.
3. Parameter efficient CSI crowd counting combines self supervised pretraining,
small room adapters, and a stateful counting machine.
4. WiFi JEPA masks complete radio links and predicts their latent embeddings,
using real and ray traced CSI for pose representation learning.
The papers report promising external results. None is measured RuView evidence,
and none authorizes deployment without leakage free occupied and empty held out
evaluation.
## Decision
### Firmware ingestion
1. When ESP-IDF marks the first CSI word invalid, zero the first four serialized
CSI bytes while retaining the fixed packet shape.
2. Set ADR-018 byte 19 bit 5 to record that sanitation occurred.
3. Feed edge DSP from the sanitized serialized payload, never the original
invalid driver buffer.
4. Keep callback work bounded to sanitation, serialization, and the existing
queue handoff. Do not add transforms to the WiFi task.
### Server time model
1. Estimate delivered CSI rate from accepted monotonic frame timestamps after
five samples. Use 20 Hz only during startup.
2. Cap the physical input rate at the firmware contract of 50 Hz so a burst or
timestamp artifact cannot retune downstream filters above their supported
range.
3. Reconfigure the vital detector only after a 20 percent rate change. Clear
temporal and smoothing state whenever its clock changes. Samples calculated
with different clocks must never be blended.
4. Compute temporal dominant frequency from the recent mean amplitude series
with demeaning, a Hann window, and bounded direct Fourier bins from 0.1 Hz to
the smaller of 4 Hz or 45 percent of sample rate.
5. Return zero for short, flat, nonfinite, or spectrally ambiguous windows.
6. Compare the current frame with the prior frame, not itself, for motion delta.
7. Export zero sample rate until the estimate is mature. Never present a startup
fallback as a measured node property.
### Model compatibility and room reliability
1. Preserve the historical fifth input feature for version 1 adaptive models at
the model boundary. Version 2 and later models consume the true temporal
frequency. This prevents an existing model from silently receiving a
different input distribution after a server update.
2. A background model is unreliable until it contains at least ten finite,
nonnegative residual references. An unreliable model cannot suppress a
runtime change and exposes no conformance score.
3. Expose background maturity, reliability, and a robust residual Z score based
on the median and median absolute deviation.
4. Keep the persisted empty home prior negative only. It cannot authorize
calibrated presence, person count, pose, or numeric vitals.
### Model roadmap
The following work remains proposal only:
1. Evaluate an OpenCSI style per link quiet residual normalization for binary
presence without replacing absolute magnitude features used for motion.
2. Evaluate delay and Doppler views after phase sanitation for activity.
3. Pretrain a frozen multilink encoder and adapt small room specific modules for
counting.
4. Evaluate link masked self supervision for pose only when at least three
spatially distinct links and synchronized positive pose labels exist.
Every candidate uses immutable manifests and splits by room, subject,
recording, and time. It is compared with the frozen baseline under identical
seeds. Counting includes confusion matrices and mean absolute error. Pose
includes PCK at 20 centimetres, MPJPE, and the mean pose baseline. A shuffled
label control must fail.
Promotion requires at least 10 percent held out lift, less than 10 milliseconds
added p95 inference latency, no more than 20 percent peak memory overhead, zero
leakage or secret findings, frozen anchor retention, and no regression in empty
rejection or occupied recall. Promotion remains an explicit maintainer action.
## Consequences
The firmware no longer preserves a known invalid prefix as sensor evidence. The
server uses an observed physical clock for spectral features and vital filters.
Existing version 1 adaptive models remain compatible. Sparse background samples
fail open to observation and fail closed for suppression.
The direct Fourier estimator is intentionally small and bounded to 128 temporal
samples. It is portable and deterministic, but it is not a replacement for a
phase sanitized delay and Doppler pipeline.
The largest remaining uncertainty is human recall. Empty home evidence can
measure false presence but cannot show whether weak occupied signals are being
suppressed. The fix path is a consented, leakage separated occupied holdout with
multiple spatial links and synchronized reference labels.
## Research basis
1. Espressif ESP-IDF WiFi CSI programming guide:
https://docs.espressif.com/projects/esp-idf/en/v5.5.4/esp32/api-guides/wifi.html
2. Espressif ESP CSI reference implementation:
https://github.com/espressif/esp-csi
3. OpenCSI:
https://arxiv.org/abs/2607.26665
4. MORIC:
https://arxiv.org/abs/2506.12997
5. Parameter efficient CSI crowd counting:
https://arxiv.org/abs/2601.02203
6. WiFi JEPA:
https://arxiv.org/abs/2607.11064
## Acceptance test
Run the firmware serializer host tests and the focused hardware, signal, and
sensing server Rust suites. The deterministic serializer test must prove the
first four bytes are zero only when ESP-IDF marks them invalid and bit 5 must
round trip through the Rust parser. The temporal estimator must recover a
1.2 Hz synthetic signal within 0.21 Hz, abstain on short and flat inputs, and
remain below 10 milliseconds p95 on the target Mac. Existing version 1 model
input must retain the legacy proxy. A background model with nine references
must never report a match or suppress a runtime change. Physical firmware is
not qualified until an S3 and C6 boot and stream the sanitized flag without
watchdog, memory, or transport regressions during a five minute burn in.
@@ -0,0 +1,143 @@
# ADR 357: Raw CSI calibration sample integrity
Status: Accepted
Date: 2026-09-06
## Context
The empty room field model requires 1,000 accepted observations over at least
600 seconds. The sensing server previously invoked calibration from both the
raw CSI path and the one second edge vital path. Edge vital packets did not add
a CSI observation, so that path repeatedly fed the existing history tail. A
counter could therefore satisfy the frame gate without representing 1,000
independent CSI packets.
Node liveness also combined edge vital and CSI traffic. A source emitting only
edge vital summaries could appear live enough to start calibration even though
it had no fresh raw CSI. Finally, cloning the complete per node history before
each feed copied as much as 204,800 bytes for a 100 frame, 256 subcarrier
window even though calibration consumes only the current observation.
The earlier single installation result recorded in ADR 355 predates this
admission boundary. Its frame count is not accepted as evidence of independent
CSI coverage. It remains useful only as diagnosis of the failure mode and must
not be promoted as a production bootstrap prior.
## Decision
1. Only a packet on the frozen, model specific CSI grid may advance field
calibration. Edge vital packets update their own health and presentation
state but never feed the field model. The independent live inference grid
remains unchanged.
2. Each calibration session stores the last admitted wrapping sequence for
every contributing node. Duplicate sequences are ignored. Normal forward
progress and the `u32::MAX` to zero wrap are accepted.
3. A packet one through sixteen sequence values behind the current high water mark
is bounded UDP reordering. It is recorded and dropped without feeding the
model or moving the high water mark. A backward depth above sixteen, or an
ambiguous half range sequence, latches that node as faulted for the remainder
of the session. Status exposes the policy, reorder window, per node reorder
count, maximum observed depth, and `sequence_fault_node_ids`. Finalization
returns `calibration_sequence_discontinuity` after a fault. The operator must
cancel and restart the empty room capture.
4. A requested source may start calibration only after a header only, bounded
observation window identifies one stable grid. The selected grid needs at
least ten seconds of evidence, a measured rate of at least 2 Hz, a latest
sample newer than five seconds, and no observed gap of five seconds or more.
Calibration status reports the immutable grid, selection evidence, current
grid age, and `last_sequence_by_node` without exposing CSI values.
5. Start, cancel, reset, and process initialization clear sequence admission
state. A failed feed does not advance the sequence cursor, so the same
packet may be retried after a transient model input failure.
6. The field bridge receives the current amplitude slice rather than a cloned
history. A separate bounded runtime history retains only that same frozen
grid, so the model is never trained on one symbol layout and evaluated on a
different layout. Canonical grid normalization remains unchanged.
7. The exact source grid is stored inside the digest protected bootstrap v2
payload. A restored model rebuilds only its matching runtime history. An
unbound legacy image cannot become an active startup prior.
8. Successful finalization clears the selected grid runtime history. Bootstrap
promotion waits up to 35 seconds for a complete fresh runtime window before
scoring twelve additional samples. This covers a 50 frame window at the 2 Hz
admission floor plus ten seconds of bounded jitter. Calibration observations
therefore cannot enter the held out decision window.
9. Stop and promotion require the frozen grid to remain fresh and reject any
latched sequence fault immediately before persistence. Bootstrap v2 accepts
exactly one source node because the current runtime supports one grid bound
field model.
10. The legacy `--calibrate` startup switch fails closed before opening ports.
Grid evidence does not exist at process start, so operators start the server,
wait ten seconds, then call the explicit single source calibration API.
## Consequences
Calibration frame count now means unique, forward raw CSI observations within
one uninterrupted sequence epoch. A vitals only node cannot produce a
bootstrap model. Late packets never become calibration evidence.
One physical Node 5 capture observed sequence 4,119,566 followed by 4,119,563,
4,119,564, and 4,119,565 in one receive batch. A second independent 90 second
capture observed bounded late packets at depths 9, 10, and 11. The window of
sixteen covers the measured maximum with five positions of margin. At the
firmware acceptance ceiling of 50 Hz, the window represents no more than 320
milliseconds. Late packets are always dropped. Larger regressions remain
failed closed.
The same 90 second capture measured three interleaved HT grids on Node 5. The
64 subcarrier stream delivered 436 frames at 4.84 Hz with a 1.56 second maximum
gap. The 128 stream delivered 100 frames at 1.11 Hz with a 5.05 second maximum
gap. The 192 stream delivered 49 frames at 0.54 Hz with an 11.53 second maximum
gap. Only the 64 grid exceeded the 2 Hz admission floor and stayed inside the
five second freshness boundary, so the final field model binds to that grid.
The current wire format still has no authenticated boot epoch or stable device
identity. A restart very early in a session or a same endpoint adversary cannot
be distinguished cryptographically. A future ADR 018 revision must carry an
authenticated device identifier and a random per boot identifier before the
server claims exact restart or identity collision detection.
No new raw CSI is persisted. Sequence cursors and fault markers remain bounded
in memory to active calibration sources. The bootstrap schema advances to v2
to protect the grid identity while its negative only authority remains
unchanged.
## Implementation
1. `NodeState` records header only recent grid evidence and a separate bounded
field model history.
2. `AppStateInner::maybe_feed_calibration_frame` owns source binding, sequence
ordering, fault latching, model feed, and cursor commit.
3. `field_bridge::maybe_feed_calibration` accepts exactly one current amplitude
slice and rejects an empty observation.
4. Calibration start, status, stop, cancel, reset, promotion, refinement, and
restart enforce the frozen source and grid contract.
5. `bootstrap_baseline` persists the source grid inside the verified v2 image.
6. Finalization clears training history, and promotion blocks until the model
has a complete fresh scoring window plus twelve separately spaced samples.
7. Startup calibration without measured grid evidence exits with a typed
operator instruction instead of creating a model that can never receive a
frame.
## Acceptance test
Unit tests must prove stable grid selection, rate and gap rejection, first
packet acceptance, forward progress, duplicate rejection, source and grid
isolation, maximum counter wrap, empty input retry, measured depth eleven
reordering without a model feed, depth sixteen acceptance, depth seventeen
latching, half range rejection, session clear, receipt visibility, protected
grid persistence, and typed stop refusal.
Tests must also prove startup flag refusal, missing and stale grid refusal,
complete holdout isolation, and sequence fault rejection during holdout.
On physical hardware, begin a Node 5 empty room capture and observe a fresh
sixty second interval. Calibration frame growth must match unique 64 subcarrier
Node 5 sequences while the independent global inference grid remains unchanged.
Edge vital packets and other grids must produce zero growth. The bound grid
must remain active, sequence faults must remain empty, and numeric vital output
must remain absent. Any bounded late packet must appear in the reorder receipts
while leaving the model count unchanged. Complete both the 1,000 frame and 600
second gates, then run the twelve sample server controlled bootstrap holdout.
Restart and prove that only the persisted grid rebuilds the runtime history.
Do not claim human detection improvement until a separate consented occupied
holdout demonstrates retained recall.
+3
View File
@@ -95,6 +95,9 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-348](ADR-348-independent-rust-multivariate-forecasting.md) | Independent Rust multivariate forecasting for RuView | Proposed |
| [ADR-349](ADR-349-governed-local-and-fal-forecast-training.md) | Governed local and fal.ai forecast training | Proposed |
| [ADR-350](ADR-350-ruvector-predictive-memory-and-ruvllm-boundary.md) | RuVector predictive memory and RuVLLM authority boundary | Proposed |
| [ADR-354](ADR-354-forecast-governance-enforcement-in-ruforecast-submodule.md) | fal.ai forecast governance and spend enforcement now real, in the vendored RuForecast submodule | Accepted |
| [ADR-355](ADR-355-local-empty-room-bootstrap-prior.md) | Local empty room bootstrap prior | Accepted |
| [ADR-356](ADR-356-rate-truthful-csi-and-reliability-gated-adaptation.md) | Rate truthful CSI and reliability gated room adaptation | Accepted |
### Platform and UI
+154
View File
@@ -378,3 +378,157 @@ Reproducer: `v2/crates/ruforecast/crates/ruforecast-train/examples/bidmc_prepare
one 76-row (context 64 + horizon 12) window per eligible patient, and writes
governed `train.jsonl`/`test.jsonl`/`train-local.toml` per judge split. Raw
data cached at `/tmp/bidmc-raw/` on the training host only.
### Zero-shot pretrained-checkpoint comparison (2026-09-02)
Every RuForecast result above this line is a **from-scratch-trained** model
(RuForecast's own Burn/Rust architecture, trained on a tiny real or synthetic
corpus) evaluated against trivial baselines. All of them lost. A literature
pass after the BIDMC result above (arXiv:2505.11349, "context parroting";
arXiv:2607.20027, zero-shot HRV forecasting with TimesFM/Chronos/MOIRAI on
wearables) converged on an explanation: published foundation-model wins over
naive baselines on short-horizon, smooth physiological signals come from
**massive real pretraining corpora** (Chronos: ~10M real+augmented series;
TimesFM: ~100B real time points), not from architecture alone — and
RuForecast's from-scratch training used a real corpus many orders of
magnitude smaller (tens to low hundreds of windows), so re-implementing
these architectures from scratch and training small was never going to
replicate their wins.
This test checks the other half of that hypothesis directly: does a **real,
public, pretrained checkpoint**, run **zero-shot** (no fine-tuning, no
training of any kind on this repo's data) on the **exact same BIDMC
cross-entity test windows and metrics** as the BIDMC result above, actually
beat the naive baselines? If the massive-pretraining-corpus explanation is
right, it should — unlike every from-scratch attempt this session.
**Setup (kept identical to the BIDMC test above for a fair comparison):**
same `/tmp/bidmc-raw/` cache (53 real ICU patients, PhysioNet BIDMC, Open
Data Commons Attribution License v1.0), same window construction (context
64s + horizon 12s, 1 Hz, variates HR/RESP/SpO2, one window per patient from
that patient's first 76 rows), same two disjoint patient-partition judges (A:
train 1-37/test 38-53 contiguous; B: train odd/test even interleaved — train
patients are irrelevant here since nothing is trained, only the *test*
partition is used), same quantile set
`[0.05,0.10,0.25,0.50,0.75,0.90,0.95]`, same seasonal period (12), and the
same WQL formula (`2*sum(pinball)/(numQuantiles*sum|actual|)`) and MAE
definition as `ruforecast-core::metrics.rs`. Baselines were independently
re-derived from `ruforecast-core::baseline.rs`'s exact logic in Python and
cross-checked against the BIDMC section above: reproduced last-value WQL was
0.01171 (A) / 0.01013 (B) versus the Rust CLI's originally reported
0.01159 / 0.01002 — a ~1% gap attributable to f32 (Rust) vs f64 (Python)
accumulation, not a methodology difference, so this reproduction is treated
as validated.
Four real, public, pretrained-checkpoint models were run zero-shot on GPU
(RTX 5080, `ruvultra`), per-variate univariate (none of these checkpoints has
a native multivariate mode for these two families, so HR/RESP/SpO2 were
forecast independently and recombined into the same window-level WQL/MAE as
the baselines and the from-scratch RuForecast model):
- **Chronos-Bolt base/small** (`amazon/chronos-bolt-base`,
`amazon/chronos-bolt-small`) — direct quantile-regression heads,
deterministic given input (no sampling).
- **Chronos (T5) small** (`amazon/chronos-t5-small`) — the original
sampling-based Chronos family; `predict_quantiles` internally draws
samples (default `num_samples`), so this one model's numbers below carry
run-to-run sampling variance the other three don't.
- **TimesFM 2.5 (200M, PyTorch)** (`google/timesfm-2.5-200m-pytorch`) —
quantile output uses 10 channels; per the installed package's source
(`timesfm_2p5_base.py`, `decode_index: int = 5`) and empirical
verification across multiple windows/variates, channel 5 is the point
forecast and channels 1-9 are the native quantile levels [0.1..0.9] in
order (channel 0 is an unused duplicate, not a real quantile). Levels 0.05
and 0.95 were clamped to the nearest native level (0.10/0.90); 0.25 and
0.75 were linearly interpolated between the adjacent native levels — the
same class of approximation Chronos-Bolt's own pipeline applies
internally when asked for quantile levels outside its trained range (a
warning it printed for 0.05/0.95 on every call).
| Judge | Model | Test windows | MAE | WQL | WQL vs last-value |
|---|---|---:|---:|---:|---:|
| A | last-value (baseline) | 16 | 0.7882 | 0.01171 | — |
| A | seasonal-naive (baseline) | 16 | 1.0885 | 0.01617 | worse |
| A | Chronos-Bolt-base (zero-shot) | 16 | 0.7802 | 0.00756 | **-35.4%** |
| A | Chronos-Bolt-small (zero-shot) | 16 | 0.7212 | 0.00684 | **-41.6%** |
| A | Chronos-T5-small (zero-shot) | 16 | 0.9487 | 0.00955 | **-18.4%** |
| A | TimesFM-2.5-200M (zero-shot) | 16 | 0.7490 | 0.00731 | **-37.6%** |
| B | last-value (baseline) | 26 | 0.6944 | 0.01013 | — |
| B | seasonal-naive (baseline) | 26 | 0.8697 | 0.01269 | worse |
| B | Chronos-Bolt-base (zero-shot) | 26 | 0.7096 | 0.00719 | **-29.0%** |
| B | Chronos-Bolt-small (zero-shot) | 26 | 0.6852 | 0.00676 | **-33.3%** |
| B | Chronos-T5-small (zero-shot) | 26 | 0.8254 | 0.00886 | **-12.5%** |
| B | TimesFM-2.5-200M (zero-shot) | 26 | 0.6511 | 0.00698 | **-31.1%** |
**Honest result: this is the first test all session where a model beats the
trivial baselines, and it does so decisively and consistently** — all four
pretrained checkpoints beat both last-value and seasonal-naive on WQL, on
both independently-partitioned judges, by double-digit-to-40% margins. This
is a materially larger and more consistent margin than the closest published
direct analog found in the literature pass (zero-shot HRV forecasting,
arXiv:2607.20027: Chronos beat EWMA by ~3%, MOIRAI showed no significant
advantage over a mean baseline, p=0.953) — plausibly because BIDMC's 1 Hz
ICU HR/RESP/SpO2 signals are smoother and more autocorrelated than the HRV
series in that paper, which is itself a fair, disclosed difference between
the two comparisons rather than a claim of a stronger general result.
On MAE (point accuracy) the picture is more mixed: Chronos-Bolt-small and
TimesFM beat last-value's MAE on both judges, but Chronos-Bolt-base and
Chronos-T5-small do not consistently (e.g. Chronos-Bolt-base's MAE is
essentially tied with last-value on judge A, worse on judge B, despite a
much better WQL there) — the pretrained models' advantage is concentrated in
calibrated quantile spread (what WQL scores), not in shifting the central
point forecast much past what last-value already gets from strong
autocorrelation in these signals.
**This directly confirms the massive-pretraining-corpus hypothesis from the
literature pass**: the same short-horizon, smooth physiological-signal task
on which every from-scratch RuForecast configuration this session lost to
naive baselines is won, cleanly, by checkpoints that carry a pretraining
corpus many orders of magnitude larger than any real dataset available to
this project. This is evidence *for* zero-shot/foundation-model inference
(or later, fine-tuning a pretrained checkpoint) as the credible next lever
for RuForecast-style short-horizon vitals forecasting — not for continuing
to scale from-scratch training on this project's own small real corpora,
which the BIDMC and household tests above already showed does not close
this gap even with genuine, cross-entity real data.
**Scope and caveats, stated plainly:**
- This is zero-shot inference only — no fine-tuning was attempted on any
checkpoint. A fine-tuned comparison (e.g. LoRA-adapting Chronos-Bolt on
RuForecast's own training windows) is a natural, not-yet-run follow-up.
- Univariate-per-variate forecasting was used for all four models because
neither Chronos nor TimesFM in these releases has a native multivariate
mode matching RuForecast's joint HR/RESP/SpO2 modeling; a true
multivariate foundation model (e.g. MOIRAI, which does support cross-variate
attention) was not tested here and is a natural next step.
- Chronos-T5-small's `predict_quantiles` samples internally (unlike
Chronos-Bolt's direct quantile heads or TimesFM's quantile head); its
numbers above are one run and carry unquantified sampling variance the
other three models' numbers don't.
- No inference-cost/latency comparison against RuForecast's own (much
smaller, ~tens-of-KB) model is included; all four pretrained checkpoints
are orders of magnitude larger (small-to-200M+ parameters) and ran on a
discrete GPU (RTX 5080), not the ESP32-class edge target RuForecast's own
architecture is designed for. A model that wins on WQL here is not
automatically deployable to the same edge budget RuForecast targets — that
tradeoff is unresolved by this test and is itself a reason zero-shot
inference (call out to a hosted/cloud checkpoint) and from-scratch
small-model training remain different tools for different deployment
constraints, not a strict replacement of one by the other.
Reproducer: `scripts/ruforecast-zeroshot/bidmc_windows.py` (window builder,
baselines, and metrics — ported line-for-line from
`ruforecast-train/examples/bidmc_prepare.rs` and
`ruforecast-core/src/{baseline,metrics}.rs`, verified against this document's
own BIDMC last-value/seasonal-naive numbers) and
`scripts/ruforecast-zeroshot/run_zeroshot_eval.py` (zero-shot inference and
scoring), both in this worktree (`research/ruforecast-zeroshot-eval`). Run
against `/tmp/bidmc-raw/` (downloaded fresh from PhysioNet if not already
cached) with `python run_zeroshot_eval.py --models chronos-bolt-base
chronos-bolt-small chronos-t5-small timesfm-2.5-200m --judges a b` inside a
venv with `chronos-forecasting`, `timesfm`, and a CUDA-enabled `torch`
installed (`amazon/chronos-*` and `google/timesfm-2.5-200m-pytorch` download
automatically from HuggingFace Hub on first run; no HF token was required).
Model weights and the raw BIDMC CSVs are not committed — `/tmp/bidmc-raw/`
and the HuggingFace cache live only on the host that ran this evaluation.
@@ -0,0 +1,63 @@
# WiFlow Reproduction Protocol
Status: Proposed research protocol
Tracks issue #1786.
## Objective
Determine whether a CSI optical flow style representation improves reusable camera free motion intelligence in RuView rather than only improving a proxy flow metric.
## Required capture metadata
Every run records hardware family, firmware, antenna count and geometry, channel width, center frequency, packet rate, timestamp source, synchronization method, room identity, participant identity class, label source, calibration, packet loss, missing subcarriers, preprocessing version, model commit, accelerator, software versions, power mode, and random seeds.
## Baselines
1. current RuView CSI preprocessing plus strongest task specific sensing head
2. WiFlow style motion field representation
3. simpler temporal derivative or phase and amplitude motion baseline
All conditions use identical train, validation, and held out environment splits.
## Metrics
Primary intermediate metrics:
* endpoint error or equivalent flow error
* directional consistency
* temporal stability
* uncertainty calibration
Required downstream metrics:
* tracking quality
* occupancy or presence quality
* pose or activity metric where available
* spatial state consistency in RuField or WorldGraph
Systems metrics:
* p50 and p95 inference latency
* peak memory
* CPU, GPU, or accelerator utilization
* energy per inference where measurable
* bytes per observation and sustained bandwidth
## Stress tests
Test unseen rooms, unseen participants, changed antenna geometry, a second RF hardware family, packet loss, burst loss, missing subcarriers, lower packet rate, clock jitter, and partial calibration failure.
## Promotion gate
The representation graduates only when the same learned representation improves at least two downstream RuView tasks on held out environments, or reaches equivalent downstream quality with materially lower edge cost.
A better optical flow proxy score alone is insufficient.
## Security and privacy
Visual or LiDAR labels may be used as privileged training evidence but must not become a runtime dependency for camera free deployment claims. Dataset provenance and consent requirements remain explicit.
## Rollback
The experiment is isolated behind a representation interface. Existing RuView sensing heads remain the production fallback.
+8 -1
View File
@@ -257,10 +257,17 @@ Offset Size Field
12 4 Sequence number (LE u32)
16 1 RSSI (i8)
17 1 Noise floor (i8)
18 2 Reserved
18 1 PPDU type (ADR-110; zero when tagging is disabled)
19 1 Flags: bit0=40 MHz, bit2=STBC, bit4=sync valid,
bit5=invalid first CSI word zeroed by firmware
20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
```
When ESP-IDF marks `first_word_invalid`, the firmware preserves packet geometry,
zeros the first four CSI bytes, and sets byte 19 bit 5. The same sanitized bytes
feed the edge DSP path. This prevents a documented hardware artifact from being
learned as motion while keeping older readers wire compatible.
### Vitals Packet (32 bytes)
```
+2 -1
View File
@@ -1,5 +1,6 @@
set(SRCS
"main.c" "csi_collector.c" "stream_sender.c" "nvs_config.c"
"main.c" "csi_collector.c"
"thermal.c" "stream_sender.c" "nvs_config.c"
"edge_processing.c" "ota_update.c" "power_mgmt.c"
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
"mmwave_sensor.c"
@@ -518,6 +518,140 @@ menu "Onboard LED (ADR-183)"
(reaches yellow with less motion).
endmenu
menu "Thermal monitoring"
config THERMAL_MONITOR
bool "Read the on-chip temperature sensor and throttle the radio"
depends on IDF_TARGET_ESP32C6
default y
help
The C6 has a temperature sensor this firmware otherwise never reads.
Harmless on a bare bench board; not once a node is sealed in a printed
enclosure on a wall with the radio transmitting continuously.
What is being protected is the ENCLOSURE, not the silicon. The part is
rated far past anything it sees here, but PLA softens at its glass
transition (~55-60 C) and sags slowly under its own weight, which never
announces itself.
config THERMAL_THROTTLE
bool "Act on the temperature by reducing transmit power"
depends on THERMAL_MONITOR
default n
help
Separate from monitoring on purpose. Reading the sensor is inert;
reducing transmit power moves every RSSI the node has, and doing that
mid-experiment gives any surprise two candidate causes.
Leave off until the fleet is stable and the logs show a node genuinely
running hot. If none ever does -- which is the likely outcome -- this
never needs enabling, and the thresholds below cost nothing.
config THERMAL_WARN_C
int "Warn above this die temperature (C)"
depends on THERMAL_MONITOR
range 40 100
default 65
config THERMAL_THROTTLE_C
int "Reduce transmit power above this die temperature (C)"
depends on THERMAL_MONITOR
range 45 105
default 72
help
Die temperature, not enclosure temperature -- the sensor is on the chip
and runs hotter than the plastic around it. ~72 C on the die in a small
vented box is roughly a 50-55 C enclosure, which is where PLA begins to
matter. Setting this at 55 would throttle a perfectly healthy node.
PETG (glass transition ~80 C) removes the concern; raise this if the
whole fleet is printed in it.
config THERMAL_CRITICAL_C
int "Minimum transmit power above this die temperature (C)"
depends on THERMAL_MONITOR
range 50 110
default 80
config THERMAL_HYSTERESIS_C
int "Degrees of cooling required before stepping down a thermal state"
depends on THERMAL_MONITOR
range 1 20
default 8
help
Recovering at the same threshold that tripped would oscillate, and every
oscillation is a visible RSSI step on every link the node has.
Applies to EVERY downward transition, each relative to the threshold that
was crossed on the way up: leaving CRITICAL needs CRITICAL_C minus this,
leaving THROTTLED needs THROTTLE_C minus this, leaving WARN needs WARN_C
minus this. Rising transitions are immediate at the plain thresholds --
heat is a reason to act now.
endmenu
menu "Network supervision"
config UPLINK_WATCHDOG
bool "Restart the node if the CSI uplink stops for too long"
default y
help
Bounds how long a node can sit powered, capturing, and unable to reach
the network.
MEASURED 2026-08-30: an AP-side event at 03:37 EDT disconnected all three
wall-mounted nodes within three minutes of each other, and every one of
them stayed dead for 5.5-7.5 hours until it was physically unplugged. The
specific cause of that outage (a reconnect counter that gave up
permanently) is fixed in main.c, but this exists so the NEXT unknown
wedge costs minutes instead of a night of data.
The watchdog counts a successful sendto(), which on UDP means the network
stack accepted the datagram -- not that the aggregator received it. That
is deliberate: stopping the server does NOT reboot the fleet, because
sendto keeps succeeding into a socket whose peer is gone. It fires only
when the node's own network path is broken.
It arms on the first successful send rather than at boot, so a node
powered on while the AP is down does not reboot-loop; the unbounded
reconnect handles that case instead.
config UPLINK_WATCHDOG_TIMEOUT_S
int "Restart after this many seconds with no successful send"
depends on UPLINK_WATCHDOG
range 30 3600
default 1200
help
Must sit above the worst SELF-HEALING gap, or the watchdog reboots nodes
that were about to recover on their own -- which is worse than the fault
it guards against, because every reboot re-runs ESP-NOW leader election
and discards the mesh time-sync state.
This was 120 s on the reasoning that the ENOMEM backoff caps at 2 s so a
healthy node is never quiet for long. A 58-minute soak on 2026-08-30
falsified that: nodes 0 and 2 each went silent repeatedly with a median
gap of 210 s and a maximum of 225 s, and recovered every time without
intervention. 120 s would have rebooted them every few minutes forever.
(The cause of those 210 s gaps is still unknown -- it is not the ENOMEM
backoff, which cannot produce a gap that long.)
Sized from the completed 112-minute soak, not the partial one: 30
silences across two nodes, median 225 s, p90 225 s, and a single 510 s
outlier that looks like two cycles running together. A third such merge
would reach ~735 s, so 600 s was too tight -- it left only 90 s of margin
over an event already observed once.
1200 s is 2.35x the observed maximum while still bounding a true wedge to
twenty minutes rather than the 7.5 hours measured the same day. Revisit
it if the ~225 s flapping is ever explained and fixed; until then do not
lower it below ~900 s.
If nodes do restart unexpectedly, check the reset reason in the sync
packet's health field before assuming the watchdog is responsible.
endmenu
config CSI_GATE_MESH_ALIGNED
bool "Align the CSI rate gate to mesh time (enables cross-node pairing)"
default y
@@ -13,6 +13,7 @@
*/
#include "adaptive_controller.h"
#include "thermal.h"
#include "rv_radio_ops.h"
#include "rv_feature_state.h"
#include "rv_mesh.h"
@@ -331,6 +332,10 @@ static void emit_feature_state(void)
static void slow_loop_cb(TimerHandle_t t)
{
(void)t;
/* Sample the die and apply the thermal policy. The slow loop (30 s) is the
* right cadence: the die does not change quickly, and reading it more
* often buys nothing but noise. No-op unless CONFIG_THERMAL_MONITOR. */
thermal_tick();
/* ADR-081 L3: publish a HEALTH mesh message every slow tick
* (default 30 s). The coordinator uses these to track liveness and
* detect sync-error drift. */
+14 -3
View File
@@ -266,8 +266,16 @@ size_t csi_serialize_frame(const wifi_csi_info_t *info, uint8_t *buf, size_t buf
buf[19] = 0;
#endif
/* I/Q data */
/* I/Q data. ESP-IDF documents that the first four CSI bytes are invalid
* when first_word_invalid is set. Never let those hardware artifacts feed
* either the host or edge DSP path. Preserve the fixed ADR-018 geometry by
* zeroing rather than removing bytes, and mark the sanitation in bit 5. */
memcpy(&buf[CSI_HEADER_SIZE], info->buf, iq_len);
if (info->first_word_invalid && iq_len > 0) {
size_t invalid_len = iq_len < 4 ? iq_len : 4;
memset(&buf[CSI_HEADER_SIZE], 0, invalid_len);
buf[19] |= CSI_FLAG_FIRST_WORD_SANITIZED;
}
return frame_size;
}
@@ -377,13 +385,16 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
* while the on-device Tier 1/2 pipeline receives a uniform, sustainable
* stream. Enqueuing every burst frame overloaded the unicore C6 DSP and
* turned 30-40 callback pps into an irregular approximately 8 Hz subset. */
if (info->buf && info->len > 0) {
if (frame_len > CSI_HEADER_SIZE) {
if (s_next_edge_enqueue_us == 0) {
s_next_edge_enqueue_us = now_us;
}
if (now_us >= s_next_edge_enqueue_us) {
(void)edge_enqueue_csi((const uint8_t *)info->buf, (uint16_t)info->len,
/* Reuse the sanitized ADR-018 payload. Feeding info->buf here
* would reintroduce first_word_invalid artifacts on device. */
(void)edge_enqueue_csi(&frame_buf[CSI_HEADER_SIZE],
(uint16_t)(frame_len - CSI_HEADER_SIZE),
(int8_t)info->rx_ctrl.rssi, info->rx_ctrl.channel);
/* Preserve the configured sample clock instead of resetting it to
@@ -17,6 +17,9 @@
/** ADR-018 header size in bytes. */
#define CSI_HEADER_SIZE 20
/** ADR-018 byte 19: ESP-IDF reported and firmware sanitized invalid CSI prefix. */
#define CSI_FLAG_FIRST_WORD_SANITIZED (1U << 5)
/** Maximum frame buffer size (header + 4 antennas * 256 subcarriers * 2 bytes). */
#define CSI_MAX_FRAME_SIZE (CSI_HEADER_SIZE + 4 * 256 * 2)
+25 -1
View File
@@ -33,7 +33,31 @@
#define EDGE_MAX_IQ_BYTES 1024 /**< Max I/Q payload per slot. */
#define EDGE_PHASE_HISTORY_LEN 256 /**< Phase history buffer depth. */
#define EDGE_TOP_K 8 /**< Top-K subcarriers to track. */
#define EDGE_MAX_SUBCARRIERS 128 /**< Max subcarriers per frame. */
/**
* Max subcarriers per frame the edge pipeline will process.
*
* Target-conditional since 2026-08-28. The original 128 was an ESP32-S3
* assumption: pre-HE chips report at most 128 CSI bins. ADR-110 onboarded the
* ESP32-C6 to the *capture* path but not to this one, and a C6 associated to an
* HE-capable AP delivers HE20 frames with 256 bins (iq_len = 512 bytes).
*
* `process_frame()` guards with `n_subcarriers > EDGE_MAX_SUBCARRIERS -> return`,
* so on C6 every frame was rejected and the whole edge pipeline — vitals,
* presence, fall detection, per-slot counting — silently did nothing. The Edge
* DSP task started, logged its banner, and never processed a frame. Confirmed
* on hardware: no edge_proc log past init and no vitals packet ever reaching
* the sink.
*
* HE-capable parts get 256; pre-HE parts keep 128 so they don't pay ~3.5 KB of
* .bss they can never use. CONFIG_SOC_WIFI_HE_SUPPORT is the same switch
* csi_collector.c uses to pick its rx_ctrl layout.
*/
#if CONFIG_SOC_WIFI_HE_SUPPORT
#define EDGE_MAX_SUBCARRIERS 256 /**< HE20 carries 256 CSI bins (C6/C5). */
#else
#define EDGE_MAX_SUBCARRIERS 128 /**< Pre-HE parts (S3 etc). */
#endif
/* ---- Measured sample-rate tracking ----
*
+144 -8
View File
@@ -21,6 +21,7 @@
#include "led_strip.h"
#include "csi_collector.h"
#include "thermal.h"
#include "stream_sender.h"
#include "nvs_config.h"
#include "edge_processing.h"
@@ -59,7 +60,52 @@ nvs_config_t g_nvs_config;
static EventGroupHandle_t s_wifi_event_group;
static int s_retry_num = 0;
#define MAX_RETRY 10
/* WiFi reconnection.
*
* This used to be `#define MAX_RETRY 10` with the disconnect handler giving up
* permanently once s_retry_num reached it: it set WIFI_FAIL_BIT and never
* called esp_wifi_connect() again. s_retry_num is only cleared on a successful
* IP_EVENT_STA_GOT_IP, so it accumulated over the node's entire uptime -- ten
* disconnects spread across days left the node permanently off the network
* with nothing but a power cycle to clear it.
*
* MEASURED 2026-08-30: a single AP-side event at 03:37:03 EDT disconnected all
* three wall-mounted nodes within three minutes of each other. Every one of
* them latched off and stayed off for 5.5-7.5 hours, until it was physically
* unplugged. CSI capture kept running throughout (it is driver-level and needs
* no IP), so the boards looked alive on the bench and dead to the server.
*
* Retry is now unbounded with exponential backoff. Two things that were
* conflated are now separate: WIFI_BOOT_WAIT_ATTEMPTS only releases app_main
* from its startup wait so the rest of the node can boot; it does not stop the
* node trying to reconnect. Nothing stops the node trying to reconnect. */
#define WIFI_BOOT_WAIT_ATTEMPTS 10
#define WIFI_RETRY_BASE_MS 500
#define WIFI_RETRY_MAX_MS 30000
static esp_timer_handle_t s_reconnect_timer;
/* 500 ms doubling to a 30 s ceiling. Backoff matters because the failure this
* guards against is an AP that is down or rebooting: hammering
* esp_wifi_connect() at full rate for the minutes an AP takes to come back
* wastes power and floods the log without reconnecting any sooner. */
static uint32_t wifi_retry_delay_ms(int attempt)
{
int shift = attempt - 1;
if (shift < 0) shift = 0;
if (shift > 6) shift = 6;
uint32_t d = (uint32_t)WIFI_RETRY_BASE_MS << shift;
return d > WIFI_RETRY_MAX_MS ? WIFI_RETRY_MAX_MS : d;
}
/* Runs on the esp_timer task, not the event loop task -- esp_wifi_connect()
* must not be reached through a delay inside the event handler itself. */
static void wifi_reconnect_cb(void *arg)
{
(void)arg;
esp_wifi_connect();
}
static void event_handler(void *arg, esp_event_base_t event_base,
int32_t event_id, void *event_data)
@@ -69,13 +115,22 @@ static void event_handler(void *arg, esp_event_base_t event_base,
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)event_data;
ESP_LOGW(TAG, "WiFi disconnected, reason=%d rssi=%d", disc->reason, disc->rssi);
if (s_retry_num < MAX_RETRY) {
esp_wifi_connect();
s_retry_num++;
ESP_LOGI(TAG, "Retrying WiFi connection (%d/%d)", s_retry_num, MAX_RETRY);
} else {
s_retry_num++;
/* Release the boot wait once, so a node that comes up while the AP is
* down still starts CSI capture and the mesh instead of blocking in
* app_main. Retrying continues regardless. */
if (s_retry_num == WIFI_BOOT_WAIT_ATTEMPTS) {
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
}
uint32_t delay_ms = wifi_retry_delay_ms(s_retry_num);
if (s_reconnect_timer != NULL) {
esp_timer_stop(s_reconnect_timer); /* may not be running; harmless */
esp_timer_start_once(s_reconnect_timer, (uint64_t)delay_ms * 1000);
} else {
esp_wifi_connect(); /* timer unavailable: retry now */
}
ESP_LOGI(TAG, "Reconnecting in %lu ms (attempt %d)",
(unsigned long)delay_ms, s_retry_num);
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
ESP_LOGI(TAG, "Got IP: " IPSTR, IP2STR(&event->ip_info.ip));
@@ -84,6 +139,41 @@ static void event_handler(void *arg, esp_event_base_t event_base,
}
}
#ifdef CONFIG_UPLINK_WATCHDOG
/* Uplink supervision.
*
* The reconnect fix above addresses the one wedge whose mechanism we have
* proven. This catches the ones we have not: any state in which the node is
* powered, capturing, and unable to put a packet on the wire. It bounds such a
* state to CONFIG_UPLINK_WATCHDOG_TIMEOUT_S instead of the 7.5 hours measured
* on 2026-08-30.
*
* The signal is a successful sendto(), which on UDP means the stack accepted
* the datagram -- NOT that the server received it. That distinction is what
* makes this safe: stopping the aggregator does not reboot the fleet, because
* sendto keeps succeeding into a socket whose peer is gone. It fires only when
* the node's own network path is broken.
*
* It arms on the first successful send rather than at boot, so a node that has
* never reached the network does not reboot-loop while the AP is down; the
* unbounded reconnect above owns that case. */
static void uplink_watchdog_tick(void)
{
int64_t last = stream_sender_last_success_us();
if (last == 0) {
return; /* never delivered -- not armed yet */
}
int64_t idle_s = (esp_timer_get_time() - last) / 1000000;
if (idle_s < CONFIG_UPLINK_WATCHDOG_TIMEOUT_S) {
return;
}
ESP_LOGE(TAG, "uplink watchdog: no successful send for %lld s "
"(limit %d s) -- restarting",
(long long)idle_s, CONFIG_UPLINK_WATCHDOG_TIMEOUT_S);
esp_restart();
}
#endif /* CONFIG_UPLINK_WATCHDOG */
static void wifi_init_sta(void)
{
s_wifi_event_group = xEventGroupCreate();
@@ -135,6 +225,17 @@ static void wifi_init_sta(void)
}
#endif
const esp_timer_create_args_t reconnect_args = {
.callback = &wifi_reconnect_cb,
.name = "wifi_reconnect",
};
esp_err_t rc_ret = esp_timer_create(&reconnect_args, &s_reconnect_timer);
if (rc_ret != ESP_OK) {
ESP_LOGW(TAG, "reconnect timer create failed: %s (falling back to "
"immediate retry)", esp_err_to_name(rc_ret));
s_reconnect_timer = NULL;
}
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "WiFi STA initialized, connecting to SSID: %s", g_nvs_config.wifi_ssid);
@@ -147,7 +248,9 @@ static void wifi_init_sta(void)
if (bits & WIFI_CONNECTED_BIT) {
ESP_LOGI(TAG, "Connected to WiFi");
} else if (bits & WIFI_FAIL_BIT) {
ESP_LOGE(TAG, "Failed to connect to WiFi after %d retries", MAX_RETRY);
ESP_LOGW(TAG, "Not connected after %d attempts -- continuing boot; "
"reconnection keeps retrying in the background",
WIFI_BOOT_WAIT_ATTEMPTS);
}
}
@@ -318,6 +421,36 @@ void app_main(void)
ESP_LOGI(TAG, "Mock CSI active (scenario=%d)", CONFIG_CSI_MOCK_SCENARIO);
}
#else
/* Why did we just start? Nothing recorded this before, and it is the
* single most useful line in a fleet log: a node that reboots tells you
* almost nothing, but a node that reboots with ESP_RST_BROWNOUT tells you
* its supply sagged, which on shared outlets is a wiring problem rather
* than a firmware one. Three nodes dropping together on 2026-08-28 would
* have been answered by this line. */
{
esp_reset_reason_t rr = esp_reset_reason();
const char *why =
rr == ESP_RST_POWERON ? "power-on" :
rr == ESP_RST_EXT ? "external reset" :
rr == ESP_RST_SW ? "software restart" :
rr == ESP_RST_PANIC ? "PANIC (crash)" :
rr == ESP_RST_INT_WDT ? "interrupt watchdog" :
rr == ESP_RST_TASK_WDT ? "task watchdog" :
rr == ESP_RST_WDT ? "other watchdog" :
rr == ESP_RST_BROWNOUT ? "BROWNOUT (supply sagged)" :
rr == ESP_RST_DEEPSLEEP? "deep-sleep wake" : "unknown";
if (rr == ESP_RST_PANIC || rr == ESP_RST_BROWNOUT ||
rr == ESP_RST_INT_WDT || rr == ESP_RST_TASK_WDT) {
ESP_LOGW(TAG, "reset reason: %s (%d) <-- not a clean start", why, (int)rr);
} else {
ESP_LOGI(TAG, "reset reason: %s (%d)", why, (int)rr);
}
}
/* Bring thermal monitoring up before the radio is loaded, so the first
* reading is taken against a known-idle baseline rather than mid-burst. */
thermal_init();
csi_collector_init();
/* ADR-073: Start multi-frequency channel hopping if configured in NVS. */
@@ -510,8 +643,11 @@ void app_main(void)
(swarm_ret == ESP_OK) ? g_nvs_config.seed_url : "off",
(adapt_ret == ESP_OK) ? "on" : "off");
/* Main loop — keep alive */
/* Main loop — keep alive, and supervise the uplink. */
while (1) {
vTaskDelay(pdMS_TO_TICKS(10000));
#ifdef CONFIG_UPLINK_WATCHDOG
uplink_watchdog_tick();
#endif
}
}
@@ -37,6 +37,12 @@ static uint32_t s_enomem_suppressed = 0;
* succeeds. */
static uint32_t s_enomem_streak = 0;
/* Timestamp of the last sendto() the stack accepted, on either path; 0 until
* the node has ever delivered anything. Read by the uplink watchdog in main.c
* -- see the rationale there for why "accepted by the stack" is deliberately
* not "received by the server". */
static int64_t s_last_success_us = 0;
static int sender_init_internal(const char *ip, uint16_t port)
{
s_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
@@ -118,6 +124,7 @@ int stream_sender_send(const uint8_t *data, size_t len)
/* A send got through — buffer pressure cleared; reset the backoff streak. */
s_enomem_streak = 0;
s_last_success_us = esp_timer_get_time();
return sent;
}
@@ -146,9 +153,18 @@ int stream_sender_send_priority(const uint8_t *data, size_t len)
}
return -1;
}
/* Counts for the watchdog: the mesh sync packet rides this path at ~1 Hz,
* and its getting through proves the node's network path is intact even
* when the bulk CSI stream is being suppressed by the ENOMEM backoff. */
s_last_success_us = esp_timer_get_time();
return sent;
}
int64_t stream_sender_last_success_us(void)
{
return s_last_success_us;
}
void stream_sender_deinit(void)
{
if (s_sock >= 0) {
@@ -50,6 +50,18 @@ int stream_sender_send(const uint8_t *data, size_t len);
*/
int stream_sender_send_priority(const uint8_t *data, size_t len);
/**
* esp_timer timestamp (microseconds) of the last sendto() the network stack
* accepted, on either the bulk or the priority path. Returns 0 if the node has
* not successfully sent anything since boot.
*
* This reports that the datagram was accepted for transmission, NOT that the
* aggregator received it -- UDP gives no such signal. Used by the uplink
* watchdog to detect a node that is powered and capturing but cannot reach the
* network.
*/
int64_t stream_sender_last_success_us(void);
/**
* Close the UDP sender socket.
*/
+201
View File
@@ -0,0 +1,201 @@
/* On-chip thermal monitoring and radio throttling.
*
* The ESP32-C6 carries a temperature sensor (SOC_TEMP_SENSOR_SUPPORTED) that
* nothing in this firmware has ever read. That was tolerable on a bare board
* on a bench; it is not once nine nodes are sealed in printed enclosures and
* left on a wall for months with the radio transmitting continuously.
*
* What we are protecting is the enclosure, not the silicon. The part is rated
* well past anything it will see here, but PLA goes soft at its glass
* transition around 55-60 C -- and that is sagging under its own weight over
* weeks, not a dramatic failure, so it would never announce itself.
*
* The sensor reads DIE temperature, which runs hotter than the plastic around
* it. Thresholds are therefore set against the die with that offset in mind:
* a die at ~72 C in a small vented box is roughly a 50-55 C enclosure, which
* is where PLA starts to matter. They are deliberately not set at 55 C -- that
* would throttle a perfectly healthy node.
*
* Throttling reduces WiFi transmit power, which is the dominant heat source
* and degrades gracefully: links weaken but keep working, where cutting the
* capture rate would blind the node entirely.
*
* IMPORTANT for anyone reading link diagnostics: a throttled node's RSSI drops
* at both ends, which looks exactly like an obstruction. `thermal_state()` and
* `thermal_tx_dbm()` exist so that can be distinguished from a cage or a wall
* rather than misdiagnosed as one.
*/
#include "sdkconfig.h"
#include "thermal.h"
#if defined(CONFIG_THERMAL_MONITOR) && defined(CONFIG_IDF_TARGET_ESP32C6)
#include "driver/temperature_sensor.h"
#include "esp_wifi.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_system.h"
#include "esp_heap_caps.h"
static const char *TAG = "thermal";
static temperature_sensor_handle_t s_sensor;
static bool s_ready;
static float s_last_c = -273.0f;
static float s_peak_c = -273.0f;
static thermal_state_t s_state = THERMAL_OK;
/* esp_wifi_set_max_tx_power() takes 0.25 dBm units. 80 = 20 dBm, the default
* on this part; each step down is 3 dB, which halves radiated power. */
#define TXP_FULL 80 /* 20 dBm */
#define TXP_STEP1 68 /* 17 dBm */
#define TXP_STEP2 56 /* 14 dBm */
#define TXP_MIN 44 /* 11 dBm */
static int8_t s_txp = TXP_FULL;
#ifdef CONFIG_THERMAL_THROTTLE
/* Only referenced from the throttling block in thermal_tick(). Guarded so a
* monitor-only build (the default, and what is on the wall) does not warn
* about an unused function. */
static void apply_tx_power(int8_t q)
{
if (q == s_txp) {
return;
}
esp_err_t r = esp_wifi_set_max_tx_power(q);
if (r == ESP_OK) {
ESP_LOGW(TAG, "tx power %d.%02d dBm -> %d.%02d dBm (thermal)",
s_txp / 4, (s_txp % 4) * 25, q / 4, (q % 4) * 25);
s_txp = q;
} else {
ESP_LOGW(TAG, "esp_wifi_set_max_tx_power(%d) failed: %s",
q, esp_err_to_name(r));
}
}
#endif /* CONFIG_THERMAL_THROTTLE */
esp_err_t thermal_init(void)
{
/* The requested span must fit ENTIRELY inside one of the sensor's
* predefined measurement ranges -- the driver walks a table and takes the
* first that contains it, so a span crossing two boundaries is rejected
* with ESP_ERR_INVALID_ARG rather than clamped. (-10,110) looks reasonable
* and is invalid for exactly that reason; it failed on real hardware.
*
* (20,100) is the right first choice here: the warn/throttle/critical
* thresholds all sit comfortably inside it, and indoor ambient is
* essentially never below 20 C. The wider fallbacks exist so an unheated
* room, or a different chip revision with a different table, degrades to a
* usable range instead of leaving the node unmonitored. */
static const struct { int lo, hi; } spans[] = {
{20, 100}, {-10, 80}, {-30, 50}, {50, 125},
};
esp_err_t r = ESP_ERR_INVALID_ARG;
for (size_t i = 0; i < sizeof(spans) / sizeof(spans[0]); i++) {
temperature_sensor_config_t cfg =
TEMPERATURE_SENSOR_CONFIG_DEFAULT(spans[i].lo, spans[i].hi);
r = temperature_sensor_install(&cfg, &s_sensor);
if (r == ESP_OK) {
ESP_LOGI(TAG, "sensor range %d..%d C", spans[i].lo, spans[i].hi);
break;
}
}
if (r != ESP_OK) {
ESP_LOGW(TAG, "sensor install failed on every range: %s "
"(continuing unmonitored)", esp_err_to_name(r));
return r;
}
r = temperature_sensor_enable(s_sensor);
if (r != ESP_OK) {
ESP_LOGW(TAG, "sensor enable failed: %s", esp_err_to_name(r));
return r;
}
s_ready = true;
ESP_LOGI(TAG, "monitoring on: warn %d C, throttle %d C, critical %d C "
"(die temperature; protecting the enclosure, not the chip)",
CONFIG_THERMAL_WARN_C, CONFIG_THERMAL_THROTTLE_C,
CONFIG_THERMAL_CRITICAL_C);
return ESP_OK;
}
void thermal_tick(void)
{
if (!s_ready) {
return;
}
float c;
if (temperature_sensor_get_celsius(s_sensor, &c) != ESP_OK) {
return;
}
s_last_c = c;
if (c > s_peak_c) {
s_peak_c = c;
}
/* Hysteresis on the way down: recovering at the same threshold that
* tripped would oscillate, and each oscillation is a visible RSSI step on
* every link this node has. The decision itself lives in thermal.h as a
* pure function so a host test can exercise the same code this runs. */
const thermal_state_t next = thermal_next_state(s_state, c,
CONFIG_THERMAL_WARN_C,
CONFIG_THERMAL_THROTTLE_C,
CONFIG_THERMAL_CRITICAL_C,
CONFIG_THERMAL_HYSTERESIS_C);
if (next != s_state) {
ESP_LOGW(TAG, "%.1f C: %s -> %s",
c, thermal_state_name(s_state), thermal_state_name(next));
s_state = next;
}
#ifdef CONFIG_THERMAL_THROTTLE
/* Acting on the reading is separate from taking it. Measuring is inert;
* changing transmit power mid-experiment moves every RSSI this node has,
* which is not something to introduce during a fleet bring-up when any
* surprise needs a single candidate cause. Turn this on once the fleet is
* stable AND the logs show a node actually gets hot -- if none ever does,
* it never needs turning on at all. */
switch (s_state) {
case THERMAL_CRITICAL: apply_tx_power(TXP_MIN); break;
case THERMAL_THROTTLED: apply_tx_power(TXP_STEP2); break;
case THERMAL_WARN: apply_tx_power(TXP_STEP1); break;
case THERMAL_OK: apply_tx_power(TXP_FULL); break;
}
#endif
static int64_t s_last_log_us;
int64_t now = esp_timer_get_time();
if (now - s_last_log_us > 60LL * 1000 * 1000) {
s_last_log_us = now;
/* Heap rides along because it is the other thing that fails slowly
* and silently over weeks. A node that reboots mysteriously after ten
* days looks identical to one that overheated, unless the minimum
* free heap has been visibly walking downwards the whole time. */
ESP_LOGI(TAG, "die %.1f C (peak %.1f C) state=%s tx=%d.%02d dBm "
"heap %u min %u",
s_last_c, s_peak_c, thermal_state_name(s_state),
s_txp / 4, (s_txp % 4) * 25,
(unsigned)esp_get_free_heap_size(),
(unsigned)esp_get_minimum_free_heap_size());
}
}
float thermal_celsius(void) { return s_last_c; }
float thermal_peak_celsius(void) { return s_peak_c; }
thermal_state_t thermal_state(void) { return s_state; }
int8_t thermal_tx_dbm(void) { return (int8_t)(s_txp / 4); }
const char *thermal_state_name(thermal_state_t s)
{
switch (s) {
case THERMAL_OK: return "ok";
case THERMAL_WARN: return "warn";
case THERMAL_THROTTLED: return "throttled";
case THERMAL_CRITICAL: return "critical";
}
return "?";
}
#endif /* CONFIG_THERMAL_MONITOR && ESP32C6 */
+106
View File
@@ -0,0 +1,106 @@
/* On-chip thermal monitoring and radio throttling. See thermal.c for why the
* thresholds are set against die temperature rather than the plastic. */
#pragma once
#include "esp_err.h"
#include <stdint.h>
typedef enum {
THERMAL_OK = 0,
THERMAL_WARN,
THERMAL_THROTTLED,
THERMAL_CRITICAL,
} thermal_state_t;
/**
* Decide the next thermal state from a reading and the current state.
*
* Pure: no globals, no hardware. Kept in the public header so a host test can
* exercise the exact function the device build runs, rather than a copy of it
* that is free to drift.
*
* Rising edges act immediately -- heat is a reason to react now. EVERY falling
* edge requires cooling a full `hyst_c` below the threshold that was crossed on
* the way up, because recovering at the same point that tripped oscillates, and
* each oscillation steps this node's transmit power, which moves the RSSI of
* every link it has. A reader cannot distinguish that from a wall moving.
*
* The CRITICAL -> THROTTLED edge is the reason this exists as a function. It
* used to be unconditional: any reading below `critical_c` dropped the state
* immediately, so a node sitting on the critical boundary flapped between
* minimum and step-2 transmit power indefinitely -- precisely the oscillation
* the hysteresis elsewhere was added to prevent. With defaults (critical 80,
* hysteresis 8) leaving CRITICAL now needs 72 C, which is exactly the THROTTLED
* entry point.
*
* @param cur Current state.
* @param c Die temperature, degrees C.
* @param warn_c Warn threshold.
* @param throttle_c Throttle threshold.
* @param critical_c Critical threshold.
* @param hyst_c Degrees of cooling required before any step down.
* @return The state to adopt.
*/
static inline thermal_state_t thermal_next_state(thermal_state_t cur, float c,
int warn_c, int throttle_c,
int critical_c, int hyst_c)
{
const float h = (float)hyst_c;
/* Rising: immediate, at the plain thresholds. */
if (c >= (float)critical_c) return THERMAL_CRITICAL;
/* Falling out of CRITICAL needs a full hysteresis band, like every other
* downward step. Without this the state tracks `c` across critical_c with
* no damping at all. */
if (cur == THERMAL_CRITICAL && c >= (float)critical_c - h) {
return THERMAL_CRITICAL;
}
if (c >= (float)throttle_c) return THERMAL_THROTTLED;
if (cur == THERMAL_THROTTLED && c >= (float)throttle_c - h) {
return THERMAL_THROTTLED;
}
if (c >= (float)warn_c) return THERMAL_WARN;
if (cur == THERMAL_WARN && c >= (float)warn_c - h) {
return THERMAL_WARN;
}
return THERMAL_OK;
}
#if defined(CONFIG_THERMAL_MONITOR) && defined(CONFIG_IDF_TARGET_ESP32C6)
esp_err_t thermal_init(void);
/* Sample and apply policy. Call from a slow loop -- the die does not change
* fast, and reading it more often buys nothing. */
void thermal_tick(void);
float thermal_celsius(void);
float thermal_peak_celsius(void);
thermal_state_t thermal_state(void);
/* Current WiFi transmit ceiling in whole dBm.
*
* Exposed because a throttled node's RSSI falls at both ends, which is
* indistinguishable from an obstruction in the link diagnostics. Anything
* reading link strength should check this before concluding a wall moved. */
int8_t thermal_tx_dbm(void);
const char *thermal_state_name(thermal_state_t s);
#else /* stubs: S3 build and when monitoring is off */
static inline esp_err_t thermal_init(void) { return ESP_OK; }
static inline void thermal_tick(void) {}
static inline float thermal_celsius(void) { return -273.0f; }
static inline float thermal_peak_celsius(void) { return -273.0f; }
static inline thermal_state_t thermal_state(void) { return THERMAL_OK; }
static inline int8_t thermal_tx_dbm(void) { return 20; }
static inline const char *thermal_state_name(thermal_state_t s) { (void)s; return "n/a"; }
#endif
@@ -77,3 +77,22 @@ CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=8192
# ── Power: keep CPU at max 160 MHz (C6 ceiling) for DSP throughput ──
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160=y
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=160
# ADR-110 D1 / ruvnet/RuView#762: the 802.15.4 RX path never delivers a
# protocol frame on IDF v5.4. Upstream tested five configurations -- channel
# 15 and 26, OpenThread on and off, promiscuous on and off, PAN-matched and
# broadcast -- and every one gave rx=0 with TX at 100% reliability. ESP-NOW
# shipped as the workaround and is what this firmware actually syncs on
# (measured 99.56% cross-board delivery, 104 us smoothed offset).
#
# The C6 has a single 2.4 GHz transceiver shared by WiFi, BLE and 802.15.4,
# so leaving the 15.4 stack running costs coexistence time-slicing against
# the CSI capture that is the entire point of the node -- beaconing every
# 100 ms and listening on a radio that provably returns nothing. Disable it
# and give the airtime back to WiFi, which matters most at nine nodes where
# the 50 Hz receive gate is already the binding constraint.
#
# Safe to turn off: the ADR-018 byte 19 bit 4 "sync valid" flag is ORed from
# both transports and the 15.4 branch is #ifdef-gated on this symbol, so the
# working ESP-NOW path keeps setting it.
CONFIG_C6_TIMESYNC_ENABLE=n
+38 -5
View File
@@ -39,14 +39,35 @@ CFLAGS = -fsanitize=fuzzer,address,undefined -g -O1 \
STUBS_SRC = stubs/esp_stubs.c
MAIN_DIR = ../main
# GNU ld and Apple's ld spell dead section elimination differently.
# Keep the host sanitation test portable across the Linux CI runner and macOS.
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
DEAD_STRIP_LDFLAGS := -Wl,-dead_strip
else
DEAD_STRIP_LDFLAGS := -Wl,--gc-sections
endif
# Default fuzz duration (seconds) and jobs
FUZZ_DURATION ?= 30
FUZZ_JOBS ?= 1
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests \
test_thermal run_thermal test_csi_sanitize run_csi_sanitize
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
test_thermal test_csi_sanitize
# --- ESP-IDF invalid first-word sanitation ---
# Links the production serializer. Function sections and dead stripping keep
# unrelated device-only callback code out of this host executable.
test_csi_sanitize: test_csi_sanitize.c $(MAIN_DIR)/csi_collector.c $(STUBS_SRC)
cc -std=c99 -Wall -Wextra -Istubs -I$(MAIN_DIR) -ffunction-sections \
$^ $(DEAD_STRIP_LDFLAGS) -o $@ -lm
run_csi_sanitize: test_csi_sanitize
./test_csi_sanitize
# --- ADR-110 encoding unit tests ---
# Host-side, no libFuzzer needed — plain C99 deterministic table tests
@@ -80,8 +101,19 @@ test_mmwave_detect: test_mmwave_detect.c $(MAIN_DIR)/mmwave_detect.h
run_mmwave_detect: test_mmwave_detect
./test_mmwave_detect
host_tests: run_adr110 run_vitals run_mmwave_detect
@echo "Host tests passed (ADR-110 + vitals #998/#996 + mmwave detect #1135)"
# --- Thermal state hysteresis ---
# Host-side. Exercises thermal_next_state() from ../main/thermal.h directly --
# the same function the device build runs -- so the test and the firmware cannot
# drift. Pins every edge in both directions, including the CRITICAL -> THROTTLED
# flap that had no hysteresis at all.
test_thermal: test_thermal_hysteresis.c $(MAIN_DIR)/thermal.h
cc -std=c99 -Wall -Wextra -Istubs -I$(MAIN_DIR) -o $@ $<
run_thermal: test_thermal
./test_thermal
host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal)"
# --- Serialize fuzzer ---
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
@@ -117,5 +149,6 @@ run_nvs: fuzz_nvs
run_all: run_serialize run_edge run_nvs
clean:
rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals
rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
test_thermal test_csi_sanitize
rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/
@@ -90,6 +90,7 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
info.rx_ctrl.sig_mode = legacy_inputs & 0x03;
info.rx_ctrl.cwb = (legacy_inputs >> 2) & 0x01;
info.rx_ctrl.stbc = (legacy_inputs >> 3) & 0x01;
info.first_word_invalid = (test_case & 0x80) != 0;
/* Use remaining fuzz data as I/Q buffer content. */
uint16_t iq_len;
@@ -136,6 +137,17 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
if (result > out_len) {
__builtin_trap(); /* Buffer overflow detected. */
}
if (result >= CSI_HEADER_SIZE + iq_len && info.first_word_invalid && iq_len > 0) {
size_t invalid_len = iq_len < 4 ? iq_len : 4;
for (size_t i = 0; i < invalid_len; i++) {
if (out_buf[CSI_HEADER_SIZE + i] != 0) {
__builtin_trap(); /* Invalid hardware prefix escaped sanitation. */
}
}
if ((out_buf[19] & CSI_FLAG_FIRST_WORD_SANITIZED) == 0) {
__builtin_trap(); /* Sanitized payload must carry provenance. */
}
}
/* --- Test case 1: NULL info pointer --- */
if (test_case & 0x01) {
@@ -90,6 +90,7 @@ typedef struct {
typedef struct {
wifi_pkt_rx_ctrl_t rx_ctrl;
uint8_t mac[6];
bool first_word_invalid;
int16_t len; /**< Length of the I/Q buffer in bytes. */
int8_t *buf; /**< Pointer to I/Q data. */
} wifi_csi_info_t;
@@ -0,0 +1,56 @@
/** Deterministic host test for ESP-IDF first_word_invalid sanitation. */
#include "esp_stubs.h"
#include "nvs_config.h"
#include "csi_collector.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
nvs_config_t g_nvs_config;
static void make_info(wifi_csi_info_t *info, int8_t *iq, uint16_t len, bool invalid)
{
memset(info, 0, sizeof(*info));
info->rx_ctrl.channel = 6;
info->rx_ctrl.rssi = -42;
info->rx_ctrl.noise_floor = -91;
info->len = (int16_t)len;
info->buf = iq;
info->first_word_invalid = invalid;
}
int main(void)
{
int8_t iq[] = {1, 2, 3, 4, 5, 6, 7, 8};
uint8_t frame[CSI_MAX_FRAME_SIZE];
wifi_csi_info_t info;
make_info(&info, iq, sizeof(iq), true);
size_t len = csi_serialize_frame(&info, frame, sizeof(frame));
assert(len == CSI_HEADER_SIZE + sizeof(iq));
assert(frame[CSI_HEADER_SIZE] == 0);
assert(frame[CSI_HEADER_SIZE + 1] == 0);
assert(frame[CSI_HEADER_SIZE + 2] == 0);
assert(frame[CSI_HEADER_SIZE + 3] == 0);
assert(frame[CSI_HEADER_SIZE + 4] == 5);
assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) != 0);
make_info(&info, iq, sizeof(iq), false);
len = csi_serialize_frame(&info, frame, sizeof(frame));
assert(len == CSI_HEADER_SIZE + sizeof(iq));
assert(memcmp(&frame[CSI_HEADER_SIZE], iq, sizeof(iq)) == 0);
assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) == 0);
int8_t short_iq[] = {9, 10};
make_info(&info, short_iq, sizeof(short_iq), true);
len = csi_serialize_frame(&info, frame, sizeof(frame));
assert(len == CSI_HEADER_SIZE + sizeof(short_iq));
assert(frame[CSI_HEADER_SIZE] == 0);
assert(frame[CSI_HEADER_SIZE + 1] == 0);
assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) != 0);
puts("CSI invalid-prefix sanitation: PASS");
return 0;
}
@@ -0,0 +1,147 @@
/**
* @file test_thermal_hysteresis.c
* @brief Pins every thermal transition, in both directions.
*
* Exercises `thermal_next_state()` from ../main/thermal.h directly -- the same
* function the device build runs, not a copy -- so the test and the firmware
* cannot drift apart.
*
* The regression that motivated this: the CRITICAL -> THROTTLED edge was
* unconditional. Any reading below `critical_c` dropped the state at once, so a
* node sitting on the critical boundary flapped between minimum and step-2
* transmit power forever. Every oscillation moves the RSSI of every link that
* node has, at both ends, which a reader cannot tell apart from an obstruction
* -- exactly the failure the hysteresis on the other edges exists to prevent.
*
* Defaults under test: warn 65, throttle 72, critical 80, hysteresis 8.
*/
#include <stdio.h>
#include "thermal.h"
#define WARN_C 65
#define THROTTLE_C 72
#define CRITICAL_C 80
#define HYST_C 8
static int g_failures = 0;
static const char *name(thermal_state_t s)
{
switch (s) {
case THERMAL_OK: return "OK";
case THERMAL_WARN: return "WARN";
case THERMAL_THROTTLED: return "THROTTLED";
case THERMAL_CRITICAL: return "CRITICAL";
}
return "?";
}
static thermal_state_t step(thermal_state_t cur, float c)
{
return thermal_next_state(cur, c, WARN_C, THROTTLE_C, CRITICAL_C, HYST_C);
}
static void expect(thermal_state_t cur, float c, thermal_state_t want,
const char *why)
{
thermal_state_t got = step(cur, c);
if (got != want) {
printf(" FAIL: from %-9s at %5.1f C -> %s, expected %s\n %s\n",
name(cur), c, name(got), name(want), why);
g_failures++;
}
}
int main(void)
{
printf("thermal hysteresis (warn %d, throttle %d, critical %d, hyst %d)\n",
WARN_C, THROTTLE_C, CRITICAL_C, HYST_C);
/* ---- Rising edges are immediate. Heat is a reason to act now. ---- */
expect(THERMAL_OK, 64.9f, THERMAL_OK, "below warn");
expect(THERMAL_OK, 65.0f, THERMAL_WARN, "warn trips at the threshold");
expect(THERMAL_WARN, 72.0f, THERMAL_THROTTLED, "throttle trips at the threshold");
expect(THERMAL_THROTTLED, 80.0f, THERMAL_CRITICAL, "critical trips at the threshold");
/* A jump straight past intermediate bands must not be damped on the way up. */
expect(THERMAL_OK, 95.0f, THERMAL_CRITICAL, "a large rise skips straight to critical");
/* ---- THE REGRESSION: falling out of CRITICAL. ----
*
* Before the fix, 79.9 C dropped the state to THROTTLED immediately, so a
* node hovering at 80 alternated TXP_MIN and TXP_STEP2 indefinitely. */
expect(THERMAL_CRITICAL, 79.9f, THERMAL_CRITICAL,
"0.1 C below critical must NOT step down -- this is the flap");
expect(THERMAL_CRITICAL, 75.0f, THERMAL_CRITICAL,
"still inside the hysteresis band");
expect(THERMAL_CRITICAL, 72.1f, THERMAL_CRITICAL,
"just inside the band (critical 80 - hyst 8 = 72)");
/* Once it has cooled a full band it adopts the band it is ACTUALLY in,
* which under these defaults is WARN, not THROTTLED: the critical exit
* point (80 - 8 = 72) coincides exactly with the throttle threshold, so
* anything cool enough to leave CRITICAL is already below THROTTLED's
* entry. THROTTLED is therefore unreachable descending from CRITICAL at
* hysteresis 8 -- a consequence of the chosen constants, not of the logic.
* With a smaller hysteresis the intermediate step reappears, which the
* next assertion pins. */
expect(THERMAL_CRITICAL, 71.9f, THERMAL_WARN,
"a full band below critical, and 71.9 is in the warn band");
/* Same descent with hysteresis 4: the exit point is 76, which lands inside
* THROTTLED's band, so the intermediate power step is used. */
{
thermal_state_t got = thermal_next_state(THERMAL_CRITICAL, 75.9f,
WARN_C, THROTTLE_C, CRITICAL_C, 4);
if (got != THERMAL_THROTTLED) {
printf(" FAIL: from CRITICAL at 75.9 C with hyst 4 -> %s, "
"expected THROTTLED\n", name(got));
g_failures++;
}
thermal_state_t held = thermal_next_state(THERMAL_CRITICAL, 76.1f,
WARN_C, THROTTLE_C, CRITICAL_C, 4);
if (held != THERMAL_CRITICAL) {
printf(" FAIL: from CRITICAL at 76.1 C with hyst 4 -> %s, "
"expected CRITICAL\n", name(held));
g_failures++;
}
}
/* Prove the flap is gone by walking across the boundary repeatedly: a node
* dithering either side of 80 must not change state on the down-swings. */
{
thermal_state_t s = THERMAL_CRITICAL;
int changes = 0;
const float dither[] = {80.2f, 79.8f, 80.1f, 79.7f, 80.3f, 79.9f};
for (unsigned i = 0; i < sizeof(dither) / sizeof(dither[0]); i++) {
thermal_state_t n = step(s, dither[i]);
if (n != s) changes++;
s = n;
}
if (changes != 0) {
printf(" FAIL: dithering around the critical boundary produced %d "
"state change(s); each one steps transmit power\n", changes);
g_failures++;
}
}
/* ---- Falling out of THROTTLED. Behaviour preserved from before. ---- */
expect(THERMAL_THROTTLED, 71.9f, THERMAL_THROTTLED, "just below the throttle point holds");
expect(THERMAL_THROTTLED, 64.1f, THERMAL_THROTTLED, "still inside the band");
expect(THERMAL_THROTTLED, 63.9f, THERMAL_OK, "throttle 72 - hyst 8 = 64 releases");
/* ---- Falling out of WARN. ---- */
expect(THERMAL_WARN, 64.9f, THERMAL_WARN, "just below warn holds");
expect(THERMAL_WARN, 57.1f, THERMAL_WARN, "still inside the band");
expect(THERMAL_WARN, 56.9f, THERMAL_OK, "warn 65 - hyst 8 = 57 releases");
/* ---- OK is stable well below everything. ---- */
expect(THERMAL_OK, 30.0f, THERMAL_OK, "a cold node stays OK");
if (g_failures == 0) {
printf(" PASS\n");
return 0;
}
printf(" %d check(s) FAILED\n", g_failures);
return 1;
}
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Extract training stills from the real fal.ai room-visualization MP4s.
Reads the source videos (real fal.ai-generated room visualizations, one per
style preset) and writes JPEG frames + a caption .txt per frame into a local
(gitignored) training-data directory, ready to be zipped for fal.ai LoRA
training. Frame interval is time-based (not frame-count-based) so each
source contributes visual diversity proportional to its length.
"""
import argparse
import shutil
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
# (source mp4 path, style tag, caption) -- sources are real fal.ai output,
# captured earlier this session by the fal-room-viz / room-viz-h3-upgrade
# agents. Caption describes the RuView visual style so the LoRA learns the
# recurring motifs (glowing pose overlay, WiFi signal arcs, vitals readout,
# dark tech aesthetic) rather than any single room layout.
SOURCES = [
("/tmp/room-viz-architectural/room-viz.mp4", "architectural",
"ruview style room visualization, architectural wireframe overlay, "
"glowing cyan pose skeleton, wifi signal arcs, dark tech aesthetic"),
("/tmp/room-viz-abstract/room-viz.mp4", "abstract",
"ruview style room visualization, abstract particle field, "
"glowing pose overlay, wifi signal arcs, dark tech aesthetic"),
("/tmp/room-viz-branded/room-viz.mp4", "branded",
"ruview style room visualization, branded hud overlay, "
"glowing cyan and emerald pose skeleton, vitals readout, dark tech aesthetic"),
("/tmp/room-viz-h3-architectural/room-viz.mp4", "h3-architectural",
"ruview style room visualization, architectural wireframe overlay, "
"glowing pose skeleton, wifi signal arcs, vitals readout, dark tech aesthetic"),
("/tmp/room-viz-h3-cinematic/room-viz.mp4", "h3-cinematic",
"ruview style room visualization, cinematic lighting, "
"glowing pose overlay, wifi signal arcs, vitals readout, dark tech aesthetic"),
("/tmp/room-viz-h3-minimal/room-viz.mp4", "h3-minimal",
"ruview style room visualization, minimal clean overlay, "
"glowing pose skeleton, subtle wifi signal arcs, dark tech aesthetic"),
("/tmp/room-viz-h3-abstract/room-viz.mp4", "h3-abstract",
"ruview style room visualization, abstract particle field, "
"glowing pose overlay, wifi signal arcs, dark tech aesthetic"),
]
FRAME_INTERVAL_SEC = 1.5
def extract(src: Path, style: str, caption: str, out_dir: Path) -> int:
pattern = out_dir / f"{style}_%03d.jpg"
cmd = [
"ffmpeg", "-y", "-loglevel", "error",
"-i", str(src),
"-vf", f"fps=1/{FRAME_INTERVAL_SEC}",
"-q:v", "2",
str(pattern),
]
subprocess.run(cmd, check=True)
frames = sorted(out_dir.glob(f"{style}_*.jpg"))
for frame in frames:
frame.with_suffix(".txt").write_text(caption, encoding="utf-8")
return len(frames)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--out", default=str(REPO_ROOT / "scripts" / "fal-visual-teacher" / "training-data"),
help="Output directory for extracted frames + captions (gitignored)",
)
args = parser.parse_args()
out_dir = Path(args.out)
if out_dir.exists():
shutil.rmtree(out_dir)
out_dir.mkdir(parents=True)
total = 0
for src_str, style, caption in SOURCES:
src = Path(src_str)
if not src.exists():
print(f"SKIP (missing): {src}", file=sys.stderr)
continue
n = extract(src, style, caption, out_dir)
print(f"{style}: {n} frames from {src}")
total += n
print(f"\nTotal frames extracted: {total}")
print(f"Training data dir: {out_dir}")
if total < 4:
print("ERROR: fal.ai training needs at least 4 images", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Submit a real fal.ai LoRA training job for the RuView synthetic visual teacher.
Zips the extracted training-data directory, uploads it to fal.ai storage,
submits a training job to fal-ai/flux-lora-fast-training, polls until it
completes, and writes the resulting trained-model reference (diffusers LoRA
weights URL + config URL) to a local (gitignored) JSON file.
Architecture boundary (ADR-353): the model trained here is ONLY ever a
source of supplementary synthetic visual/pixel output. It must never
influence, or be treated as a source of, measured/derived RF, pose, vitals,
identity, or confidence values.
Requires FAL_KEY in the environment (fetch from GCP Secret Manager,
project cognitum-20260110, secret MINIMAX_MUSIC_FAL_KEY, before running).
"""
import argparse
import json
import os
import sys
import time
import zipfile
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
TRAINING_ENDPOINT = "fal-ai/flux-lora-fast-training"
def zip_training_data(src_dir: Path, zip_path: Path) -> None:
if zip_path.exists():
zip_path.unlink()
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for f in sorted(src_dir.iterdir()):
if f.is_file():
zf.write(f, arcname=f.name)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--training-data", default=str(SCRIPT_DIR / "training-data"),
help="Directory of extracted frames + captions (from extract_frames.py)",
)
parser.add_argument(
"--steps", type=int, default=1000,
help="Training steps (fal-ai/flux-lora-fast-training default is 1000, ~$2)",
)
parser.add_argument(
"--trigger-word", default="ruviewstyle",
help="Trigger token to prepend so the LoRA is invocable at inference time",
)
parser.add_argument(
"--out", default=str(SCRIPT_DIR / "output" / "training-result.json"),
help="Where to write the trained model reference JSON (gitignored)",
)
args = parser.parse_args()
if not os.environ.get("FAL_KEY"):
print("ERROR: FAL_KEY not set in environment", file=sys.stderr)
return 1
import fal_client # imported after the FAL_KEY check for a clearer error
training_dir = Path(args.training_data)
if not training_dir.exists():
print(f"ERROR: {training_dir} not found -- run extract_frames.py first", file=sys.stderr)
return 1
zip_path = training_dir.parent / "training-data.zip"
print(f"Zipping {training_dir} -> {zip_path}")
zip_training_data(training_dir, zip_path)
print(f"Zip size: {zip_path.stat().st_size / 1024:.1f} KB")
print("Uploading training zip to fal.ai storage...")
images_data_url = fal_client.upload_file(str(zip_path))
print(f"Uploaded: {images_data_url}")
training_args = {
"images_data_url": images_data_url,
"steps": args.steps,
"trigger_word": args.trigger_word,
# This is a style LoRA (recurring visual aesthetic across varied room
# layouts), not a subject/character LoRA -- fal.ai's is_style flag
# disables segmentation/auto-captioning so our own caption .txt files
# (one per frame, describing the shared style) are used as-is.
"is_style": True,
}
print(f"Submitting training job to {TRAINING_ENDPOINT} (steps={args.steps})...")
handle = fal_client.submit(TRAINING_ENDPOINT, arguments=training_args)
print(f"request_id: {handle.request_id}")
start = time.time()
for event in handle.iter_events(with_logs=True, interval=5.0):
print(f"[{time.time() - start:.0f}s] {event}")
result = handle.get()
elapsed = time.time() - start
print(f"Training completed in {elapsed:.0f}s")
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
record = {
"endpoint": TRAINING_ENDPOINT,
"request_id": handle.request_id,
"steps": args.steps,
"trigger_word": args.trigger_word,
"training_images": sum(1 for f in training_dir.glob("*.jpg")),
"elapsed_seconds": round(elapsed),
"result": result,
}
out_path.write_text(json.dumps(record, indent=2), encoding="utf-8")
print(f"Wrote {out_path}")
lora_file = (result or {}).get("diffusers_lora_file", {}).get("url")
if lora_file:
print(f"\nTrained LoRA weights URL: {lora_file}")
else:
print("\nWARNING: no diffusers_lora_file.url in result -- inspect the JSON", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Run a real validation inference call against the trained visual-teacher LoRA.
Loads the trained model reference written by train_lora.py, calls
fal-ai/flux-lora with the trained LoRA weights, and saves the generated
image as evidence that training actually produced a working style LoRA.
This inference call, and the LoRA it exercises, produce SYNTHETIC display
imagery only (ADR-353) -- never a source of measured RF/pose/vitals/identity
or confidence values.
Requires FAL_KEY in the environment.
"""
import argparse
import json
import os
import sys
import urllib.request
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT_DIR = Path(__file__).resolve().parent
INFERENCE_ENDPOINT = "fal-ai/flux-lora"
DEFAULT_PROMPT = (
"ruviewstyle room visualization, a living room with a glowing cyan pose "
"skeleton overlay, wifi signal arcs radiating across the space, a vitals "
"readout panel, dark tech aesthetic, synthetic sensor visualization"
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--training-result", default=str(SCRIPT_DIR / "output" / "training-result.json"),
)
parser.add_argument("--prompt", default=DEFAULT_PROMPT)
parser.add_argument("--scale", type=float, default=1.0, help="LoRA application strength")
parser.add_argument(
"--out", default=str(REPO_ROOT / "assets" / "fal-visual-teacher-sample.png"),
)
args = parser.parse_args()
if not os.environ.get("FAL_KEY"):
print("ERROR: FAL_KEY not set in environment", file=sys.stderr)
return 1
import fal_client
training_result_path = Path(args.training_result)
record = json.loads(training_result_path.read_text(encoding="utf-8"))
lora_url = record["result"]["diffusers_lora_file"]["url"]
print(f"Using trained LoRA: {lora_url}")
print(f"Submitting validation inference to {INFERENCE_ENDPOINT}...")
result = fal_client.subscribe(
INFERENCE_ENDPOINT,
arguments={
"prompt": args.prompt,
"loras": [{"path": lora_url, "scale": args.scale}],
"image_size": "landscape_16_9",
"num_images": 1,
"output_format": "png",
},
with_logs=True,
)
images = result.get("images", [])
if not images:
print(f"ERROR: no images in result: {result}", file=sys.stderr)
return 1
image_url = images[0]["url"]
print(f"Generated image: {image_url}")
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
urllib.request.urlretrieve(image_url, out_path)
print(f"Saved validation sample to {out_path}")
meta_path = out_path.with_suffix(".json")
meta_path.write_text(json.dumps({
"endpoint": INFERENCE_ENDPOINT,
"lora_url": lora_url,
"prompt": args.prompt,
"scale": args.scale,
"source_image_url": image_url,
}, indent=2), encoding="utf-8")
print(f"Saved validation metadata to {meta_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,160 @@
"""
Faithful Python port of the window/baseline/metric definitions in
v2/crates/ruforecast/crates/ruforecast-train/examples/bidmc_prepare.rs and
v2/crates/ruforecast/crates/ruforecast-core/src/{baseline,metrics}.rs so the
zero-shot pretrained-model comparison is apples-to-apples with RuForecast's
own real-data evaluation.
CONTEXT=64, HORIZON=12, VARIATES=3 (HR, RESP, SpO2), quantiles as in
ForecastModelConfig::tiny_ci(), seasonal_period=12 (the `evaluate` CLI default).
"""
import csv
import math
import os
CONTEXT = 64
HORIZON = 12
WINDOW_ROWS = CONTEXT + HORIZON
VARIATE_NAMES = ["HR", "RESP", "SpO2"]
QUANTILES = [0.05, 0.10, 0.25, 0.50, 0.75, 0.90, 0.95]
SEASONAL_PERIOD = 12
RAW_DIR = "/tmp/bidmc-raw"
def read_patient(pid: int):
path = os.path.join(RAW_DIR, f"bidmc_{pid:02d}_Numerics.csv")
if not os.path.exists(path):
return None
rows = []
with open(path, newline="") as f:
reader = csv.reader(f)
next(reader) # header
for parts in reader:
if len(parts) < 5:
continue
parts = [p.strip() for p in parts]
try:
hr = float(parts[1])
resp = float(parts[3])
spo2 = float(parts[4])
except ValueError:
return None # malformed row -> treat like the Rust `?` early-return
rows.append((hr, resp, spo2))
return rows
def build_window(pid: int):
"""One window per patient: first CONTEXT rows are context, next HORIZON
rows are targets. Mirrors build_window() in bidmc_prepare.rs exactly
(context_start = row 0, no scanning for a "best" window)."""
rows = read_patient(pid)
if rows is None or len(rows) < WINDOW_ROWS:
return None
first = rows[:WINDOW_ROWS]
for row in first:
for v in row:
if not math.isfinite(v):
return None
context = first[:CONTEXT] # list of (hr, resp, spo2), CONTEXT rows
targets = first[CONTEXT:WINDOW_ROWS] # HORIZON rows
return {"patient": pid, "context": context, "targets": targets}
def judge_split(judge: str):
if judge == "a":
train_ids = list(range(1, 38))
test_ids = list(range(38, 54))
elif judge == "b":
train_ids = [i for i in range(1, 54) if i % 2 == 1]
test_ids = [i for i in range(1, 54) if i % 2 == 0]
else:
raise ValueError(judge)
return train_ids, test_ids
def build_test_windows(judge: str):
_, test_ids = judge_split(judge)
windows = []
excluded = []
for pid in test_ids:
w = build_window(pid)
if w is None:
excluded.append(pid)
else:
windows.append(w)
return windows, excluded
# ---- baselines (mirror ruforecast-core::baseline.rs) ----
def last_value_forecast(context):
"""Repeat the last observed context row for every future step (every
quantile gets the same point value -> degenerate distribution)."""
last = context[-1]
return [[last[v] for _ in QUANTILES] for _ in range(HORIZON) for v in range(len(last))]
# NOTE: shape handled explicitly in metric functions below instead; see
# last_value_predictions() for the actually-used [horizon][variate] form.
def last_value_predictions(context):
last = context[-1]
return [[last[v] for v in range(len(last))] for _ in range(HORIZON)]
def seasonal_naive_predictions(context, period=SEASONAL_PERIOD):
"""context has CONTEXT=64 rows; base = len(context) - period; for step in
0..HORIZON: row = base + step % period."""
base = len(context) - period
preds = []
for step in range(HORIZON):
row = base + (step % period)
preds.append(list(context[row]))
return preds
# ---- metrics (mirror ruforecast-core::metrics.rs) ----
def mae(actual_flat, predicted_flat):
total, count = 0.0, 0
for a, p in zip(actual_flat, predicted_flat):
total += abs(a - p)
count += 1
return total / count if count else float("nan")
def weighted_quantile_loss(actual_flat, quantile_preds_flat):
"""actual_flat: list of HORIZON*VARIATES actuals in step-major order
(step0-var0,step0-var1,step0-var2, step1-var0, ...).
quantile_preds_flat: same order, each entry is a list over QUANTILES.
Matches weighted_quantile_loss() in metrics.rs: sum pinball over every
(cell, quantile), normalize by 2 / (num_quantiles * sum(|actual|))."""
loss = 0.0
scale = 0.0
for actual, preds in zip(actual_flat, quantile_preds_flat):
scale += abs(actual)
for q, pred in zip(QUANTILES, preds):
residual = actual - pred
loss += q * residual if residual >= 0 else (q - 1.0) * residual
if scale == 0.0:
raise ValueError("weighted quantile loss undefined: all-zero actual targets")
return 2.0 * loss / (len(QUANTILES) * scale)
def flatten_targets_step_major(targets):
"""targets: HORIZON rows of VARIATES values -> flat step-major list."""
out = []
for row in targets:
out.extend(row)
return out
def point_to_quantile_preds(point_preds):
"""point_preds: HORIZON rows of VARIATES point values (deterministic
baseline) -> step-major flat list where every quantile equals the point
value, matching how LastValue/SeasonalNaive fill every quantile slot
with the same repeated value."""
out = []
for row in point_preds:
for v in row:
out.append([v for _ in QUANTILES])
return out
@@ -0,0 +1,193 @@
"""
Real zero-shot pretrained-checkpoint comparison for RuForecast (see
docs/benchmarks/ruforecast.md). Runs Amazon Chronos-Bolt (and TimesFM, if
installed) zero-shot inference against the SAME BIDMC test windows,
baselines, and metrics RuForecast's own `evaluate` CLI uses, and prints a
markdown table.
No fine-tuning. No training. Every "model" forecast below comes from a
public pretrained checkpoint's zero-shot .predict()/.forecast() call.
"""
import sys
import time
import numpy as np
sys.path.insert(0, "/tmp") # bidmc_windows.py copied there
from bidmc_windows import (
CONTEXT, HORIZON, VARIATE_NAMES, QUANTILES, SEASONAL_PERIOD,
build_test_windows, judge_split,
last_value_predictions, seasonal_naive_predictions,
mae, weighted_quantile_loss, flatten_targets_step_major, point_to_quantile_preds,
)
def eval_baselines(windows):
"""Returns dict: name -> (mae, wql) aggregated over ALL test windows and
variates, matching how the Rust evaluate CLI aggregates (one running
mean/scale accumulation across the whole test set, not averaged per
window)."""
results = {}
for name, fn in [("last-value", last_value_predictions),
("seasonal-naive", lambda ctx: seasonal_naive_predictions(ctx, SEASONAL_PERIOD))]:
all_actual, all_point_pred, all_qpred = [], [], []
for w in windows:
preds = fn(w["context"]) # HORIZON rows x VARIATES
actual_flat = flatten_targets_step_major(w["targets"])
point_flat = flatten_targets_step_major(preds)
qpred_flat = point_to_quantile_preds(preds)
all_actual.extend(actual_flat)
all_point_pred.extend(point_flat)
all_qpred.extend(qpred_flat)
results[name] = (mae(all_actual, all_point_pred), weighted_quantile_loss(all_actual, all_qpred))
return results
def chronos_zero_shot_predictions(windows, model_name="amazon/chronos-bolt-base", device="cuda"):
"""Per-variate univariate zero-shot forecast (Chronos has no native
multivariate mode) via ChronosPipeline. Returns list parallel to
`windows`, each entry: HORIZON rows x VARIATES quantile-lists."""
from chronos import BaseChronosPipeline
pipeline = BaseChronosPipeline.from_pretrained(model_name, device_map=device, torch_dtype="bfloat16" if device == "cuda" else "float32")
import torch
all_preds = []
for w in windows:
ctx = w["context"] # HORIZON... actually CONTEXT rows x VARIATES
per_variate_q = []
for v in range(len(VARIATE_NAMES)):
series = torch.tensor([row[v] for row in ctx], dtype=torch.float32)
quantiles, _mean = pipeline.predict_quantiles(
inputs=series,
prediction_length=HORIZON,
quantile_levels=QUANTILES,
)
# quantiles shape: [1, HORIZON, len(QUANTILES)]
per_variate_q.append(quantiles[0].numpy()) # [HORIZON, Q]
# reshape to HORIZON rows, each VARIATES entries of Q-length lists
rows = []
for h in range(HORIZON):
row = [per_variate_q[v][h].tolist() for v in range(len(VARIATE_NAMES))]
rows.append(row)
all_preds.append(rows)
return all_preds
def timesfm_zero_shot_predictions(windows, model_repo="google/timesfm-2.5-200m-pytorch"):
"""Per-variate univariate zero-shot forecast via TimesFM 2.5 200M torch.
Quantile output has 10 columns; per the installed package's source
(timesfm_2p5_base.py: `decode_index: int = 5`), column 5 is the
point/mean forecast and columns 1..9 are quantiles [0.1..0.9] in order
(verified empirically: columns 1-9 are monotonic and column 5 exactly
equals the returned point forecast on every sampled window/variate;
column 0 is an unused duplicate channel, not a real quantile level).
TimesFM has no 0.05/0.95 levels (clamped to 0.1/0.9, same treatment
Chronos-Bolt applies for out-of-range levels) and no native 0.25/0.75
(linearly interpolated between the adjacent 0.1-spaced levels it does
have)."""
import timesfm
import numpy as np
m = timesfm.TimesFM_2p5_200M_torch.from_pretrained(model_repo)
m.compile(timesfm.ForecastConfig(
max_context=CONTEXT, max_horizon=HORIZON, normalize_inputs=True,
use_continuous_quantile_head=True, fix_quantile_crossing=True,
))
native_levels = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9] # columns 1..9
def sample_at(row10, level):
if level <= native_levels[0]:
return row10[1]
if level >= native_levels[-1]:
return row10[9]
for i in range(len(native_levels) - 1):
lo, hi = native_levels[i], native_levels[i + 1]
if lo <= level <= hi:
col_lo, col_hi = 1 + i, 1 + i + 1
if hi == lo:
return row10[col_lo]
frac = (level - lo) / (hi - lo)
return row10[col_lo] + frac * (row10[col_hi] - row10[col_lo])
raise ValueError(level)
all_preds = []
for w in windows:
ctx = w["context"]
per_variate_rows = []
for v in range(len(VARIATE_NAMES)):
series = np.array([row[v] for row in ctx], dtype=np.float32)
_point, quant = m.forecast(horizon=HORIZON, inputs=[series])
per_variate_rows.append(quant[0]) # [HORIZON, 10]
rows = []
for h in range(HORIZON):
row = []
for v in range(len(VARIATE_NAMES)):
row10 = per_variate_rows[v][h]
row.append([sample_at(row10, q) for q in QUANTILES])
rows.append(row)
all_preds.append(rows)
return all_preds
def eval_pretrained(name, predict_fn, windows):
t0 = time.time()
preds = predict_fn(windows)
elapsed = time.time() - t0
all_actual, all_point_pred, all_qpred = [], [], []
median_idx = QUANTILES.index(0.50)
for w, pred_rows in zip(windows, preds):
actual_flat = flatten_targets_step_major(w["targets"])
qpred_flat = []
point_flat = []
for row in pred_rows: # HORIZON rows, each VARIATES quantile-lists
for var_qs in row:
qpred_flat.append(list(var_qs))
point_flat.append(var_qs[median_idx])
all_actual.extend(actual_flat)
all_point_pred.extend(point_flat)
all_qpred.extend(qpred_flat)
m = mae(all_actual, all_point_pred)
wql = weighted_quantile_loss(all_actual, all_qpred)
return m, wql, elapsed
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--models", nargs="+", default=["chronos-bolt-base"])
parser.add_argument("--judges", nargs="+", default=["a", "b"])
args = parser.parse_args()
print("| Judge | Model | Test windows | MAE | WQL | Wall time (s) |")
print("|---|---|---:|---:|---:|---:|")
for judge in args.judges:
windows, excluded = build_test_windows(judge)
train_ids, test_ids = judge_split(judge)
print(f"<!-- judge {judge}: {len(windows)} test windows from {len(test_ids)} candidate patients, excluded={excluded} -->", file=sys.stderr)
baselines = eval_baselines(windows)
for name, (m, wql) in baselines.items():
print(f"| {judge} | {name} (baseline) | {len(windows)} | {m:.4f} | {wql:.5f} | - |")
for model in args.models:
if model == "chronos-bolt-base":
fn = lambda w: chronos_zero_shot_predictions(w, "amazon/chronos-bolt-base")
elif model == "chronos-bolt-small":
fn = lambda w: chronos_zero_shot_predictions(w, "amazon/chronos-bolt-small")
elif model == "chronos-t5-small":
fn = lambda w: chronos_zero_shot_predictions(w, "amazon/chronos-t5-small")
elif model == "timesfm-2.5-200m":
fn = timesfm_zero_shot_predictions
else:
print(f"unknown model {model}", file=sys.stderr)
continue
try:
m, wql, elapsed = eval_pretrained(model, fn, windows)
print(f"| {judge} | {model} (zero-shot) | {len(windows)} | {m:.4f} | {wql:.5f} | {elapsed:.1f} |")
except Exception as e:
print(f"| {judge} | {model} (zero-shot) | {len(windows)} | FAILED | FAILED | - |")
print(f"ERROR running {model} on judge {judge}: {e}", file=sys.stderr)
if __name__ == "__main__":
main()
+21
View File
@@ -28,6 +28,7 @@ import { i18n } from './utils/i18n.js';
import { ScreenshotTool } from './utils/screenshot.js';
import { UptimeClock } from './utils/uptime-clock.js';
import { QuickSettings } from './utils/quick-settings.js';
import { dataSourceBanner } from './utils/data-source-banner.js';
class WiFiDensePoseApp {
constructor() {
@@ -98,6 +99,10 @@ class WiFiDensePoseApp {
this.showBackendStatus('Backend unavailable — start sensing-server', 'warning');
}
// Mount the global provenance bar before the service starts, so the very
// first non-live state is visible on whichever tab is showing.
dataSourceBanner.init();
// Start the sensing WebSocket service early so the dashboard and
// live-demo tabs can show the correct data-source status immediately.
sensingService.start();
@@ -360,6 +365,22 @@ class WiFiDensePoseApp {
window.addEventListener('beforeunload', () => {
this.cleanup();
});
// `beforeunload` above tears down every component and disconnects the
// sensing WebSocket. If the browser restores this page from bfcache on
// Back/Forward instead of doing a fresh navigation, that torn-down state
// comes back frozen exactly as-is — nothing re-runs DOMContentLoaded, so
// sensingService.start() never fires again and disposed components (e.g.
// Room Builder) never get recreated. `pageshow`'s `event.persisted` is
// the standard signal for "this is a bfcache restore, not a fresh load";
// forcing a real reload here is simpler and more robust than trying to
// hand-reinitialize every component/connection back to a clean state.
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
console.log('Page restored from bfcache after cleanup() — forcing reload');
window.location.reload();
}
});
}
// Handle window resize
+2 -1
View File
@@ -95,7 +95,8 @@ export class DashboardTab {
'live': { text: 'ESP32', status: 'healthy', msg: 'Real hardware connected' },
'server-simulated': { text: 'SIMULATED', status: 'warning', msg: 'Server running without hardware' },
'reconnecting': { text: 'RECONNECTING', status: 'degraded', msg: 'Attempting to connect...' },
'simulated': { text: 'OFFLINE', status: 'unhealthy', msg: 'Server unreachable, local fallback' },
'unreachable': { text: 'NO DATA', status: 'unhealthy', msg: 'Server unreachable — readings below are stale' },
'simulated': { text: 'INVENTED', status: 'unhealthy', msg: 'Browser-generated data, not measured' },
};
const cfg = config[ds] || config['reconnecting'];
el.className = `component-status status-${cfg.status}`;
+4 -2
View File
@@ -1318,7 +1318,8 @@ export class LiveDemoTab {
const ds = sensingService.dataSource;
if (ds === 'live') return 'Active \u2014 ESP32 Live';
if (ds === 'server-simulated') return 'Active \u2014 Simulated Data';
if (ds === 'simulated') return 'Active \u2014 Offline Simulation';
if (ds === 'unreachable') return 'No Data \u2014 Server Unreachable';
if (ds === 'simulated') return 'INVENTED DATA \u2014 Not Measured';
return 'Connecting...';
}
@@ -1331,7 +1332,8 @@ export class LiveDemoTab {
'live': { text: 'LIVE \u2014 ESP32 Hardware Connected', cls: 'demo-source-live' },
'server-simulated': { text: 'SIMULATED DATA \u2014 No Hardware Detected', cls: 'demo-source-sim' },
'reconnecting': { text: 'RECONNECTING TO SERVER...', cls: 'demo-source-reconnecting' },
'simulated': { text: 'OFFLINE \u2014 Server Unreachable, Local Sim', cls: 'demo-source-offline' },
'unreachable': { text: 'NO DATA \u2014 Server Unreachable, Display Is Stale', cls: 'demo-source-offline' },
'simulated': { text: 'INVENTED DATA \u2014 Generated By Your Browser, Not Measured', cls: 'demo-source-offline' },
};
const cfg = config[ds] || config['reconnecting'];
banner.textContent = cfg.text;
+2 -1
View File
@@ -228,7 +228,8 @@ export class SensingTab {
'live': { text: 'LIVE \u2014 ESP32 HARDWARE', cls: 'sensing-source-live' },
'server-simulated': { text: 'SIMULATED \u2014 NO HARDWARE', cls: 'sensing-source-server-sim' },
'reconnecting': { text: 'RECONNECTING...', cls: 'sensing-source-reconnecting' },
'simulated': { text: 'OFFLINE \u2014 CLIENT SIMULATION', cls: 'sensing-source-simulated' },
'unreachable': { text: 'NO DATA \u2014 SERVER UNREACHABLE', cls: 'sensing-source-simulated' },
'simulated': { text: 'INVENTED DATA \u2014 NOT MEASURED', cls: 'sensing-source-simulated' },
};
const cfg = bannerConfig[dataSource] || bannerConfig.reconnecting;
banner.textContent = cfg.text;
+122 -12
View File
@@ -37,6 +37,39 @@ const MAX_RECONNECT_ATTEMPTS = 20;
const SIM_FALLBACK_AFTER_ATTEMPTS = 5;
const SIMULATION_INTERVAL = 500; // ms
export const SIM_FALLBACK_STORAGE_KEY = 'ruview-client-simulation';
/**
* Whether the client may invent sensing frames when the server is unreachable.
*
* Default **off**, deliberately. The generated frames are plausible — presence
* derived from `Math.sin(t * 0.3)` crossing a fixed threshold produces a steady
* absent/present_still alternation that reads exactly like a real classifier
* struggling at its decision boundary. A reader with no reason to suspect the
* server had dropped will diagnose the sensing pipeline, and be wrong. That is
* not a hypothetical: it cost a full debugging session on 2026-08-28, chasing a
* "flip-flop" that was this function all along while the server sat at a
* perfectly steady presence=true.
*
* For an instrument, "no signal" is the honest reading and a useful one.
* Synthetic motion is only appropriate for a demo, so it must be asked for:
* `?simulate=1`, or `localStorage['ruview-client-simulation'] = 'true'`.
*
* Repository rule: never present synthetic output as measurement (CLAUDE.md).
*/
function clientSimulationAllowed() {
try {
const params = new URLSearchParams(window.location.search);
if (params.get('simulate') === '1') return true;
if (params.get('simulate') === '0') return false;
} catch { /* no window/location — non-browser test context */ }
try {
return localStorage.getItem(SIM_FALLBACK_STORAGE_KEY) === 'true';
} catch {
return false;
}
}
class SensingService {
constructor() {
/** @type {WebSocket|null} */
@@ -46,6 +79,11 @@ class SensingService {
this._reconnectAttempt = 0;
this._reconnectTimer = null;
this._simTimer = null;
// Guards the await window inside `_connect()` (see the comment there).
this._connectInFlight = false;
// Bumped per connect attempt and by `stop()`; lets a socket recognise that
// it has been abandoned and stop driving reconnect state.
this._connectEpoch = 0;
// Connection state: disconnected | connecting | connected | reconnecting | simulated
this._state = 'disconnected';
// Data-source label exposed to the UI:
@@ -72,6 +110,10 @@ class SensingService {
/** Stop the service entirely. */
stop() {
// Invalidate any attempt still inside its await window, so a socket opened
// after this call does not resurrect the service.
this._connectEpoch++;
this._connectInFlight = false;
this._clearTimers();
if (this._ws) {
this._ws.close(1000, 'client stop');
@@ -114,7 +156,10 @@ class SensingService {
* Current data source label.
* "live" — frames are arriving from the real ESP32 over WebSocket
* "reconnecting" — WebSocket disconnected; actively retrying, no frames emitted
* "simulated" — max reconnect attempts exhausted; emitting synthetic frames
* "unreachable" — retries exhausted, client simulation off; NO frames emitted
* and whatever is on screen is the last live reading, stale
* "simulated" — client simulation explicitly enabled and running; every
* frame on screen is invented
*/
get dataSource() {
return this._dataSource;
@@ -129,6 +174,22 @@ class SensingService {
// reconnects would fail on the second attempt.
async _connect() {
if (this._ws && this._ws.readyState <= WebSocket.OPEN) return;
// The readyState guard above cannot stand alone: this function awaits
// ticket minting before it assigns `this._ws`, so two callers entering
// during that window BOTH pass the guard and BOTH open a socket. The
// second assignment orphans the first — a socket nobody references, still
// open on the server, whose `onclose` fires later and calls
// `_scheduleReconnect()` even though a healthy socket is live. That walks
// `_reconnectAttempt` upward with no outage and eventually trips the
// simulation fallback. Observed on the server as connect/disconnect churn
// on every page load.
if (this._connectInFlight) return;
this._connectInFlight = true;
// Identifies this attempt for the lifetime of its socket. `stop()` and any
// superseding attempt bump it, so a late event from an abandoned socket is
// recognised and ignored rather than driving reconnect state.
const epoch = ++this._connectEpoch;
this._setState('connecting');
@@ -140,15 +201,34 @@ class SensingService {
// predating ADR-272, connecting without a ticket is correct.
}
if (epoch !== this._connectEpoch) {
// Superseded or stopped while minting. The ticket is single-use and now
// spent; the current attempt mints its own.
this._connectInFlight = false;
return;
}
let ws;
try {
this._ws = new WebSocket(url);
ws = new WebSocket(url);
} catch (err) {
console.warn('[Sensing] WebSocket constructor failed:', err.message);
this._connectInFlight = false;
this._fallbackToSimulation();
return;
}
this._ws.onopen = () => {
this._ws = ws;
this._connectInFlight = false;
/** True while `ws` is still the socket this service is driving. */
const isCurrent = () => epoch === this._connectEpoch && this._ws === ws;
ws.onopen = () => {
if (!isCurrent()) {
ws.close(1000, 'superseded');
return;
}
console.info('[Sensing] Connected to', SENSING_WS_URL);
this._reconnectAttempt = 0;
this._stopSimulation();
@@ -158,7 +238,8 @@ class SensingService {
this._detectServerSource();
};
this._ws.onmessage = (evt) => {
ws.onmessage = (evt) => {
if (!isCurrent()) return;
try {
const data = JSON.parse(evt.data);
this._handleData(data);
@@ -167,11 +248,17 @@ class SensingService {
}
};
this._ws.onerror = () => {
ws.onerror = () => {
// onerror is always followed by onclose, so we handle reconnect there
};
this._ws.onclose = (evt) => {
ws.onclose = (evt) => {
if (!isCurrent()) {
// An abandoned socket closing is not an outage. Reconnect state
// belongs to whichever attempt is current.
console.debug('[Sensing] Ignoring close from superseded socket');
return;
}
console.info('[Sensing] Connection closed (code=%d)', evt.code);
this._ws = null;
if (evt.code !== 1000) {
@@ -184,18 +271,31 @@ class SensingService {
}
_scheduleReconnect() {
// A retry is already pending; stacking timers would multiply the attempt
// count for a single outage.
if (this._reconnectTimer) return;
if (this._reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
console.warn('[Sensing] Max reconnect attempts (%d) reached, switching to simulation', MAX_RECONNECT_ATTEMPTS);
this._fallbackToSimulation();
return;
if (clientSimulationAllowed()) {
console.warn('[Sensing] Max reconnect attempts (%d) reached, switching to simulation', MAX_RECONNECT_ATTEMPTS);
this._fallbackToSimulation();
return;
}
// With simulation off there is nothing to give up *for*: keep retrying at
// the ceiling delay so the UI recovers by itself when the server returns,
// rather than requiring a hard refresh.
this._setDataSource('unreachable');
}
const delay = RECONNECT_DELAYS[Math.min(this._reconnectAttempt, RECONNECT_DELAYS.length - 1)];
this._reconnectAttempt++;
// Hold at the cap rather than counting into the thousands over a long
// outage; the delay is already saturated and the number is only read by a
// human in the console.
this._reconnectAttempt = Math.min(this._reconnectAttempt + 1, MAX_RECONNECT_ATTEMPTS);
console.info('[Sensing] Reconnecting in %dms (attempt %d/%d)', delay, this._reconnectAttempt, MAX_RECONNECT_ATTEMPTS);
this._setState('reconnecting');
this._setDataSource('reconnecting');
if (this._dataSource !== 'unreachable') this._setDataSource('reconnecting');
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
@@ -205,13 +305,23 @@ class SensingService {
// Only start simulation after several failed attempts so a brief hiccup
// does not immediately switch the UI to "SIMULATED DATA".
if (this._reconnectAttempt >= SIM_FALLBACK_AFTER_ATTEMPTS && this._state !== 'simulated') {
this._fallbackToSimulation();
if (clientSimulationAllowed()) {
this._fallbackToSimulation();
} else {
// Say "the server is gone" instead of inventing a room.
this._setDataSource('unreachable');
}
}
}
// ---- Simulation fallback -----------------------------------------------
_fallbackToSimulation() {
if (!clientSimulationAllowed()) {
this._setDataSource('unreachable');
this._scheduleReconnect();
return;
}
this._setState('simulated');
this._setDataSource('simulated');
if (this._simTimer) return; // already running
+8
View File
@@ -1804,7 +1804,11 @@ canvas {
flex-direction: column;
gap: var(--space-12);
overflow-y: auto;
/* Without this, `overflow-y: auto` alone still resolves the x axis to auto,
so anything marginally too wide scrolls sideways instead of wrapping. */
overflow-x: hidden;
max-height: 600px;
min-width: 0;
}
.sensing-card {
@@ -1812,6 +1816,10 @@ canvas {
border: 1px solid var(--color-card-border);
border-radius: var(--radius-md);
padding: var(--space-12);
/* Flex items default to min-width:auto, which refuses to shrink below their
content — one wide child then widens the whole 320px column and puts a
horizontal scrollbar on the panel. Cards must shrink to the column. */
min-width: 0;
}
.sensing-card-title {
+131
View File
@@ -0,0 +1,131 @@
// Global data-source banner.
//
// The per-tab banner in SensingTab was correct and still invisible where it
// mattered: a reader on Dashboard or Live Demo never saw it, so a screen full
// of client-invented sensing frames was indistinguishable from measurement.
// This bar is fixed to the viewport and present on every tab, so the provenance
// of what is on screen is never more than a glance away.
//
// It shows nothing at all while data is live. An always-on badge becomes
// wallpaper; appearing only when the reading is not trustworthy keeps it
// meaningful.
import { sensingService } from '../services/sensing.service.js';
const STYLE_ID = 'ruview-data-source-banner-style';
const BAR_ID = 'ruview-data-source-banner';
const CONFIG = {
// 'live' is intentionally absent: no banner is shown for trustworthy data.
'server-simulated': {
text: 'SYNTHETIC DATA — the server is generating this, no hardware is being read',
bg: '#7c3a00',
fg: '#ffd9a0',
},
reconnecting: {
text: 'RECONNECTING — the display is frozen at the last live reading',
bg: '#33302a',
fg: '#e8dcc0',
},
unreachable: {
text: 'SERVER UNREACHABLE — nothing on this screen is current',
bg: '#5c1010',
fg: '#ffc9c9',
},
simulated: {
text: 'SIMULATED — every value on this screen is invented by your browser, not measured',
bg: '#6b0f6b',
fg: '#ffc9ff',
},
};
const CSS = `
#${BAR_ID} {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100000;
display: none;
padding: 7px 14px;
font: 600 13px/1.35 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.04em;
text-align: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.45);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
}
#${BAR_ID}.is-visible { display: block; }
/* Simulated data is the dangerous case — it looks real. Make the bar move so
it cannot be mistaken for a static decoration. */
#${BAR_ID}.is-simulated { animation: ruview-dsb-pulse 1.6s ease-in-out infinite; }
@keyframes ruview-dsb-pulse { 50% { filter: brightness(1.45); } }
@media (prefers-reduced-motion: reduce) {
#${BAR_ID}.is-simulated { animation: none; }
}
body.ruview-has-data-source-banner { padding-top: 30px; }
`;
export class DataSourceBanner {
constructor() {
this._bar = null;
this._unsubscribe = null;
}
init() {
if (typeof document === 'undefined') return;
if (!document.getElementById(STYLE_ID)) {
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
}
this._bar = document.getElementById(BAR_ID);
if (!this._bar) {
this._bar = document.createElement('div');
this._bar.id = BAR_ID;
this._bar.setAttribute('role', 'status');
// Announce changes, but don't interrupt: the reader is mid-task and the
// message describes context, not an error to act on right now.
this._bar.setAttribute('aria-live', 'polite');
document.body.appendChild(this._bar);
}
// dataSource changes are published on the state-listener channel.
this._unsubscribe = sensingService.onStateChange(() => this.render());
this.render();
}
render() {
if (!this._bar) return;
const cfg = CONFIG[sensingService.dataSource];
if (!cfg) {
this._bar.classList.remove('is-visible', 'is-simulated');
document.body.classList.remove('ruview-has-data-source-banner');
return;
}
this._bar.textContent = cfg.text;
this._bar.style.background = cfg.bg;
this._bar.style.color = cfg.fg;
this._bar.classList.add('is-visible');
this._bar.classList.toggle('is-simulated', sensingService.dataSource === 'simulated');
document.body.classList.add('ruview-has-data-source-banner');
}
dispose() {
if (this._unsubscribe) {
this._unsubscribe();
this._unsubscribe = null;
}
if (this._bar && this._bar.parentNode) {
this._bar.parentNode.removeChild(this._bar);
}
this._bar = null;
document.body.classList.remove('ruview-has-data-source-banner');
}
}
export const dataSourceBanner = new DataSourceBanner();
@@ -145,13 +145,16 @@ impl PpduType {
/// bit 2 : STBC
/// bit 3 : LDPC (reserved — not yet populated by firmware)
/// bit 4 : 802.15.4 time-sync valid (C6 only)
/// bit 5-7 : reserved
/// bit 5 : ESP-IDF invalid first CSI word was zeroed by firmware
/// bit 6-7 : reserved
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Adr018Flags {
pub bw40: bool,
pub stbc: bool,
pub ldpc: bool,
pub ieee802154_sync_valid: bool,
#[serde(default)]
pub first_word_sanitized: bool,
}
impl Adr018Flags {
@@ -161,6 +164,7 @@ impl Adr018Flags {
stbc: (b & 0x04) != 0,
ldpc: (b & 0x08) != 0,
ieee802154_sync_valid: (b & 0x10) != 0,
first_word_sanitized: (b & 0x20) != 0,
}
}
pub fn to_byte(self) -> u8 {
@@ -169,6 +173,7 @@ impl Adr018Flags {
if self.stbc { b |= 0x04; }
if self.ldpc { b |= 0x08; }
if self.ieee802154_sync_valid { b |= 0x10; }
if self.first_word_sanitized { b |= 0x20; }
b
}
}
@@ -413,15 +413,16 @@ mod tests {
#[test]
fn adr110_flags_round_trip_all_bits() {
// All known flag bits set: bw40 (0x01) + STBC (0x04) + LDPC (0x08) + 15.4-sync (0x10) = 0x1D
let data = build_test_frame_with_he(6, 1, &[(0, 0); 56], 1, 0x1D);
// All known flag bits set: bw40 + STBC + LDPC + sync + sanitized prefix = 0x3D.
let data = build_test_frame_with_he(6, 1, &[(0, 0); 56], 1, 0x3D);
let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap();
assert!(frame.metadata.adr018_flags.bw40);
assert!(frame.metadata.adr018_flags.stbc);
assert!(frame.metadata.adr018_flags.ldpc);
assert!(frame.metadata.adr018_flags.ieee802154_sync_valid);
assert!(frame.metadata.adr018_flags.first_word_sanitized);
// Round-trip the encoder
assert_eq!(frame.metadata.adr018_flags.to_byte(), 0x1D);
assert_eq!(frame.metadata.adr018_flags.to_byte(), 0x3D);
}
#[test]
@@ -30,7 +30,7 @@ mdns-sd = "0.11"
# Serialization
serde = { workspace = true }
serde_json.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
# Logging
tracing.workspace = true
@@ -43,6 +43,8 @@ per ADR-022 Phase 3.
- **WebSocket broadcast** -- Real-time sensing updates pushed to all connected clients at
`ws://localhost:8765/ws/sensing`.
- **Static file serving** -- Hosts the sensing UI on port 8080 with CORS headers.
- **Private startup baseline** -- Restores a recent, installation-bound empty-room field model
without storing raw CSI or authorizing numeric vital signs (ADR-355).
## Modules
@@ -74,6 +76,23 @@ cargo run -p wifi-densepose-sensing-server -- \
--static-dir ./ui
```
### Empty-room startup baseline
Provide a stable installation ID and an application-owned private state directory:
```bash
cargo run -p wifi-densepose-sensing-server -- \
--installation-id installation-01 \
--data-dir /path/to/private/application-state
```
Complete the normal empty-room calibration, then call
`POST /api/v1/calibration/bootstrap/promote`. The server measures a separate 12-sample holdout
before storing an aggregate field-model snapshot. On a later start with the same installation ID,
the snapshot is restored with `bootstrap_only` authority. It can reduce startup background false
positives, but cannot authorize calibrated evidence or numeric heart and breathing rates. Use
`POST /api/v1/calibration/reset` with administrator scope to remove it.
### Using as a library
```rust
@@ -139,6 +139,39 @@ pub fn features_from_runtime(feat: &serde_json::Value, amps: &[f64]) -> [f64; N_
]
}
/// Preserve the fifth feature's historical meaning for models trained before
/// the server began measuring temporal frequency from a frame window.
///
/// Version 1 models learned a proxy equal to the strongest subcarrier index
/// multiplied by 0.05. Version 2 and later models consume the measured
/// temporal frequency in hertz. Keeping this translation at the model boundary
/// prevents a server upgrade from silently changing an existing model's input
/// distribution.
pub fn compatible_dominant_frequency(
model_version: u32,
temporal_frequency_hz: f64,
amplitudes: &[f64],
) -> f64 {
if model_version >= 2 {
return if temporal_frequency_hz.is_finite() {
temporal_frequency_hz.max(0.0)
} else {
0.0
};
}
amplitudes
.iter()
.enumerate()
.filter(|(_, amplitude)| amplitude.is_finite())
.max_by(|(_, left), (_, right)| {
left.partial_cmp(right)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(index, _)| index as f64 * 0.05)
.unwrap_or(0.0)
}
/// Compute statistical features from raw subcarrier amplitudes.
fn subcarrier_stats(amps: &[f64]) -> (f64, f64, f64, f64, f64, f64, f64, f64) {
if amps.is_empty() {
@@ -642,3 +675,21 @@ pub fn train_from_recordings(recordings_dir: &Path) -> Result<AdaptiveModel, Str
pub fn model_path() -> PathBuf {
PathBuf::from("data/adaptive_model.json")
}
#[cfg(test)]
mod compatibility_tests {
use super::compatible_dominant_frequency;
#[test]
fn version_one_keeps_legacy_subcarrier_proxy() {
let amplitudes = [1.0, 2.0, 8.0, 3.0];
assert_eq!(compatible_dominant_frequency(1, 1.25, &amplitudes), 0.10);
}
#[test]
fn version_two_uses_measured_temporal_frequency() {
let amplitudes = [1.0, 20.0, 2.0];
assert_eq!(compatible_dominant_frequency(2, 1.25, &amplitudes), 1.25);
assert_eq!(compatible_dominant_frequency(2, f64::NAN, &amplitudes), 0.0);
}
}
@@ -491,6 +491,10 @@ const READ_SAFE_MUTATIONS: &[&str] = &[
// Capture and calibration: create data, never destroy it.
"/api/v1/calibration/start",
"/api/v1/calibration/stop",
// Promotion consumes only server measured held out evidence and stores a
// bounded privacy reduced prior. Cancel discards only an unfinished run.
"/api/v1/calibration/bootstrap/promote",
"/api/v1/calibration/cancel",
"/api/v1/pose/calibrate",
"/api/v1/recording/start",
"/api/v1/recording/stop",
@@ -1970,6 +1974,7 @@ mod scope_gate_polarity_tests {
(Method::DELETE, "/api/v1/models/m1"),
(Method::DELETE, "/api/v1/recording/r1"),
(Method::POST, "/api/v1/config/ground-truth"),
(Method::POST, "/api/v1/calibration/reset"),
] {
assert_eq!(
required_scope_for(&m, p),
@@ -0,0 +1,532 @@
//! Local, privacy reduced empty room bootstrap model persistence.
//!
//! A bootstrap image is deliberately lower authority than a completed room
//! calibration. It can suppress background perturbations while the server
//! starts, but restoring it never authorizes calibrated evidence or vitals.
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use sha2::{Digest, Sha256};
use std::fs::{self, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use thiserror::Error;
use wifi_densepose_signal::ruvsense::field_model::{
FieldModel, FieldModelError, FieldModelSnapshotV1,
};
pub const BOOTSTRAP_BASELINE_SCHEMA: &str = "ruview.bootstrap-empty-field-model.v2";
pub const BOOTSTRAP_BASELINE_AUTHORITY: &str = "bootstrap_only";
pub const BOOTSTRAP_VALIDATION_SAMPLES: usize = 12;
pub const BOOTSTRAP_VALIDATION_MIN_EMPTY: usize = 10;
pub const BOOTSTRAP_VALIDATION_MIN_SPACING_MS: u64 = 1_000;
pub const BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS: u64 = 4_000;
const BOOTSTRAP_MAX_FILE_BYTES: u64 = 1_048_576;
/// Exact CSI layout used to train the persisted empty room model.
///
/// Restoring code must require incoming frames to match this binding before
/// the bootstrap image can influence inference.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct BootstrapCsiGrid {
pub n_subcarriers: u16,
pub ppdu_type: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
struct BootstrapPayloadV2 {
schema: String,
authority: String,
installation_binding_sha256: String,
source_node_ids: Vec<u8>,
source_grid: BootstrapCsiGrid,
source_model_id: String,
created_at_unix_ms: u64,
expires_at_unix_ms: u64,
field_model: FieldModelSnapshotV1,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
struct BootstrapImageV2 {
payload: BootstrapPayloadV2,
content_sha256: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct BootstrapImageRawV2 {
payload: Box<RawValue>,
content_sha256: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct BootstrapBaselineMetadata {
pub authority: &'static str,
pub source_node_ids: Vec<u8>,
pub source_grid: BootstrapCsiGrid,
pub source_model_id: String,
pub created_at_unix_ms: u64,
pub expires_at_unix_ms: u64,
pub content_sha256: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BootstrapValidationSample {
pub fresh_tick: bool,
pub calibrated_empty: bool,
pub vital_signs_absent: bool,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
pub struct BootstrapValidationResult {
pub sample_count: usize,
pub empty_sample_count: usize,
pub stale_sample_count: usize,
pub vital_sign_sample_count: usize,
pub passed: bool,
}
#[derive(Debug, Error)]
pub enum BootstrapBaselineError {
#[error("a stable installation_id is required for a local bootstrap baseline")]
MissingInstallationId,
#[error("bootstrap baseline path is invalid")]
InvalidPath,
#[error("bootstrap baseline file is too large")]
FileTooLarge,
#[error("bootstrap baseline image is malformed: {0}")]
Malformed(String),
#[error("bootstrap baseline belongs to another installation")]
InstallationMismatch,
#[error("bootstrap baseline has expired")]
Expired,
#[error("bootstrap baseline digest does not verify")]
DigestMismatch,
#[error("field model snapshot is invalid: {0}")]
FieldModel(#[from] FieldModelError),
#[error("bootstrap baseline storage failed: {0}")]
Io(#[from] std::io::Error),
#[error("bootstrap baseline serialization failed: {0}")]
Json(#[from] serde_json::Error),
}
pub fn path_in(data_dir: &Path) -> PathBuf {
data_dir.join("calibration").join("bootstrap-empty-v1.json")
}
pub fn installation_binding(installation_id: &str) -> Result<String, BootstrapBaselineError> {
let installation_id = installation_id.trim();
if installation_id.is_empty() || installation_id.len() > 128 {
return Err(BootstrapBaselineError::MissingInstallationId);
}
Ok(hex_sha256(installation_id.as_bytes()))
}
pub fn evaluate_validation(samples: &[BootstrapValidationSample]) -> BootstrapValidationResult {
let empty_sample_count = samples
.iter()
.filter(|sample| sample.fresh_tick && sample.calibrated_empty)
.count();
let stale_sample_count = samples.iter().filter(|sample| !sample.fresh_tick).count();
let vital_sign_sample_count = samples
.iter()
.filter(|sample| !sample.vital_signs_absent)
.count();
BootstrapValidationResult {
sample_count: samples.len(),
empty_sample_count,
stale_sample_count,
vital_sign_sample_count,
passed: samples.len() == BOOTSTRAP_VALIDATION_SAMPLES
&& empty_sample_count >= BOOTSTRAP_VALIDATION_MIN_EMPTY
&& stale_sample_count == 0
&& vital_sign_sample_count == 0,
}
}
pub fn store(
path: &Path,
installation_id: &str,
source_node_ids: &[u8],
source_grid: BootstrapCsiGrid,
source_model_id: &str,
created_at_unix_ms: u64,
field_model: &FieldModel,
) -> Result<BootstrapBaselineMetadata, BootstrapBaselineError> {
let snapshot = field_model.export_snapshot()?;
let calibrated_at_ms = snapshot.modes.calibrated_at_us / 1_000;
let expiry_ms = (snapshot.config.baseline_expiry_s * 1_000.0) as u64;
let payload = BootstrapPayloadV2 {
schema: BOOTSTRAP_BASELINE_SCHEMA.to_string(),
authority: BOOTSTRAP_BASELINE_AUTHORITY.to_string(),
installation_binding_sha256: installation_binding(installation_id)?,
source_node_ids: source_node_ids.to_vec(),
source_grid,
source_model_id: source_model_id.to_string(),
created_at_unix_ms,
expires_at_unix_ms: calibrated_at_ms.saturating_add(expiry_ms),
field_model: snapshot,
};
validate_payload(&payload, installation_id, created_at_unix_ms)?;
let content_sha256 = payload_digest(&payload)?;
let image = BootstrapImageV2 {
payload,
content_sha256,
};
let bytes = serde_json::to_vec(&image)?;
if bytes.len() as u64 > BOOTSTRAP_MAX_FILE_BYTES {
return Err(BootstrapBaselineError::FileTooLarge);
}
atomic_write_private(path, &bytes)?;
Ok(metadata(&image))
}
pub fn load(
path: &Path,
installation_id: &str,
current_unix_ms: u64,
) -> Result<(FieldModel, BootstrapBaselineMetadata), BootstrapBaselineError> {
let file_metadata = fs::symlink_metadata(path)?;
if file_metadata.file_type().is_symlink() || !file_metadata.is_file() {
return Err(BootstrapBaselineError::InvalidPath);
}
if file_metadata.len() > BOOTSTRAP_MAX_FILE_BYTES {
return Err(BootstrapBaselineError::FileTooLarge);
}
let mut bytes = Vec::with_capacity(file_metadata.len() as usize);
OpenOptions::new()
.read(true)
.open(path)?
.take(BOOTSTRAP_MAX_FILE_BYTES + 1)
.read_to_end(&mut bytes)?;
if bytes.len() as u64 > BOOTSTRAP_MAX_FILE_BYTES {
return Err(BootstrapBaselineError::FileTooLarge);
}
// Verify the exact payload bytes written by storage before decoding the
// floating point model. Equivalent JSON number spellings may serialize
// differently, so decode and reserialize is not an integrity contract.
let raw_image: BootstrapImageRawV2 = serde_json::from_slice(&bytes)?;
if hex_sha256(raw_image.payload.get().as_bytes()) != raw_image.content_sha256 {
return Err(BootstrapBaselineError::DigestMismatch);
}
let payload: BootstrapPayloadV2 = serde_json::from_str(raw_image.payload.get())?;
validate_payload(&payload, installation_id, current_unix_ms)?;
let image = BootstrapImageV2 {
payload,
content_sha256: raw_image.content_sha256,
};
let model = FieldModel::from_snapshot(
image.payload.field_model.clone(),
current_unix_ms.saturating_mul(1_000),
)?;
Ok((model, metadata(&image)))
}
pub fn remove(path: &Path) -> Result<bool, BootstrapBaselineError> {
match fs::symlink_metadata(path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(BootstrapBaselineError::InvalidPath);
}
fs::remove_file(path)?;
Ok(true)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
fn validate_payload(
payload: &BootstrapPayloadV2,
installation_id: &str,
current_unix_ms: u64,
) -> Result<(), BootstrapBaselineError> {
if payload.schema != BOOTSTRAP_BASELINE_SCHEMA
|| payload.authority != BOOTSTRAP_BASELINE_AUTHORITY
|| payload.source_model_id.is_empty()
|| payload.source_model_id.len() > 128
|| payload.source_node_ids.len() != 1
|| !(1..=2_048).contains(&payload.source_grid.n_subcarriers)
|| payload.source_grid.ppdu_type > 3
|| payload.created_at_unix_ms == 0
|| payload.expires_at_unix_ms <= payload.created_at_unix_ms
{
return Err(BootstrapBaselineError::Malformed(
"invalid schema, authority, identity, node set, source grid, or lifetime".into(),
));
}
if payload.installation_binding_sha256 != installation_binding(installation_id)? {
return Err(BootstrapBaselineError::InstallationMismatch);
}
if current_unix_ms > payload.expires_at_unix_ms {
return Err(BootstrapBaselineError::Expired);
}
Ok(())
}
fn payload_digest(payload: &BootstrapPayloadV2) -> Result<String, serde_json::Error> {
Ok(hex_sha256(&serde_json::to_vec(payload)?))
}
fn metadata(image: &BootstrapImageV2) -> BootstrapBaselineMetadata {
BootstrapBaselineMetadata {
authority: BOOTSTRAP_BASELINE_AUTHORITY,
source_node_ids: image.payload.source_node_ids.clone(),
source_grid: image.payload.source_grid,
source_model_id: image.payload.source_model_id.clone(),
created_at_unix_ms: image.payload.created_at_unix_ms,
expires_at_unix_ms: image.payload.expires_at_unix_ms,
content_sha256: image.content_sha256.clone(),
}
}
fn hex_sha256(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn atomic_write_private(path: &Path, bytes: &[u8]) -> Result<(), BootstrapBaselineError> {
let parent = path.parent().ok_or(BootstrapBaselineError::InvalidPath)?;
fs::create_dir_all(parent)?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or(BootstrapBaselineError::InvalidPath)?;
let mut nonce = [0_u8; 8];
OsRng.fill_bytes(&mut nonce);
let temporary = parent.join(format!(
".{file_name}.{:016x}.tmp",
u64::from_le_bytes(nonce)
));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let write_result = (|| -> Result<(), std::io::Error> {
let mut file = options.open(&temporary)?;
file.write_all(bytes)?;
file.sync_all()?;
fs::rename(&temporary, path)?;
Ok(())
})();
if write_result.is_err() {
let _ = fs::remove_file(&temporary);
}
write_result.map_err(Into::into)
}
#[cfg(test)]
mod tests {
use super::*;
use wifi_densepose_signal::ruvsense::field_model::FieldModelConfig;
const TEST_GRID: BootstrapCsiGrid = BootstrapCsiGrid {
n_subcarriers: 192,
ppdu_type: 2,
};
fn completed_model(now_us: u64) -> FieldModel {
let mut model = FieldModel::new(FieldModelConfig {
n_links: 1,
n_subcarriers: 4,
n_modes: 2,
min_calibration_frames: 3,
min_calibration_duration_s: 0.0,
baseline_expiry_s: 3_600.0,
})
.unwrap();
for index in 0..3 {
model
.feed_calibration(&[vec![1.0 + index as f64, 2.0, 3.0, 4.0]])
.unwrap();
}
model.finalize_calibration(now_us, 7).unwrap();
model
}
#[test]
fn round_trip_is_installation_bound_and_contains_no_raw_frames() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
let stored = store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
let text = String::from_utf8(fs::read(&path).unwrap()).unwrap();
assert!(!text.contains("home-a"));
assert!(!text.contains("raw_csi"));
assert_eq!(stored.source_grid, TEST_GRID);
let (restored, loaded) = load(&path, "home-a", now_ms + 1).unwrap();
assert_eq!(stored, loaded);
assert_eq!(
restored.export_snapshot().unwrap(),
model.export_snapshot().unwrap()
);
assert!(matches!(
load(&path, "home-b", now_ms + 1),
Err(BootstrapBaselineError::InstallationMismatch)
));
}
#[test]
fn tamper_and_expiry_fail_closed() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
let mut image: serde_json::Value =
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
image["payload"]["source_grid"]["n_subcarriers"] = serde_json::json!(64);
fs::write(&path, serde_json::to_vec(&image).unwrap()).unwrap();
assert!(matches!(
load(&path, "home-a", now_ms + 1),
Err(BootstrapBaselineError::DigestMismatch)
));
store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
assert!(matches!(
load(&path, "home-a", now_ms + 3_600_001),
Err(BootstrapBaselineError::Expired)
| Err(BootstrapBaselineError::FieldModel(
FieldModelError::BaselineExpired { .. }
))
));
}
#[test]
fn store_rejects_invalid_source_grid() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
for source_grid in [
BootstrapCsiGrid {
n_subcarriers: 0,
ppdu_type: 0,
},
BootstrapCsiGrid {
n_subcarriers: 2_049,
ppdu_type: 0,
},
BootstrapCsiGrid {
n_subcarriers: 192,
ppdu_type: 4,
},
] {
assert!(matches!(
store(&path, "home-a", &[5], source_grid, "model", now_ms, &model,),
Err(BootstrapBaselineError::Malformed(_))
));
}
}
#[test]
fn store_requires_exactly_one_source_node() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
for source_node_ids in [&[][..], &[5, 7][..]] {
assert!(matches!(
store(
&path,
"home-a",
source_node_ids,
TEST_GRID,
"model",
now_ms,
&model,
),
Err(BootstrapBaselineError::Malformed(_))
));
}
}
#[test]
fn source_grid_is_required_when_loading_a_v2_image() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
let mut image: serde_json::Value =
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
image["payload"]
.as_object_mut()
.unwrap()
.remove("source_grid");
let payload = serde_json::to_vec(&image["payload"]).unwrap();
let rewritten = format!(
"{{\"payload\":{},\"content_sha256\":\"{}\"}}",
String::from_utf8(payload.clone()).unwrap(),
hex_sha256(&payload)
);
fs::write(&path, rewritten).unwrap();
assert!(matches!(
load(&path, "home-a", now_ms + 1),
Err(BootstrapBaselineError::Json(_))
));
}
#[test]
fn promotion_gate_requires_twelve_fresh_samples_and_zero_vitals() {
assert_eq!(BOOTSTRAP_VALIDATION_MIN_SPACING_MS, 1_000);
assert_eq!(BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS, 4_000);
assert!(BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS >= BOOTSTRAP_VALIDATION_MIN_SPACING_MS);
let passing = vec![
BootstrapValidationSample {
fresh_tick: true,
calibrated_empty: true,
vital_signs_absent: true,
};
BOOTSTRAP_VALIDATION_SAMPLES
];
assert!(evaluate_validation(&passing).passed);
let mut stale = passing.clone();
stale[0].fresh_tick = false;
assert!(!evaluate_validation(&stale).passed);
let mut vital = passing;
vital[0].vital_signs_absent = false;
assert!(!evaluate_validation(&vital).passed);
}
#[test]
fn restart_verifies_the_exact_stored_payload_bytes() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
let original = fs::read_to_string(&path).unwrap();
let image: BootstrapImageRawV2 = serde_json::from_str(&original).unwrap();
let rewritten_payload = image.payload.get().replacen(
"\"min_calibration_duration_s\":0.0",
"\"min_calibration_duration_s\":0.00",
1,
);
assert_ne!(rewritten_payload, image.payload.get());
let digest = hex_sha256(rewritten_payload.as_bytes());
let rewritten =
format!("{{\"payload\":{rewritten_payload},\"content_sha256\":\"{digest}\"}}");
fs::write(&path, rewritten).unwrap();
let (_, loaded) = load(&path, "home-a", now_ms + 1).unwrap();
assert_eq!(loaded.content_sha256, digest);
}
}
@@ -11,6 +11,141 @@ use crate::vital_signs::VitalSigns;
const EDGE_MAX_PERSONS: u8 = 4;
/// Estimate a real temporal motion frequency from recent CSI amplitude
/// history. Each frame is reduced to its mean amplitude, detrended, Hann
/// windowed, and evaluated on exact DFT bins.
pub fn estimate_temporal_dominant_frequency_hz(
frame_history: &VecDeque<Vec<f64>>,
sample_rate_hz: f64,
) -> f64 {
if !sample_rate_hz.is_finite() || sample_rate_hz <= 0.0 {
return 0.0;
}
let recent_frames: Vec<&Vec<f64>> = frame_history
.iter()
.rev()
.take(128)
.collect();
if recent_frames
.iter()
.any(|frame| frame.is_empty() || frame.iter().any(|value| !value.is_finite()))
{
return 0.0;
}
let samples: Vec<f64> = recent_frames
.into_iter()
.rev()
.map(|frame| frame.iter().sum::<f64>() / frame.len() as f64)
.collect();
let n = samples.len();
if n < 16 {
return 0.0;
}
let mean = samples.iter().sum::<f64>() / n as f64;
let upper_hz = 4.0_f64.min(sample_rate_hz * 0.45);
if upper_hz < 0.1 {
return 0.0;
}
let mut powers = Vec::new();
for bin in 1..=(n / 2) {
let frequency_hz = bin as f64 * sample_rate_hz / n as f64;
if !(0.1..=upper_hz).contains(&frequency_hz) {
continue;
}
let mut real = 0.0;
let mut imag = 0.0;
let angle_step = 2.0 * std::f64::consts::PI * bin as f64 / n as f64;
let (step_sin, step_cos) = angle_step.sin_cos();
let mut angle_cos = 1.0;
let mut angle_sin = 0.0;
for (index, sample) in samples.iter().enumerate() {
let window =
0.5 * (1.0 - (2.0 * std::f64::consts::PI * index as f64 / (n - 1) as f64).cos());
let value = (sample - mean) * window;
real += value * angle_cos;
imag -= value * angle_sin;
let next_cos = angle_cos * step_cos - angle_sin * step_sin;
angle_sin = angle_sin * step_cos + angle_cos * step_sin;
angle_cos = next_cos;
}
powers.push((frequency_hz, real * real + imag * imag));
}
if powers.is_empty() {
return 0.0;
}
let mean_power = powers.iter().map(|(_, power)| power).sum::<f64>() / powers.len() as f64;
let (peak_hz, peak_power) = powers
.into_iter()
.max_by(|left, right| {
left.1
.partial_cmp(&right.1)
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or((0.0, 0.0));
if mean_power <= f64::EPSILON || peak_power / mean_power < 2.0 {
0.0
} else {
peak_hz
}
}
#[cfg(test)]
mod temporal_frequency_tests {
use super::*;
#[test]
fn recovers_temporal_frequency_in_hertz() {
let sample_rate_hz = 20.0;
let target_hz = 1.2;
let mut history = VecDeque::new();
for sample in 0..100 {
let t = sample as f64 / sample_rate_hz;
let value = 20.0 + 2.0 * (2.0 * std::f64::consts::PI * target_hz * t).sin();
history.push_back(vec![value; 56]);
}
let measured = estimate_temporal_dominant_frequency_hz(&history, sample_rate_hz);
assert!((measured - target_hz).abs() <= 0.21, "measured {measured} Hz");
}
#[test]
fn abstains_on_short_or_flat_history() {
let short = VecDeque::from(vec![vec![20.0; 56]; 15]);
assert_eq!(estimate_temporal_dominant_frequency_hz(&short, 20.0), 0.0);
let flat = VecDeque::from(vec![vec![20.0; 56]; 100]);
assert_eq!(estimate_temporal_dominant_frequency_hz(&flat, 20.0), 0.0);
let mut corrupt = flat;
corrupt[50][4] = f64::NAN;
assert_eq!(estimate_temporal_dominant_frequency_hz(&corrupt, 20.0), 0.0);
}
#[test]
fn temporal_frequency_p95_stays_below_server_budget() {
let sample_rate_hz = 50.0;
let mut history = VecDeque::new();
for sample in 0..128 {
let t = sample as f64 / sample_rate_hz;
let value = 20.0 + 2.0 * (2.0 * std::f64::consts::PI * 1.2 * t).sin();
history.push_back(vec![value; 256]);
}
let mut timings = Vec::with_capacity(1_000);
for _ in 0..1_000 {
let start = std::time::Instant::now();
std::hint::black_box(estimate_temporal_dominant_frequency_hz(
std::hint::black_box(&history),
sample_rate_hz,
));
timings.push(start.elapsed());
}
timings.sort_unstable();
let p95 = timings[949];
eprintln!("temporal dominant frequency p95: {p95:?}");
assert!(p95 < std::time::Duration::from_millis(10), "p95 {p95:?}");
}
}
/// Person count is supporting evidence, never an independent occupancy claim.
/// Return zero and mark invalid for contradictory or out-of-range firmware.
fn sanitize_edge_person_count(presence: bool, raw: u8) -> (u8, bool) {
@@ -469,14 +604,7 @@ pub fn extract_features_from_frame(
0.0
};
let peak_idx = frame
.amplitudes
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
let dominant_freq_hz = peak_idx as f64 * 0.05;
let dominant_freq_hz = estimate_temporal_dominant_frequency_hz(frame_history, sample_rate_hz);
let threshold = mean_amp * 1.2;
let change_points = frame
@@ -485,7 +613,9 @@ pub fn extract_features_from_frame(
.filter(|w| (w[0] < threshold) != (w[1] < threshold))
.count();
let temporal_motion_score = if let Some(prev_frame) = frame_history.back() {
// The caller appends the current frame before extraction, so the true
// predecessor is the second item from the back.
let temporal_motion_score = if let Some(prev_frame) = frame_history.iter().rev().nth(1) {
let n_cmp = n_sub.min(prev_frame.len());
if n_cmp > 0 {
let diff_energy: f64 = (0..n_cmp)
@@ -636,13 +766,18 @@ pub fn adaptive_override(
.back()
.map(|v| v.as_slice())
.unwrap_or(&[]);
let dominant_frequency = adaptive_classifier::compatible_dominant_frequency(
model.version,
features.dominant_freq_hz,
amps,
);
let feat_arr = adaptive_classifier::features_from_runtime(
&serde_json::json!({
"variance": features.variance,
"motion_band_power": features.motion_band_power,
"breathing_band_power": features.breathing_band_power,
"spectral_power": features.spectral_power,
"dominant_freq_hz": features.dominant_freq_hz,
"dominant_freq_hz": dominant_frequency,
"change_points": features.change_points,
"mean_rssi": features.mean_rssi,
}),
@@ -40,6 +40,106 @@ const ENERGY_THRESH_3: f64 = 25.0;
/// reliable — genuine higher counts come from the multistatic fusion path.
const MAX_SINGLE_LINK_OCCUPANCY: usize = 3;
/// Low-authority comparison of the current source window with a restored
/// empty-room bootstrap image. This is background conformance only and cannot
/// authorize presence, person count, pose, identity, or vital signs.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BootstrapBackgroundMatch {
pub matches_empty: bool,
pub score: Option<f64>,
pub normalized_residual_z: Option<f64>,
pub maturity: f64,
pub reliable: bool,
pub residual_energy: f64,
pub residual_energy_threshold: f64,
pub window_size: usize,
pub reference_window_count: usize,
}
fn mean_frame(frames: &[Vec<f64>]) -> Option<Vec<f64>> {
let width = frames.first()?.len();
if width == 0 {
return None;
}
let mut mean = vec![0.0_f64; width];
let mut count = 0_usize;
for frame in frames {
if frame.len() != width {
continue;
}
for (sum, value) in mean.iter_mut().zip(frame.iter()) {
*sum += value;
}
count += 1;
}
if count == 0 {
return None;
}
for value in &mut mean {
*value /= count as f64;
}
Some(mean)
}
fn perturbation_occupancy(field: &FieldModel, frames: &[Vec<f64>]) -> Option<usize> {
let adaptive_threshold = field.empty_room_residual_energy_threshold();
let frame = match adaptive_threshold {
Some(_) => mean_frame(frames)?,
None => frames.first()?.clone(),
};
let perturbation = field.extract_perturbation(&[frame]).ok()?;
let count = match adaptive_threshold {
Some(threshold) => {
let empty_threshold = threshold.max(1.0);
if perturbation.total_energy > empty_threshold * 25.0 {
3
} else if perturbation.total_energy > empty_threshold * 12.0 {
2
} else if perturbation.total_energy > empty_threshold {
1
} else {
0
}
}
None => {
if perturbation.total_energy > ENERGY_THRESH_3 {
3
} else if perturbation.total_energy > ENERGY_THRESH_2 {
2
} else if perturbation.total_energy > 1.0 {
1
} else {
0
}
}
};
Some(count)
}
/// Provenance for a count produced by the calibrated field model. Callers
/// that attach calibration evidence must never substitute the score heuristic
/// when the calibrated path cannot evaluate the observation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CalibratedOccupancyMethod {
Eigenvalue,
PerturbationEnergy,
}
impl CalibratedOccupancyMethod {
pub const fn wire_name(self) -> &'static str {
match self {
Self::Eigenvalue => "field_model_eigenvalue_v1",
Self::PerturbationEnergy => "field_model_perturbation_energy_v1",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CalibratedOccupancy {
pub person_count: usize,
pub method: CalibratedOccupancyMethod,
}
/// Create a FieldModelConfig for single-link mode (one ESP32 node = one link).
/// This avoids the DimensionMismatch error when feeding single-frame observations.
pub fn single_link_config() -> FieldModelConfig {
@@ -49,6 +149,18 @@ pub fn single_link_config() -> FieldModelConfig {
}
}
/// Resolve the model status at the observation wall clock. `FieldModel::status`
/// records the state at collection or restore time and does not advance as a
/// long-running server crosses the stale and expiry boundaries.
pub fn calibration_status_at(field: &FieldModel, observed_at_us: u64) -> CalibrationStatus {
match field.status() {
CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting => field.status(),
CalibrationStatus::Fresh | CalibrationStatus::Stale | CalibrationStatus::Expired => {
field.check_freshness(observed_at_us)
}
}
}
/// Estimate occupancy using the FieldModel when calibrated, falling back
/// to the score-based heuristic otherwise.
///
@@ -58,16 +170,17 @@ pub fn single_link_config() -> FieldModelConfig {
pub fn occupancy_or_fallback(
field: &FieldModel,
frame_history: &VecDeque<Vec<f64>>,
observed_at_us: u64,
smoothed_score: f64,
prev_count: usize,
) -> usize {
match field.status() {
match calibration_status_at(field, observed_at_us) {
CalibrationStatus::Fresh | CalibrationStatus::Stale => {
let frames: Vec<Vec<f64>> = frame_history
.iter()
.rev()
.take(OCCUPANCY_WINDOW)
.cloned()
.map(|frame| CALIB_NORMALIZER.resample_to_canonical(frame))
.collect();
if frames.is_empty() {
@@ -87,51 +200,119 @@ pub fn occupancy_or_fallback(
// Fallback: perturbation energy thresholds.
// FieldModel expects [n_links][n_subcarriers] — we use n_links=1.
let observation = vec![frames[0].clone()];
match field.extract_perturbation(&observation) {
Ok(perturbation) => {
if perturbation.total_energy > ENERGY_THRESH_3 {
3
} else if perturbation.total_energy > ENERGY_THRESH_2 {
2
} else if perturbation.total_energy > 1.0 {
1
} else {
0
}
}
Err(_) => score_to_person_count(smoothed_score, prev_count),
}
perturbation_occupancy(field, &frames)
.unwrap_or_else(|| score_to_person_count(smoothed_score, prev_count))
}
_ => score_to_person_count(smoothed_score, prev_count),
}
}
/// Feed the latest frame to the FieldModel during calibration collection.
/// Return true only when a restored low-authority bootstrap model has a full
/// runtime window and that model resolves the window as empty. This helper is
/// deliberately one-way: a bootstrap prior may suppress an obvious background
/// false positive, but cannot authorize presence or vital signs.
pub fn bootstrap_empty_prior_applies(
field: &FieldModel,
frame_history: &VecDeque<Vec<f64>>,
observed_at_us: u64,
) -> bool {
bootstrap_background_match(field, frame_history, observed_at_us)
.is_some_and(|result| result.matches_empty)
}
/// Score the current canonical runtime window against the learned empty-room
/// residual references. Legacy models without scalar references preserve the
/// binary match but intentionally return no score.
pub fn bootstrap_background_match(
field: &FieldModel,
frame_history: &VecDeque<Vec<f64>>,
observed_at_us: u64,
) -> Option<BootstrapBackgroundMatch> {
if frame_history.len() < OCCUPANCY_WINDOW
|| field.check_freshness(observed_at_us) != CalibrationStatus::Fresh
{
return None;
}
let frames: Vec<Vec<f64>> = frame_history
.iter()
.rev()
.take(OCCUPANCY_WINDOW)
.map(|frame| CALIB_NORMALIZER.resample_to_canonical(frame))
.collect();
let result = field.empty_room_match(&frames)?;
Some(BootstrapBackgroundMatch {
matches_empty: result.matches_empty,
score: result.score,
normalized_residual_z: result.normalized_residual_z,
maturity: result.maturity,
reliable: result.reliable,
residual_energy: result.residual_energy,
residual_energy_threshold: result.residual_energy_threshold,
window_size: result.window_size,
reference_window_count: result.reference_window_count,
})
}
/// Produce a fail-closed occupancy result from a fresh calibrated model.
pub fn calibrated_occupancy(
field: &FieldModel,
frame_history: &VecDeque<Vec<f64>>,
observed_at_us: u64,
) -> Option<CalibratedOccupancy> {
if field.check_freshness(observed_at_us) != CalibrationStatus::Fresh {
return None;
}
let frames: Vec<Vec<f64>> = frame_history
.iter()
.rev()
.take(OCCUPANCY_WINDOW)
.map(|frame| CALIB_NORMALIZER.resample_to_canonical(frame))
.collect();
if frames.is_empty() {
return None;
}
if let Ok(person_count) = field.estimate_occupancy(&frames) {
return Some(CalibratedOccupancy {
person_count: person_count.min(MAX_SINGLE_LINK_OCCUPANCY),
method: CalibratedOccupancyMethod::Eigenvalue,
});
}
Some(CalibratedOccupancy {
person_count: perturbation_occupancy(field, &frames)?,
method: CalibratedOccupancyMethod::PerturbationEnergy,
})
}
/// Feed the current CSI frame to the FieldModel during calibration.
///
/// Acts while the model is `Uncalibrated` or `Collecting`. The first fed frame
/// flips a freshly-started (`Uncalibrated`) model to `Collecting` inside
/// `feed_calibration`; without accepting the `Uncalibrated` state here the two
/// gates deadlock and the frame count never leaves 0 (calibration/start yields
/// an `Uncalibrated` model that nothing would ever advance). Wraps the latest
/// frame as a single-link observation (n_links=1) and feeds it.
pub fn maybe_feed_calibration(field: &mut FieldModel, frame_history: &VecDeque<Vec<f64>>) {
/// an `Uncalibrated` model that nothing would ever advance). Edge-vitals
/// packets have no CSI observation and must never call this helper. The caller
/// owns the stateful sequence admission gate. Wraps the current frame as a
/// single-link observation (n_links=1) and feeds it.
pub fn maybe_feed_calibration(field: &mut FieldModel, amplitudes: &[f64]) -> bool {
if !matches!(
field.status(),
CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting
) {
return;
) || amplitudes.is_empty()
{
return false;
}
if let Some(latest) = frame_history.back() {
// Resample the raw amplitude vector onto the FieldModel's canonical
// 56-tone grid before feeding. Real HT40 nodes stream 128-wide frames;
// feeding those raw made every `feed_calibration` fail DimensionMismatch
// (swallowed at debug level), pinning frame_count at 0 even after the
// status-gate deadlock was fixed. Single-link observation: [1][56].
let canonical = CALIB_NORMALIZER.resample_to_canonical(latest);
let observations = vec![canonical];
if let Err(e) = field.feed_calibration(&observations) {
// Resample the raw amplitude vector onto the FieldModel's canonical
// 56-tone grid before feeding. Real HT40 nodes stream 128-wide frames;
// feeding those raw made every `feed_calibration` fail DimensionMismatch
// (swallowed at debug level), pinning frame_count at 0 even after the
// status-gate deadlock was fixed. Single-link observation: [1][56].
let canonical = CALIB_NORMALIZER.resample_to_canonical(amplitudes);
let observations = vec![canonical];
match field.feed_calibration(&observations) {
Ok(()) => true,
Err(e) => {
tracing::debug!("FieldModel calibration feed: {e}");
false
}
}
}
@@ -174,6 +355,33 @@ pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> {
mod tests {
use super::*;
fn fresh_test_model() -> FieldModel {
let config = FieldModelConfig {
n_links: 1,
n_subcarriers: 56,
n_modes: 3,
min_calibration_frames: 10,
min_calibration_duration_s: 0.0,
baseline_expiry_s: 2.0,
};
let mut field = FieldModel::new(config).expect("field model");
for index in 0..20 {
let phase = index as f64 * 0.1;
let observation = vec![(0..56)
.map(|subcarrier| {
1.0 + subcarrier as f64 * 0.01 + (phase + subcarrier as f64 * 0.03).sin() * 0.01
})
.collect()];
field
.feed_calibration(&observation)
.expect("calibration frame");
}
field
.finalize_calibration(1_000_000, 7)
.expect("finalize calibration");
field
}
#[test]
fn test_parse_node_positions() {
let positions = parse_node_positions("0,0,1.5;3,0,1.5;1.5,3,1.5");
@@ -215,10 +423,7 @@ mod tests {
// n_subcarriers defaults to 56; one single-link frame of that width.
let frame = vec![0.5_f64; 56];
let mut history: VecDeque<Vec<f64>> = VecDeque::new();
history.push_back(frame);
maybe_feed_calibration(&mut field, &history);
assert!(maybe_feed_calibration(&mut field, &frame));
assert_eq!(
field.status(),
@@ -231,8 +436,8 @@ mod tests {
"frame count must advance past 0"
);
// Subsequent frames keep accumulating while Collecting.
maybe_feed_calibration(&mut field, &history);
// A subsequent unique frame keeps accumulating while Collecting.
assert!(maybe_feed_calibration(&mut field, &frame));
assert_eq!(field.calibration_frame_count(), 2);
}
@@ -247,10 +452,7 @@ mod tests {
// 128-wide frame (HT40), NOT the model's 56 — would DimensionMismatch raw.
let wide = vec![0.5_f64; 128];
let mut history: VecDeque<Vec<f64>> = VecDeque::new();
history.push_back(wide);
maybe_feed_calibration(&mut field, &history);
assert!(maybe_feed_calibration(&mut field, &wide));
assert_eq!(
field.status(),
@@ -263,4 +465,129 @@ mod tests {
"wide frame must accumulate, not be silently dropped"
);
}
#[test]
fn maybe_feed_calibration_rejects_an_empty_observation() {
let mut field = FieldModel::new(single_link_config()).expect("field model");
assert!(!maybe_feed_calibration(&mut field, &[]));
assert_eq!(field.calibration_frame_count(), 0);
}
#[test]
fn calibrated_occupancy_is_fresh_model_only_and_normalizes_runtime_frames() {
let field = fresh_test_model();
let mut history = VecDeque::new();
history.push_back(vec![1.02; 128]);
let result =
calibrated_occupancy(&field, &history, 1_500_000).expect("fresh calibrated inference");
assert!(result.person_count <= MAX_SINGLE_LINK_OCCUPANCY);
assert!(matches!(
result.method,
CalibratedOccupancyMethod::Eigenvalue | CalibratedOccupancyMethod::PerturbationEnergy
));
assert_eq!(calibrated_occupancy(&field, &history, 4_000_000), None);
assert_eq!(
calibrated_occupancy(&field, &VecDeque::new(), 1_500_000),
None
);
}
#[test]
fn occupancy_falls_back_after_expiry_without_a_process_restart() {
let field = fresh_test_model();
let baseline = field.modes().unwrap().baseline[0].clone();
let history: VecDeque<Vec<f64>> =
(0..OCCUPANCY_WINDOW).map(|_| baseline.clone()).collect();
assert_eq!(field.status(), CalibrationStatus::Fresh);
assert_eq!(
calibration_status_at(&field, 1_500_000),
CalibrationStatus::Fresh
);
assert_eq!(
occupancy_or_fallback(&field, &history, 1_500_000, 0.0, 0),
0,
"a fresh field model may resolve its learned baseline as empty"
);
assert_eq!(field.status(), CalibrationStatus::Fresh);
assert_eq!(
calibration_status_at(&field, 4_000_000),
CalibrationStatus::Expired
);
assert_eq!(
occupancy_or_fallback(&field, &history, 4_000_000, 0.0, 0),
score_to_person_count(0.0, 0),
"an expired in-memory model must fall back even though its cached status is fresh"
);
}
#[test]
fn adaptive_runtime_reference_scores_empty_and_rejects_shift() {
let config = FieldModelConfig {
n_links: 1,
n_subcarriers: 56,
n_modes: 3,
min_calibration_frames: 500,
min_calibration_duration_s: 0.0,
baseline_expiry_s: 60.0,
};
let mut field = FieldModel::new(config).expect("field model");
let mut calibration = Vec::new();
for frame_index in 0..600 {
let time = frame_index as f64 * 0.071;
let frame: Vec<f64> = (0..56)
.map(|subcarrier| {
let carrier = subcarrier as f64;
18.0 + carrier * 0.08
+ (time + carrier * 0.13).sin() * 0.8
+ (time * 0.37 + carrier * 0.031).cos() * 0.35
})
.collect();
field.feed_calibration(&[frame.clone()]).unwrap();
calibration.push(frame);
}
field.finalize_calibration(1_000_000, 7).unwrap();
let empty_history: VecDeque<Vec<f64>> = calibration[550..600].iter().cloned().collect();
let empty = calibrated_occupancy(&field, &empty_history, 1_500_000)
.expect("adaptive perturbation result");
assert_eq!(empty.person_count, 0);
let background = bootstrap_background_match(&field, &empty_history, 1_500_000)
.expect("background score");
assert!(background.matches_empty);
assert_eq!(background.reference_window_count, 12);
assert!(background.reliable);
assert_eq!(background.maturity, 0.6);
assert!(background.normalized_residual_z.is_some());
assert!(background
.score
.is_some_and(|score| (0.5..=1.0).contains(&score)));
let short_history: VecDeque<Vec<f64>> = empty_history.iter().take(49).cloned().collect();
assert!(!bootstrap_empty_prior_applies(
&field,
&short_history,
1_500_000
));
let shifted_history: VecDeque<Vec<f64>> = calibration[550..600]
.iter()
.map(|frame| {
frame
.iter()
.enumerate()
.map(|(index, value)| value + if index % 3 == 0 { 8.0 } else { -5.0 })
.collect()
})
.collect();
let shifted = calibrated_occupancy(&field, &shifted_history, 1_500_000)
.expect("shifted perturbation result");
assert!(shifted.person_count >= 1);
let shifted_background = bootstrap_background_match(&field, &shifted_history, 1_500_000)
.expect("shifted background result");
assert!(!shifted_background.matches_empty);
assert_eq!(shifted_background.score, Some(0.0));
assert!(shifted_background.reliable);
}
}
@@ -9,6 +9,7 @@
//! - Real-time CSI introspection / low-latency tap (`introspection`, ADR-099)
pub mod bearer_auth;
pub mod bootstrap_baseline;
pub mod browser_session;
pub mod ws_ticket;
pub mod cli;
File diff suppressed because it is too large. Load diff
@@ -544,6 +544,10 @@ impl AppStateInner {
pub fn person_count(&self) -> usize {
use crate::csi::score_to_person_count;
use crate::field_bridge;
let observed_at_us = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_micros() as u64)
.unwrap_or(0);
match self.field_model.as_ref() {
Some(fm) => {
let history = if !self.frame_history.is_empty() {
@@ -559,6 +563,7 @@ impl AppStateInner {
field_bridge::occupancy_or_fallback(
fm,
history,
observed_at_us,
self.smoothed_person_score,
self.prev_person_count,
)
@@ -346,6 +346,31 @@ impl VitalSignDetector {
self.frame_count = 0;
}
/// Retune temporal windows to an observed CSI clock. Changes smaller than
/// twenty percent are ignored to avoid resetting on normal arrival jitter.
/// A real clock change clears history so samples from different clocks are
/// never interpreted in one spectrum.
pub fn reconfigure_sample_rate(&mut self, sample_rate: f64) -> bool {
if !sample_rate.is_finite() || sample_rate < 1.0 {
return false;
}
let relative_change = (sample_rate - self.sample_rate).abs() / self.sample_rate.max(1.0);
if relative_change < 0.20 {
return false;
}
self.sample_rate = sample_rate;
self.breathing_capacity =
((sample_rate * self.breathing_window_secs) as usize).max(1);
self.heartbeat_capacity =
((sample_rate * self.heartbeat_window_secs) as usize).max(1);
self.reset();
true
}
pub fn sample_rate_hz(&self) -> f64 {
self.sample_rate
}
/// Current buffer fill levels for diagnostics.
/// Returns (breathing_len, breathing_capacity, heartbeat_len, heartbeat_capacity).
pub fn buffer_status(&self) -> (usize, usize, usize, usize) {
@@ -842,6 +867,22 @@ mod tests {
assert_eq!(hb_len, 0);
}
#[test]
fn measured_clock_reconfigures_once_and_clears_mixed_history() {
let mut detector = VitalSignDetector::new(20.0);
let amp = vec![10.0; 56];
let phase = vec![0.0; 56];
for _ in 0..32 {
detector.process_frame(&amp, &phase);
}
assert!(!detector.reconfigure_sample_rate(18.0));
assert_eq!(detector.sample_rate_hz(), 20.0);
assert!(detector.reconfigure_sample_rate(12.0));
assert_eq!(detector.sample_rate_hz(), 12.0);
assert_eq!(detector.buffer_status(), (0, 360, 0, 180));
assert!(!detector.reconfigure_sample_rate(f64::NAN));
}
#[test]
fn test_vital_signs_default() {
let vs = VitalSigns::default();
@@ -287,6 +287,7 @@ mod eig {
n_links,
n_modes: 3,
min_calibration_frames: 20,
min_calibration_duration_s: 0.0,
baseline_expiry_s: 86_400.0,
};
let mut model = FieldModel::new(config).unwrap();
File diff suppressed because it is too large. Load diff