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. +

+ + + )} + @@ -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', }; }