From 00c2ee3ccf6e60888ab50f33a73504fb98dfa419 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 15:52:59 +0000 Subject: [PATCH 01/13] docs: tuning algorithms audit + phased improvement roadmap Deep audit of src/main/analysis DSP and tuning logic, KB/apply/verify flow review, and 2026 market benchmark (PIDtoolbox PRO, Plasmatree, Blackbox Explorer 2025.12, FPVtune). Key findings: amplitude-domain spectrum averaging (uncalibrated PSD), per-step response measurement without deconvolution, dead coherence plumbing, hardcoded frame-resonance band, missing BF 4.5/4.6 version-conditional recommendations, unverified CLI-applied settings. Roadmap: Phase 0 golden-output harness, Phase 1 DSP correctness (single dB-scale recalibration event), Phase 2 SOTA parity (single time-domain recalibration event), Phase 3 differentiators (what-if simulation, explainable recommendations, filter placement optimizer). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJhhk2frYDDnpNbSHRxGdv --- docs/README.md | 1 + docs/TUNING_ALGORITHMS_AUDIT.md | 125 ++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 docs/TUNING_ALGORITHMS_AUDIT.md diff --git a/docs/README.md b/docs/README.md index 36a0729..9af7aea 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ Overview of all design documents in this directory. Completed documents are arch | Document | Status | Description | |----------|--------|-------------| +| [TUNING_ALGORITHMS_AUDIT](./TUNING_ALGORITHMS_AUDIT.md) | **Proposed** | Deep DSP/tuning-algorithm audit + market benchmark (PIDtoolbox, Plasmatree, Blackbox Explorer, FPVtune) → phased roadmap: calibrated PSD, deconvolved step response, coherence, BF 4.5/4.6 coverage, differentiators | | [TUNING_MODE_COMPARISON](./TUNING_MODE_COMPARISON.md) | **Active** | Filter+PID Tune vs Flash Tune comparison — offline cross-validation findings, real-world validation plan | | [TUNING_SESSION_EVALUATION](./TUNING_SESSION_EVALUATION.md) | **Active** | Tuning session evaluation strategy — size-aware noise thresholds, per-mode success criteria, convergence detection | | [BLACKBOX_DOWNLOAD_OPTIMIZATION](./BLACKBOX_DOWNLOAD_OPTIMIZATION.md) | **Proposed** | MSC mode for flash storage (10–50× speedup) with MSP pipelining fallback (1.5–2×). Larger chunks deprioritized (already tested, poor results) | diff --git a/docs/TUNING_ALGORITHMS_AUDIT.md b/docs/TUNING_ALGORITHMS_AUDIT.md new file mode 100644 index 0000000..c08e5b8 --- /dev/null +++ b/docs/TUNING_ALGORITHMS_AUDIT.md @@ -0,0 +1,125 @@ +# Tuning Algorithms Audit & Improvement Roadmap + +> **Status**: Proposed + +Deep audit of FPVPIDlab's tuning algorithms (July 2026): DSP correctness review of `src/main/analysis/`, knowledge-base/apply/verification flow review, and a market benchmark against state-of-the-art tools (PIDtoolbox PRO v0.74, Plasmatree PID-Analyzer, Betaflight Blackbox Explorer 2025.12, FPVtune). Produces a phased roadmap toward being the best FPV tuning tool on the market. + +**Overall assessment**: the architecture is solid and above average — convergent absolute-target filter recommendations, notch-aware resonance handling, quad-size bounds, second-flight verification with similarity matching, convergence detection, data quality scoring with confidence downgrades, and a knowledge base enforced as source of truth. The code faithfully implements the documented rules. However, the DSP core has correctness gaps and methodology shortfalls that SOTA tools handle better — chiefly step response without deconvolution/stacking, uncalibrated "PSD", dead coherence plumbing, and missing Betaflight 4.5/4.6 coverage. + +--- + +## Part 1 — Audit Findings + +### A. DSP correctness (`src/main/analysis/`) + +| # | Finding | Where | Impact | +|---|---------|-------|--------| +| A1 | **Amplitude-domain spectrum averaging** — `computePowerSpectrum` and `averageSpectra` average `10^(dB/20)` (amplitude), not `10^(dB/10)` (power); no window-energy normalization, no detrending. The "PSD" is an uncalibrated relative measure; all dB thresholds in `constants.ts` are calibrated to this specific pipeline, not to physical PSD. | `FFTCompute.ts:129`, `NoiseAnalyzer.ts:238` | All absolute dB comparisons (size-aware noise levels, LPF2 disable thresholds, dynamic-LPF triggers) are pipeline-relative; cross-tool comparison impossible | +| A2 | **Naive peak detection** — local-max vs immediate neighbors only; strict `>` misses plateau peaks entirely; no minimum peak spacing (broad humps register as many peaks); no parabolic interpolation (frequencies quantized to ~2 Hz bins); local floor fixed ±50 bins. | `NoiseAnalyzer.ts:84-116` | Missed/duplicated peaks feed resonance and notch rules | +| A3 | **Frame-resonance band hardcoded 80–200 Hz** regardless of quad size. A 2.5"/3" frame resonating at 250–350 Hz is classified `electrical`/`unknown`, never `frame_resonance`. | `constants.ts:102-103`, `NoiseAnalyzer.classifyPeak` | Wrong classification → wrong filter rules for small quads | +| A4 | **Motor-harmonic classification ignores throttle/RPM** — equal-spacing heuristic on a whole-flight averaged spectrum, where harmonics smear across RPM. `ThrottleSpectrogramAnalyzer` computes the throttle-resolved view but never cross-informs `classifyPeak`. | `NoiseAnalyzer.ts:155-186` | Frame resonances near harmonic multiples misclassified as motor noise and vice versa | +| A5 | **Non-contiguous samples FFT'd as contiguous** — throttle-binned gathers concatenate samples across discontinuous time regions before FFT, introducing edge artifacts inside windows. | `ThrottleSpectrogramAnalyzer.ts:69-75`, `ThrottleTFAnalyzer` | Spectral leakage in throttle-band views used by dynamic-LPF logic | +| A6 | **Step response: direct per-step measurement, no deconvolution/stacking** — each step is measured in the time domain and arithmetic-mean aggregated. Plasmatree/PIDtoolbox use Wiener deconvolution — which the app already has in `TransferFunctionEstimator.ts` (Flash Tune path) but does not use for PID Tune. No input-magnitude split (<500 vs >500 deg/s, the FF/D-setpoint transition PIDtoolbox respects). Steady-state = mean of last 20% of window is unreliable when the pilot doesn't hold; SSE denominator inconsistent with the other metrics. | `StepMetrics.ts:33-266`, `StepDetector.ts` | Noisier metrics than SOTA; P/D rules fire on noisy means | +| A7 | **Coherence is dead** — declared in `DataQualityScorer.WienerQualityInput` but never computed/passed by `PIDAnalyzer.extractViaWiener`; axis-coverage sub-score is a constant 50 and low-coherence warnings never fire. | `DataQualityScorer.ts:265-266`, `PIDAnalyzer.ts:224` | Flash Tune quality gating partially inert | +| A8 | **TF margins from closed-loop response with silent caps** — gain/phase margins computed on the closed-loop (FF-contaminated) Bode; when no crossing is found, capped values 60 dB/90° silently feed "stable" downstream. `ThrottleTFAnalyzer` analyzes roll only. | `TransferFunctionEstimator.ts:396-433`, `ThrottleTFAnalyzer.ts:137-144` | Overconfident TF-derived recommendations; pitch TPA asymmetry invisible | +| A9 | **Size-independent magic numbers** — `computeNoiseBasedTarget` anchors -10/-70 dB drive the main filter cutoff for all sizes (noise *classification* is size-aware, the target math is not); LPF2 enable values 250/150 Hz underived; group-delay reference fixed 80 Hz; prop-wash band 20–90 Hz and D-term band 20–150 Hz size-agnostic. | `FilterRecommender.ts:112-123, 851, 864` | Suboptimal cutoffs at the size extremes (1"–3", 7") | +| A10 | **Prop-wash severity baseline biased** — event energy is divided by whole-flight 20–90 Hz energy, which itself includes prop-wash and maneuver energy → severity compressed for aggressive flights. D-term "effectiveness" (D-energy/error-energy ratio) has an arguable interpretation direction: high ratio may mean D is amplifying noise, yet it gates D increases as "headroom". | `PropWashDetector.ts:219-222`, `DTermAnalyzer.ts` | Under-detected prop wash on aggressive logs; D gating built on a shaky metric | +| A11 | **Assorted** — `normalizeThrottle` triplicated in 3 files with heuristic format detection; yaw excluded from noise/steadiness/damping analysis; `FeedforwardAnalyzer` hardcodes maxStickRate 670 deg/s instead of reading the rate profile from the BBL header. | `SegmentSelector.ts:119-134`, `FeedforwardAnalyzer.ts:78` | Drift risk; yaw issues under-detected; FF small/large-step split wrong for non-default rates | + +### B. Betaflight 4.5/4.6 coverage + +- **No version-conditional logic in recommenders.** The only version branch is `headerValidation.ts:25-91` (DEBUG_GYRO_SCALED removal in ≥4.6). Unhandled: `d_min`→`d`/`d_max` rename (4.6, advisory-only via `applyDMinAdvisory`), anti-gravity scale change (4.3 vs 4.5), dimmable RPM weights (4.5+), low-throttle TPA — `tpa_low_*` exists in `BF_SETTING_RANGES` (`tuningHandlers.ts:183`) but is **never emitted** by any recommender. +- **Untouched settings a best-in-class tool should reason about**: `rpm_filter_harmonics`/`rpm_filter_weights`/`rpm_filter_min_hz`/`rpm_filter_fade_range_hz` (only `rpm_filter_q` is recommended), `gyro_lpf1_dyn_expo` (only the D-term counterpart is), `feedforward_transition` and per-axis FF weights, `anti_gravity_cutoff_hz`/`anti_gravity_p_gain`, simplified tuning sliders (`SliderMapper.ts` is display-only; recommendations are never expressed as slider moves), `iterm_relax_type`, per-axis `d_min` values and `d_min_advance`. +- **Applied-but-never-verified settings** — `FF_CLI_ONLY` in `verifyAppliedConfig.ts:99-114` skips read-back for `tpa_*`, `anti_gravity_gain`, `thrust_linear`, `dyn_idle_min_rpm`, `pidsum_limit*`, `vbat_sag_compensation`, `simplified_dmax_gain`, `dterm_lpf1_dyn_expo` — even though several have MSP_PID_ADVANCED offsets in `mspLayouts.ts` that are simply not parsed by `getFeedforwardConfiguration()`. +- **`simplified_dmax_gain=0` is auto-applied for ≤5"** (`PIDRecommender.ts:~1201`, no `informational` flag) — it silently turns off a simplified-tuning slider, unlike the ≥6" branch which is advisory. + +### C. Market benchmark (2026) + +| Tool | Method | Strengths | Weaknesses | +|------|--------|-----------|------------| +| PIDtoolbox PRO v0.74 | Deconvolved step response split by input magnitude (<500/>500 deg/s); throttle×freq heatmaps with RPM/dyn-notch filter lines overlaid; filter delay estimation | Reference analysis suite; BF 4.6-ready | MATLAB; paywalled (Patreon) since May 2024; analysis only, no recommendations | +| Plasmatree PID-Analyzer | Wiener deconvolution step response (2 s Hanning windows) | Transparent, free, de-facto standard method | Unmaintained; no modern BF awareness; no spectral heatmaps | +| Blackbox Explorer 2025.12 | Interactive viewer + PSD curves, PSD export/import comparison, true dynamic filter curves (throttle+expo) drawn on spectrum | Official, free, PWA | Viewer only — no recommendations, no workflow | +| FPVtune | 28-feature FFNN (step-response + FFT + prop-wash features → 18 outputs), ONNX in-browser | Fast, automated, covers PID+filters+FF | Black box, no explainability; ~$9.90/analysis; unverifiable claims | + +**Missing table stakes in FPVPIDlab**: deconvolved step response with magnitude split (A6); calibrated Welch PSD in dB (A1); filter response curves overlaid on spectrum/spectrogram; before/after PSD + step overlay comparison (history data already stored, no UI); recommendations expressed as simplified-slider moves. + +**Differentiators nobody on the market has** (FPVPIDlab is uniquely positioned — it owns the full loop: analyze → recommend → apply → verify): +1. Setpoint↔gyro **coherence plots** (trustworthy-band shading). +2. **System identification + what-if simulation** — predict the step response of proposed gains *before* the pilot re-flies. +3. **Filter placement optimizer** — fit LPF/notch/RPM-harmonic set to measured peaks, minimizing group delay subject to attenuation targets. +4. **Mechanical fault detection** (bent prop, bearing wear) from per-motor order analysis. +5. **Explainable recommendations** — every rule annotated on the plot that triggered it (direct counter to FPVtune's black box). + +--- + +## Part 2 — Improvement Roadmap + +### Governing principle: recalibrate each measurement scale exactly once + +Two orthogonal threshold families exist: + +- **dB-scale thresholds** (`NOISE_LEVEL_BY_SIZE`, noise-target anchors, LPF2 disable thresholds, dynamic-LPF enable/disable deltas, peak prominence, prop-wash severity) — all shift when FFT averaging moves from amplitude to power domain. +- **Time-domain thresholds** (rise time, overshoot, settling, damping ratio) — shift when step response moves from per-step measurement to deconvolved/stacked response. + +Therefore: **all dB-domain fixes land behind one recalibration event (P1.1); the step-response method change is the single time-domain recalibration event (P2.1).** Everything consuming a scale lands after its recalibration. Nothing gets recalibrated twice. Both recalibration PRs must update `docs/PID_TUNING_KNOWLEDGE.md`, `TESTING.md`, and pass the `/tuning-advisor` audit in the same PR. + +### Phase 0 — Regression safety net (prerequisite, 1 PR) + +| ID | Item | Effort | Risk | +|----|------|--------|------| +| P0.1 | **Golden-output harness** — test running the full FilterAnalyzer + PIDAnalyzer + TransferFunctionEstimator pipelines over demo-generator BBLs and real-log fixtures; snapshots recommendations (setting/value/ruleId/confidence), noise floors, peak lists, and step metrics into JSON fixtures. Every subsequent PR diffs against these; fixtures are regenerated only in the two recalibration PRs. New `src/main/analysis/goldenOutputs.test.ts`. Extend the demo generator with known-amplitude injected sines so absolute calibration is testable. | S | Low | + +### Phase 1 — DSP correctness + quick wins (one PR per item) + +| ID | Item | Fixes | Effort | Risk | Depends on | +|----|------|-------|--------|------|-----------| +| P1.1 | **Calibrated Welch PSD** — detrend segments, average in power domain, normalize by window energy and sample rate → true one-sided PSD in `FFTCompute.ts`. Recalibrate every dB threshold in the same PR (`constants.ts`, `FilterRecommender`, `MechanicalHealthChecker`, `PropWashDetector`, `DTermAnalyzer`, `DynamicLowpassRecommender`; relative dB deltas roughly double in power domain, absolute floors re-anchored via golden logs). Validate: injected sine of known amplitude matches theoretical PSD; golden diff shows unchanged recommendation *directions*. | A1 | M | **High** | P0.1 | +| P1.2 | **Robust peak detection** — prominence-based with plateau handling (centroid of flat tops), ~15–20 Hz minimum spacing, parabolic interpolation for sub-bin frequency, median-band local floor. Validate with synthetic spectra (close peaks, plateaus, between-bin peaks). | A2 | M | Medium | P1.1 | +| P1.3 | **Size-aware frame-resonance bands** — `Record` (e.g. 7": 60–150, 5": 80–200, 3": 120–280, 2.5"/1": 150–350 Hz) threaded into `classifyPeak`. | A3 | S | Low | P1.2 | +| P1.4 | **Coherence** — compute γ²(f) = |S_xy|²/(S_xx·S_yy) in `TransferFunctionEstimator` (cross/auto spectra already exist), pass per-axis mean from `PIDAnalyzer.extractViaWiener` into `DataQualityScorer` (dormant tests come alive); gate TF-derived rules (TF-1..TF-4) on coherence ≥ ~0.5 in-band. | A7, partially A8 | M | Low | P0.1 | +| P1.5 | **Quick-win batch** — (a) dedupe `normalizeThrottle` into `src/shared/utils/`; (b) derive maxStickRate from the BBL rate profile, fallback 670; (c) mark ≤5" `simplified_dmax_gain=0` recommendation `informational: true`; (d) TF margins return `crossingFound: false` instead of silent 60/90 caps, consumers downgrade confidence. | A11, B, A8 | S | Low | P0.1 | +| P1.6 | **Contiguity-safe throttle-binned FFT** — collect whole FFT windows lying entirely within contiguous runs of a throttle band; average per-window PSDs; bands with too few windows report insufficient data. | A5 | M | Medium | P1.1 | +| P1.7 | **Prop-wash baseline fix** — compute the 20–90 Hz baseline from clean (hover/cruise) segments exposed by `SegmentSelector` instead of the whole flight; verify severity-tier ratios against golden logs. | A10 | M | Medium | P1.1 | +| P1.8 | **Yaw coverage** — include yaw in noise and steadiness analysis with yaw-specific expectations (no D rules; damping-ratio validation stays roll/pitch-only by design, documented in KB). | A11 | M | Medium | P1.1 | +| P1.9 | **Verify-applied coverage** — parse the MSP_PID_ADVANCED offsets already present in `mspLayouts.ts` (tpa, anti-gravity, thrust_linear, dyn_idle, pidsum, vbat_sag, simplified_dmax_gain, dterm dyn expo) and remove them from `FF_CLI_ONLY` so applied values are actually verified. | B | M | Low | — | + +**Phase 1 exit criteria**: golden outputs stable across reruns; calibration unit tests green; `/tuning-advisor` audit passed; real-log recommendation directions unchanged vs pre-Phase-1. + +### Phase 2 — SOTA parity + +| ID | Item | Effort | Risk | Depends on | +|----|------|--------|------|-----------| +| P2.1 | **Deconvolved step response for PID Tune** — make the Wiener/stacked step response (code already in the Flash Tune path) the primary source of rise/overshoot/settling, **split by input magnitude <500 / >500 deg/s** (à la PIDtoolbox); keep the per-step path as cross-check and latency source, disagreement lowers confidence. Recalibrate all time-domain thresholds in the same PR. Validate against the demo generator's known second-order plant (recovered ζ and rise time must match analytic values). | L | **High** | P1.4 | +| P2.2 | **RPM/throttle-aware harmonic classification** — regress each peak's per-throttle-band frequency against throttle: tracks-throttle → motor harmonic (order from ratio to fundamental track); stationary → frame resonance/electrical. Equal-spacing heuristic kept as fallback. | M | Medium | P1.2, P1.6 | +| P2.3 | **Filter response curves overlaid on PSD/spectrogram** — magnitude-response models (PT1, biquad, notch; dynamic LPF evaluated at actual throttle incl. expo) rendered over the noise spectrum and throttle×freq spectrogram. Parity with Blackbox Explorer 2025.12. | M | Low | P1.1 | +| P2.4 | **Before/after comparison view** — overlay previous-session compact PSD (128-bin) and step metrics from `TuningHistoryManager` against current analysis with delta annotations. Storage already exists; renderer-only work. | M | Low | — | +| P2.5 | **BF version-capabilities layer** (2–3 PRs) — `bfVersionCapabilities.ts` (version → setting names/availability/defaults); d_min→d_max rename mapping across recommend/apply/verify; emit `tpa_low_*`, anti-gravity cutoff/p_gain, dimmable RPM weights; bidirectional `SliderMapper` so recommendations can be expressed as slider moves when simplified tuning is on. | L | Medium | — | +| P2.6 | **RPM filter tuning rules** — recommend `rpm_filter_harmonics`, `min_hz`, `fade_range`, per-harmonic weights from measured harmonic tracks and dyn_idle, latency-aware. | M | Medium | P2.2 | +| P2.7 | **Latency budget replaces LPF2 magic numbers** — attenuation-vs-latency decision (required attenuation at measured peak vs group-delay cost) against a per-size latency budget, surfaced as "filter latency: X ms (budget Y)". | M | Medium | P2.3 | +| P2.8 | **ThrottleTF pitch axis + TPA emission** — extend roll-only TPA diagnostics to pitch; emit tpa_rate/breakpoint/tpa_low recommendations from per-band gain trends. | S/M | Low | P2.5 | + +**Phase 2 exit criteria**: feature-parity checklist vs PIDtoolbox PRO / Blackbox Explorer passes (deconvolved step split by magnitude, filter curves, before/after, version-aware recommendations); full `/e2e-tuning-test` pass. + +### Phase 3 — Differentiators + +| ID | Item | Effort | Depends on | +|----|------|--------|-----------| +| P3.1 | **Coherence plots + explainable recommendations** — per-axis coherence chart with trustworthy-band shading; structured `evidence` field on `Recommendation` (band, measured value, threshold) rendered as annotated plot regions ("this peak fired F-RES-GYRO"). Cheap, high differentiation; can land during Phase 2. | M | P1.4 | +| P3.2 | **System identification + what-if simulation** — fit low-order model (2nd order + delay) to coherence-weighted H(f); divide out known PID/filter contribution to estimate the plant; re-close the loop with proposed gains → predicted step response and margins shown next to measured, before apply. Demo generator is a known plant, so prediction accuracy is unit-testable end-to-end. Always labeled as prediction, gated on coherence and fit quality. | L | P2.1 | +| P3.3 | **Filter placement optimizer** — discrete search over (LPF cutoffs, notch count/Q, RPM harmonic set) minimizing total group delay subject to attenuation ≥ target at every measured peak; emits standard `Recommendation` objects so apply/verify is unchanged. | L | P2.3, P2.6, P2.7 | +| P3.4 | **Mechanical fault signatures** — per-motor order analysis (eRPM/motor outputs): bent prop = strong 1×/rev on one motor; bearing wear = broadband + sub-harmonic; ship as experimental telemetry-collected flags, promote thresholds via `/telemetry-evaluator`. | M/L | P2.2 | +| P3.5 | **Longitudinal/crowd benchmarking** — opt-in fleet percentiles per quad size (telemetry pipeline exists). Deliberately last: shipping before P1.1/P2.1 would poison the dataset with pre-recalibration metric values. | L | Phases 1–2 | + +### Sequencing + +``` +P0.1 → P1.1 → {P1.2 → P1.3, P1.6, P1.7}; P1.4, P1.5, P1.8, P1.9 in parallel after P0.1 +P1.4 → P2.1 → P3.2 +P1.2 + P1.6 → P2.2 → {P2.6, P3.4} +P2.3 → {P2.7, P3.3}; P2.4, P2.5, P2.8 independent within Phase 2 +P1.4 → P3.1 (anytime, even during Phase 2) +P3.5 last +``` + +Recalibration events: exactly two — **P1.1 (dB scale)** and **P2.1 (time-domain scale)**. Golden-output fixtures (P0.1) are regenerated only in those two PRs; every other PR must not change them without explicit justification. From 46b2cb903d2c43517460824e53098d46922be5bc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:35:37 +0000 Subject: [PATCH 02/13] test: add golden-output regression harness for analysis pipelines (P0.1) Runs FilterAnalyzer/PIDAnalyzer/TransferFunction pipelines over seeded demo BBLs and a real VX3.5 BBL fixture, snapshotting stable summaries (recommendations, noise floors, peaks, step/TF metrics) into JSON fixtures. Any behavioral change in analysis now shows up as a golden diff. Regenerate with UPDATE_GOLDEN=1; only the two planned recalibration PRs may regenerate wholesale. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJhhk2frYDDnpNbSHRxGdv --- .../golden/demo-filter-cycle0.json | 116 ++++++ .../golden/demo-filter-cycle2.json | 121 +++++++ .../golden/demo-flash-cycle0.json | 147 ++++++++ .../__fixtures__/golden/demo-pid-cycle0.json | 111 ++++++ .../__fixtures__/golden/real-vx35-filter.json | 48 +++ .../__fixtures__/golden/real-vx35-pid.json | 131 +++++++ .../__fixtures__/golden/real-vx35-tf.json | 145 ++++++++ src/main/analysis/goldenOutputs.test.ts | 336 ++++++++++++++++++ 8 files changed, 1155 insertions(+) create mode 100644 src/main/analysis/__fixtures__/golden/demo-filter-cycle0.json create mode 100644 src/main/analysis/__fixtures__/golden/demo-filter-cycle2.json create mode 100644 src/main/analysis/__fixtures__/golden/demo-flash-cycle0.json create mode 100644 src/main/analysis/__fixtures__/golden/demo-pid-cycle0.json create mode 100644 src/main/analysis/__fixtures__/golden/real-vx35-filter.json create mode 100644 src/main/analysis/__fixtures__/golden/real-vx35-pid.json create mode 100644 src/main/analysis/__fixtures__/golden/real-vx35-tf.json create mode 100644 src/main/analysis/goldenOutputs.test.ts diff --git a/src/main/analysis/__fixtures__/golden/demo-filter-cycle0.json b/src/main/analysis/__fixtures__/golden/demo-filter-cycle0.json new file mode 100644 index 0000000..c3659c8 --- /dev/null +++ b/src/main/analysis/__fixtures__/golden/demo-filter-cycle0.json @@ -0,0 +1,116 @@ +{ + "overallLevel": "high", + "roll": { + "noiseFloorDb": -18.8, + "peaks": [ + { + "frequency": 160, + "amplitude": 37.6, + "type": "frame_resonance" + }, + { + "frequency": 320, + "amplitude": 29.6, + "type": "motor_harmonic" + }, + { + "frequency": 600, + "amplitude": 23.2, + "type": "motor_harmonic" + }, + { + "frequency": 45, + "amplitude": 15.6, + "type": "motor_harmonic" + }, + { + "frequency": 27, + "amplitude": 7.7, + "type": "unknown" + } + ] + }, + "pitch": { + "noiseFloorDb": -18.8, + "peaks": [ + { + "frequency": 160, + "amplitude": 37.8, + "type": "frame_resonance" + }, + { + "frequency": 320, + "amplitude": 29.7, + "type": "motor_harmonic" + }, + { + "frequency": 600, + "amplitude": 23.4, + "type": "motor_harmonic" + }, + { + "frequency": 45, + "amplitude": 13.7, + "type": "motor_harmonic" + }, + { + "frequency": 27, + "amplitude": 7.7, + "type": "unknown" + } + ] + }, + "yaw": { + "noiseFloorDb": -18.8, + "peaks": [ + { + "frequency": 160, + "amplitude": 37.8, + "type": "frame_resonance" + }, + { + "frequency": 320, + "amplitude": 29.3, + "type": "unknown" + }, + { + "frequency": 600, + "amplitude": 23, + "type": "electrical" + } + ] + }, + "segmentsUsed": 2, + "dataQuality": { + "tier": "excellent", + "overall": 93 + }, + "groupDelay": { + "gyroTotalMs": 1.42, + "dtermTotalMs": 1.65 + }, + "warningCodes": [], + "recommendations": [ + { + "setting": "dterm_lpf1_static_hz", + "currentValue": 150, + "recommendedValue": 70, + "ruleId": "F-RES-DTERM", + "confidence": "high" + }, + { + "setting": "dyn_notch_min_hz", + "currentValue": 100, + "recommendedValue": 50, + "ruleId": "F-DN-MIN", + "confidence": "medium" + }, + { + "setting": "gyro_lpf1_static_hz", + "currentValue": 250, + "recommendedValue": 75, + "ruleId": "F-RES-GYRO", + "confidence": "high" + } + ] +} diff --git a/src/main/analysis/__fixtures__/golden/demo-filter-cycle2.json b/src/main/analysis/__fixtures__/golden/demo-filter-cycle2.json new file mode 100644 index 0000000..835fee2 --- /dev/null +++ b/src/main/analysis/__fixtures__/golden/demo-filter-cycle2.json @@ -0,0 +1,121 @@ +{ + "overallLevel": "medium", + "roll": { + "noiseFloorDb": -39.7, + "peaks": [ + { + "frequency": 160, + "amplitude": 37.4, + "type": "frame_resonance" + }, + { + "frequency": 320, + "amplitude": 29.5, + "type": "motor_harmonic" + }, + { + "frequency": 600, + "amplitude": 23, + "type": "motor_harmonic" + }, + { + "frequency": 45, + "amplitude": 21.4, + "type": "motor_harmonic" + }, + { + "frequency": 27, + "amplitude": 11.2, + "type": "unknown" + } + ] + }, + "pitch": { + "noiseFloorDb": -39.5, + "peaks": [ + { + "frequency": 160, + "amplitude": 36.9, + "type": "frame_resonance" + }, + { + "frequency": 320, + "amplitude": 29.1, + "type": "motor_harmonic" + }, + { + "frequency": 600, + "amplitude": 23, + "type": "motor_harmonic" + }, + { + "frequency": 45, + "amplitude": 19.1, + "type": "motor_harmonic" + }, + { + "frequency": 27, + "amplitude": 10.2, + "type": "unknown" + } + ] + }, + "yaw": { + "noiseFloorDb": -39.7, + "peaks": [ + { + "frequency": 160, + "amplitude": 37.5, + "type": "frame_resonance" + }, + { + "frequency": 320, + "amplitude": 29.2, + "type": "motor_harmonic" + }, + { + "frequency": 600, + "amplitude": 22.7, + "type": "motor_harmonic" + }, + { + "frequency": 46, + "amplitude": 10.5, + "type": "motor_harmonic" + } + ] + }, + "segmentsUsed": 2, + "dataQuality": { + "tier": "excellent", + "overall": 93 + }, + "groupDelay": { + "gyroTotalMs": 1.42, + "dtermTotalMs": 1.65 + }, + "warningCodes": [], + "recommendations": [ + { + "setting": "dterm_lpf1_static_hz", + "currentValue": 150, + "recommendedValue": 70, + "ruleId": "F-RES-DTERM", + "confidence": "high" + }, + { + "setting": "dyn_notch_min_hz", + "currentValue": 100, + "recommendedValue": 50, + "ruleId": "F-DN-MIN", + "confidence": "medium" + }, + { + "setting": "gyro_lpf1_static_hz", + "currentValue": 250, + "recommendedValue": 75, + "ruleId": "F-RES-GYRO", + "confidence": "high" + } + ] +} diff --git a/src/main/analysis/__fixtures__/golden/demo-flash-cycle0.json b/src/main/analysis/__fixtures__/golden/demo-flash-cycle0.json new file mode 100644 index 0000000..9ae634e --- /dev/null +++ b/src/main/analysis/__fixtures__/golden/demo-flash-cycle0.json @@ -0,0 +1,147 @@ +{ + "analysisMethod": "wiener_deconvolution", + "stepsDetected": 0, + "roll": { + "responses": 0, + "meanOvershoot": 57.5, + "meanRiseTimeMs": 3.3, + "meanSettlingTimeMs": 200, + "meanLatencyMs": 0, + "meanSteadyStateError": 0 + }, + "pitch": { + "responses": 0, + "meanOvershoot": 45.3, + "meanRiseTimeMs": 2, + "meanSettlingTimeMs": 200, + "meanLatencyMs": 0, + "meanSteadyStateError": 0 + }, + "yaw": { + "responses": 0, + "meanOvershoot": 81.2, + "meanRiseTimeMs": 48, + "meanSettlingTimeMs": 200, + "meanLatencyMs": 0, + "meanSteadyStateError": 0 + }, + "dataQuality": { + "tier": "excellent", + "overall": 90 + }, + "transferFunctionMetrics": { + "roll": { + "bandwidthHz": 500, + "phaseMarginDeg": 280, + "gainMarginDb": 60, + "dcGainDb": 1.4, + "overshootPercent": 58, + "riseTimeMs": 3, + "settlingTimeMs": 200 + }, + "pitch": { + "bandwidthHz": 500, + "phaseMarginDeg": 96, + "gainMarginDb": 60, + "dcGainDb": 1, + "overshootPercent": 45, + "riseTimeMs": 2, + "settlingTimeMs": 200 + }, + "yaw": { + "bandwidthHz": 500, + "phaseMarginDeg": 64, + "gainMarginDb": 60, + "dcGainDb": 1.2, + "overshootPercent": 81, + "riseTimeMs": 48, + "settlingTimeMs": 200 + } + }, + "warningCodes": [ + "feedforward_active", + "tpa_variance" + ], + "recommendations": [ + { + "setting": "d_min_gain", + "currentValue": 20, + "recommendedValue": 25, + "ruleId": "PW-DMIN-GAIN", + "confidence": "medium" + }, + { + "setting": "feedforward_averaging", + "currentValue": 0, + "recommendedValue": 2, + "ruleId": "FF-AVG", + "confidence": "medium" + }, + { + "setting": "feedforward_jitter_factor", + "currentValue": 7, + "recommendedValue": 5, + "ruleId": "FF-JITTER", + "confidence": "medium" + }, + { + "setting": "iterm_relax_cutoff", + "currentValue": 15, + "recommendedValue": 10, + "ruleId": "PW-IRELAX-CUTOFF", + "confidence": "medium" + }, + { + "setting": "pid_pitch_d", + "currentValue": 32, + "recommendedValue": 32, + "ruleId": "P-DTE-BLOCK-pid_pitch_d", + "confidence": "low", + "informational": true + }, + { + "setting": "pid_roll_d", + "currentValue": 30, + "recommendedValue": 30, + "ruleId": "P-DTE-BLOCK-pid_roll_d", + "confidence": "low", + "informational": true + }, + { + "setting": "pid_roll_d", + "currentValue": 30, + "recommendedValue": 50, + "ruleId": "P-PW-D-roll", + "confidence": "medium" + }, + { + "setting": "pid_yaw_d", + "currentValue": 0, + "recommendedValue": 0, + "ruleId": "P-DTE-BLOCK-pid_yaw_d", + "confidence": "low", + "informational": true + }, + { + "setting": "pid_yaw_p", + "currentValue": 45, + "recommendedValue": 40, + "ruleId": "TF-2-P-yaw", + "confidence": "medium" + }, + { + "setting": "rc_smoothing_auto_factor", + "currentValue": 30, + "recommendedValue": 45, + "ruleId": "FF-RC-SMOOTH", + "confidence": "low" + }, + { + "setting": "simplified_dmax_gain", + "currentValue": 1, + "recommendedValue": 0, + "ruleId": "P-DMAX-INFO", + "confidence": "low" + } + ] +} diff --git a/src/main/analysis/__fixtures__/golden/demo-pid-cycle0.json b/src/main/analysis/__fixtures__/golden/demo-pid-cycle0.json new file mode 100644 index 0000000..e276bb9 --- /dev/null +++ b/src/main/analysis/__fixtures__/golden/demo-pid-cycle0.json @@ -0,0 +1,111 @@ +{ + "analysisMethod": "step_response", + "stepsDetected": 36, + "roll": { + "responses": 12, + "meanOvershoot": 26, + "meanRiseTimeMs": 30.3, + "meanSettlingTimeMs": 500, + "meanLatencyMs": 2.3, + "meanSteadyStateError": 2.6 + }, + "pitch": { + "responses": 12, + "meanOvershoot": 23.7, + "meanRiseTimeMs": 30.3, + "meanSettlingTimeMs": 499, + "meanLatencyMs": 1.1, + "meanSteadyStateError": 2.4 + }, + "yaw": { + "responses": 12, + "meanOvershoot": 19.1, + "meanRiseTimeMs": 44.5, + "meanSettlingTimeMs": 500, + "meanLatencyMs": 1.5, + "meanSteadyStateError": 2.7 + }, + "dataQuality": { + "tier": "excellent", + "overall": 93 + }, + "transferFunctionMetrics": null, + "warningCodes": [ + "feedforward_active" + ], + "recommendations": [ + { + "setting": "d_min_gain", + "currentValue": 20, + "recommendedValue": 25, + "ruleId": "PW-DMIN-GAIN", + "confidence": "medium" + }, + { + "setting": "feedforward_averaging", + "currentValue": 0, + "recommendedValue": 2, + "ruleId": "FF-AVG", + "confidence": "medium" + }, + { + "setting": "feedforward_jitter_factor", + "currentValue": 7, + "recommendedValue": 5, + "ruleId": "FF-JITTER", + "confidence": "medium" + }, + { + "setting": "iterm_relax_cutoff", + "currentValue": 15, + "recommendedValue": 10, + "ruleId": "PW-IRELAX-CUTOFF", + "confidence": "medium" + }, + { + "setting": "pid_pitch_d", + "currentValue": 32, + "recommendedValue": 32, + "ruleId": "P-DTE-BLOCK-pid_pitch_d", + "confidence": "low", + "informational": true + }, + { + "setting": "pid_roll_d", + "currentValue": 30, + "recommendedValue": 30, + "ruleId": "P-DTE-BLOCK-pid_roll_d", + "confidence": "low", + "informational": true + }, + { + "setting": "pid_roll_d", + "currentValue": 30, + "recommendedValue": 50, + "ruleId": "P-PW-D-roll", + "confidence": "medium" + }, + { + "setting": "pid_yaw_d", + "currentValue": 0, + "recommendedValue": 0, + "ruleId": "P-DTE-BLOCK-pid_yaw_d", + "confidence": "low", + "informational": true + }, + { + "setting": "rc_smoothing_auto_factor", + "currentValue": 30, + "recommendedValue": 45, + "ruleId": "FF-RC-SMOOTH", + "confidence": "low" + }, + { + "setting": "simplified_dmax_gain", + "currentValue": 1, + "recommendedValue": 0, + "ruleId": "P-DMAX-INFO", + "confidence": "low" + } + ] +} diff --git a/src/main/analysis/__fixtures__/golden/real-vx35-filter.json b/src/main/analysis/__fixtures__/golden/real-vx35-filter.json new file mode 100644 index 0000000..eaa1711 --- /dev/null +++ b/src/main/analysis/__fixtures__/golden/real-vx35-filter.json @@ -0,0 +1,48 @@ +{ + "overallLevel": "medium", + "roll": { + "noiseFloorDb": -36.9, + "peaks": [] + }, + "pitch": { + "noiseFloorDb": -33, + "peaks": [] + }, + "yaw": { + "noiseFloorDb": -33.4, + "peaks": [] + }, + "segmentsUsed": 5, + "dataQuality": { + "tier": "excellent", + "overall": 100 + }, + "groupDelay": { + "gyroTotalMs": 0.99, + "dtermTotalMs": 1.82 + }, + "warningCodes": [], + "recommendations": [ + { + "setting": "dterm_lpf1_dyn_max_hz", + "currentValue": 150, + "recommendedValue": 300, + "ruleId": "F-NF-M-DTERM", + "confidence": "low" + }, + { + "setting": "dterm_lpf1_dyn_min_hz", + "currentValue": 75, + "recommendedValue": 150, + "ruleId": "F-NF-M-DTERM", + "confidence": "low" + }, + { + "setting": "rpm_filter_q", + "currentValue": 500, + "recommendedValue": 850, + "ruleId": "F-RPM-Q", + "confidence": "low" + } + ] +} diff --git a/src/main/analysis/__fixtures__/golden/real-vx35-pid.json b/src/main/analysis/__fixtures__/golden/real-vx35-pid.json new file mode 100644 index 0000000..c59231d --- /dev/null +++ b/src/main/analysis/__fixtures__/golden/real-vx35-pid.json @@ -0,0 +1,131 @@ +{ + "analysisMethod": "step_response", + "stepsDetected": 24, + "roll": { + "responses": 5, + "meanOvershoot": 52.3, + "meanRiseTimeMs": 216.2, + "meanSettlingTimeMs": 495, + "meanLatencyMs": 141, + "meanSteadyStateError": 1.3 + }, + "pitch": { + "responses": 12, + "meanOvershoot": 56.6, + "meanRiseTimeMs": 113.9, + "meanSettlingTimeMs": 499, + "meanLatencyMs": 7.8, + "meanSteadyStateError": 11.5 + }, + "yaw": { + "responses": 7, + "meanOvershoot": 62.9, + "meanRiseTimeMs": 107.7, + "meanSettlingTimeMs": 497, + "meanLatencyMs": 171.5, + "meanSteadyStateError": 7.1 + }, + "dataQuality": { + "tier": "excellent", + "overall": 100 + }, + "transferFunctionMetrics": null, + "warningCodes": [ + "feedforward_active" + ], + "recommendations": [ + { + "setting": "dyn_idle_min_rpm", + "currentValue": 0, + "recommendedValue": 45, + "ruleId": "P-DYN-IDLE", + "confidence": "low" + }, + { + "setting": "pid_pitch_d", + "currentValue": 46, + "recommendedValue": 36, + "ruleId": "P-DR-OD-pitch", + "confidence": "medium" + }, + { + "setting": "pid_pitch_d", + "currentValue": 46, + "recommendedValue": 46, + "ruleId": "P-DTE-BLOCK-pid_pitch_d", + "confidence": "low", + "informational": true + }, + { + "setting": "pid_pitch_i", + "currentValue": 84, + "recommendedValue": 94, + "ruleId": "P-SSE-I-pitch", + "confidence": "high" + }, + { + "setting": "pid_pitch_p", + "currentValue": 47, + "recommendedValue": 42, + "ruleId": "P-OS-P-pitch", + "confidence": "high" + }, + { + "setting": "pid_roll_d", + "currentValue": 40, + "recommendedValue": 34, + "ruleId": "P-DR-OD-roll", + "confidence": "medium" + }, + { + "setting": "pid_roll_d", + "currentValue": 40, + "recommendedValue": 40, + "ruleId": "P-DTE-BLOCK-pid_roll_d", + "confidence": "low", + "informational": true + }, + { + "setting": "pid_roll_p", + "currentValue": 45, + "recommendedValue": 40, + "ruleId": "P-OS-P-roll", + "confidence": "high" + }, + { + "setting": "pid_yaw_i", + "currentValue": 80, + "recommendedValue": 85, + "ruleId": "P-SSE-I-yaw", + "confidence": "medium" + }, + { + "setting": "simplified_dmax_gain", + "currentValue": 1, + "recommendedValue": 0, + "ruleId": "P-DMAX-INFO", + "confidence": "low" + }, + { + "setting": "thrust_linear", + "currentValue": 0, + "recommendedValue": 40, + "ruleId": "P-THRUST-LIN", + "confidence": "low" + }, + { + "setting": "tpa_low_always", + "currentValue": 0, + "recommendedValue": 1, + "ruleId": "P-TPA", + "confidence": "low" + }, + { + "setting": "vbat_sag_compensation", + "currentValue": 0, + "recommendedValue": 75, + "ruleId": "P-VBAT-SAG", + "confidence": "low" + } + ] +} diff --git a/src/main/analysis/__fixtures__/golden/real-vx35-tf.json b/src/main/analysis/__fixtures__/golden/real-vx35-tf.json new file mode 100644 index 0000000..4e2fa31 --- /dev/null +++ b/src/main/analysis/__fixtures__/golden/real-vx35-tf.json @@ -0,0 +1,145 @@ +{ + "analysisMethod": "wiener_deconvolution", + "stepsDetected": 0, + "roll": { + "responses": 0, + "meanOvershoot": 0, + "meanRiseTimeMs": 118, + "meanSettlingTimeMs": 175, + "meanLatencyMs": 0, + "meanSteadyStateError": 0 + }, + "pitch": { + "responses": 0, + "meanOvershoot": 1.2, + "meanRiseTimeMs": 53, + "meanSettlingTimeMs": 161, + "meanLatencyMs": 0, + "meanSteadyStateError": 0 + }, + "yaw": { + "responses": 0, + "meanOvershoot": 0, + "meanRiseTimeMs": 148, + "meanSettlingTimeMs": 190, + "meanLatencyMs": 0, + "meanSteadyStateError": 0 + }, + "dataQuality": { + "tier": "good", + "overall": 70 + }, + "transferFunctionMetrics": { + "roll": { + "bandwidthHz": 4, + "phaseMarginDeg": 179, + "gainMarginDb": 60, + "dcGainDb": -6.2, + "overshootPercent": 0, + "riseTimeMs": 118, + "settlingTimeMs": 175 + }, + "pitch": { + "bandwidthHz": 16, + "phaseMarginDeg": 175, + "gainMarginDb": 60, + "dcGainDb": -0.7, + "overshootPercent": 1, + "riseTimeMs": 53, + "settlingTimeMs": 161 + }, + "yaw": { + "bandwidthHz": 4, + "phaseMarginDeg": 90, + "gainMarginDb": 60, + "dcGainDb": -14, + "overshootPercent": 0, + "riseTimeMs": 148, + "settlingTimeMs": 190 + } + }, + "warningCodes": [ + "feedforward_active", + "low_logging_rate", + "tpa_variance" + ], + "recommendations": [ + { + "setting": "dyn_idle_min_rpm", + "currentValue": 0, + "recommendedValue": 45, + "ruleId": "P-DYN-IDLE", + "confidence": "low" + }, + { + "setting": "pid_pitch_i", + "currentValue": 84, + "recommendedValue": 89, + "ruleId": "TF-4-I-pitch", + "confidence": "low" + }, + { + "setting": "pid_pitch_p", + "currentValue": 47, + "recommendedValue": 52, + "ruleId": "TF-3-P-pitch", + "confidence": "medium" + }, + { + "setting": "pid_roll_i", + "currentValue": 80, + "recommendedValue": 90, + "ruleId": "TF-4-I-roll", + "confidence": "medium" + }, + { + "setting": "pid_roll_p", + "currentValue": 45, + "recommendedValue": 50, + "ruleId": "TF-3-P-roll", + "confidence": "medium" + }, + { + "setting": "pid_yaw_i", + "currentValue": 80, + "recommendedValue": 90, + "ruleId": "TF-4-I-yaw", + "confidence": "medium" + }, + { + "setting": "pid_yaw_p", + "currentValue": 45, + "recommendedValue": 50, + "ruleId": "TF-3-P-yaw", + "confidence": "medium" + }, + { + "setting": "simplified_dmax_gain", + "currentValue": 1, + "recommendedValue": 0, + "ruleId": "P-DMAX-INFO", + "confidence": "low" + }, + { + "setting": "thrust_linear", + "currentValue": 0, + "recommendedValue": 40, + "ruleId": "P-THRUST-LIN", + "confidence": "low" + }, + { + "setting": "tpa_low_always", + "currentValue": 0, + "recommendedValue": 1, + "ruleId": "P-TPA", + "confidence": "low" + }, + { + "setting": "vbat_sag_compensation", + "currentValue": 0, + "recommendedValue": 75, + "ruleId": "P-VBAT-SAG", + "confidence": "low" + } + ] +} diff --git a/src/main/analysis/goldenOutputs.test.ts b/src/main/analysis/goldenOutputs.test.ts new file mode 100644 index 0000000..a995fea --- /dev/null +++ b/src/main/analysis/goldenOutputs.test.ts @@ -0,0 +1,336 @@ +/** + * Golden-output regression harness for the analysis pipelines. + * + * Runs the full FilterAnalyzer / PIDAnalyzer / TransferFunction pipelines over + * deterministic inputs (seeded demo BBLs + a real BBL fixture) and compares a + * stable summary of the outputs against JSON fixtures in + * `__fixtures__/golden/`. + * + * Purpose: any PR that changes analysis behavior shows up as a golden diff. + * The two planned recalibration events (calibrated PSD, deconvolved step + * response) are the ONLY PRs allowed to regenerate these fixtures wholesale; + * every other change must either leave them untouched or justify the diff. + * + * To regenerate fixtures: UPDATE_GOLDEN=1 npx vitest run goldenOutputs + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { BlackboxParser } from '../blackbox/BlackboxParser'; +import { analyze as analyzeFilters } from './FilterAnalyzer'; +import { analyzePID, analyzeTransferFunction } from './PIDAnalyzer'; +import { + generateFilterDemoBBL, + generatePIDDemoBBL, + generateFlashDemoBBL, +} from '../demo/DemoDataGenerator'; +import { extractFlightPIDs } from './PIDRecommender'; +import { enrichSettingsFromBBLHeaders } from './headerValidation'; +import { DEFAULT_FILTER_SETTINGS } from '@shared/types/analysis.types'; +import type { + FilterAnalysisResult, + PIDAnalysisResult, + AxisNoiseProfile, +} from '@shared/types/analysis.types'; +import type { PIDConfiguration } from '@shared/types/pid.types'; +import type { BlackboxFlightData } from '@shared/types/blackbox.types'; + +const GOLDEN_DIR = path.resolve(__dirname, '__fixtures__/golden'); +const REAL_BBL_PATH = path.resolve( + __dirname, + '../../../test-fixtures/bbl/blackbox_2026-03-29T11-09-44-682Z.bbl' +); +const UPDATE = process.env.UPDATE_GOLDEN === '1'; + +const DEFAULT_PIDS: PIDConfiguration = { + roll: { P: 45, I: 80, D: 30 }, + pitch: { P: 47, I: 84, D: 32 }, + yaw: { P: 45, I: 80, D: 0 }, +}; + +// ── Deterministic RNG (demo generator uses Math.random for gyro noise) ── + +/** mulberry32 seeded PRNG — stable across platforms */ +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const realRandom = Math.random; + +function seedRandom(seed: number): void { + Math.random = mulberry32(seed); +} + +function restoreRandom(): void { + Math.random = realRandom; +} + +// ── Stable summaries (rounded so tiny FP differences don't flake) ── + +function round(v: number, decimals: number): number { + const f = Math.pow(10, decimals); + const r = Math.round(v * f) / f; + // Normalize -0 so JSON round-trips are stable + return Object.is(r, -0) ? 0 : r; +} + +interface RecSummary { + setting: string; + currentValue: number; + recommendedValue: number; + ruleId?: string; + confidence: string; + informational?: boolean; +} + +function summarizeRecs( + recs: Array<{ + setting: string; + currentValue: number; + recommendedValue: number; + ruleId?: string; + confidence: string; + informational?: boolean; + }> +): RecSummary[] { + return recs + .map((r) => ({ + setting: r.setting, + currentValue: round(r.currentValue, 1), + recommendedValue: round(r.recommendedValue, 1), + ...(r.ruleId ? { ruleId: r.ruleId } : {}), + confidence: r.confidence, + ...(r.informational ? { informational: true } : {}), + })) + .sort((a, b) => + a.setting === b.setting + ? (a.ruleId ?? '').localeCompare(b.ruleId ?? '') + : a.setting.localeCompare(b.setting) + ); +} + +function summarizeAxisNoise(axis: AxisNoiseProfile) { + return { + noiseFloorDb: round(axis.noiseFloorDb, 1), + peaks: axis.peaks.slice(0, 8).map((p) => ({ + frequency: round(p.frequency, 0), + amplitude: round(p.amplitude, 1), + type: p.type, + })), + }; +} + +function summarizeFilterResult(r: FilterAnalysisResult) { + return { + overallLevel: r.noise.overallLevel, + roll: summarizeAxisNoise(r.noise.roll), + pitch: summarizeAxisNoise(r.noise.pitch), + yaw: summarizeAxisNoise(r.noise.yaw), + segmentsUsed: r.segmentsUsed, + dataQuality: r.dataQuality + ? { tier: r.dataQuality.tier, overall: round(r.dataQuality.overall, 0) } + : null, + groupDelay: r.groupDelay + ? { + gyroTotalMs: round(r.groupDelay.gyroTotalMs, 2), + dtermTotalMs: round(r.groupDelay.dtermTotalMs, 2), + } + : null, + warningCodes: (r.warnings ?? []).map((w) => w.code).sort(), + recommendations: summarizeRecs(r.recommendations), + }; +} + +function summarizeAxisStep(p: PIDAnalysisResult['roll']) { + return { + responses: p.responses.length, + meanOvershoot: round(p.meanOvershoot, 1), + meanRiseTimeMs: round(p.meanRiseTimeMs, 1), + meanSettlingTimeMs: round(p.meanSettlingTimeMs, 0), + meanLatencyMs: round(p.meanLatencyMs, 1), + meanSteadyStateError: round(p.meanSteadyStateError, 1), + }; +} + +function summarizePIDResult(r: PIDAnalysisResult) { + return { + analysisMethod: r.analysisMethod ?? 'step_response', + stepsDetected: r.stepsDetected, + roll: summarizeAxisStep(r.roll), + pitch: summarizeAxisStep(r.pitch), + yaw: summarizeAxisStep(r.yaw), + dataQuality: r.dataQuality + ? { tier: r.dataQuality.tier, overall: round(r.dataQuality.overall, 0) } + : null, + transferFunctionMetrics: r.transferFunctionMetrics + ? (['roll', 'pitch', 'yaw'] as const).reduce( + (acc, axis) => { + const m = r.transferFunctionMetrics![axis]; + acc[axis] = { + bandwidthHz: round(m.bandwidthHz, 0), + phaseMarginDeg: round(m.phaseMarginDeg, 0), + gainMarginDb: round(m.gainMarginDb, 0), + dcGainDb: round(m.dcGainDb, 1), + overshootPercent: round(m.overshootPercent, 0), + riseTimeMs: round(m.riseTimeMs, 0), + settlingTimeMs: round(m.settlingTimeMs, 0), + }; + return acc; + }, + {} as Record> + ) + : null, + warningCodes: (r.warnings ?? []).map((w) => w.code).sort(), + recommendations: summarizeRecs(r.recommendations), + }; +} + +// ── Fixture comparison ── + +function checkGolden(name: string, actual: unknown): void { + const file = path.join(GOLDEN_DIR, `${name}.json`); + if (UPDATE) { + fs.mkdirSync(GOLDEN_DIR, { recursive: true }); + fs.writeFileSync(file, JSON.stringify(actual, null, 2) + '\n'); + return; + } + if (!fs.existsSync(file)) { + throw new Error( + `Golden fixture missing: ${file}. Run: UPDATE_GOLDEN=1 npx vitest run goldenOutputs` + ); + } + const expected = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(actual).toEqual(expected); +} + +async function parseBBL(buffer: Buffer): Promise<{ + flightData: BlackboxFlightData; + rawHeaders: Map; +}> { + const result = await BlackboxParser.parse(buffer); + expect(result.success).toBe(true); + const session = result.sessions[0]; + return { flightData: session.flightData, rawHeaders: session.header.rawHeaders }; +} + +// ── Test cases ── + +describe('golden outputs — demo BBLs (seeded RNG)', () => { + beforeAll(() => seedRandom(0xf9d1ab)); + afterAll(() => restoreRandom()); + + it('filter demo cycle 0', async () => { + seedRandom(0xf9d1ab); + const { flightData } = await parseBBL(generateFilterDemoBBL(0)); + const result = await analyzeFilters(flightData, 0, DEFAULT_FILTER_SETTINGS, undefined, { + droneSize: '5"', + flightStyle: 'balanced', + }); + checkGolden('demo-filter-cycle0', summarizeFilterResult(result)); + }, 60000); + + it('filter demo cycle 2 (cleaner)', async () => { + seedRandom(0xf9d1ab); + const { flightData } = await parseBBL(generateFilterDemoBBL(2)); + const result = await analyzeFilters(flightData, 0, DEFAULT_FILTER_SETTINGS, undefined, { + droneSize: '5"', + flightStyle: 'balanced', + }); + checkGolden('demo-filter-cycle2', summarizeFilterResult(result)); + }, 60000); + + it('pid demo cycle 0', async () => { + seedRandom(0xf9d1ab); + const { flightData, rawHeaders } = await parseBBL(generatePIDDemoBBL(0)); + const result = await analyzePID( + flightData, + 0, + DEFAULT_PIDS, + undefined, + extractFlightPIDs(rawHeaders), + rawHeaders, + 'balanced', + undefined, + '5"' + ); + checkGolden('demo-pid-cycle0', summarizePIDResult(result)); + }, 60000); + + it('flash demo cycle 0 (wiener)', async () => { + seedRandom(0xf9d1ab); + const { flightData, rawHeaders } = await parseBBL(generateFlashDemoBBL(0)); + const result = await analyzeTransferFunction( + flightData, + 0, + DEFAULT_PIDS, + undefined, + extractFlightPIDs(rawHeaders), + rawHeaders, + 'balanced', + undefined, + '5"' + ); + checkGolden('demo-flash-cycle0', summarizePIDResult(result)); + }, 60000); +}); + +describe('golden outputs — real BBL (VX3.5, BF 4.5.2)', () => { + let flightData: BlackboxFlightData; + let rawHeaders: Map; + + beforeAll(async () => { + const data = fs.readFileSync(REAL_BBL_PATH); + const parsed = await parseBBL(data); + flightData = parsed.flightData; + rawHeaders = parsed.rawHeaders; + }, 120000); + + it('filter analysis', async () => { + const enriched = + enrichSettingsFromBBLHeaders(DEFAULT_FILTER_SETTINGS, rawHeaders) ?? DEFAULT_FILTER_SETTINGS; + const result = await analyzeFilters(flightData, 0, enriched, undefined, { + droneSize: '3"', + flightStyle: 'balanced', + }); + checkGolden('real-vx35-filter', summarizeFilterResult(result)); + }, 120000); + + it('pid analysis (step response)', async () => { + const flightPIDs = extractFlightPIDs(rawHeaders); + const result = await analyzePID( + flightData, + 0, + flightPIDs ?? DEFAULT_PIDS, + undefined, + flightPIDs, + rawHeaders, + 'balanced', + undefined, + '3"' + ); + checkGolden('real-vx35-pid', summarizePIDResult(result)); + }, 120000); + + it('transfer function analysis (wiener)', async () => { + const flightPIDs = extractFlightPIDs(rawHeaders); + const result = await analyzeTransferFunction( + flightData, + 0, + flightPIDs ?? DEFAULT_PIDS, + undefined, + flightPIDs, + rawHeaders, + 'balanced', + undefined, + '3"' + ); + checkGolden('real-vx35-tf', summarizePIDResult(result)); + }, 120000); +}); From 1b4c07e7164fc233f5234b8e67b89a4f496d84d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:35:38 +0000 Subject: [PATCH 03/13] feat: calibrated power spectrum + dB threshold recalibration (P1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FFTCompute now produces a calibrated one-sided power spectrum (SPECTRUM_SCALE_VERSION = 2): segments are detrended, Hanning-windowed, normalized by coherent window gain ((Σw)²), Welch-averaged in the POWER domain (was amplitude domain), and reported as 10·log10(power). A sine of amplitude A reads exactly 10·log10(A²/2) independent of FFT size and sample rate; white-noise floors depend only on FFT size. New calibration unit tests assert the theoretical values. All absolute dB thresholds shift +10 dB to the new scale in the same commit (NOISE_LEVEL_BY_SIZE, noise-target anchors, LPF2 disable thresholds, propwash floor bypass, mechanical-health extreme noise, flight-quality noise-floor anchors). Relative dB thresholds (prominence, deltas, convergence) are scale-invariant and unchanged. Empirical offset verified on demo (+10.0 dB) and real VX3.5 (+10.5-11.2 dB) logs; golden fixtures regenerated — recommendation directions unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJhhk2frYDDnpNbSHRxGdv --- docs/PID_TUNING_KNOWLEDGE.md | 42 ++++---- src/main/analysis/CLAUDE.md | 4 +- src/main/analysis/FFTCompute.test.ts | 77 ++++++++++++- src/main/analysis/FFTCompute.ts | 87 ++++++++++----- src/main/analysis/FilterRecommender.test.ts | 102 +++++++++--------- .../analysis/MechanicalHealthChecker.test.ts | 52 ++++----- src/main/analysis/MechanicalHealthChecker.ts | 10 +- src/main/analysis/NoiseAnalyzer.test.ts | 92 ++++++++-------- src/main/analysis/NoiseAnalyzer.ts | 6 +- .../golden/demo-filter-cycle0.json | 37 ++++--- .../golden/demo-filter-cycle2.json | 59 ++++++---- .../__fixtures__/golden/real-vx35-filter.json | 22 +++- src/main/analysis/constants.ts | 63 +++++++---- src/shared/utils/tuneQualityScore.test.ts | 82 +++++++------- src/shared/utils/tuneQualityScore.ts | 5 +- 15 files changed, 450 insertions(+), 290 deletions(-) diff --git a/docs/PID_TUNING_KNOWLEDGE.md b/docs/PID_TUNING_KNOWLEDGE.md index 6614934..8ea2c9f 100644 --- a/docs/PID_TUNING_KNOWLEDGE.md +++ b/docs/PID_TUNING_KNOWLEDGE.md @@ -526,20 +526,20 @@ Works from **any flight data** — no dedicated maneuvers needed. Pioneered by P ### Noise Floor Scale (FPVPIDlab-Specific) -FPVPIDlab uses its own dB scale based on raw FFT power spectral density, normalized per the analysis window. This is **not directly comparable** to BF Explorer or PIDtoolbox dB values — each tool normalizes differently. +FPVPIDlab uses a **calibrated one-sided power spectrum** (`SPECTRUM_SCALE_VERSION = 2` in `constants.ts`): segments are detrended (mean removed), Hanning-windowed, normalized by coherent window gain ((Σw)²), Welch-averaged in the power domain, and reported as `10·log10(power)` in dB re (deg/s)². Calibration: a sine of amplitude A reads exactly `10·log10(A²/2)` at its bin, independent of FFT size and sample rate; white-noise floors depend only on FFT size (per-bin power ≈ 2σ²/N), not sample rate. The scale sits ≈10 dB above the legacy v1 amplitude-averaged scale and is still **not directly comparable** to BF Explorer or PIDtoolbox dB values — each tool normalizes differently. Metrics stored by v1 app versions are ≈10 dB lower than v2 values for the same flight. -| FPVPIDlab dB | Internal Classification | Mapping Rationale | +| FPVPIDlab dB (v2) | Internal Classification | Mapping Rationale | |-----------|----------------------|-------------------| -| < -50 dB | Very clean | Minimal filtering needed | -| -50 to -30 dB | Normal | Standard filtering | -| -30 to -20 dB | Noisy | Lower cutoffs needed | -| > -20 dB | Very noisy | Aggressive filtering, check hardware | +| < -40 dB | Very clean | Minimal filtering needed | +| -40 to -20 dB | Normal | Standard filtering | +| -20 to -10 dB | Noisy | Lower cutoffs needed | +| > -10 dB | Very noisy | Aggressive filtering, check hardware | -FPVPIDlab's noise-to-cutoff interpolation range: **-70 dB (cleanest) to -10 dB (noisiest)**. These are internal scale endpoints, not community-standard values. +FPVPIDlab's noise-to-cutoff interpolation range: **-60 dB (cleanest) to 0 dB (noisiest)**. These are internal scale endpoints, not community-standard values. -**Size-aware noise classification (`NOISE_LEVEL_BY_SIZE`)**: The only community-anchored row is 5" — the PIDtoolbox convention of −30 dB for a "clean" 5" build (and roughly −10 dB for D-term). All non-5" rows (1": −15/−30, 2.5": −20/−35, 3": −25/−40, 4": −27/−40, 6": −33/−50, 7": −35/−55 high/medium dB) are a **house extrapolation** of that standard — smaller/higher-KV builds get more lenient thresholds, larger/lower-KV builds stricter ones. No published community per-size table exists. +**Size-aware noise classification (`NOISE_LEVEL_BY_SIZE`)**: The only community-anchored row is 5" — the PIDtoolbox convention of −30 dB (amplitude convention) for a "clean" 5" build, which maps to −20 dB on the v2 scale. All non-5" rows (1": −5/−20, 2.5": −10/−25, 3": −15/−30, 4": −17/−30, 6": −23/−40, 7": −25/−45 high/medium dB) are a **house extrapolation** of that standard — smaller/higher-KV builds get more lenient thresholds, larger/lower-KV builds stricter ones. No published community per-size table exists. -**Mechanical health extreme-noise threshold**: `MechanicalHealthChecker` flags "extreme noise / possible damaged prop" at `max(−20 dB, NOISE_LEVEL_BY_SIZE[size].highDb + 5 dB)` — size-aware so that a healthy 1"/2.5" build (inherently noisy) is not falsely flagged. +**Mechanical health extreme-noise threshold**: `MechanicalHealthChecker` flags "extreme noise / possible damaged prop" at `max(−10 dB, NOISE_LEVEL_BY_SIZE[size].highDb + 5 dB)` — size-aware so that a healthy 1"/2.5" build (inherently noisy) is not falsely flagged. ### Peak Detection (FPVPIDlab-Specific) @@ -554,12 +554,12 @@ FPVPIDlab's noise-to-cutoff interpolation range: **-70 dB (cleanest) to -10 dB ( **Rule 1: Noise-Floor-Based Lowpass Adjustment** - Scope: Roll and pitch axes -- **High noise** (> -30 dB): full-confidence noise-to-cutoff interpolation -- **Medium noise** (-50 to -30 dB): 20 Hz deadzone, low confidence recommendations (avoids churn) -- **Low noise** (< -50 dB): recommend raising cutoffs toward latency-optimal values (medium confidence). Clean quads benefit from higher cutoffs that reduce group delay without meaningful noise penalty -- Linear interpolation from noise floor (dB) to cutoff (Hz): +- **High noise** (> -20 dB): full-confidence noise-to-cutoff interpolation +- **Medium noise** (-40 to -20 dB): 20 Hz deadzone, low confidence recommendations (avoids churn) +- **Low noise** (< -40 dB): recommend raising cutoffs toward latency-optimal values (medium confidence). Clean quads benefit from higher cutoffs that reduce group delay without meaningful noise penalty +- Linear interpolation from noise floor (dB, v2 scale) to cutoff (Hz): ``` - t = (noiseFloorDb - (-10)) / ((-70) - (-10)) + t = (noiseFloorDb - 0) / ((-60) - 0) target = minHz + t × (maxHz - minHz) ``` - **Safety bounds** (FPVPIDlab-specific, tighter than BF firmware limits): @@ -574,7 +574,7 @@ FPVPIDlab's noise-to-cutoff interpolation range: **-70 dB (cleanest) to -10 dB ( *Rationale*: Gyro LPF1 min of 75 Hz is between BF's "very noisy" (50) and "slightly noisy" (80) — a compromise that prevents excessive phase delay while still allowing aggressive filtering for noisy quads. With RPM filter, bounds widen because RPM handles motor harmonics. - **Deadzone**: 5 Hz minimum change to trigger recommendation (prevents trivial adjustments) -- **Propwash safety floor**: If target gyro LPF1 < 100 Hz AND worst noise floor ≤ -15 dB, raise to 100 Hz. This is a **conservative FPVPIDlab house rule** — the BF docs' "avoid below 100 Hz" advice refers to notch filters, and the community D-term lowpass floor is ~80 Hz (Oscar Liang). Bypassed only when noise is extreme (> -15 dB) because filtering takes priority over propwash. +- **Propwash safety floor**: If target gyro LPF1 < 100 Hz AND worst noise floor ≤ -5 dB (v2 scale), raise to 100 Hz. This is a **conservative FPVPIDlab house rule** — the BF docs' "avoid below 100 Hz" advice refers to notch filters, and the community D-term lowpass floor is ~80 Hz (Oscar Liang). Bypassed only when noise is extreme (> -5 dB) because filtering takes priority over propwash. **Rule 2: Resonance Peak Mitigation** (notch-aware) - Collect peaks ≥12 dB above noise floor on roll and pitch @@ -596,10 +596,10 @@ FPVPIDlab's noise-to-cutoff interpolation range: **-70 dB (cleanest) to -10 dB ( - *Rationale*: With RPM handling motor harmonics, the dynamic notch only needs to catch frame resonance — 1 narrow notch suffices on 5"+; small builds keep 2. Community consensus supports simplification (UAV Tech, BF 4.3+ notes); the per-size split and max step are FPVPIDlab house choices. **Rule 6: LPF2 Recommendations** -- **Disable gyro LPF2** (F-LPF2-DIS-GYRO): When RPM filter active AND noise floor < -45 dB (`GYRO_LPF2_DISABLE_THRESHOLD_DB`). Reduces filter delay. -- **Disable D-term LPF2** (F-LPF2-DIS-DTERM): When RPM filter active AND noise floor < -45 dB (`DTERM_LPF2_DISABLE_THRESHOLD_DB`). Reduces D-term latency. RPM filter required as safety net before removing LPF2. -- **Enable gyro LPF2** (F-LPF2-EN-GYRO): When no RPM filter AND noise floor ≥ -30 dB (noisy). Enables at **250 Hz** (house choice — conservative secondary cutoff below the BF default 500 Hz). Extra filtering protects motors. -- **Enable D-term LPF2** (F-LPF2-EN-DTERM): When noise floor ≥ -30 dB AND LPF2 currently disabled. Enables at **150 Hz**. Extra D-term protection. +- **Disable gyro LPF2** (F-LPF2-DIS-GYRO): When RPM filter active AND noise floor < -35 dB (`GYRO_LPF2_DISABLE_THRESHOLD_DB`). Reduces filter delay. +- **Disable D-term LPF2** (F-LPF2-DIS-DTERM): When RPM filter active AND noise floor < -35 dB (`DTERM_LPF2_DISABLE_THRESHOLD_DB`). Reduces D-term latency. RPM filter required as safety net before removing LPF2. +- **Enable gyro LPF2** (F-LPF2-EN-GYRO): When no RPM filter AND noise floor ≥ -20 dB (noisy). Enables at **250 Hz** (house choice — conservative secondary cutoff below the BF default 500 Hz). Extra filtering protects motors. +- **Enable D-term LPF2** (F-LPF2-EN-DTERM): When noise floor ≥ -20 dB AND LPF2 currently disabled. Enables at **150 Hz**. Extra D-term protection. - *Rationale*: LPF2 adds significant phase delay — only worth it when noise level justifies it. With RPM filter + clean noise, LPF2 is counterproductive. **Dynamic Lowpass Rules (F-DLPF-*)** — `DynamicLowpassRecommender`: @@ -779,7 +779,7 @@ FPVPIDlab adjusts all PID thresholds based on the pilot's declared flight style. - Throttle-down detection: derivative < -0.3 (normalized) sustained ≥50 ms - Analysis window: 400 ms post-drop, FFT in 20-90 Hz band -- Severity: energy ratio vs full-flight baseline +- Severity: event band energy ratio vs CLEAN baseline — band energy of contiguous runs outside every drop + post-drop window, weighted by run length; falls back to full flight when no clean run ≥ 1024 samples. A whole-flight baseline would include the prop-wash energy itself, saturating the ratio on aggressive flights - < 2× = minimal, 2-5× = moderate, ≥ 5× = severe - Minimum 3 events for reliable analysis - Dominant frequency: grouped into 5 Hz buckets, most common = dominant @@ -852,7 +852,7 @@ Composite 0-100 score computed after tuning session completes. Components vary b | Component | Best Value | Worst Value | Available In | |-----------|-----------|-------------|--------------| -| Noise floor | -60 dB | -20 dB | All modes | +| Noise floor | -50 dB | -10 dB (v2 scale) | All modes | | Tracking RMS | 0 | 0.5 deg/s | PID Tune only | | Overshoot | 0% | 50% | All modes | | Settling time | 50 ms | 500 ms | PID Tune only | diff --git a/src/main/analysis/CLAUDE.md b/src/main/analysis/CLAUDE.md index 4e62b00..2cc004d 100644 --- a/src/main/analysis/CLAUDE.md +++ b/src/main/analysis/CLAUDE.md @@ -7,7 +7,7 @@ Noise analysis, step response, transfer function, and data quality scoring modul **Pipeline**: SegmentSelector → FFTCompute → NoiseAnalyzer → FilterRecommender → FilterAnalyzer - **SegmentSelector**: Finds stable hover segments and throttle sweep segments (excludes takeoff/landing/acro) -- **FFTCompute**: Hanning window, Welch's method (50% overlap), power spectral density +- **FFTCompute**: detrended + Hanning window, Welch's method (50% overlap, power-domain averaging), calibrated one-sided power spectrum (`SPECTRUM_SCALE_VERSION = 2`: sine of amplitude A reads 10·log10(A²/2); dB values sit ≈10 dB above the legacy v1 amplitude-averaged scale) - **NoiseAnalyzer**: Noise floor estimation, peak detection (prominence-based), source classification (frame resonance 80-200 Hz, motor harmonics, electrical >500 Hz) - **FilterRecommender**: Absolute noise-based target computation (convergent), safety bounds, propwash-aware gyro LPF1 floor (100 Hz min, bypass at -15 dB extreme noise), beginner-friendly explanations. Medium noise handling (conditional LPF2 recommendations, incl. `DTERM_LPF2_DISABLE_THRESHOLD_DB` for the D-term disable rule), notch-aware resonance (notch counts as covering a peak only when `dyn_notch_count > 0`), conditional dynamic notch Q based on noise severity, size-aware dyn_notch_count target (2 sub-5", 1 for 5"+, max step 2/iteration). Dynamic-lowpass-aware: when `dyn_min_hz > 0`, all noise-floor and resonance rules target `dyn_min_hz`/`dyn_max_hz` instead of `static_hz`, proportionally adjusting max to maintain ratio. Exports `isGyroDynamicActive()`, `isDtermDynamicActive()` - **ThrottleSpectrogramAnalyzer**: Bins gyro data by throttle level (10 bands), per-band FFT spectra and noise floors. Returns `ThrottleSpectrogramResult` @@ -26,7 +26,7 @@ Noise analysis, step response, transfer function, and data quality scoring modul - **StepMetrics**: Rise time, overshoot percentage, settling time, latency, ringing measurement with SNR filter (`RINGING_MIN_AMPLITUDE_FRACTION` = 5% of step magnitude excludes gyro noise from ringing count). Adaptive two-pass window sizing (`computeAdaptiveWindowMs()` — median-based, clamped 150-500ms). Steady-state error tracking (`steadyStateErrorPercent`) - **PIDRecommender**: Flight-PID-anchored P/D/I recommendations (convergent), `extractFlightPIDs()` from BBL header, proportional severity-based steps (D: +5/+10/+15, P: -5/-10), I-term rules based on `meanSteadyStateError` with flight-style thresholds, D/P damping ratio validation (0.45-0.85 range; ceiling 1.0 for 1"/2.5" micros via `DAMPING_RATIO_MAX_MICRO`), safety bounds (P: 20-120, D: 15-80 for 5"/15-90 for 6"/15-100 for 7", I: 40-120). **Quad-size-aware bounds**: `droneSize` parameter narrows P/D/I bounds via `QUAD_SIZE_BOUNDS` (e.g., micro quads pMin=30 prevents dangerously low P). **Severity-scaled sluggish P**: P increase scales with rise time severity (+5/+10). **P-too-high warning**: when P > 1.3× pTypical, emits informational recommendation (`informational: true`). **P-too-low warning**: when P < 0.7× pTypical, emits informational warning (important for micros). **D-term effectiveness gating**: 3-tier D-increase gating (>0.7 boost confidence, 0.3-0.7 allow+warn, <0.3 block the increase and emit an informational "improve filters first" rec — `P-DTE-BLOCK`). **Prop wash integration**: severe prop wash (≥5×) boosts D-increase confidence or generates new D+5 recommendation on worst axis. **Propwash iterm_relax**: two-tier progressive reduction — moderate propwash (2-5×) lowers cutoff by 5 with floor 15 (PW-IRELAX-CUTOFF-MOD), severe (≥5×) lowers with floor 7 (PW-IRELAX-CUTOFF). **Rule TF-4**: DC gain deficit from transfer function → I-term increase recommendation (Flash Tune equivalent of steady-state error detection). Style-aware threshold `20·log10(1 − steadyStateErrorMax/100)` dB; +10 step and medium confidence at 2× threshold. **D-min/TPA advisory**: `extractDMinContext()` and `extractTPAContext()` from BBL headers annotate D recommendations when D-min or TPA is active. **FF boost step**: reduced from 5 to 3 for finer convergence. **VBat sag advisory** (P-VBAT-SAG): recommends `vbat_sag_compensation=75` for freestyle/cinematic when disabled - **CrossAxisDetector**: Pearson correlation coupling detection between axis pairs. Thresholds: none (<0.15), mild (0.15-0.4), significant (≥0.4). Returns `CrossAxisCoupling` -- **PropWashDetector**: Throttle-down event detection, post-event FFT in 20-90 Hz band. Returns `PropWashAnalysis` with events, meanSeverity, worstAxis, dominantFrequencyHz. Passed to `recommendPID()` for prop wash-aware D recommendations +- **PropWashDetector**: Throttle-down event detection, post-event FFT in 20-90 Hz band. Severity ratio uses a **clean baseline** — band energy of contiguous runs outside every drop + post-drop window (per-run FFT, length-weighted; falls back to whole flight when no clean run ≥ 1024 samples). Returns `PropWashAnalysis` with events, meanSeverity, worstAxis, dominantFrequencyHz. Passed to `recommendPID()` for prop wash-aware D recommendations - **PIDAnalyzer**: Orchestrator with async progress reporting, threads `flightPIDs` through pipeline. Two-pass step detection (first 500ms, then adaptive). Passes `dTermEffectiveness`, `propWash`, `dMinContext`, and `tpaContext` to `recommendPID()` for integrated D-gain gating and advisory annotations - IPC: `ANALYSIS_RUN_PID` + `EVENT_ANALYSIS_PROGRESS` diff --git a/src/main/analysis/FFTCompute.test.ts b/src/main/analysis/FFTCompute.test.ts index 3b6a866..ed015ba 100644 --- a/src/main/analysis/FFTCompute.test.ts +++ b/src/main/analysis/FFTCompute.test.ts @@ -159,7 +159,10 @@ describe('computeSegmentSpectrum', () => { } peaks.sort((a, b) => b.mag - a.mag); - const detectedFreqs = peaks.slice(0, 2).map((p) => frequencies[p.idx]).sort((a, b) => a - b); + const detectedFreqs = peaks + .slice(0, 2) + .map((p) => frequencies[p.idx]) + .sort((a, b) => a - b); const freqRes = sampleRate / N; expect(Math.abs(detectedFreqs[0] - freq1)).toBeLessThan(freqRes * 1.5); expect(Math.abs(detectedFreqs[1] - freq2)).toBeLessThan(freqRes * 1.5); @@ -322,6 +325,76 @@ describe('trimSpectrum', () => { for (let i = 1; i < trimmed.magnitudes.length; i++) { if (trimmed.magnitudes[i] > trimmed.magnitudes[peakIdx]) peakIdx = i; } - expect(Math.abs(trimmed.frequencies[peakIdx] - 300)).toBeLessThan(sampleRate / N * 2); + expect(Math.abs(trimmed.frequencies[peakIdx] - 300)).toBeLessThan((sampleRate / N) * 2); + }); +}); + +describe('power spectrum calibration (v2 scale)', () => { + it('a sine of amplitude A reads 10*log10(A^2/2) at its bin', () => { + const N = 4096; + const sampleRate = 4000; + // Bin-aligned frequency to avoid leakage: bin 205 → 200.1953125 Hz + const freq = (205 * sampleRate) / N; + const A = 100; + const signal = new Float64Array(N); + for (let i = 0; i < N; i++) { + signal[i] = A * Math.sin((2 * Math.PI * freq * i) / sampleRate); + } + const { frequencies, magnitudes } = computeSegmentSpectrum(signal, sampleRate); + + let peakIdx = 0; + for (let i = 1; i < magnitudes.length; i++) { + if (magnitudes[i] > magnitudes[peakIdx]) peakIdx = i; + } + expect(Math.abs(frequencies[peakIdx] - freq)).toBeLessThan(sampleRate / N); + // Theoretical: 10*log10(100^2 / 2) = 36.99 dB + expect(magnitudes[peakIdx]).toBeCloseTo(10 * Math.log10((A * A) / 2), 0); + }); + + it('calibration is independent of window size and sample rate', () => { + const A = 50; + const expected = 10 * Math.log10((A * A) / 2); + for (const [N, fs] of [ + [1024, 2000], + [4096, 4000], + [8192, 8000], + ] as const) { + const bin = Math.round(N / 16); + const freq = (bin * fs) / N; + const signal = new Float64Array(N); + for (let i = 0; i < N; i++) { + signal[i] = A * Math.sin((2 * Math.PI * freq * i) / fs); + } + const { magnitudes } = computeSegmentSpectrum(signal, fs); + let peak = -Infinity; + for (const m of magnitudes) peak = Math.max(peak, m); + expect(peak).toBeCloseTo(expected, 0); + } + }); + + it('Welch averaging preserves the calibrated sine level', () => { + const N = 1024; + const fs = 4000; + const A = 20; + const bin = 64; + const freq = (bin * fs) / N; + const signal = new Float64Array(N * 8); + for (let i = 0; i < signal.length; i++) { + signal[i] = A * Math.sin((2 * Math.PI * freq * i) / fs); + } + const { magnitudes } = computePowerSpectrum(signal, fs, N); + let peak = -Infinity; + for (const m of magnitudes) peak = Math.max(peak, m); + expect(peak).toBeCloseTo(10 * Math.log10((A * A) / 2), 0); + }); + + it('detrends the segment: a constant offset does not appear at DC', () => { + const N = 256; + const signal = new Float64Array(N).fill(42); + const { magnitudes } = computeSegmentSpectrum(signal, 1000, false); + // Mean removal leaves an all-zero signal → every bin at the sentinel floor + for (const m of magnitudes) { + expect(m).toBe(-240); + } }); }); diff --git a/src/main/analysis/FFTCompute.ts b/src/main/analysis/FFTCompute.ts index 22f5407..fa48b37 100644 --- a/src/main/analysis/FFTCompute.ts +++ b/src/main/analysis/FFTCompute.ts @@ -3,13 +3,28 @@ * * Provides windowed FFT, power spectral density via Welch's method, * and frequency bin calculation. Uses fft.js for the core transform. + * + * Spectra are calibrated one-sided power spectra: segments are detrended + * (mean removed), Hanning-windowed, normalized by coherent window gain + * ((Σw)²), and reported as 10·log10(power) in dB re (deg/s)². Welch + * averaging happens in the power domain. Calibration: a sine of amplitude + * A reads exactly 10·log10(A²/2) at its bin, independent of window size + * and sample rate. White-noise floors depend only on the FFT size + * (per-bin power = 2σ²/N), not the sample rate, so dB thresholds remain + * comparable across logging rates. */ import FFT from 'fft.js'; import type { PowerSpectrum } from '@shared/types/analysis.types'; import { FFT_WINDOW_SIZE, FFT_OVERLAP, FREQUENCY_MIN_HZ, FREQUENCY_MAX_HZ } from './constants'; +/** Sentinel dB value for bins with near-zero power (10*log10(1e-24)) */ +const DB_SENTINEL = -240; + +/** Power floor below which a bin is reported as the sentinel */ +const POWER_FLOOR = 1e-24; + /** - * Apply a Hanning window to a signal segment in-place. + * Apply a Hanning window to a signal segment. * w(n) = 0.5 * (1 - cos(2*pi*n / (N-1))) */ export function applyHanningWindow(signal: Float64Array): Float64Array { @@ -22,13 +37,27 @@ export function applyHanningWindow(signal: Float64Array): Float64Array { return windowed; } +/** Coherent window gain Σw[n] for the Hanning window of length N */ +function hanningWindowSum(N: number): number { + let sum = 0; + for (let i = 0; i < N; i++) { + sum += 0.5 * (1 - Math.cos((2 * Math.PI * i) / (N - 1))); + } + return sum; +} + /** - * Compute the magnitude spectrum (in dB) from a real-valued signal segment. + * Compute the one-sided power spectrum (in dB) of a real-valued segment. + * + * The segment is detrended (mean removed) and windowed, and the power is + * normalized by the coherent window gain: + * P(k) = scale · |X(k)|² / (Σw)², scale = 2 except at DC/Nyquist + * so a sine of amplitude A reads 10·log10(A²/2) at its bin. * * @param segment - Time-domain samples (length must be power of 2) * @param sampleRate - Sample rate in Hz * @param applyWindow - Whether to apply Hanning window (default true) - * @returns PowerSpectrum with frequencies and magnitudes in dB + * @returns PowerSpectrum with frequencies and power magnitudes in dB */ export function computeSegmentSpectrum( segment: Float64Array, @@ -40,8 +69,18 @@ export function computeSegmentSpectrum( throw new Error(`FFT size must be a power of 2, got ${N}`); } + // Detrend: remove mean so DC/low-frequency drift doesn't leak across bins + let mean = 0; + for (let i = 0; i < N; i++) mean += segment[i]; + mean /= N; + const detrended = new Float64Array(N); + for (let i = 0; i < N; i++) detrended[i] = segment[i] - mean; + // Apply window - const windowed = applyWindow ? applyHanningWindow(segment) : segment; + const windowed = applyWindow ? applyHanningWindow(detrended) : detrended; + + // Coherent window gain for power normalization (rectangular gain = N) + const windowSum = applyWindow ? hanningWindowSum(N) : N; // Run real FFT const fft = new FFT(N); @@ -49,22 +88,26 @@ export function computeSegmentSpectrum( fft.realTransform(out, windowed); fft.completeSpectrum(out); - // Compute magnitudes for positive frequencies (0 to N/2 inclusive) + // Compute one-sided PSD for positive frequencies (0 to N/2 inclusive) const numBins = N / 2 + 1; const frequencies = new Float64Array(numBins); const magnitudes = new Float64Array(numBins); const freqResolution = sampleRate / N; + const norm = windowSum * windowSum; for (let i = 0; i < numBins; i++) { frequencies[i] = i * freqResolution; - // Complex magnitude: sqrt(re^2 + im^2) const re = out[2 * i]; const im = out[2 * i + 1]; - const mag = Math.sqrt(re * re + im * im) / N; + const rawPower = re * re + im * im; + + // One-sided: double all bins except DC and Nyquist + const scale = i === 0 || i === N / 2 ? 1 : 2; + const power = (scale * rawPower) / norm; // Convert to dB (with floor to avoid -Infinity) - magnitudes[i] = mag > 1e-12 ? 20 * Math.log10(mag) : -240; + magnitudes[i] = power > POWER_FLOOR ? 10 * Math.log10(power) : DB_SENTINEL; } return { frequencies, magnitudes }; @@ -73,8 +116,8 @@ export function computeSegmentSpectrum( /** * Compute the power spectral density using Welch's method. * - * Splits the signal into overlapping windows, computes FFT on each, - * and averages the magnitude spectra. This reduces variance in the estimate. + * Splits the signal into overlapping windows, computes the PSD of each, + * and averages in the power domain. This reduces variance in the estimate. * * @param signal - Full time-domain signal * @param sampleRate - Sample rate in Hz @@ -92,27 +135,19 @@ export function computePowerSpectrum( if (smallerSize < 16) { throw new Error(`Signal too short for FFT: ${signal.length} samples`); } - return computeSegmentSpectrum( - signal.subarray(0, smallerSize), - sampleRate, - true - ); + return computeSegmentSpectrum(signal.subarray(0, smallerSize), sampleRate, true); } const step = Math.floor(windowSize * (1 - FFT_OVERLAP)); const numWindows = Math.floor((signal.length - windowSize) / step) + 1; if (numWindows <= 0) { - return computeSegmentSpectrum( - signal.subarray(0, windowSize), - sampleRate, - true - ); + return computeSegmentSpectrum(signal.subarray(0, windowSize), sampleRate, true); } // Accumulate spectra const numBins = windowSize / 2 + 1; - const avgMagnitudes = new Float64Array(numBins); // in linear scale for averaging + const avgPower = new Float64Array(numBins); // linear power for averaging let frequencies: Float64Array | null = null; for (let w = 0; w < numWindows; w++) { @@ -124,17 +159,17 @@ export function computePowerSpectrum( frequencies = spectrum.frequencies; } - // Average in linear power domain (convert dB back to linear for averaging) + // Average in the linear power domain (convert dB back to power) for (let i = 0; i < numBins; i++) { - avgMagnitudes[i] += Math.pow(10, spectrum.magnitudes[i] / 20); + avgPower[i] += Math.pow(10, spectrum.magnitudes[i] / 10); } } - // Convert averaged linear magnitudes back to dB + // Convert averaged power back to dB const magnitudes = new Float64Array(numBins); for (let i = 0; i < numBins; i++) { - const avg = avgMagnitudes[i] / numWindows; - magnitudes[i] = avg > 1e-12 ? 20 * Math.log10(avg) : -240; + const avg = avgPower[i] / numWindows; + magnitudes[i] = avg > POWER_FLOOR ? 10 * Math.log10(avg) : DB_SENTINEL; } return { frequencies: frequencies!, magnitudes }; diff --git a/src/main/analysis/FilterRecommender.test.ts b/src/main/analysis/FilterRecommender.test.ts index 20aad43..1fba5dd 100644 --- a/src/main/analysis/FilterRecommender.test.ts +++ b/src/main/analysis/FilterRecommender.test.ts @@ -46,9 +46,9 @@ function makeNoiseProfile(opts: { yawPeaks?: NoisePeak[]; }): NoiseProfile { return { - roll: makeAxisProfile(opts.rollFloor ?? -50, opts.rollPeaks), - pitch: makeAxisProfile(opts.pitchFloor ?? -50, opts.pitchPeaks), - yaw: makeAxisProfile(opts.yawFloor ?? -50, opts.yawPeaks), + roll: makeAxisProfile(opts.rollFloor ?? -40, opts.rollPeaks), + pitch: makeAxisProfile(opts.pitchFloor ?? -40, opts.pitchPeaks), + yaw: makeAxisProfile(opts.yawFloor ?? -40, opts.yawPeaks), overallLevel: opts.level, }; } @@ -63,8 +63,8 @@ describe('computeNoiseBasedTarget', () => { }); it('should interpolate linearly for mid-range noise', () => { - // Midpoint: (-10 + -70) / 2 = -40 → (75 + 300) / 2 = 187.5 → 188 - const target = computeNoiseBasedTarget(-40, 75, 300); + // Midpoint: (0 + -60) / 2 = -30 → (75 + 300) / 2 = 187.5 → 188 + const target = computeNoiseBasedTarget(-30, 75, 300); expect(target).toBe(188); }); @@ -79,7 +79,7 @@ describe('computeNoiseBasedTarget', () => { describe('recommend', () => { it('should recommend noise-based targets for high noise', () => { - const noise = makeNoiseProfile({ level: 'high', rollFloor: -25, pitchFloor: -20 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -15, pitchFloor: -10 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 250, @@ -98,7 +98,7 @@ describe('recommend', () => { }); it('should recommend noise-based targets for low noise', () => { - const noise = makeNoiseProfile({ level: 'low', rollFloor: -65, pitchFloor: -60 }); + const noise = makeNoiseProfile({ level: 'low', rollFloor: -55, pitchFloor: -50 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 150, @@ -119,7 +119,7 @@ describe('recommend', () => { it('should not recommend changes for medium noise when settings are close to target', () => { // Noise floors (-50 dB) produce target ~225 Hz for gyro, ~157 Hz for dterm // Set current values within 20 Hz deadzone of targets - const noise = makeNoiseProfile({ level: 'medium', rollFloor: -50, pitchFloor: -50 }); + const noise = makeNoiseProfile({ level: 'medium', rollFloor: -40, pitchFloor: -40 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 225, // Exactly at target @@ -136,7 +136,7 @@ describe('recommend', () => { it('should recommend changes for medium noise when settings are far from target', () => { // Noise floor -50 dB produces target ~225 Hz for gyro, ~157 Hz for dterm // Current settings are far off → should recommend with low confidence - const noise = makeNoiseProfile({ level: 'medium', rollFloor: -50, pitchFloor: -50 }); + const noise = makeNoiseProfile({ level: 'medium', rollFloor: -40, pitchFloor: -40 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 100, // Far below target (~225) @@ -154,7 +154,7 @@ describe('recommend', () => { it('should respect minimum safety bounds', () => { // Very noisy noise floor → target will be at min - const noise = makeNoiseProfile({ level: 'high', rollFloor: -5, pitchFloor: -5 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: 5, pitchFloor: 5 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: GYRO_LPF1_MIN_HZ, @@ -171,7 +171,7 @@ describe('recommend', () => { it('should respect maximum safety bounds', () => { // Very clean noise floor → target will be at max - const noise = makeNoiseProfile({ level: 'low', rollFloor: -75, pitchFloor: -75 }); + const noise = makeNoiseProfile({ level: 'low', rollFloor: -65, pitchFloor: -65 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: GYRO_LPF1_MAX_HZ, @@ -189,7 +189,7 @@ describe('recommend', () => { it('should not recommend when target is within deadzone of current', () => { // Noise floor that produces a target close to the current setting // Target for gyro with floor -50: t = (-50 - (-10)) / (-60) = 0.667, target = 75 + 0.667 * 225 = 225 - const noise = makeNoiseProfile({ level: 'high', rollFloor: -50, pitchFloor: -50 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -40, pitchFloor: -40 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 225, // Exactly at target @@ -356,7 +356,7 @@ describe('recommend', () => { }); it('should skip gyro LPF noise-floor adjustment when gyro_lpf1 is disabled (0)', () => { - const noise = makeNoiseProfile({ level: 'high', rollFloor: -25, pitchFloor: -25 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -15, pitchFloor: -15 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 0, // Disabled (common with RPM filter) @@ -393,8 +393,8 @@ describe('recommend', () => { // High noise + resonance peak both want to lower gyro_lpf1 const noise = makeNoiseProfile({ level: 'high', - rollFloor: -25, - pitchFloor: -25, + rollFloor: -15, + pitchFloor: -15, rollPeaks: [{ frequency: 180, amplitude: 15, type: 'frame_resonance' }], }); @@ -409,7 +409,7 @@ describe('recommend', () => { }); it('should provide beginner-friendly reason strings', () => { - const noise = makeNoiseProfile({ level: 'high', rollFloor: -25, pitchFloor: -25 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -15, pitchFloor: -15 }); const recs = recommend(noise, DEFAULT_FILTER_SETTINGS); for (const rec of recs) { @@ -421,7 +421,7 @@ describe('recommend', () => { }); it('should set appropriate impact values', () => { - const noise = makeNoiseProfile({ level: 'high', rollFloor: -25, pitchFloor: -25 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -15, pitchFloor: -15 }); const recs = recommend(noise, DEFAULT_FILTER_SETTINGS); for (const rec of recs) { @@ -430,7 +430,7 @@ describe('recommend', () => { }); it('should set appropriate confidence values', () => { - const noise = makeNoiseProfile({ level: 'high', rollFloor: -25, pitchFloor: -25 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -15, pitchFloor: -15 }); const recs = recommend(noise, DEFAULT_FILTER_SETTINGS); for (const rec of recs) { @@ -439,7 +439,7 @@ describe('recommend', () => { }); it('should converge: applying recommendations and re-running produces no further changes', () => { - const noise = makeNoiseProfile({ level: 'high', rollFloor: -25, pitchFloor: -20 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -15, pitchFloor: -10 }); const initial: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 250, @@ -474,7 +474,7 @@ describe('generateSummary', () => { }); it('should mention high noise level', () => { - const noise = makeNoiseProfile({ level: 'high', rollFloor: -25, pitchFloor: -25 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -15, pitchFloor: -15 }); const recs = recommend(noise, DEFAULT_FILTER_SETTINGS); const summary = generateSummary(noise, recs); expect(summary).toMatch(/vibration|noise/i); @@ -499,7 +499,7 @@ describe('generateSummary', () => { }); it('should state number of recommended changes', () => { - const noise = makeNoiseProfile({ level: 'high', rollFloor: -25, pitchFloor: -25 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -15, pitchFloor: -15 }); const recs = recommend(noise, DEFAULT_FILTER_SETTINGS); const summary = generateSummary(noise, recs); expect(summary).toMatch(/\d+ filter change/); @@ -528,7 +528,7 @@ describe('isRpmFilterActive', () => { describe('RPM-aware recommendations', () => { it('should use wider bounds (RPM max) for low noise with RPM active', () => { - const noise = makeNoiseProfile({ level: 'low', rollFloor: -75, pitchFloor: -75 }); + const noise = makeNoiseProfile({ level: 'low', rollFloor: -65, pitchFloor: -65 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: GYRO_LPF1_MAX_HZ, // At non-RPM max (300) @@ -601,7 +601,7 @@ describe('RPM-aware recommendations', () => { it('should produce unchanged behavior (regression) when RPM state is unknown', () => { // Without RPM fields, should behave exactly as before - const noise = makeNoiseProfile({ level: 'high', rollFloor: -25, pitchFloor: -20 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -15, pitchFloor: -10 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 250, @@ -616,7 +616,7 @@ describe('RPM-aware recommendations', () => { }); it('should include RPM note in reason strings when RPM active', () => { - const noise = makeNoiseProfile({ level: 'low', rollFloor: -65, pitchFloor: -60 }); + const noise = makeNoiseProfile({ level: 'low', rollFloor: -55, pitchFloor: -50 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 150, @@ -636,7 +636,7 @@ describe('propwash-aware filter floor', () => { // Noise floor -16 dB: noisy enough for a low target, but below bypass threshold (-15) // Raw target: 75 + ((-16 - (-10)) / (-60)) * 225 = 75 + (6/60)*225 = 75 + 22.5 = 97.5 → 98 Hz // 98 < PROPWASH_GYRO_LPF1_FLOOR_HZ (100) and -16 <= -15 → floor applied → 100 Hz - const noise = makeNoiseProfile({ level: 'high', rollFloor: -16, pitchFloor: -16 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -6, pitchFloor: -6 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 250, @@ -653,7 +653,7 @@ describe('propwash-aware filter floor', () => { // Noise floor -12 dB: extremely noisy, above bypass threshold (-15) // Raw target: 75 + ((-12 - (-10)) / (-60)) * 225 = 75 + (2/60)*225 = 75 + 7.5 = 82.5 → 83 Hz // -12 > -15 → bypass propwash floor → 83 Hz - const noise = makeNoiseProfile({ level: 'high', rollFloor: -12, pitchFloor: -12 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -2, pitchFloor: -2 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 250, @@ -670,7 +670,7 @@ describe('propwash-aware filter floor', () => { // Noise floor -25 dB: high but not extreme, target well above 100 Hz // Raw target: 75 + ((-25 - (-10)) / (-60)) * 225 = 75 + (15/60)*225 = 75 + 56.25 = 131 → 131 Hz // 131 >= 100 → propwash floor not triggered - const noise = makeNoiseProfile({ level: 'high', rollFloor: -25, pitchFloor: -25 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -15, pitchFloor: -15 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 250, @@ -686,7 +686,7 @@ describe('propwash-aware filter floor', () => { it('should not apply propwash floor to D-term LPF (only gyro)', () => { // Noise floor -16 dB triggers propwash floor for gyro // D-term target: 70 + (6/60)*130 = 70 + 13 = 83 Hz — should NOT be floored - const noise = makeNoiseProfile({ level: 'high', rollFloor: -16, pitchFloor: -16 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -6, pitchFloor: -6 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 250, @@ -721,7 +721,7 @@ describe('propwash-aware filter floor', () => { it('should remain convergent with propwash floor applied', () => { // First run: propwash floor clamps target to 100 Hz - const noise = makeNoiseProfile({ level: 'high', rollFloor: -16, pitchFloor: -16 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -6, pitchFloor: -6 }); const initial: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 250, @@ -747,7 +747,7 @@ describe('propwash-aware filter floor', () => { // Noise floor exactly at bypass threshold: -15 dB // -15 <= -15 → floor SHOULD apply (boundary is inclusive) // Raw target: 75 + (5/60)*225 = 75 + 18.75 = 93.75 → 94 Hz (below 100) - const noise = makeNoiseProfile({ level: 'high', rollFloor: -15, pitchFloor: -15 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -5, pitchFloor: -5 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 250, @@ -763,7 +763,7 @@ describe('propwash-aware filter floor', () => { describe('LPF2 recommendations', () => { it('should recommend disabling gyro LPF2 when RPM active and noise is very clean', () => { // Noise floor < -45 dB (GYRO_LPF2_DISABLE_THRESHOLD_DB), RPM active, LPF2 enabled - const noise = makeNoiseProfile({ level: 'low', rollFloor: -55, pitchFloor: -50 }); + const noise = makeNoiseProfile({ level: 'low', rollFloor: -45, pitchFloor: -40 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf2_static_hz: 250, @@ -779,7 +779,7 @@ describe('LPF2 recommendations', () => { it('should recommend disabling dterm LPF2 when RPM active and noise is very clean', () => { // Noise floor < -45 dB, RPM active, dterm LPF2 enabled - const noise = makeNoiseProfile({ level: 'low', rollFloor: -55, pitchFloor: -50 }); + const noise = makeNoiseProfile({ level: 'low', rollFloor: -45, pitchFloor: -40 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, dterm_lpf2_static_hz: 150, @@ -795,7 +795,7 @@ describe('LPF2 recommendations', () => { it('should NOT disable dterm LPF2 at exactly the -45 dB threshold (strict <)', () => { // DTERM_LPF2_DISABLE_THRESHOLD_DB = -45: worstFloor must be strictly below - const noise = makeNoiseProfile({ level: 'low', rollFloor: -45, pitchFloor: -50 }); + const noise = makeNoiseProfile({ level: 'low', rollFloor: -35, pitchFloor: -40 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, dterm_lpf2_static_hz: 150, @@ -811,7 +811,7 @@ describe('LPF2 recommendations', () => { it('should recommend enabling gyro LPF2 when noise is high and no RPM', () => { // overallLevel='high', RPM off, gyro_lpf2_static_hz=0 → recommend 250 - const noise = makeNoiseProfile({ level: 'high', rollFloor: -25, pitchFloor: -20 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -15, pitchFloor: -10 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf2_static_hz: 0, @@ -827,7 +827,7 @@ describe('LPF2 recommendations', () => { it('should NOT recommend LPF2 changes when noise is moderate', () => { // overallLevel='medium' → no LPF2 recs (neither disable nor enable path triggers) - const noise = makeNoiseProfile({ level: 'medium', rollFloor: -40, pitchFloor: -40 }); + const noise = makeNoiseProfile({ level: 'medium', rollFloor: -30, pitchFloor: -30 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf2_static_hz: 250, @@ -844,7 +844,7 @@ describe('LPF2 recommendations', () => { it('should NOT recommend LPF2 disable when RPM is inactive even with clean noise', () => { // Noise floor < -45 dB but RPM off → disable path requires RPM active - const noise = makeNoiseProfile({ level: 'low', rollFloor: -55, pitchFloor: -55 }); + const noise = makeNoiseProfile({ level: 'low', rollFloor: -45, pitchFloor: -45 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf2_static_hz: 250, @@ -904,7 +904,7 @@ describe('Conditional dynamic notch Q with resonance', () => { describe('ruleId assignment', () => { it('should assign F-NF-H-GYRO and F-NF-H-DTERM for high noise', () => { - const noise = makeNoiseProfile({ level: 'high', rollFloor: -25, pitchFloor: -20 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -15, pitchFloor: -10 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 250, @@ -916,7 +916,7 @@ describe('ruleId assignment', () => { }); it('should assign F-NF-L-GYRO and F-NF-L-DTERM for low noise', () => { - const noise = makeNoiseProfile({ level: 'low', rollFloor: -65, pitchFloor: -60 }); + const noise = makeNoiseProfile({ level: 'low', rollFloor: -55, pitchFloor: -50 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 150, @@ -928,7 +928,7 @@ describe('ruleId assignment', () => { }); it('should assign F-NF-M-GYRO and F-NF-M-DTERM for medium noise with far-off settings', () => { - const noise = makeNoiseProfile({ level: 'medium', rollFloor: -50, pitchFloor: -50 }); + const noise = makeNoiseProfile({ level: 'medium', rollFloor: -40, pitchFloor: -40 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_static_hz: 100, @@ -1043,7 +1043,7 @@ describe('ruleId assignment', () => { }); it('should assign F-LPF2-DIS-GYRO when disabling LPF2 with RPM + clean noise', () => { - const noise = makeNoiseProfile({ level: 'low', rollFloor: -55, pitchFloor: -50 }); + const noise = makeNoiseProfile({ level: 'low', rollFloor: -45, pitchFloor: -40 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf2_static_hz: 250, @@ -1056,7 +1056,7 @@ describe('ruleId assignment', () => { }); it('should assign F-LPF2-EN-GYRO when enabling LPF2 for high noise without RPM', () => { - const noise = makeNoiseProfile({ level: 'high', rollFloor: -25, pitchFloor: -20 }); + const noise = makeNoiseProfile({ level: 'high', rollFloor: -15, pitchFloor: -10 }); const current: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf2_static_hz: 0, @@ -1072,8 +1072,8 @@ describe('ruleId assignment', () => { // High noise + resonance peak both target gyro_lpf1 → deduplicated const noise = makeNoiseProfile({ level: 'high', - rollFloor: -25, - pitchFloor: -25, + rollFloor: -15, + pitchFloor: -15, rollPeaks: [{ frequency: 180, amplitude: 15, type: 'frame_resonance' }], }); const current: CurrentFilterSettings = { @@ -1401,7 +1401,7 @@ describe('dynamic lowpass ratio enforcement', () => { it('should maintain BF 2:1 ratio when gyroMaxHz clamps dyn_max', () => { // RPM-enabled quad with gyroMaxHz = 500. Target = 300 → dyn_max = 600 but clamped to 500. // Fix: dyn_min should be adjusted down to 250 so ratio is 500/250 = 2.0 - const noisy = makeNoiseProfile({ level: 'high', rollFloor: -14, pitchFloor: -14 }); + const noisy = makeNoiseProfile({ level: 'high', rollFloor: -4, pitchFloor: -4 }); const settings: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, gyro_lpf1_dyn_min_hz: 250, @@ -1421,7 +1421,7 @@ describe('dynamic lowpass ratio enforcement', () => { }); it('should maintain BF 2:1 ratio for D-term dynamic lowpass when clamped', () => { - const noisy = makeNoiseProfile({ level: 'high', rollFloor: -14, pitchFloor: -14 }); + const noisy = makeNoiseProfile({ level: 'high', rollFloor: -4, pitchFloor: -4 }); const settings: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, dterm_lpf1_dyn_min_hz: 100, @@ -1524,8 +1524,8 @@ describe('variability-aware hysteresis', () => { // Noise that would produce a small recommendation change const noise = makeNoiseProfile({ level: 'low', - rollFloor: -40, - pitchFloor: -40, + rollFloor: -30, + pitchFloor: -30, }); // Settings close to the computed target — within normal deadzone + variability bonus const settings: CurrentFilterSettings = { @@ -1565,8 +1565,8 @@ describe('variability-aware hysteresis', () => { // Even with max deadzone (5 + 15 = 20 Hz), 87 >> 20 → rec still produced. const noise = makeNoiseProfile({ level: 'high', - rollFloor: -20, - pitchFloor: -20, + rollFloor: -10, + pitchFloor: -10, }); const settings: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, @@ -1583,8 +1583,8 @@ describe('variability-aware hysteresis', () => { it('has no effect when noiseFloorStdDb is 0', () => { const noise = makeNoiseProfile({ level: 'high', - rollFloor: -20, - pitchFloor: -20, + rollFloor: -10, + pitchFloor: -10, }); const settings: CurrentFilterSettings = { ...DEFAULT_FILTER_SETTINGS, diff --git a/src/main/analysis/MechanicalHealthChecker.test.ts b/src/main/analysis/MechanicalHealthChecker.test.ts index 2e4c47d..ccb1869 100644 --- a/src/main/analysis/MechanicalHealthChecker.test.ts +++ b/src/main/analysis/MechanicalHealthChecker.test.ts @@ -86,20 +86,20 @@ describe('checkMechanicalHealth', () => { it('should detect extreme noise on roll axis', () => { const data = makeFlightData(); // Set both axes near extreme to avoid asymmetry false positive - const noise = makeNoiseProfile({ rollFloor: -15, pitchFloor: -18 }); + const noise = makeNoiseProfile({ rollFloor: -5, pitchFloor: -8 }); const result = checkMechanicalHealth(data, noise); expect(result.status).toBe('critical'); const noiseIssues = result.issues.filter((i) => i.type === 'extreme_noise'); expect(noiseIssues.length).toBeGreaterThanOrEqual(1); expect(noiseIssues[0].affectedAxis).toBe('roll'); - expect(noiseIssues[0].measuredValue).toBe(-15); + expect(noiseIssues[0].measuredValue).toBe(-5); expect(noiseIssues[0].threshold).toBe(EXTREME_NOISE_FLOOR_DB); }); it('should detect extreme noise on multiple axes', () => { const data = makeFlightData(); - const noise = makeNoiseProfile({ rollFloor: -10, pitchFloor: -5 }); + const noise = makeNoiseProfile({ rollFloor: 0, pitchFloor: 5 }); const result = checkMechanicalHealth(data, noise); expect(result.status).toBe('critical'); @@ -188,7 +188,7 @@ describe('checkMechanicalHealth', () => { (i) => 0.5 + Math.sin(i * 0.1) * 0.1, ], }); - const noise = makeNoiseProfile({ rollFloor: -10, pitchFloor: -42 }); + const noise = makeNoiseProfile({ rollFloor: 0, pitchFloor: -32 }); const result = checkMechanicalHealth(data, noise); expect(result.status).toBe('critical'); @@ -227,65 +227,65 @@ describe('checkMechanicalHealth', () => { }); describe('resolveExtremeNoiseThresholdDb (size-aware)', () => { - it('returns -20 fallback when size is undefined', () => { + it('returns -10 fallback when size is undefined', () => { expect(resolveExtremeNoiseThresholdDb(undefined)).toBe(EXTREME_NOISE_FLOOR_DB); - expect(resolveExtremeNoiseThresholdDb(undefined)).toBe(-20); + expect(resolveExtremeNoiseThresholdDb(undefined)).toBe(-10); }); - it('returns -10 for 1" whoops (NOISE_LEVEL_BY_SIZE highDb -15 + 5 margin)', () => { - expect(resolveExtremeNoiseThresholdDb('1"')).toBe(-15 + EXTREME_NOISE_MARGIN_DB); - expect(resolveExtremeNoiseThresholdDb('1"')).toBe(-10); + it('returns 0 for 1" whoops (NOISE_LEVEL_BY_SIZE highDb -5 + 5 margin)', () => { + expect(resolveExtremeNoiseThresholdDb('1"')).toBe(-5 + EXTREME_NOISE_MARGIN_DB); + expect(resolveExtremeNoiseThresholdDb('1"')).toBe(0); }); - it('returns -15 for 2.5" micros', () => { - expect(resolveExtremeNoiseThresholdDb('2.5"')).toBe(-15); + it('returns -5 for 2.5" micros', () => { + expect(resolveExtremeNoiseThresholdDb('2.5"')).toBe(-5); }); - it('never relaxes below the -20 dB 5" baseline for larger quads', () => { - // 5": max(-20, -30+5) = -20; 7": max(-20, -35+5) = -20 - expect(resolveExtremeNoiseThresholdDb('5"')).toBe(-20); - expect(resolveExtremeNoiseThresholdDb('6"')).toBe(-20); - expect(resolveExtremeNoiseThresholdDb('7"')).toBe(-20); + it('never relaxes below the -10 dB 5" baseline for larger quads', () => { + // 5": max(-10, -20+5) = -10; 7": max(-10, -25+5) = -10 + expect(resolveExtremeNoiseThresholdDb('5"')).toBe(-10); + expect(resolveExtremeNoiseThresholdDb('6"')).toBe(-10); + expect(resolveExtremeNoiseThresholdDb('7"')).toBe(-10); }); }); describe('checkMechanicalHealth size-aware extreme noise', () => { - it('does NOT flag a -18 dB floor on a 1" whoop (healthy whoop noise level)', () => { + it('does NOT flag a -8 dB floor on a 1" whoop (healthy whoop noise level)', () => { const data = makeFlightData(); - const noise = makeNoiseProfile({ rollFloor: -18, pitchFloor: -18, yawFloor: -18 }); + const noise = makeNoiseProfile({ rollFloor: -8, pitchFloor: -8, yawFloor: -8 }); const result = checkMechanicalHealth(data, noise, '1"'); expect(result.issues.filter((i) => i.type === 'extreme_noise')).toHaveLength(0); expect(result.status).toBe('ok'); }); - it('DOES flag a -18 dB floor on a 5" quad (above -20 dB threshold)', () => { + it('DOES flag a -8 dB floor on a 5" quad (above -10 dB threshold)', () => { const data = makeFlightData(); - const noise = makeNoiseProfile({ rollFloor: -18, pitchFloor: -18, yawFloor: -18 }); + const noise = makeNoiseProfile({ rollFloor: -8, pitchFloor: -8, yawFloor: -8 }); const result = checkMechanicalHealth(data, noise, '5"'); const noiseIssues = result.issues.filter((i) => i.type === 'extreme_noise'); expect(noiseIssues).toHaveLength(3); expect(result.status).toBe('critical'); - expect(noiseIssues[0].threshold).toBe(-20); + expect(noiseIssues[0].threshold).toBe(-10); }); - it('falls back to the -20 dB threshold when size is undefined', () => { + it('falls back to the -10 dB threshold when size is undefined', () => { const data = makeFlightData(); - const noise = makeNoiseProfile({ rollFloor: -18, pitchFloor: -18, yawFloor: -18 }); + const noise = makeNoiseProfile({ rollFloor: -8, pitchFloor: -8, yawFloor: -8 }); const result = checkMechanicalHealth(data, noise); expect(result.issues.filter((i) => i.type === 'extreme_noise')).toHaveLength(3); expect(result.status).toBe('critical'); }); - it('still flags genuinely extreme noise (-5 dB) on a 1" whoop', () => { + it('still flags genuinely extreme noise (+5 dB) on a 1" whoop', () => { const data = makeFlightData(); - const noise = makeNoiseProfile({ rollFloor: -5, pitchFloor: -5, yawFloor: -5 }); + const noise = makeNoiseProfile({ rollFloor: 5, pitchFloor: 5, yawFloor: 5 }); const result = checkMechanicalHealth(data, noise, '1"'); const noiseIssues = result.issues.filter((i) => i.type === 'extreme_noise'); expect(noiseIssues).toHaveLength(3); - expect(noiseIssues[0].threshold).toBe(-10); + expect(noiseIssues[0].threshold).toBe(0); }); }); diff --git a/src/main/analysis/MechanicalHealthChecker.ts b/src/main/analysis/MechanicalHealthChecker.ts index 5effbe7..360cdf1 100644 --- a/src/main/analysis/MechanicalHealthChecker.ts +++ b/src/main/analysis/MechanicalHealthChecker.ts @@ -23,11 +23,11 @@ export type { HealthSeverity, MechanicalHealthIssue, MechanicalHealthResult }; // ---- Constants ---- /** Noise floor above this dB level indicates extreme noise (mechanical issue). - * Baseline for a 5" quad; smaller quads are naturally noisier, so the effective - * threshold is derived from NOISE_LEVEL_BY_SIZE (highDb + margin) when the - * drone size is known — a healthy whoop hovers around -20…-15 dB and must not - * be flagged as damaged hardware. */ -export const EXTREME_NOISE_FLOOR_DB = -20; + * v2 power-spectrum scale. Baseline for a 5" quad; smaller quads are naturally + * noisier, so the effective threshold is derived from NOISE_LEVEL_BY_SIZE + * (highDb + margin) when the drone size is known — a healthy whoop hovers + * around -10…-5 dB on this scale and must not be flagged as damaged hardware. */ +export const EXTREME_NOISE_FLOOR_DB = -10; /** Margin (dB) above the size's "high noise" classification threshold before * noise is considered a mechanical fault rather than just a dirty build. */ diff --git a/src/main/analysis/NoiseAnalyzer.test.ts b/src/main/analysis/NoiseAnalyzer.test.ts index 8bcb251..7d74712 100644 --- a/src/main/analysis/NoiseAnalyzer.test.ts +++ b/src/main/analysis/NoiseAnalyzer.test.ts @@ -292,68 +292,68 @@ describe('analyzeAxisNoise', () => { }); describe('categorizeNoiseLevel', () => { - it('should return "high" when noise floor >= -30 dB', () => { - const roll = makeAxisProfile(-20); - const pitch = makeAxisProfile(-25); - const yaw = makeAxisProfile(-15); + it('should return "high" when noise floor >= -20 dB', () => { + const roll = makeAxisProfile(-10); + const pitch = makeAxisProfile(-15); + const yaw = makeAxisProfile(-5); expect(categorizeNoiseLevel(roll, pitch, yaw)).toBe('high'); }); - it('should return "medium" when noise floor >= -50 dB and < -30 dB', () => { - const roll = makeAxisProfile(-40); - const pitch = makeAxisProfile(-45); - const yaw = makeAxisProfile(-10); // yaw ignored for level calc + it('should return "medium" when noise floor >= -40 dB and < -20 dB', () => { + const roll = makeAxisProfile(-30); + const pitch = makeAxisProfile(-35); + const yaw = makeAxisProfile(0); // yaw ignored for level calc expect(categorizeNoiseLevel(roll, pitch, yaw)).toBe('medium'); }); - it('should return "low" when noise floor < -50 dB', () => { - const roll = makeAxisProfile(-60); - const pitch = makeAxisProfile(-55); - const yaw = makeAxisProfile(-30); + it('should return "low" when noise floor < -40 dB', () => { + const roll = makeAxisProfile(-50); + const pitch = makeAxisProfile(-45); + const yaw = makeAxisProfile(-20); expect(categorizeNoiseLevel(roll, pitch, yaw)).toBe('low'); }); it('should use worst of roll/pitch (not yaw)', () => { - const roll = makeAxisProfile(-60); - const pitch = makeAxisProfile(-25); // High noise - const yaw = makeAxisProfile(-60); + const roll = makeAxisProfile(-50); + const pitch = makeAxisProfile(-15); // High noise + const yaw = makeAxisProfile(-50); expect(categorizeNoiseLevel(roll, pitch, yaw)).toBe('high'); }); it('should use size-aware thresholds for 4" quad', () => { - // -26 dB on 5" = HIGH (> -30), on 4" also HIGH (> -27) - // -28 dB on 5" = HIGH (> -30), but on 4" = MEDIUM (threshold is -27) - const roll = makeAxisProfile(-28); - const pitch = makeAxisProfile(-28); - const yaw = makeAxisProfile(-20); - expect(categorizeNoiseLevel(roll, pitch, yaw)).toBe('high'); // -28 > -30 → HIGH on 5" - expect(categorizeNoiseLevel(roll, pitch, yaw, '4"')).toBe('medium'); // -28 < -27 → MEDIUM on 4" + // -16 dB on 5" = HIGH (> -20), on 4" also HIGH (> -17) + // -18 dB on 5" = HIGH (> -20), but on 4" = MEDIUM (threshold is -17) + const roll = makeAxisProfile(-18); + const pitch = makeAxisProfile(-18); + const yaw = makeAxisProfile(-10); + expect(categorizeNoiseLevel(roll, pitch, yaw)).toBe('high'); // -18 > -20 → HIGH on 5" + expect(categorizeNoiseLevel(roll, pitch, yaw, '4"')).toBe('medium'); // -18 < -17 → MEDIUM on 4" }); it('should use size-aware thresholds for 7" quad', () => { - // -34 dB on 5" = MEDIUM, but on 7" = HIGH (threshold is -35) - const roll = makeAxisProfile(-34); - const pitch = makeAxisProfile(-34); - const yaw = makeAxisProfile(-30); + // -24 dB on 5" = MEDIUM, but on 7" = HIGH (threshold is -25) + const roll = makeAxisProfile(-24); + const pitch = makeAxisProfile(-24); + const yaw = makeAxisProfile(-20); expect(categorizeNoiseLevel(roll, pitch, yaw)).toBe('medium'); // 5" default - expect(categorizeNoiseLevel(roll, pitch, yaw, '7"')).toBe('high'); // 7" threshold -35 + expect(categorizeNoiseLevel(roll, pitch, yaw, '7"')).toBe('high'); // 7" threshold -25 }); it('should use size-aware thresholds for 1" whoop', () => { - // -18 dB on 5" = HIGH, but on 1" = MEDIUM (threshold is -15) - const roll = makeAxisProfile(-18); - const pitch = makeAxisProfile(-18); - const yaw = makeAxisProfile(-10); + // -8 dB on 5" = HIGH, but on 1" = MEDIUM (threshold is -5) + const roll = makeAxisProfile(-8); + const pitch = makeAxisProfile(-8); + const yaw = makeAxisProfile(0); expect(categorizeNoiseLevel(roll, pitch, yaw)).toBe('high'); // 5" default - expect(categorizeNoiseLevel(roll, pitch, yaw, '1"')).toBe('medium'); // 1" threshold -15 + expect(categorizeNoiseLevel(roll, pitch, yaw, '1"')).toBe('medium'); // 1" threshold -5 }); }); describe('buildNoiseProfile', () => { it('should combine axis profiles into a noise profile', () => { - const roll = makeAxisProfile(-40); - const pitch = makeAxisProfile(-45); - const yaw = makeAxisProfile(-35); + const roll = makeAxisProfile(-30); + const pitch = makeAxisProfile(-35); + const yaw = makeAxisProfile(-25); const profile = buildNoiseProfile(roll, pitch, yaw); expect(profile.roll).toBe(roll); @@ -363,28 +363,28 @@ describe('buildNoiseProfile', () => { }); it('should classify exactly-on-boundary noise as the higher tier (inclusive)', () => { - // -30 dB is exactly highDb for 5" → should be 'high' (inclusive >=) - const exactHigh = makeAxisProfile(-30); - const quiet = makeAxisProfile(-60); + // -20 dB is exactly highDb for 5" → should be 'high' (inclusive >=) + const exactHigh = makeAxisProfile(-20); + const quiet = makeAxisProfile(-50); expect(buildNoiseProfile(exactHigh, quiet, quiet).overallLevel).toBe('high'); - // -50 dB is exactly mediumDb for 5" → should be 'medium' (inclusive >=) - const exactMedium = makeAxisProfile(-50); + // -40 dB is exactly mediumDb for 5" → should be 'medium' (inclusive >=) + const exactMedium = makeAxisProfile(-40); expect(buildNoiseProfile(exactMedium, quiet, quiet).overallLevel).toBe('medium'); // Below mediumDb → 'low' - const low = makeAxisProfile(-51); + const low = makeAxisProfile(-41); expect(buildNoiseProfile(low, quiet, quiet).overallLevel).toBe('low'); }); it('should pass droneSize through to categorization', () => { - const roll = makeAxisProfile(-28); - const pitch = makeAxisProfile(-28); - const yaw = makeAxisProfile(-20); + const roll = makeAxisProfile(-18); + const pitch = makeAxisProfile(-18); + const yaw = makeAxisProfile(-10); const profile5 = buildNoiseProfile(roll, pitch, yaw); const profile4 = buildNoiseProfile(roll, pitch, yaw, '4"'); - expect(profile5.overallLevel).toBe('high'); // -28 >= -30 → HIGH on 5" - expect(profile4.overallLevel).toBe('medium'); // -28 < -27 → not high on 4", -28 >= -40 → MEDIUM + expect(profile5.overallLevel).toBe('high'); // -18 >= -20 → HIGH on 5" + expect(profile4.overallLevel).toBe('medium'); // -18 < -17 → not high on 4", -18 >= -30 → MEDIUM }); }); diff --git a/src/main/analysis/NoiseAnalyzer.ts b/src/main/analysis/NoiseAnalyzer.ts index 722dfee..e5585a0 100644 --- a/src/main/analysis/NoiseAnalyzer.ts +++ b/src/main/analysis/NoiseAnalyzer.ts @@ -232,17 +232,17 @@ export function averageSpectra(spectra: PowerSpectrum[]): PowerSpectrum { const numBins = spectra[0].frequencies.length; const avgMagnitudes = new Float64Array(numBins); - // Average in linear domain + // Average in the linear power domain (magnitudes are PSD dB) for (const s of spectra) { for (let i = 0; i < numBins; i++) { - avgMagnitudes[i] += Math.pow(10, s.magnitudes[i] / 20); + avgMagnitudes[i] += Math.pow(10, s.magnitudes[i] / 10); } } const magnitudes = new Float64Array(numBins); for (let i = 0; i < numBins; i++) { const avg = avgMagnitudes[i] / spectra.length; - magnitudes[i] = avg > 1e-12 ? 20 * Math.log10(avg) : -240; + magnitudes[i] = avg > 1e-24 ? 10 * Math.log10(avg) : -240; } return { frequencies: spectra[0].frequencies, magnitudes }; diff --git a/src/main/analysis/__fixtures__/golden/demo-filter-cycle0.json b/src/main/analysis/__fixtures__/golden/demo-filter-cycle0.json index c3659c8..81b67f2 100644 --- a/src/main/analysis/__fixtures__/golden/demo-filter-cycle0.json +++ b/src/main/analysis/__fixtures__/golden/demo-filter-cycle0.json @@ -1,81 +1,86 @@ { "overallLevel": "high", "roll": { - "noiseFloorDb": -18.8, + "noiseFloorDb": -8.8, "peaks": [ { "frequency": 160, - "amplitude": 37.6, + "amplitude": 36.6, "type": "frame_resonance" }, { "frequency": 320, - "amplitude": 29.6, + "amplitude": 28.5, "type": "motor_harmonic" }, { "frequency": 600, - "amplitude": 23.2, + "amplitude": 22.3, "type": "motor_harmonic" }, { "frequency": 45, - "amplitude": 15.6, + "amplitude": 18.6, "type": "motor_harmonic" }, { "frequency": 27, - "amplitude": 7.7, + "amplitude": 9.2, + "type": "unknown" + }, + { + "frequency": 51, + "amplitude": 7.3, "type": "unknown" } ] }, "pitch": { - "noiseFloorDb": -18.8, + "noiseFloorDb": -8.8, "peaks": [ { "frequency": 160, - "amplitude": 37.8, + "amplitude": 37, "type": "frame_resonance" }, { "frequency": 320, - "amplitude": 29.7, + "amplitude": 28.8, "type": "motor_harmonic" }, { "frequency": 600, - "amplitude": 23.4, + "amplitude": 22.1, "type": "motor_harmonic" }, { "frequency": 45, - "amplitude": 13.7, + "amplitude": 16.9, "type": "motor_harmonic" }, { "frequency": 27, - "amplitude": 7.7, + "amplitude": 9.4, "type": "unknown" } ] }, "yaw": { - "noiseFloorDb": -18.8, + "noiseFloorDb": -8.8, "peaks": [ { "frequency": 160, - "amplitude": 37.8, + "amplitude": 36.9, "type": "frame_resonance" }, { "frequency": 320, - "amplitude": 29.3, + "amplitude": 28.4, "type": "unknown" }, { "frequency": 600, - "amplitude": 23, + "amplitude": 21.9, "type": "electrical" } ] diff --git a/src/main/analysis/__fixtures__/golden/demo-filter-cycle2.json b/src/main/analysis/__fixtures__/golden/demo-filter-cycle2.json index 835fee2..4b923d7 100644 --- a/src/main/analysis/__fixtures__/golden/demo-filter-cycle2.json +++ b/src/main/analysis/__fixtures__/golden/demo-filter-cycle2.json @@ -1,86 +1,101 @@ { "overallLevel": "medium", "roll": { - "noiseFloorDb": -39.7, + "noiseFloorDb": -29.6, "peaks": [ { "frequency": 160, - "amplitude": 37.4, - "type": "frame_resonance" + "amplitude": 36.4, + "type": "motor_harmonic" }, { "frequency": 320, - "amplitude": 29.5, + "amplitude": 28.5, "type": "motor_harmonic" }, { - "frequency": 600, - "amplitude": 23, + "frequency": 45, + "amplitude": 23.6, "type": "motor_harmonic" }, { - "frequency": 45, - "amplitude": 21.4, + "frequency": 600, + "amplitude": 22.1, "type": "motor_harmonic" }, { "frequency": 27, - "amplitude": 11.2, + "amplitude": 11.1, "type": "unknown" + }, + { + "frequency": 54, + "amplitude": 7.5, + "type": "motor_harmonic" + }, + { + "frequency": 57, + "amplitude": 6.3, + "type": "motor_harmonic" } ] }, "pitch": { - "noiseFloorDb": -39.5, + "noiseFloorDb": -29.5, "peaks": [ { "frequency": 160, - "amplitude": 36.9, - "type": "frame_resonance" + "amplitude": 35.9, + "type": "motor_harmonic" }, { "frequency": 320, - "amplitude": 29.1, + "amplitude": 28.1, "type": "motor_harmonic" }, { "frequency": 600, - "amplitude": 23, + "amplitude": 21.9, "type": "motor_harmonic" }, { "frequency": 45, - "amplitude": 19.1, + "amplitude": 21.5, "type": "motor_harmonic" }, { "frequency": 27, - "amplitude": 10.2, + "amplitude": 11.2, "type": "unknown" + }, + { + "frequency": 55, + "amplitude": 7, + "type": "motor_harmonic" } ] }, "yaw": { - "noiseFloorDb": -39.7, + "noiseFloorDb": -29.6, "peaks": [ { "frequency": 160, - "amplitude": 37.5, + "amplitude": 36.6, "type": "frame_resonance" }, { "frequency": 320, - "amplitude": 29.2, + "amplitude": 28.1, "type": "motor_harmonic" }, { "frequency": 600, - "amplitude": 22.7, + "amplitude": 21.5, "type": "motor_harmonic" }, { - "frequency": 46, - "amplitude": 10.5, + "frequency": 45, + "amplitude": 12.1, "type": "motor_harmonic" } ] diff --git a/src/main/analysis/__fixtures__/golden/real-vx35-filter.json b/src/main/analysis/__fixtures__/golden/real-vx35-filter.json index eaa1711..e989e90 100644 --- a/src/main/analysis/__fixtures__/golden/real-vx35-filter.json +++ b/src/main/analysis/__fixtures__/golden/real-vx35-filter.json @@ -1,16 +1,28 @@ { "overallLevel": "medium", "roll": { - "noiseFloorDb": -36.9, - "peaks": [] + "noiseFloorDb": -26.3, + "peaks": [ + { + "frequency": 22, + "amplitude": 6.3, + "type": "unknown" + } + ] }, "pitch": { - "noiseFloorDb": -33, + "noiseFloorDb": -21.8, "peaks": [] }, "yaw": { - "noiseFloorDb": -33.4, - "peaks": [] + "noiseFloorDb": -22.9, + "peaks": [ + { + "frequency": 185, + "amplitude": 6.4, + "type": "frame_resonance" + } + ] }, "segmentsUsed": 5, "dataQuality": { diff --git a/src/main/analysis/constants.ts b/src/main/analysis/constants.ts index 2dea69f..bec277e 100644 --- a/src/main/analysis/constants.ts +++ b/src/main/analysis/constants.ts @@ -52,7 +52,20 @@ export const SWEEP_MAX_RESIDUAL = 0.15; // ---- Noise Analysis ---- -/** Peak detection: minimum prominence above local noise floor in dB */ +/** + * Spectrum scale version. v2 = calibrated one-sided power spectrum + * (detrended, Hanning, (Σw)² coherent-gain normalization, power-domain + * Welch averaging, dB = 10·log10). A sine of amplitude A reads + * 10·log10(A²/2). Absolute dB thresholds below are calibrated to this + * scale — they sit ≈10 dB above the legacy amplitude-averaged scale (v1), + * and ≈10 dB above the PIDToolBox amplitude-dB convention cited in older + * community sources. Stored metrics from v1 app versions are not directly + * comparable to v2 values. + */ +export const SPECTRUM_SCALE_VERSION = 2; + +/** Peak detection: minimum prominence above local noise floor in dB. + * Relative (peak vs floor) — identical meaning on the v1 and v2 scales. */ export const PEAK_PROMINENCE_DB = 6; /** Number of bins on each side for local noise floor estimation */ @@ -63,9 +76,10 @@ export const NOISE_FLOOR_PERCENTILE = 0.25; /** Noise level thresholds in dB (noise floor above these values). * These are the 5" defaults — use NOISE_LEVEL_BY_SIZE for size-aware classification. - * Source: PIDToolBox community standard (-30 dB for "clean" 5" build). */ -export const NOISE_LEVEL_HIGH_DB = -30; -export const NOISE_LEVEL_MEDIUM_DB = -50; + * Source: PIDToolBox community standard (-30 dB amplitude convention for a + * "clean" 5" build) shifted +10 dB to the v2 power-spectrum scale. */ +export const NOISE_LEVEL_HIGH_DB = -20; +export const NOISE_LEVEL_MEDIUM_DB = -40; // ---- Size-Aware Noise Classification ---- // Smaller quads with higher KV motors have inherently higher noise floors. @@ -84,13 +98,14 @@ export interface NoiseLevelThresholds { * Higher KV motors excite the gyro more → higher noise floor is "normal". */ export const NOISE_LEVEL_BY_SIZE: Record = { - '1"': { highDb: -15, mediumDb: -30 }, // Extreme KV (19000+), budget gyros - '2.5"': { highDb: -20, mediumDb: -35 }, // High KV (4500+) - '3"': { highDb: -25, mediumDb: -40 }, // High KV (3000-4500) - '4"': { highDb: -27, mediumDb: -40 }, // Medium-high KV (2500-3500) - '5"': { highDb: -30, mediumDb: -50 }, // PIDToolBox standard - '6"': { highDb: -33, mediumDb: -50 }, // Lower KV, larger props - '7"': { highDb: -35, mediumDb: -55 }, // Lowest KV, should be very clean + // v2 power-spectrum scale (legacy amplitude-scale values +10 dB) + '1"': { highDb: -5, mediumDb: -20 }, // Extreme KV (19000+), budget gyros + '2.5"': { highDb: -10, mediumDb: -25 }, // High KV (4500+) + '3"': { highDb: -15, mediumDb: -30 }, // High KV (3000-4500) + '4"': { highDb: -17, mediumDb: -30 }, // Medium-high KV (2500-3500) + '5"': { highDb: -20, mediumDb: -40 }, // PIDToolBox standard (+10 dB) + '6"': { highDb: -23, mediumDb: -40 }, // Lower KV, larger props + '7"': { highDb: -25, mediumDb: -45 }, // Lowest KV, should be very clean }; /** Fallback when drone size is unknown (= 5" standard) */ @@ -162,11 +177,13 @@ export const DYN_NOTCH_COUNT_WITHOUT_RPM = 3; /** Default dynamic notch Q without RPM filter */ export const DYN_NOTCH_Q_WITHOUT_RPM = 300; -/** dB level for extreme noise (maps to minimum cutoff in noise-based targeting) */ -export const NOISE_FLOOR_VERY_NOISY_DB = -10; +/** dB level for extreme noise (maps to minimum cutoff in noise-based targeting). + * v2 power-spectrum scale. */ +export const NOISE_FLOOR_VERY_NOISY_DB = 0; -/** dB level for very clean signal (maps to maximum cutoff in noise-based targeting) */ -export const NOISE_FLOOR_VERY_CLEAN_DB = -70; +/** dB level for very clean signal (maps to maximum cutoff in noise-based targeting). + * v2 power-spectrum scale. */ +export const NOISE_FLOOR_VERY_CLEAN_DB = -60; /** Minimum difference to recommend a noise-based filter change (Hz) */ export const NOISE_TARGET_DEADZONE_HZ = 5; @@ -254,9 +271,10 @@ export const RESONANCE_CUTOFF_MARGIN_HZ = 20; * Only applies to noise-floor-based recommendations, not resonance-based. */ export const PROPWASH_GYRO_LPF1_FLOOR_HZ = 100; -/** Noise floor threshold (dB) above which the propwash floor is bypassed. - * When noise is this severe, aggressive filtering takes priority over propwash handling. */ -export const PROPWASH_FLOOR_BYPASS_DB = -15; +/** Noise floor threshold (dB, v2 power-spectrum scale) above which the propwash floor + * is bypassed. When noise is this severe, aggressive filtering takes priority over + * propwash handling. */ +export const PROPWASH_FLOOR_BYPASS_DB = -5; // ---- Step Detection ---- @@ -457,11 +475,12 @@ export const BANDWIDTH_LOW_HZ_BY_STYLE: Record = { // ---- LPF2 Recommendation Constants ---- -/** Gyro LPF2 can be disabled when RPM filter is active and noise is this clean (dB) */ -export const GYRO_LPF2_DISABLE_THRESHOLD_DB = -45; +/** Gyro LPF2 can be disabled when RPM filter is active and noise is this clean + * (dB, v2 power-spectrum scale) */ +export const GYRO_LPF2_DISABLE_THRESHOLD_DB = -35; -/** D-term LPF2 can be disabled when noise is this clean (dB) */ -export const DTERM_LPF2_DISABLE_THRESHOLD_DB = -45; +/** D-term LPF2 can be disabled when noise is this clean (dB, v2 power-spectrum scale) */ +export const DTERM_LPF2_DISABLE_THRESHOLD_DB = -35; // ---- Prop Wash Detection ---- diff --git a/src/shared/utils/tuneQualityScore.test.ts b/src/shared/utils/tuneQualityScore.test.ts index 550ed7b..a7b0040 100644 --- a/src/shared/utils/tuneQualityScore.test.ts +++ b/src/shared/utils/tuneQualityScore.test.ts @@ -9,9 +9,9 @@ import type { const perfectFilter: FilterMetricsSummary = { noiseLevel: 'low', - roll: { noiseFloorDb: -60, peakCount: 0 }, - pitch: { noiseFloorDb: -60, peakCount: 0 }, - yaw: { noiseFloorDb: -60, peakCount: 0 }, + roll: { noiseFloorDb: -50, peakCount: 0 }, + pitch: { noiseFloorDb: -50, peakCount: 0 }, + yaw: { noiseFloorDb: -50, peakCount: 0 }, segmentsUsed: 5, summary: 'Perfect', }; @@ -49,9 +49,9 @@ const perfectPID: PIDMetricsSummary = { const worstFilter: FilterMetricsSummary = { noiseLevel: 'high', - roll: { noiseFloorDb: -20, peakCount: 5 }, - pitch: { noiseFloorDb: -20, peakCount: 5 }, - yaw: { noiseFloorDb: -20, peakCount: 5 }, + roll: { noiseFloorDb: -10, peakCount: 5 }, + pitch: { noiseFloorDb: -10, peakCount: 5 }, + yaw: { noiseFloorDb: -10, peakCount: 5 }, segmentsUsed: 1, summary: 'Terrible', }; @@ -117,9 +117,9 @@ describe('computeTuneQualityScore', () => { it('returns mid-range score for mid-range metrics', () => { const midFilter: FilterMetricsSummary = { ...perfectFilter, - roll: { noiseFloorDb: -40, peakCount: 2 }, - pitch: { noiseFloorDb: -40, peakCount: 2 }, - yaw: { noiseFloorDb: -40, peakCount: 2 }, + roll: { noiseFloorDb: -30, peakCount: 2 }, + pitch: { noiseFloorDb: -30, peakCount: 2 }, + yaw: { noiseFloorDb: -30, peakCount: 2 }, }; const midPID: PIDMetricsSummary = { ...perfectPID, @@ -191,9 +191,9 @@ describe('computeTuneQualityScore', () => { // With 4 components × 25 pts, we need exactly 20 per component → 80% const filter: FilterMetricsSummary = { ...perfectFilter, - roll: { noiseFloorDb: -52, peakCount: 0 }, // (52-20)/(60-20) = 0.8 → 20 pts - pitch: { noiseFloorDb: -52, peakCount: 0 }, - yaw: { noiseFloorDb: -52, peakCount: 0 }, + roll: { noiseFloorDb: -42, peakCount: 0 }, // (42-10)/(50-10) = 0.8 → 20 pts + pitch: { noiseFloorDb: -42, peakCount: 0 }, + yaw: { noiseFloorDb: -42, peakCount: 0 }, }; const pid: PIDMetricsSummary = { ...perfectPID, @@ -225,9 +225,9 @@ describe('computeTuneQualityScore', () => { it('tier boundary: 79 → good', () => { const filter: FilterMetricsSummary = { ...perfectFilter, - roll: { noiseFloorDb: -51, peakCount: 0 }, - pitch: { noiseFloorDb: -51, peakCount: 0 }, - yaw: { noiseFloorDb: -51, peakCount: 0 }, + roll: { noiseFloorDb: -41, peakCount: 0 }, + pitch: { noiseFloorDb: -41, peakCount: 0 }, + yaw: { noiseFloorDb: -41, peakCount: 0 }, }; const pid: PIDMetricsSummary = { ...perfectPID, @@ -261,9 +261,9 @@ describe('computeTuneQualityScore', () => { // Score ~60 const filter60: FilterMetricsSummary = { ...perfectFilter, - roll: { noiseFloorDb: -44, peakCount: 0 }, - pitch: { noiseFloorDb: -44, peakCount: 0 }, - yaw: { noiseFloorDb: -44, peakCount: 0 }, + roll: { noiseFloorDb: -34, peakCount: 0 }, + pitch: { noiseFloorDb: -34, peakCount: 0 }, + yaw: { noiseFloorDb: -34, peakCount: 0 }, }; const pid60: PIDMetricsSummary = { ...perfectPID, @@ -295,9 +295,9 @@ describe('computeTuneQualityScore', () => { it('clamps values beyond range (better than best)', () => { const superFilter: FilterMetricsSummary = { ...perfectFilter, - roll: { noiseFloorDb: -80, peakCount: 0 }, - pitch: { noiseFloorDb: -80, peakCount: 0 }, - yaw: { noiseFloorDb: -80, peakCount: 0 }, + roll: { noiseFloorDb: -70, peakCount: 0 }, + pitch: { noiseFloorDb: -70, peakCount: 0 }, + yaw: { noiseFloorDb: -70, peakCount: 0 }, }; const result = computeTuneQualityScore({ filterMetrics: superFilter, pidMetrics: perfectPID }); expect(result).not.toBeNull(); @@ -359,9 +359,9 @@ describe('computeTuneQualityScore', () => { it('tier boundary: 40/39', () => { const filter: FilterMetricsSummary = { ...perfectFilter, - roll: { noiseFloorDb: -36, peakCount: 0 }, - pitch: { noiseFloorDb: -36, peakCount: 0 }, - yaw: { noiseFloorDb: -36, peakCount: 0 }, + roll: { noiseFloorDb: -26, peakCount: 0 }, + pitch: { noiseFloorDb: -26, peakCount: 0 }, + yaw: { noiseFloorDb: -26, peakCount: 0 }, }; const pid: PIDMetricsSummary = { ...perfectPID, @@ -559,9 +559,9 @@ describe('computeTuneQualityScore', () => { }; const midFilter: FilterMetricsSummary = { ...perfectFilter, - roll: { noiseFloorDb: -40, peakCount: 2 }, - pitch: { noiseFloorDb: -40, peakCount: 2 }, - yaw: { noiseFloorDb: -40, peakCount: 2 }, + roll: { noiseFloorDb: -30, peakCount: 2 }, + pitch: { noiseFloorDb: -30, peakCount: 2 }, + yaw: { noiseFloorDb: -30, peakCount: 2 }, }; const result = computeTuneQualityScore({ filterMetrics: midFilter, @@ -570,7 +570,7 @@ describe('computeTuneQualityScore', () => { }); expect(result).not.toBeNull(); // 4 components × 25 pts each: - // NF: (-40-(-20))/(-60-(-20)) = 0.5 → round(0.5*25) = 13 + // NF: (-30-(-10))/(-50-(-10)) = 0.5 → round(0.5*25) = 13 // OS: (25-50)/(0-50) = 0.5 → round(0.5*25) = 13 // PM: (37.5-20)/(60-20) = 0.4375 → round(0.4375*25) = 11 // BW: (45-20)/(80-20) = 0.4167 → round(0.4167*25) = 10 @@ -635,23 +635,23 @@ describe('computeTuneQualityScore', () => { describe('verification metrics integration', () => { const noisyFilter: FilterMetricsSummary = { ...perfectFilter, - roll: { noiseFloorDb: -30, peakCount: 3 }, - pitch: { noiseFloorDb: -30, peakCount: 3 }, - yaw: { noiseFloorDb: -30, peakCount: 3 }, + roll: { noiseFloorDb: -20, peakCount: 3 }, + pitch: { noiseFloorDb: -20, peakCount: 3 }, + yaw: { noiseFloorDb: -20, peakCount: 3 }, }; const cleanVerification: FilterMetricsSummary = { ...perfectFilter, - roll: { noiseFloorDb: -55, peakCount: 0 }, - pitch: { noiseFloorDb: -55, peakCount: 0 }, - yaw: { noiseFloorDb: -55, peakCount: 0 }, + roll: { noiseFloorDb: -45, peakCount: 0 }, + pitch: { noiseFloorDb: -45, peakCount: 0 }, + yaw: { noiseFloorDb: -45, peakCount: 0 }, }; const degradedVerification: FilterMetricsSummary = { ...perfectFilter, - roll: { noiseFloorDb: -25, peakCount: 4 }, - pitch: { noiseFloorDb: -25, peakCount: 4 }, - yaw: { noiseFloorDb: -25, peakCount: 4 }, + roll: { noiseFloorDb: -15, peakCount: 4 }, + pitch: { noiseFloorDb: -15, peakCount: 4 }, + yaw: { noiseFloorDb: -15, peakCount: 4 }, }; it('uses verification noise floor instead of filter when available', () => { @@ -697,28 +697,28 @@ describe('computeTuneQualityScore', () => { }); it('rewards noise improvement in Noise Delta', () => { - // Filter flight: -30 dB, verification: -55 dB → 25 dB improvement + // Filter flight: -20 dB, verification: -45 dB → 25 dB improvement const result = computeTuneQualityScore({ filterMetrics: noisyFilter, pidMetrics: perfectPID, verificationMetrics: cleanVerification, }); const deltaComponent = result!.components.find((c) => c.label === 'Noise Delta')!; - // rawValue = verificationAvg - filterAvg = -55 - (-30) = -25 + // rawValue = verificationAvg - filterAvg = -45 - (-20) = -25 expect(deltaComponent.rawValue).toBeLessThan(0); // Should get full score (best = -10, -25 is even better → clamped to max) expect(deltaComponent.score).toBe(deltaComponent.maxPoints); }); it('penalizes noise regression in Noise Delta', () => { - // Filter flight: -55 dB, verification: -25 dB → 30 dB regression + // Filter flight: -45 dB, verification: -15 dB → 30 dB regression const result = computeTuneQualityScore({ filterMetrics: cleanVerification, pidMetrics: perfectPID, verificationMetrics: degradedVerification, }); const deltaComponent = result!.components.find((c) => c.label === 'Noise Delta')!; - // rawValue = -25 - (-55) = +30 dB regression + // rawValue = -15 - (-45) = +30 dB regression expect(deltaComponent.rawValue).toBeGreaterThan(0); // Should get zero score (worst = +5, +30 is way worse → clamped to 0) expect(deltaComponent.score).toBe(0); diff --git a/src/shared/utils/tuneQualityScore.ts b/src/shared/utils/tuneQualityScore.ts index 7342feb..944da91 100644 --- a/src/shared/utils/tuneQualityScore.ts +++ b/src/shared/utils/tuneQualityScore.ts @@ -99,8 +99,9 @@ const COMPONENTS: ComponentDef[] = [ if (!source) return undefined; return avgNoiseFloor(source); }, - best: -60, - worst: -20, + // v2 power-spectrum scale (legacy amplitude-scale anchors -60/-20 shifted +10 dB) + best: -50, + worst: -10, }, { label: 'Tracking RMS', From 7e95da436b7eb6ba06fe38bd0b0af899403f0feb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:35:39 +0000 Subject: [PATCH 04/13] feat: prop-wash severity baseline from clean segments (P1.7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Severity ratio now divides event band energy by a CLEAN baseline — the 20-90 Hz band energy of contiguous runs outside every drop + post-drop window, each run FFT'd separately (no concatenation artifacts) and length-weighted. The previous whole-flight baseline included the prop-wash oscillation itself, compressing severity on aggressive flights and saturating the ratio when oscillation dominated total energy (strong and weak oscillation scored identically). Falls back to the whole flight when no clean run is at least 1024 samples. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJhhk2frYDDnpNbSHRxGdv --- src/main/analysis/PropWashDetector.test.ts | 67 ++++++++++++++- src/main/analysis/PropWashDetector.ts | 95 +++++++++++++++++++--- 2 files changed, 150 insertions(+), 12 deletions(-) diff --git a/src/main/analysis/PropWashDetector.test.ts b/src/main/analysis/PropWashDetector.test.ts index 2036acf..06689bf 100644 --- a/src/main/analysis/PropWashDetector.test.ts +++ b/src/main/analysis/PropWashDetector.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { detectThrottleDrops, analyzePropWash } from './PropWashDetector'; +import { detectThrottleDrops, analyzePropWash, computeCleanRuns } from './PropWashDetector'; import type { BlackboxFlightData, TimeSeries } from '@shared/types/blackbox.types'; const SAMPLE_RATE = 4000; @@ -401,3 +401,68 @@ describe('PropWashDetector', () => { }); }); }); + +describe('computeCleanRuns', () => { + it('returns the whole flight as one run when there are no drops', () => { + expect(computeCleanRuns(10000, [], 1600)).toEqual([{ start: 0, end: 10000 }]); + }); + + it('excludes drop + post-drop window from clean runs', () => { + const runs = computeCleanRuns(10000, [{ startIndex: 4000, endIndex: 4200 }], 1600); + expect(runs).toEqual([ + { start: 0, end: 4000 }, + { start: 5800, end: 10000 }, + ]); + }); + + it('merges overlapping exclusion ranges', () => { + const runs = computeCleanRuns( + 20000, + [ + { startIndex: 4000, endIndex: 4200 }, + { startIndex: 5000, endIndex: 5200 }, // post-window of first overlaps + ], + 1600 + ); + expect(runs).toEqual([ + { start: 0, end: 4000 }, + { start: 6800, end: 20000 }, + ]); + }); + + it('drops runs shorter than the minimum FFT length', () => { + const runs = computeCleanRuns(3000, [{ startIndex: 500, endIndex: 2900 }], 1600); + // Leading run 0-500 too short, trailing run fully consumed by window + expect(runs).toEqual([]); + }); + + it('clean baseline discriminates strong vs weak oscillation (severity not saturated)', () => { + const numSamples = 20000; + const throttleFn = (i: number) => { + const t = i / SAMPLE_RATE; + if (t >= 1.0 && t < 1.05) return 0.7 - ((t - 1.0) / 0.05) * 0.5; + if (t >= 1.05 && t < 2.0) return 0.2; + if (t >= 3.0 && t < 3.05) return 0.7 - ((t - 3.0) / 0.05) * 0.5; + if (t >= 3.05 && t < 4.0) return 0.2; + return 0.7; + }; + // Deterministic background (30 Hz in-band tone) + post-drop oscillation + const makeGyro = (oscAmp: number) => (i: number) => { + const t = i / SAMPLE_RATE; + let v = 0.5 * Math.sin(2 * Math.PI * 30 * t); + if ((t >= 1.05 && t < 1.45) || (t >= 3.05 && t < 3.45)) { + v += oscAmp * Math.sin(2 * Math.PI * 50 * t); + } + return v; + }; + const strong = analyzePropWash( + createFlightData({ numSamples, throttleFn, gyroFn: makeGyro(100) }) + ); + const weak = analyzePropWash(createFlightData({ numSamples, throttleFn, gyroFn: makeGyro(2) })); + expect(strong).toBeDefined(); + expect(weak).toBeDefined(); + // With a clean baseline the ratio scales with oscillation energy — + // the whole-flight baseline used to saturate both at the same value. + expect(strong!.meanSeverity).toBeGreaterThan(10 * weak!.meanSeverity); + }); +}); diff --git a/src/main/analysis/PropWashDetector.ts b/src/main/analysis/PropWashDetector.ts index 53d9d6e..8e2eb49 100644 --- a/src/main/analysis/PropWashDetector.ts +++ b/src/main/analysis/PropWashDetector.ts @@ -10,7 +10,11 @@ * 1. Scan throttle derivative for sustained drops * 2. Extract gyro data in post-event window * 3. Compute PSD in prop wash band per axis - * 4. Score severity against baseline noise floor + * 4. Score severity against a CLEAN baseline — band energy of the flight + * excluding drop + post-drop windows. Using the whole flight as baseline + * would include the prop-wash oscillation itself, compressing severity + * ratios on aggressive flights (many drops) and saturating the ratio when + * oscillation dominates total energy. */ import type { BlackboxFlightData } from '@shared/types/blackbox.types'; import type { PropWashEvent, PropWashAnalysis } from '@shared/types/analysis.types'; @@ -125,20 +129,88 @@ function bandEnergy( return energy; } +/** Minimum contiguous clean-run length (samples) usable for baseline FFT */ +const BASELINE_MIN_RUN_SAMPLES = 1024; + /** - * Compute baseline noise floor energy in the prop wash band. - * Uses the entire flight's gyro data as baseline. + * Compute prop-wash-band energy of a single contiguous gyro slice. */ -function computeBaselineEnergy(gyroValues: Float64Array, sampleRate: number): number { - if (gyroValues.length < 256) return 1; // Prevent division by zero - const spectrum = computePowerSpectrum(gyroValues, sampleRate); +function sliceBandEnergy(gyroSlice: Float64Array, sampleRate: number): number { + const spectrum = computePowerSpectrum(gyroSlice, sampleRate); const trimmed = trimSpectrum(spectrum, PROPWASH_FREQ_MIN_HZ, PROPWASH_FREQ_MAX_HZ); - const energy = bandEnergy( + return bandEnergy( trimmed.frequencies, trimmed.magnitudes, PROPWASH_FREQ_MIN_HZ, PROPWASH_FREQ_MAX_HZ ); +} + +/** + * Build contiguous clean runs of the flight — samples outside every + * [drop start, drop end + post-drop analysis window] exclusion range. + */ +export function computeCleanRuns( + totalSamples: number, + drops: Array<{ startIndex: number; endIndex: number }>, + windowSamples: number +): Array<{ start: number; end: number }> { + // Merge exclusion ranges (drops are ordered by construction) + const exclusions: Array<{ start: number; end: number }> = []; + for (const drop of drops) { + const start = drop.startIndex; + const end = Math.min(drop.endIndex + windowSamples, totalSamples); + const last = exclusions[exclusions.length - 1]; + if (last && start <= last.end) { + last.end = Math.max(last.end, end); + } else { + exclusions.push({ start, end }); + } + } + + const runs: Array<{ start: number; end: number }> = []; + let cursor = 0; + for (const ex of exclusions) { + if (ex.start - cursor >= BASELINE_MIN_RUN_SAMPLES) { + runs.push({ start: cursor, end: ex.start }); + } + cursor = Math.max(cursor, ex.end); + } + if (totalSamples - cursor >= BASELINE_MIN_RUN_SAMPLES) { + runs.push({ start: cursor, end: totalSamples }); + } + return runs; +} + +/** + * Compute baseline noise energy in the prop wash band from clean runs. + * + * Each contiguous clean run is FFT'd separately (concatenating + * non-contiguous samples would create spectral edge artifacts) and the + * band energies are averaged weighted by run length. Falls back to the + * whole flight when no clean run is long enough. + */ +function computeBaselineEnergy( + gyroValues: Float64Array, + sampleRate: number, + cleanRuns: Array<{ start: number; end: number }> +): number { + if (gyroValues.length < 256) return 1; // Prevent division by zero + + let energy: number; + if (cleanRuns.length === 0) { + // No clean segment long enough — fall back to whole-flight baseline + energy = sliceBandEnergy(gyroValues, sampleRate); + } else { + let weighted = 0; + let totalLen = 0; + for (const run of cleanRuns) { + const len = run.end - run.start; + weighted += sliceBandEnergy(gyroValues.subarray(run.start, run.end), sampleRate) * len; + totalLen += len; + } + energy = weighted / totalLen; + } return Math.max(energy, 1e-10); // Prevent division by zero } @@ -174,11 +246,12 @@ export function analyzePropWash(flightData: BlackboxFlightData): PropWashAnalysi const drops = detectThrottleDrops(throttle.values, throttle.time, sampleRate); if (drops.length === 0) return undefined; - // Step 2: Compute baseline energy per axis (whole flight) + // Step 2: Compute baseline energy per axis from clean (non-event) runs + const cleanRuns = computeCleanRuns(flightData.gyro[0].values.length, drops, windowSamples); const baselineEnergy: [number, number, number] = [ - computeBaselineEnergy(flightData.gyro[0].values, sampleRate), - computeBaselineEnergy(flightData.gyro[1].values, sampleRate), - computeBaselineEnergy(flightData.gyro[2].values, sampleRate), + computeBaselineEnergy(flightData.gyro[0].values, sampleRate, cleanRuns), + computeBaselineEnergy(flightData.gyro[1].values, sampleRate, cleanRuns), + computeBaselineEnergy(flightData.gyro[2].values, sampleRate, cleanRuns), ]; // Step 3: Analyze each event From 3420aba07095bb43be647fe5c3605864330440d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:35:40 +0000 Subject: [PATCH 05/13] feat: robust peak detection + size-aware frame-resonance bands (P1.2, P1.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Peak detection in NoiseAnalyzer now handles flat-topped peaks (plateau runs count once, at their center), enforces a 15 Hz minimum spacing so a broad resonance hump reports as one peak instead of several adjacent bins, and refines peak frequency/magnitude with 3-point parabolic interpolation for sub-bin accuracy. Frame-resonance classification is size-aware via FRAME_RESONANCE_BY_SIZE (1"/2.5": 150-350 Hz ... 7": 60-150 Hz) instead of a fixed 80-200 Hz band — a micro frame resonating at 300 Hz is no longer misclassified as electrical noise. droneSize threads from FilterAnalyzer options through analyzeAxisNoise to classifyPeak; 5" band remains the fallback. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJhhk2frYDDnpNbSHRxGdv --- docs/PID_TUNING_KNOWLEDGE.md | 2 +- src/main/analysis/CLAUDE.md | 4 +- src/main/analysis/FilterAnalyzer.ts | 12 +-- src/main/analysis/NoiseAnalyzer.test.ts | 76 +++++++++++++ src/main/analysis/NoiseAnalyzer.ts | 101 ++++++++++++++---- .../golden/demo-filter-cycle0.json | 38 +++---- .../golden/demo-filter-cycle2.json | 54 ++++------ src/main/analysis/constants.ts | 31 +++++- 8 files changed, 236 insertions(+), 82 deletions(-) diff --git a/docs/PID_TUNING_KNOWLEDGE.md b/docs/PID_TUNING_KNOWLEDGE.md index 8ea2c9f..32a11c9 100644 --- a/docs/PID_TUNING_KNOWLEDGE.md +++ b/docs/PID_TUNING_KNOWLEDGE.md @@ -304,7 +304,7 @@ Second harmonic (2nd value) typically lower (30-50) — less energy in 2nd harmo | Source | Frequency | Characteristics | Fix Strategy | |--------|-----------|----------------|--------------| | **Prop wash** | 20-90 Hz | Broadband burst during descents/deceleration | D-term, flying technique, I-term relax | -| **Frame resonance** | 80-200 Hz | Fixed frequency, constant at all throttle levels | Notch filter, structural reinforcement | +| **Frame resonance** | 80-200 Hz (5"; size-aware via `FRAME_RESONANCE_BY_SIZE`: 1"/2.5" 150-350, 3" 120-280, 4" 100-240, 6" 70-170, 7" 60-150) | Fixed frequency, constant at all throttle levels | Notch filter, structural reinforcement | | **Motor noise** | 150-400 Hz | Tracks with throttle (RPM), harmonic pattern | RPM filter, lowpass cutoff | | **Electrical noise** | >500 Hz | High frequency, ESC switching noise | Lowpass filter, capacitors | | **Bearing noise** | Variable | Broadband, worsens with bearing wear | Replace bearings/motors | diff --git a/src/main/analysis/CLAUDE.md b/src/main/analysis/CLAUDE.md index 2cc004d..68510e0 100644 --- a/src/main/analysis/CLAUDE.md +++ b/src/main/analysis/CLAUDE.md @@ -8,8 +8,8 @@ Noise analysis, step response, transfer function, and data quality scoring modul - **SegmentSelector**: Finds stable hover segments and throttle sweep segments (excludes takeoff/landing/acro) - **FFTCompute**: detrended + Hanning window, Welch's method (50% overlap, power-domain averaging), calibrated one-sided power spectrum (`SPECTRUM_SCALE_VERSION = 2`: sine of amplitude A reads 10·log10(A²/2); dB values sit ≈10 dB above the legacy v1 amplitude-averaged scale) -- **NoiseAnalyzer**: Noise floor estimation, peak detection (prominence-based), source classification (frame resonance 80-200 Hz, motor harmonics, electrical >500 Hz) -- **FilterRecommender**: Absolute noise-based target computation (convergent), safety bounds, propwash-aware gyro LPF1 floor (100 Hz min, bypass at -15 dB extreme noise), beginner-friendly explanations. Medium noise handling (conditional LPF2 recommendations, incl. `DTERM_LPF2_DISABLE_THRESHOLD_DB` for the D-term disable rule), notch-aware resonance (notch counts as covering a peak only when `dyn_notch_count > 0`), conditional dynamic notch Q based on noise severity, size-aware dyn_notch_count target (2 sub-5", 1 for 5"+, max step 2/iteration). Dynamic-lowpass-aware: when `dyn_min_hz > 0`, all noise-floor and resonance rules target `dyn_min_hz`/`dyn_max_hz` instead of `static_hz`, proportionally adjusting max to maintain ratio. Exports `isGyroDynamicActive()`, `isDtermDynamicActive()` +- **NoiseAnalyzer**: Noise floor estimation, peak detection (prominence-based), source classification (frame resonance via size-aware `FRAME_RESONANCE_BY_SIZE` bands — 5" default 80-200 Hz, micros up to 350 Hz; motor harmonics; electrical >500 Hz). Peak detection: prominence-based with plateau handling, 15 Hz minimum spacing (`PEAK_MIN_SPACING_HZ`), parabolic sub-bin interpolation +- **FilterRecommender**: Absolute noise-based target computation (convergent), safety bounds, propwash-aware gyro LPF1 floor (100 Hz min, bypass at -5 dB extreme noise on the v2 scale), beginner-friendly explanations. Medium noise handling (conditional LPF2 recommendations, incl. `DTERM_LPF2_DISABLE_THRESHOLD_DB` for the D-term disable rule), notch-aware resonance (notch counts as covering a peak only when `dyn_notch_count > 0`), conditional dynamic notch Q based on noise severity, size-aware dyn_notch_count target (2 sub-5", 1 for 5"+, max step 2/iteration). Dynamic-lowpass-aware: when `dyn_min_hz > 0`, all noise-floor and resonance rules target `dyn_min_hz`/`dyn_max_hz` instead of `static_hz`, proportionally adjusting max to maintain ratio. Exports `isGyroDynamicActive()`, `isDtermDynamicActive()` - **ThrottleSpectrogramAnalyzer**: Bins gyro data by throttle level (10 bands), per-band FFT spectra and noise floors. Returns `ThrottleSpectrogramResult` - **GroupDelayEstimator**: Per-filter group delay estimation (PT1, biquad, notch). All lowpasses (LPF1 + LPF2) modeled as PT1 — the BF 4.3+ default (modeling LPF2 as biquad would overestimate its delay ~2×). Notch delay uses the denominator-only formula `τ(ω) = bw·(w0²+ω²) / ((w0²−ω²)² + bw²ω²)` (the numerator is purely real, contributing no phase slope). Returns `FilterGroupDelay` with gyroTotalMs, dtermTotalMs, warning if >2ms. Smart `dyn_notch_q` handling: `Q > 10 ? Q / 100 : Q` for BF internal storage quirk. Uses `dyn_min_hz` when dynamic lowpass is active (worst-case delay at tightest cutoff point) - **DynamicLowpassRecommender**: Analyzes throttle spectrogram for throttle-dependent noise (enable trigger: ≥6 dB increase, Pearson ≥0.6, ≥3 throttle bands with data). When dynamic is NOT active and throttle noise detected: recommends enabling dynamic lowpass (dyn_min = current static cutoff, dyn_max = static × 2 per BF 2:1 convention). When dynamic IS already active: returns no recommendations (FilterRecommender handles tuning dyn_min/max directly). When dynamic IS active but NO throttle-dependent noise: recommends disabling (dyn_min → 0) with low confidence — only when the delta is below `DYNAMIC_LOWPASS_DISABLE_DB = 4` (hysteresis: 4–6 dB gray zone leaves config untouched, preventing enable/disable flip-flop). Rules: F-DLPF-GYRO, F-DLPF-DTERM (enable), F-DLPF-GYRO-OFF, F-DLPF-DTERM-OFF (disable) diff --git a/src/main/analysis/FilterAnalyzer.ts b/src/main/analysis/FilterAnalyzer.ts index d1e53c9..218e6d4 100644 --- a/src/main/analysis/FilterAnalyzer.ts +++ b/src/main/analysis/FilterAnalyzer.ts @@ -138,9 +138,9 @@ export async function analyze( // Step 3: Noise analysis onProgress?.({ step: 'analyzing', percent: 65 }); - const rollNoise = analyzeAxisNoise(rollSpectra); - const pitchNoise = analyzeAxisNoise(pitchSpectra); - const yawNoise = analyzeAxisNoise(yawSpectra); + const rollNoise = analyzeAxisNoise(rollSpectra, options?.droneSize); + const pitchNoise = analyzeAxisNoise(pitchSpectra, options?.droneSize); + const yawNoise = analyzeAxisNoise(yawSpectra, options?.droneSize); const noiseProfile = buildNoiseProfile(rollNoise, pitchNoise, yawNoise, options?.droneSize); await yieldToEventLoop(); @@ -247,9 +247,9 @@ async function analyzeEntireFlight( await yieldToEventLoop(); onProgress?.({ step: 'analyzing', percent: 65 }); - const rollNoise = analyzeAxisNoise(spectraByAxis[0]); - const pitchNoise = analyzeAxisNoise(spectraByAxis[1]); - const yawNoise = analyzeAxisNoise(spectraByAxis[2]); + const rollNoise = analyzeAxisNoise(spectraByAxis[0], options?.droneSize); + const pitchNoise = analyzeAxisNoise(spectraByAxis[1], options?.droneSize); + const yawNoise = analyzeAxisNoise(spectraByAxis[2], options?.droneSize); const noiseProfile = buildNoiseProfile(rollNoise, pitchNoise, yawNoise, options?.droneSize); // Compute throttle spectrogram diff --git a/src/main/analysis/NoiseAnalyzer.test.ts b/src/main/analysis/NoiseAnalyzer.test.ts index 7d74712..c270dca 100644 --- a/src/main/analysis/NoiseAnalyzer.test.ts +++ b/src/main/analysis/NoiseAnalyzer.test.ts @@ -194,6 +194,64 @@ describe('detectPeaks', () => { }; expect(detectPeaks(spectrum).length).toBe(0); }); + + it('should detect a flat-topped (plateau) peak once, at its center', () => { + const spectrum = createSpectrum({ numBins: 512, freqResolution: 2, baselineDb: -60 }); + // Plateau: bins 98-102 all at -40 (frequency 196-204 Hz, center 200 Hz) + for (let b = 98; b <= 102; b++) spectrum.magnitudes[b] = -40; + + const peaks = detectPeaks(spectrum); + const near200 = peaks.filter((p) => Math.abs(p.frequency - 200) < 10); + expect(near200.length).toBe(1); + expect(near200[0].frequency).toBeCloseTo(200, 0); + expect(near200[0].amplitude).toBeCloseTo(20, 0); + }); + + it('should suppress weaker candidates within the minimum spacing', () => { + const spectrum = createSpectrum({ numBins: 512, freqResolution: 2, baselineDb: -60 }); + // Broad hump: strong peak at 200 Hz plus a weaker shoulder 6 Hz away + spectrum.magnitudes[100] = -30; // 200 Hz + spectrum.magnitudes[99] = -38; + spectrum.magnitudes[101] = -38; + spectrum.magnitudes[103] = -36; // 206 Hz shoulder (local max) + spectrum.magnitudes[102] = -42; + spectrum.magnitudes[104] = -42; + + const peaks = detectPeaks(spectrum); + const near = peaks.filter((p) => Math.abs(p.frequency - 203) < 12); + expect(near.length).toBe(1); + expect(near[0].frequency).toBeCloseTo(200, 0); + }); + + it('should keep separate peaks farther apart than the minimum spacing', () => { + const spectrum = createSpectrum({ + numBins: 512, + freqResolution: 2, + baselineDb: -60, + peaks: [ + { freqHz: 200, amplitudeDb: 20 }, + { freqHz: 220, amplitudeDb: 15 }, // 20 Hz away — beyond 15 Hz spacing + ], + }); + + const peaks = detectPeaks(spectrum); + expect(peaks.some((p) => Math.abs(p.frequency - 200) < 5)).toBe(true); + expect(peaks.some((p) => Math.abs(p.frequency - 220) < 5)).toBe(true); + }); + + it('should interpolate sub-bin peak frequency (parabolic)', () => { + // Asymmetric neighbors → true peak sits between bins, toward the higher side + const spectrum = createSpectrum({ numBins: 512, freqResolution: 2, baselineDb: -60 }); + spectrum.magnitudes[99] = -45; // 198 Hz + spectrum.magnitudes[100] = -30; // 200 Hz (max bin) + spectrum.magnitudes[101] = -35; // 202 Hz (higher than 198 → peak shifted right) + + const peaks = detectPeaks(spectrum); + const peak = peaks.find((p) => Math.abs(p.frequency - 200) < 4)!; + expect(peak).toBeDefined(); + expect(peak.frequency).toBeGreaterThan(200); + expect(peak.frequency).toBeLessThan(201); + }); }); describe('classifyPeak', () => { @@ -220,6 +278,24 @@ describe('classifyPeak', () => { const allPeaks = [{ frequency: 350 }]; expect(classifyPeak(350, allPeaks)).toBe('unknown'); }); + + it('should use size-aware frame resonance bands', () => { + // 300 Hz: outside the 5" band (80-200) but inside the 2.5" band (150-350) + const allPeaks = [{ frequency: 300 }]; + expect(classifyPeak(300, allPeaks)).toBe('unknown'); // 5" fallback + expect(classifyPeak(300, allPeaks, '2.5"')).toBe('frame_resonance'); + expect(classifyPeak(300, allPeaks, '1"')).toBe('frame_resonance'); + + // 65 Hz: below the 5" band but inside the 7" band (60-150) + const lowPeaks = [{ frequency: 65 }]; + expect(classifyPeak(65, lowPeaks)).toBe('unknown'); + expect(classifyPeak(65, lowPeaks, '7"')).toBe('frame_resonance'); + + // 190 Hz: inside 5" band but above the 7" band (60-150) + const midPeaks = [{ frequency: 190 }]; + expect(classifyPeak(190, midPeaks)).toBe('frame_resonance'); + expect(classifyPeak(190, midPeaks, '7"')).toBe('unknown'); + }); }); describe('averageSpectra', () => { diff --git a/src/main/analysis/NoiseAnalyzer.ts b/src/main/analysis/NoiseAnalyzer.ts index e5585a0..6607151 100644 --- a/src/main/analysis/NoiseAnalyzer.ts +++ b/src/main/analysis/NoiseAnalyzer.ts @@ -13,12 +13,14 @@ import type { import type { DroneSize } from '@shared/types/profile.types'; import { PEAK_PROMINENCE_DB, + PEAK_MIN_SPACING_HZ, PEAK_LOCAL_WINDOW_BINS, NOISE_FLOOR_PERCENTILE, NOISE_LEVEL_BY_SIZE, NOISE_LEVEL_DEFAULT, FRAME_RESONANCE_MIN_HZ, FRAME_RESONANCE_MAX_HZ, + FRAME_RESONANCE_BY_SIZE, ELECTRICAL_NOISE_MIN_HZ, MOTOR_HARMONIC_TOLERANCE_RATIO, MOTOR_HARMONIC_TOLERANCE_MIN_HZ, @@ -78,57 +80,107 @@ export function localNoiseFloor( /** * Detect peaks in a power spectrum using prominence-based detection. * - * A peak is a local maximum where its magnitude exceeds the local noise - * floor by more than the prominence threshold. + * A peak is a local maximum (with plateau support — a run of equal bins + * counts once, at its center) whose magnitude exceeds the local noise + * floor by more than the prominence threshold. Peak frequency and + * magnitude are refined by 3-point parabolic interpolation (sub-bin + * accuracy), and weaker candidates within `minSpacingHz` of a stronger + * peak are suppressed so a broad hump reports as one peak. */ export function detectPeaks( spectrum: PowerSpectrum, - prominenceDb: number = PEAK_PROMINENCE_DB + prominenceDb: number = PEAK_PROMINENCE_DB, + minSpacingHz: number = PEAK_MIN_SPACING_HZ ): Array<{ frequency: number; amplitude: number; binIndex: number }> { const { frequencies, magnitudes } = spectrum; if (magnitudes.length < 3) return []; - const peaks: Array<{ frequency: number; amplitude: number; binIndex: number }> = []; + const candidates: Array<{ frequency: number; amplitude: number; binIndex: number }> = []; - for (let i = 1; i < magnitudes.length - 1; i++) { - // Local maximum check - if (magnitudes[i] <= magnitudes[i - 1] || magnitudes[i] <= magnitudes[i + 1]) { + let i = 1; + while (i < magnitudes.length - 1) { + // Skip while ascending or flat-from-below + if (magnitudes[i] < magnitudes[i - 1]) { + i++; continue; } - // Check prominence above local noise floor - const localFloor = localNoiseFloor(magnitudes, i); - const prominence = magnitudes[i] - localFloor; + // Extend across a plateau of equal values + let plateauEnd = i; + while (plateauEnd + 1 < magnitudes.length && magnitudes[plateauEnd + 1] === magnitudes[i]) { + plateauEnd++; + } + + const isLeftRising = magnitudes[i] > magnitudes[i - 1]; + const isRightFalling = + plateauEnd + 1 < magnitudes.length && magnitudes[plateauEnd] > magnitudes[plateauEnd + 1]; + + if (isLeftRising && isRightFalling) { + // Peak candidate at the plateau center + const center = Math.floor((i + plateauEnd) / 2); + const localFloor = localNoiseFloor(magnitudes, center); + + // Parabolic interpolation for sub-bin frequency/magnitude + // (single-bin peaks only — a flat top has no curvature to fit) + let peakFreq = frequencies[center]; + let peakMag = magnitudes[center]; + if (plateauEnd === i && center > 0 && center < magnitudes.length - 1) { + const mPrev = magnitudes[center - 1]; + const mCur = magnitudes[center]; + const mNext = magnitudes[center + 1]; + const denom = mPrev - 2 * mCur + mNext; + if (denom < 0) { + const delta = Math.max(-0.5, Math.min(0.5, (0.5 * (mPrev - mNext)) / denom)); + const binWidth = frequencies[1] - frequencies[0]; + peakFreq = frequencies[center] + delta * binWidth; + peakMag = mCur - 0.25 * (mPrev - mNext) * delta; + } + } - if (prominence >= prominenceDb) { - peaks.push({ - frequency: frequencies[i], - amplitude: prominence, - binIndex: i, - }); + const prominence = peakMag - localFloor; + if (prominence >= prominenceDb) { + candidates.push({ frequency: peakFreq, amplitude: prominence, binIndex: center }); + } } + + i = plateauEnd + 1; } - // Sort by amplitude (strongest first) - peaks.sort((a, b) => b.amplitude - a.amplitude); + // Sort by amplitude (strongest first), then enforce minimum spacing: + // a weaker candidate too close to an already-accepted peak is dropped. + candidates.sort((a, b) => b.amplitude - a.amplitude); + + const peaks: Array<{ frequency: number; amplitude: number; binIndex: number }> = []; + for (const c of candidates) { + if (peaks.every((p) => Math.abs(p.frequency - c.frequency) >= minSpacingHz)) { + peaks.push(c); + } + } return peaks; } /** * Classify a noise peak based on its frequency. + * + * @param droneSize - Selects the size-aware frame-resonance band; smaller + * frames resonate at higher frequencies (falls back to the 5" band). */ export function classifyPeak( frequency: number, - allPeaks: Array<{ frequency: number }> + allPeaks: Array<{ frequency: number }>, + droneSize?: DroneSize ): NoisePeak['type'] { // Check for motor harmonics: equally-spaced peaks if (isMotorHarmonic(frequency, allPeaks)) { return 'motor_harmonic'; } - // Frame resonance band - if (frequency >= FRAME_RESONANCE_MIN_HZ && frequency <= FRAME_RESONANCE_MAX_HZ) { + // Frame resonance band (size-aware) + const band = droneSize + ? FRAME_RESONANCE_BY_SIZE[droneSize] + : { min: FRAME_RESONANCE_MIN_HZ, max: FRAME_RESONANCE_MAX_HZ }; + if (frequency >= band.min && frequency <= band.max) { return 'frame_resonance'; } @@ -191,7 +243,10 @@ function isMotorHarmonic(frequency: number, allPeaks: Array<{ frequency: number * When multiple spectra are provided (from different segments), they are * averaged for a more robust noise estimate. */ -export function analyzeAxisNoise(spectra: PowerSpectrum[]): AxisNoiseProfile { +export function analyzeAxisNoise( + spectra: PowerSpectrum[], + droneSize?: DroneSize +): AxisNoiseProfile { if (spectra.length === 0) { return { spectrum: { frequencies: new Float64Array(0), magnitudes: new Float64Array(0) }, @@ -213,7 +268,7 @@ export function analyzeAxisNoise(spectra: PowerSpectrum[]): AxisNoiseProfile { const peaks: NoisePeak[] = rawPeaks.map((p) => ({ frequency: p.frequency, amplitude: p.amplitude, - type: classifyPeak(p.frequency, rawPeaks), + type: classifyPeak(p.frequency, rawPeaks, droneSize), })); return { diff --git a/src/main/analysis/__fixtures__/golden/demo-filter-cycle0.json b/src/main/analysis/__fixtures__/golden/demo-filter-cycle0.json index 81b67f2..e060024 100644 --- a/src/main/analysis/__fixtures__/golden/demo-filter-cycle0.json +++ b/src/main/analysis/__fixtures__/golden/demo-filter-cycle0.json @@ -5,32 +5,27 @@ "peaks": [ { "frequency": 160, - "amplitude": 36.6, + "amplitude": 36.8, "type": "frame_resonance" }, { "frequency": 320, - "amplitude": 28.5, + "amplitude": 29.2, "type": "motor_harmonic" }, { "frequency": 600, - "amplitude": 22.3, + "amplitude": 23.4, "type": "motor_harmonic" }, { "frequency": 45, - "amplitude": 18.6, + "amplitude": 18.7, "type": "motor_harmonic" }, { - "frequency": 27, - "amplitude": 9.2, - "type": "unknown" - }, - { - "frequency": 51, - "amplitude": 7.3, + "frequency": 28, + "amplitude": 9.3, "type": "unknown" } ] @@ -40,17 +35,17 @@ "peaks": [ { "frequency": 160, - "amplitude": 37, + "amplitude": 37.1, "type": "frame_resonance" }, { "frequency": 320, - "amplitude": 28.8, + "amplitude": 29.5, "type": "motor_harmonic" }, { "frequency": 600, - "amplitude": 22.1, + "amplitude": 23.2, "type": "motor_harmonic" }, { @@ -59,7 +54,7 @@ "type": "motor_harmonic" }, { - "frequency": 27, + "frequency": 28, "amplitude": 9.4, "type": "unknown" } @@ -70,17 +65,17 @@ "peaks": [ { "frequency": 160, - "amplitude": 36.9, + "amplitude": 37.1, "type": "frame_resonance" }, { "frequency": 320, - "amplitude": 28.4, + "amplitude": 29, "type": "unknown" }, { "frequency": 600, - "amplitude": 21.9, + "amplitude": 23, "type": "electrical" } ] @@ -103,6 +98,13 @@ "ruleId": "F-RES-DTERM", "confidence": "high" }, + { + "setting": "dyn_notch_max_hz", + "currentValue": 600, + "recommendedValue": 620, + "ruleId": "F-DN-MAX", + "confidence": "medium" + }, { "setting": "dyn_notch_min_hz", "currentValue": 100, diff --git a/src/main/analysis/__fixtures__/golden/demo-filter-cycle2.json b/src/main/analysis/__fixtures__/golden/demo-filter-cycle2.json index 4b923d7..0af3171 100644 --- a/src/main/analysis/__fixtures__/golden/demo-filter-cycle2.json +++ b/src/main/analysis/__fixtures__/golden/demo-filter-cycle2.json @@ -5,12 +5,12 @@ "peaks": [ { "frequency": 160, - "amplitude": 36.4, - "type": "motor_harmonic" + "amplitude": 36.6, + "type": "frame_resonance" }, { "frequency": 320, - "amplitude": 28.5, + "amplitude": 29.2, "type": "motor_harmonic" }, { @@ -20,23 +20,13 @@ }, { "frequency": 600, - "amplitude": 22.1, + "amplitude": 23.1, "type": "motor_harmonic" }, { - "frequency": 27, - "amplitude": 11.1, + "frequency": 28, + "amplitude": 11.2, "type": "unknown" - }, - { - "frequency": 54, - "amplitude": 7.5, - "type": "motor_harmonic" - }, - { - "frequency": 57, - "amplitude": 6.3, - "type": "motor_harmonic" } ] }, @@ -45,17 +35,17 @@ "peaks": [ { "frequency": 160, - "amplitude": 35.9, - "type": "motor_harmonic" + "amplitude": 36.1, + "type": "frame_resonance" }, { "frequency": 320, - "amplitude": 28.1, + "amplitude": 28.9, "type": "motor_harmonic" }, { "frequency": 600, - "amplitude": 21.9, + "amplitude": 22.9, "type": "motor_harmonic" }, { @@ -64,14 +54,9 @@ "type": "motor_harmonic" }, { - "frequency": 27, - "amplitude": 11.2, + "frequency": 28, + "amplitude": 11.4, "type": "unknown" - }, - { - "frequency": 55, - "amplitude": 7, - "type": "motor_harmonic" } ] }, @@ -80,22 +65,22 @@ "peaks": [ { "frequency": 160, - "amplitude": 36.6, + "amplitude": 36.8, "type": "frame_resonance" }, { "frequency": 320, - "amplitude": 28.1, + "amplitude": 28.8, "type": "motor_harmonic" }, { "frequency": 600, - "amplitude": 21.5, + "amplitude": 22.6, "type": "motor_harmonic" }, { "frequency": 45, - "amplitude": 12.1, + "amplitude": 12.2, "type": "motor_harmonic" } ] @@ -118,6 +103,13 @@ "ruleId": "F-RES-DTERM", "confidence": "high" }, + { + "setting": "dyn_notch_max_hz", + "currentValue": 600, + "recommendedValue": 620, + "ruleId": "F-DN-MAX", + "confidence": "medium" + }, { "setting": "dyn_notch_min_hz", "currentValue": 100, diff --git a/src/main/analysis/constants.ts b/src/main/analysis/constants.ts index bec277e..1fc891c 100644 --- a/src/main/analysis/constants.ts +++ b/src/main/analysis/constants.ts @@ -68,6 +68,12 @@ export const SPECTRUM_SCALE_VERSION = 2; * Relative (peak vs floor) — identical meaning on the v1 and v2 scales. */ export const PEAK_PROMINENCE_DB = 6; +/** Peak detection: minimum spacing between reported peaks (Hz). + * A broad resonance hump spans several bins — without spacing enforcement + * it registers as multiple adjacent "peaks". Weaker candidates within this + * distance of a stronger peak are suppressed. */ +export const PEAK_MIN_SPACING_HZ = 15; + /** Number of bins on each side for local noise floor estimation */ export const PEAK_LOCAL_WINDOW_BINS = 50; @@ -113,10 +119,33 @@ export const NOISE_LEVEL_DEFAULT: NoiseLevelThresholds = NOISE_LEVEL_BY_SIZE['5" // ---- Peak Classification Frequency Bands ---- -/** Frame resonance: typically 80-200 Hz */ +/** Frame resonance band for a 5" quad: typically 80-200 Hz. + * Fallback when drone size is unknown — use FRAME_RESONANCE_BY_SIZE otherwise. */ export const FRAME_RESONANCE_MIN_HZ = 80; export const FRAME_RESONANCE_MAX_HZ = 200; +/** Frame resonance band bounds per drone size (Hz). */ +export interface FrameResonanceBand { + min: number; + max: number; +} + +/** + * Size-aware frame resonance bands. Smaller/stiffer/lighter frames resonate + * at higher frequencies than the classic 5" 80-200 Hz band — a 2.5" frame + * resonating at 300 Hz must not be classified as electrical noise. + * House extrapolation anchored on the 5" community convention. + */ +export const FRAME_RESONANCE_BY_SIZE: Record = { + '1"': { min: 150, max: 350 }, + '2.5"': { min: 150, max: 350 }, + '3"': { min: 120, max: 280 }, + '4"': { min: 100, max: 240 }, + '5"': { min: 80, max: 200 }, + '6"': { min: 70, max: 170 }, + '7"': { min: 60, max: 150 }, +}; + /** Electrical noise: typically above 500 Hz */ export const ELECTRICAL_NOISE_MIN_HZ = 500; From 5f068d314c30f1413a3b03e779714f6abcf201ac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:35:41 +0000 Subject: [PATCH 06/13] feat: setpoint-gyro coherence computation, quality wiring, TF rule gate (P1.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TransferFunctionEstimator now computes magnitude-squared coherence γ²(f) = |S_xy|²/(S_xx·S_yy) per axis (the cross/auto spectra were already accumulated; S_yy is new) and summarizes it as coherenceMean over the 1-30 Hz stick-input band. Coherence is omitted when only one Welch window fits (trivially 1). Wiring that was dead until now comes alive: PIDAnalyzer passes coherenceMean into scoreWienerDataQuality, so the axis-coverage sub-score uses real data instead of a constant 50 and low_coherence warnings fire. New TF_COHERENCE_GATE (0.5): TF-1..TF-4 gain recommendations are skipped for any axis whose transfer function is not coherence-trustworthy — verified on the real VX3.5 log where a spurious TF-2 yaw P cut is now correctly suppressed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJhhk2frYDDnpNbSHRxGdv --- docs/PID_TUNING_KNOWLEDGE.md | 2 +- src/main/analysis/CLAUDE.md | 2 +- src/main/analysis/PIDAnalyzer.ts | 13 +++++ src/main/analysis/PIDRecommender.test.ts | 39 +++++++++++++ src/main/analysis/PIDRecommender.ts | 13 +++++ .../TransferFunctionEstimator.test.ts | 55 +++++++++++++++++++ .../analysis/TransferFunctionEstimator.ts | 52 +++++++++++++++++- .../golden/demo-flash-cycle0.json | 33 +---------- .../__fixtures__/golden/real-vx35-tf.json | 47 +++------------- src/main/analysis/goldenOutputs.test.ts | 2 +- 10 files changed, 180 insertions(+), 78 deletions(-) diff --git a/docs/PID_TUNING_KNOWLEDGE.md b/docs/PID_TUNING_KNOWLEDGE.md index 32a11c9..4b9000b 100644 --- a/docs/PID_TUNING_KNOWLEDGE.md +++ b/docs/PID_TUNING_KNOWLEDGE.md @@ -835,7 +835,7 @@ Scored before generating filter recommendations: **Warnings**: `short_hover_time` (<5s), `low_logging_rate` (<2kHz), `low_step_magnitude` (RMS <10 deg/s), `low_coherence` (per-axis coherence ≤0.3 — severity: <0.15 warning, 0.15-0.3 info) -> **Implementation note**: coherence is an optional input — the Wiener estimator does not currently compute S_yy, so `coherenceMean` is absent and the axis-coverage sub-score falls back to a neutral 50. Coherence-based scoring activates only if/when the estimator provides it. +> **Implementation note**: the Wiener estimator computes magnitude-squared coherence γ²(f) = |S_xy|²/(S_xx·S_yy) per axis and reports `coherenceMean` averaged over the 1-30 Hz stick-input band (requires ≥2 Welch windows — coherence over a single window is trivially 1 and is omitted). Coherence feeds (a) the axis-coverage sub-score and `low_coherence` warnings above, and (b) a **TF rule gate**: TF-1..TF-4 gain recommendations are skipped for any axis with `coherenceMean < 0.5` (`TF_COHERENCE_GATE`) — a low-coherence transfer function reflects noise/disturbance, not commanded motion, and must not drive gain changes. ### Quality Tiers & Confidence Adjustment diff --git a/src/main/analysis/CLAUDE.md b/src/main/analysis/CLAUDE.md index 68510e0..2d85412 100644 --- a/src/main/analysis/CLAUDE.md +++ b/src/main/analysis/CLAUDE.md @@ -45,7 +45,7 @@ Noise analysis, step response, transfer function, and data quality scoring modul **Pipeline**: TransferFunctionEstimator (setpoint → gyro deconvolution → H(f) = S_xy(f) / S_xx(f)) -- **TransferFunctionEstimator**: Cross-spectral density estimation, bandwidth/phase margin extraction, `dcGainDb` field for I-term approximation (computed as the 1–5 Hz band average, not the unreliable bin 0; the -3 dB bandwidth reference uses the same band), PID recommendations based on frequency response characteristics +- **TransferFunctionEstimator**: Cross-spectral density estimation, bandwidth/phase margin extraction, `dcGainDb` field for I-term approximation (computed as the 1–5 Hz band average, not the unreliable bin 0; the -3 dB bandwidth reference uses the same band), PID recommendations based on frequency response characteristics. Computes **magnitude-squared coherence** γ²(f) per axis (`BodeResult.coherence`, requires ≥2 Welch windows) and `coherenceMean` over the 1-30 Hz stick band (`TransferFunctionMetrics.coherenceMean`) — feeds the Wiener data-quality axis-coverage sub-score and gates TF-1..TF-4 recommendations per axis (`TF_COHERENCE_GATE = 0.5` in PIDRecommender) - Used in Flash Tune mode for combined filter + PID analysis from a single flight - IPC: `ANALYSIS_RUN_TRANSFER_FUNCTION` + `EVENT_ANALYSIS_PROGRESS` diff --git a/src/main/analysis/PIDAnalyzer.ts b/src/main/analysis/PIDAnalyzer.ts index 095ed64..a1eb998 100644 --- a/src/main/analysis/PIDAnalyzer.ts +++ b/src/main/analysis/PIDAnalyzer.ts @@ -333,10 +333,23 @@ async function analyzePIDCore(params: CoreParams): Promise { } const setpointRMS = Math.sqrt(sumSq / setpointValues.length); + const tfMetrics = extracted.tfResult.metrics; + const coherenceMean = + tfMetrics.roll.coherenceMean !== undefined && + tfMetrics.pitch.coherenceMean !== undefined && + tfMetrics.yaw.coherenceMean !== undefined + ? { + roll: tfMetrics.roll.coherenceMean, + pitch: tfMetrics.pitch.coherenceMean, + yaw: tfMetrics.yaw.coherenceMean, + } + : undefined; + qualityResult = scoreWienerDataQuality({ sampleCount: flightData.frameCount, sampleRateHz: flightData.sampleRateHz, setpointRMS, + ...(coherenceMean ? { coherenceMean } : {}), }); } diff --git a/src/main/analysis/PIDRecommender.test.ts b/src/main/analysis/PIDRecommender.test.ts index 8525693..6cb789b 100644 --- a/src/main/analysis/PIDRecommender.test.ts +++ b/src/main/analysis/PIDRecommender.test.ts @@ -791,6 +791,45 @@ describe('PIDRecommender', () => { }; } + it('should skip TF rules for an axis with low coherence', () => { + const tf: TransferFunctionContext = { + // Critically low phase margin, but the TF is untrustworthy + roll: makeTFMetrics({ phaseMarginDeg: 25, coherenceMean: 0.2 }), + }; + + const recs = recommendPID( + emptyProfile(), + emptyProfile(), + emptyProfile(), + DEFAULT_PIDS, + undefined, + undefined, + 'balanced', + tf + ); + + expect(recs.find((r) => r.setting === 'pid_roll_d')).toBeUndefined(); + }); + + it('should apply TF rules when coherence is high', () => { + const tf: TransferFunctionContext = { + roll: makeTFMetrics({ phaseMarginDeg: 25, coherenceMean: 0.9 }), + }; + + const recs = recommendPID( + emptyProfile(), + emptyProfile(), + emptyProfile(), + DEFAULT_PIDS, + undefined, + undefined, + 'balanced', + tf + ); + + expect(recs.find((r) => r.setting === 'pid_roll_d')).toBeDefined(); + }); + it('should use TF metrics when responses are empty', () => { const tf: TransferFunctionContext = { roll: makeTFMetrics({ phaseMarginDeg: 25 }), // critically low diff --git a/src/main/analysis/PIDRecommender.ts b/src/main/analysis/PIDRecommender.ts index 5b857c4..8572248 100644 --- a/src/main/analysis/PIDRecommender.ts +++ b/src/main/analysis/PIDRecommender.ts @@ -995,6 +995,11 @@ const PHASE_MARGIN_LOW_DEG = 45; /** Phase margin threshold below which we consider the system critically under-damped */ const PHASE_MARGIN_CRITICAL_DEG = 30; +/** Minimum stick-band coherence for TF-derived gain recommendations. + * Below this the transfer function estimate is dominated by noise/disturbance + * rather than commanded motion, so TF-1..TF-4 must not fire for the axis. */ +const TF_COHERENCE_GATE = 0.5; + /** * Generate PID recommendations from transfer function metrics (frequency domain). * @@ -1011,6 +1016,14 @@ function generateFrequencyDomainRecs( bounds: QuadSizeBounds = DEFAULT_QUAD_SIZE_BOUNDS, flightStyle: FlightStyle = 'balanced' ): void { + // Coherence gate: when the setpoint→gyro coherence in the stick-input band + // is too low, the transfer function for this axis is not trustworthy enough + // to drive gain changes — skip all TF rules (the data quality scorer already + // emits a low_coherence warning for the axis). + if (tf.coherenceMean !== undefined && tf.coherenceMean < TF_COHERENCE_GATE) { + return; + } + const isYaw = axisName === 'yaw'; const overshootThreshold = isYaw ? thresholds.overshootMax * 1.5 : thresholds.overshootMax; const moderateOvershoot = isYaw ? thresholds.overshootMax : thresholds.moderateOvershoot; diff --git a/src/main/analysis/TransferFunctionEstimator.test.ts b/src/main/analysis/TransferFunctionEstimator.test.ts index 9ae4e85..e5ea436 100644 --- a/src/main/analysis/TransferFunctionEstimator.test.ts +++ b/src/main/analysis/TransferFunctionEstimator.test.ts @@ -4,6 +4,7 @@ import { estimateAllAxes, computeSyntheticStepResponse, computeDcGainDb, + computeCoherenceMean, extractMetrics, type BodeResult, type SyntheticStepResponse, @@ -534,3 +535,57 @@ function findClosestBin(frequencies: Float64Array, targetHz: number): number { } return closestIdx; } + +describe('coherence', () => { + const sampleRate = 4000; + + it('reports high coherence for a clean LTI system', () => { + const setpoint = generateMixedStickInputs(sampleRate, 10); + const gyro = generateSecondOrderResponse(setpoint, sampleRate, 20, 0.7, 5); + + const { bode } = estimateTransferFunction(setpoint, gyro, sampleRate); + expect(bode.coherence).toBeDefined(); + const mean = computeCoherenceMean(bode); + expect(mean).toBeDefined(); + expect(mean!).toBeGreaterThan(0.8); + }); + + it('reports low coherence when gyro is unrelated to setpoint', () => { + const setpoint = generateMixedStickInputs(sampleRate, 10); + // Gyro = deterministic multi-tone unrelated to the stick input + const gyro = new Float64Array(setpoint.length); + for (let i = 0; i < gyro.length; i++) { + const t = i / sampleRate; + gyro[i] = + 40 * Math.sin(2 * Math.PI * 3.1 * t + 1.0) + + 30 * Math.sin(2 * Math.PI * 7.7 * t + 2.0) + + 20 * Math.sin(2 * Math.PI * 13.3 * t); + } + + const { bode } = estimateTransferFunction(setpoint, gyro, sampleRate); + const mean = computeCoherenceMean(bode); + expect(mean).toBeDefined(); + expect(mean!).toBeLessThan(0.5); + }); + + it('omits coherence when only one Welch window fits', () => { + // 8192 samples = exactly one TF window → coherence would be trivially 1 + const setpoint = generateMixedStickInputs(sampleRate, 2.048); + const gyro = generateSecondOrderResponse(setpoint, sampleRate, 20, 0.7, 5); + + const { bode } = estimateTransferFunction(setpoint, gyro, sampleRate); + expect(bode.coherence).toBeUndefined(); + expect(computeCoherenceMean(bode)).toBeUndefined(); + }); + + it('estimateAllAxes carries coherenceMean into metrics', () => { + const setpoint = generateMixedStickInputs(sampleRate, 10); + const gyro = generateSecondOrderResponse(setpoint, sampleRate, 20, 0.7, 5); + const axes = { roll: setpoint, pitch: setpoint, yaw: setpoint }; + const gyros = { roll: gyro, pitch: gyro, yaw: gyro }; + + const result = estimateAllAxes(axes, gyros, sampleRate); + expect(result.metrics.roll.coherenceMean).toBeDefined(); + expect(result.metrics.roll.coherenceMean!).toBeGreaterThan(0.8); + }); +}); diff --git a/src/main/analysis/TransferFunctionEstimator.ts b/src/main/analysis/TransferFunctionEstimator.ts index c77dcf4..f0472a5 100644 --- a/src/main/analysis/TransferFunctionEstimator.ts +++ b/src/main/analysis/TransferFunctionEstimator.ts @@ -41,6 +41,11 @@ const DC_REFERENCE_MAX_HZ = 5; /** Settling tolerance for synthetic step response (±2%) */ const SETTLING_TOLERANCE = 0.02; +/** Upper bound of the band used for the mean-coherence summary (Hz). + * Stick input carries energy roughly 0.5-40 Hz; coherence above that band + * reflects noise, not tracking, and would dilute the mean. */ +const COHERENCE_BAND_MAX_HZ = 30; + // ---- Types ---- export interface BodeResult { @@ -50,6 +55,9 @@ export interface BodeResult { magnitude: Float64Array; /** Phase in degrees */ phase: Float64Array; + /** Magnitude-squared coherence γ²(f) = |S_xy|²/(S_xx·S_yy), 0-1 per bin. + * Absent when only one Welch window fits (coherence is trivially 1). */ + coherence?: Float64Array; } export interface SyntheticStepResponse { @@ -74,6 +82,9 @@ export interface TransferFunctionMetrics { riseTimeMs: number; /** DC gain in dB — 0 dB = perfect steady-state tracking */ dcGainDb: number; + /** Mean magnitude-squared coherence over the 1-30 Hz stick-input band (0-1). + * Undefined when the log was too short for multi-window Welch averaging. */ + coherenceMean?: number; } export interface TransferFunctionResult { @@ -130,10 +141,11 @@ export function estimateTransferFunction( const numBins = windowSize / 2 + 1; - // Accumulators for cross-spectral and auto-spectral density + // Accumulators for cross-spectral and auto-spectral densities const sxyRe = new Float64Array(numBins); // Real part of S_xy const sxyIm = new Float64Array(numBins); // Imaginary part of S_xy const sxx = new Float64Array(numBins); // |X(f)|^2 + const syy = new Float64Array(numBins); // |Y(f)|^2 (for coherence) onProgress?.({ step: 'windowing', percent: 5 }); @@ -165,8 +177,9 @@ export function estimateTransferFunction( sxyRe[i] += yRe * xRe + yIm * xIm; sxyIm[i] += yIm * xRe - yRe * xIm; - // |X|^2 + // |X|^2 and |Y|^2 sxx[i] += xRe * xRe + xIm * xIm; + syy[i] += yRe * yRe + yIm * yIm; } onProgress?.({ @@ -188,6 +201,11 @@ export function estimateTransferFunction( const hRe = new Float64Array(numBins); const hIm = new Float64Array(numBins); + // Magnitude-squared coherence γ²(f) = |S_xy|²/(S_xx·S_yy). + // With a single Welch window it is identically 1 — report it only when + // at least 2 windows were averaged. + const coherence = numWindows >= 2 ? new Float64Array(numBins) : undefined; + for (let i = 0; i < numBins; i++) { frequencies[i] = i * freqResolution; @@ -198,6 +216,12 @@ export function estimateTransferFunction( const mag = Math.sqrt(hRe[i] * hRe[i] + hIm[i] * hIm[i]); magnitude[i] = mag > 1e-12 ? 20 * Math.log10(mag) : -240; phase[i] = (Math.atan2(hIm[i], hRe[i]) * 180) / Math.PI; + + if (coherence) { + const crossPower = sxyRe[i] * sxyRe[i] + sxyIm[i] * sxyIm[i]; + const autoProduct = sxx[i] * syy[i]; + coherence[i] = autoProduct > 1e-20 ? Math.min(1, Math.max(0, crossPower / autoProduct)) : 0; + } } // Compute impulse response via IFFT of H(f) @@ -206,11 +230,30 @@ export function estimateTransferFunction( onProgress?.({ step: 'metrics', percent: 80 }); return { - bode: { frequencies, magnitude, phase }, + bode: { frequencies, magnitude, phase, ...(coherence ? { coherence } : {}) }, impulseResponse, }; } +/** + * Mean coherence over the stick-input band (1 to COHERENCE_BAND_MAX_HZ). + * Returns undefined when the Bode result carries no coherence data. + */ +export function computeCoherenceMean(bode: BodeResult): number | undefined { + if (!bode.coherence) return undefined; + let sum = 0; + let count = 0; + for (let i = 0; i < bode.frequencies.length; i++) { + const f = bode.frequencies[i]; + if (f >= DC_REFERENCE_MIN_HZ && f <= COHERENCE_BAND_MAX_HZ) { + sum += bode.coherence[i]; + count++; + } + if (f > COHERENCE_BAND_MAX_HZ) break; + } + return count > 0 ? sum / count : undefined; +} + /** * Estimate transfer function for all 3 axes and derive metrics. */ @@ -398,6 +441,7 @@ export function extractMetrics( stepResponse: SyntheticStepResponse, _sampleRateHz: number ): TransferFunctionMetrics { + const coherenceMean = computeCoherenceMean(bode); return { bandwidthHz: computeBandwidth(bode), gainMarginDb: computeGainMargin(bode), @@ -406,6 +450,7 @@ export function extractMetrics( settlingTimeMs: computeSettlingTime(stepResponse), riseTimeMs: computeRiseTime(stepResponse), dcGainDb: computeDcGainDb(bode), + ...(coherenceMean !== undefined ? { coherenceMean } : {}), }; } @@ -590,6 +635,7 @@ export function trimBode(bode: BodeResult, maxFreqHz: number): BodeResult { frequencies: bode.frequencies.slice(0, endIdx), magnitude: bode.magnitude.slice(0, endIdx), phase: bode.phase.slice(0, endIdx), + ...(bode.coherence ? { coherence: bode.coherence.slice(0, endIdx) } : {}), }; } diff --git a/src/main/analysis/__fixtures__/golden/demo-flash-cycle0.json b/src/main/analysis/__fixtures__/golden/demo-flash-cycle0.json index 9ae634e..422ae1c 100644 --- a/src/main/analysis/__fixtures__/golden/demo-flash-cycle0.json +++ b/src/main/analysis/__fixtures__/golden/demo-flash-cycle0.json @@ -27,7 +27,7 @@ }, "dataQuality": { "tier": "excellent", - "overall": 90 + "overall": 100 }, "transferFunctionMetrics": { "roll": { @@ -91,22 +91,6 @@ "ruleId": "PW-IRELAX-CUTOFF", "confidence": "medium" }, - { - "setting": "pid_pitch_d", - "currentValue": 32, - "recommendedValue": 32, - "ruleId": "P-DTE-BLOCK-pid_pitch_d", - "confidence": "low", - "informational": true - }, - { - "setting": "pid_roll_d", - "currentValue": 30, - "recommendedValue": 30, - "ruleId": "P-DTE-BLOCK-pid_roll_d", - "confidence": "low", - "informational": true - }, { "setting": "pid_roll_d", "currentValue": 30, @@ -114,21 +98,6 @@ "ruleId": "P-PW-D-roll", "confidence": "medium" }, - { - "setting": "pid_yaw_d", - "currentValue": 0, - "recommendedValue": 0, - "ruleId": "P-DTE-BLOCK-pid_yaw_d", - "confidence": "low", - "informational": true - }, - { - "setting": "pid_yaw_p", - "currentValue": 45, - "recommendedValue": 40, - "ruleId": "TF-2-P-yaw", - "confidence": "medium" - }, { "setting": "rc_smoothing_auto_factor", "currentValue": 30, diff --git a/src/main/analysis/__fixtures__/golden/real-vx35-tf.json b/src/main/analysis/__fixtures__/golden/real-vx35-tf.json index 4e2fa31..1932c1d 100644 --- a/src/main/analysis/__fixtures__/golden/real-vx35-tf.json +++ b/src/main/analysis/__fixtures__/golden/real-vx35-tf.json @@ -27,7 +27,7 @@ }, "dataQuality": { "tier": "good", - "overall": 70 + "overall": 67 }, "transferFunctionMetrics": { "roll": { @@ -60,6 +60,8 @@ }, "warningCodes": [ "feedforward_active", + "low_coherence", + "low_coherence", "low_logging_rate", "tpa_variance" ], @@ -72,45 +74,10 @@ "confidence": "low" }, { - "setting": "pid_pitch_i", - "currentValue": 84, - "recommendedValue": 89, - "ruleId": "TF-4-I-pitch", - "confidence": "low" - }, - { - "setting": "pid_pitch_p", - "currentValue": 47, - "recommendedValue": 52, - "ruleId": "TF-3-P-pitch", - "confidence": "medium" - }, - { - "setting": "pid_roll_i", - "currentValue": 80, - "recommendedValue": 90, - "ruleId": "TF-4-I-roll", - "confidence": "medium" - }, - { - "setting": "pid_roll_p", - "currentValue": 45, - "recommendedValue": 50, - "ruleId": "TF-3-P-roll", - "confidence": "medium" - }, - { - "setting": "pid_yaw_i", - "currentValue": 80, - "recommendedValue": 90, - "ruleId": "TF-4-I-yaw", - "confidence": "medium" - }, - { - "setting": "pid_yaw_p", - "currentValue": 45, - "recommendedValue": 50, - "ruleId": "TF-3-P-yaw", + "setting": "pid_pitch_d", + "currentValue": 46, + "recommendedValue": 40, + "ruleId": "P-DR-OD-pitch", "confidence": "medium" }, { diff --git a/src/main/analysis/goldenOutputs.test.ts b/src/main/analysis/goldenOutputs.test.ts index a995fea..6abbcf6 100644 --- a/src/main/analysis/goldenOutputs.test.ts +++ b/src/main/analysis/goldenOutputs.test.ts @@ -177,7 +177,7 @@ function summarizePIDResult(r: PIDAnalysisResult) { bandwidthHz: round(m.bandwidthHz, 0), phaseMarginDeg: round(m.phaseMarginDeg, 0), gainMarginDb: round(m.gainMarginDb, 0), - dcGainDb: round(m.dcGainDb, 1), + dcGainDb: round(m.dcGainDb ?? 0, 1), overshootPercent: round(m.overshootPercent, 0), riseTimeMs: round(m.riseTimeMs, 0), settlingTimeMs: round(m.settlingTimeMs, 0), From 870aea05b2511a103c1affc75c231606bdf0a0ae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:35:42 +0000 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20quick-win=20batch=20=E2=80=94=20t?= =?UTF-8?q?hrottle=20dedupe,=20empirical=20max=20stick=20rate,=20D-max=20a?= =?UTF-8?q?dvisory,=20TF=20margin=20sentinels=20(P1.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - normalizeThrottle deduplicated into src/main/analysis/throttleUtils.ts (was triplicated in SegmentSelector, ThrottleSpectrogramAnalyzer, PropWashDetector with drift risk). - FeedforwardAnalyzer small/large step split now uses the max stick rate actually flown (deriveMaxStickRate from setpoint traces, floored at 300 deg/s) instead of assuming the BF default 670 deg/s rate profile. - simplified_dmax_gain=0 recommendation for <=5" quads is now informational — auto-applying silently flipped a simplified-tuning slider off without the pilot's consent. - TF gain/phase margins report crossingFound flags instead of silently passing their 60 dB/90° caps as measurements; the flight-quality Phase Margin component skips axes without a measured gain crossover, so an unmeasured margin no longer scores as 'very stable'. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJhhk2frYDDnpNbSHRxGdv --- src/main/analysis/FeedforwardAnalyzer.test.ts | 24 ++++++++++ src/main/analysis/FeedforwardAnalyzer.ts | 35 +++++++++++++- src/main/analysis/PIDAnalyzer.ts | 9 +++- src/main/analysis/PIDRecommender.test.ts | 3 +- src/main/analysis/PIDRecommender.ts | 4 +- src/main/analysis/PropWashDetector.ts | 11 +---- src/main/analysis/SegmentSelector.ts | 22 +-------- .../analysis/ThrottleSpectrogramAnalyzer.ts | 18 +------ .../analysis/TransferFunctionEstimator.ts | 32 +++++++++---- .../golden/demo-flash-cycle0.json | 3 +- .../__fixtures__/golden/demo-pid-cycle0.json | 3 +- .../__fixtures__/golden/real-vx35-pid.json | 3 +- .../__fixtures__/golden/real-vx35-tf.json | 3 +- src/main/analysis/throttleUtils.test.ts | 26 ++++++++++ src/main/analysis/throttleUtils.ts | 24 ++++++++++ src/shared/types/analysis.types.ts | 47 ++++++++----------- src/shared/types/tuning-history.types.ts | 3 ++ src/shared/utils/metricsExtract.test.ts | 1 + src/shared/utils/metricsExtract.ts | 4 ++ src/shared/utils/tuneQualityScore.ts | 6 ++- 20 files changed, 186 insertions(+), 95 deletions(-) create mode 100644 src/main/analysis/throttleUtils.test.ts create mode 100644 src/main/analysis/throttleUtils.ts diff --git a/src/main/analysis/FeedforwardAnalyzer.test.ts b/src/main/analysis/FeedforwardAnalyzer.test.ts index c2bb024..42d2fb6 100644 --- a/src/main/analysis/FeedforwardAnalyzer.test.ts +++ b/src/main/analysis/FeedforwardAnalyzer.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect } from 'vitest'; import { analyzeFeedforward, + deriveMaxStickRate, + MAX_STICK_RATE_FLOOR, + MAX_STICK_RATE_FALLBACK, recommendFeedforward, recommendRCLinkBaseline, mergeFFRecommendations, @@ -826,3 +829,24 @@ describe('extractRCLinkRate', () => { expect(extractRCLinkRate(headers)).toBeUndefined(); }); }); + +describe('deriveMaxStickRate', () => { + const series = (values: number[]) => ({ values: new Float64Array(values) }); + + it('uses the max |setpoint| across roll/pitch/yaw', () => { + const setpoint = [series([100, -850, 300]), series([200, 400, -300]), series([50, 0, 25])]; + expect(deriveMaxStickRate(setpoint)).toBe(850); + }); + + it('floors gentle flights at MAX_STICK_RATE_FLOOR', () => { + const setpoint = [series([10, -40, 30]), series([20, 15, -25]), series([5, 0, 2])]; + expect(deriveMaxStickRate(setpoint)).toBe(MAX_STICK_RATE_FLOOR); + }); + + it('falls back to the BF default when no data', () => { + expect(deriveMaxStickRate(undefined)).toBe(MAX_STICK_RATE_FALLBACK); + expect(deriveMaxStickRate([series([0, 0]), series([0]), series([0])])).toBe( + MAX_STICK_RATE_FALLBACK + ); + }); +}); diff --git a/src/main/analysis/FeedforwardAnalyzer.ts b/src/main/analysis/FeedforwardAnalyzer.ts index fdd9512..1c372c7 100644 --- a/src/main/analysis/FeedforwardAnalyzer.ts +++ b/src/main/analysis/FeedforwardAnalyzer.ts @@ -61,6 +61,37 @@ export const RC_LINK_DEVIATION_THRESHOLD = 0.3; // ---- Implementation ---- +/** Fallback max stick rate (deg/s) when it cannot be derived from flight data. + * Matches the BF default rate profile's max rate. */ +export const MAX_STICK_RATE_FALLBACK = 670; + +/** Floor for the derived max stick rate — prevents a degenerate small/large + * split on very gentle flights where the sticks never moved far. */ +export const MAX_STICK_RATE_FLOOR = 300; + +/** + * Derive the maximum stick rate actually flown from the setpoint traces. + * + * More faithful than assuming the BF default rate profile: the small/large + * step split should be relative to what the pilot actually commanded in this + * log. Uses the max |setpoint| across roll/pitch/yaw, floored at + * MAX_STICK_RATE_FLOOR; falls back to MAX_STICK_RATE_FALLBACK with no data. + */ +export function deriveMaxStickRate(setpoint: Array<{ values: Float64Array }> | undefined): number { + if (!setpoint) return MAX_STICK_RATE_FALLBACK; + let max = 0; + for (let axis = 0; axis < 3 && axis < setpoint.length; axis++) { + const values = setpoint[axis]?.values; + if (!values) continue; + for (let i = 0; i < values.length; i++) { + const abs = Math.abs(values[i]); + if (abs > max) max = abs; + } + } + if (max <= 0) return MAX_STICK_RATE_FALLBACK; + return Math.max(max, MAX_STICK_RATE_FLOOR); +} + /** * Analyze feedforward characteristics from step response data. * @@ -69,13 +100,13 @@ export const RC_LINK_DEVIATION_THRESHOLD = 0.3; * * @param responses - All step responses across axes (with leadingEdgeOvershootPercent populated) * @param ffContext - Current feedforward configuration from BBL headers - * @param maxStickRate - Maximum stick rate in deg/s (default 670 for BF defaults) + * @param maxStickRate - Maximum stick rate in deg/s (derive with deriveMaxStickRate) * @returns FeedforwardAnalysis or undefined if not enough data */ export function analyzeFeedforward( responses: StepResponse[], ffContext: FeedforwardContext | undefined, - maxStickRate: number = 670 + maxStickRate: number = MAX_STICK_RATE_FALLBACK ): FeedforwardAnalysis | undefined { // Only analyze when FF is active if (!ffContext?.active) return undefined; diff --git a/src/main/analysis/PIDAnalyzer.ts b/src/main/analysis/PIDAnalyzer.ts index a1eb998..3c2c2ee 100644 --- a/src/main/analysis/PIDAnalyzer.ts +++ b/src/main/analysis/PIDAnalyzer.ts @@ -69,6 +69,7 @@ import { analyzeDTermEffectiveness } from './DTermAnalyzer'; import { mapToSliders, computeSliderDelta, buildRecommendedPIDs } from './SliderMapper'; import { analyzeFeedforward, + deriveMaxStickRate, recommendFeedforward, recommendRCLinkBaseline, mergeFFRecommendations, @@ -370,7 +371,13 @@ async function analyzePIDCore(params: CoreParams): Promise { const crossAxisCoupling = steps.length > 0 ? analyzeCrossAxisCoupling(steps, flightData) : undefined; const feedforwardAnalysis = - allResponses.length > 0 ? analyzeFeedforward(allResponses, feedforwardContext) : undefined; + allResponses.length > 0 + ? analyzeFeedforward( + allResponses, + feedforwardContext, + deriveMaxStickRate(flightData.setpoint) + ) + : undefined; await yieldToEventLoop(); diff --git a/src/main/analysis/PIDRecommender.test.ts b/src/main/analysis/PIDRecommender.test.ts index 6cb789b..81067a6 100644 --- a/src/main/analysis/PIDRecommender.test.ts +++ b/src/main/analysis/PIDRecommender.test.ts @@ -2743,7 +2743,8 @@ describe('D-max gain awareness (P-DMAX-INFO)', () => { expect(dmaxRec!.recommendedValue).toBe(0); expect(dmaxRec!.confidence).toBe('low'); expect(dmaxRec!.reason).toContain('unpredictability'); - expect(dmaxRec!.informational).toBeUndefined(); + // Advisory only — flipping a simplified-tuning slider must not auto-apply + expect(dmaxRec!.informational).toBe(true); }); it('should recommend disabling D-max for whoop (1") quads', () => { diff --git a/src/main/analysis/PIDRecommender.ts b/src/main/analysis/PIDRecommender.ts index 8572248..c1a0c8f 100644 --- a/src/main/analysis/PIDRecommender.ts +++ b/src/main/analysis/PIDRecommender.ts @@ -1210,7 +1210,8 @@ function applyDMinAdvisory( ruleId: 'P-DMAX-INFO', }); } else { - // For <=5" and whoops: recommend disabling + // For <=5" and whoops: suggest disabling — advisory only. Auto-applying + // would silently flip a simplified-tuning slider off; the pilot decides. recommendations.push({ setting: 'simplified_dmax_gain', currentValue: 1, // D-max is effectively active @@ -1221,6 +1222,7 @@ function applyDMinAdvisory( 'Disabling D-max (simplified_dmax_gain = 0) gives consistent D for faster tune convergence.', impact: 'stability', confidence: 'low', + informational: true, ruleId: 'P-DMAX-INFO', }); } diff --git a/src/main/analysis/PropWashDetector.ts b/src/main/analysis/PropWashDetector.ts index 8e2eb49..c69bce5 100644 --- a/src/main/analysis/PropWashDetector.ts +++ b/src/main/analysis/PropWashDetector.ts @@ -17,6 +17,7 @@ * oscillation dominates total energy. */ import type { BlackboxFlightData } from '@shared/types/blackbox.types'; +import { normalizeThrottle } from './throttleUtils'; import type { PropWashEvent, PropWashAnalysis } from '@shared/types/analysis.types'; import { computePowerSpectrum, trimSpectrum } from './FFTCompute'; import { @@ -30,16 +31,6 @@ import { PROPWASH_MIN_EVENTS, } from './constants'; -/** - * Normalize a raw throttle value to 0-1 range. - */ -function normalizeThrottle(value: number): number { - if (value > 1000) return (value - 1000) / 1000; - if (value > 100) return value / 1000; - if (value > 1) return value / 100; - return value; -} - interface ThrottleDropEvent { /** Sample index where the drop starts */ startIndex: number; diff --git a/src/main/analysis/SegmentSelector.ts b/src/main/analysis/SegmentSelector.ts index 22522f5..c4ae34f 100644 --- a/src/main/analysis/SegmentSelector.ts +++ b/src/main/analysis/SegmentSelector.ts @@ -7,6 +7,7 @@ * (preferred for filter analysis: captures noise across full RPM range) */ import type { BlackboxFlightData } from '@shared/types/blackbox.types'; +import { normalizeThrottle } from './throttleUtils'; import type { FlightSegment } from '@shared/types/analysis.types'; import { THROTTLE_MIN_FLIGHT, @@ -112,27 +113,6 @@ export function findSteadySegments(flightData: BlackboxFlightData): FlightSegmen return segments; } -/** - * Normalize throttle to 0-1 range. - * Betaflight setpoint throttle is typically 0-1000 or 1000-2000 depending on log version. - */ -function normalizeThrottle(value: number): number { - if (value > 1000) { - // 1000-2000 range (RC pulse width) - return (value - 1000) / 1000; - } - if (value > 100) { - // 0-1000 range - return value / 1000; - } - if (value > 1) { - // 0-100 percentage range - return value / 100; - } - // Already 0-1 range - return value; -} - /** * Compute standard deviation of a sub-range of a Float64Array. */ diff --git a/src/main/analysis/ThrottleSpectrogramAnalyzer.ts b/src/main/analysis/ThrottleSpectrogramAnalyzer.ts index cfc1154..311d5cc 100644 --- a/src/main/analysis/ThrottleSpectrogramAnalyzer.ts +++ b/src/main/analysis/ThrottleSpectrogramAnalyzer.ts @@ -9,6 +9,7 @@ * - Throttle ranges with worst noise */ import type { BlackboxFlightData } from '@shared/types/blackbox.types'; +import { normalizeThrottle } from './throttleUtils'; import type { ThrottleSpectrogramResult, ThrottleBand, @@ -24,23 +25,6 @@ export const DEFAULT_NUM_BANDS = 10; /** Minimum samples per band to compute a meaningful spectrum */ export const MIN_SAMPLES_PER_BAND = 512; -/** - * Normalize a raw throttle value to 0-1 range. - * Handles BF raw formats: 1000-2000, 0-1000, 0-100, and 0-1. - */ -function normalizeThrottle(value: number): number { - if (value > 1000) { - return (value - 1000) / 1000; - } - if (value > 100) { - return value / 1000; - } - if (value > 1) { - return value / 100; - } - return value; -} - /** * Bin flight data samples by throttle level and collect gyro indices per band. * diff --git a/src/main/analysis/TransferFunctionEstimator.ts b/src/main/analysis/TransferFunctionEstimator.ts index f0472a5..ec8a904 100644 --- a/src/main/analysis/TransferFunctionEstimator.ts +++ b/src/main/analysis/TransferFunctionEstimator.ts @@ -85,6 +85,12 @@ export interface TransferFunctionMetrics { /** Mean magnitude-squared coherence over the 1-30 Hz stick-input band (0-1). * Undefined when the log was too short for multi-window Welch averaging. */ coherenceMean?: number; + /** False when the phase never crossed -180° — gainMarginDb is then the 60 dB + * cap (an "at least this stable" placeholder), not a measured margin. */ + gainMarginCrossingFound?: boolean; + /** False when the gain never crossed 0 dB — phaseMarginDeg is then the 90° + * cap, not a measured margin. */ + phaseMarginCrossingFound?: boolean; } export interface TransferFunctionResult { @@ -442,10 +448,14 @@ export function extractMetrics( _sampleRateHz: number ): TransferFunctionMetrics { const coherenceMean = computeCoherenceMean(bode); + const gainMargin = computeGainMargin(bode); + const phaseMargin = computePhaseMargin(bode); return { bandwidthHz: computeBandwidth(bode), - gainMarginDb: computeGainMargin(bode), - phaseMarginDeg: computePhaseMargin(bode), + gainMarginDb: gainMargin.valueDb, + phaseMarginDeg: phaseMargin.valueDeg, + gainMarginCrossingFound: gainMargin.crossingFound, + phaseMarginCrossingFound: phaseMargin.crossingFound, overshootPercent: computeOvershoot(stepResponse), settlingTimeMs: computeSettlingTime(stepResponse), riseTimeMs: computeRiseTime(stepResponse), @@ -501,7 +511,7 @@ function computeBandwidth(bode: BodeResult): number { * Compute gain margin: how much gain (in dB) before instability. * Found at the frequency where phase crosses -180 degrees. */ -function computeGainMargin(bode: BodeResult): number { +function computeGainMargin(bode: BodeResult): { valueDb: number; crossingFound: boolean } { // Find phase crossover frequency (where phase = -180) for (let i = 1; i < bode.phase.length; i++) { if (bode.phase[i] <= -180 && bode.phase[i - 1] > -180) { @@ -518,19 +528,20 @@ function computeGainMargin(bode: BodeResult): number { bode.magnitude[Math.min(fracIdx + 1, bode.magnitude.length - 1)] * frac; // Gain margin = -magnitude at phase crossover (positive = stable) - return -magAtCrossover; + return { valueDb: -magAtCrossover, crossingFound: true }; } } - // Phase never crosses -180 — infinite gain margin (very stable) - return 60; // Cap at reasonable value + // Phase never crossed -180 within the analysis band — no measurable margin. + // The 60 dB cap is a placeholder; crossingFound=false marks it as unmeasured. + return { valueDb: 60, crossingFound: false }; } /** * Compute phase margin: how much additional phase lag before instability. * Found at the frequency where gain crosses 0 dB. */ -function computePhaseMargin(bode: BodeResult): number { +function computePhaseMargin(bode: BodeResult): { valueDeg: number; crossingFound: boolean } { // Find gain crossover frequency (where magnitude = 0 dB) for (let i = 1; i < bode.magnitude.length; i++) { if (bode.magnitude[i] <= 0 && bode.magnitude[i - 1] > 0) { @@ -547,12 +558,13 @@ function computePhaseMargin(bode: BodeResult): number { bode.phase[Math.min(fracIdx + 1, bode.phase.length - 1)] * frac; // Phase margin = 180 + phase at gain crossover (positive = stable) - return 180 + phaseAtCrossover; + return { valueDeg: 180 + phaseAtCrossover, crossingFound: true }; } } - // Gain never crosses 0 dB — infinite phase margin (system always attenuates) - return 90; // Cap at reasonable value + // Gain never crossed 0 dB within the analysis band — no measurable margin. + // The 90° cap is a placeholder; crossingFound=false marks it as unmeasured. + return { valueDeg: 90, crossingFound: false }; } /** diff --git a/src/main/analysis/__fixtures__/golden/demo-flash-cycle0.json b/src/main/analysis/__fixtures__/golden/demo-flash-cycle0.json index 422ae1c..e004bc3 100644 --- a/src/main/analysis/__fixtures__/golden/demo-flash-cycle0.json +++ b/src/main/analysis/__fixtures__/golden/demo-flash-cycle0.json @@ -110,7 +110,8 @@ "currentValue": 1, "recommendedValue": 0, "ruleId": "P-DMAX-INFO", - "confidence": "low" + "confidence": "low", + "informational": true } ] } diff --git a/src/main/analysis/__fixtures__/golden/demo-pid-cycle0.json b/src/main/analysis/__fixtures__/golden/demo-pid-cycle0.json index e276bb9..8f2b188 100644 --- a/src/main/analysis/__fixtures__/golden/demo-pid-cycle0.json +++ b/src/main/analysis/__fixtures__/golden/demo-pid-cycle0.json @@ -105,7 +105,8 @@ "currentValue": 1, "recommendedValue": 0, "ruleId": "P-DMAX-INFO", - "confidence": "low" + "confidence": "low", + "informational": true } ] } diff --git a/src/main/analysis/__fixtures__/golden/real-vx35-pid.json b/src/main/analysis/__fixtures__/golden/real-vx35-pid.json index c59231d..62bca87 100644 --- a/src/main/analysis/__fixtures__/golden/real-vx35-pid.json +++ b/src/main/analysis/__fixtures__/golden/real-vx35-pid.json @@ -104,7 +104,8 @@ "currentValue": 1, "recommendedValue": 0, "ruleId": "P-DMAX-INFO", - "confidence": "low" + "confidence": "low", + "informational": true }, { "setting": "thrust_linear", diff --git a/src/main/analysis/__fixtures__/golden/real-vx35-tf.json b/src/main/analysis/__fixtures__/golden/real-vx35-tf.json index 1932c1d..f50d69f 100644 --- a/src/main/analysis/__fixtures__/golden/real-vx35-tf.json +++ b/src/main/analysis/__fixtures__/golden/real-vx35-tf.json @@ -85,7 +85,8 @@ "currentValue": 1, "recommendedValue": 0, "ruleId": "P-DMAX-INFO", - "confidence": "low" + "confidence": "low", + "informational": true }, { "setting": "thrust_linear", diff --git a/src/main/analysis/throttleUtils.test.ts b/src/main/analysis/throttleUtils.test.ts new file mode 100644 index 0000000..7d5333f --- /dev/null +++ b/src/main/analysis/throttleUtils.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeThrottle } from './throttleUtils'; + +describe('normalizeThrottle', () => { + it('normalizes RC pulse width (1000-2000)', () => { + expect(normalizeThrottle(1000.5)).toBeCloseTo(0.0005, 4); + expect(normalizeThrottle(1500)).toBeCloseTo(0.5, 6); + expect(normalizeThrottle(2000)).toBeCloseTo(1.0, 6); + }); + + it('normalizes permille (0-1000)', () => { + expect(normalizeThrottle(500)).toBeCloseTo(0.5, 6); + expect(normalizeThrottle(1000)).toBeCloseTo(1.0, 6); + }); + + it('normalizes percent (0-100)', () => { + expect(normalizeThrottle(50)).toBeCloseTo(0.5, 6); + expect(normalizeThrottle(100)).toBeCloseTo(1.0, 6); + }); + + it('passes through already-normalized values', () => { + expect(normalizeThrottle(0)).toBe(0); + expect(normalizeThrottle(0.42)).toBe(0.42); + expect(normalizeThrottle(1)).toBe(1); + }); +}); diff --git a/src/main/analysis/throttleUtils.ts b/src/main/analysis/throttleUtils.ts new file mode 100644 index 0000000..342d413 --- /dev/null +++ b/src/main/analysis/throttleUtils.ts @@ -0,0 +1,24 @@ +/** + * Throttle value normalization shared by all analysis modules. + * + * BBL logs carry throttle in different units depending on firmware/field: + * RC pulse width (1000-2000), permille (0-1000), percent (0-100), or an + * already-normalized 0-1 value. Detection is heuristic by magnitude. + */ + +/** Normalize a raw throttle value to the 0-1 range. */ +export function normalizeThrottle(value: number): number { + if (value > 1000) { + // 1000-2000 range (RC pulse width) + return (value - 1000) / 1000; + } + if (value > 100) { + // 0-1000 range (permille) + return value / 1000; + } + if (value > 1) { + // 0-100 range (percent) + return value / 100; + } + return value; +} diff --git a/src/shared/types/analysis.types.ts b/src/shared/types/analysis.types.ts index dd6aade..168c0ba 100644 --- a/src/shared/types/analysis.types.ts +++ b/src/shared/types/analysis.types.ts @@ -599,38 +599,31 @@ export interface PIDAnalysisResult { }; /** Per-axis transfer function metrics (only present for Wiener deconvolution analysis) */ transferFunctionMetrics?: { - roll: { - bandwidthHz: number; - phaseMarginDeg: number; - gainMarginDb: number; - overshootPercent: number; - settlingTimeMs: number; - riseTimeMs: number; - dcGainDb?: number; - }; - pitch: { - bandwidthHz: number; - phaseMarginDeg: number; - gainMarginDb: number; - overshootPercent: number; - settlingTimeMs: number; - riseTimeMs: number; - dcGainDb?: number; - }; - yaw: { - bandwidthHz: number; - phaseMarginDeg: number; - gainMarginDb: number; - overshootPercent: number; - settlingTimeMs: number; - riseTimeMs: number; - dcGainDb?: number; - }; + roll: AxisTransferFunctionMetrics; + pitch: AxisTransferFunctionMetrics; + yaw: AxisTransferFunctionMetrics; }; /** Verification flight similarity (only present when analyzing verification log with reference context) */ verificationSimilarity?: VerificationSimilarity; } +/** Per-axis transfer function metrics (mirrors TransferFunctionEstimator.TransferFunctionMetrics) */ +export interface AxisTransferFunctionMetrics { + bandwidthHz: number; + phaseMarginDeg: number; + gainMarginDb: number; + overshootPercent: number; + settlingTimeMs: number; + riseTimeMs: number; + dcGainDb?: number; + /** Mean setpoint→gyro coherence over the stick-input band (0-1) */ + coherenceMean?: number; + /** False when the phase never crossed -180° (gainMarginDb is the cap) */ + gainMarginCrossingFound?: boolean; + /** False when the gain never crossed 0 dB (phaseMarginDeg is the cap) */ + phaseMarginCrossingFound?: boolean; +} + // ---- D-Term Effectiveness Types ---- /** D-term effectiveness analysis result */ diff --git a/src/shared/types/tuning-history.types.ts b/src/shared/types/tuning-history.types.ts index 1b1dce8..6fdfe08 100644 --- a/src/shared/types/tuning-history.types.ts +++ b/src/shared/types/tuning-history.types.ts @@ -120,6 +120,9 @@ export interface AxisTransferFunctionSummary { overshootPercent: number; settlingTimeMs: number; riseTimeMs: number; + /** False when the gain never crossed 0 dB — phaseMarginDeg is the 90° cap, + * not a measured margin. Absent on records from older app versions. */ + phaseMarginCrossingFound?: boolean; } /** Downsampled synthetic step response for history chart rendering */ diff --git a/src/shared/utils/metricsExtract.test.ts b/src/shared/utils/metricsExtract.test.ts index 1a1cdb8..102e83c 100644 --- a/src/shared/utils/metricsExtract.test.ts +++ b/src/shared/utils/metricsExtract.test.ts @@ -512,6 +512,7 @@ describe('extractTransferFunctionMetrics', () => { for (const axis of ['roll', 'pitch', 'yaw'] as const) { for (const key of Object.keys(metrics[axis]) as (keyof typeof metrics.roll)[]) { const val = metrics[axis][key]; + if (typeof val !== 'number') continue; // phaseMarginCrossingFound is boolean expect(Math.round(val * 100) / 100).toBe(val); } } diff --git a/src/shared/utils/metricsExtract.ts b/src/shared/utils/metricsExtract.ts index 6487706..0ba6a68 100644 --- a/src/shared/utils/metricsExtract.ts +++ b/src/shared/utils/metricsExtract.ts @@ -329,6 +329,7 @@ interface TFMetricsInput { settlingTimeMs: number; riseTimeMs: number; dcGainDb?: number; + phaseMarginCrossingFound?: boolean; } /** Throttle-band TF summary input (matches PIDAnalysisResult.throttleTF shape) */ @@ -359,6 +360,9 @@ export function extractTransferFunctionMetrics( overshootPercent: round2(m.overshootPercent), settlingTimeMs: round2(m.settlingTimeMs), riseTimeMs: round2(m.riseTimeMs), + ...(m.phaseMarginCrossingFound !== undefined + ? { phaseMarginCrossingFound: m.phaseMarginCrossingFound } + : {}), }); // Extract per-axis DC gain if available diff --git a/src/shared/utils/tuneQualityScore.ts b/src/shared/utils/tuneQualityScore.ts index 944da91..0aef7e1 100644 --- a/src/shared/utils/tuneQualityScore.ts +++ b/src/shared/utils/tuneQualityScore.ts @@ -152,7 +152,11 @@ const COMPONENTS: ComponentDef[] = [ label: 'Phase Margin', getValue: (_filter, _pid, _verification, tf) => { if (!tf) return undefined; - return (tf.roll.phaseMarginDeg + tf.pitch.phaseMarginDeg + tf.yaw.phaseMarginDeg) / 3; + // Only axes with a measured gain crossover count — a capped 90° + // placeholder (no crossing found) must not read as "very stable". + const axes = [tf.roll, tf.pitch, tf.yaw].filter((a) => a.phaseMarginCrossingFound !== false); + if (axes.length === 0) return undefined; + return axes.reduce((s, a) => s + a.phaseMarginDeg, 0) / axes.length; }, best: 60, // 60° = very stable system worst: 20, // 20° = near instability From 6eb380d130579b0ff685250892125d149f368eb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:35:43 +0000 Subject: [PATCH 08/13] feat: contiguity-safe throttle-binned FFT and per-band TF (P1.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Throttle-binned analyses no longer FFT concatenations of non-contiguous samples (which create phantom spectral content at splice discontinuities). ThrottleSpectrogramAnalyzer computes per-band spectra from contiguous runs only — per-run Welch FFT, length-weighted power average; bands whose samples are only short scattered chunks report no spectrum instead of an artifact-ridden one. ThrottleTFAnalyzer estimates the per-band transfer function from the longest contiguous run (min 2048 samples) since cross-spectra require an unbroken time series. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJhhk2frYDDnpNbSHRxGdv --- src/main/analysis/CLAUDE.md | 4 +- .../ThrottleSpectrogramAnalyzer.test.ts | 106 ++++++++++++++++++ .../analysis/ThrottleSpectrogramAnalyzer.ts | 100 ++++++++++++++--- src/main/analysis/ThrottleTFAnalyzer.ts | 29 +++-- 4 files changed, 205 insertions(+), 34 deletions(-) diff --git a/src/main/analysis/CLAUDE.md b/src/main/analysis/CLAUDE.md index 2d85412..0e1453b 100644 --- a/src/main/analysis/CLAUDE.md +++ b/src/main/analysis/CLAUDE.md @@ -10,7 +10,7 @@ Noise analysis, step response, transfer function, and data quality scoring modul - **FFTCompute**: detrended + Hanning window, Welch's method (50% overlap, power-domain averaging), calibrated one-sided power spectrum (`SPECTRUM_SCALE_VERSION = 2`: sine of amplitude A reads 10·log10(A²/2); dB values sit ≈10 dB above the legacy v1 amplitude-averaged scale) - **NoiseAnalyzer**: Noise floor estimation, peak detection (prominence-based), source classification (frame resonance via size-aware `FRAME_RESONANCE_BY_SIZE` bands — 5" default 80-200 Hz, micros up to 350 Hz; motor harmonics; electrical >500 Hz). Peak detection: prominence-based with plateau handling, 15 Hz minimum spacing (`PEAK_MIN_SPACING_HZ`), parabolic sub-bin interpolation - **FilterRecommender**: Absolute noise-based target computation (convergent), safety bounds, propwash-aware gyro LPF1 floor (100 Hz min, bypass at -5 dB extreme noise on the v2 scale), beginner-friendly explanations. Medium noise handling (conditional LPF2 recommendations, incl. `DTERM_LPF2_DISABLE_THRESHOLD_DB` for the D-term disable rule), notch-aware resonance (notch counts as covering a peak only when `dyn_notch_count > 0`), conditional dynamic notch Q based on noise severity, size-aware dyn_notch_count target (2 sub-5", 1 for 5"+, max step 2/iteration). Dynamic-lowpass-aware: when `dyn_min_hz > 0`, all noise-floor and resonance rules target `dyn_min_hz`/`dyn_max_hz` instead of `static_hz`, proportionally adjusting max to maintain ratio. Exports `isGyroDynamicActive()`, `isDtermDynamicActive()` -- **ThrottleSpectrogramAnalyzer**: Bins gyro data by throttle level (10 bands), per-band FFT spectra and noise floors. Returns `ThrottleSpectrogramResult` +- **ThrottleSpectrogramAnalyzer**: Bins gyro data by throttle level (10 bands). Per-band spectra are computed from **contiguous runs only** (`findContiguousRuns`, min 512 samples/run; per-run Welch FFT, length-weighted power average) — concatenating non-contiguous samples would create splice artifacts. Bands lacking a long-enough run report no spectrum. Returns `ThrottleSpectrogramResult` - **GroupDelayEstimator**: Per-filter group delay estimation (PT1, biquad, notch). All lowpasses (LPF1 + LPF2) modeled as PT1 — the BF 4.3+ default (modeling LPF2 as biquad would overestimate its delay ~2×). Notch delay uses the denominator-only formula `τ(ω) = bw·(w0²+ω²) / ((w0²−ω²)² + bw²ω²)` (the numerator is purely real, contributing no phase slope). Returns `FilterGroupDelay` with gyroTotalMs, dtermTotalMs, warning if >2ms. Smart `dyn_notch_q` handling: `Q > 10 ? Q / 100 : Q` for BF internal storage quirk. Uses `dyn_min_hz` when dynamic lowpass is active (worst-case delay at tightest cutoff point) - **DynamicLowpassRecommender**: Analyzes throttle spectrogram for throttle-dependent noise (enable trigger: ≥6 dB increase, Pearson ≥0.6, ≥3 throttle bands with data). When dynamic is NOT active and throttle noise detected: recommends enabling dynamic lowpass (dyn_min = current static cutoff, dyn_max = static × 2 per BF 2:1 convention). When dynamic IS already active: returns no recommendations (FilterRecommender handles tuning dyn_min/max directly). When dynamic IS active but NO throttle-dependent noise: recommends disabling (dyn_min → 0) with low confidence — only when the delta is below `DYNAMIC_LOWPASS_DISABLE_DB = 4` (hysteresis: 4–6 dB gray zone leaves config untouched, preventing enable/disable flip-flop). Rules: F-DLPF-GYRO, F-DLPF-DTERM (enable), F-DLPF-GYRO-OFF, F-DLPF-DTERM-OFF (disable) - **FilterAnalyzer**: Orchestrator with async progress reporting. Passes both `gyro_lpf1_static_hz` and `dterm_lpf1_static_hz` to dynamic lowpass recommender. Returns throttle spectrogram + group delay in result @@ -37,7 +37,7 @@ Noise analysis, step response, transfer function, and data quality scoring modul - **MechanicalHealthChecker**: Pre-tuning diagnostics — extreme noise, axis asymmetry, motor imbalance. Extreme-noise threshold is size-aware: `max(-20 dB, NOISE_LEVEL_BY_SIZE[size].highDb + 5 dB)` — avoids false "damaged prop" flags on inherently noisy 1"/2.5" builds. Produces mechanical-health flags consumed by analyzers (may lower confidence or add warnings) - **WindDisturbanceDetector**: Gyro variance analysis for environmental disturbance. Computes and attaches `windDisturbance` metric to analysis result - **BayesianPIDOptimizer**: Lightweight Gaussian Process surrogate for iterative PID tuning across sessions -- **ThrottleTFAnalyzer**: Per-throttle-band transfer function (Wiener deconvolution) for TPA diagnostics (5 bands) +- **ThrottleTFAnalyzer**: Per-throttle-band transfer function (Wiener deconvolution) for TPA diagnostics (5 bands). Uses the longest contiguous run per band (min 2048 samples) — TF cross-spectra require an unbroken time series - **SliderMapper**: Maps raw PID gains to Betaflight Configurator slider UI positions - **headerValidation**: BBL header parsing/validation utilities, field name mapping. Low-logging-rate warning uses the effective log rate `1e6 / (looptime × pInterval × pDenom)`, not the raw gyro rate. Static LPF cutoffs (`gyro/dterm_lpf1/lpf2_static_hz`) and `dyn_notch_min/max_hz` are always enriched from BBL headers when present (BBL is the primary source — pre-populated defaults never mask header values) diff --git a/src/main/analysis/ThrottleSpectrogramAnalyzer.test.ts b/src/main/analysis/ThrottleSpectrogramAnalyzer.test.ts index 16e1a14..4a21e35 100644 --- a/src/main/analysis/ThrottleSpectrogramAnalyzer.test.ts +++ b/src/main/analysis/ThrottleSpectrogramAnalyzer.test.ts @@ -4,6 +4,7 @@ import { computeThrottleSpectrogram, DEFAULT_NUM_BANDS, MIN_SAMPLES_PER_BAND, + findContiguousRuns, } from './ThrottleSpectrogramAnalyzer'; import type { BlackboxFlightData, TimeSeries } from '@shared/types/blackbox.types'; @@ -387,3 +388,108 @@ describe('ThrottleSpectrogramAnalyzer', () => { }); }); }); + +describe('findContiguousRuns', () => { + it('returns one run for fully contiguous indices', () => { + const indices = Array.from({ length: 600 }, (_, i) => 100 + i); + expect(findContiguousRuns(indices, 512)).toEqual([{ start: 100, end: 700 }]); + }); + + it('splits on gaps and drops short runs', () => { + // Run A: 0-599 (600 long), gap, run B: 1000-1299 (300 long — too short) + const indices = [ + ...Array.from({ length: 600 }, (_, i) => i), + ...Array.from({ length: 300 }, (_, i) => 1000 + i), + ]; + expect(findContiguousRuns(indices, 512)).toEqual([{ start: 0, end: 600 }]); + }); + + it('sorts runs longest first', () => { + const indices = [ + ...Array.from({ length: 600 }, (_, i) => i), + ...Array.from({ length: 900 }, (_, i) => 2000 + i), + ]; + expect(findContiguousRuns(indices, 512)).toEqual([ + { start: 2000, end: 2900 }, + { start: 0, end: 600 }, + ]); + }); + + it('returns empty for empty input', () => { + expect(findContiguousRuns([], 512)).toEqual([]); + }); +}); + +describe('contiguity-safe band spectra', () => { + it('excludes a band whose samples are only short non-contiguous chunks', () => { + // Throttle alternates every 100 samples between band 2 (~0.25) and + // band 7 (~0.75): both bands collect >512 total samples but no + // contiguous run reaches MIN_CONTIGUOUS_RUN. + const numSamples = 20000; + const time = new Float64Array(numSamples).map((_, i) => i / SAMPLE_RATE); + const throttleValues = new Float64Array(numSamples); + for (let i = 0; i < numSamples; i++) { + throttleValues[i] = Math.floor(i / 100) % 2 === 0 ? 0.25 : 0.75; + } + const gyro = makeSineSeries(150, 30, numSamples); + const zero = makeZeroSeries(numSamples); + const flightData: BlackboxFlightData = { + gyro: [gyro, gyro, gyro], + setpoint: [zero, zero, zero, { time, values: throttleValues }], + pidP: [zero, zero, zero], + pidI: [zero, zero, zero], + pidD: [zero, zero, zero], + pidF: [zero, zero, zero], + motor: [zero, zero, zero, zero], + debug: [], + sampleRateHz: SAMPLE_RATE, + durationSeconds: numSamples / SAMPLE_RATE, + frameCount: numSamples, + }; + + const result = computeThrottleSpectrogram(flightData); + const band2 = result.bands[2]; + const band7 = result.bands[7]; + expect(band2.sampleCount).toBeGreaterThan(MIN_SAMPLES_PER_BAND); + expect(band7.sampleCount).toBeGreaterThan(MIN_SAMPLES_PER_BAND); + // Chunks are 100 samples — below MIN_CONTIGUOUS_RUN → no FFT + expect(band2.spectra).toBeUndefined(); + expect(band7.spectra).toBeUndefined(); + expect(result.bandsWithData).toBe(0); + }); + + it('computes spectra from contiguous runs only', () => { + // First half in band 2, second half in band 7 — both fully contiguous + const numSamples = 20000; + const time = new Float64Array(numSamples).map((_, i) => i / SAMPLE_RATE); + const throttleValues = new Float64Array(numSamples); + for (let i = 0; i < numSamples; i++) { + throttleValues[i] = i < numSamples / 2 ? 0.25 : 0.75; + } + const gyro = makeSineSeries(150, 30, numSamples); + const zero = makeZeroSeries(numSamples); + const flightData: BlackboxFlightData = { + gyro: [gyro, gyro, gyro], + setpoint: [zero, zero, zero, { time, values: throttleValues }], + pidP: [zero, zero, zero], + pidI: [zero, zero, zero], + pidD: [zero, zero, zero], + pidF: [zero, zero, zero], + motor: [zero, zero, zero, zero], + debug: [], + sampleRateHz: SAMPLE_RATE, + durationSeconds: numSamples / SAMPLE_RATE, + frameCount: numSamples, + }; + + const result = computeThrottleSpectrogram(flightData); + expect(result.bandsWithData).toBe(2); + const spectrum = result.bands[2].spectra![0]; + // The 150 Hz tone must be the dominant peak + let peakIdx = 0; + for (let i = 1; i < spectrum.magnitudes.length; i++) { + if (spectrum.magnitudes[i] > spectrum.magnitudes[peakIdx]) peakIdx = i; + } + expect(Math.abs(spectrum.frequencies[peakIdx] - 150)).toBeLessThan(5); + }); +}); diff --git a/src/main/analysis/ThrottleSpectrogramAnalyzer.ts b/src/main/analysis/ThrottleSpectrogramAnalyzer.ts index 311d5cc..6b1b8cf 100644 --- a/src/main/analysis/ThrottleSpectrogramAnalyzer.ts +++ b/src/main/analysis/ThrottleSpectrogramAnalyzer.ts @@ -25,6 +25,11 @@ export const DEFAULT_NUM_BANDS = 10; /** Minimum samples per band to compute a meaningful spectrum */ export const MIN_SAMPLES_PER_BAND = 512; +/** Minimum contiguous run length (samples) usable for a band FFT window. + * Bands are FFT'd per contiguous run — concatenating non-contiguous samples + * would create phantom spectral content at the splice discontinuities. */ +export const MIN_CONTIGUOUS_RUN = 512; + /** * Bin flight data samples by throttle level and collect gyro indices per band. * @@ -48,14 +53,66 @@ export function binByThrottle(throttleValues: Float64Array, numBands: number): n } /** - * Collect gyro values at the given sample indices into a new Float64Array. + * Extract contiguous runs from an ascending list of original-sample indices. + * A run is a maximal stretch where each index is the previous one + 1. + * Returns [start, end) ranges in the original sample space, longest first. + */ +export function findContiguousRuns( + indices: number[], + minLength: number +): Array<{ start: number; end: number }> { + const runs: Array<{ start: number; end: number }> = []; + let runStart = 0; + for (let i = 1; i <= indices.length; i++) { + if (i === indices.length || indices[i] !== indices[i - 1] + 1) { + if (i - runStart >= minLength) { + runs.push({ start: indices[runStart], end: indices[i - 1] + 1 }); + } + runStart = i; + } + } + runs.sort((a, b) => b.end - b.start - (a.end - a.start)); + return runs; +} + +/** + * Weighted power-domain average of per-run Welch spectra for one axis. + * Each run is FFT'd on its own contiguous slice; averages are weighted by + * run length. All runs use the same window size → identical frequency bins. */ -function gatherSamples(gyroValues: Float64Array, indices: number[]): Float64Array { - const out = new Float64Array(indices.length); - for (let i = 0; i < indices.length; i++) { - out[i] = gyroValues[indices[i]]; +function averageRunSpectra( + gyroValues: Float64Array, + runs: Array<{ start: number; end: number }>, + sampleRateHz: number, + windowSize: number +): PowerSpectrum { + const numBins = windowSize / 2 + 1; + const avgPower = new Float64Array(numBins); + let frequencies: Float64Array | null = null; + let totalWeight = 0; + + for (const run of runs) { + const slice = gyroValues.subarray(run.start, run.end); + if (slice.length < windowSize) continue; + const spectrum = computePowerSpectrum(slice, sampleRateHz, windowSize); + if (!frequencies) frequencies = spectrum.frequencies; + const weight = run.end - run.start; + for (let i = 0; i < numBins; i++) { + avgPower[i] += Math.pow(10, spectrum.magnitudes[i] / 10) * weight; + } + totalWeight += weight; + } + + if (!frequencies || totalWeight === 0) { + return { frequencies: new Float64Array(0), magnitudes: new Float64Array(0) }; + } + + const magnitudes = new Float64Array(numBins); + for (let i = 0; i < numBins; i++) { + const avg = avgPower[i] / totalWeight; + magnitudes[i] = avg > 1e-24 ? 10 * Math.log10(avg) : -240; } - return out; + return { frequencies, magnitudes }; } /** @@ -98,8 +155,16 @@ export function computeThrottleSpectrogram( sampleCount: indices.length, }; - if (indices.length >= MIN_SAMPLES_PER_BAND) { - // Compute spectrum per axis + // Only contiguous runs are FFT'd — concatenating non-contiguous samples + // creates phantom spectral content at splice discontinuities. + const runs = + indices.length >= MIN_SAMPLES_PER_BAND ? findContiguousRuns(indices, MIN_CONTIGUOUS_RUN) : []; + + if (runs.length > 0) { + // Window fits inside the longest run (runs are sorted longest-first) + const longestRun = runs[0].end - runs[0].start; + const windowSize = Math.min(FFT_WINDOW_SIZE, prevPowerOf2(longestRun)); + const spectra: [PowerSpectrum, PowerSpectrum, PowerSpectrum] = [ { frequencies: new Float64Array(0), magnitudes: new Float64Array(0) }, { frequencies: new Float64Array(0), magnitudes: new Float64Array(0) }, @@ -107,12 +172,13 @@ export function computeThrottleSpectrogram( ]; const noiseFloors: [number, number, number] = [0, 0, 0]; - // Use smaller FFT window if band has fewer samples than default window size - const windowSize = Math.min(FFT_WINDOW_SIZE, nextPowerOf2(Math.floor(indices.length / 2))); - for (let axis = 0; axis < 3; axis++) { - const samples = gatherSamples(flightData.gyro[axis].values, indices); - const raw = computePowerSpectrum(samples, flightData.sampleRateHz, windowSize); + const raw = averageRunSpectra( + flightData.gyro[axis].values, + runs, + flightData.sampleRateHz, + windowSize + ); spectra[axis] = trimSpectrum(raw, FREQUENCY_MIN_HZ, FREQUENCY_MAX_HZ); noiseFloors[axis] = estimateNoiseFloor(spectra[axis].magnitudes); } @@ -134,11 +200,11 @@ export function computeThrottleSpectrogram( } /** - * Round up to the next power of 2. + * Round down to the largest power of 2 <= n. */ -function nextPowerOf2(n: number): number { - if (n <= 0) return 1; +function prevPowerOf2(n: number): number { + if (n < 1) return 1; let p = 1; - while (p < n) p <<= 1; + while (p * 2 <= n) p <<= 1; return p; } diff --git a/src/main/analysis/ThrottleTFAnalyzer.ts b/src/main/analysis/ThrottleTFAnalyzer.ts index bc6e498..f0761dc 100644 --- a/src/main/analysis/ThrottleTFAnalyzer.ts +++ b/src/main/analysis/ThrottleTFAnalyzer.ts @@ -8,7 +8,7 @@ */ import type { BlackboxFlightData } from '@shared/types/blackbox.types'; -import { binByThrottle } from './ThrottleSpectrogramAnalyzer'; +import { binByThrottle, findContiguousRuns } from './ThrottleSpectrogramAnalyzer'; import { estimateTransferFunction, extractMetrics, @@ -59,16 +59,10 @@ export interface ThrottleTFResult { tpaWarning?: string; } -/** - * Gather samples at given indices from a Float64Array. - */ -function gatherSamples(data: Float64Array, indices: number[]): Float64Array { - const out = new Float64Array(indices.length); - for (let i = 0; i < indices.length; i++) { - out[i] = data[indices[i]]; - } - return out; -} +/** Minimum contiguous run length usable for per-band TF estimation. + * TF deconvolution needs an unbroken time series — splicing non-contiguous + * samples corrupts the cross-spectra. */ +export const MIN_TF_RUN_SAMPLES = 2048; /** * Compute standard deviation of an array of numbers. @@ -101,12 +95,17 @@ function estimatePerBand( return { throttleMin, throttleMax, sampleCount, metrics: null }; } - const bandSetpoint = gatherSamples(setpoint, indices); - const bandGyro = gatherSamples(gyro, indices); + // TF needs an unbroken time series — use the longest contiguous run in + // the band instead of splicing non-contiguous samples together. + const runs = findContiguousRuns(indices, MIN_TF_RUN_SAMPLES); + if (runs.length === 0) { + return { throttleMin, throttleMax, sampleCount, metrics: null }; + } + const run = runs[0]; // longest first const { bode, impulseResponse } = estimateTransferFunction( - bandSetpoint, - bandGyro, + setpoint.subarray(run.start, run.end), + gyro.subarray(run.start, run.end), sampleRateHz ); const trimmed = trimBode(bode, TF_MAX_FREQ_HZ); From ced82a9fa541501c6c3b1366143664a2e3e20190 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:35:44 +0000 Subject: [PATCH 09/13] =?UTF-8?q?feat:=20yaw=20coverage=20=E2=80=94=20stea?= =?UTF-8?q?diness=20gating=20and=20yaw-only=20resonance=20observation=20(P?= =?UTF-8?q?1.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steady-segment detection now also checks yaw gyro variance with a relaxed 1.5x threshold (YAW_STEADY_MULTIPLIER) — a segment with an active yaw spin is not a steady hover and polluted noise statistics. FilterRecommender gains rule F-YAW-RES: a strong yaw-only peak (>=12 dB) that the dynamic notch does not cover and that has no roll/pitch counterpart is surfaced as an informational observation (loose stack / motor mount / frame flex indicator). Yaw still never drives LPF cutoffs by design — documented in the KB. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJhhk2frYDDnpNbSHRxGdv --- docs/PID_TUNING_KNOWLEDGE.md | 3 ++ src/main/analysis/CLAUDE.md | 2 +- src/main/analysis/FilterRecommender.test.ts | 45 +++++++++++++++++++ src/main/analysis/FilterRecommender.ts | 50 +++++++++++++++++++++ src/main/analysis/SegmentSelector.test.ts | 28 +++++++++++- src/main/analysis/SegmentSelector.ts | 12 ++++- src/main/analysis/constants.ts | 4 ++ 7 files changed, 140 insertions(+), 4 deletions(-) diff --git a/docs/PID_TUNING_KNOWLEDGE.md b/docs/PID_TUNING_KNOWLEDGE.md index 4b9000b..0ee20f8 100644 --- a/docs/PID_TUNING_KNOWLEDGE.md +++ b/docs/PID_TUNING_KNOWLEDGE.md @@ -610,6 +610,9 @@ FPVPIDlab's noise-to-cutoff interpolation range: **-60 dB (cleanest) to 0 dB (no **Rule 5: Motor Harmonic Diagnostic** (when RPM filter active) - If motor harmonics still detected at ≥12 dB: emit warning about possible `motor_poles` misconfiguration or ESC telemetry issues +**Rule 7: Yaw-Only Resonance Observation (F-YAW-RES)** — informational +- Yaw is deliberately excluded from LPF cutoff decisions (inherently noisier; lowering a global LPF for a yaw-only peak taxes roll/pitch latency). But a yaw peak ≥12 dB that the dynamic notch does not cover and that has no roll/pitch counterpart (within 15 Hz) is surfaced as an informational observation — it often indicates a loose FC stack, uneven motor mounting, or yaw-axis frame flex. + **Deduplication**: For overlapping recommendations on same parameter — keep more aggressive value, upgrade confidence if either was 'high'. ### PID Recommendation Rules diff --git a/src/main/analysis/CLAUDE.md b/src/main/analysis/CLAUDE.md index 0e1453b..e887d62 100644 --- a/src/main/analysis/CLAUDE.md +++ b/src/main/analysis/CLAUDE.md @@ -6,7 +6,7 @@ Noise analysis, step response, transfer function, and data quality scoring modul **Pipeline**: SegmentSelector → FFTCompute → NoiseAnalyzer → FilterRecommender → FilterAnalyzer -- **SegmentSelector**: Finds stable hover segments and throttle sweep segments (excludes takeoff/landing/acro) +- **SegmentSelector**: Finds stable hover segments and throttle sweep segments (excludes takeoff/landing/acro). Steadiness checks roll/pitch (≤50 deg/s std) AND yaw with a relaxed 1.5× threshold (`YAW_STEADY_MULTIPLIER`) — active yaw spins disqualify a segment - **FFTCompute**: detrended + Hanning window, Welch's method (50% overlap, power-domain averaging), calibrated one-sided power spectrum (`SPECTRUM_SCALE_VERSION = 2`: sine of amplitude A reads 10·log10(A²/2); dB values sit ≈10 dB above the legacy v1 amplitude-averaged scale) - **NoiseAnalyzer**: Noise floor estimation, peak detection (prominence-based), source classification (frame resonance via size-aware `FRAME_RESONANCE_BY_SIZE` bands — 5" default 80-200 Hz, micros up to 350 Hz; motor harmonics; electrical >500 Hz). Peak detection: prominence-based with plateau handling, 15 Hz minimum spacing (`PEAK_MIN_SPACING_HZ`), parabolic sub-bin interpolation - **FilterRecommender**: Absolute noise-based target computation (convergent), safety bounds, propwash-aware gyro LPF1 floor (100 Hz min, bypass at -5 dB extreme noise on the v2 scale), beginner-friendly explanations. Medium noise handling (conditional LPF2 recommendations, incl. `DTERM_LPF2_DISABLE_THRESHOLD_DB` for the D-term disable rule), notch-aware resonance (notch counts as covering a peak only when `dyn_notch_count > 0`), conditional dynamic notch Q based on noise severity, size-aware dyn_notch_count target (2 sub-5", 1 for 5"+, max step 2/iteration). Dynamic-lowpass-aware: when `dyn_min_hz > 0`, all noise-floor and resonance rules target `dyn_min_hz`/`dyn_max_hz` instead of `static_hz`, proportionally adjusting max to maintain ratio. Exports `isGyroDynamicActive()`, `isDtermDynamicActive()` diff --git a/src/main/analysis/FilterRecommender.test.ts b/src/main/analysis/FilterRecommender.test.ts index 1fba5dd..d3fa51a 100644 --- a/src/main/analysis/FilterRecommender.test.ts +++ b/src/main/analysis/FilterRecommender.test.ts @@ -1601,3 +1601,48 @@ describe('variability-aware hysteresis', () => { } }); }); + +describe('yaw-only resonance observation (F-YAW-RES)', () => { + it('emits an informational observation for a strong yaw-only peak outside notch range', () => { + const noise = makeNoiseProfile({ + level: 'medium', + yawPeaks: [{ frequency: 130, amplitude: 18, type: 'frame_resonance' }], + }); + const current: CurrentFilterSettings = { + ...DEFAULT_FILTER_SETTINGS, + dyn_notch_count: 0, // notch disabled → nothing covers the yaw peak + }; + + const recs = recommend(noise, current); + const obs = recs.find((r) => r.ruleId === 'F-YAW-RES'); + expect(obs).toBeDefined(); + expect(obs!.informational).toBe(true); + expect(obs!.currentValue).toBe(obs!.recommendedValue); + expect(obs!.reason).toContain('130'); + }); + + it('stays silent when the notch covers the yaw peak', () => { + const noise = makeNoiseProfile({ + level: 'medium', + yawPeaks: [{ frequency: 130, amplitude: 18, type: 'frame_resonance' }], + }); + // Default settings: notch enabled and 130 Hz within range + const recs = recommend(noise, DEFAULT_FILTER_SETTINGS); + expect(recs.find((r) => r.ruleId === 'F-YAW-RES')).toBeUndefined(); + }); + + it('stays silent when the same peak also appears on roll (other rules act)', () => { + const noise = makeNoiseProfile({ + level: 'medium', + rollPeaks: [{ frequency: 128, amplitude: 20, type: 'frame_resonance' }], + yawPeaks: [{ frequency: 130, amplitude: 18, type: 'frame_resonance' }], + }); + const current: CurrentFilterSettings = { + ...DEFAULT_FILTER_SETTINGS, + dyn_notch_count: 0, + }; + + const recs = recommend(noise, current); + expect(recs.find((r) => r.ruleId === 'F-YAW-RES')).toBeUndefined(); + }); +}); diff --git a/src/main/analysis/FilterRecommender.ts b/src/main/analysis/FilterRecommender.ts index 8dfd630..b494cfe 100644 --- a/src/main/analysis/FilterRecommender.ts +++ b/src/main/analysis/FilterRecommender.ts @@ -27,6 +27,7 @@ import { NOISE_FLOOR_VERY_CLEAN_DB, NOISE_TARGET_DEADZONE_HZ, RESONANCE_ACTION_THRESHOLD_DB, + PEAK_MIN_SPACING_HZ, RESONANCE_CUTOFF_MARGIN_HZ, PROPWASH_GYRO_LPF1_FLOOR_HZ, PROPWASH_FLOOR_BYPASS_DB, @@ -100,10 +101,59 @@ export function recommend( // 6. LPF2 recommendations (disable when clean + RPM, enable when noisy) recommendLpf2Adjustments(noise, current, recommendations, rpmActive); + // 7. Yaw-only resonance observation (informational — yaw never drives LPF cutoffs) + recommendYawResonanceObservation(noise, current, recommendations); + // Deduplicate: if multiple rules recommend the same setting, keep the more aggressive one return deduplicateRecommendations(recommendations); } +/** + * Surface strong yaw-only noise peaks that no other rule covers. + * + * Yaw is deliberately excluded from LPF cutoff decisions (inherently noisier; + * lowering a global LPF for a yaw-only peak taxes roll/pitch latency) and + * from the notch-range rules only when the notch already covers the peak. + * A strong yaw peak that the dynamic notch cannot handle and that no + * roll/pitch rule will act on still deserves the pilot's attention — it + * often indicates a loose stack, tension mismatch, or yaw-axis frame flex. + */ +function recommendYawResonanceObservation( + noise: NoiseProfile, + current: CurrentFilterSettings, + out: FilterRecommendation[] +): void { + const rollPitchPeaks = [...noise.roll.peaks, ...noise.pitch.peaks].filter( + (p) => p.amplitude >= RESONANCE_ACTION_THRESHOLD_DB + ); + + const yawOnlyPeaks = noise.yaw.peaks.filter( + (p) => + p.amplitude >= RESONANCE_ACTION_THRESHOLD_DB && + !isPeakInDynNotchRange(p.frequency, current) && + // Skip peaks that also appear on roll/pitch — those rules already act + !rollPitchPeaks.some((rp) => Math.abs(rp.frequency - p.frequency) < PEAK_MIN_SPACING_HZ) + ); + + if (yawOnlyPeaks.length === 0) return; + + const strongest = yawOnlyPeaks.reduce((a, b) => (b.amplitude > a.amplitude ? b : a)); + out.push({ + setting: 'dyn_notch_count', + currentValue: current.dyn_notch_count ?? 3, + recommendedValue: current.dyn_notch_count ?? 3, + reason: + `A strong yaw-only noise peak was detected at ${Math.round(strongest.frequency)} Hz ` + + `(${Math.round(strongest.amplitude)} dB above the floor) that the dynamic notch does not cover. ` + + 'Yaw peaks like this often point to a loose FC stack, uneven motor mounting, or frame flex — ' + + 'inspect hardware, or extend the dynamic notch range to cover it.', + impact: 'noise', + confidence: 'low', + informational: true, + ruleId: 'F-YAW-RES', + }); +} + /** * Compute an absolute target cutoff from the noise floor dB level. * Linear interpolation: VERY_NOISY_DB → minHz, VERY_CLEAN_DB → maxHz. diff --git a/src/main/analysis/SegmentSelector.test.ts b/src/main/analysis/SegmentSelector.test.ts index 9b1dc0c..b584c22 100644 --- a/src/main/analysis/SegmentSelector.test.ts +++ b/src/main/analysis/SegmentSelector.test.ts @@ -16,11 +16,13 @@ function createFlightData(opts: { throttle?: (i: number) => number; gyroRoll?: (i: number) => number; gyroPitch?: (i: number) => number; + gyroYaw?: (i: number) => number; }): BlackboxFlightData { const { sampleRate, numSamples } = opts; const throttleFn = opts.throttle || (() => 0.5); const rollFn = opts.gyroRoll || (() => 0); const pitchFn = opts.gyroPitch || (() => 0); + const yawFn = opts.gyroYaw || (() => 0); function makeSeries(fn: (i: number) => number): TimeSeries { const time = new Float64Array(numSamples); @@ -35,7 +37,7 @@ function createFlightData(opts: { const zeroSeries = makeSeries(() => 0); return { - gyro: [makeSeries(rollFn), makeSeries(pitchFn), makeSeries(() => 0)], + gyro: [makeSeries(rollFn), makeSeries(pitchFn), makeSeries(yawFn)], setpoint: [zeroSeries, zeroSeries, zeroSeries, makeSeries(throttleFn)], pidP: [zeroSeries, zeroSeries, zeroSeries], pidI: [zeroSeries, zeroSeries, zeroSeries], @@ -445,3 +447,27 @@ describe('findThrottleSweepSegments', () => { expect(segments[0].maxThrottle).toBeCloseTo(0.9, 1); }); }); + +describe('yaw steadiness (relaxed threshold)', () => { + it('rejects segments with an active yaw spin', () => { + // Roll/pitch steady, yaw spinning hard (std >> 75 deg/s) + const data = createFlightData({ + sampleRate: 1000, + numSamples: 4000, + throttle: () => 0.5, + gyroYaw: (i) => 300 * Math.sin(i * 0.3), + }); + expect(findSteadySegments(data).length).toBe(0); + }); + + it('tolerates moderate yaw noise above the roll/pitch limit', () => { + // Yaw std ~60 deg/s: above GYRO_STEADY_MAX_STD (50) but below 50×1.5 + const data = createFlightData({ + sampleRate: 1000, + numSamples: 4000, + throttle: () => 0.5, + gyroYaw: (i) => 85 * Math.sin(i * 0.3), + }); + expect(findSteadySegments(data).length).toBeGreaterThan(0); + }); +}); diff --git a/src/main/analysis/SegmentSelector.ts b/src/main/analysis/SegmentSelector.ts index c4ae34f..efdae63 100644 --- a/src/main/analysis/SegmentSelector.ts +++ b/src/main/analysis/SegmentSelector.ts @@ -13,6 +13,7 @@ import { THROTTLE_MIN_FLIGHT, THROTTLE_MAX_HOVER, GYRO_STEADY_MAX_STD, + YAW_STEADY_MULTIPLIER, SEGMENT_MIN_DURATION_S, SEGMENT_WINDOW_DURATION_S, SWEEP_MIN_THROTTLE_RANGE, @@ -36,6 +37,7 @@ export function findSteadySegments(flightData: BlackboxFlightData): FlightSegmen const throttle = flightData.setpoint[3]; // Throttle channel const gyroRoll = flightData.gyro[0]; const gyroPitch = flightData.gyro[1]; + const gyroYaw = flightData.gyro[2]; const numSamples = throttle.values.length; if (numSamples === 0) return []; @@ -54,13 +56,19 @@ export function findSteadySegments(flightData: BlackboxFlightData): FlightSegmen continue; } - // Check gyro variance in a local window + // Check gyro variance in a local window (yaw with a relaxed threshold — + // inherently noisier, but an active yaw spin is not a steady hover) const wStart = Math.max(0, i - halfWindow); const wEnd = Math.min(numSamples, i + halfWindow); const rollStd = computeStd(gyroRoll.values, wStart, wEnd); const pitchStd = computeStd(gyroPitch.values, wStart, wEnd); + const yawStd = computeStd(gyroYaw.values, wStart, wEnd); - if (rollStd <= GYRO_STEADY_MAX_STD && pitchStd <= GYRO_STEADY_MAX_STD) { + if ( + rollStd <= GYRO_STEADY_MAX_STD && + pitchStd <= GYRO_STEADY_MAX_STD && + yawStd <= GYRO_STEADY_MAX_STD * YAW_STEADY_MULTIPLIER + ) { steadyMask[i] = 1; } } diff --git a/src/main/analysis/constants.ts b/src/main/analysis/constants.ts index 1fc891c..c9c789c 100644 --- a/src/main/analysis/constants.ts +++ b/src/main/analysis/constants.ts @@ -30,6 +30,10 @@ export const THROTTLE_MAX_HOVER = 0.75; /** Maximum gyro standard deviation (deg/s) for a "steady" segment */ export const GYRO_STEADY_MAX_STD = 50; +/** Yaw steadiness threshold multiplier — yaw is inherently noisier than + * roll/pitch, but an active yaw spin still disqualifies a "steady" segment. */ +export const YAW_STEADY_MULTIPLIER = 1.5; + /** Minimum segment duration in seconds */ export const SEGMENT_MIN_DURATION_S = 0.5; From 1200ac668aa941da3e2eda8212030bca19ba46c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:35:44 +0000 Subject: [PATCH 10/13] feat: verify applied advanced settings via extended MSP_PID_ADVANCED read-back (P1.9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getFeedforwardConfiguration now parses the fields that were always present in mspLayouts.ts but never read: feedforward_averaging, dyn_idle_min_rpm (base layout), and vbat_sag_compensation, thrust_linear, anti_gravity_gain, tpa_mode/rate/breakpoint (extended API 1.45+ layout, gated by response length so a short pre-1.45 response never misreads offset 21). verifyAppliedConfig moves those settings from the skip-list into the verified set — previously TPA, anti-gravity, thrust linearization, dynamic idle and vbat sag changes were applied and saved with zero read-back verification. On old firmware they degrade to 'unchecked' (verified=false) instead of being silently skipped. Genuinely CLI-only settings (tpa_low_always, pidsum_limit*, rc_smoothing_auto_factor, simplified_dmax_gain, dterm_lpf1_dyn_expo) remain skipped. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJhhk2frYDDnpNbSHRxGdv --- src/main/CLAUDE.md | 2 +- src/main/msp/MSPClient.test.ts | 40 +++++++++++++++++++ src/main/msp/MSPClient.ts | 16 ++++++++ src/main/msp/test/mspResponseFactory.ts | 30 +++++++++++++- src/main/utils/verifyAppliedConfig.test.ts | 46 ++++++++++++++++++++-- src/main/utils/verifyAppliedConfig.ts | 27 +++++++------ src/shared/types/pid.types.ts | 16 ++++++++ 7 files changed, 157 insertions(+), 20 deletions(-) diff --git a/src/main/CLAUDE.md b/src/main/CLAUDE.md index feb9f8e..cb3ef94 100644 --- a/src/main/CLAUDE.md +++ b/src/main/CLAUDE.md @@ -115,4 +115,4 @@ Snapshots carry tuning metadata (`tuningSessionNumber`, `tuningType`, `snapshotR ## Post-Apply Verification -On smart reconnect after apply, `verifyAppliedConfig()` (`src/main/utils/verifyAppliedConfig.ts`) reads back full PID and filter configuration from FC via MSP, compares ALL readable values (not just applied changes), and runs sanity checks (P/I/D=0, filter bypassed). Applied feedforward changes are also verified for the MSP-readable subset (boost, smooth/jitter factor, max rate limit); CLI-only FF settings (e.g. `feedforward_averaging`, `tpa_*`) are skipped during verification (same treatment as `rpm_filter_q`); only unknown settings land in `unchecked`. Retries PID write+readback once on mismatch (10s timeout). Results stored on `TuningSession.applyVerified`, `applyMismatches`, `applyExpected`, `applyActual`, `applySuspicious`, and `autoReportId`. On failure, auto-submits diagnostic report (Pro only). +On smart reconnect after apply, `verifyAppliedConfig()` (`src/main/utils/verifyAppliedConfig.ts`) reads back full PID and filter configuration from FC via MSP, compares ALL readable values (not just applied changes), and runs sanity checks (P/I/D=0, filter bypassed). Applied feedforward changes are verified for the full MSP-readable set (boost, smooth/jitter factor, max rate limit, averaging, d_min_gain, iterm relax, anti-gravity gain, thrust linearization, dynamic idle, vbat sag, TPA mode/rate/breakpoint — the latter group parsed from the extended API 1.45+ MSP_PID_ADVANCED layout, marked `unchecked` on older firmware). Genuinely CLI-only settings (`tpa_low_always`, `pidsum_limit*`, `rc_smoothing_auto_factor`, `simplified_dmax_gain`, `dterm_lpf1_dyn_expo`, `rpm_filter_q`) are skipped during verification; unknown settings land in `unchecked`. Retries PID write+readback once on mismatch (10s timeout). Results stored on `TuningSession.applyVerified`, `applyMismatches`, `applyExpected`, `applyActual`, `applySuspicious`, and `autoReportId`. On failure, auto-submits diagnostic report (Pro only). diff --git a/src/main/msp/MSPClient.test.ts b/src/main/msp/MSPClient.test.ts index fe342b1..87370ba 100644 --- a/src/main/msp/MSPClient.test.ts +++ b/src/main/msp/MSPClient.test.ts @@ -344,9 +344,49 @@ describe('MSPClient.getFeedforwardConfiguration', () => { itermRelax: 0, itermRelaxType: 0, itermRelaxCutoff: 0, + averaging: 0, + dynIdleMinRpm: 0, }); }); + it('parses extended API 1.45+ fields from a 61-byte response', async () => { + const buf = Buffer.alloc(61, 0); + writeField(buf, PID_ADVANCED.FF_BOOST, 15); + writeField(buf, PID_ADVANCED.FF_AVERAGING, 2); + writeField(buf, PID_ADVANCED.IDLE_MIN_RPM, 30); + writeField(buf, PID_ADVANCED.VBAT_SAG_COMPENSATION, 75); + writeField(buf, PID_ADVANCED.THRUST_LINEARIZATION, 25); + writeField(buf, PID_ADVANCED.ANTI_GRAVITY_GAIN, 110); + writeField(buf, PID_ADVANCED.TPA_MODE, 1); + writeField(buf, PID_ADVANCED.TPA_RATE, 65); + writeField(buf, PID_ADVANCED.TPA_BREAKPOINT, 1350); + + mockSendCommand.mockResolvedValue({ command: MSPCommand.MSP_PID_ADVANCED, data: buf }); + + const result = await client.getFeedforwardConfiguration(); + + expect(result.averaging).toBe(2); + expect(result.dynIdleMinRpm).toBe(30); + expect(result.vbatSagCompensation).toBe(75); + expect(result.thrustLinear).toBe(25); + expect(result.antiGravityGain).toBe(110); + expect(result.tpaMode).toBe(1); + expect(result.tpaRate).toBe(65); + expect(result.tpaBreakpoint).toBe(1350); + }); + + it('omits extended fields on a 55-byte (API < 1.45) response', async () => { + const buf = Buffer.alloc(55, 0); + mockSendCommand.mockResolvedValue({ command: MSPCommand.MSP_PID_ADVANCED, data: buf }); + + const result = await client.getFeedforwardConfiguration(); + + expect(result.vbatSagCompensation).toBeUndefined(); + expect(result.thrustLinear).toBeUndefined(); + expect(result.antiGravityGain).toBeUndefined(); + expect(result.tpaRate).toBeUndefined(); + }); + it('throws on response shorter than minimum length', async () => { const buf = Buffer.alloc(30, 0); mockSendCommand.mockResolvedValue({ command: MSPCommand.MSP_PID_ADVANCED, data: buf }); diff --git a/src/main/msp/MSPClient.ts b/src/main/msp/MSPClient.ts index 4532dbf..8d06cc2 100644 --- a/src/main/msp/MSPClient.ts +++ b/src/main/msp/MSPClient.ts @@ -891,8 +891,24 @@ export class MSPClient extends EventEmitter { itermRelax: readField(response.data, PID_ADVANCED.ITERM_RELAX), // iterm_relax itermRelaxType: readField(response.data, PID_ADVANCED.ITERM_RELAX_TYPE), // iterm_relax_type itermRelaxCutoff: readField(response.data, PID_ADVANCED.ITERM_RELAX_CUTOFF), // iterm_relax_cutoff + averaging: readField(response.data, PID_ADVANCED.FF_AVERAGING), // feedforward_averaging + dynIdleMinRpm: readField(response.data, PID_ADVANCED.IDLE_MIN_RPM), // dyn_idle_min_rpm }; + // Fields beyond the minimum layout — present on longer responses only + if (response.data.length > PID_ADVANCED.THRUST_LINEARIZATION.offset) { + config.vbatSagCompensation = readField(response.data, PID_ADVANCED.VBAT_SAG_COMPENSATION); // vbat_sag_compensation + config.thrustLinear = readField(response.data, PID_ADVANCED.THRUST_LINEARIZATION); // thrust_linear + } + // TPA fields were appended in API 1.45 — a response long enough to carry + // them also guarantees the API >= 1.45 meaning of anti_gravity_gain @21 + if (response.data.length > PID_ADVANCED.TPA_BREAKPOINT.offset + 1) { + config.antiGravityGain = readField(response.data, PID_ADVANCED.ANTI_GRAVITY_GAIN); // anti_gravity_gain + config.tpaMode = readField(response.data, PID_ADVANCED.TPA_MODE); // tpa_mode + config.tpaRate = readField(response.data, PID_ADVANCED.TPA_RATE); // tpa_rate + config.tpaBreakpoint = readField(response.data, PID_ADVANCED.TPA_BREAKPOINT); // tpa_breakpoint + } + logger.info('Feedforward configuration read:', config); return config; } diff --git a/src/main/msp/test/mspResponseFactory.ts b/src/main/msp/test/mspResponseFactory.ts index 9109bb8..f2ddb04 100644 --- a/src/main/msp/test/mspResponseFactory.ts +++ b/src/main/msp/test/mspResponseFactory.ts @@ -241,7 +241,9 @@ export function buildAdvancedConfigData(pidProcessDenom: number, gyroSyncDenom = return buf; } -/** MSP_PID_ADVANCED (94) — 55+ bytes (feedforward configuration, BF 4.3+) */ +/** MSP_PID_ADVANCED (94) — 55+ bytes (feedforward configuration, BF 4.3+). + * Pass `fullLayout: true` for the API 1.45+ 61-byte layout that carries + * vbat sag / thrust linearization / anti-gravity gain / TPA fields. */ export function buildPIDAdvancedData( opts: { ffTransition?: number; @@ -260,9 +262,33 @@ export function buildPIDAdvancedData( itermRelax?: number; itermRelaxType?: number; itermRelaxCutoff?: number; + ffAveraging?: number; + dynIdleMinRpm?: number; + fullLayout?: boolean; + vbatSagCompensation?: number; + thrustLinear?: number; + antiGravityGain?: number; + tpaMode?: number; + tpaRate?: number; + tpaBreakpoint?: number; } = {} ): Buffer { - const buf = Buffer.alloc(55, 0); + const buf = Buffer.alloc(opts.fullLayout ? 61 : 55, 0); + if (opts.ffAveraging !== undefined) writeField(buf, PID_ADVANCED.FF_AVERAGING, opts.ffAveraging); + if (opts.dynIdleMinRpm !== undefined) + writeField(buf, PID_ADVANCED.IDLE_MIN_RPM, opts.dynIdleMinRpm); + if (opts.fullLayout) { + if (opts.vbatSagCompensation !== undefined) + writeField(buf, PID_ADVANCED.VBAT_SAG_COMPENSATION, opts.vbatSagCompensation); + if (opts.thrustLinear !== undefined) + writeField(buf, PID_ADVANCED.THRUST_LINEARIZATION, opts.thrustLinear); + if (opts.antiGravityGain !== undefined) + writeField(buf, PID_ADVANCED.ANTI_GRAVITY_GAIN, opts.antiGravityGain); + if (opts.tpaMode !== undefined) writeField(buf, PID_ADVANCED.TPA_MODE, opts.tpaMode); + if (opts.tpaRate !== undefined) writeField(buf, PID_ADVANCED.TPA_RATE, opts.tpaRate); + if (opts.tpaBreakpoint !== undefined) + writeField(buf, PID_ADVANCED.TPA_BREAKPOINT, opts.tpaBreakpoint); + } if (opts.ffTransition !== undefined) writeField(buf, PID_ADVANCED.FF_TRANSITION, opts.ffTransition); if (opts.ffRoll !== undefined) writeField(buf, PID_ADVANCED.FF_ROLL, opts.ffRoll); diff --git a/src/main/utils/verifyAppliedConfig.test.ts b/src/main/utils/verifyAppliedConfig.test.ts index fd9a4d7..c8b0ae1 100644 --- a/src/main/utils/verifyAppliedConfig.test.ts +++ b/src/main/utils/verifyAppliedConfig.test.ts @@ -312,21 +312,59 @@ describe('verifyAppliedConfig', () => { expect(result.actual.iterm_relax_cutoff).toBe(12); }); - it('skips CLI-only FF settings without failing verification', async () => { - const msp = createFFMockMSPClient(); + it('verifies advanced settings via the extended MSP_PID_ADVANCED read-back', async () => { + const msp = createFFMockMSPClient({ + tpaRate: 55, + antiGravityGain: 110, + averaging: 2, + thrustLinear: 25, + vbatSagCompensation: 75, + dynIdleMinRpm: 30, + }); const applied: AppliedChange[] = [ { setting: 'tpa_rate', previousValue: 65, newValue: 55 }, { setting: 'anti_gravity_gain', previousValue: 80, newValue: 110 }, { setting: 'feedforward_averaging', previousValue: 0, newValue: 2 }, - { setting: 'simplified_dmax_gain', previousValue: 37, newValue: 0 }, + { setting: 'thrust_linear', previousValue: 0, newValue: 25 }, + { setting: 'vbat_sag_compensation', previousValue: 0, newValue: 75 }, + { setting: 'dyn_idle_min_rpm', previousValue: 0, newValue: 30 }, + { setting: 'simplified_dmax_gain', previousValue: 37, newValue: 0 }, // genuinely CLI-only ]; const result = await verifyAppliedConfig(msp, 'pid', undefined, undefined, applied); expect(result.verified).toBe(true); expect(result.mismatches).toHaveLength(0); + expect(result.actual.tpa_rate).toBe(55); + expect(result.actual.anti_gravity_gain).toBe(110); expect(result.unchecked).not.toContain('tpa_rate'); - expect(result.unchecked).not.toContain('anti_gravity_gain'); + expect(result.unchecked).not.toContain('simplified_dmax_gain'); + }); + + it('reports mismatch when an advanced setting read-back differs', async () => { + const msp = createFFMockMSPClient({ tpaRate: 65 }); // Apply said 55, FC says 65 + const applied: AppliedChange[] = [{ setting: 'tpa_rate', previousValue: 65, newValue: 55 }]; + + const result = await verifyAppliedConfig(msp, 'pid', undefined, undefined, applied); + + expect(result.verified).toBe(false); + expect(result.mismatches.some((m) => m.includes('tpa_rate'))).toBe(true); + }); + + it('marks advanced settings unchecked on old firmware (short MSP layout)', async () => { + // Mock reports no tpaRate/antiGravityGain — API < 1.45 response + const msp = createFFMockMSPClient(); + const applied: AppliedChange[] = [ + { setting: 'tpa_rate', previousValue: 65, newValue: 55 }, + { setting: 'anti_gravity_gain', previousValue: 80, newValue: 110 }, + ]; + + const result = await verifyAppliedConfig(msp, 'pid', undefined, undefined, applied); + + expect(result.verified).toBe(false); + expect(result.unchecked).toContain('tpa_rate'); + expect(result.unchecked).toContain('anti_gravity_gain'); + expect(result.mismatches).toHaveLength(0); }); it('marks unknown FF settings as unchecked (verified=false)', async () => { diff --git a/src/main/utils/verifyAppliedConfig.ts b/src/main/utils/verifyAppliedConfig.ts index 75b3ef2..07a93d9 100644 --- a/src/main/utils/verifyAppliedConfig.ts +++ b/src/main/utils/verifyAppliedConfig.ts @@ -81,34 +81,35 @@ function buildActualPIDMap(config: PIDConfiguration): Record { /** Settings that can only be set via CLI and not read back via MSP */ const CLI_ONLY_SETTINGS = new Set(['rpm_filter_q']); -/** Feedforward-stage settings readable via MSP_PID_ADVANCED → FeedforwardConfiguration key */ +/** Feedforward-stage settings readable via MSP_PID_ADVANCED → FeedforwardConfiguration key. + * Fields that are optional in FeedforwardConfiguration (absent on short/old-firmware + * responses) fall through to `unchecked` when the read-back doesn't report them. */ const FF_MSP_READABLE: Record = { feedforward_boost: 'boost', feedforward_smooth_factor: 'smoothFactor', feedforward_jitter_factor: 'jitterFactor', feedforward_max_rate_limit: 'maxRateLimit', + feedforward_averaging: 'averaging', d_min_gain: 'dMinGain', iterm_relax: 'itermRelax', iterm_relax_cutoff: 'itermRelaxCutoff', + anti_gravity_gain: 'antiGravityGain', + thrust_linear: 'thrustLinear', + dyn_idle_min_rpm: 'dynIdleMinRpm', + vbat_sag_compensation: 'vbatSagCompensation', + tpa_mode: 'tpaMode', + tpa_rate: 'tpaRate', + tpa_breakpoint: 'tpaBreakpoint', }; -/** Feedforward-stage settings not currently parsed by getFeedforwardConfiguration(). - * Several DO have MSP_PID_ADVANCED offsets in mspLayouts.ts (tpa_*, anti_gravity_gain, - * feedforward_averaging, …) — wiring them in would extend verification coverage. - * Until then they are skipped during verification (same treatment as CLI_ONLY_SETTINGS). */ +/** Feedforward-stage settings with no MSP_PID_ADVANCED representation at all — + * genuinely CLI-only, skipped during verification (same treatment as + * CLI_ONLY_SETTINGS). Everything else applied in the FF stage is verified. */ const FF_CLI_ONLY = new Set([ - 'feedforward_averaging', - 'tpa_rate', - 'tpa_breakpoint', - 'tpa_mode', 'tpa_low_always', - 'anti_gravity_gain', - 'thrust_linear', - 'dyn_idle_min_rpm', 'pidsum_limit', 'pidsum_limit_yaw', 'rc_smoothing_auto_factor', - 'vbat_sag_compensation', 'simplified_dmax_gain', 'dterm_lpf1_dyn_expo', ]); diff --git a/src/shared/types/pid.types.ts b/src/shared/types/pid.types.ts index 9bcf7c1..3af8c97 100644 --- a/src/shared/types/pid.types.ts +++ b/src/shared/types/pid.types.ts @@ -66,4 +66,20 @@ export interface FeedforwardConfiguration { itermRelaxType?: number; /** I-term relax cutoff frequency (Hz) */ itermRelaxCutoff?: number; + /** FF averaging mode: 0=OFF, 2=2_POINT, 3=3_POINT, 4=4_POINT (API >= 1.44) */ + averaging?: number; + /** Dynamic idle minimum RPM (in 100-RPM units) */ + dynIdleMinRpm?: number; + /** VBat sag compensation strength (0-150) */ + vbatSagCompensation?: number; + /** Thrust linearization (0-150) */ + thrustLinear?: number; + /** Anti-gravity gain (BF 4.4+ 0-250 scale; only reported on API >= 1.45 layouts) */ + antiGravityGain?: number; + /** TPA mode: 0=D-only, 1=PD (API >= 1.45) */ + tpaMode?: number; + /** TPA rate (0-250, API >= 1.45) */ + tpaRate?: number; + /** TPA breakpoint (throttle 750-2000, API >= 1.45) */ + tpaBreakpoint?: number; } From 1efac193c47ceb61db2aa2e0dda7657093a1431f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:53:03 +0000 Subject: [PATCH 11/13] fix: address tuning-advisor review findings (3 majors + minors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major 1: informational recommendations now pass through deduplication unmerged — the F-YAW-RES no-op observation could previously replace an actionable F-DN-COUNT reduction sharing the setting name and inherit 'high' confidence. Major 2: SPECTRUM_SCALE_VERSION moved to shared constants and stamped into FilterMetricsSummary at write time. ConvergenceDetector refuses to compare noise floors across scale versions (a v1-stored flight vs a v2 measurement carries a phantom ~+10 dB shift that read as a huge regression); flash convergence also skips the cross-scale noise check. Major 3: verifyAppliedConfig silently skips advanced settings whose fields are absent from the firmware's (pre-1.45, short) MSP_PID_ADVANCED layout instead of marking them unchecked — previously every apply on BF 4.3/4.4 flipped verified=false and fired a false-positive auto diagnostic report. Minor 4: flash convergence ignores 90-degree phase-margin placeholders (phaseMarginCrossingFound=false) instead of diffing them as measurements. Nits: stale extreme-noise comment, ENBW-corrected white-noise floor formula in docs, POWER_FLOOR/DB_SENTINEL exported from FFTCompute and reused, propwash severity cross-version caveat in the KB. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJhhk2frYDDnpNbSHRxGdv --- docs/PID_TUNING_KNOWLEDGE.md | 4 +- src/main/analysis/ConvergenceDetector.test.ts | 56 +++++++++++++++++ src/main/analysis/ConvergenceDetector.ts | 60 +++++++++++++++---- src/main/analysis/FFTCompute.ts | 6 +- src/main/analysis/FilterRecommender.test.ts | 30 ++++++++++ src/main/analysis/FilterRecommender.ts | 12 +++- src/main/analysis/MechanicalHealthChecker.ts | 2 +- src/main/analysis/NoiseAnalyzer.ts | 7 ++- .../analysis/ThrottleSpectrogramAnalyzer.ts | 4 +- .../analysis/TransferFunctionEstimator.ts | 2 +- src/main/analysis/constants.ts | 5 +- src/main/utils/verifyAppliedConfig.test.ts | 12 ++-- src/main/utils/verifyAppliedConfig.ts | 6 +- src/shared/constants.ts | 9 +++ src/shared/types/tuning-history.types.ts | 4 ++ src/shared/utils/metricsExtract.ts | 2 + 16 files changed, 187 insertions(+), 34 deletions(-) diff --git a/docs/PID_TUNING_KNOWLEDGE.md b/docs/PID_TUNING_KNOWLEDGE.md index 0ee20f8..db43150 100644 --- a/docs/PID_TUNING_KNOWLEDGE.md +++ b/docs/PID_TUNING_KNOWLEDGE.md @@ -526,7 +526,7 @@ Works from **any flight data** — no dedicated maneuvers needed. Pioneered by P ### Noise Floor Scale (FPVPIDlab-Specific) -FPVPIDlab uses a **calibrated one-sided power spectrum** (`SPECTRUM_SCALE_VERSION = 2` in `constants.ts`): segments are detrended (mean removed), Hanning-windowed, normalized by coherent window gain ((Σw)²), Welch-averaged in the power domain, and reported as `10·log10(power)` in dB re (deg/s)². Calibration: a sine of amplitude A reads exactly `10·log10(A²/2)` at its bin, independent of FFT size and sample rate; white-noise floors depend only on FFT size (per-bin power ≈ 2σ²/N), not sample rate. The scale sits ≈10 dB above the legacy v1 amplitude-averaged scale and is still **not directly comparable** to BF Explorer or PIDtoolbox dB values — each tool normalizes differently. Metrics stored by v1 app versions are ≈10 dB lower than v2 values for the same flight. +FPVPIDlab uses a **calibrated one-sided power spectrum** (`SPECTRUM_SCALE_VERSION = 2` in `constants.ts`): segments are detrended (mean removed), Hanning-windowed, normalized by coherent window gain ((Σw)²), Welch-averaged in the power domain, and reported as `10·log10(power)` in dB re (deg/s)². Calibration: a sine of amplitude A reads exactly `10·log10(A²/2)` at its bin, independent of FFT size and sample rate; white-noise floors depend only on FFT size (per-bin power ≈ 2σ²·ENBW/N ≈ 3σ²/N for Hanning), not sample rate. The scale sits ≈10 dB above the legacy v1 amplitude-averaged scale and is still **not directly comparable** to BF Explorer or PIDtoolbox dB values — each tool normalizes differently. Metrics stored by v1 app versions are ≈10 dB lower than v2 values for the same flight. | FPVPIDlab dB (v2) | Internal Classification | Mapping Rationale | |-----------|----------------------|-------------------| @@ -779,7 +779,7 @@ FPVPIDlab adjusts all PID thresholds based on the pilot's declared flight style. - Identifies mechanical asymmetry, FC mounting angle, motor thrust differences ### Prop Wash Detection (FPVPIDlab-Specific) - + (Note: severities stored by app versions with the whole-flight baseline read systematically lower than clean-baseline values — cross-version comparisons of stored propwash severity are not meaningful.) - Throttle-down detection: derivative < -0.3 (normalized) sustained ≥50 ms - Analysis window: 400 ms post-drop, FFT in 20-90 Hz band - Severity: event band energy ratio vs CLEAN baseline — band energy of contiguous runs outside every drop + post-drop window, weighted by run length; falls back to full flight when no clean run ≥ 1024 samples. A whole-flight baseline would include the prop-wash energy itself, saturating the ratio on aggressive flights diff --git a/src/main/analysis/ConvergenceDetector.test.ts b/src/main/analysis/ConvergenceDetector.test.ts index 5005eb7..479417e 100644 --- a/src/main/analysis/ConvergenceDetector.test.ts +++ b/src/main/analysis/ConvergenceDetector.test.ts @@ -198,3 +198,59 @@ describe('detectFlashConvergence', () => { expect(result.status).toBe('converged'); }); }); + +describe('spectrum scale version guard', () => { + const makeSummary = (floor: number, version?: number) => ({ + noiseLevel: 'medium' as const, + roll: { noiseFloorDb: floor, peakCount: 0 }, + pitch: { noiseFloorDb: floor, peakCount: 0 }, + yaw: { noiseFloorDb: floor, peakCount: 0 }, + segmentsUsed: 3, + summary: '', + ...(version !== undefined ? { spectrumScaleVersion: version } : {}), + }); + + it('does not report a phantom regression across scale versions', () => { + // v1 initial (-30 on old scale) vs v2 verification (-20 = same physical + // noise on the new scale) — would read as +10 dB "regression" + const result = detectFilterConvergence(makeSummary(-30), makeSummary(-20, 2)); + expect(result.status).toBe('continue'); + expect(result.improvementDelta).toBe(0); + expect(result.message).toContain('not directly comparable'); + }); + + it('compares normally when both sides are on the same version', () => { + const result = detectFilterConvergence(makeSummary(-20, 2), makeSummary(-25, 2)); + expect(result.message).not.toContain('not directly comparable'); + expect(result.details.length).toBe(3); + }); +}); + +describe('flash convergence phase-margin sentinel guard', () => { + const makeTF = (pm: number, crossingFound?: boolean) => { + const axis = { + bandwidthHz: 50, + phaseMarginDeg: pm, + gainMarginDb: 10, + overshootPercent: 10, + settlingTimeMs: 100, + riseTimeMs: 30, + ...(crossingFound !== undefined ? { phaseMarginCrossingFound: crossingFound } : {}), + }; + return { roll: axis, pitch: axis, yaw: axis }; + }; + + it('ignores the 90° placeholder when the crossing was not found', () => { + // initial measured 45°, verification capped 90° (no crossing) — the ±45° + // "delta" is an artifact and must not block convergence + const result = detectFlashConvergence(makeTF(45, true), makeTF(90, false)); + expect(result.status).toBe('converged'); + expect(result.details.some((d) => d.metric.includes('phase margin'))).toBe(false); + }); + + it('uses measured phase margins normally', () => { + const result = detectFlashConvergence(makeTF(45, true), makeTF(75, true)); + expect(result.details.some((d) => d.metric.includes('phase margin'))).toBe(true); + expect(result.status).toBe('continue'); + }); +}); diff --git a/src/main/analysis/ConvergenceDetector.ts b/src/main/analysis/ConvergenceDetector.ts index a648e78..437daa0 100644 --- a/src/main/analysis/ConvergenceDetector.ts +++ b/src/main/analysis/ConvergenceDetector.ts @@ -24,6 +24,13 @@ import { FILTER_CONVERGENCE_DB as FLASH_NOISE_CONVERGENCE_DB, } from './constants'; +/** True when two filter summaries were measured on different spectrum scale + * versions (absent field = legacy v1). Their dB values differ by ≈10 dB for + * the same physical noise and must not be compared directly. */ +function scaleVersionsDiffer(a: FilterMetricsSummary, b: FilterMetricsSummary): boolean { + return (a.spectrumScaleVersion ?? 1) !== (b.spectrumScaleVersion ?? 1); +} + /** * Detect filter tuning convergence. * @@ -34,6 +41,22 @@ export function detectFilterConvergence( initial: FilterMetricsSummary, verification: FilterMetricsSummary ): ConvergenceResult { + // Cross-scale comparison guard: a v1-stored initial vs a v2 verification + // (session spanning an app update) carries a phantom ~+10 dB shift that + // would read as a huge regression. Report neutral "continue" instead. + if (scaleVersionsDiffer(initial, verification)) { + return { + status: 'continue', + improvementDelta: 0, + meaningfulThreshold: FILTER_CONVERGENCE_DB, + message: + 'The two flights were analyzed on different noise-scale versions (app update between ' + + 'them) — their dB values are not directly comparable. Fly a fresh analysis + ' + + 'verification pair to measure improvement.', + details: [], + }; + } + const details: ConvergenceDetail[] = []; const axes = ['roll', 'pitch', 'yaw'] as const; @@ -199,7 +222,6 @@ export function detectFlashConvergence( for (const axis of axes) { const bwDelta = Math.abs(verification[axis].bandwidthHz - initial[axis].bandwidthHz); - const pmDelta = Math.abs(verification[axis].phaseMarginDeg - initial[axis].phaseMarginDeg); details.push({ metric: `${axis} bandwidth`, @@ -208,21 +230,35 @@ export function detectFlashConvergence( delta: verification[axis].bandwidthHz - initial[axis].bandwidthHz, unit: 'Hz', }); - details.push({ - metric: `${axis} phase margin`, - initialValue: initial[axis].phaseMarginDeg, - verificationValue: verification[axis].phaseMarginDeg, - delta: verification[axis].phaseMarginDeg - initial[axis].phaseMarginDeg, - unit: '°', - }); - maxBwDelta = Math.max(maxBwDelta, bwDelta); - maxPmDelta = Math.max(maxPmDelta, pmDelta); + + // Phase margin: only when both sides carry a MEASURED margin. When the + // gain never crossed 0 dB, phaseMarginDeg is a 90° placeholder — diffing + // it against a measured ~45° would fabricate a ±45° "change". + const pmMeasured = + initial[axis].phaseMarginCrossingFound !== false && + verification[axis].phaseMarginCrossingFound !== false; + if (pmMeasured) { + const pmDelta = Math.abs(verification[axis].phaseMarginDeg - initial[axis].phaseMarginDeg); + details.push({ + metric: `${axis} phase margin`, + initialValue: initial[axis].phaseMarginDeg, + verificationValue: verification[axis].phaseMarginDeg, + delta: verification[axis].phaseMarginDeg - initial[axis].phaseMarginDeg, + unit: '°', + }); + maxPmDelta = Math.max(maxPmDelta, pmDelta); + } } - // Optional noise floor delta + // Optional noise floor delta (skip when the two flights were measured on + // different spectrum scale versions — dB values are not comparable) let noiseConverged = true; - if (initialFilter && verificationFilter) { + if ( + initialFilter && + verificationFilter && + !scaleVersionsDiffer(initialFilter, verificationFilter) + ) { for (const axis of axes) { const noiseDelta = Math.abs( verificationFilter[axis].noiseFloorDb - initialFilter[axis].noiseFloorDb diff --git a/src/main/analysis/FFTCompute.ts b/src/main/analysis/FFTCompute.ts index fa48b37..a125dbd 100644 --- a/src/main/analysis/FFTCompute.ts +++ b/src/main/analysis/FFTCompute.ts @@ -10,7 +10,7 @@ * averaging happens in the power domain. Calibration: a sine of amplitude * A reads exactly 10·log10(A²/2) at its bin, independent of window size * and sample rate. White-noise floors depend only on the FFT size - * (per-bin power = 2σ²/N), not the sample rate, so dB thresholds remain + * (per-bin power ≈ 2σ²·ENBW/N ≈ 3σ²/N for Hanning), not the sample rate, so dB thresholds remain * comparable across logging rates. */ import FFT from 'fft.js'; @@ -18,10 +18,10 @@ import type { PowerSpectrum } from '@shared/types/analysis.types'; import { FFT_WINDOW_SIZE, FFT_OVERLAP, FREQUENCY_MIN_HZ, FREQUENCY_MAX_HZ } from './constants'; /** Sentinel dB value for bins with near-zero power (10*log10(1e-24)) */ -const DB_SENTINEL = -240; +export const DB_SENTINEL = -240; /** Power floor below which a bin is reported as the sentinel */ -const POWER_FLOOR = 1e-24; +export const POWER_FLOOR = 1e-24; /** * Apply a Hanning window to a signal segment. diff --git a/src/main/analysis/FilterRecommender.test.ts b/src/main/analysis/FilterRecommender.test.ts index d3fa51a..ec33ffe 100644 --- a/src/main/analysis/FilterRecommender.test.ts +++ b/src/main/analysis/FilterRecommender.test.ts @@ -1646,3 +1646,33 @@ describe('yaw-only resonance observation (F-YAW-RES)', () => { expect(recs.find((r) => r.ruleId === 'F-YAW-RES')).toBeUndefined(); }); }); + +describe('deduplication vs informational observations', () => { + it('F-YAW-RES no-op must not swallow an actionable F-DN-COUNT reduction', () => { + // RPM active on a 5" → F-DN-COUNT wants count 3→1; yaw-only peak outside + // notch range → F-YAW-RES emits an informational no-op on the same setting + const noise = makeNoiseProfile({ + level: 'medium', + yawPeaks: [{ frequency: 700, amplitude: 15, type: 'electrical' }], + }); + const current: CurrentFilterSettings = { + ...DEFAULT_FILTER_SETTINGS, + rpm_filter_harmonics: 3, + dyn_notch_count: 3, + dyn_notch_max_hz: 600, // 700 Hz yaw peak is outside + }; + + const recs = recommend(noise, current, '5"'); + const countRecs = recs.filter((r) => r.setting === 'dyn_notch_count'); + const actionable = countRecs.find((r) => !r.informational); + const observation = countRecs.find((r) => r.informational); + + // The actionable reduction must survive dedup with its own value/confidence + expect(actionable).toBeDefined(); + expect(actionable!.recommendedValue).toBeLessThan(3); + // The informational observation passes through separately + expect(observation).toBeDefined(); + expect(observation!.recommendedValue).toBe(observation!.currentValue); + expect(observation!.confidence).toBe('low'); + }); +}); diff --git a/src/main/analysis/FilterRecommender.ts b/src/main/analysis/FilterRecommender.ts index b494cfe..b27bec2 100644 --- a/src/main/analysis/FilterRecommender.ts +++ b/src/main/analysis/FilterRecommender.ts @@ -730,8 +730,18 @@ function recommendDynamicNotchForRPM( */ function deduplicateRecommendations(recs: FilterRecommendation[]): FilterRecommendation[] { const byKey = new Map(); + // Informational observations pass through unmerged: their currentValue === + // recommendedValue no-op must never replace (or inherit confidence from) a + // real recommendation that happens to share the setting name (e.g. the + // F-YAW-RES observation vs an actionable F-DN-COUNT reduction). The + // renderer already excludes informational/no-op recs from apply. + const informational: FilterRecommendation[] = []; for (const rec of recs) { + if (rec.informational) { + informational.push(rec); + continue; + } const existing = byKey.get(rec.setting); if (!existing) { byKey.set(rec.setting, rec); @@ -760,7 +770,7 @@ function deduplicateRecommendations(recs: FilterRecommendation[]): FilterRecomme } } - return Array.from(byKey.values()); + return [...byKey.values(), ...informational]; } /** diff --git a/src/main/analysis/MechanicalHealthChecker.ts b/src/main/analysis/MechanicalHealthChecker.ts index 360cdf1..05ef9e2 100644 --- a/src/main/analysis/MechanicalHealthChecker.ts +++ b/src/main/analysis/MechanicalHealthChecker.ts @@ -2,7 +2,7 @@ * Mechanical health diagnostic module. * * Pre-tuning check that detects hardware issues before PID analysis: - * - Extreme noise floor (>-20 dB) — damaged prop, loose motor, vibration + * - Extreme noise floor (>-10 dB on the v2 scale, size-aware) — damaged prop, loose motor, vibration * - Asymmetric per-axis noise — bent prop, damaged motor, gyro mounting * - Abnormal motor output variance — motor imbalance, ESC issues * diff --git a/src/main/analysis/NoiseAnalyzer.ts b/src/main/analysis/NoiseAnalyzer.ts index 6607151..ebe5c70 100644 --- a/src/main/analysis/NoiseAnalyzer.ts +++ b/src/main/analysis/NoiseAnalyzer.ts @@ -27,8 +27,9 @@ import { MOTOR_HARMONIC_MIN_PEAKS, } from './constants'; -/** Sentinel value for bins with near-zero magnitude (20*log10(1e-12)) */ -export const DB_SENTINEL = -240; +/** Sentinel value for bins with near-zero power (10*log10(1e-24)) — re-exported from FFTCompute */ +export { DB_SENTINEL } from './FFTCompute'; +import { DB_SENTINEL, POWER_FLOOR } from './FFTCompute'; /** Minimum valid noise floor — anything below is treated as no-signal */ const DB_FLOOR_VALID = -100; @@ -297,7 +298,7 @@ export function averageSpectra(spectra: PowerSpectrum[]): PowerSpectrum { const magnitudes = new Float64Array(numBins); for (let i = 0; i < numBins; i++) { const avg = avgMagnitudes[i] / spectra.length; - magnitudes[i] = avg > 1e-24 ? 10 * Math.log10(avg) : -240; + magnitudes[i] = avg > POWER_FLOOR ? 10 * Math.log10(avg) : DB_SENTINEL; } return { frequencies: spectra[0].frequencies, magnitudes }; diff --git a/src/main/analysis/ThrottleSpectrogramAnalyzer.ts b/src/main/analysis/ThrottleSpectrogramAnalyzer.ts index 6b1b8cf..68d5126 100644 --- a/src/main/analysis/ThrottleSpectrogramAnalyzer.ts +++ b/src/main/analysis/ThrottleSpectrogramAnalyzer.ts @@ -15,7 +15,7 @@ import type { ThrottleBand, PowerSpectrum, } from '@shared/types/analysis.types'; -import { computePowerSpectrum, trimSpectrum } from './FFTCompute'; +import { computePowerSpectrum, trimSpectrum, POWER_FLOOR, DB_SENTINEL } from './FFTCompute'; import { estimateNoiseFloor } from './NoiseAnalyzer'; import { FFT_WINDOW_SIZE, FREQUENCY_MIN_HZ, FREQUENCY_MAX_HZ } from './constants'; @@ -110,7 +110,7 @@ function averageRunSpectra( const magnitudes = new Float64Array(numBins); for (let i = 0; i < numBins; i++) { const avg = avgPower[i] / totalWeight; - magnitudes[i] = avg > 1e-24 ? 10 * Math.log10(avg) : -240; + magnitudes[i] = avg > POWER_FLOOR ? 10 * Math.log10(avg) : DB_SENTINEL; } return { frequencies, magnitudes }; } diff --git a/src/main/analysis/TransferFunctionEstimator.ts b/src/main/analysis/TransferFunctionEstimator.ts index ec8a904..b26cdae 100644 --- a/src/main/analysis/TransferFunctionEstimator.ts +++ b/src/main/analysis/TransferFunctionEstimator.ts @@ -42,7 +42,7 @@ const DC_REFERENCE_MAX_HZ = 5; const SETTLING_TOLERANCE = 0.02; /** Upper bound of the band used for the mean-coherence summary (Hz). - * Stick input carries energy roughly 0.5-40 Hz; coherence above that band + * Stick input carries most energy below ~40 Hz; the mean uses 1-30 Hz (bin 0 excluded, see DC_REFERENCE notes) — coherence above that band * reflects noise, not tracking, and would dilute the mean. */ const COHERENCE_BAND_MAX_HZ = 30; diff --git a/src/main/analysis/constants.ts b/src/main/analysis/constants.ts index c9c789c..e20de24 100644 --- a/src/main/analysis/constants.ts +++ b/src/main/analysis/constants.ts @@ -57,7 +57,8 @@ export const SWEEP_MAX_RESIDUAL = 0.15; // ---- Noise Analysis ---- /** - * Spectrum scale version. v2 = calibrated one-sided power spectrum + * Spectrum scale version (re-exported from shared so metric summaries can + * stamp it at write time). v2 = calibrated one-sided power spectrum * (detrended, Hanning, (Σw)² coherent-gain normalization, power-domain * Welch averaging, dB = 10·log10). A sine of amplitude A reads * 10·log10(A²/2). Absolute dB thresholds below are calibrated to this @@ -66,7 +67,7 @@ export const SWEEP_MAX_RESIDUAL = 0.15; * community sources. Stored metrics from v1 app versions are not directly * comparable to v2 values. */ -export const SPECTRUM_SCALE_VERSION = 2; +export { SPECTRUM_SCALE_VERSION } from '@shared/constants'; /** Peak detection: minimum prominence above local noise floor in dB. * Relative (peak vs floor) — identical meaning on the v1 and v2 scales. */ diff --git a/src/main/utils/verifyAppliedConfig.test.ts b/src/main/utils/verifyAppliedConfig.test.ts index c8b0ae1..29d7f77 100644 --- a/src/main/utils/verifyAppliedConfig.test.ts +++ b/src/main/utils/verifyAppliedConfig.test.ts @@ -351,8 +351,10 @@ describe('verifyAppliedConfig', () => { expect(result.mismatches.some((m) => m.includes('tpa_rate'))).toBe(true); }); - it('marks advanced settings unchecked on old firmware (short MSP layout)', async () => { - // Mock reports no tpaRate/antiGravityGain — API < 1.45 response + it('silently skips advanced settings on old firmware (short MSP layout)', async () => { + // Mock reports no tpaRate/antiGravityGain — API < 1.45 response. + // These must NOT fail verification (would fire false-positive auto + // diagnostic reports on every apply on BF 4.3/4.4). const msp = createFFMockMSPClient(); const applied: AppliedChange[] = [ { setting: 'tpa_rate', previousValue: 65, newValue: 55 }, @@ -361,9 +363,9 @@ describe('verifyAppliedConfig', () => { const result = await verifyAppliedConfig(msp, 'pid', undefined, undefined, applied); - expect(result.verified).toBe(false); - expect(result.unchecked).toContain('tpa_rate'); - expect(result.unchecked).toContain('anti_gravity_gain'); + expect(result.verified).toBe(true); + expect(result.unchecked).not.toContain('tpa_rate'); + expect(result.unchecked).not.toContain('anti_gravity_gain'); expect(result.mismatches).toHaveLength(0); }); diff --git a/src/main/utils/verifyAppliedConfig.ts b/src/main/utils/verifyAppliedConfig.ts index 07a93d9..5ec8306 100644 --- a/src/main/utils/verifyAppliedConfig.ts +++ b/src/main/utils/verifyAppliedConfig.ts @@ -378,8 +378,10 @@ export async function verifyAppliedConfig( } const act = ffConfig[configKey]; if (act === undefined) { - // Optional field — older firmware/layouts may not report it via MSP - unchecked.push(change.setting); + // Optional field the firmware's (shorter, pre-1.45) MSP layout does + // not report — skip silently, same treatment as FF_CLI_ONLY. Marking + // it `unchecked` would flip verified=false and fire a false-positive + // auto diagnostic report on every apply on older firmware. continue; } expected[change.setting] = change.newValue; diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d0be7a2..9ce1afa 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,6 +1,15 @@ declare const __APP_VERSION__: string; export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0-dev'; +/** + * Spectrum scale version. v2 = calibrated one-sided power spectrum (see + * src/main/analysis/FFTCompute.ts). dB values on the v2 scale sit ≈10 dB + * above legacy v1 (amplitude-averaged) values, so stored metrics from + * different scale versions are NOT directly comparable. Stamped into + * FilterMetricsSummary at write time; records without the field are v1. + */ +export const SPECTRUM_SCALE_VERSION = 2; + export const MSP = { DEFAULT_BAUD_RATE: 115200, CONNECTION_TIMEOUT: 5000, diff --git a/src/shared/types/tuning-history.types.ts b/src/shared/types/tuning-history.types.ts index 6fdfe08..8467091 100644 --- a/src/shared/types/tuning-history.types.ts +++ b/src/shared/types/tuning-history.types.ts @@ -68,6 +68,10 @@ export interface FilterMetricsSummary { windDisturbance?: { level: string; worstVariance: number }; /** Optional compact throttle spectrogram for heatmap rendering */ throttleSpectrogram?: CompactThrottleSpectrogram; + /** Spectrum scale version the dB values were measured on (see + * SPECTRUM_SCALE_VERSION in shared/constants). Absent = legacy v1 scale + * (≈10 dB below v2) — not directly comparable to v2 records. */ + spectrumScaleVersion?: number; } /** Compact per-axis PID step response metrics */ diff --git a/src/shared/utils/metricsExtract.ts b/src/shared/utils/metricsExtract.ts index 0ba6a68..2c2cd96 100644 --- a/src/shared/utils/metricsExtract.ts +++ b/src/shared/utils/metricsExtract.ts @@ -18,6 +18,7 @@ import type { TransferFunctionMetricsSummary, } from '../types/tuning-history.types'; import type { ThrottleSpectrogramResult } from '../types/analysis.types'; +import { SPECTRUM_SCALE_VERSION } from '../constants'; /** * Downsample a full-resolution FFT spectrum to a fixed number of bins. @@ -213,6 +214,7 @@ export function extractFilterMetrics(result: FilterAnalysisResult): FilterMetric return { noiseLevel: result.noise.overallLevel, + spectrumScaleVersion: SPECTRUM_SCALE_VERSION, roll: { noiseFloorDb: round2(result.noise.roll.noiseFloorDb), peakCount: result.noise.roll.peaks.length, From e25f0566af5fef9fad62786e8f729419422cb9d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 14:12:51 +0000 Subject: [PATCH 12/13] docs: sync all documentation with Phase 0/1 tuning-algorithm changes /doc-sync audit across README, TESTING, ARCHITECTURE, SPEC, subdirectory CLAUDE.md files, and design docs. Decision tables updated to the v2 power-spectrum scale (size-aware noise levels, 0/-60 dB targeting anchors, -35 dB LPF2 disable), size-aware frame-resonance bands, F-YAW-RES row, coherence gate for TF rules, clean-segment propwash baseline, and the extended MSP_PID_ADVANCED verification. TESTING.md inventory recounted per file (3243 tests / 147 files, incl. 32 pre-existing stale rows fixed). TUNING_ALGORITHMS_AUDIT.md marked Active with Phase 0/1 implemented. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJhhk2frYDDnpNbSHRxGdv --- ARCHITECTURE.md | 76 ++++++++++++++++--------------- README.md | 72 +++++++++++++++-------------- SPEC.md | 4 +- TESTING.md | 71 +++++++++++++++-------------- docs/PID_TUNING_KNOWLEDGE.md | 4 +- docs/README.md | 2 +- docs/TUNING_ALGORITHMS_AUDIT.md | 28 ++++++------ docs/TUNING_SESSION_EVALUATION.md | 23 ++++++---- src/main/CLAUDE.md | 4 +- src/main/analysis/CLAUDE.md | 8 +++- src/main/msp/CLAUDE.md | 5 ++ 11 files changed, 164 insertions(+), 133 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 447c081..601ade2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture Overview -**Last Updated:** July 6, 2026 | **Phase 4 Complete, Phase 6 Complete** | **3189 unit tests, 145 files + 37 Playwright E2E tests** +**Last Updated:** July 24, 2026 | **Phase 4 Complete, Phase 6 Complete** | **3243 unit tests (3220 passing + 23 skipped), 147 files + 37 Playwright E2E tests** --- @@ -57,8 +57,8 @@ │ │ ┌───┴──────────┐ ┌─────────────────┐ ┌──────────────────┐ │ │ │ │ │MSPConnection │ │ BlackboxParser │ │ Analysis Engine │ │ │ │ │ │ + CLI Mode │ │ (6 modules, │ │ FFT + Step Resp │ │ │ -│ │ │ + fcEntered │ │ 227 tests) │ │ (26 modules, │ │ │ -│ │ │ CLI flag │ │ │ │ 661 tests) │ │ │ +│ │ │ + fcEntered │ │ 245 tests) │ │ (27 modules, │ │ │ +│ │ │ CLI flag │ │ │ │ 1127 tests) │ │ │ │ │ └───┬──────────┘ └─────────────────┘ └──────────────────┘ │ │ │ │ │ │ │ │ │ ┌───┴──────────┐ │ │ @@ -287,22 +287,23 @@ Two independent analysis pipelines: **filter tuning** (FFT noise analysis) and * | File | Lines | Tests | Purpose | |------|-------|-------|---------| -| `FFTCompute.ts` | 171 | 20 | Welch's method, Hanning window | -| `SegmentSelector.ts` | 195 | 27 | Hover + throttle sweep detection | -| `NoiseAnalyzer.ts` | 246 | 25 | Peak detection, noise classification | -| `FilterRecommender.ts` | 627 | 80 | Noise-based filter targets, RPM-aware bounds, dynamic-lowpass-aware (tunes dyn_min/max when active), propwash floor, medium noise, notch-aware resonance, LPF2, preset gap analysis settings | -| `FilterAnalyzer.ts` | 206 | 19 | Filter analysis orchestrator (data quality, throttle spectrogram, group delay) | -| `ThrottleSpectrogramAnalyzer.ts` | — | 19 | Throttle-dependent spectrogram analysis | -| `GroupDelayEstimator.ts` | — | 23 | Group delay estimation, filter latency measurement, uses dyn_min_hz when dynamic active | -| `StepDetector.ts` | 142 | 16 | Derivative-based step input detection | -| `StepMetrics.ts` | 330 | 38 | Rise time, overshoot, settling, trace, FF contribution, adaptive window | -| `PIDRecommender.ts` | 430 | 207 | Flight-PID-anchored P/D recommendations, FF-aware, damping ratio, I-term, quad-size-aware bounds, D-min/TPA advisory, preset gap analysis settings | -| `PIDAnalyzer.ts` | 185 | 21 | PID analysis orchestrator (FF context, data quality, cross-axis, propwash) | -| `CrossAxisDetector.ts` | — | 20 | Cross-axis coupling detection | -| `PropWashDetector.ts` | — | 16 | Propwash detection and analysis | -| `DataQualityScorer.ts` | ~200 | 39 | Flight data quality scoring (0-100), confidence adjustment, low coherence warning | -| `headerValidation.ts` | 94 | 28 | BB header diagnostics, version-aware debug mode, RPM enrichment, preset gap analysis fields | -| `constants.ts` | 177 | — | All tunable thresholds | +| `FFTCompute.ts` | 224 | 24 | Welch's method, detrended Hanning windows, power-domain averaging, calibrated one-sided power spectrum (v2 scale) | +| `SegmentSelector.ts` | 375 | 31 | Hover + throttle sweep detection, yaw steadiness gating (1.5×) | +| `NoiseAnalyzer.ts` | 342 | 36 | Peak detection (plateau handling, 15 Hz spacing, parabolic interpolation), size-aware noise classification | +| `FilterRecommender.ts` | 1045 | 108 | Noise-based filter targets, RPM-aware bounds, dynamic-lowpass-aware (tunes dyn_min/max when active), propwash floor, medium noise, notch-aware resonance, LPF2, yaw-only resonance observation, preset gap analysis settings | +| `FilterAnalyzer.ts` | 372 | 20 | Filter analysis orchestrator (data quality, throttle spectrogram, group delay) | +| `ThrottleSpectrogramAnalyzer.ts` | 210 | 23 | Throttle-dependent spectrogram analysis (contiguous runs only) | +| `GroupDelayEstimator.ts` | 216 | 28 | Group delay estimation, filter latency measurement, uses dyn_min_hz when dynamic active | +| `StepDetector.ts` | 164 | 16 | Derivative-based step input detection | +| `StepMetrics.ts` | 416 | 53 | Rise time, overshoot, settling, trace, FF contribution, adaptive window | +| `PIDRecommender.ts` | 1840 | 266 | Flight-PID-anchored P/D recommendations, FF-aware, damping ratio, I-term, quad-size-aware bounds, D-min/TPA advisory, TF coherence gate, preset gap analysis settings | +| `PIDAnalyzer.ts` | 640 | 28 | PID analysis orchestrator (FF context, data quality, cross-axis, propwash) | +| `CrossAxisDetector.ts` | 162 | 20 | Cross-axis coupling detection | +| `PropWashDetector.ts` | 367 | 20 | Propwash detection and analysis (clean-segment baseline) | +| `DataQualityScorer.ts` | 403 | 39 | Flight data quality scoring (0-100), confidence adjustment, low coherence warning | +| `headerValidation.ts` | 300 | 45 | BB header diagnostics, version-aware debug mode, RPM enrichment, preset gap analysis fields | +| `throttleUtils.ts` | 24 | 4 | Shared throttle normalization + contiguous-run finder | +| `constants.ts` | 927 | 11 | All tunable thresholds (validated by `constants.test.ts`) | #### Filter Analysis Pipeline @@ -314,14 +315,14 @@ BlackboxFlightData → SegmentSelector → FFTCompute → NoiseAnalyzer → Filt ``` **SegmentSelector** finds stable hover segments and throttle sweeps: -- Hover: throttle 15–75%, gyro std < 50 deg/s, min 0.5s duration +- Hover: throttle 15–75%, roll/pitch gyro std < 50 deg/s (yaw < 75 deg/s — 1.5× relaxed), min 0.5s duration - Sweeps: throttle range > 40%, 2–15s duration, monotonic check - Prefers sweeps over hovers when available -**FFTCompute**: Hanning window, Welch's method (50% overlap, 4096-sample window), returns `PowerSpectrum { frequencies, magnitudes }` (Float64Array) +**FFTCompute**: detrended Hanning windows, Welch's method (50% overlap, 4096-sample window, power-domain averaging), returns a calibrated one-sided power spectrum `PowerSpectrum { frequencies, magnitudes }` (Float64Array). Spectrum scale v2 (`SPECTRUM_SCALE_VERSION = 2`): a sine of amplitude A reads 10·log10(A²/2); dB values sit ≈10 dB above the legacy v1 amplitude-averaged scale. -**NoiseAnalyzer** detects peaks by prominence (> 6 dB above local floor) and classifies: -- **Frame resonance**: 80–200 Hz +**NoiseAnalyzer** detects peaks by prominence (> 6 dB above local floor, plateau-aware, 15 Hz minimum spacing, parabolic sub-bin interpolation) and classifies: +- **Frame resonance**: size-aware `FRAME_RESONANCE_BY_SIZE` band (5": 80–200 Hz, 1"/2.5": 150–350 Hz, 7": 60–150 Hz) - **Motor harmonics**: equally-spaced peaks (≥ 3 peaks) - **Electrical noise**: > 500 Hz @@ -330,9 +331,9 @@ Noise floor: 25th percentile of magnitude spectrum. **FilterRecommender** — convergent noise-based targeting: ``` -Target cutoff = linear interpolation: - noiseFloorDb = -10 dB → min cutoff (very noisy) - noiseFloorDb = -70 dB → max cutoff (very clean) +Target cutoff = linear interpolation (v2 power-spectrum scale): + noiseFloorDb = 0 dB → min cutoff (very noisy) + noiseFloorDb = -60 dB → max cutoff (very clean) Safety bounds (RPM-aware): Gyro LPF1: 75–300 Hz (75–500 Hz with RPM filter) @@ -365,7 +366,7 @@ BBL rawHeaders → extractFeedforwardContext() → FeedforwardContext **StepDetector** finds sharp stick inputs: - Derivative threshold: 500 deg/s/s -- Minimum magnitude: 100 deg/s +- Minimum magnitude: 150 deg/s - Hold time: ≥ 50ms, cooldown: ≥ 100ms between steps **StepMetrics** computes per-step response quality: @@ -845,26 +846,27 @@ Hardware error (FC timeout, USB disconnect) ## Testing Strategy -**3189 unit tests across 145 files + 37 Playwright E2E tests**. See [TESTING.md](./TESTING.md) for complete inventory. +**3243 unit tests across 147 files (3220 passing + 23 skipped fixture-gated) + 37 Playwright E2E tests**. See [TESTING.md](./TESTING.md) for complete inventory. | Area | Files | Tests | |------|-------|-------| | Blackbox Parser | 9 | 245 | -| FFT Analysis (+ Data Quality + Spectrogram + Delay) | 8 | 278 | -| Step Response + PID + TF + CrossAxis + PropWash + DTerm + Bayesian + Verification | 19 | 604 | -| Header Validation + Constants + Main Utils | 3 | 65 | -| MSP Protocol & Client | 4 | 194 | +| FFT Analysis (+ Data Quality + Spectrogram + Delay + Throttle Utils) | 9 | 313 | +| Step Response + PID + TF + CrossAxis + PropWash + DTerm + Bayesian + Verification + Golden Outputs | 21 | 758 | +| Header Validation + Constants + Main Utils | 3 | 82 | +| MSP Protocol & Client | 4 | 196 | | MSC (Mass Storage) | 2 | 45 | -| Storage Managers | 7 | 142 | -| IPC Handlers | 4 | 140 | +| Storage Managers | 7 | 123 | +| IPC Handlers | 4 | 152 | +| FC State Cache | 1 | 17 | | Telemetry | 2 | 38 | | Diagnostic | 1 | 12 | | License | 1 | 12 | | Auto-Updater | 1 | 12 | -| UI Components + Charts + Contexts | 51 | 809 | -| React Hooks + Utils | 17 | 185 | +| UI Components + Charts + Contexts | 56 | 829 | +| React Hooks + Utils | 18 | 197 | | Shared Constants & Utils | 5 | 102 | -| E2E Workflows (Vitest) | 4 | 105 | +| E2E Workflows (Vitest) | 1 | 31 | | Demo Mode (Vitest) | 2 | 79 | | **Playwright E2E** | **7** | **37** | diff --git a/README.md b/README.md index 86b4bd1..397c599 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ FPVPIDlab reads your Blackbox log, analyzes the data (FFT noise spectrum, step r - **Safety-first** — automatic pre/post-tuning snapshots, all values clamped to proven safe bounds - **Multi-quad profiles** — auto-detects each FC by serial number, stores configs and history per quad - **Flight style adaptation** — Smooth (cinematic), Balanced (freestyle), Aggressive (racing) thresholds -- **26 analysis modules** — FFT, step response, Wiener deconvolution, prop wash, D-term effectiveness, cross-axis coupling, throttle spectrograms, group delay, feedforward, dynamic lowpass, Bayesian optimizer, convergence detection, verification matching, and more +- **27 analysis modules** — FFT, step response, Wiener deconvolution, setpoint→gyro coherence, prop wash, D-term effectiveness, cross-axis coupling, throttle spectrograms, group delay, feedforward, dynamic lowpass, Bayesian optimizer, convergence detection, verification matching, and more - **Works offline** — demo mode with simulated FC for testing without hardware - **Anonymous telemetry** — opt-in usage telemetry with per-session analytics (tuning mode usage, drone sizes, quality scores, recommendation rule tracing, verification deltas; no flight data or PIDs ever sent) - **Freemium license system** — free tier (1 profile), Pro tier (unlimited profiles). Ed25519-signed offline-first license validation @@ -75,15 +75,16 @@ Connecting with BF 4.2 or earlier will show an error and auto-disconnect. See [B - FC diagnostics: debug_mode, logging rate, and feedforward configuration display with warnings + one-click fix ### Automated Filter Tuning -- FFT noise analysis (Welch's method, Hanning window, peak detection) -- Noise source classification (frame resonance, motor harmonics, electrical) +- FFT noise analysis (Welch's method, detrended Hanning windows, power-domain averaging, calibrated one-sided power spectrum — spectrum scale v2) +- Peak detection with plateau handling, 15 Hz minimum peak spacing, and parabolic sub-bin frequency interpolation +- Noise source classification (frame resonance via size-aware bands, motor harmonics, electrical) - Noise-floor-based filter cutoff targeting with linear interpolation -- Medium noise handling: 20 Hz deadzone with low-confidence recommendations (avoids churn in the -50 to -30 dB range) +- Medium noise handling: 20 Hz deadzone with low-confidence recommendations (avoids churn in the size-aware MEDIUM band — 5": -40 to -20 dB) - Notch-aware resonance filtering: peaks within dyn_notch range are excluded from LPF recommendations (notch already handles them) - RPM filter awareness: widens safety bounds (gyro LPF1 up to 500 Hz), optimizes dynamic notch count and Q - Dynamic lowpass awareness: when `dyn_min_hz > 0`, tunes `dyn_min`/`dyn_max` instead of static cutoff; targets BF 2:1 ratio (dyn_max ≈ 2 × dyn_min), capped by safety bounds; enforces BF constraint `static_hz <= dyn_min_hz` - Conditional dynamic notch Q: Q=300 (wide) when strong frame resonance detected, Q=500 (narrow) otherwise -- LPF2 recommendations: disable when RPM active + clean signal (< -45 dB), enable when noisy (≥ -30 dB) without RPM +- LPF2 recommendations: disable when RPM active + clean signal (< -35 dB), enable when overall noise level is HIGH (size-aware) without RPM - Propwash floor protection (never pushes gyro LPF1 below 100 Hz) - Group delay estimation for filter chain latency visualization (uses `dyn_min_hz` when dynamic active for worst-case delay) @@ -95,7 +96,7 @@ Connecting with BF 4.2 or earlier will show an error and auto-disconnect. See [B - I-term rules: steady-state error detection with I increase/decrease recommendations (I min = 40) - Damping ratio validation: D/P ratio check (0.45–0.85 range; ceiling 1.0 on 1"/2.5" micros) with automatic correction - D-term effectiveness gating: measures D dampening vs noise ratio — redirects to filter tuning when D is mostly noise -- Prop wash detection: throttle-down event analysis with severity scoring per axis +- Prop wash detection: throttle-down event analysis with severity scoring per axis, measured against a clean-segment baseline (band energy outside drop windows) - Cross-axis coupling detection: measures roll↔pitch interference - Feedforward awareness: detects FF-dominated overshoot, recommends `feedforward_boost` reduction (step size 3) instead of P/D changes - FF energy ratio: downgrades P-decrease confidence when feedforward contributes >60% of overshoot energy @@ -111,7 +112,8 @@ Inspired by [Plasmatree PID-Analyzer](https://github.com/Plasmatree/PID-Analyzer - Synthetic step response via IFFT cumulative integration - Bode plot visualization (magnitude + phase) with bandwidth, phase margin, and gain margin markers - Frequency-domain PID rules: low phase margin (<45°) → D increase, low bandwidth → P increase (per-style thresholds) -- Per-axis coherence warnings when coherence ≤ 0.3 +- Per-axis magnitude-squared coherence γ²(f) with mean over the 1–30 Hz stick band; TF rules are gated per axis on coherence ≥ 0.5, and low-coherence axes get data quality warnings +- Gain/phase margins carry measured-crossing flags — axes without a measurable crossover are excluded from margin-based scoring instead of reporting the 60 dB / 90° caps as real - Shares the same unified recommendation pipeline with Filter Tune and PID Tune (same gating logic, same safety bounds) - DC gain analysis: detects poor steady-state tracking (< -1 dB → I increase) - Per-band transfer function across 5 throttle levels — detects TPA tuning problems @@ -123,7 +125,7 @@ Inspired by [Plasmatree PID-Analyzer](https://github.com/Plasmatree/PID-Analyzer - Multi-session history-based optimization (available for future integration) ### Throttle Spectrogram Analysis -- Per-throttle-bin FFT computation (10 bins across 0–100% throttle range) +- Per-throttle-bin FFT computation (10 bins across 0–100% throttle range), computed from contiguous sample runs only (min 512 samples) to avoid splice artifacts - Reveals how noise changes with motor speed (motor harmonics, frame resonance, electrical patterns) - Used by the dynamic lowpass recommender; visualized in FilterAnalysisStep, AnalysisOverview, TuningCompletionSummary, and TuningSessionDetail @@ -213,7 +215,7 @@ See [QUICK_START.md](./QUICK_START.md) for installation, setup, all available co All UI changes must include tests. Tests automatically run before commits. Coverage thresholds enforced: 80% lines/functions/statements, 75% branches. -**Unit tests:** 3189 tests across 145 files — MSP protocol, storage managers, IPC handlers, UI components, hooks, BBL parser fuzz, analysis pipeline validation, telemetry, diagnostic, license, auto-updater. +**Unit tests:** 3220 tests across 147 files (plus 23 skipped fixture-gated tests) — MSP protocol, storage managers, IPC handlers, UI components, hooks, BBL parser fuzz, analysis pipeline validation, golden-output regression, telemetry, diagnostic, license, auto-updater. **Playwright E2E:** 37 tests across 7 spec files — launches real Electron app in demo mode, walks through complete tuning cycles (Filter Tune, PID Tune, Flash Tune, diagnostic reports, and stress-test edge cases). @@ -247,7 +249,7 @@ pidlab/ │ │ │ ├── commands.ts # MSP command definitions │ │ │ └── types.ts # MSP type definitions │ │ ├── blackbox/ # BBL binary log parser (6 modules, 245 tests) -│ │ ├── analysis/ # Signal processing & tuning engine (26 modules) +│ │ ├── analysis/ # Signal processing & tuning engine (27 modules) │ │ │ ├── FFTCompute.ts # Welch's method, Hanning window │ │ │ ├── SegmentSelector.ts # Hover/sweep segment detection │ │ │ ├── NoiseAnalyzer.ts # Peak detection, noise classification @@ -274,6 +276,7 @@ pidlab/ │ │ │ ├── MechanicalHealthChecker.ts # Frame/motor health diagnostics │ │ │ ├── WindDisturbanceDetector.ts # Wind/disturbance detection │ │ │ ├── headerValidation.ts # BB header diagnostics +│ │ │ ├── throttleUtils.ts # Shared throttle normalization + contiguous run finder │ │ │ └── constants.ts # Tunable thresholds │ │ ├── storage/ # Data managers │ │ │ ├── ProfileManager.ts # Multi-quad profile CRUD @@ -622,17 +625,17 @@ Analyzes gyro noise to compute optimal lowpass cutoffs. The analysis code (`Filt 1. **Segment selection** — Finds stable hover segments from throttle and gyro data (excludes takeoff, landing, aggressive maneuvers). Prefers throttle sweeps, falls back to steady hovers (up to 5 segments). If none found (e.g., aggressive Flash Tune flight), uses the entire flight with an accuracy warning. 2. **Data quality scoring** — Rates flight data 0–100. Sub-scores: segment count (0.20), hover time (0.35), throttle coverage (0.25), segment type (0.20). Tiers: excellent (80+), good (60–79), fair (40–59), poor (<40). Fair/poor downgrades recommendation confidence. -3. **FFT computation** — Welch's method (Hanning window, 50% overlap, 4096-sample windows) → power spectral density per axis, trimmed to 20–1000 Hz. -4. **Noise analysis** — Estimates noise floor (lower quartile), detects peaks (>6 dB above local floor), classifies sources: - - Frame resonance (80–200 Hz) +3. **FFT computation** — Welch's method (detrended Hanning windows, 50% overlap, 4096-sample windows, power-domain averaging) → calibrated one-sided power spectrum per axis (spectrum scale v2: a sine of amplitude A reads 10·log10(A²/2)), trimmed to 20–1000 Hz. All absolute dB thresholds below are calibrated to this scale (≈10 dB above the legacy amplitude-averaged scale). +4. **Noise analysis** — Estimates noise floor (lower quartile), detects peaks (>6 dB above local floor, plateau-aware, 15 Hz minimum spacing, parabolic sub-bin interpolation), classifies sources: + - Frame resonance (size-aware band: 5" 80–200 Hz, 1"/2.5" 150–350 Hz, 7" 60–150 Hz) - Motor harmonics (equally-spaced peaks) - Electrical noise (>500 Hz) -5. **Throttle spectrogram** — Bins gyro data by throttle (10 bands), computes per-band FFT. Feeds the dynamic lowpass recommender. +5. **Throttle spectrogram** — Bins gyro data by throttle (10 bands), computes per-band FFT from contiguous runs only (min 512 samples per run, length-weighted power average). Feeds the dynamic lowpass recommender. 6. **Filter recommendation** — Maps measured noise floor (dB) to target cutoff (Hz) via linear interpolation between safety bounds. 7. **Dynamic lowpass** — When noise increases ≥ 6 dB from low to high throttle (Pearson ≥ 0.6), recommends dynamic lowpass for gyro LPF1 and D-term LPF1. D-term benefits more because the derivative amplifies high-frequency noise. 8. **Group delay estimation** — Estimates total filter chain latency (gyro + D-term). Warns when delay exceeds 2 ms. 9. **Wind detection** — Analyzes gyro variance during hover. High variance reduces recommendation confidence. -10. **Mechanical health** — Flags extreme noise (> -20 dB), asymmetric roll/pitch noise (> 8 dB), motor imbalance (> 3× ratio) before tuning proceeds. +10. **Mechanical health** — Flags extreme noise (size-aware threshold: max(-10 dB, size HIGH threshold + 5 dB) — e.g. -10 dB for 5", 0 dB for 1"), asymmetric roll/pitch noise (> 8 dB), motor imbalance (> 3× ratio) before tuning proceeds. #### Filter Safety Bounds @@ -643,31 +646,31 @@ Analyzes gyro noise to compute optimal lowpass cutoffs. The analysis code (`Filt The **minimum cutoffs** are derived from the official Betaflight guides. The **maximum cutoffs** represent the point where further relaxation provides negligible latency benefit. With RPM filter active, maximums are raised because 36 per-motor notch filters already handle motor noise, so the lowpass can afford to be more relaxed. -**Propwash floor:** Gyro LPF1 is never pushed below 100 Hz (configurable per flight style) to preserve responsiveness in the 20–90 Hz prop wash band. This is a conservative FPVPIDlab house rule — the BF wiki's "avoid below 100 Hz" guidance concerns notch filters, and the community D-term floor is ~80 Hz (Oscar Liang). +**Propwash floor:** Gyro LPF1 is never pushed below 100 Hz to preserve responsiveness in the 20–90 Hz prop wash band; the floor is bypassed only when the noise floor is extreme (> -5 dB on the v2 scale). This is a conservative FPVPIDlab house rule — the BF wiki's "avoid below 100 Hz" guidance concerns notch filters, and the community D-term floor is ~80 Hz (Oscar Liang). #### Noise-Based Targeting (Linear Interpolation) The cutoff target is computed from the **worst-case noise floor** across roll and pitch axes (dB), mapped linearly to the cutoff range: ``` -t = (noiseFloorDb - (-10)) / ((-70) - (-10)) +t = (noiseFloorDb - 0) / ((-60) - 0) targetHz = minHz + t × (maxHz - minHz) ``` -| Noise Floor (dB) | Meaning | Gyro LPF1 Target | D-term LPF1 Target | +| Noise Floor (dB, v2 scale) | Meaning | Gyro LPF1 Target | D-term LPF1 Target | |-------------------|---------|-------------------|---------------------| -| **-10 dB** (very noisy) | Extreme vibration/noise | 75 Hz (min) | 70 Hz (min) | -| **-40 dB** (moderate) | Typical mid-range quad | ~188 Hz | ~135 Hz | -| **-70 dB** (very clean) | Pristine signal | 300 Hz (max) | 200 Hz (max) | +| **0 dB** (very noisy) | Extreme vibration/noise | 75 Hz (min) | 70 Hz (min) | +| **-30 dB** (moderate) | Typical mid-range quad | ~188 Hz | ~135 Hz | +| **-60 dB** (very clean) | Pristine signal | 300 Hz (max) | 200 Hz (max) | -The -10 dB and -70 dB anchor points are calibrated from real Blackbox logs across various frame sizes (3"–7"). Same noise data always produces the same target, regardless of current settings. +The 0 dB and -60 dB anchor points are calibrated from real Blackbox logs across various frame sizes (3"–7") on the v2 power-spectrum scale. Same noise data always produces the same target, regardless of current settings. #### Filter Decision Table | Rule | Trigger Condition | Action | Confidence | Source / Rationale | |------|-------------------|--------|------------|---------------------| -| **Noise floor → lowpass (high)** | Noise > -30 dB | Set gyro/D-term LPF1 to noise-based target | High | Linear interpolation from BF guide bounds (see above) | -| **Noise floor → lowpass (medium)** | Noise -50 to -30 dB, \|target − current\| > 20 Hz | Set gyro/D-term LPF1 to noise-based target | Low | Medium noise: wider deadzone (20 Hz), low confidence to avoid churn | +| **Noise floor → lowpass (high)** | Overall noise level HIGH (size-aware; 5": > -20 dB) | Set gyro/D-term LPF1 to noise-based target | High | Linear interpolation from BF guide bounds (see above) | +| **Noise floor → lowpass (medium)** | Overall noise level MEDIUM (size-aware; 5": -40 to -20 dB), \|target − current\| > 20 Hz | Set gyro/D-term LPF1 to noise-based target | Low | Medium noise: wider deadzone (20 Hz), low confidence to avoid churn | | **Dead zone** | \|target − current\| ≤ 5 Hz (high noise) or ≤ 20 Hz (medium noise) | No change recommended | — | Prevents micro-adjustments that add no real benefit | | **Dynamic mode targeting** | Any noise-floor rule fires AND `dyn_min_hz > 0` | Target `dyn_min_hz` instead of `static_hz`; proportionally adjust `dyn_max_hz` to maintain ratio; lower `static_hz` to ≤ `dyn_min` (BF constraint) | Same as base rule | When dynamic lowpass is active, the tightest cutoff is `dyn_min` — tuning static would have no effect | | **Resonance peak → cutoff** | Peak ≥ 12 dB above floor AND outside dyn_notch coverage AND below current cutoff | Lower cutoff to peakFreq − 20 Hz (clamped to bounds). When dynamic active, targets `dyn_min_hz` | High | Notch-aware: peaks within dyn_notch_min–max count as covered only when `dyn_notch_count > 0` — with notches disabled, the LPF rule handles them | @@ -676,16 +679,17 @@ The -10 dB and -70 dB anchor points are calibrated from real Blackbox logs acros | **Dynamic notch range** | Peak above `dyn_notch_max_hz` | Raise dyn_notch_max to peakFreq + 20 Hz (ceiling: 1000 Hz) | Medium | Same as above, upper bound | | **RPM → notch count** | RPM filter active AND dyn_notch_count > size target (2 for sub-5", 1 for 5"+) | Reduce dyn_notch_count toward size target, max step 2 per iteration | High | Motor noise handled by RPM notches; sub-5" keeps 2 notches for frame vibration modes. Conservative stepping avoids removing too many notches at once | | **RPM → notch Q** | RPM filter active AND no strong frame resonance | Raise dyn_notch_q to 500 | High | Only weak resonances remain; narrower notch = less signal distortion | -| **RPM → notch Q (resonance)** | RPM filter active AND strong frame resonance (≥ 12 dB, 80-200 Hz) | Keep dyn_notch_q at 300 | Medium | Broad frame resonance needs wider notch to be effective | -| **LPF2 disable (gyro)** | RPM active AND noise < -45 dB | Disable gyro LPF2 | Medium | Very clean signal: LPF2 adds latency with no benefit | -| **LPF2 disable (D-term)** | RPM active AND noise < -45 dB | Disable D-term LPF2 | Medium | Clean signal with RPM: D-term LPF2 latency unnecessary | -| **LPF2 enable (gyro)** | No RPM AND noise ≥ -30 dB AND LPF2 disabled | Enable gyro LPF2 | Low | Noisy without RPM: extra filtering protects motors | -| **LPF2 enable (D-term)** | No RPM AND noise ≥ -30 dB AND LPF2 disabled | Enable D-term LPF2 | Low | High noise without RPM needs additional D-term protection | +| **RPM → notch Q (resonance)** | RPM filter active AND strong frame resonance (≥ 12 dB, size-aware band — 5": 80-200 Hz) | Keep dyn_notch_q at 300 | Medium | Broad frame resonance needs wider notch to be effective | +| **LPF2 disable (gyro)** | RPM active AND noise < -35 dB | Disable gyro LPF2 | Medium | Very clean signal: LPF2 adds latency with no benefit | +| **LPF2 disable (D-term)** | RPM active AND noise < -35 dB | Disable D-term LPF2 | Medium | Clean signal with RPM: D-term LPF2 latency unnecessary | +| **LPF2 enable (gyro)** | No RPM AND overall noise level HIGH AND LPF2 disabled | Enable gyro LPF2 (250 Hz) | Low | Noisy without RPM: extra filtering protects motors | +| **LPF2 enable (D-term)** | No RPM AND overall noise level HIGH AND LPF2 disabled | Enable D-term LPF2 (150 Hz) | Low | High noise without RPM needs additional D-term protection | +| **F-YAW-RES** | Yaw-only peak ≥ 12 dB above floor, not covered by dyn_notch, no roll/pitch counterpart within 15 Hz | Informational observation (no value change) | Low | Yaw-only resonance often indicates loose FC stack, uneven motor mounting, or frame flex — yaw never drives LPF cutoffs | | **F-DLPF-GYRO** | Throttle spectrogram noise increases ≥ 6 dB, Pearson ≥ 0.6, gyro LPF1 > 0, AND gyro dynamic NOT already active | Enable `gyro_lpf1_dyn_min_hz` (current static cutoff) and `gyro_lpf1_dyn_max_hz` (current static × 2) | Medium | BF 2:1 ratio convention: dyn_min = static, dyn_max = 2 × static. Throttle-ramped cutoff: more filtering at high throttle, less latency at cruise | | **F-DLPF-DTERM** | Same throttle-noise trigger as F-DLPF-GYRO AND D-term LPF1 > 0 AND D-term dynamic NOT already active | Enable `dterm_lpf1_dyn_min_hz` (current static cutoff) and `dterm_lpf1_dyn_max_hz` (current static × 2) | Medium | BF 2:1 ratio convention. D amplifies high-frequency noise — dynamic filtering reduces motor heating at high throttle while preserving stick feel at cruise | | **F-DLPF-GYRO-OFF** | No throttle-noise trigger AND noise delta < 4 dB AND `gyro_lpf1_dyn_min_hz > 0` | Disable gyro dynamic lowpass (`dyn_min_hz → 0`) | Low | Hysteresis: enable at ≥ 6 dB, disable only below 4 dB — deltas in the 4–6 dB gray zone leave the config untouched (prevents flip-flop) | | **F-DLPF-DTERM-OFF** | Same no-throttle-noise trigger (delta < 4 dB) AND `dterm_lpf1_dyn_min_hz > 0` | Disable D-term dynamic lowpass (`dyn_min_hz → 0`) | Low | Simplify filter stack when throttle-dependent noise is absent (same 4/6 dB hysteresis) | -| **Deduplication** | Multiple rules target same setting | Keep more aggressive value, upgrade confidence | — | Ensures a single coherent recommendation per setting | +| **Deduplication** | Multiple rules target same setting | Keep more aggressive value, upgrade confidence | — | Ensures a single coherent recommendation per setting. Informational (no-op) recommendations pass through unmerged — they never replace or absorb an actionable recommendation | **RPM filter awareness:** When RPM filter is active, safety bounds widen because 36 per-motor notch filters already handle motor noise. The dynamic notch is optimized (count stepped down toward the size target — 2 for sub-5", 1 for 5"+ — and Q 300→500) since only frame resonances remain. @@ -700,7 +704,7 @@ The -10 dB and -70 dB anchor points are calibrated from real Blackbox logs acros | [BF Configurator](https://github.com/betaflight/betaflight-configurator) | RPM-aware max cutoffs (verified against Configurator auto-adjust behavior) | | [Oscar Liang: PID Filter Tuning](https://oscarliang.com/pid-filter-tuning-blackbox/) | Blackbox-based filter tuning workflow, noise floor interpretation | | [PIDtoolbox](https://pidtoolbox.com/home) | Spectral analysis methodology, noise floor percentile approach | -| Real Blackbox logs (3"–7" quads) | Calibration of -10 dB / -70 dB noise anchor points | +| Real Blackbox logs (3"–7" quads) | Calibration of 0 dB / -60 dB noise anchor points (v2 power-spectrum scale) | ### PID Tuning (Unified Pipeline) @@ -746,7 +750,7 @@ Metric definitions follow standard control theory (consistent with MATLAB `stepi #### Flash Tune: Transfer Function Extraction -Flash Tune estimates the closed-loop transfer function H(f) = S_xy(f) / (S_xx(f) + ε) via Wiener deconvolution from any flight data. A synthetic step response is derived via IFFT cumulative integration. Extracted metrics: bandwidth (-3 dB), phase margin, gain margin, overshoot, settling time, DC gain. Per-band analysis across 5 throttle levels reveals TPA tuning problems when metrics vary significantly with throttle. +Flash Tune estimates the closed-loop transfer function H(f) = S_xy(f) / (S_xx(f) + ε) via Wiener deconvolution from any flight data. A synthetic step response is derived via IFFT cumulative integration. Extracted metrics: bandwidth (-3 dB), phase margin, gain margin (both carry a measured-crossing flag — unmeasured margins are excluded from scoring), overshoot, settling time, DC gain, and per-axis coherence γ²(f) with `coherenceMean` over the 1–30 Hz stick band. Per-band analysis across 5 throttle levels (longest contiguous run per band, min 2048 samples) reveals TPA tuning problems when metrics vary significantly with throttle. #### Shared Recommendation Engine @@ -819,6 +823,8 @@ Transfer function rules complement step-response rules. Both run in the same `re | **TF-3** | Bandwidth < threshold (smooth: 30, balanced: 40, aggressive: 60 Hz; yaw: × 0.7), no overshoot | P ↑ | +5 | Medium | | **TF-4** | DC gain < style-aware threshold `20·log10(1 − SSE_max/100)` dB (smooth ≈ −0.7, balanced ≈ −0.4, aggressive ≈ −0.3) | I ↑ | +5 (+10 at 2× threshold) | Low / Medium | +**Coherence gate:** All TF rules (TF-1..TF-4) are skipped per axis when the axis's stick-band coherence mean (1–30 Hz) is below 0.5 — the transfer function estimate is not trustworthy enough to drive gain changes (the data quality scorer flags the axis with a low-coherence warning instead). + Base confidence is adjusted by the same post-processing as step-response rules. There is no blanket confidence cap for Flash Tune — gating logic is identical to PID Tune. **Safety Bounds (quad-size-aware):** @@ -843,7 +849,7 @@ Default bounds (5") shown. When drone size is known from the profile, per-size b - **Flight style adaptation** — Smooth pilots get tighter overshoot tolerances; Aggressive pilots tolerate more overshoot for sharper response. - **Damping ratio validation** — Post-processing ensures D/P stays within 0.45–0.85 (upper bound 1.0 for 1"/2.5" micros — whoop presets legitimately run D/P ≈ 0.9–0.95). - **D-term effectiveness gating** — Three tiers: >0.7 (boost confidence), 0.3–0.7 (allow with advisory), <0.3 (redirect to filter tuning). Prevents blindly increasing D when the problem is noise. -- **Prop wash integration** — Severe prop wash (≥5× baseline, 20–90 Hz) boosts D-increase confidence or generates D +5 on worst axis. Minimum 3 events required. +- **Prop wash integration** — Severe prop wash (≥5× the clean-segment baseline, 20–90 Hz; baseline = band energy of contiguous runs outside drop windows) boosts D-increase confidence or generates D +5 on worst axis. Minimum 3 events required. ### Methodology Sources diff --git a/SPEC.md b/SPEC.md index a6275f9..31d4b7c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -169,7 +169,7 @@ High-level user journey: | Requirement | Status | Notes | |-------------|--------|-------| | Package analysis engine as a stateless service (container) | :fast_forward: | Architecture supports this — analysis modules are pure functions | -| Keep core algorithms pure and testable (input → output) | :white_check_mark: | All analysis modules: pure TypeScript, no side effects, 347 tests (160 filter + 130 PID + 25 data quality + 27 header validation + 5 misc) | +| Keep core algorithms pure and testable (input → output) | :white_check_mark: | All analysis modules: pure TypeScript, no side effects, 1127 tests across 32 test files in `src/main/analysis/` (incl. golden-output regression harness) | | Cloud optional; local remains primary | :white_check_mark: | Fully offline, no network calls | --- @@ -357,7 +357,7 @@ Automated end-to-end tests running in CI pipeline against a real FC connected to ## Progress Summary -**Last Updated:** July 6, 2026 | **Tests:** 3189 unit tests across 145 files + 37 Playwright E2E tests | **PRs Merged:** #1–#432 +**Last Updated:** July 24, 2026 | **Tests:** 3243 unit tests across 147 files (3220 passing + 23 skipped) + 37 Playwright E2E tests | **PRs Merged:** #1–#432 | Phase | Status | Notes | |-------|--------|-------| diff --git a/TESTING.md b/TESTING.md index 707d3f4..1fb0ce4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -167,14 +167,16 @@ npm run test:ui # Visual interface with DOM snapshots ## Test Inventory -**Total: 3189 unit tests across 145 files + 37 Playwright E2E tests across 7 spec files** (last verified: July 6, 2026) +**Total: 3243 unit tests across 147 files (3220 passing + 23 skipped fixture-gated) + 37 Playwright E2E tests across 7 spec files** (last verified: July 24, 2026) + +Per-file counts below include skipped tests (as reported by `vitest run`). The 23 skipped tests live in `blackbox/realflight.regression.test.ts` (13) and `analysis/AnalysisPipeline.realdata.test.ts` (10) — they require optional local BBL fixtures. ### UI Components | File | Tests | Description | |------|-------|-------------| | `ConnectionPanel/ConnectionPanel.test.tsx` | 13 | Connection flow, port scanning, cooldown, auto-cooldown on unexpected disconnect | -| `FCInfo/FCInfoDisplay.test.tsx` | 35 | FC information display, CLI export, diagnostics, version-aware debug mode, feedforward config, fix/reset settings | +| `FCInfo/FCInfoDisplay.test.tsx` | 39 | FC information display, CLI export, diagnostics, version-aware debug mode, feedforward config, fix/reset settings | | `FCInfo/FixSettingsConfirmModal.test.tsx` | 4 | Fix settings confirmation modal, reboot warning, confirm/cancel | | `BlackboxStatus/BlackboxStatus.test.tsx` | 34 | Blackbox status, download trigger, readonly mode, onAnalyze, SD card storage type, erase labels, log numbering, pagination, Huffman compression badge, disabled analyze for compressed logs | | `ProfileSelector.test.tsx` | 11 | Profile switching, locking when FC connected | @@ -185,8 +187,8 @@ npm run test:ui # Visual interface with DOM snapshots | `SnapshotManager/snapshotDiffUtils.test.ts` | 29 | CLI diff parsing, change computation, corrupted config line detection | | `Toast/Toast.test.tsx` | 14 | Toast notification rendering and lifecycle | | `Toast/ToastContainer.test.tsx` | 6 | Toast container layout and stacking | -| `StartTuningModal.test.tsx` | 16 | Start tuning modal, 3-mode selection (Filter Tune/PID Tune/Flash Tune), "Start here" badge, cancel, BF PID profile selector (display, selection, labels, persistence) | -| `TuningStatusBanner/TuningStatusBanner.test.tsx` | 78 | Workflow banner, unified 4-step indicator, actions, downloading, applied phases, BB settings pre-flight warning, filter/PID verification flow, flashUsedSize-based erased state, import file, skip erase, SD card labels + eraseCompleted, Flash Tune phases, post-apply verification mismatch warning | +| `StartTuningModal.test.tsx` | 19 | Start tuning modal, 3-mode selection (Filter Tune/PID Tune/Flash Tune), "Start here" badge, cancel, BF PID profile selector (display, selection, labels, persistence) | +| `TuningStatusBanner/TuningStatusBanner.test.tsx` | 80 | Workflow banner, unified 4-step indicator, actions, downloading, applied phases, BB settings pre-flight warning, filter/PID verification flow, flashUsedSize-based erased state, import file, skip erase, SD card labels + eraseCompleted, Flash Tune phases, post-apply verification mismatch warning | | `TuningWizard/TuningWizard.test.tsx` | 46 | Multi-step wizard flow, results display, apply, mode-aware routing, onApplyComplete with metrics, FF warning, RPM status, flight style display | | `TuningWizard/FlightGuideContent.test.tsx` | 11 | Flight guide content rendering, version-aware tip filtering | | `TuningWizard/TestFlightGuideStep.test.tsx` | 5 | Flight guide step integration | @@ -195,7 +197,7 @@ npm run test:ui # Visual interface with DOM snapshots | `AnalysisOverview/AnalysisOverview.test.tsx` | 39 | Diagnostic-only analysis view, auto-parse, session picker, breadcrumb navigation, session metadata, FF warning, RPM status, data quality pill, TF analysis, wind disturbance pill, mechanical health warnings | | `TuningWizard/PIDAnalysisStep.test.tsx` | 10 | PID results display, flight style pill, step count pluralization, data quality pill | | `TuningWizard/RecommendationCard.test.tsx` | 11 | Setting label lookup, value display, change percentage, confidence, feedforward labels | -| `TuningWizard/ApplyConfirmationModal.test.tsx` | 6 | Change counts, confirm/cancel, reboot warning | +| `TuningWizard/ApplyConfirmationModal.test.tsx` | 8 | Change counts, confirm/cancel, reboot warning | | `TuningWizard/QuickAnalysisStep.test.tsx` | 6 | Quick analysis dual-panel (filter + TF), auto-run, progress, retry | | `TuningWizard/WizardProgress.test.tsx` | 10 | Step indicator, mode-aware filtering (filter/pid/quick), current/done/upcoming states | | `TuningWizard/SessionSelectStep.test.tsx` | 8 | Session picker, auto-parse, parsing/error/empty states, reverse order | @@ -211,16 +213,17 @@ npm run test:ui # Visual interface with DOM snapshots | `TuningHistory/QualityTrendChart.test.tsx` | 7 | Trend chart rendering, minimum data threshold, null score handling, per-type colored lines, legend | | `TuningHistory/SpectrogramComparisonChart.test.tsx` | 5 | Side-by-side spectrogram comparison, before/after labels, empty states, compact data rendering | | `TuningHistory/StepResponseComparison.test.tsx` | 6 | Before/after step response metrics, per-axis comparison, delta indicators, empty states | -| `ProfileWizard.test.tsx` | 6 | Profile creation wizard, flight style selector, preset mapping | -| `ProfileCard.test.tsx` | 17 | Profile card rendering, badges (Active/Recent), relative time, click handlers, locked state, CSS classes | +| `ProfileWizard.test.tsx` | 9 | Profile creation wizard, flight style selector, preset mapping | +| `ProfileCard.test.tsx` | 18 | Profile card rendering, badges (Active/Recent), relative time, click handlers, locked state, CSS classes | +| `ProfileWipeModal.test.tsx` | 8 | Profile wipe confirmation modal, deleted/kept lists, confirm/cancel, loading state, disabled buttons while wiping | | `PresetSelector.test.tsx` | 11 | Preset dropdown rendering, selection callback, flight style mapping | | `ErrorBoundary.test.tsx` | 6 | Error catch, fallback UI, try again reset, custom fallback, normal render | | `App.test.tsx` | 10 | App render, title, version, BF compat badge, help button, ErrorBoundary integration, start tuning modal | | `TelemetrySettings/TelemetrySettingsModal.test.tsx` | 7 | Telemetry settings modal, toggle switch, send now, installation ID display, close/overlay dismiss | -| `LicenseSettings/LicenseSettingsModal.test.tsx` | 8 | License settings modal, activate, error, dismiss, comparison table | -| `UpdateNotification/UpdateNotification.test.tsx` | 4 | Update notification render, events, changelog, install | +| `LicenseSettings/LicenseSettingsModal.test.tsx` | 9 | License settings modal, activate, error, dismiss, comparison table | +| `UpdateNotification/UpdateNotification.test.tsx` | 10 | Update notification render, events, changelog, install | | `DiagnosticReport/ReportIssueModal.test.tsx` | 13 | Report issue modal form fields, submit with email/note, empty submit, sending state, cancel, privacy note, flight data checkbox (show/hide, default checked, submit with includeFlightData, BBL in privacy note) | -| `DiagnosticReport/ReportIssueButton.test.tsx` | 6 | Report issue button Pro gate, Free user hidden, modal open, submit success, submit failure, button variant | +| `DiagnosticReport/ReportIssueButton.test.tsx` | 12 | Report issue button Pro gate, Free user hidden, modal open, submit success, submit failure, button variant | | `LogPickerModal.test.tsx` | 7 | Log picker modal rendering, selection, cancel | ### Charts @@ -252,11 +255,11 @@ npm run test:ui # Visual interface with DOM snapshots | `hooks/useAnalysisOverview.test.ts` | 12 | Auto-parse, dual analysis, session picker | | `hooks/useFCInfo.test.ts` | 8 | FC info fetch, CLI export, loading/error states | | `hooks/useToast.test.tsx` | 5 | Toast helper methods, context requirement | -| `hooks/useBlackboxInfo.test.ts` | 8 | Auto-load, refresh, concurrent request prevention | +| `hooks/useBlackboxInfo.test.ts` | 9 | Auto-load, refresh, concurrent request prevention | | `hooks/useBlackboxLogs.test.ts` | 9 | Log list, profile change subscription, delete, openFolder | | `hooks/useTelemetrySettings.test.ts` | 4 | Telemetry settings hook, toggle enabled, send now, load failure handling | -| `hooks/useLicense.test.ts` | 9 | License load, activate, remove, events | -| `hooks/useAutoUpdate.test.ts` | 5 | Update state, events, install, cleanup | +| `hooks/useLicense.test.ts` | 7 | License load, activate, remove, events | +| `hooks/useAutoUpdate.test.ts` | 8 | Update state, events, install, cleanup | | `hooks/useFCState.test.ts` | 6 | FC state cache hook, mount hydration, push updates, cleanup | | `hooks/useDemoMode.test.ts` | 3 | Demo mode detection, reset demo | | `utils/bbSettingsUtils.test.ts` | 18 | BB settings status computation, version-aware debug mode, fix/reset commands | @@ -277,7 +280,7 @@ npm run test:ui # Visual interface with DOM snapshots |------|-------|-------------| | `msp/MSPProtocol.test.ts` | 40 | MSPv1 encode/decode, jumbo frames, round-trip, parseBuffer, checksum validation, garbage recovery | | `msp/MSPConnection.test.ts` | 51 | Connection lifecycle, sendCommand, sendCommandNoResponse, timeouts, error/partial responses, CLI mode (prompt debounce, chunk-boundary, trailing CR), event forwarding, port error fast-fail, CLI buffer limit | -| `msp/MSPClient.test.ts` | 77 | FC info queries, PID/filter/FF config, board info, UID, blackbox info (flash+SD card), SD card summary, MSC reboot (fire-and-forget), set PID, CLI diff, save & reboot, connect/disconnect, version gate, listPorts, chunk ceiling, erase disconnect detection, BF PID profile selection (getStatusEx, selectPIDProfile), exportCLIDiff auto-reconnect | +| `msp/MSPClient.test.ts` | 86 | FC info queries, PID/filter/FF config, board info, UID, blackbox info (flash+SD card), SD card summary, MSC reboot (fire-and-forget), set PID, CLI diff, save & reboot, connect/disconnect, version gate, listPorts, chunk ceiling, erase disconnect detection, BF PID profile selection (getStatusEx, selectPIDProfile), exportCLIDiff auto-reconnect, extended MSP_PID_ADVANCED parsing (feedforward_averaging, dyn_idle_min_rpm base layout + vbat_sag/thrust_linear/anti_gravity/TPA from 61-byte API 1.45+ layout) | | `msp/cliUtils.test.ts` | 19 | CLI command response validation, error pattern detection (incl. Allowed range), setting extraction | ### MSC (Mass Storage Class) @@ -293,11 +296,11 @@ npm run test:ui # Visual interface with DOM snapshots |------|-------|-------------| | `storage/FileStorage.test.ts` | 13 | Snapshot JSON save/load/delete/list/export, ensureDirectory, snapshotExists | | `storage/ProfileStorage.test.ts` | 13 | Profile persistence, loadProfiles, findBySerial, export, ensureDirectory idempotent | -| `storage/ProfileManager.test.ts` | 23 | Profile CRUD, preset creation, current profile, link/unlink snapshots, export | -| `storage/SnapshotManager.test.ts` | 18 | Snapshot creation via MSP, baseline management, server-side filtering, delete protection, tuning metadata | -| `storage/BlackboxManager.test.ts` | 15 | Log save/list/get/delete/export, profile filtering, soft delete, initialization | +| `storage/ProfileManager.test.ts` | 4 | clearSnapshotRefs behavior: clears snapshotIds + baselineSnapshotId, updatedAt bump, missing profile, no snapshots | +| `storage/SnapshotManager.test.ts` | 8 | Baseline force-delete protection (snapshot + profile baseline), baselineId clearing, profile unlink after force delete, cliDiff+cliDump snapshot creation | +| `storage/BlackboxManager.test.ts` | 17 | Log save/list/get/delete/export, profile filtering, soft delete, initialization | | `storage/TuningSessionManager.test.ts` | 35 | Session CRUD, phase transitions, transition validation (invalid/backward/cross-mode rejected), zero-change `*_analysis → completed` shortcut (all 3 tuning types), per-profile persistence, Flash Tune phases | -| `storage/TuningHistoryManager.test.ts` | 32 | History archive, retrieval ordering, corrupted data handling, per-profile isolation, delete, updateLatestVerification, updateRecordVerification, tuningType field, getLatestByType filtering | +| `storage/TuningHistoryManager.test.ts` | 33 | History archive, retrieval ordering, corrupted data handling, per-profile isolation, delete, updateLatestVerification, updateRecordVerification, tuningType field, getLatestByType filtering | ### Telemetry @@ -328,7 +331,7 @@ npm run test:ui # Visual interface with DOM snapshots | File | Tests | Description | |------|-------|-------------| -| `utils/verifyAppliedConfig.test.ts` | 24 | Full-config apply verification: PID match/mismatch/retry, filter match/mismatch, flash combined, sanity checks (P/I/D=0, filter bypassed), expected/actual recording, mode-aware scope (PID-only, filter-only, both), feedforward read-back (MSP-readable match/mismatch, CLI-only skip, unknown → unchecked, optional getFeedforwardConfiguration) | +| `utils/verifyAppliedConfig.test.ts` | 26 | Full-config apply verification: PID match/mismatch/retry, filter match/mismatch, flash combined, sanity checks (P/I/D=0, filter bypassed), expected/actual recording, mode-aware scope (PID-only, filter-only, both), feedforward read-back (full MSP-readable set incl. averaging/TPA/anti-gravity/thrust_linear/dyn_idle/vbat_sag, silent skip of fields absent from pre-1.45 short layouts, CLI-only skip shrunk to tpa_low_always/pidsum_limit*/rc_smoothing_auto_factor/simplified_dmax_gain/dterm_lpf1_dyn_expo, optional getFeedforwardConfiguration) | ### Auto-Updater @@ -354,13 +357,13 @@ npm run test:ui # Visual interface with DOM snapshots | File | Tests | Description | |------|-------|-------------| -| `analysis/FFTCompute.test.ts` | 20 | Hanning window, Welch's method, sine detection | -| `analysis/SegmentSelector.test.ts` | 29 | Hover detection, throttle sweep detection, throttle normalization | -| `analysis/NoiseAnalyzer.test.ts` | 31 | Peak detection, classification, noise floor | -| `analysis/FilterRecommender.test.ts` | 104 | Noise-based targets, convergence, safety bounds, RPM-aware bounds, dynamic notch, propwash floor, medium noise handling, notch-aware resonance (incl. disabled-notch dyn_notch_count=0 coverage), LPF2 recommendations (incl. D-term disable threshold boundary), conditional Q, motor harmonic diagnostic (F-MOTOR-DIAG), structured ruleId on all recommendations, iterm_relax, anti-gravity, thrust linear, RPM Q (3-4" midpoint 850), D-max, dyn idle, TPA, D-term expo, pidsum limit, FF rate limit, FF-dominated noise guard | +| `analysis/FFTCompute.test.ts` | 24 | Hanning window, Welch's method, sine detection, calibrated v2 power-spectrum scale (detrending, power-domain averaging, known-amplitude sine reads 10·log10(A²/2)) | +| `analysis/SegmentSelector.test.ts` | 31 | Hover detection, throttle sweep detection, throttle normalization, yaw steadiness gating (1.5× threshold) | +| `analysis/NoiseAnalyzer.test.ts` | 36 | Peak detection (plateau handling, 15 Hz min spacing, parabolic sub-bin interpolation), size-aware frame-resonance classification, noise floor | +| `analysis/FilterRecommender.test.ts` | 108 | Noise-based targets, convergence, safety bounds, RPM-aware bounds, dynamic notch, propwash floor, medium noise handling, notch-aware resonance (incl. disabled-notch dyn_notch_count=0 coverage), LPF2 recommendations (incl. D-term disable threshold boundary), conditional Q, motor harmonic diagnostic (F-MOTOR-DIAG), structured ruleId on all recommendations, iterm_relax, anti-gravity, thrust linear, RPM Q (3-4" midpoint 850), D-max, dyn idle, TPA, D-term expo, pidsum limit, FF rate limit, FF-dominated noise guard, yaw-only resonance observation (F-YAW-RES), informational recs bypass deduplication | | `analysis/DataQualityScorer.test.ts` | 39 | Filter/PID data quality scoring, tier mapping, warnings, confidence adjustment, TF data quality, low coherence warning | | `analysis/FilterAnalyzer.test.ts` | 20 | End-to-end pipeline, progress reporting, segment fallback warnings, RPM context propagation, data quality scoring, throttle spectrogram, group delay | -| `analysis/ThrottleSpectrogramAnalyzer.test.ts` | 17 | Throttle-dependent spectrogram analysis, frequency-throttle mapping, noise source tracking | +| `analysis/ThrottleSpectrogramAnalyzer.test.ts` | 23 | Throttle-dependent spectrogram analysis, frequency-throttle mapping, noise source tracking, contiguous-run gating (findContiguousRuns, min 512 samples, length-weighted power average) | | `analysis/GroupDelayEstimator.test.ts` | 28 | Group delay estimation, filter phase response, latency measurement, analytic PT1/notch anchors (denominator-only notch formula), LPF2 modeled as PT1 (BF 4.3+ default) | ### Step Response Analysis @@ -369,23 +372,25 @@ npm run test:ui # Visual interface with DOM snapshots |------|-------|-------------| | `analysis/StepDetector.test.ts` | 16 | Derivative-based step detection, hold/cooldown | | `analysis/StepMetrics.test.ts` | 53 | Rise time, overshoot, settling, latency, ringing, FF contribution classification, trackingErrorRMS computation and aggregation, adaptive window, FF energy ratio | -| `analysis/PIDRecommender.test.ts` | 264 | Flight PID anchoring, convergence, safety bounds, FF context, FF-aware recommendations, flight style thresholds, proportional severity scaling, TF-based recommendations, damping ratio (micro max 1.0 vs standard 0.85), I-term, D-term effectiveness gating (informational P-DTE-BLOCK replacement), prop wash integration, Rule TF-4 DC gain I-term (style-aware threshold), quad-size-aware bounds (1" dMax 80/pTypical 72), severity-scaled sluggish P, P-too-high warning, P-too-low warning, informational flag, relaxed yaw ringing threshold (×1.5), FF boost step 3, D-min/TPA advisory, structured ruleId on all recommendations, iterm_relax_cutoff (severity-aware floor, aggressive typical 30), anti-gravity (700 g gate), thrust linear, RPM notch Q, D-max boost, dyn idle, TPA breakpoint/rate (small breakpoint 1250), D-term expo, pidsum limit (informational), FF rate limit, RC link FF profiles, bounds clamping validation, style-aware d_min gain | +| `analysis/PIDRecommender.test.ts` | 266 | Flight PID anchoring, TF coherence gate (TF rules skipped below coherenceMean 0.5), convergence, safety bounds, FF context, FF-aware recommendations, flight style thresholds, proportional severity scaling, TF-based recommendations, damping ratio (micro max 1.0 vs standard 0.85), I-term, D-term effectiveness gating (informational P-DTE-BLOCK replacement), prop wash integration, Rule TF-4 DC gain I-term (style-aware threshold), quad-size-aware bounds (1" dMax 80/pTypical 72), severity-scaled sluggish P, P-too-high warning, P-too-low warning, informational flag, relaxed yaw ringing threshold (×1.5), FF boost step 3, D-min/TPA advisory, structured ruleId on all recommendations, iterm_relax_cutoff (severity-aware floor, aggressive typical 30), anti-gravity (700 g gate), thrust linear, RPM notch Q, D-max boost, dyn idle, TPA breakpoint/rate (small breakpoint 1250), D-term expo, pidsum limit (informational), FF rate limit, RC link FF profiles, bounds clamping validation, style-aware d_min gain | | `analysis/PIDAnalyzer.test.ts` | 28 | End-to-end pipeline, progress reporting, FF context wiring, flight style propagation, data quality scoring, cross-axis, propwash integration | | `analysis/CrossAxisDetector.test.ts` | 20 | Cross-axis coupling detection, axis interaction analysis | -| `analysis/PropWashDetector.test.ts` | 15 | Propwash detection, wash-out frequency analysis | +| `analysis/PropWashDetector.test.ts` | 20 | Propwash detection, wash-out frequency analysis, clean-segment baseline (computeCleanRuns, whole-flight fallback) | | `analysis/DTermAnalyzer.test.ts` | 8 | D-term effectiveness, energy ratio computation, dCritical flag | | `analysis/WindDisturbanceDetector.test.ts` | 11 | Wind/disturbance detection, gyro variance during hover, calm/moderate/windy classification, per-axis independence, hover-only analysis, multiple segments | -| `analysis/MechanicalHealthChecker.test.ts` | 21 | Mechanical health diagnostic, extreme noise detection, axis asymmetry, motor imbalance, combined issues, threshold edge cases, size-aware extreme-noise threshold (resolveExtremeNoiseThresholdDb: whoop -10 dB vs 5" -20 dB, undefined fallback) | +| `analysis/MechanicalHealthChecker.test.ts` | 21 | Mechanical health diagnostic, extreme noise detection, axis asymmetry, motor imbalance, combined issues, threshold edge cases, size-aware extreme-noise threshold (resolveExtremeNoiseThresholdDb: whoop 0 dB vs 5" -10 dB, undefined fallback) | | `analysis/DynamicLowpassRecommender.test.ts` | 31 | Dynamic lowpass analysis, throttle-noise correlation, recommendation generation (gyro + D-term), threshold validation, structured ruleId, disable hysteresis (4 dB threshold, 4-6 dB gray zone leaves config untouched) | | `analysis/SliderMapper.test.ts` | 16 | Slider-aligned PID mapping, master multiplier, PD ratio, buildRecommendedPIDs, slider delta computation | -| `analysis/FeedforwardAnalyzer.test.ts` | 62 | Extended FF analysis, leading-edge overshoot detection, small-step jitter analysis, RC link rate extraction, smooth/jitter factor recommendations, RC link profile lookup, baseline comparison, merge logic, FF-RC-SMOOTH advisory skipped for aggressive style | +| `analysis/FeedforwardAnalyzer.test.ts` | 65 | Extended FF analysis, leading-edge overshoot detection, small-step jitter analysis, RC link rate extraction, smooth/jitter factor recommendations, RC link profile lookup, baseline comparison, merge logic, FF-RC-SMOOTH advisory skipped for aggressive style, deriveMaxStickRate (max \|setpoint\|, floor 300, fallback 670) | | `analysis/BayesianPIDOptimizer.test.ts` | 31 | Gaussian Process surrogate, Expected Improvement, Latin Hypercube Sampling, bounds | -| `analysis/TransferFunctionEstimator.test.ts` | 28 | Wiener deconvolution, frequency response estimation, Bode plot data, PID recommendations from transfer function, DC gain from 1-5 Hz band average (computeDcGainDb with bin-1/bin-0 fallbacks) | +| `analysis/TransferFunctionEstimator.test.ts` | 32 | Wiener deconvolution, frequency response estimation, Bode plot data, PID recommendations from transfer function, DC gain from 1-5 Hz band average (computeDcGainDb with bin-1/bin-0 fallbacks), magnitude-squared coherence γ²(f) + coherenceMean (1-30 Hz band, ≥2 Welch windows), gain/phase margin crossingFound flags | | `analysis/ThrottleTFAnalyzer.test.ts` | 8 | Per-band TF analysis, throttle binning, variance computation, TPA warning, band boundaries | | `analysis/VerificationMatcher.test.ts` | 33 | Flight similarity matching (mechanical peaks, throttle overlap, step count ratio), filter/PID/flash verification scoring, PID magnitude CoV sub-score, BBL fixture calibration with real flight data | -| `analysis/ConvergenceDetector.test.ts` | 14 | Convergence detection, diminishing returns, iteration tracking, previous session comparison | +| `analysis/ConvergenceDetector.test.ts` | 18 | Convergence detection, diminishing returns, iteration tracking, previous session comparison, spectrum-scale-version guard (refuses cross-scale noise comparison), flash phase-margin sentinel guard (ignores 90° placeholder) | | `analysis/AnalysisPipeline.realdata.bbl.test.ts` | 10 | Real BBL fixture integration tests with actual flight data | | `analysis/AnalysisPipeline.realdata.test.ts` | 20 | End-to-end filter+PID analysis with bf45-reference fixture and real_flight.bbl, safety bounds, determinism, performance | +| `analysis/goldenOutputs.test.ts` | 7 | Golden-output regression harness: full FilterAnalyzer + PIDAnalyzer + TransferFunctionEstimator pipelines over demo-generator BBLs and real VX3.5 logs, snapshot-compared against `__fixtures__/golden/*.json` (recommendations, noise floors, peaks, step metrics; regenerate via `UPDATE_GOLDEN=1`) | +| `analysis/throttleUtils.test.ts` | 4 | Shared throttle normalization (normalizeThrottle) and contiguous-run finder (findContiguousRuns) | | `analysis/OfflineTuning.pipeline.test.ts` | 44 | Offline tuning validation with 4 real VX3.5 BBL logs: pipeline smoke (filter+PID on all logs), factory settings regression, convergence (fixpoint ≤3 iterations, no oscillation), determinism, recommendation direction, cross-pipeline robustness, mechanical health (no false-positive critical), group delay sanity, header extraction invariants, cross-validation Filter+PID vs Flash Tune (noise floor consistency, rec target convergence, step response vs Wiener PID comparison, TF-exclusive metrics, data quality) | ### Header Validation @@ -400,9 +405,9 @@ npm run test:ui # Visual interface with DOM snapshots |------|-------|-------------| | `shared/utils/metricsExtract.test.ts` | 40 | Spectrum downsampling, filter/PID/TF metrics extraction, boundary handling, trackingErrorRMS extraction, step response downsampling, throttleBands extraction, dcGain extraction, throttle spectrogram extraction, recommendation trace extraction | | `shared/utils/verificationDelta.test.ts` | 10 | Verification delta computation, before/after metric comparison, improvement/regression detection, missing metrics handling | -| `shared/utils/tuneQualityScore.test.ts` | 36 | Quality score computation, tier boundaries, partial metrics, backward compat, clamping, TIER_LABELS, verification quality, transfer function metrics (bandwidth, phase margin, quality parity) | +| `shared/utils/tuneQualityScore.test.ts` | 41 | Quality score computation, tier boundaries, partial metrics, backward compat, clamping, TIER_LABELS, verification quality, transfer function metrics (bandwidth, phase margin, quality parity), v2 noise-floor anchors (best -50 / worst -10 dB), phase-margin skip for axes without measured crossover | | `shared/constants.test.ts` | 7 | Preset profile flight style mapping validation | -| `shared/types/profile.types.test.ts` | 5 | FlightStyle type compilation, DroneProfileOptional inheritance | +| `shared/types/profile.types.test.ts` | 4 | FlightStyle type compilation, DroneProfileOptional inheritance | ### Header Validation & Constants @@ -434,7 +439,7 @@ End-to-end tests that launch the real Electron app in demo mode and walk through | `e2e/demo-pid-tune-cycle.spec.ts` | 7 | Full PID Tune cycle: start → modal → erase → download → PID wizard → apply → erase & verify → download → analyze verification → complete → dismiss → check history | | `e2e/demo-quick-tune-cycle.spec.ts` | 7 | Full Flash Tune cycle: start → modal (Flash) → erase → download → flash wizard (auto-analysis) → apply all → erase & verify → download → analyze verification → complete → dismiss → check history | | `e2e/demo-generate-history.spec.ts` | 4 | Generates completed tuning sessions in 4 modes: mixed, filter-only, pid-only, flash-only. Session count configurable via `GENERATE_COUNT` env var (default 5). Excluded from normal `test:e2e` runs, run via `npm run demo:generate-history` | -| `e2e/demo-diagnostic-report.spec.ts` | 7 | Diagnostic Report flow: complete filter tune → report issue from completion summary → submit to dev worker → dismiss → report from history (excluded from normal `test:e2e` runs, run via `npm run test:e2e:diagnostic`) | +| `e2e/demo-diagnostic-report.spec.ts` | 7 | Diagnostic Report flow: complete filter tune → report issue from completion summary → submit to dev worker → dismiss → report from history (included in normal `test:e2e` runs; can be run alone via `npm run test:e2e:diagnostic`) | | `e2e/demo-generate-stress.spec.ts` | 1 | Generates stress-test tuning sessions with edge-case scenarios (excluded from normal `test:e2e` runs, run via `npm run demo:generate-history:stress`) | **E2E infrastructure:** diff --git a/docs/PID_TUNING_KNOWLEDGE.md b/docs/PID_TUNING_KNOWLEDGE.md index db43150..41d5272 100644 --- a/docs/PID_TUNING_KNOWLEDGE.md +++ b/docs/PID_TUNING_KNOWLEDGE.md @@ -526,7 +526,7 @@ Works from **any flight data** — no dedicated maneuvers needed. Pioneered by P ### Noise Floor Scale (FPVPIDlab-Specific) -FPVPIDlab uses a **calibrated one-sided power spectrum** (`SPECTRUM_SCALE_VERSION = 2` in `constants.ts`): segments are detrended (mean removed), Hanning-windowed, normalized by coherent window gain ((Σw)²), Welch-averaged in the power domain, and reported as `10·log10(power)` in dB re (deg/s)². Calibration: a sine of amplitude A reads exactly `10·log10(A²/2)` at its bin, independent of FFT size and sample rate; white-noise floors depend only on FFT size (per-bin power ≈ 2σ²·ENBW/N ≈ 3σ²/N for Hanning), not sample rate. The scale sits ≈10 dB above the legacy v1 amplitude-averaged scale and is still **not directly comparable** to BF Explorer or PIDtoolbox dB values — each tool normalizes differently. Metrics stored by v1 app versions are ≈10 dB lower than v2 values for the same flight. +FPVPIDlab uses a **calibrated one-sided power spectrum** (`SPECTRUM_SCALE_VERSION = 2` in `src/shared/constants.ts`, re-exported from the analysis `constants.ts`; stamped into stored `FilterMetricsSummary` records so cross-version comparisons can be refused): segments are detrended (mean removed), Hanning-windowed, normalized by coherent window gain ((Σw)²), Welch-averaged in the power domain, and reported as `10·log10(power)` in dB re (deg/s)². Calibration: a sine of amplitude A reads exactly `10·log10(A²/2)` at its bin, independent of FFT size and sample rate; white-noise floors depend only on FFT size (per-bin power ≈ 2σ²·ENBW/N ≈ 3σ²/N for Hanning), not sample rate. The scale sits ≈10 dB above the legacy v1 amplitude-averaged scale and is still **not directly comparable** to BF Explorer or PIDtoolbox dB values — each tool normalizes differently. Metrics stored by v1 app versions are ≈10 dB lower than v2 values for the same flight. | FPVPIDlab dB (v2) | Internal Classification | Mapping Rationale | |-----------|----------------------|-------------------| @@ -591,7 +591,7 @@ FPVPIDlab's noise-to-cutoff interpolation range: **-60 dB (cleanest) to 0 dB (no **Rule 4: RPM-Aware Dynamic Notch Simplification** (when RPM filter active) - **Size-aware count target**: sub-5" quads → 2 notches (more complex vibration coupling), 5"+ → 1 notch. If dyn_notch_count > target: step down toward the target, at most **2 per iteration** (`DYN_NOTCH_COUNT_MAX_STEP`) — dropping 5→1 at once can regress axes where removed notches tracked real peaks - **Conditional Q recommendation**: - - If strong frame resonance detected (≥12 dB peaks in 80-200 Hz): keep Q=300 (wider notch needed to catch broad resonance) — medium confidence + - If strong frame resonance detected (≥12 dB peaks classified `frame_resonance` — size-aware band, 5": 80-200 Hz): keep Q=300 (wider notch needed to catch broad resonance) — medium confidence - Otherwise: recommend Q=500 (narrower notch, less signal distortion) - *Rationale*: With RPM handling motor harmonics, the dynamic notch only needs to catch frame resonance — 1 narrow notch suffices on 5"+; small builds keep 2. Community consensus supports simplification (UAV Tech, BF 4.3+ notes); the per-size split and max step are FPVPIDlab house choices. diff --git a/docs/README.md b/docs/README.md index 9af7aea..82c62f8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,7 @@ Overview of all design documents in this directory. Completed documents are arch | Document | Status | Description | |----------|--------|-------------| -| [TUNING_ALGORITHMS_AUDIT](./TUNING_ALGORITHMS_AUDIT.md) | **Proposed** | Deep DSP/tuning-algorithm audit + market benchmark (PIDtoolbox, Plasmatree, Blackbox Explorer, FPVtune) → phased roadmap: calibrated PSD, deconvolved step response, coherence, BF 4.5/4.6 coverage, differentiators | +| [TUNING_ALGORITHMS_AUDIT](./TUNING_ALGORITHMS_AUDIT.md) | **Active** | Deep DSP/tuning-algorithm audit + market benchmark (PIDtoolbox, Plasmatree, Blackbox Explorer, FPVtune) → phased roadmap. Phase 0 + Phase 1 (calibrated v2 PSD, robust peaks, size-aware resonance, coherence, contiguity, propwash baseline, yaw, verify coverage) implemented; Phases 2-3 (deconvolved step response, BF 4.5/4.6 coverage, differentiators) proposed | | [TUNING_MODE_COMPARISON](./TUNING_MODE_COMPARISON.md) | **Active** | Filter+PID Tune vs Flash Tune comparison — offline cross-validation findings, real-world validation plan | | [TUNING_SESSION_EVALUATION](./TUNING_SESSION_EVALUATION.md) | **Active** | Tuning session evaluation strategy — size-aware noise thresholds, per-mode success criteria, convergence detection | | [BLACKBOX_DOWNLOAD_OPTIMIZATION](./BLACKBOX_DOWNLOAD_OPTIMIZATION.md) | **Proposed** | MSC mode for flash storage (10–50× speedup) with MSP pipelining fallback (1.5–2×). Larger chunks deprioritized (already tested, poor results) | diff --git a/docs/TUNING_ALGORITHMS_AUDIT.md b/docs/TUNING_ALGORITHMS_AUDIT.md index c08e5b8..00181a6 100644 --- a/docs/TUNING_ALGORITHMS_AUDIT.md +++ b/docs/TUNING_ALGORITHMS_AUDIT.md @@ -1,9 +1,11 @@ # Tuning Algorithms Audit & Improvement Roadmap -> **Status**: Proposed +> **Status**: Active Deep audit of FPVPIDlab's tuning algorithms (July 2026): DSP correctness review of `src/main/analysis/`, knowledge-base/apply/verification flow review, and a market benchmark against state-of-the-art tools (PIDtoolbox PRO v0.74, Plasmatree PID-Analyzer, Betaflight Blackbox Explorer 2025.12, FPVtune). Produces a phased roadmap toward being the best FPV tuning tool on the market. +**Implementation status (July 2026)**: Phase 0 (P0.1) and all of Phase 1 (P1.1–P1.9) are ✅ implemented — calibrated v2 power spectrum with all dB thresholds recalibrated (`SPECTRUM_SCALE_VERSION = 2`), robust peak detection, size-aware frame-resonance bands, coherence computation + TF rule gating, quick-win batch, contiguity-safe throttle-binned FFT/TF, clean-segment prop-wash baseline, yaw coverage, and extended apply verification. Phases 2 and 3 remain proposed. + **Overall assessment**: the architecture is solid and above average — convergent absolute-target filter recommendations, notch-aware resonance handling, quad-size bounds, second-flight verification with similarity matching, convergence detection, data quality scoring with confidence downgrades, and a knowledge base enforced as source of truth. The code faithfully implements the documented rules. However, the DSP core has correctness gaps and methodology shortfalls that SOTA tools handle better — chiefly step response without deconvolution/stacking, uncalibrated "PSD", dead coherence plumbing, and missing Betaflight 4.5/4.6 coverage. --- @@ -68,23 +70,23 @@ Therefore: **all dB-domain fixes land behind one recalibration event (P1.1); the | ID | Item | Effort | Risk | |----|------|--------|------| -| P0.1 | **Golden-output harness** — test running the full FilterAnalyzer + PIDAnalyzer + TransferFunctionEstimator pipelines over demo-generator BBLs and real-log fixtures; snapshots recommendations (setting/value/ruleId/confidence), noise floors, peak lists, and step metrics into JSON fixtures. Every subsequent PR diffs against these; fixtures are regenerated only in the two recalibration PRs. New `src/main/analysis/goldenOutputs.test.ts`. Extend the demo generator with known-amplitude injected sines so absolute calibration is testable. | S | Low | +| P0.1 ✅ | **Golden-output harness** — test running the full FilterAnalyzer + PIDAnalyzer + TransferFunctionEstimator pipelines over demo-generator BBLs and real-log fixtures; snapshots recommendations (setting/value/ruleId/confidence), noise floors, peak lists, and step metrics into JSON fixtures. Every subsequent PR diffs against these; fixtures are regenerated only in the two recalibration PRs. New `src/main/analysis/goldenOutputs.test.ts`. Extend the demo generator with known-amplitude injected sines so absolute calibration is testable. | S | Low | ### Phase 1 — DSP correctness + quick wins (one PR per item) | ID | Item | Fixes | Effort | Risk | Depends on | |----|------|-------|--------|------|-----------| -| P1.1 | **Calibrated Welch PSD** — detrend segments, average in power domain, normalize by window energy and sample rate → true one-sided PSD in `FFTCompute.ts`. Recalibrate every dB threshold in the same PR (`constants.ts`, `FilterRecommender`, `MechanicalHealthChecker`, `PropWashDetector`, `DTermAnalyzer`, `DynamicLowpassRecommender`; relative dB deltas roughly double in power domain, absolute floors re-anchored via golden logs). Validate: injected sine of known amplitude matches theoretical PSD; golden diff shows unchanged recommendation *directions*. | A1 | M | **High** | P0.1 | -| P1.2 | **Robust peak detection** — prominence-based with plateau handling (centroid of flat tops), ~15–20 Hz minimum spacing, parabolic interpolation for sub-bin frequency, median-band local floor. Validate with synthetic spectra (close peaks, plateaus, between-bin peaks). | A2 | M | Medium | P1.1 | -| P1.3 | **Size-aware frame-resonance bands** — `Record` (e.g. 7": 60–150, 5": 80–200, 3": 120–280, 2.5"/1": 150–350 Hz) threaded into `classifyPeak`. | A3 | S | Low | P1.2 | -| P1.4 | **Coherence** — compute γ²(f) = |S_xy|²/(S_xx·S_yy) in `TransferFunctionEstimator` (cross/auto spectra already exist), pass per-axis mean from `PIDAnalyzer.extractViaWiener` into `DataQualityScorer` (dormant tests come alive); gate TF-derived rules (TF-1..TF-4) on coherence ≥ ~0.5 in-band. | A7, partially A8 | M | Low | P0.1 | -| P1.5 | **Quick-win batch** — (a) dedupe `normalizeThrottle` into `src/shared/utils/`; (b) derive maxStickRate from the BBL rate profile, fallback 670; (c) mark ≤5" `simplified_dmax_gain=0` recommendation `informational: true`; (d) TF margins return `crossingFound: false` instead of silent 60/90 caps, consumers downgrade confidence. | A11, B, A8 | S | Low | P0.1 | -| P1.6 | **Contiguity-safe throttle-binned FFT** — collect whole FFT windows lying entirely within contiguous runs of a throttle band; average per-window PSDs; bands with too few windows report insufficient data. | A5 | M | Medium | P1.1 | -| P1.7 | **Prop-wash baseline fix** — compute the 20–90 Hz baseline from clean (hover/cruise) segments exposed by `SegmentSelector` instead of the whole flight; verify severity-tier ratios against golden logs. | A10 | M | Medium | P1.1 | -| P1.8 | **Yaw coverage** — include yaw in noise and steadiness analysis with yaw-specific expectations (no D rules; damping-ratio validation stays roll/pitch-only by design, documented in KB). | A11 | M | Medium | P1.1 | -| P1.9 | **Verify-applied coverage** — parse the MSP_PID_ADVANCED offsets already present in `mspLayouts.ts` (tpa, anti-gravity, thrust_linear, dyn_idle, pidsum, vbat_sag, simplified_dmax_gain, dterm dyn expo) and remove them from `FF_CLI_ONLY` so applied values are actually verified. | B | M | Low | — | - -**Phase 1 exit criteria**: golden outputs stable across reruns; calibration unit tests green; `/tuning-advisor` audit passed; real-log recommendation directions unchanged vs pre-Phase-1. +| P1.1 ✅ | **Calibrated Welch PSD** — detrend segments, average in power domain, normalize by window energy and sample rate → true one-sided PSD in `FFTCompute.ts`. Recalibrate every dB threshold in the same PR (`constants.ts`, `FilterRecommender`, `MechanicalHealthChecker`, `PropWashDetector`, `DTermAnalyzer`, `DynamicLowpassRecommender`; relative dB deltas roughly double in power domain, absolute floors re-anchored via golden logs). Validate: injected sine of known amplitude matches theoretical PSD; golden diff shows unchanged recommendation *directions*. | A1 | M | **High** | P0.1 | +| P1.2 ✅ | **Robust peak detection** — prominence-based with plateau handling (centroid of flat tops), ~15–20 Hz minimum spacing, parabolic interpolation for sub-bin frequency, median-band local floor. Validate with synthetic spectra (close peaks, plateaus, between-bin peaks). | A2 | M | Medium | P1.1 | +| P1.3 ✅ | **Size-aware frame-resonance bands** — `Record` (e.g. 7": 60–150, 5": 80–200, 3": 120–280, 2.5"/1": 150–350 Hz) threaded into `classifyPeak`. | A3 | S | Low | P1.2 | +| P1.4 ✅ | **Coherence** — compute γ²(f) = |S_xy|²/(S_xx·S_yy) in `TransferFunctionEstimator` (cross/auto spectra already exist), pass per-axis mean from `PIDAnalyzer.extractViaWiener` into `DataQualityScorer` (dormant tests come alive); gate TF-derived rules (TF-1..TF-4) on coherence ≥ ~0.5 in-band. | A7, partially A8 | M | Low | P0.1 | +| P1.5 ✅ | **Quick-win batch** — (a) dedupe `normalizeThrottle` into `src/shared/utils/`; (b) derive maxStickRate from the BBL rate profile, fallback 670; (c) mark ≤5" `simplified_dmax_gain=0` recommendation `informational: true`; (d) TF margins return `crossingFound: false` instead of silent 60/90 caps, consumers downgrade confidence. | A11, B, A8 | S | Low | P0.1 | +| P1.6 ✅ | **Contiguity-safe throttle-binned FFT** — collect whole FFT windows lying entirely within contiguous runs of a throttle band; average per-window PSDs; bands with too few windows report insufficient data. | A5 | M | Medium | P1.1 | +| P1.7 ✅ | **Prop-wash baseline fix** — compute the 20–90 Hz baseline from clean (hover/cruise) segments exposed by `SegmentSelector` instead of the whole flight; verify severity-tier ratios against golden logs. | A10 | M | Medium | P1.1 | +| P1.8 ✅ | **Yaw coverage** — include yaw in noise and steadiness analysis with yaw-specific expectations (no D rules; damping-ratio validation stays roll/pitch-only by design, documented in KB). | A11 | M | Medium | P1.1 | +| P1.9 ✅ | **Verify-applied coverage** — parse the MSP_PID_ADVANCED offsets already present in `mspLayouts.ts` (tpa, anti-gravity, thrust_linear, dyn_idle, pidsum, vbat_sag, simplified_dmax_gain, dterm dyn expo) and remove them from `FF_CLI_ONLY` so applied values are actually verified. | B | M | Low | — | + +**Phase 1 exit criteria**: golden outputs stable across reruns; calibration unit tests green; `/tuning-advisor` audit passed; real-log recommendation directions unchanged vs pre-Phase-1. ✅ **Phase 0 and Phase 1 complete** — golden fixtures live in `src/main/analysis/__fixtures__/golden/` (regenerate via `UPDATE_GOLDEN=1`). ### Phase 2 — SOTA parity diff --git a/docs/TUNING_SESSION_EVALUATION.md b/docs/TUNING_SESSION_EVALUATION.md index 33a9efb..8353c34 100644 --- a/docs/TUNING_SESSION_EVALUATION.md +++ b/docs/TUNING_SESSION_EVALUATION.md @@ -23,17 +23,19 @@ Noise floor thresholds are adjusted per drone size. Smaller quads with higher KV Classification uses strict `>` comparisons: exactly on the boundary = the lower category. +All dB values are on the **v2 calibrated power-spectrum scale** (`SPECTRUM_SCALE_VERSION = 2`: detrended Hanning windows, power-domain Welch averaging, 10·log10 — a sine of amplitude A reads 10·log10(A²/2)). They sit ≈10 dB above the legacy v1 amplitude-averaged scale; stored metrics from v1 app versions are not directly comparable. + | Size | HIGH (noisy) | MEDIUM | LOW (clean) | Typical KV | |------|-------------|--------|-------------|------------| -| 1" | > -15 dB | > -30 and ≤ -15 | ≤ -30 | 19,000+ | -| 2.5" | > -20 dB | > -35 and ≤ -20 | ≤ -35 | 4,500+ | -| 3" | > -25 dB | > -40 and ≤ -25 | ≤ -40 | 3,000-4,500 | -| 4" | > -27 dB | > -40 and ≤ -27 | ≤ -40 | 2,500-3,500 | -| 5" | > -30 dB | > -50 and ≤ -30 | ≤ -50 | 1,750-2,100 | -| 6" | > -33 dB | > -50 and ≤ -33 | ≤ -50 | 1,300-1,500 | -| 7" | > -35 dB | > -55 and ≤ -35 | ≤ -55 | 1,100-1,300 | +| 1" | > -5 dB | > -20 and ≤ -5 | ≤ -20 | 19,000+ | +| 2.5" | > -10 dB | > -25 and ≤ -10 | ≤ -25 | 4,500+ | +| 3" | > -15 dB | > -30 and ≤ -15 | ≤ -30 | 3,000-4,500 | +| 4" | > -17 dB | > -30 and ≤ -17 | ≤ -30 | 2,500-3,500 | +| 5" | > -20 dB | > -40 and ≤ -20 | ≤ -40 | 1,750-2,100 | +| 6" | > -23 dB | > -40 and ≤ -23 | ≤ -40 | 1,300-1,500 | +| 7" | > -25 dB | > -45 and ≤ -25 | ≤ -45 | 1,100-1,300 | -**Source**: PIDToolBox -30 dB standard (5" reference), scaled by KV/prop-size relationship. +**Source**: PIDToolBox -30 dB standard (5" reference, amplitude-dB convention) shifted +10 dB to the v2 power-spectrum scale, scaled by KV/prop-size relationship. **Implementation**: `NOISE_LEVEL_BY_SIZE` in `src/main/analysis/constants.ts`, consumed by `NoiseAnalyzer.categorizeNoiseLevel()`. @@ -93,6 +95,7 @@ Classification uses strict `>` comparisons: exactly on the boundary = the lower Propwash is evaluated via PropWashDetector during PID Tune and Flash Tune analysis: - **Detection**: Throttle-down events with post-event FFT in 20-90 Hz band +- **Baseline**: Severity ratio is measured against a clean-segment baseline — band energy of contiguous runs outside every drop + post-drop window (falls back to the whole flight when no clean run ≥ 1024 samples) - **Metrics**: Mean severity ratio, worst axis, dominant frequency - **Severity scale**: minimal (< 2.0), moderate (2.0-5.0), severe (≥ 5.0) - **Impact on recommendations**: Triggers d_min gain adjustment, iterm_relax cutoff reduction, TPA mode/breakpoint changes @@ -110,10 +113,14 @@ The flight quality score (0-100) uses type-aware components: When verification data is present, a **Noise Delta** component is added (improvement/regression dB). +Noise-floor scoring anchors are on the v2 power-spectrum scale (best -50 dB, worst -10 dB). The Phase Margin component skips axes without a measured gain crossover (`phaseMarginCrossingFound === false`) — the 90° cap is a sentinel, not a measurement. + **Implementation**: `src/shared/utils/tuneQualityScore.ts` ## Convergence Detection +**Cross-scale guard**: `FilterMetricsSummary` records are stamped with `spectrumScaleVersion` at write time. When the initial and verification flights were measured on different scale versions (e.g. a v1-stored flight vs a v2 measurement after an app update), the ConvergenceDetector refuses the noise-floor comparison and reports a neutral "continue" — the ~+10 dB scale shift would otherwise read as a huge regression. Flash convergence also skips the cross-scale noise check and ignores 90° phase-margin placeholders (`phaseMarginCrossingFound = false`). + A tuning mode is considered converged when: 1. Recommended changes are all within deadzone thresholds 2. Quality score is stable across 2+ sessions (±5 points) diff --git a/src/main/CLAUDE.md b/src/main/CLAUDE.md index cb3ef94..de121f0 100644 --- a/src/main/CLAUDE.md +++ b/src/main/CLAUDE.md @@ -23,7 +23,7 @@ Entry point: `src/main/index.ts`. Manages MSPClient, ProfileManager, SnapshotMan - Array of `CompletedTuningRecord[]` per profile (oldest-first on disk, newest-first in API) - Archived from completed sessions with self-contained metrics + applied changes - Deleted when profile is deleted -- Compact metrics: `FilterMetricsSummary` (noise floor, peaks, 128-bin spectrum, throttle spectrogram), `PIDMetricsSummary` (step response), `TransferFunctionMetricsSummary` (bandwidth, phase margin, dcGain, throttleBands) +- Compact metrics: `FilterMetricsSummary` (noise floor, peaks, 128-bin spectrum, throttle spectrogram, `spectrumScaleVersion` stamp — ConvergenceDetector refuses cross-scale-version noise comparisons), `PIDMetricsSummary` (step response), `TransferFunctionMetricsSummary` (bandwidth, phase margin, dcGain, throttleBands) - Spectrum downsampling: `downsampleSpectrum()`, `extractThrottleSpectrogram()` in `src/shared/utils/metricsExtract.ts` **Telemetry Storage** (`TelemetryManager.ts` + `TelemetryEventCollector.ts`): @@ -115,4 +115,4 @@ Snapshots carry tuning metadata (`tuningSessionNumber`, `tuningType`, `snapshotR ## Post-Apply Verification -On smart reconnect after apply, `verifyAppliedConfig()` (`src/main/utils/verifyAppliedConfig.ts`) reads back full PID and filter configuration from FC via MSP, compares ALL readable values (not just applied changes), and runs sanity checks (P/I/D=0, filter bypassed). Applied feedforward changes are verified for the full MSP-readable set (boost, smooth/jitter factor, max rate limit, averaging, d_min_gain, iterm relax, anti-gravity gain, thrust linearization, dynamic idle, vbat sag, TPA mode/rate/breakpoint — the latter group parsed from the extended API 1.45+ MSP_PID_ADVANCED layout, marked `unchecked` on older firmware). Genuinely CLI-only settings (`tpa_low_always`, `pidsum_limit*`, `rc_smoothing_auto_factor`, `simplified_dmax_gain`, `dterm_lpf1_dyn_expo`, `rpm_filter_q`) are skipped during verification; unknown settings land in `unchecked`. Retries PID write+readback once on mismatch (10s timeout). Results stored on `TuningSession.applyVerified`, `applyMismatches`, `applyExpected`, `applyActual`, `applySuspicious`, and `autoReportId`. On failure, auto-submits diagnostic report (Pro only). +On smart reconnect after apply, `verifyAppliedConfig()` (`src/main/utils/verifyAppliedConfig.ts`) reads back full PID and filter configuration from FC via MSP, compares ALL readable values (not just applied changes), and runs sanity checks (P/I/D=0, filter bypassed). Applied feedforward changes are verified for the full MSP-readable set (boost, smooth/jitter factor, max rate limit, averaging, d_min_gain, iterm relax, anti-gravity gain, thrust linearization, dynamic idle, vbat sag, TPA mode/rate/breakpoint — the latter group parsed from the extended API 1.45+ MSP_PID_ADVANCED layout; fields the firmware's shorter pre-1.45 layout doesn't report are silently skipped — marking them `unchecked` would flip verified=false and fire false-positive auto reports on BF 4.3/4.4). Genuinely CLI-only settings (`tpa_low_always`, `pidsum_limit*`, `rc_smoothing_auto_factor`, `simplified_dmax_gain`, `dterm_lpf1_dyn_expo`, `rpm_filter_q`) are skipped during verification; unknown settings land in `unchecked`. Retries PID write+readback once on mismatch (10s timeout). Results stored on `TuningSession.applyVerified`, `applyMismatches`, `applyExpected`, `applyActual`, `applySuspicious`, and `autoReportId`. On failure, auto-submits diagnostic report (Pro only). diff --git a/src/main/analysis/CLAUDE.md b/src/main/analysis/CLAUDE.md index e887d62..93cad51 100644 --- a/src/main/analysis/CLAUDE.md +++ b/src/main/analysis/CLAUDE.md @@ -33,8 +33,8 @@ Noise analysis, step response, transfer function, and data quality scoring modul ### Additional Analysis Modules - **DTermAnalyzer**: D-term effectiveness via FFT energy ratio in 20-150 Hz band. Used for D-increase gating -- **FeedforwardAnalyzer**: RC-link-aware FF baseline + step-response refinement (smooth/jitter factors) -- **MechanicalHealthChecker**: Pre-tuning diagnostics — extreme noise, axis asymmetry, motor imbalance. Extreme-noise threshold is size-aware: `max(-20 dB, NOISE_LEVEL_BY_SIZE[size].highDb + 5 dB)` — avoids false "damaged prop" flags on inherently noisy 1"/2.5" builds. Produces mechanical-health flags consumed by analyzers (may lower confidence or add warnings) +- **FeedforwardAnalyzer**: RC-link-aware FF baseline + step-response refinement (smooth/jitter factors). Small/large-step split uses `deriveMaxStickRate()` — max |setpoint| observed in flight (floor 300 deg/s, fallback 670 when no setpoint data) instead of a hardcoded 670 +- **MechanicalHealthChecker**: Pre-tuning diagnostics — extreme noise, axis asymmetry, motor imbalance. Extreme-noise threshold is size-aware: `max(-10 dB, NOISE_LEVEL_BY_SIZE[size].highDb + 5 dB)` on the v2 scale (5": -10 dB, 1" whoop: 0 dB) — avoids false "damaged prop" flags on inherently noisy 1"/2.5" builds. Produces mechanical-health flags consumed by analyzers (may lower confidence or add warnings) - **WindDisturbanceDetector**: Gyro variance analysis for environmental disturbance. Computes and attaches `windDisturbance` metric to analysis result - **BayesianPIDOptimizer**: Lightweight Gaussian Process surrogate for iterative PID tuning across sessions - **ThrottleTFAnalyzer**: Per-throttle-band transfer function (Wiener deconvolution) for TPA diagnostics (5 bands). Uses the longest contiguous run per band (min 2048 samples) — TF cross-spectra require an unbroken time series @@ -60,3 +60,7 @@ Rates flight data quality 0-100 before generating recommendations. Integrated in ## Flight Quality Score (`src/shared/utils/tuneQualityScore.ts`) Composite 0-100 score with type-aware components (noise floor, overshoot, settling, bandwidth, phase margin). Points redistributed evenly among available components. Displayed as badge in TuningCompletionSummary and TuningHistoryPanel. + +## Golden-Output Regression Harness (`goldenOutputs.test.ts`) + +Runs the full FilterAnalyzer + PIDAnalyzer + TransferFunctionEstimator pipelines over demo-generator BBLs and real VX3.5 logs and snapshot-compares recommendations (setting/value/ruleId/confidence), noise floors, peak lists, and step metrics against `__fixtures__/golden/*.json`. Any analysis change that shifts outputs fails these tests. Regenerate fixtures deliberately with `UPDATE_GOLDEN=1 npx vitest run src/main/analysis/goldenOutputs.test.ts` — only in recalibration PRs (see `docs/TUNING_ALGORITHMS_AUDIT.md`). diff --git a/src/main/msp/CLAUDE.md b/src/main/msp/CLAUDE.md index c7dc999..31be4a8 100644 --- a/src/main/msp/CLAUDE.md +++ b/src/main/msp/CLAUDE.md @@ -57,6 +57,11 @@ Never guess byte offsets — always cross-reference with the configurator source - Auto-read in analysis handlers when FC connected and settings not provided - Byte layout verified against betaflight-configurator MSPHelper.js +## MSP PID Advanced (`MSP_PID_ADVANCED`, command 94) + +- `getFeedforwardConfiguration()` parses the base layout (FF boost/smooth/jitter/max-rate-limit, d_min per axis + gain/advance, iterm_relax type/cutoff, `feedforward_averaging`, `dyn_idle_min_rpm`) plus length-gated extended fields: `vbat_sag_compensation` and `thrust_linear` when the response reaches their offsets, and `anti_gravity_gain`/`tpa_mode`/`tpa_rate`/`tpa_breakpoint` from the 61-byte API 1.45+ layout (a response long enough for TPA also guarantees the ≥1.45 meaning of anti_gravity_gain @21) +- These parsed fields feed `verifyAppliedConfig()` post-apply read-back — on older firmware without the extended layout the settings land in `unchecked` instead of failing verification + ## MSP Dataflash Read (`MSP_DATAFLASH_READ`, command 0x46) - Response format: `[4B readAddress LE][2B dataSize LE][1B isCompressed (BF4.1+)][flash data]` From ba7763ddb3cdb53e84f9b01e95b7ce376da20854 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 14:20:10 +0000 Subject: [PATCH 13/13] docs: address Copilot review comments on PR #434 - throttleUtils.ts descriptions no longer claim it contains the contiguous-run finder (that lives in ThrottleSpectrogramAnalyzer) - msp/CLAUDE.md: pre-1.45 firmware fields are silently skipped during verification, not marked unchecked - tuneQualityScore: phase-margin filter comment documents the backward-compatibility treatment of legacy records without the flag Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJhhk2frYDDnpNbSHRxGdv --- ARCHITECTURE.md | 2 +- README.md | 2 +- src/main/msp/CLAUDE.md | 2 +- src/shared/utils/tuneQualityScore.ts | 6 ++++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 601ade2..516673d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -302,7 +302,7 @@ Two independent analysis pipelines: **filter tuning** (FFT noise analysis) and * | `PropWashDetector.ts` | 367 | 20 | Propwash detection and analysis (clean-segment baseline) | | `DataQualityScorer.ts` | 403 | 39 | Flight data quality scoring (0-100), confidence adjustment, low coherence warning | | `headerValidation.ts` | 300 | 45 | BB header diagnostics, version-aware debug mode, RPM enrichment, preset gap analysis fields | -| `throttleUtils.ts` | 24 | 4 | Shared throttle normalization + contiguous-run finder | +| `throttleUtils.ts` | 24 | 4 | Shared throttle normalization heuristics (1000-2000/0-1000/0-100/0-1 formats) | | `constants.ts` | 927 | 11 | All tunable thresholds (validated by `constants.test.ts`) | #### Filter Analysis Pipeline diff --git a/README.md b/README.md index 397c599..a363ac5 100644 --- a/README.md +++ b/README.md @@ -276,7 +276,7 @@ pidlab/ │ │ │ ├── MechanicalHealthChecker.ts # Frame/motor health diagnostics │ │ │ ├── WindDisturbanceDetector.ts # Wind/disturbance detection │ │ │ ├── headerValidation.ts # BB header diagnostics -│ │ │ ├── throttleUtils.ts # Shared throttle normalization + contiguous run finder +│ │ │ ├── throttleUtils.ts # Shared throttle normalization (contiguous-run finder lives in ThrottleSpectrogramAnalyzer) │ │ │ └── constants.ts # Tunable thresholds │ │ ├── storage/ # Data managers │ │ │ ├── ProfileManager.ts # Multi-quad profile CRUD diff --git a/src/main/msp/CLAUDE.md b/src/main/msp/CLAUDE.md index 31be4a8..6e2d5fb 100644 --- a/src/main/msp/CLAUDE.md +++ b/src/main/msp/CLAUDE.md @@ -60,7 +60,7 @@ Never guess byte offsets — always cross-reference with the configurator source ## MSP PID Advanced (`MSP_PID_ADVANCED`, command 94) - `getFeedforwardConfiguration()` parses the base layout (FF boost/smooth/jitter/max-rate-limit, d_min per axis + gain/advance, iterm_relax type/cutoff, `feedforward_averaging`, `dyn_idle_min_rpm`) plus length-gated extended fields: `vbat_sag_compensation` and `thrust_linear` when the response reaches their offsets, and `anti_gravity_gain`/`tpa_mode`/`tpa_rate`/`tpa_breakpoint` from the 61-byte API 1.45+ layout (a response long enough for TPA also guarantees the ≥1.45 meaning of anti_gravity_gain @21) -- These parsed fields feed `verifyAppliedConfig()` post-apply read-back — on older firmware without the extended layout the settings land in `unchecked` instead of failing verification +- These parsed fields feed `verifyAppliedConfig()` post-apply read-back — on older firmware whose shorter layout omits them, the fields are absent from the read-back and verification silently skips those settings (same treatment as CLI-only settings; they must not flip `verified=false` or fire a false-positive auto diagnostic report) ## MSP Dataflash Read (`MSP_DATAFLASH_READ`, command 0x46) diff --git a/src/shared/utils/tuneQualityScore.ts b/src/shared/utils/tuneQualityScore.ts index 0aef7e1..39f2dbc 100644 --- a/src/shared/utils/tuneQualityScore.ts +++ b/src/shared/utils/tuneQualityScore.ts @@ -152,8 +152,10 @@ const COMPONENTS: ComponentDef[] = [ label: 'Phase Margin', getValue: (_filter, _pid, _verification, tf) => { if (!tf) return undefined; - // Only axes with a measured gain crossover count — a capped 90° - // placeholder (no crossing found) must not read as "very stable". + // Exclude axes explicitly marked as having no measured gain crossover — + // a capped 90° placeholder must not read as "very stable". Records from + // older app versions lack the flag (undefined) and are kept for + // backward compatibility, since their margins cannot be re-derived. const axes = [tf.roll, tf.pitch, tf.yaw].filter((a) => a.phaseMarginCrossingFound !== false); if (axes.length === 0) return undefined; return axes.reduce((s, a) => s + a.phaseMarginDeg, 0) / axes.length;