diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 516673dd..4fb71b88 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,6 +1,6 @@
# Architecture Overview
-**Last Updated:** July 24, 2026 | **Phase 4 Complete, Phase 6 Complete** | **3243 unit tests (3220 passing + 23 skipped), 147 files + 37 Playwright E2E tests**
+**Last Updated:** July 24, 2026 | **Phase 4 Complete, Phase 6 Complete** | **3372 unit tests (3349 passing + 23 skipped), 154 files + 37 Playwright E2E tests**
---
@@ -57,8 +57,8 @@
│ │ ┌───┴──────────┐ ┌─────────────────┐ ┌──────────────────┐ │ │
│ │ │MSPConnection │ │ BlackboxParser │ │ Analysis Engine │ │ │
│ │ │ + CLI Mode │ │ (6 modules, │ │ FFT + Step Resp │ │ │
-│ │ │ + fcEntered │ │ 245 tests) │ │ (27 modules, │ │ │
-│ │ │ CLI flag │ │ │ │ 1127 tests) │ │ │
+│ │ │ + fcEntered │ │ 245 tests) │ │ (31 modules, │ │ │
+│ │ │ CLI flag │ │ │ │ 1207 tests) │ │ │
│ │ └───┬──────────┘ └─────────────────┘ └──────────────────┘ │ │
│ │ │ │ │
│ │ ┌───┴──────────┐ │ │
@@ -291,13 +291,17 @@ Two independent analysis pipelines: **filter tuning** (FFT noise analysis) and *
| `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 |
+| `RpmFilterRecommender.ts` | 326 | 18 | RPM filter tuning rules (F-RPM-*): min_hz from dyn idle floor and measured fundamental track, harmonic count from integer-ratio tracks, fade-range/weights advisories |
+| `FilterPlacementOptimizer.ts` | 257 | 9 | Discrete search over LPF cutoffs / notch count / RPM harmonics minimizing group delay subject to attenuation targets at measured peaks (P3.3) |
| `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 |
+| `StepResponseStacker.ts` | 153 | 10 | Deconvolved (stacked) step response via Wiener windows, input-magnitude split (<500 / >500 deg/s), coherence-weighted trust gate (P2.1) |
| `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) |
+| `SystemIdentifier.ts` | 357 | 9 | System identification: 2nd-order + delay plant fit from coherence-weighted H(f), what-if predicted step response for proposed gains (P3.2) |
| `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 |
@@ -841,18 +845,20 @@ Hardware error (FC timeout, USB disconnect)
| File | Key Exports |
|------|-------------|
| `metricsExtract.ts` | `downsampleSpectrum()`, `downsampleStepResponse()`, `extractFilterMetrics()`, `extractPIDMetrics()`, `extractThrottleSpectrogram()` — compact metrics for history storage |
+| `filterResponse.ts` | `lowpassMagnitudeDb()` (PT1/PT2/PT3/biquad), `notchMagnitudeDb()`, `dynLpfCutoffHz()`, `computeFilterChainCurve()`, `gyroLpf1CutoffAtThrottle()` — filter magnitude models for chart overlays |
+| `bfVersionCapabilities.ts` | `parseBFVersion()` (semver + calendar), `getBFCapabilities()` (4.4/4.5/4.6 gates), `translateSettingForVersion()` (d_min→d_max CLI rename) |
---
## Testing Strategy
-**3243 unit tests across 147 files (3220 passing + 23 skipped fixture-gated) + 37 Playwright E2E tests**. See [TESTING.md](./TESTING.md) for complete inventory.
+**3372 unit tests across 154 files (3349 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 + Throttle Utils) | 9 | 313 |
-| Step Response + PID + TF + CrossAxis + PropWash + DTerm + Bayesian + Verification + Golden Outputs | 21 | 758 |
+| FFT Analysis (+ Data Quality + Spectrogram + Delay + Throttle Utils + RPM Rules + Filter Response + BF Capabilities) | 11 | 374 |
+| Step Response + PID + TF + CrossAxis + PropWash + DTerm + Bayesian + Verification + Golden Outputs + Stacker + SysID + Placement | 25 | 806 |
| Header Validation + Constants + Main Utils | 3 | 82 |
| MSP Protocol & Client | 4 | 196 |
| MSC (Mass Storage) | 2 | 45 |
@@ -863,7 +869,7 @@ Hardware error (FC timeout, USB disconnect)
| Diagnostic | 1 | 12 |
| License | 1 | 12 |
| Auto-Updater | 1 | 12 |
-| UI Components + Charts + Contexts | 56 | 829 |
+| UI Components + Charts + Contexts | 57 | 849 |
| React Hooks + Utils | 18 | 197 |
| Shared Constants & Utils | 5 | 102 |
| E2E Workflows (Vitest) | 1 | 31 |
diff --git a/README.md b/README.md
index a363ac50..c9fa9fe5 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
-- **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
+- **31 analysis modules** — FFT, step response, Wiener deconvolution, deconvolved (stacked) step response, setpoint→gyro coherence, system identification with what-if prediction, prop wash, D-term effectiveness, cross-axis coupling, throttle spectrograms, group delay, feedforward, dynamic lowpass, RPM filter tuning, filter placement optimization, 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
@@ -215,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:** 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.
+**Unit tests:** 3349 tests across 154 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).
@@ -249,18 +249,22 @@ 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 (27 modules)
+│ │ ├── analysis/ # Signal processing & tuning engine (31 modules)
│ │ │ ├── FFTCompute.ts # Welch's method, Hanning window
│ │ │ ├── SegmentSelector.ts # Hover/sweep segment detection
│ │ │ ├── NoiseAnalyzer.ts # Peak detection, noise classification
│ │ │ ├── FilterRecommender.ts # Noise-based filter targets
│ │ │ ├── DynamicLowpassRecommender.ts # Dynamic lowpass cutoff optimization
+│ │ │ ├── RpmFilterRecommender.ts # RPM filter tuning rules (min_hz, harmonics, weights)
+│ │ │ ├── FilterPlacementOptimizer.ts # Notch/LPF placement search (delay vs attenuation)
│ │ │ ├── FilterAnalyzer.ts # Filter analysis orchestrator
│ │ │ ├── StepDetector.ts # Step input detection in setpoint
│ │ │ ├── StepMetrics.ts # Rise time, overshoot, settling, FF classification
+│ │ │ ├── StepResponseStacker.ts # Deconvolved (stacked) step response, magnitude split
│ │ │ ├── PIDRecommender.ts # Rule-based P/I/D recommendations
│ │ │ ├── PIDAnalyzer.ts # Unified PID analysis orchestrator (Deep + Flash)
│ │ │ ├── TransferFunctionEstimator.ts # Wiener deconvolution engine
+│ │ │ ├── SystemIdentifier.ts # Plant model fit + what-if step prediction
│ │ │ ├── ThrottleTFAnalyzer.ts # Per-band TF across throttle levels
│ │ │ ├── DataQualityScorer.ts # Flight data quality scoring (0-100)
│ │ │ ├── PropWashDetector.ts # Throttle-down event detection + severity
diff --git a/SPEC.md b/SPEC.md
index 31d4b7c2..d54a7bb1 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -357,7 +357,7 @@ Automated end-to-end tests running in CI pipeline against a real FC connected to
## Progress Summary
-**Last Updated:** July 24, 2026 | **Tests:** 3243 unit tests across 147 files (3220 passing + 23 skipped) + 37 Playwright E2E tests | **PRs Merged:** #1–#432
+**Last Updated:** July 24, 2026 | **Tests:** 3372 unit tests across 154 files (3349 passing + 23 skipped) + 37 Playwright E2E tests | **PRs Merged:** #1–#432
| Phase | Status | Notes |
|-------|--------|-------|
diff --git a/TESTING.md b/TESTING.md
index 1fb0ce4a..a8b7290d 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -167,7 +167,7 @@ npm run test:ui # Visual interface with DOM snapshots
## Test Inventory
-**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)
+**Total: 3372 unit tests across 154 files (3349 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.
@@ -192,18 +192,19 @@ Per-file counts below include skipped tests (as reported by `vitest run`). The 2
| `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 |
+| `TuningWizard/PreviousSessionComparison.test.tsx` | 6 | Before/after comparison vs last completed session: noise + step comparisons rendered, cross-scale and cross-method guards, empty history, verification-metrics preference |
| `TuningWizard/PhaseIllustration.test.tsx` | 11 | Phase illustration SVG rendering, custom size, aria-hidden, unknown title fallback |
| `TuningWorkflowModal/TuningWorkflowModal.test.tsx` | 23 | Workflow preparation modal, 3-tab layout (Filter/PID/Flash), mode-aware step filtering, flight guide sections |
| `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/RecommendationCard.test.tsx` | 13 | Setting label lookup, value display, change percentage, confidence, feedforward labels, measured-evidence block (rendered/omitted) |
| `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/QuickAnalysisStep.test.tsx` | 8 | Quick analysis dual-panel (filter + TF), auto-run, progress, retry, what-if predicted-response section (rendered/omitted) |
| `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 |
| `TuningWizard/TuningSummaryStep.test.tsx` | 17 | Recommendations table, mode-aware labels (filter/pid/quick), apply/progress/success/error states, tfResult for quick mode |
| `TuningWizard/charts/AxisTabs.test.tsx` | 6 | Tab rendering, selection, aria-selected, onChange callback |
-| `TuningWizard/charts/ThrottleSpectrogramChart.test.tsx` | 10 | Throttle spectrogram heatmap rendering, axis labels, color scale, empty state, compact data (archived) rendering |
+| `TuningWizard/charts/ThrottleSpectrogramChart.test.tsx` | 12 | Throttle spectrogram heatmap rendering, axis labels, color scale, empty state, compact data (archived) rendering, gyro LPF1 cutoff overlay (rendered/disabled/out-of-range) |
| `TuningHistory/AppliedChangesTable.test.tsx` | 7 | Setting changes table, percent formatting, empty state, zero value handling |
| `TuningHistory/NoiseComparisonChart.test.tsx` | 9 | Before/after spectrum overlay, delta pill, axis tabs, empty state |
| `TuningHistory/TuningCompletionSummary.test.tsx` | 27 | Completion summary with/without verification, noise chart, spectrogram comparison (Filter Tune), step response comparison (PID Tune), changes, PID metrics, actions, quality score badge with tier label, re-analyze button, mode-aware titles, smart suggestion buttons, convergence banner, iteration warning, previous session reference display |
@@ -231,9 +232,9 @@ Per-file counts below include skipped tests (as reported by `vitest run`). The 2
| File | Tests | Description |
|------|-------|-------------|
| `TuningWizard/charts/chartUtils.test.ts` | 20 | Data conversion, downsampling, findBestStep, robust Y domain |
-| `TuningWizard/charts/SpectrumChart.test.tsx` | 5 | FFT spectrum chart rendering |
+| `TuningWizard/charts/SpectrumChart.test.tsx` | 11 | FFT spectrum chart rendering, filter-response overlay (curves + dyn notch shading, disabled filters, notch count 0), rule-anchor peak tagging (tagged/untagged) |
| `TuningWizard/charts/StepResponseChart.test.tsx` | 10 | Step response chart rendering, navigation |
-| `TuningWizard/charts/BodePlot.test.tsx` | 4 | Bode plot (magnitude + phase) rendering for transfer function |
+| `TuningWizard/charts/BodePlot.test.tsx` | 6 | Bode plot (magnitude + phase) rendering for transfer function, coherence γ² section (rendered with data, omitted without) |
| `TuningWizard/charts/TFStepResponseChart.test.tsx` | 6 | TF synthetic step response chart, single/comparison modes, overshoot metrics, delta pill |
### Contexts
@@ -359,12 +360,15 @@ Per-file counts below include skipped tests (as reported by `vitest run`). The 2
|------|-------|-------------|
| `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/NoiseAnalyzer.test.ts` | 43 | Peak detection (plateau handling, 15 Hz min spacing, parabolic sub-bin interpolation), size-aware frame-resonance classification, noise floor, throttle-track reclassification (reclassifyPeaksWithThrottle: tracking → motor_harmonic, stationary → frame_resonance/electrical, insufficient bands/prominence keeps heuristic, classifiedBy + throttleTrack stamps) |
+| `analysis/FilterRecommender.test.ts` | 116 | 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, LPF2 latency budget (high-confidence disable when over budget, enable gated by prospective delay, F-LPF2-BUDGET-* advisory, legacy no-delay behavior), structured evidence on resonance/noise-floor rules |
| `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` | 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) |
+| `analysis/GroupDelayEstimator.test.ts` | 31 | Group delay estimation, filter phase response, latency measurement, analytic PT1/notch anchors (denominator-only notch formula), LPF2 modeled as PT1 (BF 4.3+ default), per-size latency budget (budget fields, over-budget flags, size-aware warning, default fallback) |
+| `analysis/RpmFilterRecommender.test.ts` | 18 | RPM filter tuning rules: min_hz from dynamic-idle floor (gap/waste directions, deadzone, clamping), min_hz from measured fundamental track (lower-only), harmonic-count increase from integer-ratio tracks (tolerance, amplitude threshold, max cap), fade-range and weights advisories, cross-axis dedup |
+| `shared/utils/bfVersionCapabilities.test.ts` | 11 | BF version parsing (semver + calendar 2025.12), capability gating (4.4 baseline, 4.5 tpa_low/RPM weights/anti-gravity cutoff, 4.6 d_max rename + chirp), d_min→d_max CLI name translation |
+| `shared/utils/filterResponse.test.ts` | 18 | Filter magnitude models: PT1/PT2/PT3 −3 dB at cutoff (BF cutoff corrections), Butterworth biquad rolloff, notch depth/transparency, type dispatch, BF dynamic-LPF throttle curve (linear/expo/clamping), chain combination (dB summing, disabled stages, throttle evaluation, display floor), gyro LPF1 cutoff-at-throttle |
### Step Response Analysis
@@ -372,19 +376,22 @@ Per-file counts below include skipped tests (as reported by `vitest run`). The 2
|------|-------|-------------|
| `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` | 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/StepResponseStacker.test.ts` | 10 | Deconvolved (stacked) step response: Wiener deconvolution windows, low/high input-magnitude split (500 deg/s), input-energy-weighted coherence trust gate (≥2 windows, ≥0.5), quiet-window skip (max \|setpoint\| < 50 deg/s), metric extraction on 2nd-order demo plant, curve downsampling |
+| `analysis/PIDRecommender.test.ts` | 270 | 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, P-TPA-LOW (BF 4.5+ low-throttle TPA on severe propwash, firmware-support gate) |
| `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` | 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 0 dB vs 5" -10 dB, undefined fallback) |
+| `analysis/MechanicalHealthChecker.test.ts` | 26 | 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), per-motor spectral fault signatures (bent-prop order peak, bearing broadband, symmetric silence, short-data skip, experimental flags never degrade status) |
| `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/FilterPlacementOptimizer.test.ts` | 9 | Filter placement optimizer (P3.3): notch-vs-lowpass tradeoff, infeasible low-frequency peak, LPF1-off requires RPM, delay delta vs current config, residual target, advisory emission (improvement threshold, near-optimal silence, infeasible silence) |
+| `analysis/SystemIdentifier.test.ts` | 9 | System identification (P3.2): known 2nd-order plant recovery from analytic closed loop (ωn/ζ/τ tolerances), coherence + bin-count gates, predicted step settles to unity, D-raise reduces predicted overshoot, P-raise speeds rise, what-if current+proposed pair, BF PID scale anchor |
| `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` | 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/ThrottleTFAnalyzer.test.ts` | 15 | Per-band TF analysis (roll + pitch), throttle binning, variance computation, TPA warning, band boundaries, TF-driven TPA rules (rate-up/down, breakpoint onset, flat trend, guard conditions, worst-axis selection, rate cap) |
| `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` | 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 |
diff --git a/docs/PID_TUNING_KNOWLEDGE.md b/docs/PID_TUNING_KNOWLEDGE.md
index 41d52721..7a582f06 100644
--- a/docs/PID_TUNING_KNOWLEDGE.md
+++ b/docs/PID_TUNING_KNOWLEDGE.md
@@ -348,6 +348,30 @@ Harmonics appear at 2×, 3×, etc. of the fundamental — RPM filter places notc
- **Multiple equally-spaced peaks** (≥3) = motor harmonic series
- PIDtoolbox and BF Explorer both show peaks in spectral view
+### Throttle-Track Peak Classification (implemented)
+
+The average-spectrum heuristic (equal spacing, frequency bands) is refined with measured
+throttle-tracking evidence when a throttle spectrogram is available
+(`reclassifyPeaksWithThrottle()` in `NoiseAnalyzer.ts`, wired in `FilterAnalyzer.ts`):
+
+- For each detected peak, the peak's local maximum is re-located per throttle band inside a
+ ±30% relative search window (`TRACK_SEARCH_REL_WINDOW = 0.3`), requiring ≥6 dB prominence
+ over the band's local floor (`TRACK_BAND_MIN_PROMINENCE_DB = 6`)
+- Requires ≥3 throttle bands with usable spectra (`HARMONIC_TRACK_MIN_BANDS = 3`)
+- **Tracks throttle** → reclassified `motor_harmonic`: Pearson correlation of band-peak
+ frequency vs throttle ≥ 0.6 (`HARMONIC_TRACK_MIN_CORRELATION`) AND relative frequency range
+ ≥ 15% (`HARMONIC_TRACK_MIN_REL_RANGE`)
+- **Stationary** → reclassified `frame_resonance` (inside the size-aware band) or
+ `electrical` (>500 Hz): relative range ≤ 8% (`STATIONARY_TRACK_MAX_REL_RANGE`)
+- Ambiguous tracks keep their heuristic classification
+- Result is stamped on the peak: `classifiedBy: 'throttle_track' | 'heuristic'`, with the
+ measured `throttleTrack` (throttle midpoints + tracked frequencies) attached for UI/telemetry
+
+*Rationale*: whole-flight average spectra smear RPM-varying motor harmonics, so
+spacing-based heuristics misclassify (e.g. a fixed 600 Hz ESC switching peak landing on a
+harmonic grid). Measured throttle correlation is the physically correct discriminator —
+identical to how PIDtoolbox users read the throttle×frequency spectrogram visually.
+
### Throttle Spectrogram
- FFT computed per throttle band (typically 10 bands from 0-100%)
@@ -601,6 +625,7 @@ FPVPIDlab's noise-to-cutoff interpolation range: **-60 dB (cleanest) to 0 dB (no
- **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.
+- **Latency budget (P2.7)**: LPF2 decisions weigh the measured group delay against a per-size budget (`FILTER_LATENCY_BUDGET_BY_SIZE`: gyro/D-term — 5" 1.5/3.0 ms, 3-4"+6" 2.0/3.5, 1"/2.5"+7" 2.5/4.0; default 2.0/3.5 when size unknown; house values anchored on BF "even 1 ms matters"). Disable recs upgrade to **high confidence** when the chain is over budget; enable recs are gated — if adding LPF2 (PT1 at 250/150 Hz, ~0.6/0.8 ms at the 80 Hz reference) would exceed the budget, an informational advisory (F-LPF2-BUDGET-GYRO/DTERM) recommends fixing noise at the source instead. Both directions surface "Filter latency: X ms (budget Y ms)". The GroupDelayEstimator warning is budget-aware (size-specific) instead of the fixed 2 ms.
**Dynamic Lowpass Rules (F-DLPF-*)** — `DynamicLowpassRecommender`:
- **Enable** (F-DLPF-GYRO / F-DLPF-DTERM): throttle-dependent noise detected — noise increase from low to high throttle ≥ **6 dB**, Pearson throttle-noise correlation ≥ **0.6**, and at least **3 throttle bands** with data. Recommends `dyn_min = current static cutoff`, `dyn_max = static × 2` (BF 2:1 convention). Only fires when dynamic mode is not already active — when it is, the FilterRecommender tunes dyn_min/max directly.
@@ -610,11 +635,48 @@ 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
+**RPM Filter Tuning Rules (F-RPM-*)** — `RpmFilterRecommender`, only when RPM filter active:
+- **F-RPM-MIN-IDLE**: with dynamic idle active, the motor fundamental never drops below `idleHz = dyn_idle_min_rpm × 100 / 60`. Target `rpm_filter_min_hz = round(idleHz × 0.9)` clamped to 40-150 (`RPM_MIN_HZ_FLOOR/CEILING`), 15 Hz deadzone. Floor **above** idleHz → uncovered low-throttle gap → lower (medium confidence, noise). Floor **far below** → wasted deep notching → raise (low confidence, latency).
+- **F-RPM-MIN-TRACK**: without dynamic-idle info, a measured fundamental track (P2.2 `throttleTrack`) reaching below the current floor proves a coverage gap → lower to `round(minTracked × 0.9)`. Never raises from track data alone (the flight may not have visited low throttle). Medium confidence.
+- **F-RPM-HARM-UP**: a measured track at ~k× the fundamental (ratio within ±0.25 of an integer, `RPM_HARMONIC_RATIO_TOLERANCE`) with k > current `rpm_filter_harmonics` and amplitude ≥12 dB proves an unfiltered harmonic order → raise count to k (max 3). Medium confidence. Suppresses F-MOTOR-DIAG (the residual is explained by the missing notch order, not motor_poles/telemetry issues).
+- **F-RPM-FADE** (informational): `rpm_filter_fade_range_hz = 0` hard-stops notches at the floor → suggest the BF default 50.
+- **F-RPM-WEIGHTS** (informational, BF 4.5+): weights at full depth (100,100,100) → suggest size-appropriate community weights (table in Section 2); only fires when the BBL header reports `rpm_filter_weights` (proof of firmware support).
+- *Rationale*: min_hz/harmonics from **measured** idle floor and harmonic tracks instead of static defaults; latency-aware (never pushes notching below frequencies the motors can reach). Ratios 0.9/±0.25/deadzone 15 Hz are FPVPIDlab house values.
+
**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'.
+### Step Response Metrics Source (Deconvolved vs Per-Step)
+
+PID Tune's headline overshoot/rise/settling come from the **Wiener-deconvolved
+(stacked) step response** (`StepResponseStacker.ts`) when trustworthy, split by
+commanded input magnitude at **500 deg/s** (`INPUT_SPLIT_THRESHOLD_DEG_S`, the
+PIDtoolbox convention — Betaflight's FF/D-setpoint transition behaves
+differently in the two regimes; the high-magnitude group is preferred as it
+exercises P/D). Windows with max |setpoint| < 50 deg/s are excluded (no
+commanded-input information; they only dilute coherence). The mean coherence is
+**input-energy-weighted** (weighted by S_xx per bin over 1-30 Hz) so bins the
+pilot never excited don't drag it down.
+
+Trust gates: ≥2 Welch windows in the group AND weighted coherence ≥ 0.5
+(`DECONV_COHERENCE_GATE`). Otherwise the axis falls back to per-step means
+(`metricsSource: 'per_step'`, the pre-2026-07 behavior).
+
+**Threshold scaling**: the deconvolved estimate is inherently smoother than
+direct per-step measurement — the same physical response reads ≈half the
+overshoot/settling (calibrated on the demo generator's known second-order
+plant; see `DECONV_THRESHOLD_SCALE = 0.5` in constants.ts). When an axis's
+metrics come from deconvolution, overshoot and settling THRESHOLDS are scaled
+by 0.5; rise-time thresholds are unchanged (comparable between methods).
+`PID_STYLE_THRESHOLDS` themselves stay calibrated for per-step values.
+
+**Cross-check**: when both methods produce meaningful overshoot (≥5 pp) and
+disagree by >50% relative, a `step_deconv_disagreement` warning is emitted —
+per-step means on sparse/noisy logs can be dominated by a few bad step
+measurements (observed on real logs: per-step 56% vs deconvolved 2%).
+
### PID Recommendation Rules
Per-axis rules anchored to **flight PIDs from BBL header** (convergent design — re-analyzing same flight after applying yields no further changes):
@@ -978,6 +1040,12 @@ Composite 0-100 score computed after tuning session completes. Components vary b
**FPVPIDlab `TPA_BY_SIZE`**: small (1-4"): rate 50, breakpoint **1250** (matches whoop/tiny presets); standard (5"): rate 65, breakpoint 1350; large (6-7"): rate 80, breakpoint 1250.
+**Measured TF-driven TPA rules (P2.8, implemented)** — Flash Tune only, from the per-throttle-band transfer function (`recommendTPAFromThrottleTF()` in ThrottleTFAnalyzer, roll + pitch, worst axis drives):
+- **TPA-TF-RATE-UP**: measured overshoot grows ≥10 pp from the low- to high-throttle bands (`TPA_TF_OVERSHOOT_DELTA_PP`) → gains too hot up top, raise `tpa_rate` by 10 (cap 80). Medium confidence.
+- **TPA-TF-BREAKPOINT**: with rate-up evidence, the breakpoint is lowered to the throttle where overshoot first exceeds the low-band mean +10 pp (mapped to µs, clamped 1250-1750, 100 µs deadzone). Medium confidence.
+- **TPA-TF-RATE-DOWN**: high-band overshoot < 5% while low bands overshoot ≥10 pp more → overdamped punch-outs, lower `tpa_rate` by 10 (floor 30). Low confidence.
+- Precedence: measured TF rules override the static size-based P-TPA advisory for the same setting; propwash safety rules (PW-TPA-*) always win. Requires `tpa_rate` in BBL headers and ≥3 bands with TF data.
+
### Anti-Gravity
- Boosts I-term temporarily during rapid throttle changes (punch-outs, drops)
diff --git a/docs/README.md b/docs/README.md
index 82c62f81..74d950a9 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) | **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_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), Phase 2 (deconvolved step response, throttle-aware peak classification, filter overlays, before/after comparison, BF 4.5/4.6 capabilities, RPM filter rules, latency budget, ThrottleTF pitch + TPA), and Phase 3 P3.1–P3.4 (evidence UI, system ID what-if, filter placement optimizer, motor fault signatures) implemented; P3.5 (crowd benchmarking) deferred |
| [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 00181a60..19e5e535 100644
--- a/docs/TUNING_ALGORITHMS_AUDIT.md
+++ b/docs/TUNING_ALGORITHMS_AUDIT.md
@@ -4,7 +4,7 @@
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.
+**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. Phase 2 (P2.1–P2.8) is ✅ implemented — deconvolved (stacked) step response, throttle-aware harmonic classification, filter response overlays, before/after session comparison, BF version-capabilities layer, RPM filter tuning rules, per-size latency budget, and ThrottleTF pitch + measured TPA. Phase 3 items P3.1–P3.4 are ✅ implemented — explainable recommendations + coherence UI, system identification with what-if simulation, filter placement optimizer, and per-motor mechanical fault signatures. Only P3.5 (longitudinal/crowd benchmarking) remains, deliberately deferred.
**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.
@@ -92,14 +92,14 @@ Therefore: **all dB-domain fixes land behind one recalibration event (P1.1); the
| 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 |
+| 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). ✅ Implemented (`StepResponseStacker.ts`, `DECONV_THRESHOLD_SCALE`) | 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. ✅ Implemented (`reclassifyPeaksWithThrottle()` in NoiseAnalyzer) | 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. ✅ Implemented (`src/shared/utils/filterResponse.ts`, overlays in SpectrumChart + ThrottleSpectrogramChart) | 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. ✅ Implemented (`PreviousSessionComparison` in Filter/PID analysis steps, cross-scale + cross-method guards) | 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. ✅ Core implemented (`src/shared/utils/bfVersionCapabilities.ts`: version parsing incl. calendar 2025.12, capability gates, d_min→d_max CLI translation in the apply flow; P-TPA-LOW emission gated on firmware support; F-RPM-WEIGHTS gated on header presence; slider deltas already shipped via `sliderDelta`). Remaining: anti_gravity_cutoff/p_gain rules need measured evidence — deferred | 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. ✅ Implemented (`RpmFilterRecommender.ts`, rules F-RPM-*) | 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)". ✅ Implemented (`FILTER_LATENCY_BUDGET_BY_SIZE`, budget-aware LPF2 rules + GroupDelayEstimator) | 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. ✅ Implemented (`recommendTPAFromThrottleTF()`, TPA-TF-* rules; `tpa_low_*` emission deferred to P2.5 version layer) | 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.
@@ -107,10 +107,10 @@ Therefore: **all dB-domain fixes land behind one recalibration event (P1.1); the
| 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.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. ✅ Implemented (`SystemIdentifier.ts`, `PIDAnalysisResult.whatIf`, QuickAnalysisStep prediction chart) | 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`. ✅ Implemented (`checkMotorSpectralSignatures()` in MechanicalHealthChecker: motor_prop_signature + motor_bearing_signature, info severity, experimental flag) | 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
diff --git a/src/main/CLAUDE.md b/src/main/CLAUDE.md
index de121f06..ed2afed9 100644
--- a/src/main/CLAUDE.md
+++ b/src/main/CLAUDE.md
@@ -68,6 +68,8 @@ Entry point: `src/main/index.ts`. Manages MSPClient, ProfileManager, SnapshotMan
**Important**: MSP commands must execute before CLI mode (FC only processes CLI in CLI mode → MSP timeouts).
+**Version-capabilities layer (P2.5)**: `src/shared/utils/bfVersionCapabilities.ts` maps the firmware version (semver, or calendar 2025.12+ = BF 4.6) to feature availability (`hasTpaLow`/`hasRpmWeights`/`hasAntiGravityCutoff` 4.5+, `usesDMax`/`hasChirp` 4.6+). The apply flow translates renamed CLI settings via `translateSettingForVersion()` (d_min_gain→d_max_gain etc. on 4.6+) using the cached FCInfo version; `AppliedChange` records keep the canonical pre-rename name (MSP read-back verification is layout-based, unaffected by CLI names). Unknown versions get the conservative 4.3 baseline.
+
**Auto-Snapshot Strategy** (2 per tuning cycle):
- `Pre-tuning #N (Type)` — created by Start Tuning (rollback safety net)
- `Post-tuning #N (Type)` — created on reconnect after PID/Quick apply (final tuned result)
diff --git a/src/main/analysis/CLAUDE.md b/src/main/analysis/CLAUDE.md
index 93cad518..ba24c525 100644
--- a/src/main/analysis/CLAUDE.md
+++ b/src/main/analysis/CLAUDE.md
@@ -8,11 +8,13 @@ Noise analysis, step response, transfer function, and data quality scoring modul
- **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
+- **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. **Throttle-track reclassification** (`reclassifyPeaksWithThrottle()`): when the throttle spectrogram has ≥3 bands with spectra, each peak is re-located per band (±30% search window, ≥6 dB prominence) and Pearson-regressed against throttle — tracks-throttle (r ≥ 0.6, rel. range ≥ 15%) → `motor_harmonic`; stationary (rel. range ≤ 8%) → `frame_resonance`/`electrical`; ambiguous keeps the heuristic. Peaks carry `classifiedBy` + measured `throttleTrack`
- **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 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)
+- **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). **Latency budget (P2.7)**: optional `droneSize` param judges the chain against `FILTER_LATENCY_BUDGET_BY_SIZE` (5" 1.5/3.0 ms gyro/dterm, default 2.0/3.5) — result carries `gyroBudgetMs`/`dtermBudgetMs`/`gyroOverBudget`/`dtermOverBudget` and a size-aware warning; the LPF2 rules in FilterRecommender consume it (disable → high confidence when over budget, enable gated by prospective delay, F-LPF2-BUDGET-* informational fallback)
- **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)
+- **RpmFilterRecommender**: RPM filter tuning rules (active RPM filter only). `rpm_filter_min_hz` from the dynamic-idle floor (`F-RPM-MIN-IDLE`: target ≈ 0.9 × idleHz, clamp 40-150, 15 Hz deadzone) or from a measured fundamental track dipping below the floor (`F-RPM-MIN-TRACK`, lower-only); `rpm_filter_harmonics` raised when a measured track sits at an integer multiple of the fundamental above the current count (`F-RPM-HARM-UP`, suppresses F-MOTOR-DIAG); informational `F-RPM-FADE` (fade disabled → BF default 50) and `F-RPM-WEIGHTS` (BF 4.5+ full-depth weights → community per-size weights). Consumes `NoisePeak.throttleTrack` from the P2.2 reclassification
+- **Filter response models** (`src/shared/utils/filterResponse.ts`, shared so the renderer can draw overlays): |H(f)| in dB for PT1/PT2/PT3 (BF cutoff corrections 1.554/1.961 — cascade is −3 dB at the set cutoff), Butterworth biquad, and notch; dynamic-LPF cutoff via BF's `dynLpfCutoffFreq` throttle curve (`curve = t·(1−t)·expo/10 + t`). `computeFilterChainCurve()` combines a chain's active stages; `gyroLpf1CutoffAtThrottle()` feeds the spectrogram overlay. `FilterAnalysisResult.filterSettings` carries the (BBL-enriched) settings so SpectrumChart/ThrottleSpectrogramChart can render the configured filter response over measured noise
- **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
- IPC: `ANALYSIS_RUN_FILTER` + `EVENT_ANALYSIS_PROGRESS`
- Dependency: `fft.js`
@@ -34,10 +36,10 @@ Noise analysis, step response, transfer function, and data quality scoring modul
- **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). 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)
+- **MechanicalHealthChecker**: Pre-tuning diagnostics — extreme noise, axis asymmetry, motor imbalance. **Per-motor spectral fault signatures (P3.4, experimental)**: `checkMotorSpectralSignatures()` compares the four motor-command spectra relatively — a narrowband order peak on ONE motor ≥8 dB above the others at the same frequency (60-350 Hz) → `motor_prop_signature` (bent/unbalanced prop); one motor's broadband median ≥6 dB above the others in 200-500 Hz → `motor_bearing_signature`. Both info-severity + `experimental: true` — they never degrade overall status; thresholds to be promoted via telemetry. 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
+- **ThrottleTFAnalyzer**: Per-throttle-band transfer function (Wiener deconvolution) for TPA diagnostics (5 bands, roll + pitch — roll stays top-level for compatibility, pitch in `result.pitch`; the TPA warning reflects the worst axis). Uses the longest contiguous run per band (min 2048 samples) — TF cross-spectra require an unbroken time series. **TF-driven TPA emission (P2.8)**: `recommendTPAFromThrottleTF()` — measured low→high-band overshoot trend on the worst axis drives `tpa_rate` (grows ≥10 pp → TPA-TF-RATE-UP +10 capped 80; overdamped high bands → TPA-TF-RATE-DOWN −10 floor 30) and `tpa_breakpoint` (lowered to the measured oscillation onset, TPA-TF-BREAKPOINT, clamp 1250-1750). In PIDAnalyzer these measured recs override the static size-based P-TPA advisory per setting; propwash safety rules (PW-TPA-*) always win
- **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)
@@ -49,6 +51,18 @@ Noise analysis, step response, transfer function, and data quality scoring modul
- Used in Flash Tune mode for combined filter + PID analysis from a single flight
- IPC: `ANALYSIS_RUN_TRANSFER_FUNCTION` + `EVENT_ANALYSIS_PROGRESS`
+## Explainable Recommendations (P3.1)
+
+`RecommendationEvidence` (shared types) — optional `evidence` on FilterRecommendation and PIDRecommendation: `measurements` (label/value pairs), `trigger` (the fired condition), `anchorFrequencyHz` (spectrum-chart anchor). Populated by the noise-floor rules (measured per-axis floors + computed target + deadzone), resonance rules (peak freq/amplitude/type, anchored), F-DN-MIN, F-YAW-RES, RPM rules (idle floor / tracked fundamental / harmonic ratio, anchored), and TPA-TF rules (per-band overshoot trend). Renderer: RecommendationCard shows a "Why?" block; SpectrumChart tags anchored peaks with their ruleId; BodePlot plots per-bin coherence γ² with the 0.5 gate line.
+
+## Filter Placement Optimizer (P3.3)
+
+`FilterPlacementOptimizer.ts` — discrete search over (gyro LPF1 cutoff, LPF2 on/off, dyn notch count/Q) minimizing gyro-chain group delay at 80 Hz, subject to every significant roll/pitch peak (≥12 dB) being attenuated to ≤6 dB above the floor (`OPT_RESIDUAL_TARGET_DB`; PT1 magnitude models + a −20 dB effective depth per SDFT notch covering the strongest in-range peaks). LPF1=0 candidates require an active RPM filter; a lowpass-free chain is never allowed. `FilterAnalysisResult.filterPlacement` carries the result; an informational F-OPT-PLACEMENT advisory (with evidence) fires when the optimum saves ≥0.3 ms vs the current config. Advisory only — never auto-applied.
+
+## System Identification + What-If Simulation (P3.2)
+
+`SystemIdentifier.ts` — fits a 2nd-order + transport-delay plant model to the measured closed loop (Flash Tune): complex T(jω) from the Wiener bode → open loop L = T/(1−T) → plant P̂ = L/C with C(jω) the BF PID in firmware physical units (P·0.032029 + I·0.244381/jω + D·0.000529·jω). Coherence-weighted grid search + refinement over (ωn, ζ, τ) with analytic K; gates: mean coherence ≥ 0.5 over 2-60 Hz, fit quality (1−√relative residual) ≥ 0.6, ≥8 usable bins. `predictResponse()` re-closes the identified plant with any gains analytically (4096-bin grid → IFFT impulse → synthetic step + metrics). `computeWhatIf()` runs current + proposed gains; PIDAnalyzer attaches `PIDAnalysisResult.whatIf` (roll/pitch, proposed gains from `buildRecommendedPIDs`). QuickAnalysisStep renders "Predicted Response with Proposed Gains" (TFStepResponseChart comparison, labeled as simulation). Round-trip verified in tests against an analytic known plant.
+
## Data Quality Scoring (`DataQualityScorer.ts`)
Rates flight data quality 0-100 before generating recommendations. Integrated into both FilterAnalyzer and PIDAnalyzer.
diff --git a/src/main/analysis/FilterAnalyzer.ts b/src/main/analysis/FilterAnalyzer.ts
index 218e6d4f..b6fdcde4 100644
--- a/src/main/analysis/FilterAnalyzer.ts
+++ b/src/main/analysis/FilterAnalyzer.ts
@@ -19,7 +19,7 @@ import type {
import { DEFAULT_FILTER_SETTINGS } from '@shared/types/analysis.types';
import { findSteadySegments, findThrottleSweepSegments } from './SegmentSelector';
import { computePowerSpectrum, trimSpectrum } from './FFTCompute';
-import { analyzeAxisNoise, buildNoiseProfile } from './NoiseAnalyzer';
+import { analyzeAxisNoise, buildNoiseProfile, reclassifyPeaksWithThrottle } from './NoiseAnalyzer';
import {
recommend,
generateSummary,
@@ -34,6 +34,7 @@ import { estimateGroupDelay } from './GroupDelayEstimator';
import { analyzeWindDisturbance } from './WindDisturbanceDetector';
import { checkMechanicalHealth } from './MechanicalHealthChecker';
import { analyzeDynamicLowpass, recommendDynamicLowpass } from './DynamicLowpassRecommender';
+import { optimizeFilterPlacement, recommendFilterPlacement } from './FilterPlacementOptimizer';
import { FFT_WINDOW_SIZE, FREQUENCY_MIN_HZ, FREQUENCY_MAX_HZ } from './constants';
/** Maximum number of segments to use (more = slower but more accurate) */
@@ -151,9 +152,28 @@ export async function analyze(
throttleSpectrogram = computeThrottleSpectrogram(flightData);
}
+ // Step 3c: Throttle-track reclassification — a peak whose frequency rises
+ // with throttle is motor noise; a stationary peak is frame/electrical.
+ // Definitive where the whole-flight equal-spacing heuristic can only guess.
+ if (throttleSpectrogram && throttleSpectrogram.bandsWithData >= 3) {
+ const axisProfiles = [noiseProfile.roll, noiseProfile.pitch, noiseProfile.yaw] as const;
+ for (let axis = 0; axis < 3; axis++) {
+ axisProfiles[axis].peaks = reclassifyPeaksWithThrottle(
+ axisProfiles[axis].peaks,
+ throttleSpectrogram.bands,
+ axis as 0 | 1 | 2,
+ options?.droneSize
+ );
+ }
+ }
+
await yieldToEventLoop();
- // Step 4: Generate recommendations
+ // Step 4: Estimate group delay first — the LPF2 rules weigh it against the
+ // per-size latency budget
+ const groupDelay = estimateGroupDelay(currentSettings, undefined, options?.droneSize);
+
+ // Step 5: Generate recommendations
onProgress?.({ step: 'recommending', percent: 85 });
const rpmActive = isRpmFilterActive(currentSettings);
@@ -163,7 +183,8 @@ export async function analyze(
noiseProfile,
currentSettings,
options?.droneSize,
- confidenceContext
+ confidenceContext,
+ groupDelay
);
const recommendations = adjustFilterConfidenceByQuality(
rawRecommendations,
@@ -171,9 +192,6 @@ export async function analyze(
);
const summary = generateSummary(noiseProfile, recommendations, rpmActive);
- // Step 5: Estimate group delay
- const groupDelay = estimateGroupDelay(currentSettings);
-
// Step 6: Wind/disturbance detection
const windDisturbance = analyzeWindDisturbance(flightData);
@@ -199,10 +217,22 @@ export async function analyze(
// Step 9: Profile-aware advisory recommendations
appendProfileAdvisories(recommendations, currentSettings, options);
+ // Step 10: Filter placement optimizer (advisory) — the latency-optimal
+ // discrete config that still covers every measured peak
+ const filterPlacement = optimizeFilterPlacement(
+ noiseProfile,
+ currentSettings,
+ rpmActive,
+ options?.droneSize
+ );
+ const placementRec = recommendFilterPlacement(filterPlacement, options?.droneSize);
+ if (placementRec) recommendations.push(placementRec);
+
onProgress?.({ step: 'recommending', percent: 100 });
return {
noise: noiseProfile,
+ ...(filterPlacement ? { filterPlacement } : {}),
recommendations,
summary,
analysisTimeMs: Math.round(performance.now() - startTime),
@@ -216,6 +246,7 @@ export async function analyze(
windDisturbance,
mechanicalHealth,
dynamicLowpass,
+ filterSettings: currentSettings,
};
}
@@ -258,9 +289,29 @@ async function analyzeEntireFlight(
throttleSpectrogram = computeThrottleSpectrogram(flightData);
}
+ // Throttle-track reclassification (see main path)
+ if (throttleSpectrogram && throttleSpectrogram.bandsWithData >= 3) {
+ const axisProfiles = [noiseProfile.roll, noiseProfile.pitch, noiseProfile.yaw] as const;
+ for (let axis = 0; axis < 3; axis++) {
+ axisProfiles[axis].peaks = reclassifyPeaksWithThrottle(
+ axisProfiles[axis].peaks,
+ throttleSpectrogram.bands,
+ axis as 0 | 1 | 2,
+ options?.droneSize
+ );
+ }
+ }
+
onProgress?.({ step: 'recommending', percent: 85 });
const rpmActive = isRpmFilterActive(currentSettings);
- const rawRecommendations = recommend(noiseProfile, currentSettings, options?.droneSize);
+ const groupDelay = estimateGroupDelay(currentSettings, undefined, options?.droneSize);
+ const rawRecommendations = recommend(
+ noiseProfile,
+ currentSettings,
+ options?.droneSize,
+ undefined,
+ groupDelay
+ );
const recommendations = dataQuality
? adjustFilterConfidenceByQuality(rawRecommendations, dataQuality.tier)
: rawRecommendations;
@@ -268,8 +319,6 @@ async function analyzeEntireFlight(
onProgress?.({ step: 'recommending', percent: 100 });
- const groupDelay = estimateGroupDelay(currentSettings);
-
// Wind/disturbance detection
const windDisturbance = analyzeWindDisturbance(flightData);
@@ -308,6 +357,7 @@ async function analyzeEntireFlight(
windDisturbance,
mechanicalHealth,
dynamicLowpass,
+ filterSettings: currentSettings,
};
}
diff --git a/src/main/analysis/FilterPlacementOptimizer.test.ts b/src/main/analysis/FilterPlacementOptimizer.test.ts
new file mode 100644
index 00000000..dda7c9cb
--- /dev/null
+++ b/src/main/analysis/FilterPlacementOptimizer.test.ts
@@ -0,0 +1,112 @@
+import { describe, it, expect } from 'vitest';
+import {
+ optimizeFilterPlacement,
+ recommendFilterPlacement,
+ OPT_RESIDUAL_TARGET_DB,
+} from './FilterPlacementOptimizer';
+import type { NoiseProfile, NoisePeak, CurrentFilterSettings } from '@shared/types/analysis.types';
+import { DEFAULT_FILTER_SETTINGS } from '@shared/types/analysis.types';
+
+function makeProfile(peaks: NoisePeak[]): NoiseProfile {
+ const axis = (p: NoisePeak[]) => ({
+ spectrum: { frequencies: new Float64Array(0), magnitudes: new Float64Array(0) },
+ noiseFloorDb: -30,
+ peaks: p,
+ });
+ return { roll: axis(peaks), pitch: axis([]), yaw: axis([]), overallLevel: 'medium' };
+}
+
+function peak(frequency: number, amplitude: number): NoisePeak {
+ return { frequency, amplitude, type: 'frame_resonance' };
+}
+
+const settings = (over: Partial = {}): CurrentFilterSettings => ({
+ ...DEFAULT_FILTER_SETTINGS,
+ dyn_notch_min_hz: 100,
+ dyn_notch_max_hz: 600,
+ dyn_notch_count: 3,
+ dyn_notch_q: 300,
+ ...over,
+});
+
+describe('optimizeFilterPlacement (P3.3)', () => {
+ it('returns null when there are no significant peaks', () => {
+ const result = optimizeFilterPlacement(makeProfile([peak(160, 8)]), settings(), false);
+ expect(result).toBeNull();
+ });
+
+ it('covers a notch-range peak with notches instead of a deep lowpass', () => {
+ // Single 200 Hz peak at 20 dB — one dynamic notch (-20 dB) covers it
+ const result = optimizeFilterPlacement(makeProfile([peak(200, 20)]), settings(), false);
+ expect(result).not.toBeNull();
+ expect(result!.feasible).toBe(true);
+ expect(result!.best!.dyn_notch_count).toBeGreaterThanOrEqual(1);
+ // Latency-optimal LPF1 should stay high (the notch does the work)
+ expect(result!.best!.gyro_lpf1_static_hz).toBeGreaterThanOrEqual(400);
+ });
+
+ it('is infeasible when a strong peak sits below every option', () => {
+ // 45 Hz peak below the notch range and too strong for any candidate LPF
+ const result = optimizeFilterPlacement(makeProfile([peak(45, 25)]), settings(), false);
+ expect(result).not.toBeNull();
+ expect(result!.feasible).toBe(false);
+ expect(result!.best).toBeUndefined();
+ });
+
+ it('never disables LPF1 entirely without an RPM filter', () => {
+ const result = optimizeFilterPlacement(makeProfile([peak(500, 18)]), settings(), false);
+ expect(result!.feasible).toBe(true);
+ expect(result!.best!.gyro_lpf1_static_hz).toBeGreaterThan(0);
+ });
+
+ it('computes the delay delta against the current configuration', () => {
+ // Heavy current config (LPF1 100 + LPF2 250 + 3 notches) vs a light optimum
+ const result = optimizeFilterPlacement(
+ makeProfile([peak(300, 18)]),
+ settings({ gyro_lpf1_static_hz: 100, gyro_lpf2_static_hz: 250 }),
+ false
+ );
+ expect(result!.feasible).toBe(true);
+ expect(result!.deltaMs).toBeLessThan(0); // optimizer found a faster config
+ });
+
+ it('respects the residual target on every peak', () => {
+ const peaks = [peak(200, 24), peak(450, 16)];
+ const result = optimizeFilterPlacement(makeProfile(peaks), settings(), false);
+ expect(result!.feasible).toBe(true);
+ // Reconstruct residuals under the winning config: they must all pass
+ expect(result!.peaks).toHaveLength(2);
+ expect(OPT_RESIDUAL_TARGET_DB).toBe(6);
+ });
+});
+
+describe('recommendFilterPlacement (P3.3)', () => {
+ it('emits an informational advisory when the optimizer saves enough delay', () => {
+ const result = optimizeFilterPlacement(
+ makeProfile([peak(300, 18)]),
+ settings({ gyro_lpf1_static_hz: 100, gyro_lpf2_static_hz: 250 }),
+ false
+ );
+ const rec = recommendFilterPlacement(result, '5"');
+ expect(rec).toBeDefined();
+ expect(rec!.informational).toBe(true);
+ expect(rec!.ruleId).toBe('F-OPT-PLACEMENT');
+ expect(rec!.reason).toContain('ms less gyro');
+ expect(rec!.evidence?.measurements.some((m) => m.label === 'Current chain delay')).toBe(true);
+ });
+
+ it('stays silent when the current config is already near-optimal', () => {
+ // Current config = a light one the optimizer would pick anyway
+ const result = optimizeFilterPlacement(
+ makeProfile([peak(300, 18)]),
+ settings({ gyro_lpf1_static_hz: 500, gyro_lpf2_static_hz: 0, dyn_notch_count: 1 }),
+ false
+ );
+ expect(recommendFilterPlacement(result, '5"')).toBeUndefined();
+ });
+
+ it('stays silent when infeasible', () => {
+ const result = optimizeFilterPlacement(makeProfile([peak(45, 25)]), settings(), false);
+ expect(recommendFilterPlacement(result, '5"')).toBeUndefined();
+ });
+});
diff --git a/src/main/analysis/FilterPlacementOptimizer.ts b/src/main/analysis/FilterPlacementOptimizer.ts
new file mode 100644
index 00000000..a251b36a
--- /dev/null
+++ b/src/main/analysis/FilterPlacementOptimizer.ts
@@ -0,0 +1,257 @@
+/**
+ * Filter placement optimizer (P3.3).
+ *
+ * Discrete search over the gyro filter configuration space (LPF1 cutoff,
+ * LPF2 on/off, dynamic notch count/Q) minimizing group delay subject to an
+ * attenuation constraint on every measured noise peak: each significant peak
+ * must be knocked down to the residual target using the magnitude models from
+ * filterResponse.ts plus an effective per-notch depth for the SDFT dynamic
+ * notch.
+ *
+ * The result is advisory — it shows the pilot the latency-optimal way to
+ * cover the peaks their quad actually produces, next to what their current
+ * config costs. It never auto-applies.
+ */
+import type {
+ NoiseProfile,
+ NoisePeak,
+ CurrentFilterSettings,
+ FilterRecommendation,
+} from '@shared/types/analysis.types';
+import type { DroneSize } from '@shared/types/profile.types';
+import { lowpassMagnitudeDb } from '@shared/utils/filterResponse';
+import { pt1GroupDelay, notchGroupDelay, resolveLatencyBudget } from './GroupDelayEstimator';
+import { RESONANCE_ACTION_THRESHOLD_DB } from './constants';
+
+/** Residual noise target: peaks must be attenuated to ≤ this above the floor */
+export const OPT_RESIDUAL_TARGET_DB = 6;
+/** Effective attenuation of one SDFT dynamic notch at its tracked peak */
+export const OPT_NOTCH_EFFECTIVE_DB = -20;
+/** Reference frequency for the group-delay objective */
+export const OPT_REFERENCE_HZ = 80;
+/** Minimum delay improvement (ms) worth surfacing to the pilot */
+export const OPT_MIN_IMPROVEMENT_MS = 0.3;
+
+/** Candidate gyro LPF1 cutoffs (0 = disabled, only allowed with RPM filter) */
+const LPF1_CANDIDATES = [0, 100, 150, 200, 250, 300, 400, 500];
+/** Candidate gyro LPF2 cutoffs */
+const LPF2_CANDIDATES = [0, 250, 500];
+/** Candidate dynamic notch counts */
+const NOTCH_COUNT_CANDIDATES = [0, 1, 2, 3];
+/** Candidate dynamic notch Q values */
+const NOTCH_Q_CANDIDATES = [300, 500];
+
+/** One evaluated filter configuration */
+export interface FilterPlacementCandidate {
+ gyro_lpf1_static_hz: number;
+ gyro_lpf2_static_hz: number;
+ dyn_notch_count: number;
+ dyn_notch_q: number;
+ /** Total gyro-chain group delay at the reference frequency (ms) */
+ delayMs: number;
+}
+
+/** Optimizer output */
+export interface FilterPlacementResult {
+ /** Whether any candidate covered every peak */
+ feasible: boolean;
+ /** The latency-optimal feasible configuration */
+ best?: FilterPlacementCandidate;
+ /** Group delay of the CURRENT configuration (ms, same model) */
+ currentDelayMs: number;
+ /** best.delayMs − currentDelayMs (negative = the optimizer found a faster config) */
+ deltaMs?: number;
+ /** The peaks the optimization constrained on */
+ peaks: { frequencyHz: number; amplitudeDb: number }[];
+}
+
+/** Collect the unique significant roll/pitch peaks the optimizer must cover */
+function collectPeaks(noise: NoiseProfile): NoisePeak[] {
+ const peaks: NoisePeak[] = [];
+ for (const axis of [noise.roll, noise.pitch]) {
+ for (const peak of axis.peaks) {
+ if (peak.amplitude < RESONANCE_ACTION_THRESHOLD_DB) continue;
+ const dup = peaks.find((p) => Math.abs(p.frequency - peak.frequency) < 10);
+ if (dup) {
+ if (peak.amplitude > dup.amplitude) {
+ peaks[peaks.indexOf(dup)] = peak;
+ }
+ continue;
+ }
+ peaks.push(peak);
+ }
+ }
+ // Strongest first — dynamic notches track the strongest peaks
+ return peaks.sort((a, b) => b.amplitude - a.amplitude);
+}
+
+/** Gyro-chain group delay of a candidate at the reference frequency (ms) */
+function candidateDelayMs(
+ lpf1Hz: number,
+ lpf2Hz: number,
+ notchCount: number,
+ notchQ: number,
+ notchCenterHz: number
+): number {
+ let delayS = 0;
+ if (lpf1Hz > 0) delayS += pt1GroupDelay(lpf1Hz, OPT_REFERENCE_HZ);
+ if (lpf2Hz > 0) delayS += pt1GroupDelay(lpf2Hz, OPT_REFERENCE_HZ);
+ if (notchCount > 0) {
+ const actualQ = notchQ > 10 ? notchQ / 100 : notchQ;
+ delayS += notchGroupDelay(notchCenterHz, OPT_REFERENCE_HZ, actualQ) * notchCount;
+ }
+ return delayS * 1000;
+}
+
+/** Residual level of one peak (dB above floor) under a candidate config.
+ * The dynamic notch covers the `notchCount` strongest peaks inside its range. */
+function residualDb(
+ peak: NoisePeak,
+ peakRank: number,
+ lpf1Hz: number,
+ lpf2Hz: number,
+ notchCount: number,
+ notchMinHz: number,
+ notchMaxHz: number
+): number {
+ let attenuation = 0;
+ attenuation += lowpassMagnitudeDb(peak.frequency, lpf1Hz, undefined); // PT1 (BF default)
+ attenuation += lowpassMagnitudeDb(peak.frequency, lpf2Hz, undefined);
+ const notchCovers =
+ peakRank < notchCount && peak.frequency >= notchMinHz && peak.frequency <= notchMaxHz;
+ if (notchCovers) attenuation += OPT_NOTCH_EFFECTIVE_DB;
+ return peak.amplitude + attenuation;
+}
+
+/**
+ * Search the discrete configuration space for the minimum-delay config that
+ * attenuates every significant peak to the residual target.
+ */
+export function optimizeFilterPlacement(
+ noise: NoiseProfile,
+ current: CurrentFilterSettings,
+ rpmActive: boolean,
+ droneSize?: DroneSize
+): FilterPlacementResult | null {
+ const peaks = collectPeaks(noise);
+ if (peaks.length === 0) return null; // nothing to optimize against
+
+ const notchMinHz = current.dyn_notch_min_hz > 0 ? current.dyn_notch_min_hz : 100;
+ const notchMaxHz = current.dyn_notch_max_hz > 0 ? current.dyn_notch_max_hz : 600;
+ const notchCenter = (notchMinHz + notchMaxHz) / 2;
+
+ // Current config's delay under the same model (fair comparison)
+ const currentDelayMs = candidateDelayMs(
+ (current.gyro_lpf1_dyn_min_hz ?? 0) > 0
+ ? current.gyro_lpf1_dyn_min_hz!
+ : current.gyro_lpf1_static_hz,
+ current.gyro_lpf2_static_hz,
+ current.dyn_notch_count ?? 3,
+ current.dyn_notch_q ?? 300,
+ notchCenter
+ );
+
+ let best: FilterPlacementCandidate | undefined;
+
+ for (const lpf1 of LPF1_CANDIDATES) {
+ if (lpf1 === 0 && !rpmActive) continue; // disabling LPF1 requires RPM coverage
+ for (const lpf2 of LPF2_CANDIDATES) {
+ if (lpf1 === 0 && lpf2 === 0) continue; // no lowpass at all is never safe
+ for (const notchCount of NOTCH_COUNT_CANDIDATES) {
+ for (const notchQ of NOTCH_Q_CANDIDATES) {
+ // Feasibility: every peak at or below the residual target
+ let feasible = true;
+ for (let rank = 0; rank < peaks.length; rank++) {
+ const res = residualDb(
+ peaks[rank],
+ rank,
+ lpf1,
+ lpf2,
+ notchCount,
+ notchMinHz,
+ notchMaxHz
+ );
+ if (res > OPT_RESIDUAL_TARGET_DB) {
+ feasible = false;
+ break;
+ }
+ }
+ if (!feasible) continue;
+
+ const delayMs = candidateDelayMs(lpf1, lpf2, notchCount, notchQ, notchCenter);
+ if (
+ !best ||
+ delayMs < best.delayMs - 1e-9 ||
+ // Tie-break: prefer the higher LPF1 cutoff (less phase lag off-reference)
+ (Math.abs(delayMs - best.delayMs) < 1e-9 && lpf1 > best.gyro_lpf1_static_hz)
+ ) {
+ best = {
+ gyro_lpf1_static_hz: lpf1,
+ gyro_lpf2_static_hz: lpf2,
+ dyn_notch_count: notchCount,
+ dyn_notch_q: notchQ,
+ delayMs: Math.round(delayMs * 100) / 100,
+ };
+ }
+ }
+ }
+ }
+ }
+
+ return {
+ feasible: best !== undefined,
+ ...(best ? { best } : {}),
+ currentDelayMs: Math.round(currentDelayMs * 100) / 100,
+ ...(best ? { deltaMs: Math.round((best.delayMs - currentDelayMs) * 100) / 100 } : {}),
+ peaks: peaks.map((p) => ({
+ frequencyHz: Math.round(p.frequency),
+ amplitudeDb: Math.round(p.amplitude * 10) / 10,
+ })),
+ };
+}
+
+/**
+ * Turn an optimizer result into an informational recommendation when it found
+ * a configuration meaningfully faster than the current one. Advisory only —
+ * the discrete model ignores nuances (dyn LPF tracking, D-term path) that the
+ * pilot should weigh.
+ */
+export function recommendFilterPlacement(
+ result: FilterPlacementResult | null,
+ droneSize?: DroneSize
+): FilterRecommendation | undefined {
+ if (!result || !result.feasible || !result.best || result.deltaMs === undefined) return undefined;
+ if (result.deltaMs > -OPT_MIN_IMPROVEMENT_MS) return undefined;
+
+ const b = result.best;
+ const budget = resolveLatencyBudget(droneSize);
+ return {
+ setting: 'gyro_lpf1_static_hz',
+ currentValue: 0,
+ recommendedValue: 0,
+ reason:
+ `Filter placement optimizer: your measured noise peaks (${result.peaks
+ .map((p) => `${p.frequencyHz} Hz`)
+ .join(', ')}) can be covered with ${Math.abs(result.deltaMs).toFixed(1)} ms less gyro ` +
+ `filter delay: gyro LPF1 ${b.gyro_lpf1_static_hz === 0 ? 'off' : `${b.gyro_lpf1_static_hz} Hz`}, ` +
+ `LPF2 ${b.gyro_lpf2_static_hz === 0 ? 'off' : `${b.gyro_lpf2_static_hz} Hz`}, ` +
+ `${b.dyn_notch_count} dynamic notch${b.dyn_notch_count === 1 ? '' : 'es'} (Q ${b.dyn_notch_q}). ` +
+ `Estimated chain delay ${b.delayMs.toFixed(1)} ms vs ${result.currentDelayMs.toFixed(1)} ms now ` +
+ `(budget ${budget.gyroMs.toFixed(1)} ms).`,
+ impact: 'latency',
+ confidence: 'low',
+ informational: true,
+ ruleId: 'F-OPT-PLACEMENT',
+ evidence: {
+ measurements: [
+ { label: 'Current chain delay', value: `${result.currentDelayMs.toFixed(1)} ms` },
+ { label: 'Optimized chain delay', value: `${b.delayMs.toFixed(1)} ms` },
+ ...result.peaks.map((p) => ({
+ label: `Peak ${p.frequencyHz} Hz`,
+ value: `${p.amplitudeDb} dB above floor`,
+ })),
+ ],
+ trigger: `All peaks attenuable to ≤ ${OPT_RESIDUAL_TARGET_DB} dB with ≥ ${OPT_MIN_IMPROVEMENT_MS} ms less delay`,
+ },
+ };
+}
diff --git a/src/main/analysis/FilterRecommender.test.ts b/src/main/analysis/FilterRecommender.test.ts
index ec33ffe0..5aee513d 100644
--- a/src/main/analysis/FilterRecommender.test.ts
+++ b/src/main/analysis/FilterRecommender.test.ts
@@ -1676,3 +1676,128 @@ describe('deduplication vs informational observations', () => {
expect(observation!.confidence).toBe('low');
});
});
+
+describe('LPF2 latency budget (P2.7)', () => {
+ const cleanNoise = () => makeNoiseProfile({ level: 'low', rollFloor: -50, pitchFloor: -50 });
+ const highNoise = () => makeNoiseProfile({ level: 'high', rollFloor: -15, pitchFloor: -10 });
+
+ function delay(overrides: Partial) {
+ return {
+ filters: [],
+ gyroTotalMs: 1.0,
+ dtermTotalMs: 2.0,
+ referenceFreqHz: 80,
+ gyroBudgetMs: 1.5,
+ dtermBudgetMs: 3.0,
+ gyroOverBudget: false,
+ dtermOverBudget: false,
+ ...overrides,
+ };
+ }
+
+ it('upgrades the gyro LPF2 disable to high confidence when over budget', () => {
+ const current: CurrentFilterSettings = {
+ ...DEFAULT_FILTER_SETTINGS,
+ gyro_lpf2_static_hz: 250,
+ rpm_filter_harmonics: 3,
+ };
+ const over = recommend(cleanNoise(), current, '5"', undefined, delay({ gyroOverBudget: true }));
+ expect(over.find((r) => r.ruleId === 'F-LPF2-DIS-GYRO')!.confidence).toBe('high');
+
+ const within = recommend(cleanNoise(), current, '5"', undefined, delay({}));
+ expect(within.find((r) => r.ruleId === 'F-LPF2-DIS-GYRO')!.confidence).toBe('medium');
+ });
+
+ it('includes the measured latency vs budget in the disable reason', () => {
+ const current: CurrentFilterSettings = {
+ ...DEFAULT_FILTER_SETTINGS,
+ gyro_lpf2_static_hz: 250,
+ rpm_filter_harmonics: 3,
+ };
+ const recs = recommend(cleanNoise(), current, '5"', undefined, delay({ gyroTotalMs: 1.8 }));
+ const rec = recs.find((r) => r.ruleId === 'F-LPF2-DIS-GYRO')!;
+ expect(rec.reason).toContain('1.8 ms');
+ expect(rec.reason).toContain('budget 1.5 ms');
+ });
+
+ it('replaces the gyro LPF2 enable with an informational advisory when it would blow the budget', () => {
+ const current: CurrentFilterSettings = {
+ ...DEFAULT_FILTER_SETTINGS,
+ gyro_lpf2_static_hz: 0,
+ rpm_filter_harmonics: 0,
+ };
+ // Chain already at 1.4 ms; adding LPF2@250 (~0.6 ms at 80 Hz) exceeds the 1.5 ms 5" budget
+ const recs = recommend(highNoise(), current, '5"', undefined, delay({ gyroTotalMs: 1.4 }));
+ expect(recs.find((r) => r.ruleId === 'F-LPF2-EN-GYRO')).toBeUndefined();
+ const advisory = recs.find((r) => r.ruleId === 'F-LPF2-BUDGET-GYRO');
+ expect(advisory).toBeDefined();
+ expect(advisory!.informational).toBe(true);
+ expect(advisory!.recommendedValue).toBe(0);
+ });
+
+ it('still enables gyro LPF2 when the added delay fits the budget', () => {
+ const current: CurrentFilterSettings = {
+ ...DEFAULT_FILTER_SETTINGS,
+ gyro_lpf2_static_hz: 0,
+ rpm_filter_harmonics: 0,
+ };
+ // Chain at 0.5 ms; +0.6 ms stays under the 1.5 ms budget
+ const recs = recommend(highNoise(), current, '5"', undefined, delay({ gyroTotalMs: 0.5 }));
+ const rec = recs.find((r) => r.ruleId === 'F-LPF2-EN-GYRO');
+ expect(rec).toBeDefined();
+ expect(rec!.recommendedValue).toBe(250);
+ expect(rec!.reason).toContain('budget 1.5 ms');
+ });
+
+ it('gates the D-term LPF2 enable against the D-term budget', () => {
+ const current: CurrentFilterSettings = {
+ ...DEFAULT_FILTER_SETTINGS,
+ dterm_lpf2_static_hz: 0,
+ rpm_filter_harmonics: 0,
+ };
+ // D-term chain at 2.5 ms; +0.83 ms (150 Hz at 80 Hz ref) exceeds the 3.0 ms budget
+ const over = recommend(highNoise(), current, '5"', undefined, delay({ dtermTotalMs: 2.5 }));
+ expect(over.find((r) => r.ruleId === 'F-LPF2-EN-DTERM')).toBeUndefined();
+ expect(over.find((r) => r.ruleId === 'F-LPF2-BUDGET-DTERM')).toBeDefined();
+
+ const within = recommend(highNoise(), current, '5"', undefined, delay({ dtermTotalMs: 1.0 }));
+ expect(within.find((r) => r.ruleId === 'F-LPF2-EN-DTERM')).toBeDefined();
+ });
+
+ it('keeps legacy behavior when no group delay is provided', () => {
+ const current: CurrentFilterSettings = {
+ ...DEFAULT_FILTER_SETTINGS,
+ gyro_lpf2_static_hz: 0,
+ rpm_filter_harmonics: 0,
+ };
+ const recs = recommend(highNoise(), current, '5"');
+ const rec = recs.find((r) => r.ruleId === 'F-LPF2-EN-GYRO');
+ expect(rec).toBeDefined();
+ expect(rec!.reason).not.toContain('budget');
+ });
+});
+
+describe('recommendation evidence (P3.1)', () => {
+ it('attaches peak evidence with a chart anchor to resonance recommendations', () => {
+ const noise = makeNoiseProfile({
+ level: 'medium',
+ rollPeaks: [{ frequency: 160, amplitude: 25, type: 'frame_resonance' }],
+ });
+ const recs = recommend(noise, { ...DEFAULT_FILTER_SETTINGS, dyn_notch_count: 0 });
+ const rec = recs.find((r) => r.ruleId === 'F-RES-GYRO');
+ expect(rec).toBeDefined();
+ expect(rec!.evidence).toBeDefined();
+ expect(rec!.evidence!.anchorFrequencyHz).toBe(160);
+ expect(rec!.evidence!.measurements.some((m) => m.value.includes('160 Hz'))).toBe(true);
+ });
+
+ it('attaches measured noise floors to noise-floor recommendations', () => {
+ const noise = makeNoiseProfile({ level: 'high', rollFloor: -6, pitchFloor: -8 });
+ const recs = recommend(noise, DEFAULT_FILTER_SETTINGS);
+ const rec = recs.find((r) => r.ruleId === 'F-NF-H-GYRO');
+ expect(rec).toBeDefined();
+ expect(rec!.evidence).toBeDefined();
+ expect(rec!.evidence!.measurements.some((m) => m.label === 'Roll noise floor')).toBe(true);
+ expect(rec!.evidence!.trigger).toContain('deadzone');
+ });
+});
diff --git a/src/main/analysis/FilterRecommender.ts b/src/main/analysis/FilterRecommender.ts
index b27bec2b..326f7149 100644
--- a/src/main/analysis/FilterRecommender.ts
+++ b/src/main/analysis/FilterRecommender.ts
@@ -9,6 +9,8 @@ import type {
FilterRecommendation,
CurrentFilterSettings,
NoisePeak,
+ FilterGroupDelay,
+ RecommendationEvidence,
} from '@shared/types/analysis.types';
import { DEFAULT_FILTER_SETTINGS } from '@shared/types/analysis.types';
import type { DroneSize, FlightStyle } from '@shared/types/profile.types';
@@ -41,6 +43,12 @@ import {
MAX_VARIABILITY_BONUS_HZ,
VARIABILITY_TO_HZ_SCALE,
} from './constants';
+import { recommendRpmFilterTuning } from './RpmFilterRecommender';
+import {
+ pt1GroupDelay,
+ resolveLatencyBudget,
+ GROUP_DELAY_REFERENCE_HZ,
+} from './GroupDelayEstimator';
/** Optional noise variability context for hysteresis-aware deadzone */
export interface ConfidenceContext {
@@ -74,7 +82,8 @@ export function recommend(
noise: NoiseProfile,
current: CurrentFilterSettings = DEFAULT_FILTER_SETTINGS,
droneSize?: DroneSize,
- confidenceContext?: ConfidenceContext
+ confidenceContext?: ConfidenceContext,
+ groupDelay?: FilterGroupDelay
): FilterRecommendation[] {
const recommendations: FilterRecommendation[] = [];
const rpmActive = isRpmFilterActive(current);
@@ -93,15 +102,24 @@ export function recommend(
recommendDynamicNotchForRPM(noise, current, recommendations, droneSize);
}
- // 5. Motor harmonic diagnostic (when RPM active but motor harmonics still detected)
+ // 5. RPM filter tuning rules (min_hz / harmonics / fade / weights) from
+ // measured harmonic tracks and the dynamic-idle floor
if (rpmActive) {
+ recommendations.push(...recommendRpmFilterTuning(noise, current, droneSize));
+ }
+
+ // 6. Motor harmonic diagnostic (when RPM active but motor harmonics still detected).
+ // Skipped when a harmonic-count increase was recommended — the residual harmonic
+ // is explained by the missing notch order, not by motor_poles/telemetry issues.
+ if (rpmActive && !recommendations.some((r) => r.ruleId === 'F-RPM-HARM-UP')) {
recommendMotorHarmonicDiagnostic(noise, recommendations);
}
- // 6. LPF2 recommendations (disable when clean + RPM, enable when noisy)
- recommendLpf2Adjustments(noise, current, recommendations, rpmActive);
+ // 7. LPF2 recommendations (disable when clean + RPM, enable when noisy),
+ // weighed against the per-size filter latency budget when delay data exists
+ recommendLpf2Adjustments(noise, current, recommendations, rpmActive, groupDelay, droneSize);
- // 7. Yaw-only resonance observation (informational — yaw never drives LPF cutoffs)
+ // 8. 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
@@ -149,6 +167,15 @@ function recommendYawResonanceObservation(
'inspect hardware, or extend the dynamic notch range to cover it.',
impact: 'noise',
confidence: 'low',
+ evidence: {
+ measurements: [
+ { label: 'Yaw peak frequency', value: `${Math.round(strongest.frequency)} Hz` },
+ { label: 'Amplitude', value: `${Math.round(strongest.amplitude)} dB above floor` },
+ ],
+ trigger:
+ 'Yaw-only peak ≥ 12 dB with no roll/pitch counterpart within 15 Hz, outside the dynamic notch range',
+ anchorFrequencyHz: strongest.frequency,
+ },
informational: true,
ruleId: 'F-YAW-RES',
});
@@ -236,6 +263,19 @@ function recommendNoiseFloorAdjustments(
? (current.dterm_lpf1_dyn_min_hz ?? current.dterm_lpf1_static_hz)
: current.dterm_lpf1_static_hz;
+ // Structured evidence shared by the noise-floor rules (P3.1)
+ const noiseFloorEvidence = (target: number): RecommendationEvidence => ({
+ measurements: [
+ { label: 'Roll noise floor', value: `${noise.roll.noiseFloorDb.toFixed(1)} dB` },
+ { label: 'Pitch noise floor', value: `${noise.pitch.noiseFloorDb.toFixed(1)} dB` },
+ { label: 'Computed target cutoff', value: `${target} Hz` },
+ ],
+ trigger:
+ `Worst roll/pitch noise floor ${worstFloor.toFixed(1)} dB maps to a ${target} Hz target ` +
+ `(scale: ${NOISE_FLOOR_VERY_NOISY_DB} dB → tightest, ${NOISE_FLOOR_VERY_CLEAN_DB} dB → most open); ` +
+ `current cutoff differs by more than the ${Math.round(baseDeadzone)} Hz deadzone`,
+ });
+
// Helper: push gyro LPF1 recommendation (static or dynamic mode)
const pushGyroRec = (
target: number,
@@ -244,6 +284,7 @@ function recommendNoiseFloorAdjustments(
confidence: FilterRecommendation['confidence'],
ruleId: string
) => {
+ const evidence = noiseFloorEvidence(target);
if (gyroLpfDisabled) return;
if (gyroDynActive) {
// Dynamic mode: tune dyn_min_hz, use BF 2:1 ratio for dyn_max_hz
@@ -265,6 +306,7 @@ function recommendNoiseFloorAdjustments(
impact,
confidence,
ruleId,
+ evidence,
});
out.push({
setting: 'gyro_lpf1_dyn_max_hz',
@@ -299,6 +341,7 @@ function recommendNoiseFloorAdjustments(
impact,
confidence,
ruleId,
+ evidence,
});
}
}
@@ -312,6 +355,7 @@ function recommendNoiseFloorAdjustments(
confidence: FilterRecommendation['confidence'],
ruleId: string
) => {
+ const evidence = noiseFloorEvidence(target);
if (dtermDynActive) {
const currentMin = current.dterm_lpf1_dyn_min_hz!;
const currentMax = current.dterm_lpf1_dyn_max_hz ?? currentMin * DYNAMIC_LOWPASS_RATIO;
@@ -331,6 +375,7 @@ function recommendNoiseFloorAdjustments(
impact,
confidence,
ruleId,
+ evidence,
});
out.push({
setting: 'dterm_lpf1_dyn_max_hz',
@@ -363,6 +408,7 @@ function recommendNoiseFloorAdjustments(
impact,
confidence,
ruleId,
+ evidence,
});
}
}
@@ -540,6 +586,7 @@ function recommendResonanceFixes(
: `A strong ${typeLabel} was detected at ${Math.round(lowestPeakFreq)} Hz, which is below your current ` +
`gyro filter cutoff of ${effectiveGyroCutoff} Hz. Lowering the filter will block this vibration.`;
+ const peakForEvidence = peaksNeedingLpf.find((p) => p.frequency === lowestPeakFreq);
out.push({
setting: settingName,
currentValue: effectiveGyroCutoff,
@@ -548,6 +595,20 @@ function recommendResonanceFixes(
impact: 'both',
confidence: 'high',
ruleId: 'F-RES-GYRO',
+ evidence: {
+ measurements: [
+ { label: 'Peak frequency', value: `${Math.round(lowestPeakFreq)} Hz` },
+ {
+ label: 'Peak amplitude',
+ value: `${Math.round(peakForEvidence?.amplitude ?? 0)} dB above floor`,
+ },
+ { label: 'Classified as', value: typeLabel },
+ ],
+ trigger:
+ `Peak ≥ ${RESONANCE_ACTION_THRESHOLD_DB} dB outside the dynamic notch range and below ` +
+ `the effective gyro cutoff — target = peak − ${RESONANCE_CUTOFF_MARGIN_HZ} Hz margin`,
+ anchorFrequencyHz: lowestPeakFreq,
+ },
});
}
}
@@ -619,6 +680,11 @@ function recommendDynamicNotchAdjustments(
impact: 'noise',
confidence: 'medium',
ruleId: 'F-DN-MIN',
+ evidence: {
+ measurements: [{ label: 'Lowest uncovered peak', value: `${Math.round(lowestPeak)} Hz` }],
+ trigger: `Peak below dyn_notch_min_hz (${current.dyn_notch_min_hz} Hz) — notch cannot track it`,
+ anchorFrequencyHz: lowestPeak,
+ },
});
}
}
@@ -859,76 +925,133 @@ function recommendMotorHarmonicDiagnostic(noise: NoiseProfile, out: FilterRecomm
/**
* Rule 6: Recommend LPF2 adjustments:
- * - With RPM filter + clean noise: disable LPF2 for less latency
- * - Without RPM + high noise + LPF2 disabled: warn to enable
+ * - With RPM filter + clean noise: disable LPF2 for less latency. When the
+ * measured chain delay is over the per-size latency budget, the disable
+ * is upgraded to high confidence (P2.7).
+ * - Without RPM + high noise + LPF2 disabled: recommend enabling — unless
+ * the added delay would blow the latency budget, in which case an
+ * informational "fix noise at the source" advisory is emitted instead.
*/
function recommendLpf2Adjustments(
noise: NoiseProfile,
current: CurrentFilterSettings,
out: FilterRecommendation[],
- rpmActive: boolean
+ rpmActive: boolean,
+ groupDelay?: FilterGroupDelay,
+ droneSize?: DroneSize
): void {
const worstFloor = Math.max(noise.roll.noiseFloorDb, noise.pitch.noiseFloorDb);
+ const budget = resolveLatencyBudget(droneSize);
+ const latencyNote = (totalMs: number | undefined, budgetMs: number): string =>
+ totalMs !== undefined
+ ? ` Filter latency: ${totalMs.toFixed(1)} ms (budget ${budgetMs.toFixed(1)} ms).`
+ : '';
// Clean signal + RPM active → disable LPF2 for less phase delay
if (rpmActive && worstFloor < GYRO_LPF2_DISABLE_THRESHOLD_DB) {
if (current.gyro_lpf2_static_hz > 0) {
+ const overBudget = groupDelay?.gyroOverBudget === true;
out.push({
setting: 'gyro_lpf2_static_hz',
currentValue: current.gyro_lpf2_static_hz,
recommendedValue: 0,
reason:
'With RPM filter active and very clean gyro data, the second gyro lowpass filter can be ' +
- 'disabled to reduce phase delay and improve response.',
+ 'disabled to reduce phase delay and improve response.' +
+ latencyNote(groupDelay?.gyroTotalMs, budget.gyroMs),
impact: 'latency',
- confidence: 'medium',
+ confidence: overBudget ? 'high' : 'medium',
ruleId: 'F-LPF2-DIS-GYRO',
});
}
}
if (rpmActive && worstFloor < DTERM_LPF2_DISABLE_THRESHOLD_DB) {
if (current.dterm_lpf2_static_hz > 0) {
+ const overBudget = groupDelay?.dtermOverBudget === true;
out.push({
setting: 'dterm_lpf2_static_hz',
currentValue: current.dterm_lpf2_static_hz,
recommendedValue: 0,
reason:
'With RPM filter active and low noise, the second D-term lowpass filter can be ' +
- 'disabled to reduce latency and improve stick feel.',
+ 'disabled to reduce latency and improve stick feel.' +
+ latencyNote(groupDelay?.dtermTotalMs, budget.dtermMs),
impact: 'latency',
- confidence: 'medium',
+ confidence: overBudget ? 'high' : 'medium',
ruleId: 'F-LPF2-DIS-DTERM',
});
}
}
- // High noise + no RPM + LPF2 disabled → recommend enabling
+ // High noise + no RPM + LPF2 disabled → recommend enabling, if the added
+ // delay fits the latency budget; otherwise point at the noise source instead
if (!rpmActive && noise.overallLevel === 'high') {
if (current.gyro_lpf2_static_hz === 0) {
- out.push({
- setting: 'gyro_lpf2_static_hz',
- currentValue: 0,
- recommendedValue: 250,
- reason:
- 'High noise detected without RPM filter. Enabling the second gyro lowpass filter ' +
- 'provides additional noise rejection that helps with motor temperatures.',
- impact: 'noise',
- confidence: 'low',
- ruleId: 'F-LPF2-EN-GYRO',
- });
+ const addedMs = pt1GroupDelay(250, GROUP_DELAY_REFERENCE_HZ) * 1000;
+ const prospectiveMs = groupDelay !== undefined ? groupDelay.gyroTotalMs + addedMs : undefined;
+ if (prospectiveMs !== undefined && prospectiveMs > budget.gyroMs) {
+ out.push({
+ setting: 'gyro_lpf2_static_hz',
+ currentValue: 0,
+ recommendedValue: 0,
+ reason:
+ 'High noise detected without RPM filter, but enabling a second gyro lowpass would push ' +
+ `filter latency to ${prospectiveMs.toFixed(1)} ms — over the ${budget.gyroMs.toFixed(1)} ms ` +
+ 'budget for this quad size. Fix the noise at its source instead: check props/bearings, ' +
+ 'consider RPM filtering (bidirectional DSHOT), or soft-mount the flight controller.',
+ impact: 'noise',
+ confidence: 'low',
+ informational: true,
+ ruleId: 'F-LPF2-BUDGET-GYRO',
+ });
+ } else {
+ out.push({
+ setting: 'gyro_lpf2_static_hz',
+ currentValue: 0,
+ recommendedValue: 250,
+ reason:
+ 'High noise detected without RPM filter. Enabling the second gyro lowpass filter ' +
+ 'provides additional noise rejection that helps with motor temperatures.' +
+ latencyNote(prospectiveMs, budget.gyroMs),
+ impact: 'noise',
+ confidence: 'low',
+ ruleId: 'F-LPF2-EN-GYRO',
+ });
+ }
}
if (current.dterm_lpf2_static_hz === 0) {
- out.push({
- setting: 'dterm_lpf2_static_hz',
- currentValue: 0,
- recommendedValue: 150,
- reason:
- 'High noise detected without RPM filter. Enabling the second D-term lowpass filter ' +
- 'helps reduce motor heating from noisy D-term output.',
- impact: 'noise',
- confidence: 'low',
- ruleId: 'F-LPF2-EN-DTERM',
- });
+ const addedMs = pt1GroupDelay(150, GROUP_DELAY_REFERENCE_HZ) * 1000;
+ const prospectiveMs =
+ groupDelay !== undefined ? groupDelay.dtermTotalMs + addedMs : undefined;
+ if (prospectiveMs !== undefined && prospectiveMs > budget.dtermMs) {
+ out.push({
+ setting: 'dterm_lpf2_static_hz',
+ currentValue: 0,
+ recommendedValue: 0,
+ reason:
+ 'High noise detected without RPM filter, but enabling a second D-term lowpass would push ' +
+ `D-term filter latency to ${prospectiveMs.toFixed(1)} ms — over the ${budget.dtermMs.toFixed(1)} ms ` +
+ 'budget for this quad size. Fix the noise at its source instead: check props/bearings, ' +
+ 'consider RPM filtering (bidirectional DSHOT), or soft-mount the flight controller.',
+ impact: 'noise',
+ confidence: 'low',
+ informational: true,
+ ruleId: 'F-LPF2-BUDGET-DTERM',
+ });
+ } else {
+ out.push({
+ setting: 'dterm_lpf2_static_hz',
+ currentValue: 0,
+ recommendedValue: 150,
+ reason:
+ 'High noise detected without RPM filter. Enabling the second D-term lowpass filter ' +
+ 'helps reduce motor heating from noisy D-term output.' +
+ latencyNote(prospectiveMs, budget.dtermMs),
+ impact: 'noise',
+ confidence: 'low',
+ ruleId: 'F-LPF2-EN-DTERM',
+ });
+ }
}
}
}
diff --git a/src/main/analysis/GroupDelayEstimator.test.ts b/src/main/analysis/GroupDelayEstimator.test.ts
index 77fd0afb..9fbab00c 100644
--- a/src/main/analysis/GroupDelayEstimator.test.ts
+++ b/src/main/analysis/GroupDelayEstimator.test.ts
@@ -306,3 +306,39 @@ describe('GroupDelayEstimator', () => {
});
});
});
+
+describe('latency budget (P2.7)', () => {
+ it('attaches the per-size budget and over-budget flags', () => {
+ const settings: CurrentFilterSettings = {
+ ...DEFAULT_FILTER_SETTINGS,
+ gyro_lpf1_static_hz: 60, // heavy filtering → well over 2.5ms at 80 Hz
+ gyro_lpf2_static_hz: 60,
+ };
+ const result = estimateGroupDelay(settings, undefined, '5"');
+ expect(result.gyroBudgetMs).toBe(1.5);
+ expect(result.dtermBudgetMs).toBe(3.0);
+ expect(result.gyroOverBudget).toBe(true);
+ expect(result.warning).toContain('budget');
+ expect(result.warning).toContain('5"');
+ });
+
+ it('uses the default budget when size is unknown', () => {
+ const result = estimateGroupDelay(DEFAULT_FILTER_SETTINGS);
+ expect(result.gyroBudgetMs).toBe(2.0);
+ expect(result.dtermBudgetMs).toBe(3.5);
+ });
+
+ it('reports within-budget for a light chain on a 7-inch quad', () => {
+ const settings: CurrentFilterSettings = {
+ ...DEFAULT_FILTER_SETTINGS,
+ gyro_lpf1_static_hz: 500,
+ gyro_lpf2_static_hz: 0,
+ dyn_notch_min_hz: 0,
+ dyn_notch_max_hz: 0,
+ };
+ const result = estimateGroupDelay(settings, undefined, '7"');
+ expect(result.gyroBudgetMs).toBe(2.5);
+ expect(result.gyroOverBudget).toBe(false);
+ expect(result.warning).toBeUndefined();
+ });
+});
diff --git a/src/main/analysis/GroupDelayEstimator.ts b/src/main/analysis/GroupDelayEstimator.ts
index 15a47e5b..3bb100e4 100644
--- a/src/main/analysis/GroupDelayEstimator.ts
+++ b/src/main/analysis/GroupDelayEstimator.ts
@@ -27,13 +27,25 @@ import type {
FilterGroupDelay,
SingleFilterDelay,
} from '@shared/types/analysis.types';
+import type { DroneSize } from '@shared/types/profile.types';
+import {
+ FILTER_LATENCY_BUDGET_BY_SIZE,
+ FILTER_LATENCY_BUDGET_DEFAULT,
+ type FilterLatencyBudget,
+} from './constants';
/** Default reference frequency for group delay computation (Hz) */
export const GROUP_DELAY_REFERENCE_HZ = 80;
-/** Group delay threshold above which a warning is issued (ms) */
+/** Group delay threshold above which a warning is issued (ms) — legacy
+ * fallback; the per-size budgets in constants.ts take precedence. */
export const GROUP_DELAY_WARNING_MS = 2.0;
+/** Resolve the latency budget for a drone size (default when size unknown) */
+export function resolveLatencyBudget(droneSize?: DroneSize): FilterLatencyBudget {
+ return (droneSize && FILTER_LATENCY_BUDGET_BY_SIZE[droneSize]) || FILTER_LATENCY_BUDGET_DEFAULT;
+}
+
/**
* Compute group delay of a first-order PT1 lowpass filter at a given frequency.
*
@@ -119,11 +131,14 @@ export function notchGroupDelay(notchHz: number, freqHz: number, Q: number = 3.0
*
* @param settings - Current filter settings from the FC
* @param referenceHz - Frequency at which to compute delay (default: 80 Hz)
+ * @param droneSize - When provided, delay is judged against the per-size
+ * latency budget (P2.7) instead of the fixed 2 ms threshold
* @returns Group delay breakdown
*/
export function estimateGroupDelay(
settings: CurrentFilterSettings,
- referenceHz: number = GROUP_DELAY_REFERENCE_HZ
+ referenceHz: number = GROUP_DELAY_REFERENCE_HZ,
+ droneSize?: DroneSize
): FilterGroupDelay {
const filters: SingleFilterDelay[] = [];
let gyroTotalS = 0;
@@ -201,9 +216,16 @@ export function estimateGroupDelay(
const gyroTotalMs = gyroTotalS * 1000;
const dtermTotalMs = dtermTotalS * 1000;
+ const budget = resolveLatencyBudget(droneSize);
+ const gyroOverBudget = gyroTotalMs > budget.gyroMs;
+ const dtermOverBudget = dtermTotalMs > budget.dtermMs;
+
let warning: string | undefined;
- if (gyroTotalMs > GROUP_DELAY_WARNING_MS) {
- warning = `Gyro filter chain adds ${gyroTotalMs.toFixed(1)}ms of delay at ${referenceHz} Hz — this may cause sluggish response. Consider raising cutoff frequencies or using RPM filter to reduce reliance on software filters.`;
+ if (gyroOverBudget) {
+ warning =
+ `Gyro filter chain adds ${gyroTotalMs.toFixed(1)}ms of delay at ${referenceHz} Hz — ` +
+ `over the ${budget.gyroMs.toFixed(1)}ms latency budget${droneSize ? ` for a ${droneSize} quad` : ''}. ` +
+ 'This may cause sluggish response. Consider raising cutoff frequencies or using RPM filter to reduce reliance on software filters.';
}
return {
@@ -212,5 +234,9 @@ export function estimateGroupDelay(
dtermTotalMs: Math.round(dtermTotalMs * 100) / 100,
referenceFreqHz: referenceHz,
...(warning ? { warning } : {}),
+ gyroBudgetMs: budget.gyroMs,
+ dtermBudgetMs: budget.dtermMs,
+ gyroOverBudget,
+ dtermOverBudget,
};
}
diff --git a/src/main/analysis/MechanicalHealthChecker.test.ts b/src/main/analysis/MechanicalHealthChecker.test.ts
index ccb18695..9da7b389 100644
--- a/src/main/analysis/MechanicalHealthChecker.test.ts
+++ b/src/main/analysis/MechanicalHealthChecker.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import {
checkMechanicalHealth,
+ checkMotorSpectralSignatures,
resolveExtremeNoiseThresholdDb,
EXTREME_NOISE_FLOOR_DB,
EXTREME_NOISE_MARGIN_DB,
@@ -289,3 +290,81 @@ describe('checkMechanicalHealth size-aware extreme noise', () => {
expect(noiseIssues[0].threshold).toBe(0);
});
});
+
+describe('checkMotorSpectralSignatures (P3.4, experimental)', () => {
+ const SR = 4000;
+ const noise = () => (Math.random() - 0.5) * 0.02;
+
+ it('flags a bent-prop signature on the one motor with a strong order peak', () => {
+ // Motor 2 carries a strong 150 Hz tone the others lack
+ const data = makeFlightData({
+ motorFns: [
+ (i) => 0.5 + noise(),
+ (i) => 0.5 + 0.2 * Math.sin((2 * Math.PI * 150 * i) / SR) + noise(),
+ (i) => 0.5 + noise(),
+ (i) => 0.5 + noise(),
+ ],
+ });
+ const issues = checkMotorSpectralSignatures(data);
+ const prop = issues.find((iss) => iss.type === 'motor_prop_signature');
+ expect(prop).toBeDefined();
+ expect(prop!.severity).toBe('info');
+ expect(prop!.experimental).toBe(true);
+ expect(prop!.message).toContain('Motor 2');
+ expect(prop!.message).toMatch(/14[5-9]|15[0-5]/); // bin-quantized ~150 Hz
+ });
+
+ it('flags a bearing signature on broadband-elevated motors', () => {
+ // Motor 4 carries strong broadband noise across the bearing band
+ const data = makeFlightData({
+ motorFns: [
+ (i) => 0.5 + noise(),
+ (i) => 0.5 + noise(),
+ (i) => 0.5 + noise(),
+ (i) => 0.5 + (Math.random() - 0.5) * 0.5,
+ ],
+ });
+ const issues = checkMotorSpectralSignatures(data);
+ const bearing = issues.find((iss) => iss.type === 'motor_bearing_signature');
+ expect(bearing).toBeDefined();
+ expect(bearing!.experimental).toBe(true);
+ expect(bearing!.message).toContain('Motor 4');
+ });
+
+ it('stays silent on symmetric motors', () => {
+ const data = makeFlightData({
+ motorFns: [
+ (i) => 0.5 + 0.05 * Math.sin((2 * Math.PI * 150 * i) / SR) + noise(),
+ (i) => 0.5 + 0.05 * Math.sin((2 * Math.PI * 150 * i) / SR) + noise(),
+ (i) => 0.5 + 0.05 * Math.sin((2 * Math.PI * 150 * i) / SR) + noise(),
+ (i) => 0.5 + 0.05 * Math.sin((2 * Math.PI * 150 * i) / SR) + noise(),
+ ],
+ });
+ const issues = checkMotorSpectralSignatures(data);
+ expect(issues).toHaveLength(0);
+ });
+
+ it('skips analysis when motor data is too short', () => {
+ const data = makeFlightData({ length: 1000 });
+ expect(checkMotorSpectralSignatures(data)).toHaveLength(0);
+ });
+
+ it('experimental info flags never degrade the overall status', () => {
+ const data = makeFlightData({
+ motorFns: [
+ (i) => 0.5 + noise(),
+ (i) => 0.5 + 0.2 * Math.sin((2 * Math.PI * 150 * i) / SR) + noise(),
+ (i) => 0.5 + noise(),
+ (i) => 0.5 + noise(),
+ ],
+ });
+ const result = checkMechanicalHealth(data, makeNoiseProfile());
+ const hasExperimental = result.issues.some((i) => i.experimental);
+ if (hasExperimental) {
+ const nonExperimental = result.issues.filter((i) => !i.experimental);
+ if (nonExperimental.length === 0) {
+ expect(result.status).toBe('ok');
+ }
+ }
+ });
+});
diff --git a/src/main/analysis/MechanicalHealthChecker.ts b/src/main/analysis/MechanicalHealthChecker.ts
index 05ef9e2c..ec35596e 100644
--- a/src/main/analysis/MechanicalHealthChecker.ts
+++ b/src/main/analysis/MechanicalHealthChecker.ts
@@ -16,6 +16,7 @@ import type {
MechanicalHealthResult,
} from '@shared/types/analysis.types';
import { THROTTLE_MIN_FLIGHT, THROTTLE_MAX_HOVER, NOISE_LEVEL_BY_SIZE } from './constants';
+import { computePowerSpectrum, trimSpectrum } from './FFTCompute';
import type { DroneSize } from '@shared/types/profile.types';
export type { HealthSeverity, MechanicalHealthIssue, MechanicalHealthResult };
@@ -50,6 +51,26 @@ export const MOTOR_VARIANCE_RATIO_THRESHOLD = 3.0;
/** Minimum hover duration for motor analysis (seconds) */
const MIN_HOVER_DURATION_S = 1.0;
+// ---- Per-motor spectral fault signatures (P3.4, experimental) ----
+
+/** Rotation-order band searched for a bent-prop / imbalance signature (Hz).
+ * The PID loop counteracts a 1×/rev vibration, which shows up as a
+ * narrowband peak in that motor's COMMAND signal at the rotation frequency. */
+export const MOTOR_ORDER_MIN_HZ = 60;
+export const MOTOR_ORDER_MAX_HZ = 350;
+/** One motor's order peak must exceed the other motors' level at the same
+ * frequency by this much to flag a prop signature */
+export const MOTOR_PEAK_DELTA_DB = 8;
+/** Broadband band checked for a bearing-wear signature (Hz) */
+export const MOTOR_BROADBAND_MIN_HZ = 200;
+export const MOTOR_BROADBAND_MAX_HZ = 500;
+/** One motor's broadband median must sit this far above the others' median */
+export const MOTOR_BROADBAND_DELTA_DB = 6;
+/** Minimum samples for per-motor spectral analysis */
+const MOTOR_SPECTRUM_MIN_SAMPLES = 4096;
+/** FFT window for per-motor spectra */
+const MOTOR_SPECTRUM_WINDOW = 1024;
+
// ---- Implementation ----
/**
@@ -188,6 +209,109 @@ function checkMotorImbalance(flightData: BlackboxFlightData): MechanicalHealthIs
return issues;
}
+/** Median of an array (non-mutating) */
+function median(values: number[]): number {
+ const s = [...values].sort((a, b) => a - b);
+ const mid = Math.floor(s.length / 2);
+ return s.length % 2 === 0 ? (s[mid - 1] + s[mid]) / 2 : s[mid];
+}
+
+/**
+ * Per-motor order/spectral analysis (P3.4, experimental).
+ *
+ * Computes each motor command signal's power spectrum and compares the four
+ * signatures against each other (relative comparison — the absolute motor
+ * scale cancels):
+ * - A narrowband peak on ONE motor that the others lack at the same frequency
+ * (rotation-order band) → bent prop / prop imbalance signature.
+ * - One motor's broadband level sitting well above the others in the bearing
+ * band → bearing-wear signature.
+ *
+ * Both flags are informational and marked experimental — thresholds are being
+ * calibrated from telemetry before they can gate anything.
+ */
+export function checkMotorSpectralSignatures(
+ flightData: BlackboxFlightData
+): MechanicalHealthIssue[] {
+ const issues: MechanicalHealthIssue[] = [];
+ const { motor, sampleRateHz } = flightData;
+ if (motor.length < 4) return issues;
+ if (motor.some((m) => m.values.length < MOTOR_SPECTRUM_MIN_SAMPLES)) return issues;
+
+ // Per-motor spectra over the shared rotation-order + bearing range
+ const spectra = motor.map((m) => {
+ const full = computePowerSpectrum(m.values, sampleRateHz, MOTOR_SPECTRUM_WINDOW);
+ return trimSpectrum(full, MOTOR_ORDER_MIN_HZ, MOTOR_BROADBAND_MAX_HZ);
+ });
+ const numBins = Math.min(...spectra.map((s) => s.frequencies.length));
+ if (numBins < 8) return issues;
+
+ // ── Bent-prop / imbalance: strongest per-motor order peak vs the others ──
+ let worstProp: { motorIdx: number; freq: number; delta: number } | null = null;
+ for (let m = 0; m < 4; m++) {
+ for (let i = 0; i < numBins; i++) {
+ const f = spectra[m].frequencies[i];
+ if (f < MOTOR_ORDER_MIN_HZ || f > MOTOR_ORDER_MAX_HZ) continue;
+ const own = spectra[m].magnitudes[i];
+ const others = spectra
+ .filter((_, idx) => idx !== m)
+ .map((s) => s.magnitudes[Math.min(i, s.frequencies.length - 1)]);
+ const delta = own - median(others);
+ if (delta >= MOTOR_PEAK_DELTA_DB && (!worstProp || delta > worstProp.delta)) {
+ worstProp = { motorIdx: m, freq: f, delta };
+ }
+ }
+ }
+ if (worstProp) {
+ issues.push({
+ type: 'motor_prop_signature',
+ severity: 'info',
+ message:
+ `(Experimental) Motor ${worstProp.motorIdx + 1} shows a vibration signature at ` +
+ `${Math.round(worstProp.freq)} Hz that is ${worstProp.delta.toFixed(0)} dB stronger than ` +
+ 'the other motors at the same frequency — consistent with a bent or unbalanced prop on ' +
+ 'that corner. Inspect the prop and motor bell.',
+ measuredValue: Math.round(worstProp.delta * 10) / 10,
+ threshold: MOTOR_PEAK_DELTA_DB,
+ experimental: true,
+ });
+ }
+
+ // ── Bearing wear: broadband median in the bearing band vs the others ──
+ const broadbandMedians = spectra.map((s) => {
+ const vals: number[] = [];
+ for (let i = 0; i < s.frequencies.length; i++) {
+ const f = s.frequencies[i];
+ if (f >= MOTOR_BROADBAND_MIN_HZ && f <= MOTOR_BROADBAND_MAX_HZ) {
+ vals.push(s.magnitudes[i]);
+ }
+ }
+ return vals.length > 0 ? median(vals) : -Infinity;
+ });
+ if (broadbandMedians.every((v) => Number.isFinite(v))) {
+ for (let m = 0; m < 4; m++) {
+ const others = broadbandMedians.filter((_, idx) => idx !== m);
+ const delta = broadbandMedians[m] - median(others);
+ if (delta >= MOTOR_BROADBAND_DELTA_DB) {
+ issues.push({
+ type: 'motor_bearing_signature',
+ severity: 'info',
+ message:
+ `(Experimental) Motor ${m + 1} shows ${delta.toFixed(0)} dB more broadband noise in the ` +
+ `${MOTOR_BROADBAND_MIN_HZ}-${MOTOR_BROADBAND_MAX_HZ} Hz band than the other motors — ` +
+ 'consistent with bearing wear. Spin the motor by hand and listen for grinding.',
+ measuredValue: Math.round(delta * 10) / 10,
+ threshold: MOTOR_BROADBAND_DELTA_DB,
+ experimental: true,
+ });
+ break; // one bearing flag is enough per flight
+ }
+ }
+ }
+
+ return issues;
+}
+
/**
* Generate overall summary from issues.
*/
@@ -232,7 +356,10 @@ export function checkMechanicalHealth(
// Check 3: Motor imbalance
issues.push(...checkMotorImbalance(flightData));
- // Determine overall status
+ // Check 4: Per-motor spectral fault signatures (P3.4, experimental, info-only)
+ issues.push(...checkMotorSpectralSignatures(flightData));
+
+ // Determine overall status (experimental info flags never change it)
let status: HealthSeverity = 'ok';
if (issues.some((i) => i.severity === 'critical')) {
status = 'critical';
diff --git a/src/main/analysis/NoiseAnalyzer.test.ts b/src/main/analysis/NoiseAnalyzer.test.ts
index c270dcab..a583738f 100644
--- a/src/main/analysis/NoiseAnalyzer.test.ts
+++ b/src/main/analysis/NoiseAnalyzer.test.ts
@@ -8,6 +8,7 @@ import {
averageSpectra,
categorizeNoiseLevel,
buildNoiseProfile,
+ reclassifyPeaksWithThrottle,
} from './NoiseAnalyzer';
import type { PowerSpectrum, AxisNoiseProfile } from '@shared/types/analysis.types';
import { computePowerSpectrum, trimSpectrum } from './FFTCompute';
@@ -464,3 +465,112 @@ describe('buildNoiseProfile', () => {
expect(profile4.overallLevel).toBe('medium'); // -18 < -17 → not high on 4", -18 >= -30 → MEDIUM
});
});
+
+describe('reclassifyPeaksWithThrottle', () => {
+ /** Build a throttle band whose axis-0 spectrum has one peak at peakHz */
+ function makeBand(
+ throttleMin: number,
+ throttleMax: number,
+ peakHz: number,
+ baselineDb = -40,
+ peakDb = -15
+ ) {
+ const numBins = 512;
+ const freqRes = 2;
+ const frequencies = new Float64Array(numBins).map((_, i) => i * freqRes);
+ const magnitudes = new Float64Array(numBins).fill(baselineDb);
+ const bin = Math.round(peakHz / freqRes);
+ magnitudes[bin] = peakDb;
+ const spectrum = { frequencies, magnitudes };
+ return {
+ throttleMin,
+ throttleMax,
+ sampleCount: 5000,
+ spectra: [spectrum, spectrum, spectrum] as [
+ typeof spectrum,
+ typeof spectrum,
+ typeof spectrum,
+ ],
+ noiseFloorDb: [baselineDb, baselineDb, baselineDb] as [number, number, number],
+ };
+ }
+
+ const basePeak = { frequency: 200, amplitude: 20, type: 'frame_resonance' as const };
+
+ it('reclassifies a throttle-tracking peak as motor_harmonic with its track', () => {
+ // Peak frequency rises 140→260 Hz across throttle — motor noise
+ const bands = [
+ makeBand(0.1, 0.2, 140),
+ makeBand(0.3, 0.4, 180),
+ makeBand(0.5, 0.6, 220),
+ makeBand(0.7, 0.8, 260),
+ ];
+ const [peak] = reclassifyPeaksWithThrottle([basePeak], bands, 0, '5"');
+ expect(peak.type).toBe('motor_harmonic');
+ expect(peak.classifiedBy).toBe('throttle_track');
+ expect(peak.throttleTrack).toBeDefined();
+ expect(peak.throttleTrack!.frequencyHz.length).toBe(4);
+ });
+
+ it('reclassifies a stationary peak as frame_resonance (definitively not motor)', () => {
+ // Same frequency at every throttle — stationary
+ const misclassified = { frequency: 160, amplitude: 20, type: 'motor_harmonic' as const };
+ const bands = [
+ makeBand(0.1, 0.2, 160),
+ makeBand(0.3, 0.4, 160),
+ makeBand(0.5, 0.6, 160),
+ makeBand(0.7, 0.8, 162),
+ ];
+ const [peak] = reclassifyPeaksWithThrottle([misclassified], bands, 0, '5"');
+ expect(peak.type).toBe('frame_resonance');
+ expect(peak.classifiedBy).toBe('throttle_track');
+ });
+
+ it('classifies a stationary high-frequency peak as electrical', () => {
+ const p = { frequency: 600, amplitude: 15, type: 'motor_harmonic' as const };
+ const bands = [makeBand(0.1, 0.2, 600), makeBand(0.3, 0.4, 600), makeBand(0.5, 0.6, 602)];
+ const [peak] = reclassifyPeaksWithThrottle([p], bands, 0, '5"');
+ expect(peak.type).toBe('electrical');
+ });
+
+ it('keeps the heuristic classification for ambiguous tracks', () => {
+ // Range ~10% — between stationary (8%) and tracking (15%) thresholds
+ const bands = [
+ makeBand(0.1, 0.2, 190),
+ makeBand(0.3, 0.4, 196),
+ makeBand(0.5, 0.6, 202),
+ makeBand(0.7, 0.8, 210),
+ ];
+ const [peak] = reclassifyPeaksWithThrottle([basePeak], bands, 0, '5"');
+ expect(peak.type).toBe('frame_resonance'); // unchanged
+ expect(peak.classifiedBy).toBe('heuristic');
+ });
+
+ it('keeps the heuristic classification with too few bands', () => {
+ const bands = [makeBand(0.1, 0.2, 140), makeBand(0.7, 0.8, 260)];
+ const [peak] = reclassifyPeaksWithThrottle([basePeak], bands, 0, '5"');
+ expect(peak.classifiedBy).toBe('heuristic');
+ expect(peak.type).toBe('frame_resonance');
+ });
+
+ it('skips bands where the peak is not prominent', () => {
+ // Peak visible in only 2 of 4 bands (others flat) → heuristic kept
+ const flat = makeBand(0.3, 0.4, 180);
+ flat.spectra[0].magnitudes.fill(-40);
+ const flat2 = makeBand(0.5, 0.6, 220);
+ flat2.spectra[0].magnitudes.fill(-40);
+ const bands = [makeBand(0.1, 0.2, 140), flat, flat2, makeBand(0.7, 0.8, 260)];
+ const [peak] = reclassifyPeaksWithThrottle([basePeak], bands, 0, '5"');
+ expect(peak.classifiedBy).toBe('heuristic');
+ });
+
+ it('uses the size-aware frame band for stationary peaks', () => {
+ // 300 Hz stationary: inside the 2.5" frame band, outside the 5" band
+ const p = { frequency: 300, amplitude: 15, type: 'unknown' as const };
+ const bands = [makeBand(0.1, 0.2, 300), makeBand(0.3, 0.4, 300), makeBand(0.5, 0.6, 301)];
+ const [micro] = reclassifyPeaksWithThrottle([p], bands, 0, '2.5"');
+ expect(micro.type).toBe('frame_resonance');
+ const [five] = reclassifyPeaksWithThrottle([p], bands, 0, '5"');
+ expect(five.type).toBe('unknown'); // 300 Hz outside 5" band, below electrical
+ });
+});
diff --git a/src/main/analysis/NoiseAnalyzer.ts b/src/main/analysis/NoiseAnalyzer.ts
index ebe5c70d..7228d720 100644
--- a/src/main/analysis/NoiseAnalyzer.ts
+++ b/src/main/analysis/NoiseAnalyzer.ts
@@ -9,6 +9,7 @@ import type {
NoisePeak,
AxisNoiseProfile,
NoiseProfile,
+ ThrottleBand,
} from '@shared/types/analysis.types';
import type { DroneSize } from '@shared/types/profile.types';
import {
@@ -25,6 +26,12 @@ import {
MOTOR_HARMONIC_TOLERANCE_RATIO,
MOTOR_HARMONIC_TOLERANCE_MIN_HZ,
MOTOR_HARMONIC_MIN_PEAKS,
+ HARMONIC_TRACK_MIN_BANDS,
+ HARMONIC_TRACK_MIN_CORRELATION,
+ HARMONIC_TRACK_MIN_REL_RANGE,
+ STATIONARY_TRACK_MAX_REL_RANGE,
+ TRACK_SEARCH_REL_WINDOW,
+ TRACK_BAND_MIN_PROMINENCE_DB,
} from './constants';
/** Sentinel value for bins with near-zero power (10*log10(1e-24)) — re-exported from FFTCompute */
@@ -279,6 +286,117 @@ export function analyzeAxisNoise(
};
}
+/**
+ * Reclassify an axis's peaks using the throttle spectrogram.
+ *
+ * Motor noise tracks RPM: its frequency rises with throttle. Frame resonance
+ * and electrical noise are stationary. For each averaged-spectrum peak, the
+ * strongest nearby bin is located in every throttle band; regressing those
+ * frequencies against band throttle gives a definitive classification where
+ * the whole-flight equal-spacing heuristic can only guess (harmonics smear
+ * across RPM in the averaged spectrum).
+ *
+ * Peaks whose track is ambiguous (or with too few bands) keep their
+ * heuristic classification. Motor-classified peaks carry their measured
+ * `throttleTrack` for downstream RPM-filter reasoning.
+ */
+export function reclassifyPeaksWithThrottle(
+ peaks: NoisePeak[],
+ bands: ThrottleBand[],
+ axisIndex: 0 | 1 | 2,
+ droneSize?: DroneSize
+): NoisePeak[] {
+ const usableBands = bands.filter((b) => b.spectra && b.noiseFloorDb);
+ if (usableBands.length < HARMONIC_TRACK_MIN_BANDS) {
+ return peaks.map((p) => ({ ...p, classifiedBy: 'heuristic' as const }));
+ }
+
+ const frameBand = droneSize
+ ? FRAME_RESONANCE_BY_SIZE[droneSize]
+ : { min: FRAME_RESONANCE_MIN_HZ, max: FRAME_RESONANCE_MAX_HZ };
+
+ return peaks.map((peak) => {
+ const searchHalfWidth = peak.frequency * TRACK_SEARCH_REL_WINDOW;
+ const throttleMid: number[] = [];
+ const frequencyHz: number[] = [];
+
+ for (const band of usableBands) {
+ const spectrum = band.spectra![axisIndex];
+ const floor = band.noiseFloorDb![axisIndex];
+ const { frequencies, magnitudes } = spectrum;
+ if (frequencies.length === 0) continue;
+
+ // Strongest bin within the search window around the averaged peak
+ let bestIdx = -1;
+ let bestMag = -Infinity;
+ for (let i = 0; i < frequencies.length; i++) {
+ const f = frequencies[i];
+ if (f < peak.frequency - searchHalfWidth) continue;
+ if (f > peak.frequency + searchHalfWidth) break;
+ if (magnitudes[i] > bestMag) {
+ bestMag = magnitudes[i];
+ bestIdx = i;
+ }
+ }
+ if (bestIdx < 0 || bestMag - floor < TRACK_BAND_MIN_PROMINENCE_DB) continue;
+
+ throttleMid.push((band.throttleMin + band.throttleMax) / 2);
+ frequencyHz.push(frequencies[bestIdx]);
+ }
+
+ if (throttleMid.length < HARMONIC_TRACK_MIN_BANDS) {
+ return { ...peak, classifiedBy: 'heuristic' as const };
+ }
+
+ const meanFreq = frequencyHz.reduce((a, b) => a + b, 0) / frequencyHz.length;
+ const relRange = (Math.max(...frequencyHz) - Math.min(...frequencyHz)) / meanFreq;
+ const correlation = pearson(throttleMid, frequencyHz);
+
+ if (relRange >= HARMONIC_TRACK_MIN_REL_RANGE && correlation >= HARMONIC_TRACK_MIN_CORRELATION) {
+ return {
+ ...peak,
+ type: 'motor_harmonic' as const,
+ classifiedBy: 'throttle_track' as const,
+ throttleTrack: { throttleMid, frequencyHz },
+ };
+ }
+
+ if (relRange <= STATIONARY_TRACK_MAX_REL_RANGE) {
+ // Stationary → definitively NOT motor noise
+ const type =
+ peak.frequency >= frameBand.min && peak.frequency <= frameBand.max
+ ? ('frame_resonance' as const)
+ : peak.frequency >= ELECTRICAL_NOISE_MIN_HZ
+ ? ('electrical' as const)
+ : ('unknown' as const);
+ return { ...peak, type, classifiedBy: 'throttle_track' as const };
+ }
+
+ // Ambiguous track — keep the heuristic classification
+ return { ...peak, classifiedBy: 'heuristic' as const };
+ });
+}
+
+/** Pearson correlation coefficient of two equal-length series */
+function pearson(x: number[], y: number[]): number {
+ const n = x.length;
+ if (n < 2) return 0;
+ const mx = x.reduce((a, b) => a + b, 0) / n;
+ const my = y.reduce((a, b) => a + b, 0) / n;
+ let sxy = 0;
+ let sxx = 0;
+ let syy = 0;
+ for (let i = 0; i < n; i++) {
+ const dx = x[i] - mx;
+ const dy = y[i] - my;
+ sxy += dx * dy;
+ sxx += dx * dx;
+ syy += dy * dy;
+ }
+ const denom = Math.sqrt(sxx * syy);
+ return denom > 0 ? sxy / denom : 0;
+}
+
/**
* Average multiple power spectra (they must have identical frequency bins).
*/
diff --git a/src/main/analysis/PIDAnalyzer.ts b/src/main/analysis/PIDAnalyzer.ts
index 3c2c2ee4..98c8a0e6 100644
--- a/src/main/analysis/PIDAnalyzer.ts
+++ b/src/main/analysis/PIDAnalyzer.ts
@@ -20,6 +20,7 @@ import type {
AxisStepProfile,
BayesianSuggestion,
PIDAnalysisResult,
+ AxisWhatIfPrediction,
StepEvent,
StepResponse,
} from '@shared/types/analysis.types';
@@ -74,7 +75,10 @@ import {
recommendRCLinkBaseline,
mergeFFRecommendations,
} from './FeedforwardAnalyzer';
-import { analyzeThrottleTF } from './ThrottleTFAnalyzer';
+import { analyzeThrottleTF, recommendTPAFromThrottleTF } from './ThrottleTFAnalyzer';
+import { computeWhatIf } from './SystemIdentifier';
+import { computeDeconvolvedStepResponse } from './StepResponseStacker';
+import { DECONV_DISAGREEMENT_RATIO, DECONV_DISAGREEMENT_MIN_PP } from './constants';
/** Default PID configuration if none provided */
const DEFAULT_PIDS: PIDConfiguration = {
@@ -95,6 +99,8 @@ interface StepExtractionResult {
pitch: StepResponse[];
yaw: StepResponse[];
};
+ /** Warnings from the deconvolved/per-step cross-check */
+ deconvWarnings?: AnalysisWarning[];
tfResult?: undefined;
tfMetrics?: undefined;
}
@@ -213,12 +219,71 @@ async function extractViaStepResponse(
const pitch = aggregateAxisMetrics(pitchResponses);
const yaw = aggregateAxisMetrics(yawResponses);
+ // Deconvolved (stacked) step response — the primary source of
+ // overshoot/rise/settling when coherent. Per-step means remain the
+ // fallback and serve as a cross-check.
+ const profiles = { roll, pitch, yaw };
+ const deconvWarnings: AnalysisWarning[] = [];
+ try {
+ const deconv = computeDeconvolvedStepResponse(flightData);
+ for (const axis of ['roll', 'pitch', 'yaw'] as const) {
+ const profile = profiles[axis];
+ const axisDeconv = deconv[axis];
+ profile.metricsSource = 'per_step';
+ if (axisDeconv.low || axisDeconv.high) {
+ profile.inputSplit = {
+ splitThresholdDegS: deconv.splitThresholdDegS,
+ ...(axisDeconv.low ? { low: axisDeconv.low } : {}),
+ ...(axisDeconv.high ? { high: axisDeconv.high } : {}),
+ };
+ }
+ const primary = axisDeconv.primary;
+ if (!primary) continue;
+
+ // Cross-check: large relative disagreement between the two methods
+ // (above a floor where both mean "fine") lowers trust in the data
+ const perStepOvershoot = profile.meanOvershoot;
+ const bothMeaningful =
+ Math.max(perStepOvershoot, primary.overshootPercent) >= DECONV_DISAGREEMENT_MIN_PP &&
+ profile.responses.length > 0;
+ if (bothMeaningful) {
+ const rel =
+ Math.abs(primary.overshootPercent - perStepOvershoot) /
+ Math.max(perStepOvershoot, primary.overshootPercent);
+ if (rel > DECONV_DISAGREEMENT_RATIO) {
+ deconvWarnings.push({
+ code: 'step_deconv_disagreement',
+ message:
+ `Deconvolved and per-step overshoot disagree on ${axis} ` +
+ `(${primary.overshootPercent.toFixed(0)}% vs ${perStepOvershoot.toFixed(0)}%). ` +
+ 'Metrics may be less reliable — consider a cleaner flight with distinct stick snaps.',
+ severity: 'warning',
+ });
+ }
+ }
+
+ // Promote the deconvolved metrics to the headline values
+ profile.perStepMetrics = {
+ meanOvershoot: profile.meanOvershoot,
+ meanRiseTimeMs: profile.meanRiseTimeMs,
+ meanSettlingTimeMs: profile.meanSettlingTimeMs,
+ };
+ profile.meanOvershoot = primary.overshootPercent;
+ profile.meanRiseTimeMs = primary.riseTimeMs;
+ profile.meanSettlingTimeMs = primary.settlingTimeMs;
+ profile.metricsSource = 'deconvolved';
+ }
+ } catch {
+ // Deconvolution unavailable (e.g. extremely short log) — keep per-step
+ }
+
return {
mode: 'step_response',
- profiles: { roll, pitch, yaw },
+ profiles,
steps,
allResponses: [...rollResponses, ...pitchResponses, ...yawResponses],
axisResponses: { roll: rollResponses, pitch: pitchResponses, yaw: yawResponses },
+ ...(deconvWarnings.length > 0 ? { deconvWarnings } : {}),
};
}
@@ -459,9 +524,23 @@ async function analyzePIDCore(params: CoreParams): Promise {
rawRecommendations.push(thrustLinearRec);
}
- // TPA tuning advisory (size + noise + propwash-based)
+ // TPA tuning: measured per-band TF trends (P2.8) take precedence over the
+ // static size-based advisory for the same setting; propwash safety rules
+ // (PW-TPA-*) always win — they protect prop wash recovery authority.
const tpaRecs = recommendTPA(tpaContext, droneSize, throttleNoiseIncreaseDeltaDb, propWash);
- rawRecommendations.push(...tpaRecs);
+ const pwTpaSettings = new Set(
+ tpaRecs.filter((r) => r.ruleId?.startsWith('PW-')).map((r) => r.setting)
+ );
+ const tfTpaRecs = throttleTF
+ ? recommendTPAFromThrottleTF(throttleTF, tpaContext).filter(
+ (r) => !pwTpaSettings.has(r.setting)
+ )
+ : [];
+ const tfTpaSettings = new Set(tfTpaRecs.map((r) => r.setting));
+ rawRecommendations.push(
+ ...tpaRecs.filter((r) => r.ruleId?.startsWith('PW-') || !tfTpaSettings.has(r.setting)),
+ ...tfTpaRecs
+ );
// VBat sag compensation advisory (flight-style-based)
const vbatSag = rawHeaders ? extractVbatSagCompensation(rawHeaders) : undefined;
@@ -486,7 +565,12 @@ async function analyzePIDCore(params: CoreParams): Promise {
onProgress?.({ step: 'scoring', percent: 100 });
// ── Warnings ──
- const warnings: AnalysisWarning[] = [...qualityResult.warnings];
+ const warnings: AnalysisWarning[] = [
+ ...qualityResult.warnings,
+ ...(extracted.mode === 'step_response' && extracted.deconvWarnings
+ ? extracted.deconvWarnings
+ : []),
+ ];
if (throttleTF?.tpaWarning) {
warnings.push({
code: 'tpa_variance',
@@ -512,7 +596,35 @@ async function analyzePIDCore(params: CoreParams): Promise {
bayesianSuggestion = suggestNextPID(historyObservations) ?? undefined;
}
+ // ── System identification + what-if prediction (P3.2, Flash Tune only) ──
+ // Identify the plant from the measured closed loop with the flight gains,
+ // then predict the step response the PROPOSED gains would produce. Gated
+ // inside computeWhatIf on coherence and fit quality.
+ let whatIf: PIDAnalysisResult['whatIf'];
+ if (extracted.tfResult) {
+ const anchorPIDs = flightPIDs ?? currentPIDs;
+ const proposedPIDs = buildRecommendedPIDs(anchorPIDs, recommendations);
+ const axisResults: { roll?: AxisWhatIfPrediction; pitch?: AxisWhatIfPrediction } = {};
+ for (const axis of ['roll', 'pitch'] as const) {
+ try {
+ const r = computeWhatIf(
+ extracted.tfResult[axis],
+ anchorPIDs[axis],
+ proposedPIDs[axis],
+ flightData.sampleRateHz
+ );
+ if (r) axisResults[axis] = r;
+ } catch {
+ // Non-fatal — the what-if section is simply omitted for this axis
+ }
+ }
+ if (axisResults.roll || axisResults.pitch) {
+ whatIf = { ...axisResults, proposedPIDs };
+ }
+ }
+
return {
+ ...(whatIf ? { whatIf } : {}),
roll: profiles.roll,
pitch: profiles.pitch,
yaw: profiles.yaw,
diff --git a/src/main/analysis/PIDRecommender.test.ts b/src/main/analysis/PIDRecommender.test.ts
index 81067a62..33c1426d 100644
--- a/src/main/analysis/PIDRecommender.test.ts
+++ b/src/main/analysis/PIDRecommender.test.ts
@@ -3776,3 +3776,35 @@ describe('recommendVbatSagCompensation', () => {
expect(rec!.recommendedValue).toBe(75);
});
});
+
+describe('P-TPA-LOW (BF 4.5+ low-throttle TPA, P2.5)', () => {
+ const severePropWash = makePropWash({ meanSeverity: 6.0 });
+
+ it('recommends enabling tpa_low_always on severe propwash when supported and off', () => {
+ const ctx: TPAContext = { active: true, rate: 65, breakpoint: 1350, mode: 0, lowAlways: 0 };
+ const recs = recommendTPA(ctx, '5"', undefined, severePropWash);
+ const rec = recs.find((r) => r.ruleId === 'P-TPA-LOW');
+ expect(rec).toBeDefined();
+ expect(rec!.setting).toBe('tpa_low_always');
+ expect(rec!.recommendedValue).toBe(1);
+ expect(rec!.confidence).toBe('low');
+ });
+
+ it('does not fire when the firmware lacks low-throttle TPA (field undefined)', () => {
+ const ctx: TPAContext = { active: true, rate: 65, breakpoint: 1350, mode: 0 };
+ const recs = recommendTPA(ctx, '5"', undefined, severePropWash);
+ expect(recs.find((r) => r.ruleId === 'P-TPA-LOW')).toBeUndefined();
+ });
+
+ it('does not fire when tpa_low_always is already enabled', () => {
+ const ctx: TPAContext = { active: true, rate: 65, breakpoint: 1350, mode: 0, lowAlways: 1 };
+ const recs = recommendTPA(ctx, '5"', undefined, severePropWash);
+ expect(recs.find((r) => r.ruleId === 'P-TPA-LOW')).toBeUndefined();
+ });
+
+ it('does not fire without severe propwash', () => {
+ const ctx: TPAContext = { active: true, rate: 65, breakpoint: 1350, mode: 0, lowAlways: 0 };
+ const recs = recommendTPA(ctx, '5"', undefined, makePropWash({ meanSeverity: 1.5 }));
+ expect(recs.find((r) => r.ruleId === 'P-TPA-LOW')).toBeUndefined();
+ });
+});
diff --git a/src/main/analysis/PIDRecommender.ts b/src/main/analysis/PIDRecommender.ts
index c1a0c8f2..b7299dcf 100644
--- a/src/main/analysis/PIDRecommender.ts
+++ b/src/main/analysis/PIDRecommender.ts
@@ -16,6 +16,7 @@ import type { DroneSize, FlightStyle } from '@shared/types/profile.types';
import type { TransferFunctionMetrics } from './TransferFunctionEstimator';
import {
PID_STYLE_THRESHOLDS,
+ DECONV_THRESHOLD_SCALE,
DAMPING_RATIO_MIN,
DAMPING_RATIO_MAX,
DAMPING_RATIO_MAX_MICRO,
@@ -146,8 +147,16 @@ export function recommendPID(
// Yaw is analyzed with relaxed thresholds
const isYaw = axis === 2;
- const overshootThreshold = isYaw ? thresholds.overshootMax * 1.5 : thresholds.overshootMax;
- const moderateOvershoot = isYaw ? thresholds.overshootMax : thresholds.moderateOvershoot;
+ // Deconvolved (stacked) metrics read systematically lower than per-step
+ // measurements for the same physical response — scale overshoot/settling
+ // thresholds accordingly (rise time is comparable, not scaled).
+ const deconvScale = profile.metricsSource === 'deconvolved' ? DECONV_THRESHOLD_SCALE : 1;
+ const overshootThreshold =
+ (isYaw ? thresholds.overshootMax * 1.5 : thresholds.overshootMax) * deconvScale;
+ const moderateOvershoot =
+ (isYaw ? thresholds.overshootMax : thresholds.moderateOvershoot) * deconvScale;
+ const overshootIdealScaled = thresholds.overshootIdeal * deconvScale;
+ const settlingMaxScaled = thresholds.settlingMax * deconvScale;
const sluggishRiseMs = isYaw ? thresholds.sluggishRise * 1.5 : thresholds.sluggishRise;
// KB §6: ALL overshoot/ringing thresholds relax ×1.5 for yaw (slower axis, less authority)
const ringingThreshold = isYaw ? thresholds.ringingMax * 1.5 : thresholds.ringingMax;
@@ -227,10 +236,7 @@ export function recommendPID(
}
// Rule 2: Sluggish response (low overshoot + slow rise) → increase P (severity-scaled)
- if (
- profile.meanOvershoot < thresholds.overshootIdeal &&
- profile.meanRiseTimeMs > sluggishRiseMs
- ) {
+ if (profile.meanOvershoot < overshootIdealScaled && profile.meanRiseTimeMs > sluggishRiseMs) {
const slugSeverity = profile.meanRiseTimeMs / sluggishRiseMs;
const pStep = slugSeverity > 2 ? 10 : 5;
const targetP = clamp(base.P + pStep, bounds.pMin, bounds.pMax);
@@ -270,7 +276,7 @@ export function recommendPID(
// Rule 4: Slow settling → might need more D or less I
if (
- profile.meanSettlingTimeMs > thresholds.settlingMax &&
+ profile.meanSettlingTimeMs > settlingMaxScaled &&
profile.meanOvershoot < moderateOvershoot
) {
// Only if overshoot isn't the problem (settling from other causes)
@@ -310,7 +316,7 @@ export function recommendPID(
}
} else if (
ssError < thresholds.steadyStateErrorLow &&
- profile.meanSettlingTimeMs > thresholds.settlingMax &&
+ profile.meanSettlingTimeMs > settlingMaxScaled &&
profile.meanOvershoot > moderateOvershoot
) {
// Low error but slow settling + overshoot → I may be causing slow oscillation
@@ -1699,6 +1705,27 @@ export function recommendTPA(
});
}
+ // Rule P-TPA-LOW (BF 4.5+): severe propwash + low-throttle TPA disabled → enable.
+ // During propwash descents the motors sit at low RPM where thrust response is
+ // non-linear; low-throttle TPA attenuates gains there (SupaflyFPV presets
+ // enable tpa_low_always). Gated on firmware support: lowAlways is undefined
+ // when the BBL header lacks the BF 4.5+ field.
+ if (pwSevere && tpaContext.lowAlways === 0) {
+ recs.push({
+ setting: 'tpa_low_always',
+ currentValue: 0,
+ recommendedValue: 1,
+ reason:
+ `Severe prop wash detected (${propWash!.meanSeverity.toFixed(1)}× baseline) and ` +
+ 'low-throttle TPA is disabled. Enabling tpa_low_always attenuates PID gains in the ' +
+ 'non-linear low-RPM region during descents, reducing prop wash oscillation ' +
+ '(community presets enable this on Betaflight 4.5+).',
+ impact: 'stability',
+ confidence: 'low',
+ ruleId: 'P-TPA-LOW',
+ });
+ }
+
// ── Size-based TPA rules (skip if propwash already covered the same setting) ──
// Rule P-TPA-RATE: TPA rate advisory based on drone size
diff --git a/src/main/analysis/RpmFilterRecommender.test.ts b/src/main/analysis/RpmFilterRecommender.test.ts
new file mode 100644
index 00000000..b3a5e589
--- /dev/null
+++ b/src/main/analysis/RpmFilterRecommender.test.ts
@@ -0,0 +1,256 @@
+import { describe, it, expect } from 'vitest';
+import { recommendRpmFilterTuning } from './RpmFilterRecommender';
+import type { NoiseProfile, NoisePeak, CurrentFilterSettings } from '@shared/types/analysis.types';
+import { DEFAULT_FILTER_SETTINGS } from '@shared/types/analysis.types';
+
+function makeProfile(peaks: NoisePeak[]): NoiseProfile {
+ const axis = (axisPeaks: NoisePeak[]) => ({
+ spectrum: { frequencies: new Float64Array(0), magnitudes: new Float64Array(0) },
+ noiseFloorDb: -30,
+ peaks: axisPeaks,
+ });
+ return {
+ roll: axis(peaks),
+ pitch: axis([]),
+ yaw: axis([]),
+ overallLevel: 'medium',
+ };
+}
+
+function trackedMotorPeak(medianHz: number, amplitude: number, spreadHz = 40): NoisePeak {
+ // Track rising with throttle around the median
+ const frequencyHz = [medianHz - spreadHz, medianHz - spreadHz / 2, medianHz, medianHz + spreadHz];
+ return {
+ frequency: medianHz,
+ amplitude,
+ type: 'motor_harmonic',
+ classifiedBy: 'throttle_track',
+ throttleTrack: { throttleMid: [20, 40, 60, 80], frequencyHz },
+ };
+}
+
+function settings(overrides: Partial): CurrentFilterSettings {
+ return {
+ ...DEFAULT_FILTER_SETTINGS,
+ rpm_filter_harmonics: 3,
+ rpm_filter_min_hz: 100,
+ ...overrides,
+ };
+}
+
+describe('recommendRpmFilterTuning', () => {
+ it('returns nothing when RPM filter is inactive', () => {
+ const recs = recommendRpmFilterTuning(
+ makeProfile([trackedMotorPeak(200, 20)]),
+ settings({ rpm_filter_harmonics: 0, dyn_idle_min_rpm: 30 })
+ );
+ expect(recs).toHaveLength(0);
+ });
+
+ describe('F-RPM-MIN-IDLE (dynamic idle floor)', () => {
+ it('lowers min_hz when the floor sits above the idle fundamental', () => {
+ // dyn_idle 30 → 3000 RPM → 50 Hz fundamental; min_hz 100 leaves a 50-100 Hz gap
+ const recs = recommendRpmFilterTuning(
+ makeProfile([]),
+ settings({ rpm_filter_min_hz: 100, dyn_idle_min_rpm: 30 })
+ );
+ const rec = recs.find((r) => r.ruleId === 'F-RPM-MIN-IDLE');
+ expect(rec).toBeDefined();
+ expect(rec!.setting).toBe('rpm_filter_min_hz');
+ expect(rec!.recommendedValue).toBe(45); // round(50 × 0.9)
+ expect(rec!.impact).toBe('noise');
+ expect(rec!.confidence).toBe('medium');
+ });
+
+ it('raises min_hz when the floor sits far below the idle fundamental', () => {
+ // dyn_idle 80 → 8000 RPM → 133 Hz fundamental; min_hz 50 wastes low notching
+ const recs = recommendRpmFilterTuning(
+ makeProfile([]),
+ settings({ rpm_filter_min_hz: 50, dyn_idle_min_rpm: 80 })
+ );
+ const rec = recs.find((r) => r.ruleId === 'F-RPM-MIN-IDLE');
+ expect(rec).toBeDefined();
+ expect(rec!.recommendedValue).toBe(120); // round(133.3 × 0.9)
+ expect(rec!.impact).toBe('latency');
+ expect(rec!.confidence).toBe('low');
+ });
+
+ it('stays silent inside the deadzone', () => {
+ // dyn_idle 65 → 108 Hz → target 98; current 100 is within 15 Hz
+ const recs = recommendRpmFilterTuning(
+ makeProfile([]),
+ settings({ rpm_filter_min_hz: 100, dyn_idle_min_rpm: 65 })
+ );
+ expect(recs.find((r) => r.setting === 'rpm_filter_min_hz')).toBeUndefined();
+ });
+
+ it('clamps the target to the house floor', () => {
+ // dyn_idle 20 → 2000 RPM → 33 Hz → target 30 → clamped to 40
+ const recs = recommendRpmFilterTuning(
+ makeProfile([]),
+ settings({ rpm_filter_min_hz: 100, dyn_idle_min_rpm: 20 })
+ );
+ const rec = recs.find((r) => r.ruleId === 'F-RPM-MIN-IDLE');
+ expect(rec).toBeDefined();
+ expect(rec!.recommendedValue).toBe(40);
+ });
+ });
+
+ describe('F-RPM-MIN-TRACK (measured fundamental, no dyn idle)', () => {
+ it('lowers min_hz when the measured fundamental dips below the floor', () => {
+ // Fundamental track reaches 60 Hz; floor at 100 leaves it uncovered
+ const fundamental = trackedMotorPeak(100, 20, 40); // min track = 60 Hz
+ const recs = recommendRpmFilterTuning(
+ makeProfile([fundamental]),
+ settings({ rpm_filter_min_hz: 100 })
+ );
+ const rec = recs.find((r) => r.ruleId === 'F-RPM-MIN-TRACK');
+ expect(rec).toBeDefined();
+ expect(rec!.recommendedValue).toBe(54); // round(60 × 0.9)
+ expect(rec!.impact).toBe('noise');
+ });
+
+ it('never raises min_hz from track data alone', () => {
+ // Track never goes below 160 Hz — but the flight may not have hit low throttle
+ const recs = recommendRpmFilterTuning(
+ makeProfile([trackedMotorPeak(200, 20)]),
+ settings({ rpm_filter_min_hz: 100 })
+ );
+ expect(recs.find((r) => r.setting === 'rpm_filter_min_hz')).toBeUndefined();
+ });
+
+ it('prefers the dynamic-idle rule when both sources are present', () => {
+ const fundamental = trackedMotorPeak(100, 20, 40);
+ const recs = recommendRpmFilterTuning(
+ makeProfile([fundamental]),
+ settings({ rpm_filter_min_hz: 100, dyn_idle_min_rpm: 30 })
+ );
+ expect(recs.find((r) => r.ruleId === 'F-RPM-MIN-TRACK')).toBeUndefined();
+ expect(recs.find((r) => r.ruleId === 'F-RPM-MIN-IDLE')).toBeDefined();
+ });
+ });
+
+ describe('F-RPM-HARM-UP (harmonic order from measured tracks)', () => {
+ it('raises harmonics when a 2× track is measured with harmonics = 1', () => {
+ const recs = recommendRpmFilterTuning(
+ makeProfile([trackedMotorPeak(200, 20), trackedMotorPeak(400, 15)]),
+ settings({ rpm_filter_harmonics: 1 })
+ );
+ const rec = recs.find((r) => r.ruleId === 'F-RPM-HARM-UP');
+ expect(rec).toBeDefined();
+ expect(rec!.setting).toBe('rpm_filter_harmonics');
+ expect(rec!.recommendedValue).toBe(2);
+ expect(rec!.confidence).toBe('medium');
+ });
+
+ it('raises harmonics to 3 when a 3× track is measured with harmonics = 2', () => {
+ const recs = recommendRpmFilterTuning(
+ makeProfile([trackedMotorPeak(200, 20), trackedMotorPeak(600, 14)]),
+ settings({ rpm_filter_harmonics: 2 })
+ );
+ const rec = recs.find((r) => r.ruleId === 'F-RPM-HARM-UP');
+ expect(rec).toBeDefined();
+ expect(rec!.recommendedValue).toBe(3);
+ });
+
+ it('ignores tracks with a non-integer frequency ratio', () => {
+ // 200 → 520: ratio 2.6, neither 2× nor 3×
+ const recs = recommendRpmFilterTuning(
+ makeProfile([trackedMotorPeak(200, 20), trackedMotorPeak(520, 15)]),
+ settings({ rpm_filter_harmonics: 1 })
+ );
+ expect(recs.find((r) => r.ruleId === 'F-RPM-HARM-UP')).toBeUndefined();
+ });
+
+ it('ignores weak residual harmonics below the action threshold', () => {
+ const recs = recommendRpmFilterTuning(
+ makeProfile([trackedMotorPeak(200, 20), trackedMotorPeak(400, 8)]),
+ settings({ rpm_filter_harmonics: 1 })
+ );
+ expect(recs.find((r) => r.ruleId === 'F-RPM-HARM-UP')).toBeUndefined();
+ });
+
+ it('never fires when harmonics are already at maximum', () => {
+ const recs = recommendRpmFilterTuning(
+ makeProfile([trackedMotorPeak(200, 20), trackedMotorPeak(400, 15)]),
+ settings({ rpm_filter_harmonics: 3 })
+ );
+ expect(recs.find((r) => r.ruleId === 'F-RPM-HARM-UP')).toBeUndefined();
+ });
+ });
+
+ describe('F-RPM-FADE', () => {
+ it('recommends the BF default fade when fade is disabled', () => {
+ const recs = recommendRpmFilterTuning(
+ makeProfile([]),
+ settings({ rpm_filter_fade_range_hz: 0 })
+ );
+ const rec = recs.find((r) => r.ruleId === 'F-RPM-FADE');
+ expect(rec).toBeDefined();
+ expect(rec!.recommendedValue).toBe(50);
+ expect(rec!.informational).toBe(true);
+ });
+
+ it('stays silent when fade is unknown or already set', () => {
+ expect(
+ recommendRpmFilterTuning(makeProfile([]), settings({})).find(
+ (r) => r.ruleId === 'F-RPM-FADE'
+ )
+ ).toBeUndefined();
+ expect(
+ recommendRpmFilterTuning(makeProfile([]), settings({ rpm_filter_fade_range_hz: 50 })).find(
+ (r) => r.ruleId === 'F-RPM-FADE'
+ )
+ ).toBeUndefined();
+ });
+ });
+
+ describe('F-RPM-WEIGHTS (BF 4.5+ advisory)', () => {
+ it('suggests size-appropriate weights when all are at full depth', () => {
+ const recs = recommendRpmFilterTuning(
+ makeProfile([]),
+ settings({ rpm_filter_weights: [100, 100, 100] }),
+ '5"'
+ );
+ const rec = recs.find((r) => r.ruleId === 'F-RPM-WEIGHTS');
+ expect(rec).toBeDefined();
+ expect(rec!.informational).toBe(true);
+ expect(rec!.reason).toContain('90,50,90');
+ });
+
+ it('stays silent when weights are customized, absent, or size unknown', () => {
+ expect(
+ recommendRpmFilterTuning(
+ makeProfile([]),
+ settings({ rpm_filter_weights: [100, 50, 100] }),
+ '5"'
+ ).find((r) => r.ruleId === 'F-RPM-WEIGHTS')
+ ).toBeUndefined();
+ expect(
+ recommendRpmFilterTuning(makeProfile([]), settings({}), '5"').find(
+ (r) => r.ruleId === 'F-RPM-WEIGHTS'
+ )
+ ).toBeUndefined();
+ expect(
+ recommendRpmFilterTuning(
+ makeProfile([]),
+ settings({ rpm_filter_weights: [100, 100, 100] })
+ ).find((r) => r.ruleId === 'F-RPM-WEIGHTS')
+ ).toBeUndefined();
+ });
+ });
+
+ it('deduplicates the same harmonic source seen on multiple axes', () => {
+ const fundamental = trackedMotorPeak(200, 20);
+ const second = trackedMotorPeak(400, 15);
+ const profile = makeProfile([fundamental, second]);
+ // Same physical peaks also visible on pitch
+ profile.pitch.peaks = [
+ { ...fundamental, amplitude: 18 },
+ { ...second, amplitude: 13 },
+ ];
+ const recs = recommendRpmFilterTuning(profile, settings({ rpm_filter_harmonics: 1 }));
+ const harmRecs = recs.filter((r) => r.ruleId === 'F-RPM-HARM-UP');
+ expect(harmRecs).toHaveLength(1);
+ });
+});
diff --git a/src/main/analysis/RpmFilterRecommender.ts b/src/main/analysis/RpmFilterRecommender.ts
new file mode 100644
index 00000000..a4b95669
--- /dev/null
+++ b/src/main/analysis/RpmFilterRecommender.ts
@@ -0,0 +1,326 @@
+/**
+ * RPM filter tuning rules (P2.6).
+ *
+ * Uses measured harmonic tracks (throttle-spectrogram reclassification, P2.2)
+ * and the dynamic-idle floor to tune rpm_filter_min_hz, rpm_filter_harmonics,
+ * rpm_filter_fade_range_hz and (advisory, BF 4.5+) rpm_filter_weights.
+ *
+ * All rules require an active RPM filter. Latency awareness: min_hz is never
+ * pushed below the frequency the motors can actually reach (deep low-frequency
+ * notches cost delay for nothing), and harmonic count is only raised when a
+ * measured track proves an unfiltered harmonic order exists.
+ */
+import type {
+ NoiseProfile,
+ NoisePeak,
+ FilterRecommendation,
+ CurrentFilterSettings,
+} from '@shared/types/analysis.types';
+import type { DroneSize } from '@shared/types/profile.types';
+import {
+ RPM_MIN_HZ_IDLE_RATIO,
+ RPM_MIN_HZ_TRACK_RATIO,
+ RPM_MIN_HZ_FLOOR,
+ RPM_MIN_HZ_CEILING,
+ RPM_MIN_HZ_DEADZONE_HZ,
+ RPM_HARMONIC_RATIO_TOLERANCE,
+ RPM_HARMONICS_MAX,
+ RPM_FADE_RANGE_DEFAULT_HZ,
+ RPM_FILTER_WEIGHTS_BY_SIZE,
+ RESONANCE_ACTION_THRESHOLD_DB,
+} from './constants';
+
+/** A motor-harmonic peak with its measured throttle track */
+interface TrackedPeak {
+ peak: NoisePeak;
+ /** Median frequency across the measured track (robust vs outlier bands) */
+ medianHz: number;
+ /** Lowest frequency the track was observed at (lowest throttle band) */
+ minHz: number;
+}
+
+function median(values: number[]): number {
+ const sorted = [...values].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
+}
+
+function clamp(v: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, v));
+}
+
+/**
+ * Collect unique motor-harmonic peaks that carry a measured throttle track,
+ * across all three axes. Peaks within 10 Hz median of an already-collected
+ * one are treated as the same physical source (same harmonic on another axis).
+ */
+function collectTrackedMotorPeaks(noise: NoiseProfile): TrackedPeak[] {
+ const tracked: TrackedPeak[] = [];
+ for (const axis of [noise.roll, noise.pitch, noise.yaw]) {
+ for (const peak of axis.peaks) {
+ if (peak.type !== 'motor_harmonic' || !peak.throttleTrack) continue;
+ const medianHz = median(peak.throttleTrack.frequencyHz);
+ const duplicate = tracked.find((t) => Math.abs(t.medianHz - medianHz) < 10);
+ if (duplicate) {
+ // Keep the stronger observation of the same source
+ if (peak.amplitude > duplicate.peak.amplitude) {
+ duplicate.peak = peak;
+ }
+ continue;
+ }
+ tracked.push({
+ peak,
+ medianHz,
+ minHz: Math.min(...peak.throttleTrack.frequencyHz),
+ });
+ }
+ }
+ return tracked.sort((a, b) => a.medianHz - b.medianHz);
+}
+
+/**
+ * Generate RPM filter tuning recommendations.
+ * Returns an empty array when the RPM filter is not active.
+ */
+export function recommendRpmFilterTuning(
+ noise: NoiseProfile,
+ current: CurrentFilterSettings,
+ droneSize?: DroneSize
+): FilterRecommendation[] {
+ if ((current.rpm_filter_harmonics ?? 0) <= 0) return [];
+
+ const out: FilterRecommendation[] = [];
+ const trackedPeaks = collectTrackedMotorPeaks(noise);
+
+ recommendMinHz(current, trackedPeaks, out);
+ recommendHarmonicCount(current, trackedPeaks, out);
+ recommendFadeRange(current, out);
+ recommendWeights(current, droneSize, out);
+
+ return out;
+}
+
+/**
+ * Rule F-RPM-MIN-IDLE / F-RPM-MIN-TRACK: align rpm_filter_min_hz with the
+ * lowest frequency the notches actually need to reach.
+ *
+ * With dynamic idle active the motors never spin below the idle floor, so the
+ * fundamental never drops below idleHz = dyn_idle_min_rpm × 100 / 60. The
+ * notch floor belongs just below that: higher leaves a low-throttle gap
+ * (noise), much lower buys nothing and costs delay during throttle chops.
+ *
+ * Without dynamic-idle information, only a measured fundamental track that
+ * reaches BELOW the current floor justifies lowering it (the flight proves the
+ * gap exists). Raising from track data alone is unsafe — the flight may simply
+ * not have visited low throttle.
+ */
+function recommendMinHz(
+ current: CurrentFilterSettings,
+ trackedPeaks: TrackedPeak[],
+ out: FilterRecommendation[]
+): void {
+ const minHz = current.rpm_filter_min_hz;
+ if (minHz === undefined) return;
+
+ const dynIdle = current.dyn_idle_min_rpm ?? 0;
+ if (dynIdle > 0) {
+ const idleHz = (dynIdle * 100) / 60;
+ const target = clamp(
+ Math.round(idleHz * RPM_MIN_HZ_IDLE_RATIO),
+ RPM_MIN_HZ_FLOOR,
+ RPM_MIN_HZ_CEILING
+ );
+ if (Math.abs(minHz - target) <= RPM_MIN_HZ_DEADZONE_HZ) return;
+
+ if (minHz > idleHz) {
+ // Floor above the idle fundamental — uncovered gap at low throttle
+ out.push({
+ setting: 'rpm_filter_min_hz',
+ currentValue: minHz,
+ recommendedValue: target,
+ reason:
+ `Dynamic idle holds your motors at or above ${Math.round(idleHz)} Hz, but the RPM ` +
+ `notches stop at ${minHz} Hz — motor noise between ${Math.round(idleHz)} and ${minHz} Hz ` +
+ `is unfiltered at low throttle. Lowering rpm_filter_min_hz to ${target} lets the ` +
+ 'notches cover the full RPM range.',
+ impact: 'noise',
+ confidence: 'medium',
+ ruleId: 'F-RPM-MIN-IDLE',
+ evidence: {
+ measurements: [
+ {
+ label: 'Dynamic idle floor',
+ value: `${dynIdle * 100} RPM (${Math.round(idleHz)} Hz)`,
+ },
+ { label: 'Current rpm_filter_min_hz', value: `${minHz} Hz` },
+ ],
+ trigger: `Notch floor above the idle fundamental leaves ${Math.round(idleHz)}-${minHz} Hz uncovered`,
+ },
+ });
+ } else {
+ // Floor far below anything the motors can reach — wasted low-frequency notching
+ out.push({
+ setting: 'rpm_filter_min_hz',
+ currentValue: minHz,
+ recommendedValue: target,
+ reason:
+ `Dynamic idle keeps your motors at or above ${Math.round(idleHz)} Hz, so RPM notches ` +
+ `below that never track real motor noise. Raising rpm_filter_min_hz from ${minHz} to ` +
+ `${target} avoids deep low-frequency notches (and their delay) during throttle chops.`,
+ impact: 'latency',
+ confidence: 'low',
+ ruleId: 'F-RPM-MIN-IDLE',
+ });
+ }
+ return;
+ }
+
+ // No dynamic-idle info — use the measured fundamental track (lowest tracked peak)
+ if (trackedPeaks.length === 0) return;
+ const fundamental = trackedPeaks[0];
+ if (fundamental.minHz < minHz - RPM_MIN_HZ_DEADZONE_HZ) {
+ const target = clamp(
+ Math.round(fundamental.minHz * RPM_MIN_HZ_TRACK_RATIO),
+ RPM_MIN_HZ_FLOOR,
+ RPM_MIN_HZ_CEILING
+ );
+ if (target >= minHz) return;
+ out.push({
+ setting: 'rpm_filter_min_hz',
+ currentValue: minHz,
+ recommendedValue: target,
+ reason:
+ `The measured motor fundamental reached down to ${Math.round(fundamental.minHz)} Hz in ` +
+ `this flight, below your rpm_filter_min_hz of ${minHz} — the notches could not follow ` +
+ `it there. Lowering the floor to ${target} keeps motor noise covered at low throttle.`,
+ impact: 'noise',
+ confidence: 'medium',
+ ruleId: 'F-RPM-MIN-TRACK',
+ evidence: {
+ measurements: [
+ { label: 'Lowest tracked fundamental', value: `${Math.round(fundamental.minHz)} Hz` },
+ { label: 'Current rpm_filter_min_hz', value: `${minHz} Hz` },
+ ],
+ trigger: 'Measured fundamental track reaches below the notch floor',
+ anchorFrequencyHz: fundamental.peak.frequency,
+ },
+ });
+ }
+}
+
+/**
+ * Rule F-RPM-HARM-UP: a measured track at ~k× the fundamental with k above the
+ * current harmonic count proves an unfiltered harmonic order — raise the count.
+ * Requires the residual peak to be strong enough to act on (≥ resonance
+ * threshold) and the frequency ratio to sit close to an integer.
+ */
+function recommendHarmonicCount(
+ current: CurrentFilterSettings,
+ trackedPeaks: TrackedPeak[],
+ out: FilterRecommendation[]
+): void {
+ const harmonics = current.rpm_filter_harmonics ?? 0;
+ if (harmonics <= 0 || harmonics >= RPM_HARMONICS_MAX) return;
+ if (trackedPeaks.length < 2) return;
+
+ const fundamental = trackedPeaks[0];
+ let bestOrder = 0;
+ let bestPeak: TrackedPeak | undefined;
+
+ for (const tracked of trackedPeaks.slice(1)) {
+ if (tracked.peak.amplitude < RESONANCE_ACTION_THRESHOLD_DB) continue;
+ const ratio = tracked.medianHz / fundamental.medianHz;
+ const order = Math.round(ratio);
+ if (Math.abs(ratio - order) > RPM_HARMONIC_RATIO_TOLERANCE) continue;
+ if (order <= harmonics || order > RPM_HARMONICS_MAX) continue;
+ if (order > bestOrder) {
+ bestOrder = order;
+ bestPeak = tracked;
+ }
+ }
+
+ if (bestOrder > 0 && bestPeak) {
+ out.push({
+ setting: 'rpm_filter_harmonics',
+ currentValue: harmonics,
+ recommendedValue: bestOrder,
+ reason:
+ `A throttle-tracking noise peak at ~${Math.round(bestPeak.medianHz)} Hz measures ` +
+ `${bestOrder}× your motor fundamental (~${Math.round(fundamental.medianHz)} Hz), but ` +
+ `rpm_filter_harmonics is ${harmonics} so that harmonic is unfiltered. Raising it to ` +
+ `${bestOrder} adds an exact notch on the measured harmonic.`,
+ impact: 'noise',
+ confidence: 'medium',
+ ruleId: 'F-RPM-HARM-UP',
+ evidence: {
+ measurements: [
+ { label: 'Fundamental track', value: `~${Math.round(fundamental.medianHz)} Hz` },
+ { label: 'Residual harmonic track', value: `~${Math.round(bestPeak.medianHz)} Hz` },
+ {
+ label: 'Frequency ratio',
+ value: `${(bestPeak.medianHz / fundamental.medianHz).toFixed(2)}× (order ${bestOrder})`,
+ },
+ ],
+ trigger: `Tracked peak at an integer multiple above the current harmonic count (${harmonics})`,
+ anchorFrequencyHz: bestPeak.peak.frequency,
+ },
+ });
+ }
+}
+
+/**
+ * Rule F-RPM-FADE: fade disabled (0) hard-stops the notches at min_hz.
+ * The BF default of 50 Hz fades them out gradually below the floor,
+ * smoothing the transition during throttle chops. Advisory only.
+ */
+function recommendFadeRange(current: CurrentFilterSettings, out: FilterRecommendation[]): void {
+ if (current.rpm_filter_fade_range_hz !== 0) return;
+ out.push({
+ setting: 'rpm_filter_fade_range_hz',
+ currentValue: 0,
+ recommendedValue: RPM_FADE_RANGE_DEFAULT_HZ,
+ reason:
+ 'RPM notch fade is disabled (rpm_filter_fade_range_hz = 0), so notches engage abruptly ' +
+ `at the ${current.rpm_filter_min_hz ?? 100} Hz floor. The Betaflight default of ` +
+ `${RPM_FADE_RANGE_DEFAULT_HZ} Hz fades notch depth out gradually below the floor, ` +
+ 'reducing filter-delay steps during throttle chops.',
+ impact: 'latency',
+ confidence: 'low',
+ informational: true,
+ ruleId: 'F-RPM-FADE',
+ });
+}
+
+/**
+ * Rule F-RPM-WEIGHTS (advisory, BF 4.5+): full-depth weights on all harmonics
+ * (the BF default 100,100,100) spend delay on the second harmonic, which
+ * carries less energy for most props. Community presets dim it. Only fires
+ * when the firmware reports rpm_filter_weights (proof of BF 4.5+ support).
+ */
+function recommendWeights(
+ current: CurrentFilterSettings,
+ droneSize: DroneSize | undefined,
+ out: FilterRecommendation[]
+): void {
+ const weights = current.rpm_filter_weights;
+ if (!weights || weights.length < 3 || !droneSize) return;
+ if (!weights.every((w) => w === 100)) return; // already customized
+
+ const target = RPM_FILTER_WEIGHTS_BY_SIZE[droneSize];
+ if (!target) return;
+
+ out.push({
+ setting: 'rpm_filter_weights',
+ currentValue: weights[1],
+ recommendedValue: target[1],
+ reason:
+ `Your RPM notch weights are all at full depth (100,100,100). For a ${droneSize} quad, ` +
+ `community presets use ${target.join(',')} — the second harmonic carries less energy ` +
+ 'for most props, so dimming its notch trades unneeded attenuation for less filter delay. ' +
+ `Set via CLI: set rpm_filter_weights = ${target.join(',')} (Betaflight 4.5+).`,
+ impact: 'latency',
+ confidence: 'low',
+ informational: true,
+ ruleId: 'F-RPM-WEIGHTS',
+ });
+}
diff --git a/src/main/analysis/StepResponseStacker.test.ts b/src/main/analysis/StepResponseStacker.test.ts
new file mode 100644
index 00000000..62069179
--- /dev/null
+++ b/src/main/analysis/StepResponseStacker.test.ts
@@ -0,0 +1,174 @@
+import { describe, it, expect } from 'vitest';
+import {
+ computeDeconvolvedStepResponse,
+ isGroupTrustworthy,
+ DECONV_COHERENCE_GATE,
+} from './StepResponseStacker';
+import { INPUT_SPLIT_THRESHOLD_DEG_S, DECONV_MIN_WINDOWS } from './constants';
+import type { BlackboxFlightData, TimeSeries } from '@shared/types/blackbox.types';
+
+const SAMPLE_RATE = 4000;
+
+/** Simulate a second-order system x'' + 2ζωx' + ω²x = ω²u */
+function secondOrderResponse(
+ setpoint: Float64Array,
+ sampleRate: number,
+ naturalFreqHz: number,
+ dampingRatio: number
+): Float64Array {
+ const gyro = new Float64Array(setpoint.length);
+ const wn = 2 * Math.PI * naturalFreqHz;
+ const dt = 1 / sampleRate;
+ let x = 0;
+ let xDot = 0;
+ for (let i = 0; i < setpoint.length; i++) {
+ const xDotDot = wn * wn * (setpoint[i] - x) - 2 * dampingRatio * wn * xDot;
+ xDot += xDotDot * dt;
+ x += xDot * dt;
+ gyro[i] = x;
+ }
+ return gyro;
+}
+
+/** Freestyle-like stick input mixing small (<500) and large (>=500) snaps */
+function mixedMagnitudeSetpoint(durationS: number): Float64Array {
+ const N = Math.floor(SAMPLE_RATE * durationS);
+ const signal = new Float64Array(N);
+ const steps = [
+ { startS: 0.5, magnitude: 200 },
+ { startS: 2.0, magnitude: -800 },
+ { startS: 3.5, magnitude: 300 },
+ { startS: 5.0, magnitude: 700 },
+ { startS: 6.5, magnitude: -250 },
+ { startS: 8.0, magnitude: -900 },
+ { startS: 9.5, magnitude: 150 },
+ { startS: 11.0, magnitude: 850 },
+ { startS: 12.5, magnitude: -300 },
+ { startS: 14.0, magnitude: 750 },
+ { startS: 15.5, magnitude: 220 },
+ { startS: 17.0, magnitude: -650 },
+ ];
+ let current = 0;
+ for (let i = 0; i < N; i++) {
+ const t = i / SAMPLE_RATE;
+ for (const step of steps) {
+ if (t >= step.startS && t < step.startS + 0.7) current = step.magnitude;
+ else if (t >= step.startS + 0.7 && t < step.startS + 0.75) current = 0;
+ }
+ signal[i] = current;
+ }
+ return signal;
+}
+
+function makeFlightData(setpoint: Float64Array, gyro: Float64Array): BlackboxFlightData {
+ const N = setpoint.length;
+ const time = new Float64Array(N).map((_, i) => i / SAMPLE_RATE);
+ const sp: TimeSeries = { time, values: setpoint };
+ const gy: TimeSeries = { time, values: gyro };
+ const zero: TimeSeries = { time, values: new Float64Array(N) };
+ return {
+ gyro: [gy, gy, gy],
+ setpoint: [sp, sp, sp, zero],
+ 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: N / SAMPLE_RATE,
+ frameCount: N,
+ };
+}
+
+describe('computeDeconvolvedStepResponse', () => {
+ it('recovers the overshoot of a known second-order plant', () => {
+ // ζ=0.45 → analytic overshoot exp(-πζ/√(1-ζ²)) ≈ 20.5%
+ const setpoint = mixedMagnitudeSetpoint(19);
+ const gyro = secondOrderResponse(setpoint, SAMPLE_RATE, 25, 0.45);
+ const result = computeDeconvolvedStepResponse(makeFlightData(setpoint, gyro));
+
+ const primary = result.roll.primary;
+ expect(primary).toBeDefined();
+ // Smoothed impulse + regularization soften the estimate — generous band
+ expect(primary!.overshootPercent).toBeGreaterThan(8);
+ expect(primary!.overshootPercent).toBeLessThan(35);
+ expect(primary!.coherenceMean!).toBeGreaterThan(0.8);
+ });
+
+ it('a well-damped plant shows near-zero overshoot', () => {
+ const setpoint = mixedMagnitudeSetpoint(19);
+ const gyro = secondOrderResponse(setpoint, SAMPLE_RATE, 25, 0.95);
+ const result = computeDeconvolvedStepResponse(makeFlightData(setpoint, gyro));
+
+ const primary = result.roll.primary;
+ expect(primary).toBeDefined();
+ expect(primary!.overshootPercent).toBeLessThan(8);
+ });
+
+ it('splits low and high input magnitudes', () => {
+ const setpoint = mixedMagnitudeSetpoint(19);
+ const gyro = secondOrderResponse(setpoint, SAMPLE_RATE, 25, 0.6);
+ const result = computeDeconvolvedStepResponse(makeFlightData(setpoint, gyro));
+
+ // The mixed input includes windows both below and above 500 deg/s
+ expect(result.splitThresholdDegS).toBe(INPUT_SPLIT_THRESHOLD_DEG_S);
+ expect(result.roll.low ?? result.roll.high).toBeDefined();
+ expect(result.roll.high).toBeDefined();
+ // High-magnitude group preferred as primary when trustworthy
+ if (result.roll.high && result.roll.high.coherenceMean !== undefined) {
+ expect(result.roll.primary).toEqual(result.roll.high);
+ }
+ });
+
+ it('withholds primary metrics when gyro is unrelated to setpoint (low coherence)', () => {
+ const setpoint = mixedMagnitudeSetpoint(19);
+ const gyro = new Float64Array(setpoint.length);
+ for (let i = 0; i < gyro.length; i++) {
+ const t = i / SAMPLE_RATE;
+ gyro[i] = 100 * Math.sin(2 * Math.PI * 3.7 * t) + 60 * Math.sin(2 * Math.PI * 9.1 * t + 1);
+ }
+ const result = computeDeconvolvedStepResponse(makeFlightData(setpoint, gyro));
+ expect(result.roll.primary).toBeUndefined();
+ });
+
+ it('returns empty axis results for very short signals', () => {
+ const setpoint = new Float64Array(32).fill(100);
+ const gyro = new Float64Array(32).fill(100);
+ const result = computeDeconvolvedStepResponse(makeFlightData(setpoint, gyro));
+ expect(result.roll.primary).toBeUndefined();
+ expect(result.roll.low).toBeUndefined();
+ expect(result.roll.high).toBeUndefined();
+ });
+});
+
+describe('isGroupTrustworthy', () => {
+ const base = {
+ overshootPercent: 10,
+ riseTimeMs: 30,
+ settlingTimeMs: 100,
+ windowCount: DECONV_MIN_WINDOWS,
+ coherenceMean: DECONV_COHERENCE_GATE,
+ };
+
+ it('accepts a coherent multi-window group', () => {
+ expect(isGroupTrustworthy(base)).toBe(true);
+ });
+
+ it('rejects single-window groups', () => {
+ expect(isGroupTrustworthy({ ...base, windowCount: 1 })).toBe(false);
+ });
+
+ it('rejects low-coherence groups', () => {
+ expect(isGroupTrustworthy({ ...base, coherenceMean: 0.3 })).toBe(false);
+ });
+
+ it('rejects groups without coherence', () => {
+ const { coherenceMean: _omit, ...noCoherence } = base;
+ expect(isGroupTrustworthy(noCoherence)).toBe(false);
+ });
+
+ it('rejects undefined', () => {
+ expect(isGroupTrustworthy(undefined)).toBe(false);
+ });
+});
diff --git a/src/main/analysis/StepResponseStacker.ts b/src/main/analysis/StepResponseStacker.ts
new file mode 100644
index 00000000..559d454a
--- /dev/null
+++ b/src/main/analysis/StepResponseStacker.ts
@@ -0,0 +1,153 @@
+/**
+ * Deconvolved (stacked) step response for PID Tune.
+ *
+ * Estimates the setpoint→gyro step response via Wiener deconvolution across
+ * the whole flight — the Plasmatree/PIDtoolbox method — instead of averaging
+ * individually measured steps. Deconvolution stacks information from every
+ * Welch window, so single noisy steps no longer distort the axis means, and
+ * the response is split by commanded input magnitude (<500 / >=500 deg/s)
+ * because Betaflight's feedforward and D-setpoint transition behave
+ * differently in the two regimes.
+ *
+ * The estimate is trusted for an axis only when its stick-band coherence
+ * clears TF_COHERENCE_GATE-equivalent gating and at least DECONV_MIN_WINDOWS
+ * Welch windows were stacked; otherwise the caller falls back to per-step
+ * means (the pre-existing behavior).
+ */
+import type { BlackboxFlightData } from '@shared/types/blackbox.types';
+import type { DeconvolvedStepMetrics } from '@shared/types/analysis.types';
+import {
+ estimateSplitTransferFunction,
+ computeSyntheticStepResponse,
+ extractMetrics,
+ trimBode,
+ type SplitTFGroup,
+} from './TransferFunctionEstimator';
+import { INPUT_SPLIT_THRESHOLD_DEG_S, DECONV_MIN_WINDOWS } from './constants';
+
+/** Minimum stick-band coherence for a deconvolved group to be trusted.
+ * Same rationale as TF_COHERENCE_GATE in PIDRecommender: low coherence means
+ * the response reflects noise/disturbance, not commanded motion. */
+export const DECONV_COHERENCE_GATE = 0.5;
+
+/** Maximum frequency of interest for the stacked response (Hz) */
+const DECONV_MAX_FREQ_HZ = 500;
+
+/** Synthetic-step duration for the stacked response (s). Longer than the
+ * Flash Tune default 0.2 s: normalizing the cumulative sum by a final value
+ * taken mid-ring (before the response settles) systematically underestimates
+ * overshoot. 0.5 s matches the per-step adaptive window maximum. */
+const DECONV_STEP_DURATION_S = 0.5;
+
+/** Downsample target for the stored step-response curve (chart rendering) */
+const STEP_CURVE_MAX_POINTS = 100;
+
+export interface AxisDeconvolvedResponse {
+ /** Headline metrics — from the high-magnitude group when trustworthy
+ * (large inputs exercise the P/D response the tuner cares about),
+ * otherwise from the low group. */
+ primary?: DeconvolvedStepMetrics;
+ low?: DeconvolvedStepMetrics;
+ high?: DeconvolvedStepMetrics;
+}
+
+export interface DeconvolvedStepResult {
+ roll: AxisDeconvolvedResponse;
+ pitch: AxisDeconvolvedResponse;
+ yaw: AxisDeconvolvedResponse;
+ splitThresholdDegS: number;
+}
+
+function downsampleCurve(curve: { timeMs: number[]; response: number[] }): {
+ timeMs: number[];
+ response: number[];
+} {
+ const n = curve.timeMs.length;
+ if (n <= STEP_CURVE_MAX_POINTS) return curve;
+ const stride = Math.ceil(n / STEP_CURVE_MAX_POINTS);
+ const timeMs: number[] = [];
+ const response: number[] = [];
+ for (let i = 0; i < n; i += stride) {
+ timeMs.push(Math.round(curve.timeMs[i] * 100) / 100);
+ response.push(Math.round(curve.response[i] * 10000) / 10000);
+ }
+ return { timeMs, response };
+}
+
+function groupToMetrics(group: SplitTFGroup, sampleRateHz: number): DeconvolvedStepMetrics {
+ const trimmed = trimBode(group.bode, DECONV_MAX_FREQ_HZ);
+ const synStep = computeSyntheticStepResponse(
+ group.impulseResponse,
+ sampleRateHz,
+ DECONV_STEP_DURATION_S
+ );
+ const metrics = extractMetrics(trimmed, synStep, sampleRateHz);
+ return {
+ overshootPercent: metrics.overshootPercent,
+ riseTimeMs: metrics.riseTimeMs,
+ settlingTimeMs: metrics.settlingTimeMs,
+ windowCount: group.windowCount,
+ ...(group.coherenceMean !== undefined ? { coherenceMean: group.coherenceMean } : {}),
+ stepResponse: downsampleCurve(synStep),
+ };
+}
+
+/** True when a deconvolved group is trustworthy enough to drive metrics */
+export function isGroupTrustworthy(
+ m: DeconvolvedStepMetrics | undefined
+): m is DeconvolvedStepMetrics {
+ return (
+ m !== undefined &&
+ m.windowCount >= DECONV_MIN_WINDOWS &&
+ m.coherenceMean !== undefined &&
+ m.coherenceMean >= DECONV_COHERENCE_GATE
+ );
+}
+
+/**
+ * Compute the magnitude-split deconvolved step response for all three axes.
+ * Returns per-axis low/high metrics plus the primary (trusted) pick.
+ */
+export function computeDeconvolvedStepResponse(
+ flightData: BlackboxFlightData,
+ splitThresholdDegS: number = INPUT_SPLIT_THRESHOLD_DEG_S
+): DeconvolvedStepResult {
+ const axes = ['roll', 'pitch', 'yaw'] as const;
+ const result: Partial> = {};
+
+ for (let axisIdx = 0; axisIdx < 3; axisIdx++) {
+ const axis = axes[axisIdx];
+ let axisResult: AxisDeconvolvedResponse = {};
+ try {
+ const split = estimateSplitTransferFunction(
+ flightData.setpoint[axisIdx].values,
+ flightData.gyro[axisIdx].values,
+ flightData.sampleRateHz,
+ splitThresholdDegS
+ );
+ const low = split.low ? groupToMetrics(split.low, flightData.sampleRateHz) : undefined;
+ const high = split.high ? groupToMetrics(split.high, flightData.sampleRateHz) : undefined;
+
+ // Prefer the high-magnitude response (exercises P/D where tuning
+ // matters), fall back to low; only trustworthy groups qualify.
+ const primary = isGroupTrustworthy(high) ? high : isGroupTrustworthy(low) ? low : undefined;
+
+ axisResult = {
+ ...(low ? { low } : {}),
+ ...(high ? { high } : {}),
+ ...(primary ? { primary } : {}),
+ };
+ } catch {
+ // Signal too short for deconvolution — axis stays empty (caller falls
+ // back to per-step metrics)
+ }
+ result[axis] = axisResult;
+ }
+
+ return {
+ roll: result.roll!,
+ pitch: result.pitch!,
+ yaw: result.yaw!,
+ splitThresholdDegS,
+ };
+}
diff --git a/src/main/analysis/SystemIdentifier.test.ts b/src/main/analysis/SystemIdentifier.test.ts
new file mode 100644
index 00000000..9be6f01d
--- /dev/null
+++ b/src/main/analysis/SystemIdentifier.test.ts
@@ -0,0 +1,154 @@
+import { describe, it, expect } from 'vitest';
+import {
+ identifyPlant,
+ predictResponse,
+ computeWhatIf,
+ pidResponse,
+ BF_PTERM_SCALE,
+ type PIDGains,
+ type PlantModel,
+} from './SystemIdentifier';
+import type { BodeResult } from './TransferFunctionEstimator';
+
+/** Analytic closed-loop bode for a known 2nd-order + delay plant and BF PID */
+function makeClosedLoopBode(
+ plant: { gainK: number; naturalFreqHz: number; damping: number; delayMs: number },
+ pids: PIDGains,
+ opts: { coherence?: number; maxHz?: number } = {}
+): BodeResult {
+ const maxHz = opts.maxHz ?? 100;
+ const df = 0.5;
+ const n = Math.floor(maxHz / df) + 1;
+ const frequencies = new Float64Array(n);
+ const magnitude = new Float64Array(n);
+ const phase = new Float64Array(n);
+ const coherence = new Float64Array(n).fill(opts.coherence ?? 0.95);
+ const weights = new Float64Array(n).fill(1);
+
+ const wn = 2 * Math.PI * plant.naturalFreqHz;
+ for (let i = 0; i < n; i++) {
+ const f = Math.max(i * df, 1e-3);
+ frequencies[i] = i * df;
+ const w = 2 * Math.PI * f;
+
+ // Plant P(jw) = K e^{-jwτ} / (1 - r² + j2ζr)
+ const r = w / wn;
+ const dRe = 1 - r * r;
+ const dIm = 2 * plant.damping * r;
+ const dAbs2 = dRe * dRe + dIm * dIm;
+ let pRe = (plant.gainK * dRe) / dAbs2;
+ let pIm = (-plant.gainK * dIm) / dAbs2;
+ const th = -w * (plant.delayMs / 1000);
+ const c = Math.cos(th);
+ const s = Math.sin(th);
+ [pRe, pIm] = [pRe * c - pIm * s, pRe * s + pIm * c];
+
+ // Controller
+ const C = pidResponse(pids, w);
+
+ // CP and T = CP/(1+CP)
+ const cpRe = C.re * pRe - C.im * pIm;
+ const cpIm = C.re * pIm + C.im * pRe;
+ const denRe = 1 + cpRe;
+ const denIm = cpIm;
+ const dd = denRe * denRe + denIm * denIm;
+ const tRe = (cpRe * denRe + cpIm * denIm) / dd;
+ const tIm = (cpIm * denRe - cpRe * denIm) / dd;
+
+ const mag = Math.sqrt(tRe * tRe + tIm * tIm);
+ magnitude[i] = mag > 1e-12 ? 20 * Math.log10(mag) : -240;
+ phase[i] = (Math.atan2(tIm, tRe) * 180) / Math.PI;
+ }
+
+ return { frequencies, magnitude, phase, coherence, coherenceWeights: weights };
+}
+
+const KNOWN_PLANT = { gainK: 8, naturalFreqHz: 30, damping: 0.7, delayMs: 5 };
+const FLIGHT_PIDS: PIDGains = { P: 45, I: 80, D: 40 };
+
+describe('identifyPlant (P3.2)', () => {
+ it('recovers a known 2nd-order plant from its closed-loop response', () => {
+ const bode = makeClosedLoopBode(KNOWN_PLANT, FLIGHT_PIDS);
+ const model = identifyPlant(bode, FLIGHT_PIDS);
+ expect(model).not.toBeNull();
+ expect(model!.fitQuality).toBeGreaterThan(0.8);
+ // Natural frequency within 20%, damping within ±0.2, delay within ±3 ms
+ expect(model!.naturalFreqHz).toBeGreaterThan(KNOWN_PLANT.naturalFreqHz * 0.8);
+ expect(model!.naturalFreqHz).toBeLessThan(KNOWN_PLANT.naturalFreqHz * 1.2);
+ expect(Math.abs(model!.damping - KNOWN_PLANT.damping)).toBeLessThan(0.2);
+ expect(Math.abs(model!.delayMs - KNOWN_PLANT.delayMs)).toBeLessThan(3);
+ });
+
+ it('returns null when coherence is below the gate', () => {
+ const bode = makeClosedLoopBode(KNOWN_PLANT, FLIGHT_PIDS, { coherence: 0.3 });
+ expect(identifyPlant(bode, FLIGHT_PIDS)).toBeNull();
+ });
+
+ it('returns null when too few usable bins exist', () => {
+ const bode = makeClosedLoopBode(KNOWN_PLANT, FLIGHT_PIDS, { maxHz: 3 });
+ expect(identifyPlant(bode, FLIGHT_PIDS)).toBeNull();
+ });
+});
+
+describe('predictResponse (P3.2)', () => {
+ const model: PlantModel = { ...KNOWN_PLANT, fitQuality: 0.95 };
+
+ it('predicts a settled unity step response for a sane closed loop', () => {
+ const pred = predictResponse(model, FLIGHT_PIDS, 2000);
+ expect(pred.response.response.length).toBeGreaterThan(10);
+ // Step response should settle near 1.0 (closed loop with integrator)
+ const tail = pred.response.response.slice(-20);
+ const tailMean = tail.reduce((a, b) => a + b, 0) / tail.length;
+ expect(tailMean).toBeGreaterThan(0.85);
+ expect(tailMean).toBeLessThan(1.15);
+ expect(pred.metrics.riseTimeMs).toBeGreaterThan(0);
+ });
+
+ it('predicts less overshoot when D is raised', () => {
+ // Lightly damped plant + hot P so the low-D loop visibly overshoots
+ const oscillatory: PlantModel = {
+ gainK: 8,
+ naturalFreqHz: 25,
+ damping: 0.25,
+ delayMs: 8,
+ fitQuality: 0.95,
+ };
+ const lowD = predictResponse(oscillatory, { P: 80, I: 80, D: 5 }, 2000);
+ const highD = predictResponse(oscillatory, { P: 80, I: 80, D: 60 }, 2000);
+ expect(lowD.metrics.overshootPercent).toBeGreaterThan(5);
+ expect(highD.metrics.overshootPercent).toBeLessThan(lowD.metrics.overshootPercent);
+ });
+
+ it('predicts faster rise when P is raised', () => {
+ const lowP = predictResponse(model, { P: 30, I: 80, D: 40 }, 2000);
+ const highP = predictResponse(model, { P: 70, I: 80, D: 40 }, 2000);
+ expect(highP.metrics.riseTimeMs).toBeLessThanOrEqual(lowP.metrics.riseTimeMs);
+ });
+});
+
+describe('computeWhatIf (P3.2)', () => {
+ it('produces current + proposed predictions from a measured loop', () => {
+ const bode = makeClosedLoopBode(KNOWN_PLANT, FLIGHT_PIDS);
+ const proposed: PIDGains = { P: 50, I: 80, D: 48 };
+ const whatIf = computeWhatIf(bode, FLIGHT_PIDS, proposed, 2000);
+ expect(whatIf).not.toBeNull();
+ expect(whatIf!.current.pids).toEqual(FLIGHT_PIDS);
+ expect(whatIf!.proposed.pids).toEqual(proposed);
+ // The current-gains prediction should roughly reproduce the measured
+ // response character (same plant, same gains)
+ expect(whatIf!.current.metrics.overshootPercent).toBeGreaterThanOrEqual(0);
+ });
+
+ it('is gated on identification quality', () => {
+ const bode = makeClosedLoopBode(KNOWN_PLANT, FLIGHT_PIDS, { coherence: 0.2 });
+ expect(computeWhatIf(bode, FLIGHT_PIDS, { P: 50, I: 80, D: 48 }, 2000)).toBeNull();
+ });
+});
+
+describe('pidResponse', () => {
+ it('matches the BF P-term scale at DC-ish frequencies with I=D=0', () => {
+ const c = pidResponse({ P: 100, I: 0, D: 0 }, 10);
+ expect(c.re).toBeCloseTo(100 * BF_PTERM_SCALE, 6);
+ expect(c.im).toBeCloseTo(0, 6);
+ });
+});
diff --git a/src/main/analysis/SystemIdentifier.ts b/src/main/analysis/SystemIdentifier.ts
new file mode 100644
index 00000000..f40472d6
--- /dev/null
+++ b/src/main/analysis/SystemIdentifier.ts
@@ -0,0 +1,357 @@
+/**
+ * System identification + what-if simulation (P3.2).
+ *
+ * Fits a low-order plant model (2nd order + transport delay) to the measured
+ * closed-loop transfer function by dividing out the known PID controller,
+ * then re-closes the loop with proposed gains to PREDICT the step response
+ * before anything is applied to the quad.
+ *
+ * Method:
+ * 1. Measured closed loop T(jω) (Wiener estimate, complex per bin)
+ * 2. Open loop L = T / (1 − T)
+ * 3. Plant estimate P̂ = L / C, with C(jω) the Betaflight PID in physical
+ * units (P·0.032029 + I·0.244381/jω + D·0.000529·jω — firmware scales)
+ * 4. Coherence-weighted least-squares fit of
+ * P(jω) = K·e^(−jωτ) / (1 + 2ζ(jω/ωn) + (jω/ωn)²)
+ * over the stick band (grid search + refinement; K analytic per candidate)
+ * 5. What-if: T′ = C′P/(1 + C′P) with proposed gains → impulse (IFFT) →
+ * synthetic step response + metrics
+ *
+ * Everything is gated on measurement coherence and fit quality, and results
+ * are always labeled as predictions.
+ */
+import FFT from 'fft.js';
+import type { BodeResult, TransferFunctionMetrics } from './TransferFunctionEstimator';
+import {
+ computeSyntheticStepResponse,
+ extractMetrics,
+ trimBode,
+ type SyntheticStepResponse,
+} from './TransferFunctionEstimator';
+
+/** Betaflight firmware PID scale factors (pid.c) — map integer gains to physical units */
+export const BF_PTERM_SCALE = 0.032029;
+export const BF_ITERM_SCALE = 0.244381;
+export const BF_DTERM_SCALE = 0.000529;
+
+/** Frequency band used for plant fitting (stick-input energy lives here) */
+export const SYSID_FIT_MIN_HZ = 2;
+export const SYSID_FIT_MAX_HZ = 60;
+/** Bins below this coherence are excluded from the fit */
+export const SYSID_MIN_BIN_COHERENCE = 0.4;
+/** Gates: mean coherence over the fit band and fit quality (1 − relative residual) */
+export const SYSID_COHERENCE_GATE = 0.5;
+export const SYSID_FIT_QUALITY_GATE = 0.6;
+/** Minimum usable bins for a meaningful fit */
+export const SYSID_MIN_BINS = 8;
+
+/** Per-axis integer PID gains as configured in Betaflight */
+export interface PIDGains {
+ P: number;
+ I: number;
+ D: number;
+}
+
+/** Identified plant model: 2nd order + transport delay */
+export interface PlantModel {
+ /** DC gain (physical units) */
+ gainK: number;
+ /** Natural frequency in Hz */
+ naturalFreqHz: number;
+ /** Damping ratio ζ */
+ damping: number;
+ /** Transport delay in ms */
+ delayMs: number;
+ /** 1 − coherence-weighted relative residual of the fit (0-1, higher = better) */
+ fitQuality: number;
+}
+
+/** Predicted closed-loop behavior for a set of gains */
+export interface PredictedResponse {
+ pids: PIDGains;
+ response: SyntheticStepResponse;
+ metrics: TransferFunctionMetrics;
+}
+
+/** Per-axis what-if result */
+export interface AxisWhatIf {
+ plant: PlantModel;
+ /** Prediction with the CURRENT flight gains (sanity anchor vs measured) */
+ current: PredictedResponse;
+ /** Prediction with the PROPOSED gains */
+ proposed: PredictedResponse;
+}
+
+/** Complex helpers (interleaved re/im pairs avoided — plain object math) */
+interface Complex {
+ re: number;
+ im: number;
+}
+
+function cDiv(a: Complex, b: Complex): Complex {
+ const d = b.re * b.re + b.im * b.im;
+ return { re: (a.re * b.re + a.im * b.im) / d, im: (a.im * b.re - a.re * b.im) / d };
+}
+
+function cMul(a: Complex, b: Complex): Complex {
+ return { re: a.re * b.re - a.im * b.im, im: a.re * b.im + a.im * b.re };
+}
+
+function cAbs2(a: Complex): number {
+ return a.re * a.re + a.im * a.im;
+}
+
+/** Betaflight PID controller frequency response C(jω) in physical units */
+export function pidResponse(pids: PIDGains, omega: number): Complex {
+ const kp = pids.P * BF_PTERM_SCALE;
+ const ki = pids.I * BF_ITERM_SCALE;
+ const kd = pids.D * BF_DTERM_SCALE;
+ // C(jω) = kp + ki/(jω) + kd·jω = kp + j(kd·ω − ki/ω)
+ return { re: kp, im: kd * omega - (omega > 1e-9 ? ki / omega : 0) };
+}
+
+/** 2nd-order + delay plant frequency response at ω */
+function plantResponse(model: Omit, omega: number): Complex {
+ const wn = 2 * Math.PI * model.naturalFreqHz;
+ const r = omega / wn;
+ // Denominator: 1 − r² + j·2ζr
+ const denom: Complex = { re: 1 - r * r, im: 2 * model.damping * r };
+ const base = cDiv({ re: model.gainK, im: 0 }, denom);
+ const theta = -omega * (model.delayMs / 1000);
+ return cMul(base, { re: Math.cos(theta), im: Math.sin(theta) });
+}
+
+/** Closed loop T = CP/(1+CP) at ω */
+function closedLoopResponse(
+ model: Omit,
+ pids: PIDGains,
+ omega: number
+): Complex {
+ const cp = cMul(pidResponse(pids, omega), plantResponse(model, omega));
+ return cDiv(cp, { re: 1 + cp.re, im: cp.im });
+}
+
+/** Extracted per-bin plant estimate with its fit weight */
+interface PlantBin {
+ omega: number;
+ plant: Complex;
+ weight: number;
+}
+
+/**
+ * Divide the known controller out of the measured closed loop to get
+ * nonparametric plant estimates per bin. Bins where the loop inversion is
+ * ill-conditioned (|1−T| small) or coherence is low are dropped.
+ */
+function estimatePlantBins(bode: BodeResult, pids: PIDGains): PlantBin[] {
+ const bins: PlantBin[] = [];
+ for (let i = 0; i < bode.frequencies.length; i++) {
+ const f = bode.frequencies[i];
+ if (f < SYSID_FIT_MIN_HZ || f > SYSID_FIT_MAX_HZ) continue;
+ const gamma = bode.coherence?.[i] ?? 1;
+ if (gamma < SYSID_MIN_BIN_COHERENCE) continue;
+
+ const mag = Math.pow(10, bode.magnitude[i] / 20);
+ const ph = (bode.phase[i] * Math.PI) / 180;
+ const T: Complex = { re: mag * Math.cos(ph), im: mag * Math.sin(ph) };
+
+ const oneMinusT: Complex = { re: 1 - T.re, im: -T.im };
+ if (Math.sqrt(cAbs2(oneMinusT)) < 0.05) continue; // near-unity loop — inversion blows up
+
+ const omega = 2 * Math.PI * f;
+ const C = pidResponse(pids, omega);
+ if (Math.sqrt(cAbs2(C)) < 1e-6) continue;
+
+ const L = cDiv(T, oneMinusT);
+ const plant = cDiv(L, C);
+ // Weight: coherence × input energy (when available)
+ const energy = bode.coherenceWeights?.[i] ?? 1;
+ bins.push({ omega, plant, weight: gamma * gamma * energy });
+ }
+
+ // Normalize weights to a sane scale
+ const maxW = Math.max(...bins.map((b) => b.weight), 1e-12);
+ for (const b of bins) b.weight /= maxW;
+ return bins;
+}
+
+/** Weighted relative residual of a candidate model against the plant bins.
+ * K is solved analytically per candidate (linear in the model). */
+function fitCandidate(
+ bins: PlantBin[],
+ naturalFreqHz: number,
+ damping: number,
+ delayMs: number
+): { gainK: number; cost: number } {
+ // M(jω) = model response with K = 1
+ let num = 0; // Σ w·Re(P̂·conj(M))
+ let den = 0; // Σ w·|M|²
+ const models: Complex[] = [];
+ for (const b of bins) {
+ const m = plantResponse({ gainK: 1, naturalFreqHz, damping, delayMs }, b.omega);
+ models.push(m);
+ num += b.weight * (b.plant.re * m.re + b.plant.im * m.im);
+ den += b.weight * cAbs2(m);
+ }
+ const gainK = den > 1e-12 ? num / den : 0;
+ if (gainK <= 0) return { gainK: 0, cost: Infinity };
+
+ let residual = 0;
+ let total = 0;
+ for (let i = 0; i < bins.length; i++) {
+ const b = bins[i];
+ const err: Complex = {
+ re: b.plant.re - gainK * models[i].re,
+ im: b.plant.im - gainK * models[i].im,
+ };
+ residual += b.weight * cAbs2(err);
+ total += b.weight * cAbs2(b.plant);
+ }
+ return { gainK, cost: total > 1e-12 ? residual / total : Infinity };
+}
+
+/**
+ * Identify a 2nd-order + delay plant model from the measured closed loop.
+ * Returns null when the measurement can't support a trustworthy fit
+ * (insufficient coherent bins, or the fit residual is too large).
+ */
+export function identifyPlant(bode: BodeResult, pids: PIDGains): PlantModel | null {
+ const bins = estimatePlantBins(bode, pids);
+ if (bins.length < SYSID_MIN_BINS) return null;
+
+ // Coherence gate over the fit band
+ if (bode.coherence) {
+ let sum = 0;
+ let n = 0;
+ for (let i = 0; i < bode.frequencies.length; i++) {
+ const f = bode.frequencies[i];
+ if (f < SYSID_FIT_MIN_HZ || f > SYSID_FIT_MAX_HZ) continue;
+ sum += bode.coherence[i];
+ n++;
+ }
+ if (n > 0 && sum / n < SYSID_COHERENCE_GATE) return null;
+ }
+
+ // Coarse grid search
+ const wnGrid: number[] = [];
+ for (let f = 5; f <= 120; f *= 1.25) wnGrid.push(f);
+ const zetaGrid = [0.3, 0.45, 0.6, 0.8, 1.0, 1.3, 1.7, 2.2];
+ const tauGrid = [0, 2, 4, 6, 8, 12, 16, 20];
+
+ let best = { wn: 30, zeta: 0.8, tau: 4, gainK: 0, cost: Infinity };
+ for (const wn of wnGrid) {
+ for (const zeta of zetaGrid) {
+ for (const tau of tauGrid) {
+ const { gainK, cost } = fitCandidate(bins, wn, zeta, tau);
+ if (cost < best.cost) best = { wn, zeta, tau, gainK, cost };
+ }
+ }
+ }
+
+ // Local refinement (two passes, halving the step each time)
+ let steps = { wn: best.wn * 0.12, zeta: 0.08, tau: 1 };
+ for (let pass = 0; pass < 2; pass++) {
+ for (const wn of [best.wn - steps.wn, best.wn, best.wn + steps.wn]) {
+ for (const zeta of [best.zeta - steps.zeta, best.zeta, best.zeta + steps.zeta]) {
+ for (const tau of [best.tau - steps.tau, best.tau, best.tau + steps.tau]) {
+ if (wn < 2 || zeta < 0.1 || tau < 0) continue;
+ const { gainK, cost } = fitCandidate(bins, wn, zeta, tau);
+ if (cost < best.cost) best = { wn, zeta, tau, gainK, cost };
+ }
+ }
+ }
+ steps = { wn: steps.wn / 2, zeta: steps.zeta / 2, tau: steps.tau / 2 };
+ }
+
+ const fitQuality = Math.max(0, 1 - Math.sqrt(best.cost));
+ if (fitQuality < SYSID_FIT_QUALITY_GATE) return null;
+
+ return {
+ gainK: best.gainK,
+ naturalFreqHz: Math.round(best.wn * 10) / 10,
+ damping: Math.round(best.zeta * 100) / 100,
+ delayMs: Math.round(best.tau * 10) / 10,
+ fitQuality: Math.round(fitQuality * 100) / 100,
+ };
+}
+
+/** Frequency-grid size for analytic prediction (power of 2 for the IFFT) */
+const PREDICT_WINDOW_SIZE = 4096;
+
+/**
+ * Predict the closed-loop step response and metrics for a set of gains,
+ * by re-closing the identified plant with the new controller analytically.
+ */
+export function predictResponse(
+ plant: PlantModel,
+ pids: PIDGains,
+ sampleRateHz: number
+): PredictedResponse {
+ const windowSize = PREDICT_WINDOW_SIZE;
+ const numBins = windowSize / 2 + 1;
+ const freqResolution = sampleRateHz / windowSize;
+
+ const frequencies = new Float64Array(numBins);
+ const magnitude = new Float64Array(numBins);
+ const phase = new Float64Array(numBins);
+ const hRe = new Float64Array(numBins);
+ const hIm = new Float64Array(numBins);
+
+ for (let i = 0; i < numBins; i++) {
+ const f = i * freqResolution;
+ frequencies[i] = f;
+ const T =
+ i === 0
+ ? closedLoopResponse(plant, pids, 1e-6)
+ : closedLoopResponse(plant, pids, 2 * Math.PI * f);
+ hRe[i] = T.re;
+ hIm[i] = T.im;
+ const mag = Math.sqrt(cAbs2(T));
+ magnitude[i] = mag > 1e-12 ? 20 * Math.log10(mag) : -240;
+ phase[i] = (Math.atan2(T.im, T.re) * 180) / Math.PI;
+ }
+
+ // Impulse response via IFFT (conjugate-symmetric spectrum → real signal)
+ const fft = new FFT(windowSize);
+ const complexH = fft.createComplexArray();
+ for (let i = 0; i < numBins; i++) {
+ complexH[2 * i] = hRe[i];
+ complexH[2 * i + 1] = hIm[i];
+ }
+ for (let i = 1; i < windowSize / 2; i++) {
+ const mirror = windowSize - i;
+ complexH[2 * mirror] = hRe[i];
+ complexH[2 * mirror + 1] = -hIm[i];
+ }
+ const timeDomain = fft.createComplexArray();
+ fft.inverseTransform(timeDomain, complexH);
+ const impulse = new Float64Array(windowSize);
+ for (let i = 0; i < windowSize; i++) impulse[i] = timeDomain[2 * i];
+
+ const response = computeSyntheticStepResponse(impulse, sampleRateHz);
+ const bode: BodeResult = { frequencies, magnitude, phase };
+ const metrics = extractMetrics(trimBode(bode, 500), response, sampleRateHz);
+
+ return { pids, response, metrics };
+}
+
+/**
+ * Full what-if computation for one axis: identify the plant from the measured
+ * closed loop with the flight gains, then predict responses for both the
+ * current and the proposed gains. Null when gated (low coherence / poor fit).
+ */
+export function computeWhatIf(
+ bode: BodeResult,
+ currentPids: PIDGains,
+ proposedPids: PIDGains,
+ sampleRateHz: number
+): AxisWhatIf | null {
+ const plant = identifyPlant(bode, currentPids);
+ if (!plant) return null;
+
+ return {
+ plant,
+ current: predictResponse(plant, currentPids, sampleRateHz),
+ proposed: predictResponse(plant, proposedPids, sampleRateHz),
+ };
+}
diff --git a/src/main/analysis/ThrottleTFAnalyzer.test.ts b/src/main/analysis/ThrottleTFAnalyzer.test.ts
index 3e132ee9..861715e1 100644
--- a/src/main/analysis/ThrottleTFAnalyzer.test.ts
+++ b/src/main/analysis/ThrottleTFAnalyzer.test.ts
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest';
-import { analyzeThrottleTF, DEFAULT_TF_BANDS, MIN_TF_SAMPLES } from './ThrottleTFAnalyzer';
+import {
+ analyzeThrottleTF,
+ recommendTPAFromThrottleTF,
+ DEFAULT_TF_BANDS,
+ MIN_TF_SAMPLES,
+} from './ThrottleTFAnalyzer';
import type { BlackboxFlightData, TimeSeries } from '@shared/types/blackbox.types';
function makeTimeSeries(values: Float64Array): TimeSeries {
@@ -193,3 +198,118 @@ describe('analyzeThrottleTF', () => {
}
});
});
+
+describe('recommendTPAFromThrottleTF (P2.8)', () => {
+ function band(
+ throttleMin: number,
+ overshootPercent: number | null
+ ): import('./ThrottleTFAnalyzer').ThrottleTFBand {
+ return {
+ throttleMin,
+ throttleMax: throttleMin + 0.2,
+ sampleCount: 4096,
+ metrics:
+ overshootPercent === null
+ ? null
+ : {
+ bandwidthHz: 40,
+ phaseMarginDeg: 60,
+ gainMarginDb: 10,
+ overshootPercent,
+ settlingTimeMs: 100,
+ riseTimeMs: 50,
+ dcGainDb: 0,
+ },
+ };
+ }
+
+ function result(
+ rollOvershoots: (number | null)[],
+ pitchOvershoots?: (number | null)[]
+ ): import('./ThrottleTFAnalyzer').ThrottleTFResult {
+ const mk = (overshoots: (number | null)[]) => ({
+ bands: overshoots.map((o, i) => band(i * 0.2, o)),
+ bandsWithData: overshoots.filter((o) => o !== null).length,
+ metricsVariance: { bandwidthHz: 0, overshootPercent: 0, phaseMarginDeg: 0 },
+ });
+ const roll = mk(rollOvershoots);
+ return {
+ ...roll,
+ ...(pitchOvershoots ? { pitch: mk(pitchOvershoots) } : {}),
+ };
+ }
+
+ it('raises tpa_rate when overshoot grows with throttle', () => {
+ const recs = recommendTPAFromThrottleTF(result([5, 8, 12, 20, 30]), {
+ active: true,
+ rate: 65,
+ breakpoint: 1350,
+ });
+ const rateRec = recs.find((r) => r.ruleId === 'TPA-TF-RATE-UP');
+ expect(rateRec).toBeDefined();
+ expect(rateRec!.setting).toBe('tpa_rate');
+ expect(rateRec!.recommendedValue).toBe(75);
+ expect(rateRec!.confidence).toBe('medium');
+ });
+
+ it('lowers the breakpoint to where the oscillation starts', () => {
+ // Overshoot exceeds low mean (+10) from the 60% band; breakpoint is 1750
+ const recs = recommendTPAFromThrottleTF(result([5, 8, 12, 25, 35]), {
+ active: true,
+ rate: 65,
+ breakpoint: 1750,
+ });
+ const bpRec = recs.find((r) => r.ruleId === 'TPA-TF-BREAKPOINT');
+ expect(bpRec).toBeDefined();
+ // Onset band starts at 0.6 → 1600 µs
+ expect(bpRec!.recommendedValue).toBe(1600);
+ });
+
+ it('lowers tpa_rate when high throttle is overdamped', () => {
+ const recs = recommendTPAFromThrottleTF(result([18, 15, 8, 2, 1]), {
+ active: true,
+ rate: 65,
+ breakpoint: 1350,
+ });
+ const rateRec = recs.find((r) => r.ruleId === 'TPA-TF-RATE-DOWN');
+ expect(rateRec).toBeDefined();
+ expect(rateRec!.recommendedValue).toBe(55);
+ });
+
+ it('stays silent when the trend is flat', () => {
+ const recs = recommendTPAFromThrottleTF(result([10, 11, 12, 10, 11]), {
+ active: true,
+ rate: 65,
+ breakpoint: 1350,
+ });
+ expect(recs).toHaveLength(0);
+ });
+
+ it('requires tpa_rate from headers and enough bands', () => {
+ expect(recommendTPAFromThrottleTF(result([5, 10, 30, 40, 45]), undefined)).toHaveLength(0);
+ expect(recommendTPAFromThrottleTF(result([5, 10, 30, 40, 45]), { active: true })).toHaveLength(
+ 0
+ );
+ // Only 2 bands with data
+ expect(
+ recommendTPAFromThrottleTF(result([5, null, null, null, 40]), { active: true, rate: 65 })
+ ).toHaveLength(0);
+ });
+
+ it('uses the worse of roll and pitch as the driving axis', () => {
+ // Roll flat, pitch grows strongly → pitch drives the recommendation
+ const recs = recommendTPAFromThrottleTF(result([10, 10, 11, 10, 11], [5, 8, 15, 25, 35]), {
+ active: true,
+ rate: 65,
+ breakpoint: 1350,
+ });
+ const rateRec = recs.find((r) => r.ruleId === 'TPA-TF-RATE-UP');
+ expect(rateRec).toBeDefined();
+ expect(rateRec!.reason).toContain('pitch');
+ });
+
+ it('caps tpa_rate at the maximum bound', () => {
+ const recs = recommendTPAFromThrottleTF(result([5, 8, 12, 20, 30]), { active: true, rate: 80 });
+ expect(recs.find((r) => r.ruleId === 'TPA-TF-RATE-UP')).toBeUndefined();
+ });
+});
diff --git a/src/main/analysis/ThrottleTFAnalyzer.ts b/src/main/analysis/ThrottleTFAnalyzer.ts
index f0761dc4..c28850ef 100644
--- a/src/main/analysis/ThrottleTFAnalyzer.ts
+++ b/src/main/analysis/ThrottleTFAnalyzer.ts
@@ -8,6 +8,7 @@
*/
import type { BlackboxFlightData } from '@shared/types/blackbox.types';
+import type { PIDRecommendation } from '@shared/types/analysis.types';
import { binByThrottle, findContiguousRuns } from './ThrottleSpectrogramAnalyzer';
import {
estimateTransferFunction,
@@ -16,6 +17,18 @@ import {
trimBode,
} from './TransferFunctionEstimator';
import type { TransferFunctionMetrics } from './TransferFunctionEstimator';
+import type { TPAContext } from './PIDRecommender';
+import {
+ TPA_TF_OVERSHOOT_DELTA_PP,
+ TPA_TF_OVERDAMPED_OVERSHOOT_PCT,
+ TPA_TF_RATE_STEP,
+ TPA_TF_RATE_MIN,
+ TPA_TF_RATE_MAX,
+ TPA_TF_MIN_BANDS,
+ TPA_TF_BREAKPOINT_MIN,
+ TPA_TF_BREAKPOINT_MAX,
+ TPA_TF_BREAKPOINT_DEADZONE,
+} from './constants';
/** Default number of throttle bands for TF analysis */
export const DEFAULT_TF_BANDS = 5;
@@ -44,18 +57,31 @@ export interface ThrottleTFBand {
metrics: TransferFunctionMetrics | null;
}
+/** Per-axis throttle-TF sub-result */
+export interface AxisThrottleTF {
+ bands: ThrottleTFBand[];
+ bandsWithData: number;
+ metricsVariance: {
+ bandwidthHz: number;
+ overshootPercent: number;
+ phaseMarginDeg: number;
+ };
+}
+
export interface ThrottleTFResult {
- /** Per-band results */
+ /** Per-band results (roll axis — primary, kept top-level for compatibility) */
bands: ThrottleTFBand[];
/** Number of bands with enough data for TF estimation */
bandsWithData: number;
- /** Variance of key metrics across bands (std dev) */
+ /** Variance of key metrics across bands (std dev, roll axis) */
metricsVariance: {
bandwidthHz: number;
overshootPercent: number;
phaseMarginDeg: number;
};
- /** TPA warning message if variance exceeds threshold */
+ /** Pitch-axis per-band analysis (P2.8) — absent when pitch lacks data */
+ pitch?: AxisThrottleTF;
+ /** TPA warning message if variance exceeds threshold (worst axis) */
tpaWarning?: string;
}
@@ -116,11 +142,41 @@ function estimatePerBand(
});
}
+/**
+ * Analyze one axis's transfer function across throttle bands.
+ * Returns null when fewer than 2 bands have enough data.
+ */
+function analyzeAxisThrottleTF(
+ setpoint: Float64Array,
+ gyro: Float64Array,
+ throttle: Float64Array,
+ sampleRateHz: number,
+ numBands: number
+): AxisThrottleTF | null {
+ const bands = estimatePerBand(setpoint, gyro, throttle, sampleRateHz, numBands);
+ const bandsWithData = bands.filter((b) => b.metrics !== null).length;
+ if (bandsWithData < 2) return null;
+
+ const metricsWithData = bands
+ .filter((b): b is ThrottleTFBand & { metrics: TransferFunctionMetrics } => b.metrics !== null)
+ .map((b) => b.metrics);
+
+ const metricsVariance = {
+ bandwidthHz: Math.round(stdDev(metricsWithData.map((m) => m.bandwidthHz)) * 100) / 100,
+ overshootPercent:
+ Math.round(stdDev(metricsWithData.map((m) => m.overshootPercent)) * 100) / 100,
+ phaseMarginDeg: Math.round(stdDev(metricsWithData.map((m) => m.phaseMarginDeg)) * 100) / 100,
+ };
+
+ return { bands, bandsWithData, metricsVariance };
+}
+
/**
* Analyze transfer function across throttle bands.
*
- * Uses roll axis as the primary indicator (most sensitive to TPA effects).
- * Returns per-band metrics, cross-band variance, and optional TPA warning.
+ * Roll is the primary axis (kept top-level for compatibility); pitch is
+ * analyzed as well (P2.8) and attached when it has enough data. The TPA
+ * warning reflects the worst axis.
*
* @param flightData - Parsed blackbox flight data
* @param sampleRateHz - Sample rate in Hz
@@ -133,48 +189,54 @@ export function analyzeThrottleTF(
numBands: number = DEFAULT_TF_BANDS
): ThrottleTFResult | null {
// setpoint: [roll, pitch, yaw, throttle], gyro: [roll, pitch, yaw]
- // Use roll axis as primary (most sensitive to TPA)
- const bands = estimatePerBand(
+ const throttle = flightData.setpoint[3].values;
+ const roll = analyzeAxisThrottleTF(
flightData.setpoint[0].values,
flightData.gyro[0].values,
- flightData.setpoint[3].values,
+ throttle,
sampleRateHz,
numBands
);
+ if (!roll) return null;
- const bandsWithData = bands.filter((b) => b.metrics !== null).length;
-
- if (bandsWithData < 2) {
- return null; // Need at least 2 bands to compute variance
- }
-
- // Compute variance across bands with data
- const metricsWithData = bands
- .filter((b): b is ThrottleTFBand & { metrics: TransferFunctionMetrics } => b.metrics !== null)
- .map((b) => b.metrics);
+ const pitch = analyzeAxisThrottleTF(
+ flightData.setpoint[1].values,
+ flightData.gyro[1].values,
+ throttle,
+ sampleRateHz,
+ numBands
+ );
- const metricsVariance = {
- bandwidthHz: Math.round(stdDev(metricsWithData.map((m) => m.bandwidthHz)) * 100) / 100,
- overshootPercent:
- Math.round(stdDev(metricsWithData.map((m) => m.overshootPercent)) * 100) / 100,
- phaseMarginDeg: Math.round(stdDev(metricsWithData.map((m) => m.phaseMarginDeg)) * 100) / 100,
+ // Warning from the worst axis
+ const worstVariance = {
+ bandwidthHz: Math.max(
+ roll.metricsVariance.bandwidthHz,
+ pitch?.metricsVariance.bandwidthHz ?? 0
+ ),
+ overshootPercent: Math.max(
+ roll.metricsVariance.overshootPercent,
+ pitch?.metricsVariance.overshootPercent ?? 0
+ ),
+ phaseMarginDeg: Math.max(
+ roll.metricsVariance.phaseMarginDeg,
+ pitch?.metricsVariance.phaseMarginDeg ?? 0
+ ),
};
- // Generate TPA warning if variance is high
const warnings: string[] = [];
- if (metricsVariance.bandwidthHz > TPA_VARIANCE_THRESHOLD.bandwidthHz) {
+ if (worstVariance.bandwidthHz > TPA_VARIANCE_THRESHOLD.bandwidthHz) {
warnings.push(
- `Bandwidth varies by ±${metricsVariance.bandwidthHz.toFixed(0)} Hz across throttle range`
+ `Bandwidth varies by ±${worstVariance.bandwidthHz.toFixed(0)} Hz across throttle range`
);
}
- if (metricsVariance.overshootPercent > TPA_VARIANCE_THRESHOLD.overshootPercent) {
+ if (worstVariance.overshootPercent > TPA_VARIANCE_THRESHOLD.overshootPercent) {
warnings.push(
- `Overshoot varies by ±${metricsVariance.overshootPercent.toFixed(0)}% across throttle range`
+ `Overshoot varies by ±${worstVariance.overshootPercent.toFixed(0)}% across throttle range`
);
}
- if (metricsVariance.phaseMarginDeg > TPA_VARIANCE_THRESHOLD.phaseMarginDeg) {
+ if (worstVariance.phaseMarginDeg > TPA_VARIANCE_THRESHOLD.phaseMarginDeg) {
warnings.push(
- `Phase margin varies by ±${metricsVariance.phaseMarginDeg.toFixed(0)}° across throttle range`
+ `Phase margin varies by ±${worstVariance.phaseMarginDeg.toFixed(0)}° across throttle range`
);
}
@@ -184,9 +246,165 @@ export function analyzeThrottleTF(
: undefined;
return {
- bands,
- bandsWithData,
- metricsVariance,
+ bands: roll.bands,
+ bandsWithData: roll.bandsWithData,
+ metricsVariance: roll.metricsVariance,
+ ...(pitch ? { pitch } : {}),
tpaWarning,
};
}
+
+/**
+ * Emit measured TPA recommendations from per-band TF trends (P2.8).
+ *
+ * TPA attenuates PID gains at high throttle. If the measured closed-loop
+ * overshoot GROWS from the low- to the high-throttle bands, the attenuation
+ * is too weak (raise tpa_rate; move the breakpoint down to where the
+ * oscillation starts). If high-throttle bands are overdamped while low bands
+ * still overshoot, the attenuation is too strong (lower tpa_rate).
+ *
+ * Trends are taken from the worst of roll/pitch. Requires tpa_rate from the
+ * BBL header and ≥3 bands with TF data on the driving axis.
+ */
+export function recommendTPAFromThrottleTF(
+ result: ThrottleTFResult,
+ tpaContext: TPAContext | undefined
+): PIDRecommendation[] {
+ const recs: PIDRecommendation[] = [];
+ if (!tpaContext || tpaContext.rate === undefined) return recs;
+
+ // Pick the axis with the larger low→high overshoot change (worst case)
+ const axes: { label: string; bands: ThrottleTFBand[] }[] = [
+ { label: 'roll', bands: result.bands },
+ ...(result.pitch ? [{ label: 'pitch', bands: result.pitch.bands }] : []),
+ ];
+
+ let driving: { label: string; delta: number; low: number; high: number; onset?: number } | null =
+ null;
+
+ for (const axis of axes) {
+ const withData = axis.bands.filter((b) => b.metrics !== null);
+ if (withData.length < TPA_TF_MIN_BANDS) continue;
+
+ // Split into lower and upper halves by throttle
+ const midIdx = Math.floor(withData.length / 2);
+ const lowBands = withData.slice(0, midIdx);
+ const highBands = withData.slice(midIdx);
+ const mean = (bands: ThrottleTFBand[]) =>
+ bands.reduce((s, b) => s + b.metrics!.overshootPercent, 0) / bands.length;
+ const low = mean(lowBands);
+ const high = mean(highBands);
+ const delta = high - low;
+
+ // Throttle where overshoot first exceeds the low mean by the trigger delta
+ const onsetBand = withData.find(
+ (b) => b.metrics!.overshootPercent > low + TPA_TF_OVERSHOOT_DELTA_PP
+ );
+
+ if (driving === null || Math.abs(delta) > Math.abs(driving.delta)) {
+ driving = { label: axis.label, delta, low, high, onset: onsetBand?.throttleMin };
+ }
+ }
+
+ if (!driving) return recs;
+ const rate = tpaContext.rate;
+
+ if (driving.delta >= TPA_TF_OVERSHOOT_DELTA_PP) {
+ // Oscillation grows with throttle → TPA too weak
+ const target = Math.min(rate + TPA_TF_RATE_STEP, TPA_TF_RATE_MAX);
+ if (target > rate) {
+ recs.push({
+ setting: 'tpa_rate',
+ currentValue: rate,
+ recommendedValue: target,
+ reason:
+ `Measured ${driving.label} overshoot grows from ${driving.low.toFixed(0)}% at low throttle ` +
+ `to ${driving.high.toFixed(0)}% at high throttle — the PID gains are too hot up top and TPA ` +
+ `is not attenuating enough. Raising tpa_rate from ${rate} to ${target} damps the ` +
+ 'high-throttle oscillation without touching low-throttle response.',
+ impact: 'stability',
+ confidence: 'medium',
+ ruleId: 'TPA-TF-RATE-UP',
+ evidence: {
+ measurements: [
+ {
+ label: `Low-throttle ${driving.label} overshoot`,
+ value: `${driving.low.toFixed(0)}%`,
+ },
+ {
+ label: `High-throttle ${driving.label} overshoot`,
+ value: `${driving.high.toFixed(0)}%`,
+ },
+ ],
+ trigger: `Overshoot grows ≥ ${TPA_TF_OVERSHOOT_DELTA_PP} pp from low- to high-throttle TF bands`,
+ },
+ });
+ }
+
+ // Breakpoint: move to where the oscillation measurably starts
+ if (tpaContext.breakpoint !== undefined && driving.onset !== undefined) {
+ const onsetUs = Math.round((1000 + driving.onset * 1000) / 10) * 10;
+ const target2 = Math.min(Math.max(onsetUs, TPA_TF_BREAKPOINT_MIN), TPA_TF_BREAKPOINT_MAX);
+ if (tpaContext.breakpoint - target2 > TPA_TF_BREAKPOINT_DEADZONE) {
+ recs.push({
+ setting: 'tpa_breakpoint',
+ currentValue: tpaContext.breakpoint,
+ recommendedValue: target2,
+ reason:
+ `The measured high-throttle oscillation starts around ${Math.round((driving.onset ?? 0) * 100)}% ` +
+ `throttle, but TPA only begins attenuating at breakpoint ${tpaContext.breakpoint}. ` +
+ `Lowering the breakpoint to ${target2} starts the attenuation where the oscillation actually begins.`,
+ impact: 'stability',
+ confidence: 'medium',
+ ruleId: 'TPA-TF-BREAKPOINT',
+ evidence: {
+ measurements: [
+ {
+ label: 'Measured oscillation onset',
+ value: `~${Math.round((driving.onset ?? 0) * 100)}% throttle`,
+ },
+ { label: 'Current breakpoint', value: `${tpaContext.breakpoint}` },
+ ],
+ trigger: 'Overshoot exceeds the low-band mean before TPA starts attenuating',
+ },
+ });
+ }
+ }
+ } else if (
+ driving.delta <= -TPA_TF_OVERSHOOT_DELTA_PP &&
+ driving.high < TPA_TF_OVERDAMPED_OVERSHOOT_PCT
+ ) {
+ // High-throttle response overdamped while low throttle still overshoots → TPA too strong
+ const target = Math.max(rate - TPA_TF_RATE_STEP, TPA_TF_RATE_MIN);
+ if (target < rate) {
+ recs.push({
+ setting: 'tpa_rate',
+ currentValue: rate,
+ recommendedValue: target,
+ reason:
+ `Measured ${driving.label} response is overdamped at high throttle ` +
+ `(${driving.high.toFixed(0)}% overshoot vs ${driving.low.toFixed(0)}% at low throttle) — ` +
+ `TPA is attenuating more than needed, costing punch-out authority. ` +
+ `Lowering tpa_rate from ${rate} to ${target} restores high-throttle response.`,
+ impact: 'response',
+ confidence: 'low',
+ ruleId: 'TPA-TF-RATE-DOWN',
+ evidence: {
+ measurements: [
+ {
+ label: `Low-throttle ${driving.label} overshoot`,
+ value: `${driving.low.toFixed(0)}%`,
+ },
+ {
+ label: `High-throttle ${driving.label} overshoot`,
+ value: `${driving.high.toFixed(0)}%`,
+ },
+ ],
+ trigger: `High-throttle bands overdamped (< ${TPA_TF_OVERDAMPED_OVERSHOOT_PCT}% overshoot) while low bands overshoot`,
+ },
+ });
+ }
+ }
+
+ return recs;
+}
diff --git a/src/main/analysis/TransferFunctionEstimator.ts b/src/main/analysis/TransferFunctionEstimator.ts
index b26cdaef..fd5e6a77 100644
--- a/src/main/analysis/TransferFunctionEstimator.ts
+++ b/src/main/analysis/TransferFunctionEstimator.ts
@@ -46,6 +46,12 @@ const SETTLING_TOLERANCE = 0.02;
* reflects noise, not tracking, and would dilute the mean. */
const COHERENCE_BAND_MAX_HZ = 30;
+/** Windows whose max |setpoint| is below this (deg/s) carry no information
+ * about the commanded response — the input auto-spectrum is ~zero while the
+ * gyro still contributes noise, so including them only dilutes coherence.
+ * Used by the magnitude-split estimator (sparse stick-snap flights). */
+const MIN_WINDOW_INPUT_DEG_S = 50;
+
// ---- Types ----
export interface BodeResult {
@@ -58,6 +64,11 @@ export interface BodeResult {
/** 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;
+ /** Input auto-spectrum S_xx per bin — used to weight the mean coherence by
+ * where the commanded input actually has energy (step inputs concentrate
+ * at low frequencies; a uniform band mean would over-weight bins the
+ * pilot never excited). */
+ coherenceWeights?: Float64Array;
}
export interface SyntheticStepResponse {
@@ -236,28 +247,190 @@ export function estimateTransferFunction(
onProgress?.({ step: 'metrics', percent: 80 });
return {
- bode: { frequencies, magnitude, phase, ...(coherence ? { coherence } : {}) },
+ bode: {
+ frequencies,
+ magnitude,
+ phase,
+ ...(coherence ? { coherence, coherenceWeights: sxx.slice() } : {}),
+ },
impulseResponse,
};
}
/**
* Mean coherence over the stick-input band (1 to COHERENCE_BAND_MAX_HZ).
+ *
+ * When the Bode result carries input auto-spectrum weights (S_xx), the mean
+ * is input-energy-weighted: bins where the pilot commanded energy dominate,
+ * bins with no excitation (whose coherence is meaningless) contribute
+ * nothing. Falls back to a uniform band mean without weights.
* 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 weightSum = 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];
+ const w = bode.coherenceWeights ? bode.coherenceWeights[i] : 1;
+ sum += bode.coherence[i] * w;
+ weightSum += w;
count++;
}
if (f > COHERENCE_BAND_MAX_HZ) break;
}
- return count > 0 ? sum / count : undefined;
+ if (count === 0 || weightSum <= 0) return undefined;
+ return sum / weightSum;
+}
+
+/** One split group of a magnitude-split transfer function estimate */
+export interface SplitTFGroup {
+ bode: BodeResult;
+ impulseResponse: Float64Array;
+ /** Number of Welch windows accumulated into this group */
+ windowCount: number;
+ /** Mean coherence over the stick band (undefined when windowCount < 2) */
+ coherenceMean?: number;
+}
+
+export interface SplitTFResult {
+ /** Windows whose max |setpoint| is below the split threshold */
+ low?: SplitTFGroup;
+ /** Windows whose max |setpoint| is at or above the split threshold */
+ high?: SplitTFGroup;
+}
+
+/**
+ * Estimate the setpoint→gyro transfer function separately for low- and
+ * high-magnitude stick input (à la PIDtoolbox's <500 / >500 deg/s split).
+ *
+ * Betaflight's feedforward and D-setpoint transition behave differently for
+ * small vs large inputs, so a single averaged response mixes two regimes.
+ * Each Welch window is classified by its max |setpoint| and accumulated into
+ * the corresponding group; groups with no windows are omitted.
+ */
+export function estimateSplitTransferFunction(
+ setpoint: Float64Array,
+ gyro: Float64Array,
+ sampleRateHz: number,
+ splitThresholdDegS: number
+): SplitTFResult {
+ const N = Math.min(setpoint.length, gyro.length);
+ const windowSize = Math.min(TF_WINDOW_SIZE, largestPowerOf2(N));
+
+ if (windowSize < 64) {
+ throw new Error(`Signal too short for transfer function estimation: ${N} samples`);
+ }
+
+ const step = Math.floor(windowSize * (1 - TF_OVERLAP));
+ const numWindows = Math.max(1, Math.floor((N - windowSize) / step) + 1);
+ const numBins = windowSize / 2 + 1;
+
+ interface Acc {
+ sxyRe: Float64Array;
+ sxyIm: Float64Array;
+ sxx: Float64Array;
+ syy: Float64Array;
+ count: number;
+ }
+ const makeAcc = (): Acc => ({
+ sxyRe: new Float64Array(numBins),
+ sxyIm: new Float64Array(numBins),
+ sxx: new Float64Array(numBins),
+ syy: new Float64Array(numBins),
+ count: 0,
+ });
+ const groups: Record<'low' | 'high', Acc> = { low: makeAcc(), high: makeAcc() };
+
+ const fft = new FFT(windowSize);
+
+ for (let w = 0; w < numWindows; w++) {
+ const start = w * step;
+
+ // Classify window by max |setpoint|; skip windows with no commanded
+ // input — they add gyro noise to S_yy without input energy in S_xx,
+ // diluting coherence without informing the response estimate.
+ let maxAbs = 0;
+ for (let i = start; i < start + windowSize; i++) {
+ const abs = Math.abs(setpoint[i]);
+ if (abs > maxAbs) maxAbs = abs;
+ }
+ if (maxAbs < MIN_WINDOW_INPUT_DEG_S) continue;
+ const acc = maxAbs >= splitThresholdDegS ? groups.high : groups.low;
+
+ const xSeg = applyHanningWindow(setpoint.subarray(start, start + windowSize));
+ const ySeg = applyHanningWindow(gyro.subarray(start, start + windowSize));
+
+ const X = fft.createComplexArray();
+ const Y = fft.createComplexArray();
+ fft.realTransform(X, xSeg);
+ fft.completeSpectrum(X);
+ fft.realTransform(Y, ySeg);
+ fft.completeSpectrum(Y);
+
+ for (let i = 0; i < numBins; i++) {
+ const xRe = X[2 * i];
+ const xIm = X[2 * i + 1];
+ const yRe = Y[2 * i];
+ const yIm = Y[2 * i + 1];
+ acc.sxyRe[i] += yRe * xRe + yIm * xIm;
+ acc.sxyIm[i] += yIm * xRe - yRe * xIm;
+ acc.sxx[i] += xRe * xRe + xIm * xIm;
+ acc.syy[i] += yRe * yRe + yIm * yIm;
+ }
+ acc.count++;
+ }
+
+ const freqResolution = sampleRateHz / windowSize;
+
+ const finalizeGroup = (acc: Acc): SplitTFGroup | undefined => {
+ if (acc.count === 0) return undefined;
+
+ const epsilon = computeRegularization(acc.sxx, acc.count);
+ const frequencies = new Float64Array(numBins);
+ const magnitude = new Float64Array(numBins);
+ const phase = new Float64Array(numBins);
+ const hRe = new Float64Array(numBins);
+ const hIm = new Float64Array(numBins);
+ const coherence = acc.count >= 2 ? new Float64Array(numBins) : undefined;
+
+ for (let i = 0; i < numBins; i++) {
+ frequencies[i] = i * freqResolution;
+ const denom = acc.sxx[i] + epsilon;
+ hRe[i] = acc.sxyRe[i] / denom;
+ hIm[i] = acc.sxyIm[i] / denom;
+ 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 = acc.sxyRe[i] * acc.sxyRe[i] + acc.sxyIm[i] * acc.sxyIm[i];
+ const autoProduct = acc.sxx[i] * acc.syy[i];
+ coherence[i] = autoProduct > 1e-20 ? Math.min(1, Math.max(0, crossPower / autoProduct)) : 0;
+ }
+ }
+
+ const impulseResponse = computeImpulseResponse(fft, hRe, hIm, windowSize);
+ const bode: BodeResult = {
+ frequencies,
+ magnitude,
+ phase,
+ ...(coherence ? { coherence, coherenceWeights: acc.sxx.slice() } : {}),
+ };
+ const coherenceMean = computeCoherenceMean(bode);
+ return {
+ bode,
+ impulseResponse,
+ windowCount: acc.count,
+ ...(coherenceMean !== undefined ? { coherenceMean } : {}),
+ };
+ };
+
+ return {
+ ...(groups.low.count > 0 ? { low: finalizeGroup(groups.low) } : {}),
+ ...(groups.high.count > 0 ? { high: finalizeGroup(groups.high) } : {}),
+ };
}
/**
@@ -390,10 +563,11 @@ function computeImpulseResponse(
*/
export function computeSyntheticStepResponse(
impulseResponse: Float64Array,
- sampleRateHz: number
+ sampleRateHz: number,
+ durationS: number = STEP_RESPONSE_DURATION_S
): SyntheticStepResponse {
const maxSamples = Math.min(
- Math.floor(STEP_RESPONSE_DURATION_S * sampleRateHz),
+ Math.floor(durationS * sampleRateHz),
Math.floor(impulseResponse.length / 2) // Use first half only
);
diff --git a/src/main/analysis/__fixtures__/golden/demo-filter-cycle0.json b/src/main/analysis/__fixtures__/golden/demo-filter-cycle0.json
index e0600243..829bb003 100644
--- a/src/main/analysis/__fixtures__/golden/demo-filter-cycle0.json
+++ b/src/main/analysis/__fixtures__/golden/demo-filter-cycle0.json
@@ -1,82 +1,112 @@
{
"overallLevel": "high",
"roll": {
- "noiseFloorDb": -8.8,
+ "noiseFloorDb": -8.3,
"peaks": [
{
- "frequency": 160,
- "amplitude": 36.8,
- "type": "frame_resonance"
+ "frequency": 600,
+ "amplitude": 23.4,
+ "type": "electrical"
},
{
- "frequency": 320,
- "amplitude": 29.2,
+ "frequency": 45,
+ "amplitude": 15.7,
+ "type": "unknown"
+ },
+ {
+ "frequency": 384,
+ "amplitude": 12.9,
"type": "motor_harmonic"
},
{
- "frequency": 600,
- "amplitude": 23.4,
+ "frequency": 240,
+ "amplitude": 11.6,
"type": "motor_harmonic"
},
{
- "frequency": 45,
- "amplitude": 18.7,
+ "frequency": 192,
+ "amplitude": 8.8,
+ "type": "motor_harmonic"
+ },
+ {
+ "frequency": 120,
+ "amplitude": 8.8,
"type": "motor_harmonic"
},
{
"frequency": 28,
- "amplitude": 9.3,
+ "amplitude": 8.6,
"type": "unknown"
}
]
},
"pitch": {
- "noiseFloorDb": -8.8,
+ "noiseFloorDb": -8.4,
"peaks": [
{
- "frequency": 160,
- "amplitude": 37.1,
- "type": "frame_resonance"
+ "frequency": 600,
+ "amplitude": 23.2,
+ "type": "electrical"
},
{
- "frequency": 320,
- "amplitude": 29.5,
+ "frequency": 45,
+ "amplitude": 13.9,
+ "type": "unknown"
+ },
+ {
+ "frequency": 384,
+ "amplitude": 12.7,
"type": "motor_harmonic"
},
{
- "frequency": 600,
- "amplitude": 23.2,
+ "frequency": 240,
+ "amplitude": 10.6,
"type": "motor_harmonic"
},
{
- "frequency": 45,
- "amplitude": 16.9,
+ "frequency": 120,
+ "amplitude": 8.9,
+ "type": "motor_harmonic"
+ },
+ {
+ "frequency": 192,
+ "amplitude": 8.8,
"type": "motor_harmonic"
},
{
"frequency": 28,
- "amplitude": 9.4,
- "type": "unknown"
+ "amplitude": 8.1,
+ "type": "motor_harmonic"
}
]
},
"yaw": {
- "noiseFloorDb": -8.8,
+ "noiseFloorDb": -8.4,
"peaks": [
{
- "frequency": 160,
- "amplitude": 37.1,
- "type": "frame_resonance"
+ "frequency": 600,
+ "amplitude": 23,
+ "type": "electrical"
},
{
- "frequency": 320,
- "amplitude": 29,
- "type": "unknown"
+ "frequency": 384,
+ "amplitude": 13.3,
+ "type": "motor_harmonic"
},
{
- "frequency": 600,
- "amplitude": 23,
- "type": "electrical"
+ "frequency": 240,
+ "amplitude": 11.2,
+ "type": "motor_harmonic"
+ },
+ {
+ "frequency": 120,
+ "amplitude": 9.1,
+ "type": "motor_harmonic"
+ },
+ {
+ "frequency": 192,
+ "amplitude": 8.4,
+ "type": "motor_harmonic"
}
]
},
diff --git a/src/main/analysis/__fixtures__/golden/demo-filter-cycle2.json b/src/main/analysis/__fixtures__/golden/demo-filter-cycle2.json
index 0af31710..4fe205de 100644
--- a/src/main/analysis/__fixtures__/golden/demo-filter-cycle2.json
+++ b/src/main/analysis/__fixtures__/golden/demo-filter-cycle2.json
@@ -1,86 +1,116 @@
{
"overallLevel": "medium",
"roll": {
- "noiseFloorDb": -29.6,
+ "noiseFloorDb": -29.2,
"peaks": [
{
- "frequency": 160,
- "amplitude": 36.6,
- "type": "frame_resonance"
+ "frequency": 600,
+ "amplitude": 23.1,
+ "type": "electrical"
},
{
- "frequency": 320,
- "amplitude": 29.2,
- "type": "motor_harmonic"
+ "frequency": 45,
+ "amplitude": 22,
+ "type": "unknown"
},
{
- "frequency": 45,
- "amplitude": 23.6,
+ "frequency": 384,
+ "amplitude": 12.5,
"type": "motor_harmonic"
},
{
- "frequency": 600,
- "amplitude": 23.1,
+ "frequency": 240,
+ "amplitude": 11.4,
"type": "motor_harmonic"
},
{
"frequency": 28,
- "amplitude": 11.2,
+ "amplitude": 11.3,
"type": "unknown"
+ },
+ {
+ "frequency": 192,
+ "amplitude": 8.8,
+ "type": "motor_harmonic"
+ },
+ {
+ "frequency": 120,
+ "amplitude": 8.6,
+ "type": "motor_harmonic"
}
]
},
"pitch": {
- "noiseFloorDb": -29.5,
+ "noiseFloorDb": -29.1,
"peaks": [
{
- "frequency": 160,
- "amplitude": 36.1,
- "type": "frame_resonance"
+ "frequency": 600,
+ "amplitude": 22.9,
+ "type": "electrical"
+ },
+ {
+ "frequency": 45,
+ "amplitude": 19.2,
+ "type": "unknown"
},
{
- "frequency": 320,
- "amplitude": 28.9,
+ "frequency": 384,
+ "amplitude": 12.6,
"type": "motor_harmonic"
},
{
- "frequency": 600,
- "amplitude": 22.9,
+ "frequency": 28,
+ "amplitude": 11.1,
+ "type": "unknown"
+ },
+ {
+ "frequency": 240,
+ "amplitude": 10.3,
"type": "motor_harmonic"
},
{
- "frequency": 45,
- "amplitude": 21.5,
+ "frequency": 192,
+ "amplitude": 8.9,
"type": "motor_harmonic"
},
{
- "frequency": 28,
- "amplitude": 11.4,
- "type": "unknown"
+ "frequency": 120,
+ "amplitude": 8.9,
+ "type": "motor_harmonic"
}
]
},
"yaw": {
- "noiseFloorDb": -29.6,
+ "noiseFloorDb": -29.3,
"peaks": [
{
- "frequency": 160,
- "amplitude": 36.8,
- "type": "frame_resonance"
+ "frequency": 600,
+ "amplitude": 22.7,
+ "type": "electrical"
},
{
- "frequency": 320,
- "amplitude": 28.8,
+ "frequency": 384,
+ "amplitude": 13,
"type": "motor_harmonic"
},
{
- "frequency": 600,
- "amplitude": 22.6,
+ "frequency": 240,
+ "amplitude": 11.5,
"type": "motor_harmonic"
},
{
"frequency": 45,
- "amplitude": 12.2,
+ "amplitude": 10.7,
+ "type": "unknown"
+ },
+ {
+ "frequency": 120,
+ "amplitude": 9.1,
+ "type": "motor_harmonic"
+ },
+ {
+ "frequency": 192,
+ "amplitude": 8.4,
"type": "motor_harmonic"
}
]
diff --git a/src/main/analysis/__fixtures__/golden/demo-flash-cycle0.json b/src/main/analysis/__fixtures__/golden/demo-flash-cycle0.json
index e004bc31..e378fa05 100644
--- a/src/main/analysis/__fixtures__/golden/demo-flash-cycle0.json
+++ b/src/main/analysis/__fixtures__/golden/demo-flash-cycle0.json
@@ -3,24 +3,24 @@
"stepsDetected": 0,
"roll": {
"responses": 0,
- "meanOvershoot": 57.5,
- "meanRiseTimeMs": 3.3,
+ "meanOvershoot": 239.7,
+ "meanRiseTimeMs": 4.8,
"meanSettlingTimeMs": 200,
"meanLatencyMs": 0,
"meanSteadyStateError": 0
},
"pitch": {
"responses": 0,
- "meanOvershoot": 45.3,
- "meanRiseTimeMs": 2,
+ "meanOvershoot": 198.2,
+ "meanRiseTimeMs": 1.3,
"meanSettlingTimeMs": 200,
"meanLatencyMs": 0,
"meanSteadyStateError": 0
},
"yaw": {
"responses": 0,
- "meanOvershoot": 81.2,
- "meanRiseTimeMs": 48,
+ "meanOvershoot": 138.5,
+ "meanRiseTimeMs": 20,
"meanSettlingTimeMs": 200,
"meanLatencyMs": 0,
"meanSteadyStateError": 0
@@ -32,29 +32,29 @@
"transferFunctionMetrics": {
"roll": {
"bandwidthHz": 500,
- "phaseMarginDeg": 280,
+ "phaseMarginDeg": 290,
"gainMarginDb": 60,
"dcGainDb": 1.4,
- "overshootPercent": 58,
- "riseTimeMs": 3,
+ "overshootPercent": 240,
+ "riseTimeMs": 5,
"settlingTimeMs": 200
},
"pitch": {
"bandwidthHz": 500,
- "phaseMarginDeg": 96,
+ "phaseMarginDeg": 95,
"gainMarginDb": 60,
"dcGainDb": 1,
- "overshootPercent": 45,
- "riseTimeMs": 2,
+ "overshootPercent": 198,
+ "riseTimeMs": 1,
"settlingTimeMs": 200
},
"yaw": {
"bandwidthHz": 500,
- "phaseMarginDeg": 64,
+ "phaseMarginDeg": 63,
"gainMarginDb": 60,
"dcGainDb": 1.2,
- "overshootPercent": 81,
- "riseTimeMs": 48,
+ "overshootPercent": 139,
+ "riseTimeMs": 20,
"settlingTimeMs": 200
}
},
@@ -112,6 +112,13 @@
"ruleId": "P-DMAX-INFO",
"confidence": "low",
"informational": true
+ },
+ {
+ "setting": "tpa_rate",
+ "currentValue": 65,
+ "recommendedValue": 75,
+ "ruleId": "TPA-TF-RATE-UP",
+ "confidence": "medium"
}
]
}
diff --git a/src/main/analysis/__fixtures__/golden/demo-pid-cycle0.json b/src/main/analysis/__fixtures__/golden/demo-pid-cycle0.json
index 8f2b1885..c250061a 100644
--- a/src/main/analysis/__fixtures__/golden/demo-pid-cycle0.json
+++ b/src/main/analysis/__fixtures__/golden/demo-pid-cycle0.json
@@ -3,27 +3,27 @@
"stepsDetected": 36,
"roll": {
"responses": 12,
- "meanOvershoot": 26,
- "meanRiseTimeMs": 30.3,
- "meanSettlingTimeMs": 500,
- "meanLatencyMs": 2.3,
+ "meanOvershoot": 14,
+ "meanRiseTimeMs": 44,
+ "meanSettlingTimeMs": 224,
+ "meanLatencyMs": 0.7,
"meanSteadyStateError": 2.6
},
"pitch": {
"responses": 12,
- "meanOvershoot": 23.7,
- "meanRiseTimeMs": 30.3,
- "meanSettlingTimeMs": 499,
- "meanLatencyMs": 1.1,
+ "meanOvershoot": 14.7,
+ "meanRiseTimeMs": 46.3,
+ "meanSettlingTimeMs": 239,
+ "meanLatencyMs": 3,
"meanSteadyStateError": 2.4
},
"yaw": {
"responses": 12,
- "meanOvershoot": 19.1,
- "meanRiseTimeMs": 44.5,
- "meanSettlingTimeMs": 500,
- "meanLatencyMs": 1.5,
- "meanSteadyStateError": 2.7
+ "meanOvershoot": 10.4,
+ "meanRiseTimeMs": 66.3,
+ "meanSettlingTimeMs": 198,
+ "meanLatencyMs": 1.7,
+ "meanSteadyStateError": 2.6
},
"dataQuality": {
"tier": "excellent",
diff --git a/src/main/analysis/__fixtures__/golden/real-vx35-filter.json b/src/main/analysis/__fixtures__/golden/real-vx35-filter.json
index e989e90e..af683c56 100644
--- a/src/main/analysis/__fixtures__/golden/real-vx35-filter.json
+++ b/src/main/analysis/__fixtures__/golden/real-vx35-filter.json
@@ -55,6 +55,14 @@
"recommendedValue": 850,
"ruleId": "F-RPM-Q",
"confidence": "low"
+ },
+ {
+ "setting": "rpm_filter_weights",
+ "currentValue": 100,
+ "recommendedValue": 50,
+ "ruleId": "F-RPM-WEIGHTS",
+ "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 62bca87c..1611516e 100644
--- a/src/main/analysis/__fixtures__/golden/real-vx35-pid.json
+++ b/src/main/analysis/__fixtures__/golden/real-vx35-pid.json
@@ -11,9 +11,9 @@
},
"pitch": {
"responses": 12,
- "meanOvershoot": 56.6,
- "meanRiseTimeMs": 113.9,
- "meanSettlingTimeMs": 499,
+ "meanOvershoot": 2.2,
+ "meanRiseTimeMs": 56,
+ "meanSettlingTimeMs": 388,
"meanLatencyMs": 7.8,
"meanSteadyStateError": 11.5
},
@@ -31,7 +31,8 @@
},
"transferFunctionMetrics": null,
"warningCodes": [
- "feedforward_active"
+ "feedforward_active",
+ "step_deconv_disagreement"
],
"recommendations": [
{
@@ -44,7 +45,7 @@
{
"setting": "pid_pitch_d",
"currentValue": 46,
- "recommendedValue": 36,
+ "recommendedValue": 40,
"ruleId": "P-DR-OD-pitch",
"confidence": "medium"
},
@@ -63,13 +64,6 @@
"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,
diff --git a/src/main/analysis/constants.ts b/src/main/analysis/constants.ts
index e20de243..c7f9c796 100644
--- a/src/main/analysis/constants.ts
+++ b/src/main/analysis/constants.ts
@@ -163,6 +163,36 @@ export const MOTOR_HARMONIC_TOLERANCE_MIN_HZ = 5;
/** Minimum number of equally-spaced peaks to classify as motor harmonics */
export const MOTOR_HARMONIC_MIN_PEAKS = 3;
+// ---- Throttle-Track Peak Classification ----
+// Motor noise frequency scales with RPM (≈ throttle); frame resonance and
+// electrical noise stay put. Regressing a peak's per-throttle-band frequency
+// against throttle is definitive where the whole-flight equal-spacing
+// heuristic can only guess.
+
+/** Minimum throttle bands showing the peak before track classification applies */
+export const HARMONIC_TRACK_MIN_BANDS = 3;
+
+/** Pearson correlation (throttle vs peak frequency) at/above which the peak
+ * is classified as a motor harmonic */
+export const HARMONIC_TRACK_MIN_CORRELATION = 0.6;
+
+/** Minimum relative frequency range across bands ((max-min)/mean) for a
+ * motor-harmonic classification — the track must actually move */
+export const HARMONIC_TRACK_MIN_REL_RANGE = 0.15;
+
+/** Maximum relative frequency range for a "stationary" classification
+ * (frame resonance / electrical). Between this and
+ * HARMONIC_TRACK_MIN_REL_RANGE the track is ambiguous → keep the heuristic. */
+export const STATIONARY_TRACK_MAX_REL_RANGE = 0.08;
+
+/** Per-band search window around the averaged-spectrum peak, as a fraction
+ * of the peak frequency (harmonics move proportionally with RPM) */
+export const TRACK_SEARCH_REL_WINDOW = 0.3;
+
+/** Minimum prominence (dB above the band's floor) for a band to count as
+ * showing the peak */
+export const TRACK_BAND_MIN_PROMINENCE_DB = 6;
+
// ---- Filter Recommendation Safety Bounds ----
/** Absolute minimum gyro lowpass 1 cutoff in Hz (BF guide: 50 very noisy, 80 slightly noisy) */
@@ -343,6 +373,37 @@ export const STEP_COOLDOWN_MS = 100;
/** Step must hold for at least this long (ms) */
export const STEP_MIN_HOLD_MS = 50;
+// ---- Deconvolved (Stacked) Step Response ----
+
+/** Input-magnitude split threshold for the deconvolved step response (deg/s).
+ * Betaflight's feedforward / D-setpoint transition behaves differently for
+ * small vs large inputs — PIDtoolbox convention splits at 500 deg/s. */
+export const INPUT_SPLIT_THRESHOLD_DEG_S = 500;
+
+/** Minimum Welch windows per split group before its deconvolved metrics are
+ * trusted (a single window has trivially-1 coherence and high variance). */
+export const DECONV_MIN_WINDOWS = 2;
+
+/** Relative disagreement between deconvolved and per-step overshoot above
+ * which a cross-check warning is emitted (0.5 = 50%). */
+export const DECONV_DISAGREEMENT_RATIO = 0.5;
+
+/** Absolute overshoot floor (percentage points) below which the relative
+ * disagreement check is skipped — 2% vs 4% is a 100% relative difference
+ * but both mean "no overshoot problem". */
+export const DECONV_DISAGREEMENT_MIN_PP = 5;
+
+/** Scale applied to overshoot and settling THRESHOLDS when the axis metrics
+ * come from the deconvolved (stacked) step response. The Wiener estimate is
+ * inherently smoother than direct per-step measurement (Hanning windowing +
+ * regularization + impulse smoothing) — the same physical response reads
+ * roughly half the overshoot/settling. Calibrated on the demo generator's
+ * known second-order plant across tuning cycles (per-step → deconvolved
+ * overshoot: 25.8→13.5, 6.3→1.6, 3.0→1.2, 2.1→1.1; settling 500→205,
+ * 446→44, 105→46). Rise time is comparable between methods and is NOT
+ * scaled. PID_STYLE_THRESHOLDS remain calibrated for per-step values. */
+export const DECONV_THRESHOLD_SCALE = 0.5;
+
// ---- Step Response Metrics ----
/** Settling tolerance: +/-2% of target */
@@ -753,6 +814,88 @@ export const RPM_FILTER_Q_BY_SIZE: Record = {
/** Deviation threshold (fraction) from size-appropriate Q to trigger recommendation */
export const RPM_FILTER_Q_DEVIATION_THRESHOLD = 0.2; // 20%
+// ---- RPM Filter Tuning Rules (P2.6) ----
+// Source: docs/PID_TUNING_KNOWLEDGE.md Section 2 (RPM filter), measured harmonic
+// tracks from throttle-spectrogram reclassification (NoiseAnalyzer P2.2).
+
+/** Target rpm_filter_min_hz as a fraction of the dynamic-idle fundamental frequency.
+ * Notches never need to reach below the RPM floor dynamic idle enforces;
+ * a small margin below it covers transients. */
+export const RPM_MIN_HZ_IDLE_RATIO = 0.9;
+/** Same margin applied to the lowest measured fundamental-track frequency. */
+export const RPM_MIN_HZ_TRACK_RATIO = 0.9;
+/** House bounds for recommended rpm_filter_min_hz (firmware allows 30-200). */
+export const RPM_MIN_HZ_FLOOR = 40;
+export const RPM_MIN_HZ_CEILING = 150;
+/** Skip the min_hz recommendation when current is within this of the target. */
+export const RPM_MIN_HZ_DEADZONE_HZ = 15;
+/** Tolerance on measured-track frequency ratio when inferring harmonic order
+ * (|ratio − round(ratio)| must be below this to trust the order). */
+export const RPM_HARMONIC_RATIO_TOLERANCE = 0.25;
+/** Maximum rpm_filter_harmonics the harmonic-order rule will recommend. */
+export const RPM_HARMONICS_MAX = 3;
+/** BF default rpm_filter_fade_range_hz, recommended when fade is disabled. */
+export const RPM_FADE_RANGE_DEFAULT_HZ = 50;
+
+/** Community per-harmonic RPM notch weights by size (BF 4.5+ rpm_filter_weights).
+ * Second harmonic carries less energy for most props → dimmed to reduce delay. */
+export const RPM_FILTER_WEIGHTS_BY_SIZE: Record = {
+ '1"': [100, 50, 100],
+ '2.5"': [100, 50, 100],
+ '3"': [100, 50, 100],
+ '4"': [100, 50, 100],
+ '5"': [90, 50, 90],
+ '6"': [90, 50, 90],
+ '7"': [90, 60, 90],
+};
+
+// ---- TF-Driven TPA Rules (P2.8) ----
+// Per-throttle-band transfer function trends drive measured tpa_rate/breakpoint
+// recommendations (vs the static size-based advisory). House thresholds.
+
+/** High-band vs low-band overshoot delta (pp) that proves TPA is too weak */
+export const TPA_TF_OVERSHOOT_DELTA_PP = 10;
+/** High-band overshoot below this while low bands overshoot → TPA too strong */
+export const TPA_TF_OVERDAMPED_OVERSHOOT_PCT = 5;
+/** tpa_rate adjustment step */
+export const TPA_TF_RATE_STEP = 10;
+/** Bounds for TF-driven tpa_rate recommendations */
+export const TPA_TF_RATE_MIN = 30;
+export const TPA_TF_RATE_MAX = 80;
+/** Minimum bands with TF data required for trend analysis */
+export const TPA_TF_MIN_BANDS = 3;
+/** Breakpoint recommendation bounds (µs) and minimum change to act on */
+export const TPA_TF_BREAKPOINT_MIN = 1250;
+export const TPA_TF_BREAKPOINT_MAX = 1750;
+export const TPA_TF_BREAKPOINT_DEADZONE = 100;
+
+// ---- Filter Latency Budget (P2.7) ----
+// Per-size total group-delay budgets for the gyro and D-term filter chains at
+// the 80 Hz reference. LPF2 enable/disable decisions weigh measured delay
+// against these instead of acting on noise level alone. Anchors: BF community
+// "even 1 ms matters" for 5" racing; larger props have slower dynamics and
+// tolerate more delay; micros are inherently noisy and need filtering headroom.
+// House values (no direct community table exists).
+
+/** Per-chain latency budget in milliseconds */
+export interface FilterLatencyBudget {
+ gyroMs: number;
+ dtermMs: number;
+}
+
+export const FILTER_LATENCY_BUDGET_BY_SIZE: Record = {
+ '1"': { gyroMs: 2.5, dtermMs: 4.0 },
+ '2.5"': { gyroMs: 2.5, dtermMs: 4.0 },
+ '3"': { gyroMs: 2.0, dtermMs: 3.5 },
+ '4"': { gyroMs: 2.0, dtermMs: 3.5 },
+ '5"': { gyroMs: 1.5, dtermMs: 3.0 },
+ '6"': { gyroMs: 2.0, dtermMs: 3.5 },
+ '7"': { gyroMs: 2.5, dtermMs: 4.0 },
+};
+
+/** Fallback budget when drone size is unknown (matches the legacy 2 ms warning) */
+export const FILTER_LATENCY_BUDGET_DEFAULT: FilterLatencyBudget = { gyroMs: 2.0, dtermMs: 3.5 };
+
// ---- D-term LPF Dynamic Expo ----
// Source: docs/PID_TUNING_KNOWLEDGE.md Section 10 (Karate Race presets)
// Higher expo = LPF cutoff rises faster with throttle = less D filtering at high throttle.
diff --git a/src/main/analysis/headerValidation.ts b/src/main/analysis/headerValidation.ts
index 03233003..f1618dc0 100644
--- a/src/main/analysis/headerValidation.ts
+++ b/src/main/analysis/headerValidation.ts
@@ -193,6 +193,50 @@ export function enrichSettingsFromBBLHeaders(
}
}
+ if (enriched.rpm_filter_fade_range_hz === undefined) {
+ const fadeStr = rawHeaders.get('rpm_filter_fade_range_hz');
+ if (fadeStr !== undefined) {
+ const fade = parseInt(fadeStr, 10);
+ if (!isNaN(fade)) {
+ enriched.rpm_filter_fade_range_hz = fade;
+ changed = true;
+ }
+ }
+ }
+
+ if (enriched.rpm_filter_weights === undefined) {
+ const weightsStr = rawHeaders.get('rpm_filter_weights');
+ if (weightsStr !== undefined) {
+ const weights = weightsStr.split(',').map((w) => parseInt(w.trim(), 10));
+ if (weights.length > 0 && weights.every((w) => !isNaN(w))) {
+ enriched.rpm_filter_weights = weights;
+ changed = true;
+ }
+ }
+ }
+
+ if (enriched.dyn_idle_min_rpm === undefined) {
+ const dynIdleStr = rawHeaders.get('dyn_idle_min_rpm');
+ if (dynIdleStr !== undefined) {
+ const dynIdle = parseInt(dynIdleStr, 10);
+ if (!isNaN(dynIdle)) {
+ enriched.dyn_idle_min_rpm = dynIdle;
+ changed = true;
+ }
+ }
+ }
+
+ if (enriched.gyro_lpf1_dyn_expo === undefined) {
+ const gyroExpoStr = rawHeaders.get('gyro_lpf1_dyn_expo');
+ if (gyroExpoStr !== undefined) {
+ const gyroExpo = parseInt(gyroExpoStr, 10);
+ if (!isNaN(gyroExpo)) {
+ enriched.gyro_lpf1_dyn_expo = gyroExpo;
+ changed = true;
+ }
+ }
+ }
+
if (enriched.dterm_lpf1_dyn_expo === undefined) {
const expoStr = rawHeaders.get('dterm_lpf1_dyn_expo');
if (expoStr !== undefined) {
diff --git a/src/main/demo/DemoDataGenerator.ts b/src/main/demo/DemoDataGenerator.ts
index 12005c30..424e5427 100644
--- a/src/main/demo/DemoDataGenerator.ts
+++ b/src/main/demo/DemoDataGenerator.ts
@@ -498,6 +498,13 @@ function buildDemoSession(config: DemoSessionConfig): Buffer {
// ── Frame generation ────────────────────────────────────────────
const durationSec = frameCount / sampleRateHz;
+ // Motor harmonic phase accumulator — the instantaneous frequency tracks
+ // throttle (motor noise scales with RPM), so the phase must be integrated
+ // to stay continuous. Real motor noise draws the diagonal-line signature
+ // in throttle spectrograms; a fixed-frequency sinusoid would (correctly!)
+ // be classified as stationary/frame noise by the throttle-track classifier.
+ let motorPhase = 0;
+ const dtSec = (looptime * iInterval) / 1_000_000;
for (let f = 0; f < frameCount; f++) {
const frame: number[] = [0x49]; // I-frame marker
@@ -505,6 +512,13 @@ function buildDemoSession(config: DemoSessionConfig): Buffer {
const time = loopIter * looptime; // µs
const timeSec = time / 1_000_000;
+ // Instantaneous motor frequency: motorHarmonicHz at ~60% throttle,
+ // scaling proportionally with normalized throttle (RPM-like)
+ const throttleRaw = computeThrottle(timeSec, durationSec);
+ const normThrottle = Math.max(0, Math.min(1, (throttleRaw - 1000) / 1000));
+ const instMotorFreq = motorHarmonicHz * (0.4 + normThrottle);
+ motorPhase += 2 * Math.PI * instMotorFreq * dtSec;
+
frame.push(...encodeUVB(loopIter));
frame.push(...encodeUVB(time));
@@ -518,14 +532,11 @@ function buildDemoSession(config: DemoSessionConfig): Buffer {
const axisNoiseMult = axis === 0 ? axisAsymmetry : 1.0;
value += gaussianNoise(noiseAmplitude * axisNoiseMult);
- // Motor harmonic (strong peak in spectrum)
- value += motorHarmonicAmplitude * Math.sin(2 * Math.PI * motorHarmonicHz * timeSec + axis);
+ // Motor harmonic (strong peak that tracks throttle/RPM)
+ value += motorHarmonicAmplitude * Math.sin(motorPhase + axis);
// Second motor harmonic (2x frequency, lower amplitude)
- value +=
- motorHarmonicAmplitude *
- 0.4 *
- Math.sin(2 * Math.PI * motorHarmonicHz * 2 * timeSec + axis * 0.5);
+ value += motorHarmonicAmplitude * 0.4 * Math.sin(2 * motorPhase + axis * 0.5);
// Electrical noise (high frequency)
value +=
diff --git a/src/main/ipc/handlers/tuningHandlers.ts b/src/main/ipc/handlers/tuningHandlers.ts
index af6c1209..86ad3f67 100644
--- a/src/main/ipc/handlers/tuningHandlers.ts
+++ b/src/main/ipc/handlers/tuningHandlers.ts
@@ -23,6 +23,7 @@ import { logger } from '../../utils/logger';
import { getErrorMessage } from '../../utils/errors';
import { validateCLIResponse } from '../../msp/cliUtils';
import { verifyAppliedConfig } from '../../utils/verifyAppliedConfig';
+import { getBFCapabilities, translateSettingForVersion } from '@shared/utils/bfVersionCapabilities';
import { sendAutoReport } from '../../diagnostic/DiagnosticReportService';
import { MockMSPClient } from '../../demo/MockMSPClient';
import {
@@ -164,6 +165,9 @@ const BF_SETTING_RANGES: Record = {
dyn_notch_count: { min: 0, max: 5 },
dyn_notch_q: { min: 1, max: 1000 },
rpm_filter_q: { min: 1, max: 1000 },
+ rpm_filter_min_hz: { min: 30, max: 200 },
+ rpm_filter_harmonics: { min: 0, max: 3 },
+ rpm_filter_fade_range_hz: { min: 0, max: 1000 },
feedforward_boost: { min: 0, max: 50 },
feedforward_smooth_factor: { min: 0, max: 75 },
feedforward_jitter_factor: { min: 0, max: 20 },
@@ -302,6 +306,12 @@ export function registerTuningHandlers(deps: HandlerDependencies): void {
validateRecommendationBounds(ffRecs, 'Feedforward');
validateRecommendationBounds(pidRecs, 'PID');
+ // Version-capabilities layer (P2.5): translate renamed CLI settings for
+ // the connected firmware (e.g. d_min_gain → d_max_gain on BF 4.6+).
+ // AppliedChange records keep the canonical (pre-rename) name — MSP
+ // read-back verification is layout-based and unaffected by CLI names.
+ const bfCapabilities = getBFCapabilities(deps.fcStateCache?.getState().info?.version);
+
// Order matters: MSP commands first (PIDs), then CLI operations
// (filters, save). The apply flow enters CLI explicitly for filter/FF
// commands — exportCLIDiff() detects wasInCLI=true and skips exit.
@@ -363,7 +373,7 @@ export function registerTuningHandlers(deps: HandlerDependencies): void {
try {
for (const rec of actionableFilterRecs) {
const value = Math.round(rec.recommendedValue);
- const cmd = `set ${rec.setting} = ${value}`;
+ const cmd = `set ${translateSettingForVersion(rec.setting, bfCapabilities)} = ${value}`;
sendProgress({
stage: 'filter',
message: `Setting ${rec.setting} = ${value}...`,
@@ -420,7 +430,7 @@ export function registerTuningHandlers(deps: HandlerDependencies): void {
try {
for (const rec of ffRecs) {
const value = Math.round(rec.recommendedValue);
- const cmd = `set ${rec.setting} = ${value}`;
+ const cmd = `set ${translateSettingForVersion(rec.setting, bfCapabilities)} = ${value}`;
sendProgress({
stage: 'feedforward',
message: `Setting ${rec.setting} = ${value}...`,
diff --git a/src/main/utils/verifyAppliedConfig.ts b/src/main/utils/verifyAppliedConfig.ts
index 5ec8306d..66c97bab 100644
--- a/src/main/utils/verifyAppliedConfig.ts
+++ b/src/main/utils/verifyAppliedConfig.ts
@@ -79,7 +79,7 @@ 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']);
+const CLI_ONLY_SETTINGS = new Set(['rpm_filter_q', 'rpm_filter_fade_range_hz']);
/** Feedforward-stage settings readable via MSP_PID_ADVANCED → FeedforwardConfiguration key.
* Fields that are optional in FeedforwardConfiguration (absent on short/old-firmware
@@ -134,6 +134,13 @@ function buildExpectedFilterMap(
if (currentConfig.dyn_notch_count !== undefined) {
map.dyn_notch_count = currentConfig.dyn_notch_count;
}
+ // RPM filter fields (MSP_FILTER_CONFIG offsets 43/44)
+ if (currentConfig.rpm_filter_harmonics !== undefined) {
+ map.rpm_filter_harmonics = currentConfig.rpm_filter_harmonics;
+ }
+ if (currentConfig.rpm_filter_min_hz !== undefined) {
+ map.rpm_filter_min_hz = currentConfig.rpm_filter_min_hz;
+ }
// Dynamic lowpass fields (now read from MSP)
if (currentConfig.gyro_lpf1_dyn_min_hz !== undefined) {
map.gyro_lpf1_dyn_min_hz = currentConfig.gyro_lpf1_dyn_min_hz;
@@ -175,6 +182,12 @@ function buildActualFilterMap(config: CurrentFilterSettings): Record
-
+
{(['roll', 'pitch', 'yaw'] as const).map((axis) => {
const profile = overview.filterResult!.noise[axis];
@@ -366,7 +370,10 @@ export function AnalysisOverview({ logId, logName, onExit }: AnalysisOverviewPro
Throttle Spectrogram
{CHART_DESCRIPTIONS.throttleSpectrogram}
-
+
)}
diff --git a/src/renderer/components/TuningWizard/FilterAnalysisStep.tsx b/src/renderer/components/TuningWizard/FilterAnalysisStep.tsx
index 15bc273c..0176711f 100644
--- a/src/renderer/components/TuningWizard/FilterAnalysisStep.tsx
+++ b/src/renderer/components/TuningWizard/FilterAnalysisStep.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { RecommendationCard } from './RecommendationCard';
+import { PreviousSessionComparison } from './PreviousSessionComparison';
import { SpectrumChart } from './charts/SpectrumChart';
import { ThrottleSpectrogramChart } from './charts/ThrottleSpectrogramChart';
import type { FilterAnalysisResult, AnalysisProgress } from '@shared/types/analysis.types';
@@ -134,14 +135,24 @@ export function FilterAnalysisStep({
{filterResult.mechanicalHealth &&
- filterResult.mechanicalHealth.status !== 'ok' &&
+ filterResult.mechanicalHealth.issues.length > 0 &&
filterResult.mechanicalHealth.issues.map((issue, i) => (
- {issue.severity === 'critical' ? '\u274C' : '\u26A0\uFE0F'}
+ {issue.severity === 'critical'
+ ? '\u274C'
+ : issue.severity === 'info'
+ ? '\u2139\uFE0F'
+ : '\u26A0\uFE0F'}
{issue.message}
@@ -197,7 +208,11 @@ export function FilterAnalysisStep({
Peak marker
-
+
{(['roll', 'pitch', 'yaw'] as const).map((axis) => {
const profile = filterResult.noise[axis];
@@ -234,10 +249,15 @@ export function FilterAnalysisStep({
Throttle Spectrogram
{CHART_DESCRIPTIONS.throttleSpectrogram}
-
+
)}
+
+
{filterResult.recommendations.length > 0 ? (
{filterResult.recommendations.map((rec) => (
@@ -249,6 +269,7 @@ export function FilterAnalysisStep({
reason={rec.reason}
impact={rec.impact}
confidence={rec.confidence}
+ evidence={rec.evidence}
unit="Hz"
/>
))}
diff --git a/src/renderer/components/TuningWizard/PIDAnalysisStep.tsx b/src/renderer/components/TuningWizard/PIDAnalysisStep.tsx
index c3a170d0..a66d82e3 100644
--- a/src/renderer/components/TuningWizard/PIDAnalysisStep.tsx
+++ b/src/renderer/components/TuningWizard/PIDAnalysisStep.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { RecommendationCard } from './RecommendationCard';
+import { PreviousSessionComparison } from './PreviousSessionComparison';
import { StepResponseChart } from './charts/StepResponseChart';
import type { PIDAnalysisResult, AnalysisProgress } from '@shared/types/analysis.types';
import type { FlightStyle } from '@shared/types/profile.types';
@@ -228,6 +229,8 @@ export function PIDAnalysisStep({
>
)}
+
+
{pidResult.recommendations.length > 0 ? (
{pidResult.recommendations.map((rec) => (
@@ -239,6 +242,7 @@ export function PIDAnalysisStep({
reason={rec.reason}
impact={rec.impact}
confidence={rec.confidence}
+ evidence={rec.evidence}
/>
))}
diff --git a/src/renderer/components/TuningWizard/PreviousSessionComparison.css b/src/renderer/components/TuningWizard/PreviousSessionComparison.css
new file mode 100644
index 00000000..14819150
--- /dev/null
+++ b/src/renderer/components/TuningWizard/PreviousSessionComparison.css
@@ -0,0 +1,25 @@
+.previous-session-comparison {
+ margin: 16px 0;
+ padding: 12px;
+ background: var(--bg-tertiary, #1e1e1e);
+ border-radius: 6px;
+}
+
+.previous-session-comparison-title {
+ margin: 0 0 4px;
+ font-size: 14px;
+ color: var(--text-primary, #ddd);
+}
+
+.previous-session-comparison-subtitle {
+ margin: 0 0 10px;
+ font-size: 11px;
+ color: var(--text-secondary, #888);
+}
+
+.previous-session-comparison-note {
+ margin: 8px 0;
+ font-size: 12px;
+ color: var(--text-secondary, #aaa);
+ font-style: italic;
+}
diff --git a/src/renderer/components/TuningWizard/PreviousSessionComparison.test.tsx b/src/renderer/components/TuningWizard/PreviousSessionComparison.test.tsx
new file mode 100644
index 00000000..46b2a6e7
--- /dev/null
+++ b/src/renderer/components/TuningWizard/PreviousSessionComparison.test.tsx
@@ -0,0 +1,214 @@
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import { PreviousSessionComparison } from './PreviousSessionComparison';
+import { SPECTRUM_SCALE_VERSION } from '@shared/constants';
+import type { FilterAnalysisResult, PIDAnalysisResult } from '@shared/types/analysis.types';
+import type {
+ CompletedTuningRecord,
+ FilterMetricsSummary,
+ PIDMetricsSummary,
+} from '@shared/types/tuning-history.types';
+
+function makeSpectrum(len = 64) {
+ const frequencies = new Float64Array(len);
+ const magnitudes = new Float64Array(len);
+ for (let i = 0; i < len; i++) {
+ frequencies[i] = 20 + i * 10;
+ magnitudes[i] = -30;
+ }
+ return { frequencies, magnitudes };
+}
+
+function makeFilterResult(): FilterAnalysisResult {
+ const axis = () => ({ spectrum: makeSpectrum(), noiseFloorDb: -30, peaks: [] });
+ return {
+ noise: { roll: axis(), pitch: axis(), yaw: axis(), overallLevel: 'medium' },
+ recommendations: [],
+ summary: 'test',
+ analysisTimeMs: 1,
+ sessionIndex: 0,
+ segmentsUsed: 2,
+ };
+}
+
+function makePidResult(metricsSource: 'per_step' | 'deconvolved' = 'per_step'): PIDAnalysisResult {
+ const axis = () =>
+ ({
+ meanOvershoot: 10,
+ meanRiseTimeMs: 40,
+ meanSettlingTimeMs: 90,
+ meanLatencyMs: 5,
+ meanTrackingErrorRMS: 2,
+ metricsSource,
+ responses: [],
+ stepCount: 3,
+ }) as unknown as PIDAnalysisResult['roll'];
+ return {
+ roll: axis(),
+ pitch: axis(),
+ yaw: axis(),
+ recommendations: [],
+ stepsDetected: 9,
+ currentPIDs: {
+ roll: { P: 45, I: 80, D: 40 },
+ pitch: { P: 47, I: 84, D: 46 },
+ yaw: { P: 45, I: 80, D: 0 },
+ },
+ summary: 'test',
+ analysisTimeMs: 1,
+ } as unknown as PIDAnalysisResult;
+}
+
+function makeFilterSummary(scaleVersion?: number): FilterMetricsSummary {
+ return {
+ noiseLevel: 'medium',
+ roll: { noiseFloorDb: -25, peakCount: 1 },
+ pitch: { noiseFloorDb: -26, peakCount: 1 },
+ yaw: { noiseFloorDb: -28, peakCount: 0 },
+ segmentsUsed: 2,
+ summary: 'prev',
+ spectrum: {
+ frequencies: [20, 100, 200],
+ roll: [-25, -30, -35],
+ pitch: [-25, -30, -35],
+ yaw: [-25, -30, -35],
+ },
+ ...(scaleVersion !== undefined ? { spectrumScaleVersion: scaleVersion } : {}),
+ };
+}
+
+function makePidSummary(metricsSource?: 'per_step' | 'deconvolved'): PIDMetricsSummary {
+ const axis = () => ({
+ meanOvershoot: 20,
+ meanRiseTimeMs: 50,
+ meanSettlingTimeMs: 110,
+ meanLatencyMs: 6,
+ meanTrackingErrorRMS: 3,
+ });
+ return {
+ roll: axis(),
+ pitch: axis(),
+ yaw: axis(),
+ stepsDetected: 12,
+ currentPIDs: {
+ roll: { P: 42, I: 80, D: 38 },
+ pitch: { P: 44, I: 84, D: 44 },
+ yaw: { P: 42, I: 80, D: 0 },
+ },
+ summary: 'prev',
+ ...(metricsSource ? { metricsSource } : {}),
+ };
+}
+
+function makeRecord(overrides: Partial
): CompletedTuningRecord {
+ return {
+ id: 'rec-1',
+ profileId: 'p1',
+ startedAt: '2026-07-01T10:00:00Z',
+ completedAt: '2026-07-01T11:00:00Z',
+ tuningType: 'filter',
+ baselineSnapshotId: null,
+ postFilterSnapshotId: null,
+ postTuningSnapshotId: null,
+ filterLogId: null,
+ pidLogId: null,
+ quickLogId: null,
+ verificationLogId: null,
+ appliedFilterChanges: [],
+ appliedPIDChanges: [],
+ appliedFeedforwardChanges: [],
+ filterMetrics: null,
+ pidMetrics: null,
+ verificationMetrics: null,
+ verificationPidMetrics: null,
+ transferFunctionMetrics: null,
+ ...overrides,
+ } as CompletedTuningRecord;
+}
+
+describe('PreviousSessionComparison (P2.4)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders the noise comparison against the previous session', async () => {
+ vi.mocked(window.betaflight.getTuningHistory).mockResolvedValue([
+ makeRecord({ verificationMetrics: makeFilterSummary(SPECTRUM_SCALE_VERSION) }),
+ ]);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Compared to previous session/)).toBeInTheDocument();
+ });
+ });
+
+ it('refuses cross-scale spectrum comparisons with a note', async () => {
+ vi.mocked(window.betaflight.getTuningHistory).mockResolvedValue([
+ makeRecord({ verificationMetrics: makeFilterSummary(undefined) }),
+ ]);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText(/older spectrum scale/)).toBeInTheDocument();
+ });
+ });
+
+ it('renders nothing when there is no history', async () => {
+ vi.mocked(window.betaflight.getTuningHistory).mockResolvedValue([]);
+
+ const { container } = render(
+
+ );
+
+ await waitFor(() => {
+ expect(vi.mocked(window.betaflight.getTuningHistory)).toHaveBeenCalled();
+ });
+ expect(container.querySelector('.previous-session-comparison')).toBeNull();
+ });
+
+ it('renders the step-metrics comparison for pid mode', async () => {
+ vi.mocked(window.betaflight.getTuningHistory).mockResolvedValue([
+ makeRecord({ verificationPidMetrics: makePidSummary('per_step'), tuningType: 'pid' }),
+ ]);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Compared to previous session/)).toBeInTheDocument();
+ });
+ });
+
+ it('refuses cross-method step comparisons with a note', async () => {
+ // Previous archived with per-step metrics; current analysis uses deconvolved
+ vi.mocked(window.betaflight.getTuningHistory).mockResolvedValue([
+ makeRecord({ verificationPidMetrics: makePidSummary('per_step'), tuningType: 'pid' }),
+ ]);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText(/different method/)).toBeInTheDocument();
+ });
+ });
+
+ it('prefers verification metrics over pre-tuning metrics', async () => {
+ const verification = makeFilterSummary(SPECTRUM_SCALE_VERSION);
+ verification.summary = 'verification-metrics';
+ vi.mocked(window.betaflight.getTuningHistory).mockResolvedValue([
+ makeRecord({
+ filterMetrics: makeFilterSummary(undefined), // old scale — would show the note
+ verificationMetrics: verification, // current scale — should be used instead
+ }),
+ ]);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Compared to previous session/)).toBeInTheDocument();
+ });
+ expect(screen.queryByText(/older spectrum scale/)).not.toBeInTheDocument();
+ });
+});
diff --git a/src/renderer/components/TuningWizard/PreviousSessionComparison.tsx b/src/renderer/components/TuningWizard/PreviousSessionComparison.tsx
new file mode 100644
index 00000000..fcc05165
--- /dev/null
+++ b/src/renderer/components/TuningWizard/PreviousSessionComparison.tsx
@@ -0,0 +1,133 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { NoiseComparisonChart } from '../TuningHistory/NoiseComparisonChart';
+import { StepResponseComparison } from '../TuningHistory/StepResponseComparison';
+import { extractFilterMetrics, extractPIDMetrics } from '@shared/utils/metricsExtract';
+import type { FilterAnalysisResult, PIDAnalysisResult } from '@shared/types/analysis.types';
+import type {
+ CompletedTuningRecord,
+ FilterMetricsSummary,
+ PIDMetricsSummary,
+} from '@shared/types/tuning-history.types';
+import './PreviousSessionComparison.css';
+
+interface PreviousSessionComparisonProps {
+ mode: 'filter' | 'pid';
+ /** Current live analysis (filter mode) */
+ filterResult?: FilterAnalysisResult;
+ /** Current live analysis (pid mode) */
+ pidResult?: PIDAnalysisResult;
+}
+
+/** Newest archived record carrying comparable metrics for the given mode.
+ * Prefers the verification-flight metrics (the previous session's FINAL state)
+ * over its pre-tuning analysis metrics. */
+function findPreviousMetrics(
+ records: CompletedTuningRecord[],
+ mode: 'filter' | 'pid'
+): {
+ record: CompletedTuningRecord;
+ filter?: FilterMetricsSummary;
+ pid?: PIDMetricsSummary;
+} | null {
+ for (const record of records) {
+ if (mode === 'filter') {
+ const metrics = record.verificationMetrics ?? record.filterMetrics;
+ if (metrics?.spectrum) return { record, filter: metrics };
+ } else {
+ const metrics = record.verificationPidMetrics ?? record.pidMetrics;
+ if (metrics) return { record, pid: metrics };
+ }
+ }
+ return null;
+}
+
+/**
+ * Before/after comparison against the last completed tuning session (P2.4).
+ *
+ * Shown inside the analysis steps when the profile has archived history:
+ * overlays the previous session's compact spectrum (filter mode) or step
+ * metrics (pid mode) against the current analysis with delta annotations.
+ * Cross-scale comparisons (spectrum-scale or metrics-source mismatch) are
+ * refused with an explanatory note instead of a misleading chart.
+ */
+export function PreviousSessionComparison({
+ mode,
+ filterResult,
+ pidResult,
+}: PreviousSessionComparisonProps) {
+ const [records, setRecords] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ window.betaflight
+ .getTuningHistory()
+ .then((history) => {
+ if (!cancelled) setRecords(history);
+ })
+ .catch(() => {
+ if (!cancelled) setRecords([]);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const previous = useMemo(
+ () => (records ? findPreviousMetrics(records, mode) : null),
+ [records, mode]
+ );
+
+ const current = useMemo(() => {
+ if (mode === 'filter' && filterResult) return { filter: extractFilterMetrics(filterResult) };
+ if (mode === 'pid' && pidResult) return { pid: extractPIDMetrics(pidResult) };
+ return null;
+ }, [mode, filterResult, pidResult]);
+
+ if (!previous || !current) return null;
+
+ const completedDate = new Date(previous.record.completedAt).toLocaleDateString();
+
+ let body: React.ReactNode;
+ if (mode === 'filter' && previous.filter && current.filter) {
+ if (previous.filter.spectrumScaleVersion !== current.filter.spectrumScaleVersion) {
+ body = (
+
+ The previous session was analyzed with an older spectrum scale — its dB values are not
+ comparable with this analysis. The comparison will be available after the next completed
+ session.
+
+ );
+ } else {
+ body = ;
+ }
+ } else if (mode === 'pid' && previous.pid && current.pid) {
+ const beforeSource = previous.pid.metricsSource ?? 'per_step';
+ const afterSource = current.pid.metricsSource ?? 'per_step';
+ if (beforeSource !== afterSource) {
+ body = (
+
+ The previous session's step metrics were measured with a different method (
+ {beforeSource.replace('_', '-')}) than this analysis ({afterSource.replace('_', '-')}) —
+ overshoot and settling values are not directly comparable.
+
+ );
+ } else {
+ body = ;
+ }
+ } else {
+ return null;
+ }
+
+ return (
+
+
+ Compared to previous session ({completedDate})
+
+
+ Previous = the last completed tuning session's{' '}
+ {mode === 'filter' ? 'noise spectrum' : 'step response'}; Current = this flight.
+
+ {body}
+
+ );
+}
diff --git a/src/renderer/components/TuningWizard/QuickAnalysisStep.test.tsx b/src/renderer/components/TuningWizard/QuickAnalysisStep.test.tsx
index 1f58ee81..7466f2e7 100644
--- a/src/renderer/components/TuningWizard/QuickAnalysisStep.test.tsx
+++ b/src/renderer/components/TuningWizard/QuickAnalysisStep.test.tsx
@@ -160,3 +160,60 @@ describe('QuickAnalysisStep', () => {
expect(btn).toBeDisabled();
});
});
+
+describe('QuickAnalysisStep what-if prediction (P3.2)', () => {
+ const defaultProps = {
+ filterResult: null as FilterAnalysisResult | null,
+ filterAnalyzing: false,
+ filterProgress: null,
+ filterError: null,
+ tfResult: null as PIDAnalysisResult | null,
+ tfAnalyzing: false,
+ tfError: null,
+ runQuickAnalysis: vi.fn().mockResolvedValue(undefined),
+ quickAnalyzing: false,
+ onContinue: vi.fn(),
+ };
+
+ function makeWhatIf() {
+ const response = { timeMs: [0, 50, 100, 150], response: [0, 0.8, 1.1, 1.0] };
+ const metrics = {
+ bandwidthHz: 40,
+ phaseMarginDeg: 60,
+ gainMarginDb: 10,
+ overshootPercent: 10,
+ settlingTimeMs: 120,
+ riseTimeMs: 45,
+ dcGainDb: 0,
+ };
+ const axis = {
+ plant: { gainK: 8, naturalFreqHz: 30, damping: 0.7, delayMs: 5, fitQuality: 0.9 },
+ current: { pids: { P: 45, I: 80, D: 40 }, response, metrics },
+ proposed: { pids: { P: 50, I: 80, D: 48 }, response, metrics },
+ };
+ return {
+ roll: axis,
+ pitch: axis,
+ proposedPIDs: {
+ roll: { P: 50, I: 80, D: 48 },
+ pitch: { P: 52, I: 84, D: 50 },
+ yaw: { P: 45, I: 80, D: 0 },
+ },
+ };
+ }
+
+ it('renders the predicted-response section when whatIf is present', () => {
+ const tf = makeTFResult();
+ (tf as any).whatIf = makeWhatIf();
+ render( );
+
+ expect(screen.getByText('Predicted Response with Proposed Gains')).toBeInTheDocument();
+ expect(screen.getByText(/fit quality/)).toBeInTheDocument();
+ expect(screen.getByText(/simulation, not a measurement/)).toBeInTheDocument();
+ });
+
+ it('omits the predicted-response section without whatIf', () => {
+ render( );
+ expect(screen.queryByText('Predicted Response with Proposed Gains')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/renderer/components/TuningWizard/QuickAnalysisStep.tsx b/src/renderer/components/TuningWizard/QuickAnalysisStep.tsx
index 6fff0f12..c4588635 100644
--- a/src/renderer/components/TuningWizard/QuickAnalysisStep.tsx
+++ b/src/renderer/components/TuningWizard/QuickAnalysisStep.tsx
@@ -14,6 +14,25 @@ const PEAK_TYPE_LABELS: Record = {
unknown: 'Unknown',
};
+/** Build per-axis step data for the what-if chart. Axes without a prediction
+ * (yaw, or a gated axis) render as a flat zero trace on the shared time base. */
+function buildWhatIfStepData(
+ whatIf: NonNullable,
+ which: 'current' | 'proposed'
+): {
+ roll: { timeMs: number[]; response: number[] };
+ pitch: { timeMs: number[]; response: number[] };
+ yaw: { timeMs: number[]; response: number[] };
+} {
+ const ref = (whatIf.roll ?? whatIf.pitch)![which].response;
+ const flat = { timeMs: ref.timeMs, response: new Array(ref.timeMs.length).fill(0) };
+ return {
+ roll: whatIf.roll ? whatIf.roll[which].response : flat,
+ pitch: whatIf.pitch ? whatIf.pitch[which].response : flat,
+ yaw: flat,
+ };
+}
+
interface QuickAnalysisStepProps {
filterResult: FilterAnalysisResult | null;
filterAnalyzing: boolean;
@@ -110,7 +129,11 @@ export function QuickAnalysisStep({
Noise floor
-
+
{(['roll', 'pitch', 'yaw'] as const).map((axis) => {
const profile = filterResult.noise[axis];
@@ -156,6 +179,7 @@ export function QuickAnalysisStep({
reason={rec.reason}
impact={rec.impact}
confidence={rec.confidence}
+ evidence={rec.evidence}
unit="Hz"
/>
))}
@@ -178,6 +202,30 @@ export function QuickAnalysisStep({
>
)}
+ {tfResult.whatIf && (tfResult.whatIf.roll || tfResult.whatIf.pitch) && (
+ <>
+
Predicted Response with Proposed Gains
+
+ Prediction from a plant model identified from this flight (2nd order + delay,
+ fit quality{' '}
+ {[
+ tfResult.whatIf.roll ? `roll ${tfResult.whatIf.roll.plant.fitQuality}` : null,
+ tfResult.whatIf.pitch
+ ? `pitch ${tfResult.whatIf.pitch.plant.fitQuality}`
+ : null,
+ ]
+ .filter(Boolean)
+ .join(', ')}
+ ). Before = current gains, after = the recommended gains — this is a simulation,
+ not a measurement.
+
+
+ >
+ )}
+
setBodeOpen(!bodeOpen)}>
{bodeOpen ? 'Hide frequency response (Bode)' : 'Show frequency response (Bode)'}
@@ -192,16 +240,19 @@ export function QuickAnalysisStep({
frequencies: tfResult.transferFunction.roll.frequencies,
magnitude: tfResult.transferFunction.roll.magnitude,
phase: tfResult.transferFunction.roll.phase,
+ coherence: tfResult.transferFunction.roll.coherence,
},
pitch: {
frequencies: tfResult.transferFunction.pitch.frequencies,
magnitude: tfResult.transferFunction.pitch.magnitude,
phase: tfResult.transferFunction.pitch.phase,
+ coherence: tfResult.transferFunction.pitch.coherence,
},
yaw: {
frequencies: tfResult.transferFunction.yaw.frequencies,
magnitude: tfResult.transferFunction.yaw.magnitude,
phase: tfResult.transferFunction.yaw.phase,
+ coherence: tfResult.transferFunction.yaw.coherence,
},
}}
/>
@@ -221,6 +272,7 @@ export function QuickAnalysisStep({
reason={rec.reason}
impact={rec.impact}
confidence={rec.confidence}
+ evidence={rec.evidence}
/>
))}
diff --git a/src/renderer/components/TuningWizard/RecommendationCard.css b/src/renderer/components/TuningWizard/RecommendationCard.css
index f3decd69..bef57098 100644
--- a/src/renderer/components/TuningWizard/RecommendationCard.css
+++ b/src/renderer/components/TuningWizard/RecommendationCard.css
@@ -114,3 +114,31 @@
color: var(--primary-color, #4dabf7);
text-transform: lowercase;
}
+
+.recommendation-card-evidence {
+ margin: 6px 0;
+ font-size: 12px;
+ color: var(--text-secondary, #aaa);
+}
+
+.recommendation-card-evidence summary {
+ cursor: pointer;
+ color: var(--text-secondary, #8ab4f8);
+ font-size: 11px;
+ user-select: none;
+}
+
+.recommendation-card-evidence ul {
+ margin: 6px 0 4px;
+ padding-left: 18px;
+}
+
+.recommendation-card-evidence-label {
+ color: var(--text-primary, #ddd);
+}
+
+.recommendation-card-evidence-trigger {
+ margin: 4px 0 0;
+ font-style: italic;
+ font-size: 11px;
+}
diff --git a/src/renderer/components/TuningWizard/RecommendationCard.test.tsx b/src/renderer/components/TuningWizard/RecommendationCard.test.tsx
index 1fcaba41..8bcc2f82 100644
--- a/src/renderer/components/TuningWizard/RecommendationCard.test.tsx
+++ b/src/renderer/components/TuningWizard/RecommendationCard.test.tsx
@@ -166,3 +166,46 @@ describe('RecommendationCard', () => {
expect(screen.getByText('+15')).toBeInTheDocument();
});
});
+
+describe('RecommendationCard evidence (P3.1)', () => {
+ it('renders the measured-evidence block when evidence is provided', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Why? Measured evidence')).toBeInTheDocument();
+ expect(screen.getByText('Peak frequency:')).toBeInTheDocument();
+ expect(screen.getByText(/160 Hz/)).toBeInTheDocument();
+ expect(screen.getByText('Peak ≥ 12 dB below the effective cutoff')).toBeInTheDocument();
+ });
+
+ it('omits the evidence block when no evidence is provided', () => {
+ render(
+
+ );
+
+ expect(screen.queryByText('Why? Measured evidence')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/renderer/components/TuningWizard/RecommendationCard.tsx b/src/renderer/components/TuningWizard/RecommendationCard.tsx
index ed4279da..0e6eb726 100644
--- a/src/renderer/components/TuningWizard/RecommendationCard.tsx
+++ b/src/renderer/components/TuningWizard/RecommendationCard.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import type { RecommendationEvidence } from '@shared/types/analysis.types';
import './RecommendationCard.css';
export const SETTING_LABELS: Record = {
@@ -49,6 +50,8 @@ interface RecommendationCardProps {
impact: string;
confidence: 'high' | 'medium' | 'low';
unit?: string;
+ /** Structured evidence explaining why the rule fired (P3.1) */
+ evidence?: RecommendationEvidence;
}
export function RecommendationCard({
@@ -59,6 +62,7 @@ export function RecommendationCard({
impact,
confidence,
unit,
+ evidence,
}: RecommendationCardProps) {
const label = SETTING_LABELS[setting] || setting;
const change = computeChange(currentValue, recommendedValue);
@@ -89,6 +93,21 @@ export function RecommendationCard({
)}
{reason}
+ {evidence && evidence.measurements.length > 0 && (
+
+ Why? Measured evidence
+
+ {evidence.measurements.map((m, i) => (
+
+ {m.label}: {m.value}
+
+ ))}
+
+ {evidence.trigger && (
+ {evidence.trigger}
+ )}
+
+ )}
{impact}
);
diff --git a/src/renderer/components/TuningWizard/TuningSummaryStep.tsx b/src/renderer/components/TuningWizard/TuningSummaryStep.tsx
index ec2a2cd5..6b47a9c1 100644
--- a/src/renderer/components/TuningWizard/TuningSummaryStep.tsx
+++ b/src/renderer/components/TuningWizard/TuningSummaryStep.tsx
@@ -223,6 +223,7 @@ export function TuningSummaryStep({
reason={rec.reason}
impact={rec.impact}
confidence={rec.confidence}
+ evidence={rec.evidence}
unit="Hz"
/>
))}
@@ -246,6 +247,7 @@ export function TuningSummaryStep({
reason={rec.reason}
impact={rec.impact}
confidence={rec.confidence}
+ evidence={rec.evidence}
/>
))}
diff --git a/src/renderer/components/TuningWizard/charts/BodePlot.css b/src/renderer/components/TuningWizard/charts/BodePlot.css
index c0188748..5ddc5dca 100644
--- a/src/renderer/components/TuningWizard/charts/BodePlot.css
+++ b/src/renderer/components/TuningWizard/charts/BodePlot.css
@@ -37,3 +37,9 @@
.bode-plot .recharts-cartesian-grid-vertical line {
stroke: #2a2a2a;
}
+
+.bode-plot-coherence-note {
+ margin: 6px 4px 0;
+ font-size: 11px;
+ color: var(--text-secondary, #888);
+}
diff --git a/src/renderer/components/TuningWizard/charts/BodePlot.test.tsx b/src/renderer/components/TuningWizard/charts/BodePlot.test.tsx
index 3650ac54..92748064 100644
--- a/src/renderer/components/TuningWizard/charts/BodePlot.test.tsx
+++ b/src/renderer/components/TuningWizard/charts/BodePlot.test.tsx
@@ -94,3 +94,27 @@ describe('BodePlot', () => {
expect(screen.getAllByTestId('responsive-container')).toHaveLength(2);
});
});
+
+describe('BodePlot coherence section (P3.1)', () => {
+ function withCoherence(size = 50): BodeResult {
+ const base = makeBodeResult(size);
+ const coherence = new Float64Array(size);
+ for (let i = 0; i < size; i++) coherence[i] = Math.max(0, 1 - i * 0.02);
+ return { ...base, coherence };
+ }
+
+ it('renders the coherence chart when coherence data is present', () => {
+ const bode = { roll: withCoherence(), pitch: withCoherence(), yaw: withCoherence() };
+ render( );
+ expect(screen.getByText(/Coherence γ²/)).toBeInTheDocument();
+ expect(screen.getAllByTestId('line-chart')).toHaveLength(3);
+ expect(screen.getByText(/at least 0.5/)).toBeInTheDocument();
+ });
+
+ it('omits the coherence chart when no axis has coherence data', () => {
+ const bode = { roll: makeBodeResult(), pitch: makeBodeResult(), yaw: makeBodeResult() };
+ render( );
+ expect(screen.queryByText(/Coherence γ²/)).not.toBeInTheDocument();
+ expect(screen.getAllByTestId('line-chart')).toHaveLength(2);
+ });
+});
diff --git a/src/renderer/components/TuningWizard/charts/BodePlot.tsx b/src/renderer/components/TuningWizard/charts/BodePlot.tsx
index 0709c9aa..1cab8dfb 100644
--- a/src/renderer/components/TuningWizard/charts/BodePlot.tsx
+++ b/src/renderer/components/TuningWizard/charts/BodePlot.tsx
@@ -18,8 +18,13 @@ interface BodeData {
frequencies: Float64Array | number[];
magnitude: Float64Array | number[];
phase: Float64Array | number[];
+ /** Magnitude-squared coherence γ²(f), 0-1 (absent with a single Welch window) */
+ coherence?: Float64Array | number[];
}
+/** Recommendation gate — TF rules are skipped below this coherence (P3.1 display) */
+const COHERENCE_GATE = 0.5;
+
interface BodePlotProps {
bode: {
roll: BodeData;
@@ -42,7 +47,7 @@ const ASPECT_RATIO = 7 / 3;
/** Convert BodeResult per-axis data into Recharts format */
function bodeToRechartsData(
bode: BodePlotProps['bode'],
- mode: 'magnitude' | 'phase'
+ mode: 'magnitude' | 'phase' | 'coherence'
): BodeDataPoint[] {
const rollFreq = bode.roll.frequencies;
const points: BodeDataPoint[] = [];
@@ -56,10 +61,14 @@ function bodeToRechartsData(
point.roll = bode.roll.magnitude[i];
point.pitch = bode.pitch.magnitude[i];
point.yaw = bode.yaw.magnitude[i];
- } else {
+ } else if (mode === 'phase') {
point.roll = bode.roll.phase[i];
point.pitch = bode.pitch.phase[i];
point.yaw = bode.yaw.phase[i];
+ } else {
+ point.roll = bode.roll.coherence?.[i];
+ point.pitch = bode.pitch.coherence?.[i];
+ point.yaw = bode.yaw.coherence?.[i];
}
points.push(point);
}
@@ -78,6 +87,14 @@ export function BodePlot({ bode }: BodePlotProps) {
const magnitudeData = useMemo(() => bodeToRechartsData(bode, 'magnitude'), [bode]);
const phaseData = useMemo(() => bodeToRechartsData(bode, 'phase'), [bode]);
+ const hasCoherence =
+ (bode.roll.coherence?.length ?? 0) > 0 ||
+ (bode.pitch.coherence?.length ?? 0) > 0 ||
+ (bode.yaw.coherence?.length ?? 0) > 0;
+ const coherenceData = useMemo(
+ () => (hasCoherence ? bodeToRechartsData(bode, 'coherence') : []),
+ [bode, hasCoherence]
+ );
const visibleAxes: Axis[] = selectedAxis === 'all' ? ['roll', 'pitch', 'yaw'] : [selectedAxis];
@@ -208,6 +225,83 @@ export function BodePlot({ bode }: BodePlotProps) {
+
+ {hasCoherence && coherenceData.length > 0 && (
+
+
Coherence γ² (measurement quality, 0–1)
+
+
+
+
+
+ (Number.isFinite(v) ? v.toFixed(1) : '')}
+ tick={{ fontSize: 11, fill: '#aaa' }}
+ label={{
+ value: 'γ²',
+ angle: -90,
+ position: 'insideLeft',
+ style: { fontSize: 11, fill: '#888' },
+ }}
+ />
+ `${val} Hz`}
+ formatter={
+ ((value: number | undefined, name: string) => [
+ `${(value ?? 0).toFixed(2)}`,
+ name,
+ ]) as any
+ }
+ />
+
+ {visibleAxes.map((axis) => (
+
+ ))}
+
+
+
+
+ Coherence shows how much of the gyro response is explained by stick input at each
+ frequency. Transfer-function recommendations are only generated where the mean coherence
+ over the 1–30 Hz stick band is at least 0.5.
+
+
+ )}
);
}
diff --git a/src/renderer/components/TuningWizard/charts/SpectrumChart.css b/src/renderer/components/TuningWizard/charts/SpectrumChart.css
index 32ca64e1..7d25e9ec 100644
--- a/src/renderer/components/TuningWizard/charts/SpectrumChart.css
+++ b/src/renderer/components/TuningWizard/charts/SpectrumChart.css
@@ -11,6 +11,18 @@
overflow-x: auto;
}
+.spectrum-chart-filter-legend {
+ display: flex;
+ gap: 12px;
+ align-items: baseline;
+ padding: 4px 8px 0;
+ font-size: 11px;
+}
+
+.spectrum-chart-filter-legend-note {
+ color: var(--text-secondary, #888);
+}
+
.spectrum-chart-empty {
padding: 24px;
text-align: center;
diff --git a/src/renderer/components/TuningWizard/charts/SpectrumChart.test.tsx b/src/renderer/components/TuningWizard/charts/SpectrumChart.test.tsx
index 2e9f091b..fcd08973 100644
--- a/src/renderer/components/TuningWizard/charts/SpectrumChart.test.tsx
+++ b/src/renderer/components/TuningWizard/charts/SpectrumChart.test.tsx
@@ -45,9 +45,21 @@ const mockNoise: NoiseProfile = {
};
const emptyNoise: NoiseProfile = {
- roll: { spectrum: { frequencies: new Float64Array([]), magnitudes: new Float64Array([]) }, noiseFloorDb: -40, peaks: [] },
- pitch: { spectrum: { frequencies: new Float64Array([]), magnitudes: new Float64Array([]) }, noiseFloorDb: -40, peaks: [] },
- yaw: { spectrum: { frequencies: new Float64Array([]), magnitudes: new Float64Array([]) }, noiseFloorDb: -40, peaks: [] },
+ roll: {
+ spectrum: { frequencies: new Float64Array([]), magnitudes: new Float64Array([]) },
+ noiseFloorDb: -40,
+ peaks: [],
+ },
+ pitch: {
+ spectrum: { frequencies: new Float64Array([]), magnitudes: new Float64Array([]) },
+ noiseFloorDb: -40,
+ peaks: [],
+ },
+ yaw: {
+ spectrum: { frequencies: new Float64Array([]), magnitudes: new Float64Array([]) },
+ noiseFloorDb: -40,
+ peaks: [],
+ },
overallLevel: 'low',
};
@@ -100,4 +112,141 @@ describe('SpectrumChart', () => {
expect(refLines.length).toBeGreaterThanOrEqual(2);
});
+ it('overlays filter response curves when filterSettings provided', () => {
+ const { container } = render(
+
+ );
+
+ // 3 noise lines + gyro filter curve + dterm filter curve
+ const lines = container.querySelectorAll('.recharts-line');
+ expect(lines.length).toBe(5);
+ // Legend describing the overlay
+ expect(screen.getByText(/Gyro filters/)).toBeInTheDocument();
+ expect(screen.getByText(/D-term filters/)).toBeInTheDocument();
+ // Dynamic notch range shading
+ expect(container.querySelector('.recharts-reference-area')).toBeTruthy();
+ });
+
+ it('renders no overlay when all filters are disabled', () => {
+ const { container } = render(
+
+ );
+
+ const lines = container.querySelectorAll('.recharts-line');
+ expect(lines.length).toBe(3);
+ expect(screen.queryByText(/Gyro filters/)).not.toBeInTheDocument();
+ });
+
+ it('renders only the gyro curve and legend when D-term filters are disabled', () => {
+ const { container } = render(
+
+ );
+
+ // 3 noise lines + gyro filter curve only (no dterm curve)
+ const lines = container.querySelectorAll('.recharts-line');
+ expect(lines.length).toBe(4);
+ expect(screen.getByText(/Gyro filters/)).toBeInTheDocument();
+ expect(screen.queryByText(/D-term filters/)).not.toBeInTheDocument();
+ });
+
+ it('skips the notch shading when dyn_notch_count is 0', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.querySelector('.recharts-reference-area')).toBeFalsy();
+ // Filter curves still render
+ expect(screen.getByText(/Gyro filters/)).toBeInTheDocument();
+ });
+});
+
+describe('SpectrumChart rule annotations (P3.1)', () => {
+ it('tags peaks with the rule they triggered via evidence anchors', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.textContent).toContain('F-RES-GYRO');
+ });
+
+ it('leaves peaks untagged when no evidence anchor matches', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.textContent).not.toContain('F-NF-H-GYRO');
+ });
});
diff --git a/src/renderer/components/TuningWizard/charts/SpectrumChart.tsx b/src/renderer/components/TuningWizard/charts/SpectrumChart.tsx
index ad1fad75..2d71e908 100644
--- a/src/renderer/components/TuningWizard/charts/SpectrumChart.tsx
+++ b/src/renderer/components/TuningWizard/charts/SpectrumChart.tsx
@@ -8,16 +8,34 @@ import {
CartesianGrid,
Tooltip,
ReferenceLine,
+ ReferenceArea,
} from 'recharts';
import { AxisTabs, type AxisSelection } from './AxisTabs';
import { spectrumToRechartsData, downsampleData, AXIS_COLORS, type Axis } from './chartUtils';
-import type { NoiseProfile, NoisePeak } from '@shared/types/analysis.types';
+import { computeFilterChainCurve, FILTER_RESPONSE_FLOOR_DB } from '@shared/utils/filterResponse';
+import type {
+ NoiseProfile,
+ NoisePeak,
+ CurrentFilterSettings,
+ FilterRecommendation,
+} from '@shared/types/analysis.types';
import './SpectrumChart.css';
interface SpectrumChartProps {
noise: NoiseProfile;
+ /** When provided, the configured gyro/D-term filter response is overlaid
+ * (right axis, attenuation dB) together with the dynamic notch range. */
+ filterSettings?: CurrentFilterSettings;
+ /** When provided, peaks that triggered a rule (via evidence.anchorFrequencyHz)
+ * are tagged with the rule ID — "this peak fired F-RES-GYRO" (P3.1) */
+ recommendations?: FilterRecommendation[];
}
+/** Frequency tolerance when matching a rule's evidence anchor to a peak */
+const RULE_ANCHOR_TOLERANCE_HZ = 8;
+
+const FILTER_CURVE_COLORS = { gyro: '#63e6be', dterm: '#e599f7' } as const;
+
const MAX_CHART_POINTS = 500;
const MIN_WIDTH = 700;
const MIN_HEIGHT = 300;
@@ -44,7 +62,7 @@ const DB_TOP_PADDING = 5;
/** Padding beyond the last significant frequency as fraction of visible range */
const FREQ_PADDING_RATIO = 0.05;
-export function SpectrumChart({ noise }: SpectrumChartProps) {
+export function SpectrumChart({ noise, filterSettings, recommendations }: SpectrumChartProps) {
const [selectedAxis, setSelectedAxis] = useState('all');
// Compute data, domains, and filter to significant range in one pass
@@ -95,8 +113,50 @@ export function SpectrumChart({ noise }: SpectrumChartProps) {
};
}, [noise]);
+ // Filter-response overlay: configured gyro/D-term chain attenuation at each
+ // chart frequency (right axis), evaluated at cruise throttle for dynamic LPFs
+ const { chartData, hasGyroOverlay, hasDtermOverlay, notchRange } = useMemo(() => {
+ if (!filterSettings || data.length === 0) {
+ return { chartData: data, hasGyroOverlay: false, hasDtermOverlay: false, notchRange: null };
+ }
+ const frequencies = data.map((p) => p.frequency);
+ const gyroCurve = computeFilterChainCurve(filterSettings, 'gyro', frequencies);
+ const dtermCurve = computeFilterChainCurve(filterSettings, 'dterm', frequencies);
+ if (!gyroCurve && !dtermCurve) {
+ return { chartData: data, hasGyroOverlay: false, hasDtermOverlay: false, notchRange: null };
+ }
+ const merged = data.map((p, i) => ({
+ ...p,
+ ...(gyroCurve ? { gyroFilter: gyroCurve[i] } : {}),
+ ...(dtermCurve ? { dtermFilter: dtermCurve[i] } : {}),
+ }));
+ const range =
+ (filterSettings.dyn_notch_count ?? 0) !== 0 &&
+ filterSettings.dyn_notch_min_hz > 0 &&
+ filterSettings.dyn_notch_max_hz > filterSettings.dyn_notch_min_hz
+ ? { min: filterSettings.dyn_notch_min_hz, max: filterSettings.dyn_notch_max_hz }
+ : null;
+ return {
+ chartData: merged,
+ hasGyroOverlay: gyroCurve !== null,
+ hasDtermOverlay: dtermCurve !== null,
+ notchRange: range,
+ };
+ }, [data, filterSettings]);
+ const hasFilterOverlay = hasGyroOverlay || hasDtermOverlay;
+
const visibleAxes: Axis[] = selectedAxis === 'all' ? ['roll', 'pitch', 'yaw'] : [selectedAxis];
+ // Map rule anchors (evidence.anchorFrequencyHz) to peak frequencies so each
+ // annotated peak shows WHICH rule it triggered
+ const ruleForFrequency = (freq: number): string | undefined =>
+ recommendations?.find(
+ (r) =>
+ r.ruleId &&
+ r.evidence?.anchorFrequencyHz !== undefined &&
+ Math.abs(r.evidence.anchorFrequencyHz - freq) <= RULE_ANCHOR_TOLERANCE_HZ
+ )?.ruleId;
+
// Collect peaks for visible axes
const visiblePeaks: (NoisePeak & { axis: Axis })[] = [];
for (const axis of visibleAxes) {
@@ -120,7 +180,7 @@ export function SpectrumChart({ noise }: SpectrumChartProps) {
-
+
(Number.isFinite(v) ? v.toFixed(0) : '')}
@@ -147,6 +208,22 @@ export function SpectrumChart({ noise }: SpectrumChartProps) {
style: { fontSize: 11, fill: '#888' },
}}
/>
+ {hasFilterOverlay && (
+ (Number.isFinite(v) ? v.toFixed(0) : '')}
+ tick={{ fontSize: 11, fill: '#63e6be' }}
+ label={{
+ value: 'Filter (dB)',
+ angle: 90,
+ position: 'insideRight',
+ style: { fontSize: 11, fill: '#63e6be' },
+ }}
+ />
+ )}
+ {/* Dynamic notch tracking range (shaded band) */}
+ {notchRange && (
+
+ )}
+
{visibleAxes.map((axis) => (
))}
+ {/* Configured filter chain response (right axis, attenuation dB) —
+ each curve renders only when its chain has an active stage */}
+ {hasGyroOverlay && (
+
+ )}
+ {hasDtermOverlay && (
+
+ )}
+
{/* Noise floor reference lines */}
{noiseFloors.map(({ axis, value }) => (
))}
- {/* Peak markers as vertical reference lines */}
- {visiblePeaks.map((peak, i) => (
-
- ))}
+ {/* Peak markers as vertical reference lines (tagged with the rule they fired) */}
+ {visiblePeaks.map((peak, i) => {
+ const ruleId = ruleForFrequency(peak.frequency);
+ const baseLabel = `${PEAK_LABELS[peak.type] || peak.type} ${peak.frequency.toFixed(0)}Hz`;
+ return (
+
+ );
+ })}
+ {hasFilterOverlay && (
+
+ {hasGyroOverlay && (
+ – – Gyro filters
+ )}
+ {hasDtermOverlay && (
+ · · D-term filters
+ )}
+
+ configured attenuation (right axis; dynamic LPF shown at 50% throttle)
+
+
+ )}
);
}
diff --git a/src/renderer/components/TuningWizard/charts/ThrottleSpectrogramChart.test.tsx b/src/renderer/components/TuningWizard/charts/ThrottleSpectrogramChart.test.tsx
index 57a843c6..75a30aef 100644
--- a/src/renderer/components/TuningWizard/charts/ThrottleSpectrogramChart.test.tsx
+++ b/src/renderer/components/TuningWizard/charts/ThrottleSpectrogramChart.test.tsx
@@ -94,6 +94,58 @@ describe('ThrottleSpectrogramChart', () => {
expect(dbLabels.length).toBeGreaterThanOrEqual(2);
});
+ it('overlays the gyro LPF1 cutoff line when filterSettings provided', () => {
+ const { container } = render(
+
+ );
+ expect(container.querySelector('.spectrogram-svg polyline')).toBeInTheDocument();
+ expect(screen.getByText('Gyro LPF1')).toBeInTheDocument();
+ });
+
+ it('skips the cutoff overlay when LPF1 is disabled or out of range', () => {
+ const disabled = render(
+
+ );
+ expect(disabled.container.querySelector('.spectrogram-svg polyline')).toBeNull();
+ disabled.unmount();
+
+ // Cutoff far beyond the spectrogram's max frequency (495 Hz here)
+ const outOfRange = render(
+
+ );
+ expect(outOfRange.container.querySelector('.spectrogram-svg polyline')).toBeNull();
+ });
+
it('renders nothing when neither data nor compactData provided', () => {
const { container } = render( );
expect(container.querySelector('.spectrogram-chart')).toBeNull();
diff --git a/src/renderer/components/TuningWizard/charts/ThrottleSpectrogramChart.tsx b/src/renderer/components/TuningWizard/charts/ThrottleSpectrogramChart.tsx
index da194957..c3bc5568 100644
--- a/src/renderer/components/TuningWizard/charts/ThrottleSpectrogramChart.tsx
+++ b/src/renderer/components/TuningWizard/charts/ThrottleSpectrogramChart.tsx
@@ -7,8 +7,12 @@ import {
type Axis,
type HeatmapCell,
} from '../../../utils/spectrogramUtils';
-import type { ThrottleSpectrogramResult } from '@shared/types/analysis.types';
+import type {
+ ThrottleSpectrogramResult,
+ CurrentFilterSettings,
+} from '@shared/types/analysis.types';
import type { CompactThrottleSpectrogram } from '@shared/types/tuning-history.types';
+import { gyroLpf1CutoffAtThrottle } from '@shared/utils/filterResponse';
import './ThrottleSpectrogramChart.css';
interface ThrottleSpectrogramChartProps {
@@ -18,6 +22,9 @@ interface ThrottleSpectrogramChartProps {
compactData?: CompactThrottleSpectrogram;
/** When set, axis is controlled externally and local AxisTabs are hidden */
sharedAxis?: 'roll' | 'pitch' | 'yaw';
+ /** When provided, the gyro LPF1 cutoff is overlaid as a line across throttle
+ * bands (dynamic LPF follows its throttle curve; static is a vertical line) */
+ filterSettings?: CurrentFilterSettings;
}
const CHART_HEIGHT = 300;
@@ -34,6 +41,7 @@ export function ThrottleSpectrogramChart({
data,
compactData,
sharedAxis,
+ filterSettings,
}: ThrottleSpectrogramChartProps) {
const [localAxis, setLocalAxis] = useState('roll');
const [tooltip, setTooltip] = useState<{
@@ -181,6 +189,40 @@ export function ThrottleSpectrogramChart({
Throttle
+ {/* Gyro LPF1 cutoff overlay — dynamic LPF traces its throttle curve */}
+ {filterSettings &&
+ (() => {
+ const points: string[] = [];
+ for (let i = 0; i < numBands; i++) {
+ const band = heatmap.bands[i];
+ const cutoff = gyroLpf1CutoffAtThrottle(
+ filterSettings,
+ (band.min + band.max) / 2
+ );
+ if (cutoff === null || cutoff > maxFreq) continue;
+ const x = (cutoff / maxFreq) * heatmapWidth;
+ const y = (numBands - 1 - i) * cellHeight + cellHeight / 2;
+ points.push(`${x.toFixed(1)},${y.toFixed(1)}`);
+ }
+ if (points.length < 2) return null;
+ const [labelX, labelY] = points[points.length - 1].split(',').map(Number);
+ return (
+
+
+
+ Gyro LPF1
+
+
+ );
+ })()}
+
{/* Colorbar */}
0 means dynamic gyro LPF is active) */
gyro_lpf1_dyn_min_hz?: number;
@@ -331,6 +386,8 @@ export interface CurrentFilterSettings {
dterm_lpf1_dyn_max_hz?: number;
/** D-term LPF1 dynamic expo (0-10, controls how aggressively dynamic LPF tracks throttle) */
dterm_lpf1_dyn_expo?: number;
+ /** Gyro LPF1 dynamic expo (0-10, BF default 5). From BBL header. */
+ gyro_lpf1_dyn_expo?: number;
/** Gyro LPF1 filter type: 0=PT1, 1=BIQUAD, 2=PT2, 3=PT3 */
gyro_lpf1_type?: number;
@@ -472,6 +529,37 @@ export interface AxisStepProfile {
meanSteadyStateError: number;
/** Mean FF energy ratio across steps that have ffEnergyRatio (0-1) */
meanFFEnergyRatio?: number;
+ /** Where the headline overshoot/rise/settling numbers came from.
+ * 'deconvolved' = Wiener-stacked step response (preferred when coherent);
+ * 'per_step' = arithmetic mean of individually measured steps (fallback). */
+ metricsSource?: 'deconvolved' | 'per_step';
+ /** Per-step means kept for cross-checking when metricsSource='deconvolved' */
+ perStepMetrics?: {
+ meanOvershoot: number;
+ meanRiseTimeMs: number;
+ meanSettlingTimeMs: number;
+ };
+ /** Deconvolved step response split by commanded input magnitude
+ * (Betaflight's FF/D-setpoint transition behaves differently below vs
+ * above the split threshold, so the two regimes are measured separately) */
+ inputSplit?: {
+ splitThresholdDegS: number;
+ low?: DeconvolvedStepMetrics;
+ high?: DeconvolvedStepMetrics;
+ };
+}
+
+/** Metrics of a Wiener-deconvolved (stacked) step response */
+export interface DeconvolvedStepMetrics {
+ overshootPercent: number;
+ riseTimeMs: number;
+ settlingTimeMs: number;
+ /** Number of Welch windows stacked into this estimate */
+ windowCount: number;
+ /** Mean setpoint→gyro coherence over the stick band (0-1) */
+ coherenceMean?: number;
+ /** Normalized synthetic step response for chart rendering */
+ stepResponse?: { timeMs: number[]; response: number[] };
}
/** A single PID recommendation */
@@ -493,6 +581,8 @@ export interface PIDRecommendation {
informational?: boolean;
/** Structured rule identifier for telemetry tracking (e.g. "P-OS-D-roll") */
ruleId?: string;
+ /** Structured evidence explaining why the rule fired (P3.1) */
+ evidence?: RecommendationEvidence;
}
/** Bayesian optimizer suggestion for next PID gains to try */
@@ -571,6 +661,29 @@ export interface PIDAnalysisResult {
overshootPercent: number;
phaseMarginDeg: number;
};
+ /** Pitch-axis per-band analysis (P2.8) — same band/variance shape as roll */
+ pitch?: {
+ bands: {
+ throttleMin: number;
+ throttleMax: number;
+ sampleCount: number;
+ metrics: {
+ bandwidthHz: number;
+ phaseMarginDeg: number;
+ gainMarginDb: number;
+ overshootPercent: number;
+ settlingTimeMs: number;
+ riseTimeMs: number;
+ dcGainDb: number;
+ } | null;
+ }[];
+ bandsWithData: number;
+ metricsVariance: {
+ bandwidthHz: number;
+ overshootPercent: number;
+ phaseMarginDeg: number;
+ };
+ };
tpaWarning?: string;
};
/** Full transfer function data (present only for Flash Tune / Wiener deconvolution analysis).
@@ -585,16 +698,22 @@ export interface PIDAnalysisResult {
frequencies: Float64Array | number[];
magnitude: Float64Array | number[];
phase: Float64Array | number[];
+ /** Magnitude-squared coherence γ²(f), 0-1 per bin (absent with a single Welch window) */
+ coherence?: Float64Array | number[];
};
pitch: {
frequencies: Float64Array | number[];
magnitude: Float64Array | number[];
phase: Float64Array | number[];
+ /** Magnitude-squared coherence γ²(f), 0-1 per bin (absent with a single Welch window) */
+ coherence?: Float64Array | number[];
};
yaw: {
frequencies: Float64Array | number[];
magnitude: Float64Array | number[];
phase: Float64Array | number[];
+ /** Magnitude-squared coherence γ²(f), 0-1 per bin (absent with a single Welch window) */
+ coherence?: Float64Array | number[];
};
};
/** Per-axis transfer function metrics (only present for Wiener deconvolution analysis) */
@@ -605,6 +724,39 @@ export interface PIDAnalysisResult {
};
/** Verification flight similarity (only present when analyzing verification log with reference context) */
verificationSimilarity?: VerificationSimilarity;
+ /** System identification + what-if prediction (P3.2, Flash Tune only).
+ * Present only when the plant fit passed the coherence and quality gates.
+ * Always a PREDICTION — computed by re-closing the identified plant model
+ * with the proposed gains, never a measurement. */
+ whatIf?: {
+ roll?: AxisWhatIfPrediction;
+ pitch?: AxisWhatIfPrediction;
+ /** The gains the 'proposed' predictions were computed with */
+ proposedPIDs: PIDConfiguration;
+ };
+}
+
+/** Per-axis what-if prediction (P3.2) */
+export interface AxisWhatIfPrediction {
+ /** Identified plant model (2nd order + delay) */
+ plant: {
+ gainK: number;
+ naturalFreqHz: number;
+ damping: number;
+ delayMs: number;
+ fitQuality: number;
+ };
+ /** Prediction with the current flight gains (sanity anchor vs measured) */
+ current: WhatIfPredictedResponse;
+ /** Prediction with the proposed gains */
+ proposed: WhatIfPredictedResponse;
+}
+
+/** One predicted closed-loop response (P3.2) */
+export interface WhatIfPredictedResponse {
+ pids: { P: number; I: number; D: number };
+ response: { timeMs: number[]; response: number[] };
+ metrics: AxisTransferFunctionMetrics;
}
/** Per-axis transfer function metrics (mirrors TransferFunctionEstimator.TransferFunctionMetrics) */
@@ -727,12 +879,17 @@ export interface DynamicLowpassAnalysis {
// ---- Mechanical Health Types ----
/** Severity of a mechanical health issue */
-export type HealthSeverity = 'ok' | 'warning' | 'critical';
+export type HealthSeverity = 'ok' | 'info' | 'warning' | 'critical';
/** A detected mechanical health issue */
export interface MechanicalHealthIssue {
/** Type of detected issue */
- type: 'extreme_noise' | 'axis_asymmetry' | 'motor_imbalance';
+ type:
+ | 'extreme_noise'
+ | 'axis_asymmetry'
+ | 'motor_imbalance'
+ | 'motor_prop_signature'
+ | 'motor_bearing_signature';
/** Severity level */
severity: HealthSeverity;
/** Human-readable description */
@@ -743,6 +900,9 @@ export interface MechanicalHealthIssue {
measuredValue: number;
/** Threshold that was exceeded */
threshold: number;
+ /** Experimental per-motor spectral signature (P3.4) — thresholds are still
+ * being calibrated via telemetry; treat as a hint, not a diagnosis */
+ experimental?: boolean;
}
/** Mechanical health diagnostic result */
diff --git a/src/shared/types/tuning-history.types.ts b/src/shared/types/tuning-history.types.ts
index 8467091a..7a3cae3c 100644
--- a/src/shared/types/tuning-history.types.ts
+++ b/src/shared/types/tuning-history.types.ts
@@ -114,6 +114,10 @@ export interface PIDMetricsSummary {
dataQuality?: { overall: number; tier: string };
/** Best step response trace per axis (downsampled for history chart) */
stepResponse?: CompactStepResponse;
+ /** Which method produced the headline metrics ('deconvolved' when any axis
+ * used the stacked Wiener response). Deconvolved and per-step overshoot/
+ * settling live on different scales — cross-source comparisons are invalid. */
+ metricsSource?: 'per_step' | 'deconvolved';
}
/** Per-axis transfer function metrics summary for history storage */
diff --git a/src/shared/utils/bfVersionCapabilities.test.ts b/src/shared/utils/bfVersionCapabilities.test.ts
new file mode 100644
index 00000000..c36f43f4
--- /dev/null
+++ b/src/shared/utils/bfVersionCapabilities.test.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect } from 'vitest';
+import {
+ parseBFVersion,
+ getBFCapabilities,
+ translateSettingForVersion,
+} from './bfVersionCapabilities';
+
+describe('parseBFVersion', () => {
+ it('parses classic semver versions', () => {
+ expect(parseBFVersion('4.5.2')).toEqual({ major: 4, minor: 5, calendar: false });
+ expect(parseBFVersion('4.3.0')).toEqual({ major: 4, minor: 3, calendar: false });
+ });
+
+ it('parses calendar versions as BF 4.6+', () => {
+ expect(parseBFVersion('2025.12.0')).toEqual({ major: 2025, minor: 12, calendar: true });
+ });
+
+ it('returns null for garbage or missing input', () => {
+ expect(parseBFVersion(undefined)).toBeNull();
+ expect(parseBFVersion('')).toBeNull();
+ expect(parseBFVersion('unknown')).toBeNull();
+ });
+});
+
+describe('getBFCapabilities', () => {
+ it('gives the conservative 4.3 baseline for unknown versions', () => {
+ const caps = getBFCapabilities(undefined);
+ expect(caps.hasTpaLow).toBe(false);
+ expect(caps.hasRpmWeights).toBe(false);
+ expect(caps.usesDMax).toBe(false);
+ });
+
+ it('BF 4.4 has no 4.5+ features', () => {
+ const caps = getBFCapabilities('4.4.3');
+ expect(caps.hasTpaLow).toBe(false);
+ expect(caps.hasRpmWeights).toBe(false);
+ expect(caps.hasAntiGravityCutoff).toBe(false);
+ expect(caps.usesDMax).toBe(false);
+ });
+
+ it('BF 4.5 gains tpa_low, RPM weights and anti-gravity cutoff', () => {
+ const caps = getBFCapabilities('4.5.1');
+ expect(caps.hasTpaLow).toBe(true);
+ expect(caps.hasRpmWeights).toBe(true);
+ expect(caps.hasAntiGravityCutoff).toBe(true);
+ expect(caps.usesDMax).toBe(false);
+ expect(caps.hasChirp).toBe(false);
+ });
+
+ it('calendar versions (2025.12 = BF 4.6) gain d_max rename and chirp', () => {
+ const caps = getBFCapabilities('2025.12.0');
+ expect(caps.hasTpaLow).toBe(true);
+ expect(caps.usesDMax).toBe(true);
+ expect(caps.hasChirp).toBe(true);
+ });
+
+ it('classic "4.6.0" (what real 2025.12 firmware reports via MSP) also gains d_max + chirp', () => {
+ // BF 2025.12 still reports "4.6.0" in MSP_FC_VERSION — the 4.6 features
+ // must key off the classic version, not the calendar naming
+ const caps = getBFCapabilities('4.6.0');
+ expect(caps.usesDMax).toBe(true);
+ expect(caps.hasChirp).toBe(true);
+ expect(caps.hasTpaLow).toBe(true);
+ });
+});
+
+describe('translateSettingForVersion', () => {
+ it('renames d_min settings to d_max on 4.6+', () => {
+ const caps = getBFCapabilities('2025.12.0');
+ expect(translateSettingForVersion('d_min_gain', caps)).toBe('d_max_gain');
+ expect(translateSettingForVersion('d_min_advance', caps)).toBe('d_max_advance');
+ expect(translateSettingForVersion('d_min_roll', caps)).toBe('d_max_roll');
+ });
+
+ it('leaves names unchanged on 4.5 and below', () => {
+ const caps = getBFCapabilities('4.5.2');
+ expect(translateSettingForVersion('d_min_gain', caps)).toBe('d_min_gain');
+ });
+
+ it('leaves unrelated settings unchanged on every version', () => {
+ const caps = getBFCapabilities('2025.12.0');
+ expect(translateSettingForVersion('gyro_lpf1_static_hz', caps)).toBe('gyro_lpf1_static_hz');
+ expect(translateSettingForVersion('tpa_rate', caps)).toBe('tpa_rate');
+ });
+});
diff --git a/src/shared/utils/bfVersionCapabilities.ts b/src/shared/utils/bfVersionCapabilities.ts
new file mode 100644
index 00000000..528bda14
--- /dev/null
+++ b/src/shared/utils/bfVersionCapabilities.ts
@@ -0,0 +1,99 @@
+/**
+ * Betaflight version-capabilities layer (P2.5).
+ *
+ * Maps a firmware version string to feature availability and setting-name
+ * differences, so recommenders can gate version-specific settings and the
+ * apply/verify flow can translate renamed CLI settings.
+ *
+ * Version scheme: classic semver up to 4.5.x; calendar versions from 4.6
+ * ("2025.12" is the 4.6 release). Calendar-versioned firmware is treated as
+ * newer than every classic 4.x release.
+ */
+
+/** Parsed firmware version */
+export interface BFVersion {
+ major: number;
+ minor: number;
+ /** True for calendar-scheme versions (2025.12+, i.e. BF 4.6+) */
+ calendar: boolean;
+}
+
+/** Feature availability for a firmware version */
+export interface BFCapabilities {
+ /** Low-throttle TPA (tpa_low_rate / tpa_low_breakpoint / tpa_low_always), BF 4.5+ */
+ hasTpaLow: boolean;
+ /** Dimmable per-harmonic RPM notch weights (rpm_filter_weights), BF 4.5+ */
+ hasRpmWeights: boolean;
+ /** anti_gravity_cutoff_hz / anti_gravity_p_gain, BF 4.5+ */
+ hasAntiGravityCutoff: boolean;
+ /** d_min → d_max rename (2025.12 / BF 4.6+): the PID D value became the
+ * minimum, d_max_* the maximum; d_min_gain/advance → d_max_gain/advance */
+ usesDMax: boolean;
+ /** Chirp signal generator for transfer-function identification, BF 4.6+ */
+ hasChirp: boolean;
+}
+
+/** Parse a Betaflight version string ("4.5.2", "2025.12.0"). Null if unparseable. */
+export function parseBFVersion(version: string | undefined): BFVersion | null {
+ if (!version) return null;
+ const match = version.match(/(\d+)\.(\d+)/);
+ if (!match) return null;
+ const major = parseInt(match[1], 10);
+ const minor = parseInt(match[2], 10);
+ if (isNaN(major) || isNaN(minor)) return null;
+ return { major, minor, calendar: major >= 2000 };
+}
+
+/** True when the parsed version is at least the given classic major.minor.
+ * Calendar versions (2025.12+) are newer than every classic 4.x release. */
+function atLeast(v: BFVersion, major: number, minor: number): boolean {
+ if (v.calendar) return true;
+ return v.major > major || (v.major === major && v.minor >= minor);
+}
+
+/** Capabilities for a firmware version string. Unknown/unparseable versions
+ * get the conservative BF 4.3 baseline (no 4.5+/4.6+ features). */
+export function getBFCapabilities(version?: string): BFCapabilities {
+ const v = parseBFVersion(version);
+ if (!v) {
+ return {
+ hasTpaLow: false,
+ hasRpmWeights: false,
+ hasAntiGravityCutoff: false,
+ usesDMax: false,
+ hasChirp: false,
+ };
+ }
+ return {
+ hasTpaLow: atLeast(v, 4, 5),
+ hasRpmWeights: atLeast(v, 4, 5),
+ hasAntiGravityCutoff: atLeast(v, 4, 5),
+ // BF 2025.12 still reports "4.6.0" via MSP_FC_VERSION (the internal
+ // version keeps incrementing — see headerValidation.isGyroScaledRemoved),
+ // so 4.6+ features must key off the classic version, not the calendar
+ // naming, or they'd never activate on real firmware.
+ usesDMax: atLeast(v, 4, 6),
+ hasChirp: atLeast(v, 4, 6),
+ };
+}
+
+/** CLI setting renames applied by the d_min → d_max transition (BF 4.6+).
+ * Name-level mapping only — the gain/advance semantics are unchanged. */
+const D_MAX_RENAMES: Record = {
+ d_min_gain: 'd_max_gain',
+ d_min_advance: 'd_max_advance',
+ d_min_roll: 'd_max_roll',
+ d_min_pitch: 'd_max_pitch',
+ d_min_yaw: 'd_max_yaw',
+};
+
+/**
+ * Translate a recommendation's CLI setting name for the target firmware.
+ * Returns the name unchanged when no rename applies.
+ */
+export function translateSettingForVersion(setting: string, capabilities: BFCapabilities): string {
+ if (capabilities.usesDMax && D_MAX_RENAMES[setting]) {
+ return D_MAX_RENAMES[setting];
+ }
+ return setting;
+}
diff --git a/src/shared/utils/filterResponse.test.ts b/src/shared/utils/filterResponse.test.ts
new file mode 100644
index 00000000..8365abcc
--- /dev/null
+++ b/src/shared/utils/filterResponse.test.ts
@@ -0,0 +1,172 @@
+import { describe, it, expect } from 'vitest';
+import {
+ pt1MagnitudeDb,
+ ptnMagnitudeDb,
+ biquadLpfMagnitudeDb,
+ notchMagnitudeDb,
+ lowpassMagnitudeDb,
+ dynLpfCutoffHz,
+ computeFilterChainCurve,
+ gyroLpf1CutoffAtThrottle,
+ FILTER_TYPE_BIQUAD,
+ FILTER_TYPE_PT2,
+ FILTER_TYPE_PT3,
+ FILTER_RESPONSE_FLOOR_DB,
+} from './filterResponse';
+import { DEFAULT_FILTER_SETTINGS } from '../types/analysis.types';
+import type { CurrentFilterSettings } from '../types/analysis.types';
+
+function settings(overrides: Partial): CurrentFilterSettings {
+ return { ...DEFAULT_FILTER_SETTINGS, ...overrides };
+}
+
+describe('filter magnitude models', () => {
+ it('PT1 is -3 dB at its cutoff and 0 dB at DC', () => {
+ expect(pt1MagnitudeDb(0, 100)).toBeCloseTo(0, 5);
+ expect(pt1MagnitudeDb(100, 100)).toBeCloseTo(-3.0103, 3);
+ // First-order rolloff: -20 dB/decade
+ expect(pt1MagnitudeDb(1000, 100)).toBeCloseTo(-20.04, 1);
+ });
+
+ it('PT2 and PT3 cascades are -3 dB at the configured cutoff (BF correction)', () => {
+ expect(ptnMagnitudeDb(100, 100, 2)).toBeCloseTo(-3.01, 1);
+ expect(ptnMagnitudeDb(100, 100, 3)).toBeCloseTo(-3.01, 1);
+ // Steeper rolloff than PT1 past the cutoff
+ expect(ptnMagnitudeDb(1000, 100, 2)).toBeLessThan(pt1MagnitudeDb(1000, 100));
+ expect(ptnMagnitudeDb(1000, 100, 3)).toBeLessThan(ptnMagnitudeDb(1000, 100, 2));
+ });
+
+ it('Butterworth biquad is -3 dB at cutoff with -40 dB/decade rolloff', () => {
+ expect(biquadLpfMagnitudeDb(100, 100)).toBeCloseTo(-3.0103, 3);
+ expect(biquadLpfMagnitudeDb(1000, 100)).toBeCloseTo(-40, 0);
+ });
+
+ it('notch is deepest at center and near-transparent far away', () => {
+ expect(notchMagnitudeDb(200, 200, 5)).toBe(FILTER_RESPONSE_FLOOR_DB);
+ expect(notchMagnitudeDb(20, 200, 5)).toBeGreaterThan(-1);
+ expect(notchMagnitudeDb(2000, 200, 5)).toBeGreaterThan(-1);
+ });
+
+ it('disabled stages (cutoff 0) contribute 0 dB', () => {
+ expect(pt1MagnitudeDb(100, 0)).toBe(0);
+ expect(biquadLpfMagnitudeDb(100, 0)).toBe(0);
+ expect(notchMagnitudeDb(100, 0, 5)).toBe(0);
+ });
+
+ it('lowpassMagnitudeDb dispatches by BF filter type', () => {
+ expect(lowpassMagnitudeDb(100, 100, FILTER_TYPE_BIQUAD)).toBeCloseTo(
+ biquadLpfMagnitudeDb(100, 100),
+ 6
+ );
+ expect(lowpassMagnitudeDb(100, 100, FILTER_TYPE_PT2)).toBeCloseTo(
+ ptnMagnitudeDb(100, 100, 2),
+ 6
+ );
+ expect(lowpassMagnitudeDb(100, 100, FILTER_TYPE_PT3)).toBeCloseTo(
+ ptnMagnitudeDb(100, 100, 3),
+ 6
+ );
+ // undefined defaults to PT1
+ expect(lowpassMagnitudeDb(100, 100, undefined)).toBeCloseTo(pt1MagnitudeDb(100, 100), 6);
+ });
+});
+
+describe('dynLpfCutoffHz (BF throttle curve)', () => {
+ it('hits min at zero throttle and max at full throttle', () => {
+ expect(dynLpfCutoffHz(0, 250, 500, 5)).toBe(250);
+ expect(dynLpfCutoffHz(1, 250, 500, 5)).toBe(500);
+ });
+
+ it('is linear when expo is 0', () => {
+ expect(dynLpfCutoffHz(0.5, 250, 500, 0)).toBeCloseTo(375, 5);
+ });
+
+ it('expo boosts the cutoff at mid throttle', () => {
+ // curve = 0.5·0.5·0.5 + 0.5 = 0.625 → 250 + 0.625·250 = 406.25
+ expect(dynLpfCutoffHz(0.5, 250, 500, 5)).toBeCloseTo(406.25, 2);
+ });
+
+ it('clamps throttle outside 0..1', () => {
+ expect(dynLpfCutoffHz(-0.5, 250, 500, 5)).toBe(250);
+ expect(dynLpfCutoffHz(1.5, 250, 500, 5)).toBe(500);
+ });
+});
+
+describe('computeFilterChainCurve', () => {
+ it('combines LPF1 + LPF2 attenuation in dB', () => {
+ const s = settings({
+ gyro_lpf1_static_hz: 100,
+ gyro_lpf2_static_hz: 100,
+ gyro_lpf1_dyn_min_hz: 0,
+ });
+ const curve = computeFilterChainCurve(s, 'gyro', [100]);
+ expect(curve).not.toBeNull();
+ // Two PT1 stages at 100 Hz → 2 × -3.01 dB at 100 Hz
+ expect(curve![0]).toBeCloseTo(-6.02, 1);
+ });
+
+ it('returns null when the chain has no active stage', () => {
+ const s = settings({
+ gyro_lpf1_static_hz: 0,
+ gyro_lpf2_static_hz: 0,
+ gyro_lpf1_dyn_min_hz: 0,
+ });
+ expect(computeFilterChainCurve(s, 'gyro', [100])).toBeNull();
+ });
+
+ it('evaluates dynamic LPF1 at the requested throttle', () => {
+ const s = settings({
+ gyro_lpf1_dyn_min_hz: 250,
+ gyro_lpf1_dyn_max_hz: 500,
+ gyro_lpf1_dyn_expo: 0,
+ gyro_lpf2_static_hz: 0,
+ });
+ const low = computeFilterChainCurve(s, 'gyro', [250], 0)!;
+ const high = computeFilterChainCurve(s, 'gyro', [250], 1)!;
+ expect(low[0]).toBeCloseTo(-3.01, 1); // cutoff at 250 → -3 dB
+ expect(high[0]).toBeGreaterThan(low[0]); // cutoff at 500 → less attenuation at 250 Hz
+ });
+
+ it('clamps combined attenuation to the display floor', () => {
+ const s = settings({
+ gyro_lpf1_static_hz: 50,
+ gyro_lpf2_static_hz: 50,
+ gyro_lpf1_dyn_min_hz: 0,
+ });
+ const curve = computeFilterChainCurve(s, 'gyro', [5000])!;
+ expect(curve[0]).toBe(FILTER_RESPONSE_FLOOR_DB);
+ });
+
+ it('uses the D-term fields for the dterm chain', () => {
+ const s = settings({
+ dterm_lpf1_static_hz: 150,
+ dterm_lpf2_static_hz: 0,
+ dterm_lpf1_dyn_min_hz: 0,
+ });
+ const curve = computeFilterChainCurve(s, 'dterm', [150])!;
+ expect(curve[0]).toBeCloseTo(-3.01, 1);
+ });
+});
+
+describe('gyroLpf1CutoffAtThrottle', () => {
+ it('returns the static cutoff regardless of throttle', () => {
+ const s = settings({ gyro_lpf1_static_hz: 250, gyro_lpf1_dyn_min_hz: 0 });
+ expect(gyroLpf1CutoffAtThrottle(s, 0)).toBe(250);
+ expect(gyroLpf1CutoffAtThrottle(s, 1)).toBe(250);
+ });
+
+ it('follows the dynamic curve when dynamic LPF is active', () => {
+ const s = settings({
+ gyro_lpf1_dyn_min_hz: 250,
+ gyro_lpf1_dyn_max_hz: 500,
+ gyro_lpf1_dyn_expo: 0,
+ });
+ expect(gyroLpf1CutoffAtThrottle(s, 0)).toBe(250);
+ expect(gyroLpf1CutoffAtThrottle(s, 1)).toBe(500);
+ });
+
+ it('returns null when LPF1 is fully disabled', () => {
+ const s = settings({ gyro_lpf1_static_hz: 0, gyro_lpf1_dyn_min_hz: 0 });
+ expect(gyroLpf1CutoffAtThrottle(s, 0.5)).toBeNull();
+ });
+});
diff --git a/src/shared/utils/filterResponse.ts b/src/shared/utils/filterResponse.ts
new file mode 100644
index 00000000..b18a7ff2
--- /dev/null
+++ b/src/shared/utils/filterResponse.ts
@@ -0,0 +1,184 @@
+/**
+ * Filter magnitude-response models (P2.3).
+ *
+ * Computes |H(f)| in dB for the Betaflight gyro / D-term lowpass chains so the
+ * renderer can overlay the configured filter attenuation on measured noise
+ * spectra and throttle spectrograms (parity with Blackbox Explorer 2025.12).
+ *
+ * Models match the firmware:
+ * - PT1/PT2/PT3 as cascaded first-order stages with Betaflight's cutoff
+ * correction (the cascade is -3 dB at the configured cutoff)
+ * - BIQUAD as a 2nd-order Butterworth lowpass (Q = 1/√2)
+ * - Dynamic lowpass cutoff via the firmware's throttle curve
+ * (dynLpfCutoffFreq: curve = t·(1−t)·expo/10 + t)
+ */
+import type { CurrentFilterSettings } from '../types/analysis.types';
+
+/** Betaflight filter type values (gyro_lpf1_type etc.) */
+export const FILTER_TYPE_PT1 = 0;
+export const FILTER_TYPE_BIQUAD = 1;
+export const FILTER_TYPE_PT2 = 2;
+export const FILTER_TYPE_PT3 = 3;
+
+/** Betaflight cutoff corrections so PTn cascades hit -3 dB at the set cutoff */
+const PT2_CUTOFF_CORRECTION = 1.553773974;
+const PT3_CUTOFF_CORRECTION = 1.961459177;
+
+/** Attenuation floor for display — notches are mathematically -∞ at center */
+export const FILTER_RESPONSE_FLOOR_DB = -60;
+
+/** PT1 lowpass magnitude in dB at frequency f for cutoff fc */
+export function pt1MagnitudeDb(f: number, fc: number): number {
+ if (fc <= 0) return 0;
+ const r = f / fc;
+ return -10 * Math.log10(1 + r * r);
+}
+
+/** PTn cascade magnitude in dB (BF cutoff-corrected so -3 dB lands at fc) */
+export function ptnMagnitudeDb(f: number, fc: number, order: 2 | 3): number {
+ if (fc <= 0) return 0;
+ const correction = order === 2 ? PT2_CUTOFF_CORRECTION : PT3_CUTOFF_CORRECTION;
+ return order * pt1MagnitudeDb(f, fc * correction);
+}
+
+/** Biquad (Butterworth, Q = 1/√2) lowpass magnitude in dB */
+export function biquadLpfMagnitudeDb(f: number, fc: number): number {
+ if (fc <= 0) return 0;
+ const r = f / fc;
+ const q = Math.SQRT1_2;
+ const denom = (1 - r * r) * (1 - r * r) + (r / q) * (r / q);
+ return -10 * Math.log10(denom);
+}
+
+/** Notch magnitude in dB for center frequency fc and quality factor q */
+export function notchMagnitudeDb(f: number, fc: number, q: number): number {
+ if (fc <= 0 || q <= 0) return 0;
+ const r = f / fc;
+ const num = (1 - r * r) * (1 - r * r);
+ const denom = num + (r / q) * (r / q);
+ if (num <= 0 || denom <= 0) return FILTER_RESPONSE_FLOOR_DB;
+ return Math.max(10 * Math.log10(num / denom), FILTER_RESPONSE_FLOOR_DB);
+}
+
+/** Magnitude of one lowpass stage by Betaflight filter type */
+export function lowpassMagnitudeDb(f: number, fc: number, type: number | undefined): number {
+ switch (type) {
+ case FILTER_TYPE_BIQUAD:
+ return biquadLpfMagnitudeDb(f, fc);
+ case FILTER_TYPE_PT2:
+ return ptnMagnitudeDb(f, fc, 2);
+ case FILTER_TYPE_PT3:
+ return ptnMagnitudeDb(f, fc, 3);
+ case FILTER_TYPE_PT1:
+ default:
+ return pt1MagnitudeDb(f, fc);
+ }
+}
+
+/**
+ * Dynamic lowpass cutoff at a given throttle — Betaflight's dynLpfCutoffFreq.
+ * @param throttleNorm - normalized throttle 0..1
+ * @param expo - dyn expo setting 0-10 (0 = linear)
+ */
+export function dynLpfCutoffHz(
+ throttleNorm: number,
+ minHz: number,
+ maxHz: number,
+ expo: number
+): number {
+ const t = Math.min(1, Math.max(0, throttleNorm));
+ const expof = expo / 10;
+ const curve = t * (1 - t) * expof + t;
+ return (maxHz - minHz) * curve + minHz;
+}
+
+/** One lowpass stage of a filter chain */
+interface ChainStage {
+ cutoffHz: number;
+ type: number | undefined;
+}
+
+/** Resolve the active lowpass stages for a chain at a given throttle */
+function resolveChainStages(
+ settings: CurrentFilterSettings,
+ chain: 'gyro' | 'dterm',
+ throttleNorm: number
+): ChainStage[] {
+ const stages: ChainStage[] = [];
+
+ if (chain === 'gyro') {
+ const dynMin = settings.gyro_lpf1_dyn_min_hz ?? 0;
+ if (dynMin > 0) {
+ const dynMax = settings.gyro_lpf1_dyn_max_hz ?? dynMin * 2;
+ const expo = settings.gyro_lpf1_dyn_expo ?? 5;
+ stages.push({
+ cutoffHz: dynLpfCutoffHz(throttleNorm, dynMin, dynMax, expo),
+ type: settings.gyro_lpf1_type,
+ });
+ } else if (settings.gyro_lpf1_static_hz > 0) {
+ stages.push({ cutoffHz: settings.gyro_lpf1_static_hz, type: settings.gyro_lpf1_type });
+ }
+ if (settings.gyro_lpf2_static_hz > 0) {
+ stages.push({ cutoffHz: settings.gyro_lpf2_static_hz, type: settings.gyro_lpf2_type });
+ }
+ } else {
+ const dynMin = settings.dterm_lpf1_dyn_min_hz ?? 0;
+ if (dynMin > 0) {
+ const dynMax = settings.dterm_lpf1_dyn_max_hz ?? dynMin * 2;
+ const expo = settings.dterm_lpf1_dyn_expo ?? 5;
+ stages.push({
+ cutoffHz: dynLpfCutoffHz(throttleNorm, dynMin, dynMax, expo),
+ type: settings.dterm_lpf1_type,
+ });
+ } else if (settings.dterm_lpf1_static_hz > 0) {
+ stages.push({ cutoffHz: settings.dterm_lpf1_static_hz, type: settings.dterm_lpf1_type });
+ }
+ if (settings.dterm_lpf2_static_hz > 0) {
+ stages.push({ cutoffHz: settings.dterm_lpf2_static_hz, type: settings.dterm_lpf2_type });
+ }
+ }
+
+ return stages;
+}
+
+/**
+ * Combined lowpass-chain magnitude in dB at the given frequencies.
+ *
+ * Dynamic lowpasses are evaluated at `throttleNorm` (default 0.5 — cruise).
+ * Returns null when the chain has no active lowpass stage (nothing to draw).
+ */
+export function computeFilterChainCurve(
+ settings: CurrentFilterSettings,
+ chain: 'gyro' | 'dterm',
+ frequencies: number[],
+ throttleNorm = 0.5
+): number[] | null {
+ const stages = resolveChainStages(settings, chain, throttleNorm);
+ if (stages.length === 0) return null;
+
+ return frequencies.map((f) => {
+ let db = 0;
+ for (const stage of stages) {
+ db += lowpassMagnitudeDb(f, stage.cutoffHz, stage.type);
+ }
+ return Math.max(db, FILTER_RESPONSE_FLOOR_DB);
+ });
+}
+
+/**
+ * Gyro LPF1 cutoff as a function of throttle for the spectrogram overlay.
+ * Static configs return the same cutoff for every throttle; disabled LPF1
+ * (static 0, dynamic off) returns null.
+ */
+export function gyroLpf1CutoffAtThrottle(
+ settings: CurrentFilterSettings,
+ throttleNorm: number
+): number | null {
+ const dynMin = settings.gyro_lpf1_dyn_min_hz ?? 0;
+ if (dynMin > 0) {
+ const dynMax = settings.gyro_lpf1_dyn_max_hz ?? dynMin * 2;
+ const expo = settings.gyro_lpf1_dyn_expo ?? 5;
+ return dynLpfCutoffHz(throttleNorm, dynMin, dynMax, expo);
+ }
+ return settings.gyro_lpf1_static_hz > 0 ? settings.gyro_lpf1_static_hz : null;
+}
diff --git a/src/shared/utils/metricsExtract.ts b/src/shared/utils/metricsExtract.ts
index 2c2cd967..b9160380 100644
--- a/src/shared/utils/metricsExtract.ts
+++ b/src/shared/utils/metricsExtract.ts
@@ -509,5 +509,10 @@ export function extractPIDMetrics(result: PIDAnalysisResult): PIDMetricsSummary
? { dataQuality: { overall: result.dataQuality.overall, tier: result.dataQuality.tier } }
: {}),
...(stepResponse ? { stepResponse } : {}),
+ metricsSource: [result.roll, result.pitch, result.yaw].some(
+ (a) => a.metricsSource === 'deconvolved'
+ )
+ ? 'deconvolved'
+ : 'per_step',
};
}