inav-toolkit 2.16.1__tar.gz → 2.18.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/PKG-INFO +1 -1
  2. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit/__init__.py +1 -1
  3. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit/blackbox_analyzer.py +285 -107
  4. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit/flight_db.py +1 -1
  5. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit/msp.py +12 -1
  6. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit/param_analyzer.py +119 -22
  7. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit/vtol_configurator.py +1 -1
  8. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit/wizard.py +1 -1
  9. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit.egg-info/PKG-INFO +1 -1
  10. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/pyproject.toml +1 -1
  11. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/LICENSE +0 -0
  12. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/README.md +0 -0
  13. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit/autotune.py +0 -0
  14. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit/i18n.py +0 -0
  15. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit/locales/en.json +0 -0
  16. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit/locales/es.json +0 -0
  17. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit/locales/pt_BR.json +0 -0
  18. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit.egg-info/SOURCES.txt +0 -0
  19. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit.egg-info/dependency_links.txt +0 -0
  20. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit.egg-info/entry_points.txt +0 -0
  21. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit.egg-info/requires.txt +0 -0
  22. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/inav_toolkit.egg-info/top_level.txt +0 -0
  23. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/setup.cfg +0 -0
  24. {inav_toolkit-2.16.1 → inav_toolkit-2.18.0}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inav-toolkit
3
- Version: 2.16.1
3
+ Version: 2.18.0
4
4
  Summary: Blackbox analyzer, parameter checker, and tuning wizard for INAV flight controllers
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/agoliveira/INAV-Toolkit
@@ -1,3 +1,3 @@
1
1
  """INAV Toolkit - Blackbox analyzer, parameter checker, and tuning wizard for INAV flight controllers."""
2
2
 
3
- __version__ = "2.16.1"
3
+ __version__ = "2.18.0"
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- INAV Blackbox Analyzer - Multirotor Tuning Tool v2.16.1
3
+ INAV Blackbox Analyzer - Multirotor Tuning Tool v2.18.0
4
4
  =====================================================
5
5
  Analyzes INAV blackbox logs and tells you EXACTLY what to change.
6
6
 
@@ -104,7 +104,7 @@ def _disable_colors():
104
104
  AXIS_NAMES = ["Roll", "Pitch", "Yaw"]
105
105
  AXIS_COLORS = ["#FF6B6B", "#4ECDC4", "#FFD93D"]
106
106
  MOTOR_COLORS = ["#FF6B6B", "#4ECDC4", "#FFD93D", "#A78BFA"]
107
- REPORT_VERSION = "2.16.1"
107
+ REPORT_VERSION = "2.18.0"
108
108
 
109
109
  # ─── Frame and Prop Profiles ─────────────────────────────────────────────────
110
110
  # Two separate concerns:
@@ -882,7 +882,7 @@ CLI_BOOL_KEYS = {
882
882
  }
883
883
 
884
884
 
885
- def merge_diff_into_config(config, diff_raw):
885
+ def merge_diff_into_config(config, config_raw):
886
886
  """Merge INAV CLI 'diff all' output into the analysis config dict.
887
887
 
888
888
  Strategy:
@@ -895,19 +895,19 @@ def merge_diff_into_config(config, diff_raw):
895
895
 
896
896
  Args:
897
897
  config: Existing config dict from extract_fc_config()
898
- diff_raw: Raw 'diff all' output string
898
+ config_raw: Raw 'diff all' output string
899
899
 
900
900
  Returns:
901
901
  Number of settings merged
902
902
  """
903
- if not diff_raw:
903
+ if not config_raw:
904
904
  return 0
905
905
 
906
906
  try:
907
907
  from inav_toolkit.flight_db import parse_diff_output
908
908
  except ImportError:
909
909
  from inav_flight_db import parse_diff_output
910
- diff_settings = parse_diff_output(diff_raw)
910
+ diff_settings = parse_diff_output(config_raw)
911
911
 
912
912
  merged = 0
913
913
  mismatches = []
@@ -2542,17 +2542,34 @@ def measure_tracking_delay_xcorr(sp, gy, sr, max_delay_ms=200):
2542
2542
  return float(np.median(delays))
2543
2543
 
2544
2544
 
2545
- def detect_hover_oscillation(data, sr):
2545
+ def detect_hover_oscillation(data, sr, profile=None):
2546
2546
  """Detect oscillation during hover (no/minimal stick input).
2547
2547
 
2548
2548
  Finds segments where setpoint is near zero, then measures gyro
2549
2549
  oscillation amplitude and dominant frequency. This catches the most
2550
2550
  dangerous tuning problem: a quad that can't hold still.
2551
2551
 
2552
+ Frame-size aware: larger frames naturally have more gyro activity,
2553
+ and low-frequency wobble (1-5Hz) outdoors is often wind buffeting,
2554
+ not P oscillation. The cause classification accounts for this.
2555
+
2552
2556
  Returns list of per-axis dicts:
2553
2557
  axis, gyro_rms, gyro_p2p, dominant_freq_hz, severity, cause, hover_seconds
2554
2558
  Or empty list if no hover segments found.
2555
2559
  """
2560
+ if profile is None:
2561
+ profile = get_frame_profile(5)
2562
+
2563
+ # Scale severity thresholds by frame size.
2564
+ # Larger frames have more inertia and naturally higher gyro readings.
2565
+ frame_inches = profile.get("frame_inches", 5)
2566
+ size_factor = max(1.0, frame_inches / 7.0) # 1.0 for 7", 1.43 for 10", 2.14 for 15"
2567
+
2568
+ # Severity thresholds (RMS deg/s) — scaled by frame size
2569
+ sev_none = 2.0 * size_factor # 2.0 for 7", 2.9 for 10"
2570
+ sev_mild = 5.0 * size_factor # 5.0 for 7", 7.1 for 10"
2571
+ sev_moderate = 15.0 * size_factor # 15.0 for 7", 21.4 for 10"
2572
+
2556
2573
  results = []
2557
2574
  min_hover_samples = int(sr * 0.5) # At least 0.5s of hover
2558
2575
 
@@ -2609,6 +2626,7 @@ def detect_hover_oscillation(data, sr):
2609
2626
 
2610
2627
  # Dominant frequency via FFT
2611
2628
  dominant_freq = None
2629
+ peak_prominence = 0 # How much the peak stands out from noise floor
2612
2630
  if len(hover_gyro) >= int(sr * 0.25): # Need at least 0.25s for FFT
2613
2631
  freqs = rfftfreq(len(hover_gyro), 1.0 / sr)
2614
2632
  spectrum = np.abs(rfft(hover_gyro))
@@ -2621,29 +2639,35 @@ def detect_hover_oscillation(data, sr):
2621
2639
  dominant_freq = float(masked_freqs[peak_idx])
2622
2640
  peak_power = masked_spectrum[peak_idx]
2623
2641
  mean_power = np.mean(masked_spectrum)
2642
+ peak_prominence = peak_power / mean_power if mean_power > 0 else 0
2624
2643
  # Only report if peak is significantly above noise floor
2625
- if peak_power < mean_power * 3:
2644
+ if peak_prominence < 3:
2626
2645
  dominant_freq = None # No clear dominant frequency
2627
2646
 
2628
- # Classify severity
2629
- # For a well-tuned hover: gyro_rms < 2, p2p < 10
2630
- # Mild wobble: rms 2-8, p2p 10-30
2631
- # Serious oscillation: rms 8-20, p2p 30-80
2632
- # Dangerous: rms > 20, p2p > 80
2633
- if gyro_rms < 2:
2647
+ # Classify severity (frame-size-scaled thresholds)
2648
+ if gyro_rms < sev_none:
2634
2649
  severity = "none"
2635
- elif gyro_rms < 5:
2650
+ elif gyro_rms < sev_mild:
2636
2651
  severity = "mild"
2637
- elif gyro_rms < 15:
2652
+ elif gyro_rms < sev_moderate:
2638
2653
  severity = "moderate"
2639
2654
  else:
2640
2655
  severity = "severe"
2641
2656
 
2642
- # Classify cause from dominant frequency
2657
+ # Classify cause from dominant frequency — frame-size aware
2643
2658
  cause = None
2644
2659
  if severity != "none" and dominant_freq is not None:
2645
2660
  if dominant_freq < 10:
2646
- cause = "P_too_high" # Low-freq: P-term overshoot
2661
+ # Low-frequency wobble. On large frames (10"+), this is often
2662
+ # wind buffeting or GPS position hold corrections, NOT P oscillation.
2663
+ # True P oscillation has a sharp spectral peak (high prominence).
2664
+ # Wind buffeting is broadband (low prominence).
2665
+ if frame_inches >= 10 and peak_prominence < 6:
2666
+ cause = "wind_buffeting" # Likely environmental, not tuning
2667
+ elif frame_inches >= 10 and dominant_freq < 3:
2668
+ cause = "wind_buffeting" # <3Hz on 10"+ is almost certainly wind
2669
+ else:
2670
+ cause = "P_too_high"
2647
2671
  elif dominant_freq < 25:
2648
2672
  cause = "PD_interaction" # Mid-freq: P/D fighting
2649
2673
  elif dominant_freq < 50:
@@ -2651,7 +2675,11 @@ def detect_hover_oscillation(data, sr):
2651
2675
  else:
2652
2676
  cause = "filter_gap" # High-freq noise leaking through
2653
2677
  elif severity != "none":
2654
- cause = "unknown"
2678
+ # No dominant frequency — broadband noise, likely environmental
2679
+ if frame_inches >= 10:
2680
+ cause = "wind_buffeting"
2681
+ else:
2682
+ cause = "unknown"
2655
2683
 
2656
2684
  results.append({
2657
2685
  "axis": axis,
@@ -2661,6 +2689,7 @@ def detect_hover_oscillation(data, sr):
2661
2689
  "severity": severity,
2662
2690
  "cause": cause,
2663
2691
  "hover_seconds": total_hover_seconds,
2692
+ "peak_prominence": peak_prominence,
2664
2693
  })
2665
2694
 
2666
2695
  return results
@@ -2830,6 +2859,12 @@ def compute_recommended_filter(noise_results, current_hz, filter_type="gyro", pr
2830
2859
  before noise starts.
2831
2860
  3. Avoid placing the cutoff on or near a noise peak (would amplify it).
2832
2861
  4. Clamp to the frame profile's min/max range.
2862
+ 5. CHECK PHASE LAG: if the recommended cutoff would add excessive phase lag
2863
+ compared to current, don't recommend lowering. Large quads (10"+) are
2864
+ especially sensitive to phase lag — lowering LPF from 65 to 30Hz can
2865
+ make oscillations worse even though noise numbers improve.
2866
+ 6. If noise is well above the current LPF (all peaks >2x current cutoff),
2867
+ the LPF can't meaningfully help — recommend notch/RPM filters instead.
2833
2868
 
2834
2869
  Returns the recommended Hz value (int, rounded to 5), or None.
2835
2870
  """
@@ -2881,7 +2916,7 @@ def compute_recommended_filter(noise_results, current_hz, filter_type="gyro", pr
2881
2916
  # Apply safety margin: place cutoff below the crossover
2882
2917
  ideal = worst_crossover * safety
2883
2918
 
2884
- # Avoid placing cutoff near a noise peak (within ±15Hz of any strong peak)
2919
+ # Avoid placing cutoff near a noise peak (within +/-15Hz of any strong peak)
2885
2920
  all_peaks = []
2886
2921
  for nr in valid:
2887
2922
  for p in nr["peaks"]:
@@ -2893,12 +2928,83 @@ def compute_recommended_filter(noise_results, current_hz, filter_type="gyro", pr
2893
2928
  if abs(ideal - peak_freq) < 15:
2894
2929
  ideal = min(ideal, peak_freq - 20)
2895
2930
 
2931
+ # ── Phase lag guard ──
2932
+ # If lowering the filter would significantly increase phase lag, don't.
2933
+ # This is critical for large frames where phase lag causes more problems
2934
+ # than the noise the filter would remove.
2935
+ if current_hz is not None and ideal < current_hz:
2936
+ signal_freq = profile.get("phase_lag_freq", 50.0) # typical control freq
2937
+
2938
+ current_lag = estimate_filter_phase_lag(current_hz, signal_freq)
2939
+ proposed_lag = estimate_filter_phase_lag(ideal, signal_freq)
2940
+
2941
+ if current_lag and proposed_lag:
2942
+ added_lag_ms = proposed_lag["ms"] - current_lag["ms"]
2943
+ added_lag_deg = proposed_lag["degrees"] - current_lag["degrees"]
2944
+
2945
+ # For 10"+ frames, even 5ms extra lag is significant
2946
+ frame_size = profile.get("frame_inches", 5)
2947
+ max_added_lag_ms = 8.0 if frame_size <= 7 else 5.0 if frame_size <= 10 else 3.0
2948
+
2949
+ if added_lag_ms > max_added_lag_ms:
2950
+ # Lowering would add too much phase lag — don't recommend
2951
+ return None
2952
+
2953
+ # ── Relative change guard ──
2954
+ # On large frames, aggressive LPF cuts cause more harm than good.
2955
+ # The added phase lag and signal attenuation destabilize the PID loop.
2956
+ # Don't recommend cutting the LPF by more than 30% on 10"+.
2957
+ if current_hz is not None and ideal < current_hz:
2958
+ frame_size = profile.get("frame_inches", 5)
2959
+ if frame_size >= 10:
2960
+ max_reduction = 0.30 # 30% max cut for 10"+
2961
+ elif frame_size >= 7:
2962
+ max_reduction = 0.40 # 40% for 7"
2963
+ else:
2964
+ max_reduction = 0.50 # 50% for 5"
2965
+
2966
+ min_allowed = current_hz * (1.0 - max_reduction)
2967
+ if ideal < min_allowed:
2968
+ ideal = min_allowed # Cap the reduction
2969
+
2970
+ # ── Propwash guard ──
2971
+ # If the lowest significant noise is below ~80Hz, it's likely propwash
2972
+ # (aerodynamic turbulence). LPF changes don't effectively address propwash
2973
+ # because it's broadband — you'd have to crush the filter so low that
2974
+ # the phase lag makes everything worse. Propwash is fixed through D-term
2975
+ # tuning and flight technique, not gyro LPF.
2976
+ if current_hz is not None and all_peaks and filter_type == "gyro":
2977
+ lowest_peak = min(all_peaks)
2978
+ frame_size = profile.get("frame_inches", 5)
2979
+ # For large frames, propwash typically sits at 30-80Hz
2980
+ propwash_ceiling = 80 if frame_size >= 10 else 100
2981
+ if lowest_peak <= propwash_ceiling and ideal < current_hz:
2982
+ # The noise driving the recommendation is in the propwash band.
2983
+ # Don't lower the LPF — it won't help and will add lag.
2984
+ # Only recommend if there are also peaks well above propwash.
2985
+ peaks_above_propwash = [p for p in all_peaks if p > propwash_ceiling * 1.5]
2986
+ if not peaks_above_propwash:
2987
+ return None
2988
+ # There are higher peaks too — but don't let propwash drive the cutoff down
2989
+ ideal = max(ideal, current_hz * 0.85) # at most 15% cut
2990
+
2991
+ # ── High-frequency noise guard ──
2992
+ # If all significant noise is well above the current LPF (>2x cutoff),
2993
+ # lowering the LPF won't meaningfully help — the noise needs notch/RPM
2994
+ # filters, not a lower LPF that would crush phase margin.
2995
+ if current_hz is not None and all_peaks:
2996
+ lowest_significant_peak = min(all_peaks)
2997
+ if lowest_significant_peak > current_hz * 2.0:
2998
+ # All noise is far above current LPF — LPF change won't help
2999
+ return None
3000
+
2896
3001
  # Clamp and round
2897
3002
  result = round(int(np.clip(ideal, min_cutoff, max_cutoff)) / 5) * 5
2898
3003
  return result
2899
3004
 
2900
3005
 
2901
- def compute_recommended_pid(pid_result, current_p, current_i, current_d, profile=None):
3006
+ def compute_recommended_pid(pid_result, current_p, current_i, current_d, profile=None,
3007
+ current_ff=None):
2902
3008
  if pid_result is None:
2903
3009
  return None
2904
3010
  if profile is None:
@@ -2918,6 +3024,26 @@ def compute_recommended_pid(pid_result, current_p, current_i, current_d, profile
2918
3024
  dl_high = delay > ok_dl
2919
3025
  dl_bad = delay > bad_dl
2920
3026
 
3027
+ # ── FF-aware overshoot attribution ──
3028
+ # FF drives initial response proportional to stick speed. High FF + high P
3029
+ # = double overshoot source. When FF is significant (>40), attribute some
3030
+ # overshoot to FF and reduce it before cutting P aggressively.
3031
+ ff_is_high = current_ff is not None and current_ff > 40
3032
+ ff_contributes_overshoot = ff_is_high and os_high
3033
+
3034
+ if ff_contributes_overshoot:
3035
+ # Split the blame: FF handles stick-driven overshoot, P handles error-driven.
3036
+ # With high FF, reduce FF first, smaller P cut.
3037
+ ff_severity = current_ff / 60.0 # 1.0 at FF=60, higher = more blame on FF
3038
+ ff_share = min(0.6, ff_severity * 0.3) # FF takes up to 60% of the blame
3039
+
3040
+ new_ff = max(15, int(current_ff * (1 - min(0.25, ff_share * 0.4))))
3041
+ if new_ff != current_ff:
3042
+ changes["FF"] = {"current": current_ff, "new": new_ff}
3043
+ reasons.append(
3044
+ f"FeedForward is {current_ff} - contributes to overshoot on stick inputs. "
3045
+ f"Reducing FF from {current_ff} to {new_ff} before cutting P.")
3046
+
2921
3047
  # ── Severity-proportional P adjustment ──
2922
3048
  # How far off from target determines size of correction.
2923
3049
  # Profile's max_change caps the adjustment for safety.
@@ -2925,7 +3051,7 @@ def compute_recommended_pid(pid_result, current_p, current_i, current_d, profile
2925
3051
  new_p = current_p
2926
3052
 
2927
3053
  if os_high:
2928
- # severity: 1.0 = at ok threshold, 2.0 = the ok threshold, etc.
3054
+ # severity: 1.0 = at ok threshold, 2.0 = 2x the ok threshold, etc.
2929
3055
  severity = os_pct / ok_os
2930
3056
  if severity > 3.0:
2931
3057
  raw_cut = 0.35
@@ -2936,6 +3062,11 @@ def compute_recommended_pid(pid_result, current_p, current_i, current_d, profile
2936
3062
  else:
2937
3063
  raw_cut = 0.10
2938
3064
 
3065
+ # If FF is taking some blame, reduce the P cut proportionally
3066
+ if ff_contributes_overshoot:
3067
+ ff_share_actual = min(0.6, (current_ff / 60.0) * 0.3)
3068
+ raw_cut *= (1.0 - ff_share_actual)
3069
+
2939
3070
  has_d = current_d is not None and current_d > 0
2940
3071
  d_hint = " and raise D" if has_d else ""
2941
3072
  if os_high and dl_high:
@@ -4028,6 +4159,8 @@ def generate_action_plan(noise_results, pid_results, motor_analysis, dterm_resul
4028
4159
  ok_noise = profile["ok_noise_db"]
4029
4160
  bad_noise = profile["bad_noise_db"]
4030
4161
 
4162
+ info_items = []
4163
+
4031
4164
  # ── Hover oscillation (highest priority - can't tune if quad won't hold still) ──
4032
4165
  if hover_osc:
4033
4166
  for osc in hover_osc:
@@ -4047,7 +4180,16 @@ def generate_action_plan(noise_results, pid_results, motor_analysis, dterm_resul
4047
4180
  freq_str = f" at ~{freq:.0f}Hz" if freq else ""
4048
4181
  amp_str = f"gyro RMS {rms:.1f}°/s, peak-to-peak {p2p:.0f}°/s{freq_str}"
4049
4182
 
4050
- if cause == "P_too_high":
4183
+ if cause == "wind_buffeting":
4184
+ # Low-frequency broadband wobble on large frame — likely wind, not P
4185
+ # Don't recommend aggressive P cuts — show as informational
4186
+ info_items.append({
4187
+ "text": f"{axis}: Low-frequency gyro activity ({rms:.1f}°/s RMS at ~{freq:.0f}Hz)",
4188
+ "detail": f"On a {profile.get('frame_inches', '?')}-inch frame, {freq:.0f}Hz wobble is "
4189
+ f"typically wind buffeting or GPS position corrections, not P oscillation. "
4190
+ f"If this happens indoors or in calm air, then P may be too high."})
4191
+
4192
+ elif cause == "P_too_high":
4051
4193
  current_p = config.get(f"{axis_l}_p")
4052
4194
  if current_p:
4053
4195
  new_p = max(10, int(current_p * 0.70)) # Aggressive 30% cut
@@ -4143,7 +4285,6 @@ def generate_action_plan(noise_results, pid_results, motor_analysis, dterm_resul
4143
4285
  f"Consider raising lowpass cutoffs or switching BIQUAD filters to PT1."})
4144
4286
 
4145
4287
  # ═══ 0b. MOTOR RESPONSE INFO (not an action - informational only) ═══
4146
- info_items = []
4147
4288
  if motor_response and motor_response["motor_response_ms"] > profile["ok_delay_ms"]:
4148
4289
  mr_ms = motor_response["motor_response_ms"]
4149
4290
  info_items.append({
@@ -4251,9 +4392,10 @@ def generate_action_plan(noise_results, pid_results, motor_analysis, dterm_resul
4251
4392
  cur_p = config.get(f"{axis.lower()}_p")
4252
4393
  cur_i = config.get(f"{axis.lower()}_i")
4253
4394
  cur_d = config.get(f"{axis.lower()}_d")
4395
+ cur_ff = config.get(f"{axis.lower()}_ff")
4254
4396
 
4255
4397
  if has_config:
4256
- rec = compute_recommended_pid(pid, cur_p, cur_i, cur_d, profile)
4398
+ rec = compute_recommended_pid(pid, cur_p, cur_i, cur_d, profile, current_ff=cur_ff)
4257
4399
  if rec and rec["changes"]:
4258
4400
  _os = pid["avg_overshoot_pct"] or 0
4259
4401
  _dl = pid["tracking_delay_ms"] or 0
@@ -4263,7 +4405,7 @@ def generate_action_plan(noise_results, pid_results, motor_analysis, dterm_resul
4263
4405
  # Build a single merged description for all PID changes on this axis
4264
4406
  change_parts = []
4265
4407
  sub_actions = [] # for CLI generation
4266
- for term in ["P", "D", "I"]:
4408
+ for term in ["FF", "P", "D", "I"]:
4267
4409
  if term in rec["changes"]:
4268
4410
  ch = rec["changes"][term]
4269
4411
  direction = "Reduce" if ch["new"] < ch["current"] else "Increase"
@@ -4418,6 +4560,14 @@ def generate_action_plan(noise_results, pid_results, motor_analysis, dterm_resul
4418
4560
  for osc in hover_osc:
4419
4561
  rms = osc["gyro_rms"]
4420
4562
  sev = osc["severity"]
4563
+ cause = osc.get("cause")
4564
+
4565
+ # Wind buffeting gets a much softer penalty — it's environmental,
4566
+ # not a tuning problem. Don't tank the score for flying in wind.
4567
+ if cause == "wind_buffeting":
4568
+ osc_scores.append(max(60, 100 - rms * 2)) # Floor at 60
4569
+ continue
4570
+
4421
4571
  if sev == "none":
4422
4572
  osc_scores.append(100)
4423
4573
  elif sev == "mild":
@@ -4611,9 +4761,9 @@ def create_dterm_chart(dterm_results):
4611
4761
 
4612
4762
  # Map from action plan param names to INAV CLI setting names
4613
4763
  INAV_CLI_MAP = {
4614
- "roll_p": "mc_p_roll", "roll_i": "mc_i_roll", "roll_d": "mc_d_roll",
4615
- "pitch_p": "mc_p_pitch", "pitch_i": "mc_i_pitch", "pitch_d": "mc_d_pitch",
4616
- "yaw_p": "mc_p_yaw", "yaw_i": "mc_i_yaw", "yaw_d": "mc_d_yaw",
4764
+ "roll_p": "mc_p_roll", "roll_i": "mc_i_roll", "roll_d": "mc_d_roll", "roll_ff": "mc_cd_roll",
4765
+ "pitch_p": "mc_p_pitch", "pitch_i": "mc_i_pitch", "pitch_d": "mc_d_pitch", "pitch_ff": "mc_cd_pitch",
4766
+ "yaw_p": "mc_p_yaw", "yaw_i": "mc_i_yaw", "yaw_d": "mc_d_yaw", "yaw_ff": "mc_cd_yaw",
4617
4767
  "gyro_lowpass_hz": "gyro_main_lpf_hz", "gyro_lpf_hz": "gyro_main_lpf_hz",
4618
4768
  "gyro_lowpass2_hz": "gyro_main_lpf2_hz",
4619
4769
  "dterm_lpf_hz": "dterm_lpf_hz", "dterm_lpf2_hz": "dterm_lpf2_hz",
@@ -4626,12 +4776,15 @@ INAV_GUI_MAP = {
4626
4776
  "roll_p": ("PID Tuning", "Roll → P"),
4627
4777
  "roll_i": ("PID Tuning", "Roll → I"),
4628
4778
  "roll_d": ("PID Tuning", "Roll → D"),
4779
+ "roll_ff": ("PID Tuning", "Roll → FF"),
4629
4780
  "pitch_p": ("PID Tuning", "Pitch → P"),
4630
4781
  "pitch_i": ("PID Tuning", "Pitch → I"),
4631
4782
  "pitch_d": ("PID Tuning", "Pitch → D"),
4783
+ "pitch_ff": ("PID Tuning", "Pitch → FF"),
4632
4784
  "yaw_p": ("PID Tuning", "Yaw → P"),
4633
4785
  "yaw_i": ("PID Tuning", "Yaw → I"),
4634
4786
  "yaw_d": ("PID Tuning", "Yaw → D"),
4787
+ "yaw_ff": ("PID Tuning", "Yaw → FF"),
4635
4788
  "gyro_lowpass_hz": ("Filtering", "Gyro LPF Hz"),
4636
4789
  "dterm_lpf_hz": ("Filtering", "D-term LPF Hz"),
4637
4790
  }
@@ -4708,19 +4861,26 @@ def generate_narrative(plan, pid_results, motor_analysis, noise_results, config,
4708
4861
 
4709
4862
  # Hover oscillation (most critical - mention first)
4710
4863
  hover_osc_data = plan["scores"].get("hover_osc", [])
4711
- osc_axes = [o for o in hover_osc_data if o["severity"] in ("moderate", "severe")] if hover_osc_data else []
4864
+ # Filter out wind_buffeting it's environmental, not a tuning problem
4865
+ osc_axes = [o for o in hover_osc_data
4866
+ if o["severity"] in ("moderate", "severe") and o.get("cause") != "wind_buffeting"
4867
+ ] if hover_osc_data else []
4868
+ wind_axes = [o for o in hover_osc_data
4869
+ if o["severity"] in ("moderate", "severe") and o.get("cause") == "wind_buffeting"
4870
+ ] if hover_osc_data else []
4871
+
4712
4872
  if osc_axes:
4713
4873
  axis_names = [o["axis"] for o in osc_axes]
4714
4874
  worst = max(osc_axes, key=lambda o: o["gyro_rms"])
4715
4875
  if worst["severity"] == "severe":
4716
4876
  parts.append(
4717
4877
  f"CRITICAL: The quad is oscillating during hover on {'/'.join(axis_names)} "
4718
- f"(worst: {worst['axis']} at {worst['gyro_rms']:.1f}°/s RMS). "
4878
+ f"(worst: {worst['axis']} at {worst['gyro_rms']:.1f}\u00b0/s RMS). "
4719
4879
  f"This must be fixed before any other tuning - the quad is fighting itself just to stay in the air.")
4720
4880
  else:
4721
4881
  parts.append(
4722
4882
  f"The quad shows oscillation during hover on {'/'.join(axis_names)} "
4723
- f"(worst: {worst['axis']} at {worst['gyro_rms']:.1f}°/s RMS). "
4883
+ f"(worst: {worst['axis']} at {worst['gyro_rms']:.1f}\u00b0/s RMS). "
4724
4884
  f"This should be addressed first as it affects all other measurements.")
4725
4885
  if worst.get("dominant_freq_hz"):
4726
4886
  freq = worst["dominant_freq_hz"]
@@ -4733,6 +4893,14 @@ def generate_narrative(plan, pid_results, motor_analysis, noise_results, config,
4733
4893
  else:
4734
4894
  parts.append(f"The oscillation frequency (~{freq:.0f}Hz) suggests noise is leaking through the filters.")
4735
4895
 
4896
+ if wind_axes and not osc_axes:
4897
+ # Only wind buffeting detected — inform but don't alarm
4898
+ worst_w = max(wind_axes, key=lambda o: o["gyro_rms"])
4899
+ parts.append(
4900
+ f"Some low-frequency gyro activity detected on hover ({worst_w['gyro_rms']:.1f}\u00b0/s RMS at "
4901
+ f"~{worst_w['dominant_freq_hz']:.0f}Hz). On this frame size, this is likely wind buffeting "
4902
+ f"or GPS position corrections rather than a PID problem.")
4903
+
4736
4904
  # PID behavior per axis
4737
4905
  ok_os = profile["ok_overshoot"]
4738
4906
  bad_os = profile["bad_overshoot"]
@@ -5933,7 +6101,7 @@ def count_blackbox_logs(filepath):
5933
6101
  # ─── Main ─────────────────────────────────────────────────────────────────────
5934
6102
 
5935
6103
  def main():
5936
- parser = argparse.ArgumentParser(description="INAV Blackbox Analyzer v2.16.1 - Prescriptive Tuning",
6104
+ parser = argparse.ArgumentParser(description="INAV Blackbox Analyzer v2.18.0 - Prescriptive Tuning",
5937
6105
  formatter_class=argparse.RawDescriptionHelpFormatter)
5938
6106
  parser.add_argument("--version", action="version", version=f"inav-analyze {REPORT_VERSION}")
5939
6107
  parser.add_argument("logfile", nargs="?", default=None,
@@ -5973,11 +6141,11 @@ def main():
5973
6141
  help="Omit the plain-English description of the quad's behavior.")
5974
6142
  parser.add_argument("--no-color", action="store_true",
5975
6143
  help="Disable colored terminal output.")
5976
- parser.add_argument("--diff", metavar="FILE", default=None,
5977
- help="Path to a CLI 'diff all' file for enriched analysis. "
6144
+ parser.add_argument("--config", metavar="FILE", default=None,
6145
+ help="Path to a CLI 'dump all' or 'diff all' file for enriched analysis. "
5978
6146
  "Adds config cross-referencing to nav and tuning results. "
5979
6147
  "Not needed with --device (config is pulled automatically). "
5980
- "Also auto-discovered if a *_diff.txt file sits next to the BBL.")
6148
+ "Also auto-discovered if a *_dump.txt or *_diff.txt file sits next to the BBL.")
5981
6149
  parser.add_argument("--nav", action="store_true",
5982
6150
  help="Enable navigation health analysis (compass, GPS, baro, "
5983
6151
  "estimator). Works on any flight with nav fields in the log.")
@@ -6019,7 +6187,7 @@ def main():
6019
6187
 
6020
6188
  # ── Device mode: download blackbox from FC ──
6021
6189
  logfile = args.logfile
6022
- diff_raw = None
6190
+ config_raw = None
6023
6191
  if args.device:
6024
6192
  try:
6025
6193
  try:
@@ -6089,19 +6257,18 @@ def main():
6089
6257
  pct = summary['used_size'] * 100 // summary['total_size'] if summary['total_size'] > 0 else 0
6090
6258
  print(f" Dataflash: {used_kb:.0f}KB / {total_kb:.0f}KB ({pct}% used)")
6091
6259
 
6092
- # Pull CLI diff (always when connected - enriches analysis)
6093
- diff_raw = None
6094
- print(" Pulling configuration (diff all)...", end="", flush=True)
6095
- diff_raw = fc.get_diff_all(timeout=10.0)
6096
- if diff_raw:
6097
- n_settings = len([l for l in diff_raw.splitlines() if l.strip().startswith("set ")])
6098
- print(f" {n_settings} settings")
6099
- # Save diff to file alongside blackbox
6100
- diff_path = os.path.join(args.blackbox_dir, f"{info['craft_name'] or 'fc'}_diff.txt")
6260
+ # Pull full config dump (all parameters used for analysis, fingerprinting, and backup)
6261
+ config_raw = None
6262
+ print(" Pulling configuration (dump all)...", end="", flush=True)
6263
+ config_raw = fc.get_dump_all(timeout=30.0)
6264
+ if config_raw:
6265
+ n_settings = len([l for l in config_raw.splitlines() if l.strip().startswith("set ")])
6266
+ print(f" {n_settings} parameters")
6267
+ config_path = os.path.join(args.blackbox_dir, f"{info['craft_name'] or 'fc'}_dump.txt")
6101
6268
  os.makedirs(args.blackbox_dir, exist_ok=True)
6102
- with open(diff_path, "w") as f:
6103
- f.write(diff_raw)
6104
- print(f" Saved: {diff_path}")
6269
+ with open(config_path, "w") as f:
6270
+ f.write(config_raw)
6271
+ print(f" Saved: {config_path}")
6105
6272
  else:
6106
6273
  print(" no response (FC may not support CLI over MSP)")
6107
6274
 
@@ -6143,39 +6310,42 @@ def main():
6143
6310
  print(f"ERROR: File not found: {logfile}"); sys.exit(1)
6144
6311
 
6145
6312
  # ── Load diff from file (when not using --device) ──
6146
- if diff_raw is None:
6147
- diff_file = None
6148
- if args.diff:
6149
- # Explicit file path: --diff my_diff.txt
6150
- diff_file = args.diff
6313
+ if config_raw is None:
6314
+ config_file = None
6315
+ if args.config:
6316
+ # Explicit file path: --config my_dump.txt
6317
+ config_file = args.config
6151
6318
  else:
6152
- # Auto-discover diff files in same directory as BBL
6319
+ # Auto-discover config files in same directory as BBL
6320
+ # Prefer dump (complete) over diff (only changed params)
6153
6321
  log_dir = os.path.dirname(os.path.abspath(logfile))
6154
6322
  candidates = []
6155
6323
  for fname in os.listdir(log_dir):
6156
- if fname.endswith("_diff.txt") or fname == "diff.txt" or fname == "diff_all.txt":
6324
+ if (fname.endswith("_dump.txt") or fname.endswith("_diff.txt")
6325
+ or fname in ("dump.txt", "dump_all.txt", "diff.txt", "diff_all.txt")):
6157
6326
  candidates.append(os.path.join(log_dir, fname))
6158
- if len(candidates) == 1:
6159
- diff_file = candidates[0]
6160
- elif len(candidates) > 1:
6161
- # Pick the most recently modified
6162
- diff_file = max(candidates, key=os.path.getmtime)
6163
-
6164
- if diff_file:
6165
- if os.path.isfile(diff_file):
6327
+ if candidates:
6328
+ # Prefer dump over diff, then most recent
6329
+ def _score(path):
6330
+ is_dump = "_dump" in path or "dump" in os.path.basename(path)
6331
+ return (1 if is_dump else 0, os.path.getmtime(path))
6332
+ config_file = max(candidates, key=_score)
6333
+
6334
+ if config_file:
6335
+ if os.path.isfile(config_file):
6166
6336
  try:
6167
- with open(diff_file, "r", errors="ignore") as f:
6168
- diff_raw = f.read()
6169
- n_settings = len([l for l in diff_raw.splitlines()
6337
+ with open(config_file, "r", errors="ignore") as f:
6338
+ config_raw = f.read()
6339
+ n_settings = len([l for l in config_raw.splitlines()
6170
6340
  if l.strip().startswith("set ")])
6171
6341
  if n_settings > 0:
6172
- print(f" Config: {os.path.basename(diff_file)} ({n_settings} settings)")
6342
+ print(f" Config: {os.path.basename(config_file)} ({n_settings} settings)")
6173
6343
  else:
6174
- diff_raw = None # not a valid diff file
6344
+ config_raw = None # not a valid config file
6175
6345
  except Exception:
6176
- diff_raw = None
6346
+ config_raw = None
6177
6347
  else:
6178
- print(f" Warning: Diff file not found: {diff_file}")
6348
+ print(f" Warning: Config file not found: {config_file}")
6179
6349
 
6180
6350
  # ── History mode: show progression and exit ──
6181
6351
  if args.history or args.trend:
@@ -6206,14 +6376,14 @@ def main():
6206
6376
  if not os.path.isfile(args.compare):
6207
6377
  print(f"ERROR: Comparison file not found: {args.compare}")
6208
6378
  sys.exit(1)
6209
- _run_comparison(logfile, args.compare, args, diff_raw)
6379
+ _run_comparison(logfile, args.compare, args, config_raw)
6210
6380
  return
6211
6381
 
6212
6382
  # ── Replay mode: interactive HTML time-series ──
6213
6383
  if args.replay:
6214
6384
  if not logfile:
6215
6385
  parser.error("logfile required for --replay")
6216
- _run_replay(logfile, args, diff_raw)
6386
+ _run_replay(logfile, args, config_raw)
6217
6387
  return
6218
6388
 
6219
6389
  # ── Log quality check mode ──
@@ -6252,15 +6422,15 @@ def main():
6252
6422
  target = log_files[-1]
6253
6423
  if len(log_files) > 1:
6254
6424
  print(f"\n Nav mode: analyzing last flight ({len(log_files)} in flash)")
6255
- _analyze_single_log(target, args, diff_raw)
6425
+ _analyze_single_log(target, args, config_raw)
6256
6426
  elif len(log_files) > 1:
6257
- _process_multi_log(log_files, args, diff_raw)
6427
+ _process_multi_log(log_files, args, config_raw)
6258
6428
  else:
6259
6429
  # Single flight - full analysis
6260
- _analyze_single_log(log_files[0], args, diff_raw)
6430
+ _analyze_single_log(log_files[0], args, config_raw)
6261
6431
 
6262
6432
 
6263
- def _process_multi_log(log_files, args, diff_raw):
6433
+ def _process_multi_log(log_files, args, config_raw):
6264
6434
  """Handle multiple flights from a single flash download.
6265
6435
 
6266
6436
  Strategy:
@@ -6276,7 +6446,7 @@ def _process_multi_log(log_files, args, diff_raw):
6276
6446
  print(f" Scanning {len(log_files)} flights...")
6277
6447
  summaries = []
6278
6448
  for lf in log_files:
6279
- s = _analyze_single_log(lf, args, diff_raw, summary_only=True)
6449
+ s = _analyze_single_log(lf, args, config_raw, summary_only=True)
6280
6450
  if s:
6281
6451
  summaries.append(s)
6282
6452
 
@@ -6289,7 +6459,7 @@ def _process_multi_log(log_files, args, diff_raw):
6289
6459
  # header config matches the diff are "current session" (post-change),
6290
6460
  # flights that don't match are "old session" (pre-change).
6291
6461
  # Without a diff, fall back to comparing consecutive flights.
6292
- current_fp = _fingerprint_from_diff(diff_raw)
6462
+ current_fp = _fingerprint_from_diff(config_raw)
6293
6463
  is_current = [] # True/False per flight
6294
6464
 
6295
6465
  if current_fp:
@@ -6413,7 +6583,7 @@ def _process_multi_log(log_files, args, diff_raw):
6413
6583
 
6414
6584
  # ── Phase 5: Full analysis on the selected flight ──
6415
6585
  print(f"{'═' * 70}")
6416
- _analyze_single_log(log_files[best_idx], args, diff_raw)
6586
+ _analyze_single_log(log_files[best_idx], args, config_raw)
6417
6587
 
6418
6588
  # ── Phase 6: Show cross-session progression ──
6419
6589
  if not args.no_db:
@@ -6467,7 +6637,7 @@ def _config_fingerprint(config):
6467
6637
  return "|".join(parts) if parts else ""
6468
6638
 
6469
6639
 
6470
- def _fingerprint_from_diff(diff_raw):
6640
+ def _fingerprint_from_diff(config_raw):
6471
6641
  """Build a config fingerprint from CLI 'diff all' output.
6472
6642
 
6473
6643
  This represents the FC's CURRENT config - the ground truth.
@@ -6475,14 +6645,14 @@ def _fingerprint_from_diff(diff_raw):
6475
6645
  session; flights that don't match are from before the user applied
6476
6646
  changes.
6477
6647
  """
6478
- if not diff_raw:
6648
+ if not config_raw:
6479
6649
  return ""
6480
6650
 
6481
6651
  try:
6482
6652
  from inav_toolkit.flight_db import parse_diff_output
6483
6653
  except ImportError:
6484
6654
  from inav_flight_db import parse_diff_output
6485
- diff_settings = parse_diff_output(diff_raw)
6655
+ diff_settings = parse_diff_output(config_raw)
6486
6656
 
6487
6657
  # Map CLI names → config keys (same mapping as merge_diff_into_config)
6488
6658
  config = {}
@@ -6498,8 +6668,12 @@ def _fingerprint_from_diff(diff_raw):
6498
6668
  return _config_fingerprint(config)
6499
6669
 
6500
6670
 
6501
- def _print_config_review(diff_raw, config, frame_inches, plan):
6502
- """Run parameter analyzer on FC diff and show findings not covered by flight analysis.
6671
+ def _print_config_review(config_raw, config, frame_inches, plan):
6672
+ """Run parameter analyzer on FC config and show findings not covered by flight analysis.
6673
+
6674
+ Accepts either 'dump all' or 'diff all' output. Dump is preferred since it
6675
+ includes every parameter including unchanged defaults, which matters for
6676
+ nav PID checks on large frames.
6503
6677
 
6504
6678
  Only shows CRITICAL and WARNING findings from categories that the blackbox
6505
6679
  analyzer doesn't cover (safety, nav, motor protocol, GPS, battery, RX).
@@ -6508,6 +6682,9 @@ def _print_config_review(diff_raw, config, frame_inches, plan):
6508
6682
  """
6509
6683
  R, B, C, G, Y, RED, DIM = _colors()
6510
6684
 
6685
+ if not config_raw:
6686
+ return
6687
+
6511
6688
  try:
6512
6689
  try:
6513
6690
  from inav_toolkit.param_analyzer import parse_diff_all, run_all_checks, CRITICAL, WARNING
@@ -6517,7 +6694,7 @@ def _print_config_review(diff_raw, config, frame_inches, plan):
6517
6694
  return # param analyzer not available
6518
6695
 
6519
6696
  try:
6520
- parsed = parse_diff_all(diff_raw)
6697
+ parsed = parse_diff_all(config_raw)
6521
6698
  except Exception:
6522
6699
  return
6523
6700
 
@@ -6788,15 +6965,15 @@ footer {{ text-align:center; color:#555; margin-top:30px; padding:10px; border-t
6788
6965
 
6789
6966
  # ─── Comparison Mode ─────────────────────────────────────────────────────────
6790
6967
 
6791
- def _analyze_for_compare(logfile, args, diff_raw=None):
6968
+ def _analyze_for_compare(logfile, args, config_raw=None):
6792
6969
  """Run analysis pipeline on a single file and return structured results.
6793
6970
  Returns dict with: config, data, noise_results, pid_results, motor_analysis,
6794
6971
  dterm_results, plan, noise_fp, hover_osc, profile
6795
6972
  """
6796
6973
  raw_params = parse_headers_from_bbl(logfile)
6797
6974
  config = extract_fc_config(raw_params)
6798
- if diff_raw:
6799
- merge_diff_into_config(config, diff_raw)
6975
+ if config_raw:
6976
+ merge_diff_into_config(config, config_raw)
6800
6977
 
6801
6978
  # Auto-detect frame
6802
6979
  craft = config.get("craft_name", "")
@@ -6824,7 +7001,7 @@ def _analyze_for_compare(logfile, args, diff_raw=None):
6824
7001
  sr = data["sample_rate"]
6825
7002
 
6826
7003
  # Run all analyses
6827
- hover_osc = detect_hover_oscillation(data, sr)
7004
+ hover_osc = detect_hover_oscillation(data, sr, profile)
6828
7005
  noise_results = [analyze_noise(data, ax, f"gyro_{ax.lower()}", sr) for ax in AXIS_NAMES]
6829
7006
  pid_results = [analyze_pid_response(data, i, sr) for i in range(3)]
6830
7007
  motor_analysis = analyze_motors(data, sr, config)
@@ -7081,7 +7258,7 @@ footer{{text-align:center;color:var(--dm);font-size:.7rem;padding:24px 0;border-
7081
7258
  </div><footer>INAV Blackbox Analyzer v{REPORT_VERSION} - Comparison Report</footer></body></html>"""
7082
7259
 
7083
7260
 
7084
- def _run_comparison(file_a, file_b, args, diff_raw):
7261
+ def _run_comparison(file_a, file_b, args, config_raw):
7085
7262
  """Run comparative analysis on two flight logs."""
7086
7263
  R, B, C, G, Y, RED, DIM = _colors()
7087
7264
 
@@ -7091,12 +7268,12 @@ def _run_comparison(file_a, file_b, args, diff_raw):
7091
7268
  print()
7092
7269
 
7093
7270
  print(f" Analyzing flight A...", end=" ", flush=True)
7094
- res_a = _analyze_for_compare(file_a, args, diff_raw)
7271
+ res_a = _analyze_for_compare(file_a, args, config_raw)
7095
7272
  sa = res_a["plan"]["scores"]
7096
7273
  print(f"score {sa['overall']:.0f}/100")
7097
7274
 
7098
7275
  print(f" Analyzing flight B...", end=" ", flush=True)
7099
- res_b = _analyze_for_compare(file_b, args, diff_raw)
7276
+ res_b = _analyze_for_compare(file_b, args, config_raw)
7100
7277
  sb = res_b["plan"]["scores"]
7101
7278
  print(f"score {sb['overall']:.0f}/100")
7102
7279
 
@@ -7606,7 +7783,7 @@ allPlotIds.forEach(srcId => {{
7606
7783
  </script></body></html>"""
7607
7784
 
7608
7785
 
7609
- def _run_replay(logfile, args, diff_raw):
7786
+ def _run_replay(logfile, args, config_raw):
7610
7787
  """Generate interactive replay HTML for a single flight."""
7611
7788
  R, B, C, G, Y, RED, DIM = _colors()
7612
7789
 
@@ -7615,8 +7792,8 @@ def _run_replay(logfile, args, diff_raw):
7615
7792
 
7616
7793
  raw_params = parse_headers_from_bbl(logfile)
7617
7794
  config = extract_fc_config(raw_params)
7618
- if diff_raw:
7619
- merge_diff_into_config(config, diff_raw)
7795
+ if config_raw:
7796
+ merge_diff_into_config(config, config_raw)
7620
7797
 
7621
7798
  ext = os.path.splitext(logfile)[1].lower()
7622
7799
  is_blackbox = ext in (".bbl", ".bfl", ".bbs")
@@ -7665,13 +7842,13 @@ def _run_replay(logfile, args, diff_raw):
7665
7842
  print()
7666
7843
 
7667
7844
 
7668
- def _analyze_single_log(logfile, args, diff_raw=None, summary_only=False):
7845
+ def _analyze_single_log(logfile, args, config_raw=None, summary_only=False):
7669
7846
  """Analyze a single blackbox log file.
7670
7847
 
7671
7848
  Args:
7672
7849
  logfile: Path to log file
7673
7850
  args: Command line arguments
7674
- diff_raw: Optional CLI diff text
7851
+ config_raw: Optional CLI diff text
7675
7852
  summary_only: If True, skip verbose output/reports but still analyze
7676
7853
  and store in DB. Returns a summary dict.
7677
7854
 
@@ -7710,8 +7887,8 @@ def _analyze_single_log(logfile, args, diff_raw=None, summary_only=False):
7710
7887
  config = extract_fc_config(raw_params)
7711
7888
 
7712
7889
  # ── Merge CLI diff if available ──
7713
- if diff_raw:
7714
- n_merged = merge_diff_into_config(config, diff_raw)
7890
+ if config_raw:
7891
+ n_merged = merge_diff_into_config(config, config_raw)
7715
7892
  mismatches = config.get("_diff_mismatches", [])
7716
7893
  if n_merged > 0 or mismatches:
7717
7894
  parts = [f"{n_merged} new settings from CLI diff"]
@@ -7979,8 +8156,9 @@ def _analyze_single_log(logfile, args, diff_raw=None, summary_only=False):
7979
8156
  # ── NAV-ONLY MODE: skip tuning, run navigation health analysis ──
7980
8157
  if nav_mode:
7981
8158
  # Quick oscillation check - warn if PID tuning is polluting nav readings
7982
- hover_osc = detect_hover_oscillation(data, sr)
7983
- osc_severe = [h for h in hover_osc if h["severity"] in ("moderate", "severe")]
8159
+ hover_osc = detect_hover_oscillation(data, sr, profile)
8160
+ osc_severe = [h for h in hover_osc
8161
+ if h["severity"] in ("moderate", "severe") and h.get("cause") != "wind_buffeting"]
7984
8162
  tune_warning = False
7985
8163
  if osc_severe:
7986
8164
  tune_warning = True
@@ -8011,7 +8189,7 @@ def _analyze_single_log(logfile, args, diff_raw=None, summary_only=False):
8011
8189
  print(" Enable NAV_POS, NAV_ACC, and Attitude in blackbox settings.")
8012
8190
  return None
8013
8191
 
8014
- hover_osc = detect_hover_oscillation(data, sr)
8192
+ hover_osc = detect_hover_oscillation(data, sr, profile)
8015
8193
  noise_results = [analyze_noise(data, ax, f"gyro_{ax.lower()}", sr) for ax in AXIS_NAMES]
8016
8194
  noise_fp = fingerprint_noise(noise_results, config, prop_harmonics)
8017
8195
  pid_results = [analyze_pid_response(data, i, sr) for i in range(3)]
@@ -8057,7 +8235,7 @@ def _analyze_single_log(logfile, args, diff_raw=None, summary_only=False):
8057
8235
  db = FlightDB(args.db_path)
8058
8236
  flight_id, is_new = db.store_flight(
8059
8237
  plan, config, data, hover_osc, motor_analysis,
8060
- pid_results, noise_results, log_file=logfile, diff_raw=diff_raw)
8238
+ pid_results, noise_results, log_file=logfile, config_raw=config_raw)
8061
8239
  db.close()
8062
8240
  summary["flight_id"] = flight_id
8063
8241
  summary["is_new"] = is_new
@@ -8072,8 +8250,8 @@ def _analyze_single_log(logfile, args, diff_raw=None, summary_only=False):
8072
8250
  # ── Config review from diff (if available) ──
8073
8251
  # Runs parameter analyzer checks on the FC's current config.
8074
8252
  # Catches safety, nav, motor protocol issues that flight data alone can't detect.
8075
- if diff_raw and not args.no_terminal and plan["verdict"] != "GROUND_ONLY":
8076
- _print_config_review(diff_raw, config, frame_inches, plan)
8253
+ if config_raw and not args.no_terminal and plan["verdict"] != "GROUND_ONLY":
8254
+ _print_config_review(config_raw, config, frame_inches, plan)
8077
8255
 
8078
8256
  nav_results = None # nav analysis only runs in --nav mode
8079
8257
  if not args.no_html and plan["verdict"] != "GROUND_ONLY":
@@ -8115,7 +8293,7 @@ def _analyze_single_log(logfile, args, diff_raw=None, summary_only=False):
8115
8293
  db = FlightDB(args.db_path)
8116
8294
  flight_id, is_new = db.store_flight(
8117
8295
  plan, config, data, hover_osc, motor_analysis,
8118
- pid_results, noise_results, log_file=logfile, diff_raw=diff_raw)
8296
+ pid_results, noise_results, log_file=logfile, config_raw=config_raw)
8119
8297
  craft = config.get("craft_name", "unknown")
8120
8298
  n_flights = db.get_flight_count(craft)
8121
8299
  if is_new:
@@ -21,7 +21,7 @@ import json
21
21
  import os
22
22
  from datetime import datetime
23
23
 
24
- VERSION = "2.16.1"
24
+ VERSION = "2.18.0"
25
25
 
26
26
  SCHEMA_VERSION = 1
27
27
 
@@ -33,7 +33,7 @@ try:
33
33
  except ImportError:
34
34
  serial = None # Checked in open()
35
35
 
36
- VERSION = "2.16.1"
36
+ VERSION = "2.18.0"
37
37
 
38
38
  # ─── MSP Command IDs ─────────────────────────────────────────────────────────
39
39
 
@@ -1005,6 +1005,17 @@ class INAVDevice:
1005
1005
  """
1006
1006
  return self.cli_command("diff all", timeout=timeout)
1007
1007
 
1008
+ def get_dump_all(self, timeout=30.0):
1009
+ """Pull the full 'dump all' configuration from the FC.
1010
+
1011
+ This is a complete backup of every parameter — much larger output
1012
+ than 'diff all' which only shows non-defaults.
1013
+
1014
+ Returns:
1015
+ Raw dump output string, or None on error
1016
+ """
1017
+ return self.cli_command("dump all", timeout=timeout)
1018
+
1008
1019
  def cli_batch(self, commands, timeout=5.0, save=True):
1009
1020
  """Send multiple CLI commands in a single CLI session.
1010
1021
 
@@ -21,7 +21,7 @@ import sys
21
21
  import textwrap
22
22
  from datetime import datetime
23
23
 
24
- VERSION = "2.16.1"
24
+ VERSION = "2.18.0"
25
25
 
26
26
 
27
27
  def _enable_ansi_colors():
@@ -811,7 +811,7 @@ def run_all_checks(parsed, frame_inches=None, blackbox_state=None):
811
811
  findings.extend(check_motors_protocol(parsed))
812
812
  findings.extend(check_filters(parsed, frame_inches))
813
813
  findings.extend(check_pid_config(parsed, frame_inches))
814
- findings.extend(check_navigation(parsed))
814
+ findings.extend(check_navigation(parsed, frame_inches))
815
815
  findings.extend(check_gps(parsed))
816
816
  findings.extend(check_blackbox(parsed))
817
817
  findings.extend(check_battery(parsed))
@@ -1280,7 +1280,7 @@ def check_pid_config(parsed, frame_inches=None):
1280
1280
 
1281
1281
  # ─── Navigation Checks ───────────────────────────────────────────────────────
1282
1282
 
1283
- def check_navigation(parsed):
1283
+ def check_navigation(parsed, frame_inches=None):
1284
1284
  findings = []
1285
1285
 
1286
1286
  rth_alt = get_setting(parsed, "nav_rth_altitude", 5000)
@@ -1346,26 +1346,123 @@ def check_navigation(parsed):
1346
1346
  setting="nav_mc_hover_thr",
1347
1347
  current=str(hover)))
1348
1348
 
1349
- # Nav PID values from active profile
1350
- pos_p = profile.get("nav_mc_pos_xy_p", get_setting(parsed, "nav_mc_pos_xy_p", None))
1351
- heading_p = profile.get("nav_mc_heading_p", get_setting(parsed, "nav_mc_heading_p", None))
1349
+ # ── Frame-aware nav PID checks ──
1350
+ # INAV defaults are tuned for 5" quads. Larger frames have more inertia
1351
+ # and need softer nav PIDs and longer deceleration time. The default
1352
+ # values cause oscillation on deceleration, overshoot on RTH arrival,
1353
+ # and bouncy position hold on 10"+ frames.
1354
+ #
1355
+ # Recommended ranges by frame size:
1356
+ # 5": pos_p=50-65 vel_p=35-50 vel_d=80-120 decel=100-150
1357
+ # 7": pos_p=40-55 vel_p=30-40 vel_d=80-120 decel=120-180
1358
+ # 10": pos_p=30-45 vel_p=20-30 vel_d=80-120 decel=180-280
1359
+ # 12": pos_p=25-40 vel_p=15-25 vel_d=80-120 decel=220-350
1360
+ # 15": pos_p=20-35 vel_p=10-20 vel_d=80-120 decel=280-400
1361
+
1362
+ nav_rec = None
1363
+ if frame_inches and frame_inches >= 7:
1364
+ if frame_inches >= 15:
1365
+ nav_rec = {"pos_p_max": 35, "vel_p_max": 20, "decel_min": 280,
1366
+ "pos_p_rec": 25, "vel_p_rec": 15, "vel_i_rec": 8, "decel_rec": 350}
1367
+ elif frame_inches >= 12:
1368
+ nav_rec = {"pos_p_max": 40, "vel_p_max": 25, "decel_min": 220,
1369
+ "pos_p_rec": 30, "vel_p_rec": 20, "vel_i_rec": 10, "decel_rec": 280}
1370
+ elif frame_inches >= 10:
1371
+ nav_rec = {"pos_p_max": 45, "vel_p_max": 30, "decel_min": 180,
1372
+ "pos_p_rec": 40, "vel_p_rec": 25, "vel_i_rec": 10, "decel_rec": 250}
1373
+ elif frame_inches >= 7:
1374
+ nav_rec = {"pos_p_max": 55, "vel_p_max": 40, "decel_min": 120,
1375
+ "pos_p_rec": 45, "vel_p_rec": 35, "vel_i_rec": 15, "decel_rec": 150}
1376
+
1377
+ # INAV defaults for nav PIDs (these won't appear in diff all if unchanged)
1378
+ INAV_NAV_DEFAULTS = {
1379
+ "nav_mc_pos_xy_p": 65,
1380
+ "nav_mc_vel_xy_p": 40,
1381
+ "nav_mc_vel_xy_i": 15,
1382
+ "nav_mc_pos_deceleration_time": 120,
1383
+ "nav_mc_heading_p": 60,
1384
+ }
1352
1385
 
1353
- if pos_p is not None and isinstance(pos_p, (int, float)):
1354
- if pos_p > 50:
1355
- findings.append(Finding(
1356
- WARNING, "Navigation", f"Position hold P = {pos_p} - aggressive",
1357
- "High position P gain can cause oscillation (salad bowling) in position hold and RTH. "
1358
- "The quad overcorrects, overshoots, and oscillates around the target position.",
1359
- setting="nav_mc_pos_xy_p",
1360
- current=str(pos_p),
1361
- recommended="20-35",
1362
- cli_fix=f"set nav_mc_pos_xy_p = 30"))
1363
- elif pos_p < 15:
1364
- findings.append(Finding(
1365
- INFO, "Navigation", f"Position hold P = {pos_p} - conservative",
1366
- "Low position P may result in slow corrections and drifting in wind.",
1367
- setting="nav_mc_pos_xy_p",
1368
- current=str(pos_p)))
1386
+ pos_p = profile.get("nav_mc_pos_xy_p",
1387
+ get_setting(parsed, "nav_mc_pos_xy_p", INAV_NAV_DEFAULTS["nav_mc_pos_xy_p"]))
1388
+ vel_p = profile.get("nav_mc_vel_xy_p",
1389
+ get_setting(parsed, "nav_mc_vel_xy_p", INAV_NAV_DEFAULTS["nav_mc_vel_xy_p"]))
1390
+ vel_i = profile.get("nav_mc_vel_xy_i",
1391
+ get_setting(parsed, "nav_mc_vel_xy_i", INAV_NAV_DEFAULTS["nav_mc_vel_xy_i"]))
1392
+ decel = get_setting(parsed, "nav_mc_pos_deceleration_time",
1393
+ INAV_NAV_DEFAULTS["nav_mc_pos_deceleration_time"])
1394
+ heading_p = profile.get("nav_mc_heading_p",
1395
+ get_setting(parsed, "nav_mc_heading_p", INAV_NAV_DEFAULTS["nav_mc_heading_p"]))
1396
+
1397
+ if nav_rec:
1398
+ is_default_pos = (pos_p == INAV_NAV_DEFAULTS["nav_mc_pos_xy_p"])
1399
+ is_default_vel = (vel_p == INAV_NAV_DEFAULTS["nav_mc_vel_xy_p"])
1400
+ is_default_decel = (decel == INAV_NAV_DEFAULTS["nav_mc_pos_deceleration_time"])
1401
+ default_note = " (INAV default - tuned for 5-inch)"
1402
+
1403
+ # Frame-aware checks
1404
+ if pos_p is not None and isinstance(pos_p, (int, float)):
1405
+ if pos_p > nav_rec["pos_p_max"]:
1406
+ val_note = default_note if is_default_pos else ""
1407
+ findings.append(Finding(
1408
+ WARNING, "Navigation",
1409
+ f"Position P = {pos_p}{val_note} - too aggressive for {frame_inches}-inch",
1410
+ f"On {frame_inches}-inch with more "
1411
+ f"inertia, high position P causes overshoot and oscillation on RTH arrival "
1412
+ f"and position hold. The quad overshoots the target position, corrects back, "
1413
+ f"overshoots again.",
1414
+ setting="nav_mc_pos_xy_p",
1415
+ current=str(pos_p),
1416
+ recommended=str(nav_rec["pos_p_rec"]),
1417
+ cli_fix=f"set nav_mc_pos_xy_p = {nav_rec['pos_p_rec']}"))
1418
+
1419
+ if vel_p is not None and isinstance(vel_p, (int, float)):
1420
+ if vel_p > nav_rec["vel_p_max"]:
1421
+ val_note = default_note if is_default_vel else ""
1422
+ findings.append(Finding(
1423
+ WARNING, "Navigation",
1424
+ f"Velocity XY P = {vel_p}{val_note} - too aggressive for {frame_inches}-inch",
1425
+ f"Controls how hard the quad brakes when decelerating. "
1426
+ f"On {frame_inches}-inch, the quad can't stop as fast due to momentum, "
1427
+ f"so high velocity P causes oscillation in the direction of travel when "
1428
+ f"stopping or changing direction.",
1429
+ setting="nav_mc_vel_xy_p",
1430
+ current=str(vel_p),
1431
+ recommended=str(nav_rec["vel_p_rec"]),
1432
+ cli_fix=f"set nav_mc_vel_xy_p = {nav_rec['vel_p_rec']}"))
1433
+
1434
+ if decel is not None and isinstance(decel, (int, float)):
1435
+ if decel < nav_rec["decel_min"]:
1436
+ val_note = default_note if is_default_decel else ""
1437
+ findings.append(Finding(
1438
+ WARNING, "Navigation",
1439
+ f"Deceleration time = {decel} ({decel/100:.1f}s){val_note} - too short for {frame_inches}-inch",
1440
+ f"This controls how quickly the quad tries to stop from cruise speed. "
1441
+ f"A {frame_inches}-inch has much more "
1442
+ f"momentum and needs more distance/time to decelerate smoothly. "
1443
+ f"Too short causes overshoot and oscillation on RTH and position hold transitions.",
1444
+ setting="nav_mc_pos_deceleration_time",
1445
+ current=f"{decel} ({decel/100:.1f}s)",
1446
+ recommended=f"{nav_rec['decel_rec']} ({nav_rec['decel_rec']/100:.1f}s)",
1447
+ cli_fix=f"set nav_mc_pos_deceleration_time = {nav_rec['decel_rec']}"))
1448
+ else:
1449
+ # Generic checks (no frame size or small frame)
1450
+ if pos_p is not None and isinstance(pos_p, (int, float)):
1451
+ if pos_p > 50:
1452
+ findings.append(Finding(
1453
+ WARNING, "Navigation", f"Position hold P = {pos_p} - aggressive",
1454
+ "High position P gain can cause oscillation in position hold and RTH. "
1455
+ "The quad overcorrects, overshoots, and oscillates around the target position.",
1456
+ setting="nav_mc_pos_xy_p",
1457
+ current=str(pos_p),
1458
+ recommended="20-35",
1459
+ cli_fix=f"set nav_mc_pos_xy_p = 30"))
1460
+ elif pos_p < 15:
1461
+ findings.append(Finding(
1462
+ INFO, "Navigation", f"Position hold P = {pos_p} - conservative",
1463
+ "Low position P may result in slow corrections and drifting in wind.",
1464
+ setting="nav_mc_pos_xy_p",
1465
+ current=str(pos_p)))
1369
1466
 
1370
1467
  if heading_p is not None and isinstance(heading_p, (int, float)):
1371
1468
  if heading_p > 60:
@@ -25,7 +25,7 @@ import re
25
25
  import sys
26
26
  import textwrap
27
27
 
28
- VERSION = "2.16.1"
28
+ VERSION = "2.18.0"
29
29
 
30
30
 
31
31
  def _enable_ansi_colors():
@@ -22,7 +22,7 @@ import time
22
22
  try:
23
23
  from inav_toolkit import __version__ as VERSION
24
24
  except ImportError:
25
- VERSION = "2.16.1"
25
+ VERSION = "2.18.0"
26
26
 
27
27
  # Module paths for subprocess invocation (package-aware)
28
28
  ANALYZER_MODULE = "inav_toolkit.blackbox_analyzer"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inav-toolkit
3
- Version: 2.16.1
3
+ Version: 2.18.0
4
4
  Summary: Blackbox analyzer, parameter checker, and tuning wizard for INAV flight controllers
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/agoliveira/INAV-Toolkit
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "inav-toolkit"
7
- version = "2.16.1"
7
+ version = "2.18.0"
8
8
  description = "Blackbox analyzer, parameter checker, and tuning wizard for INAV flight controllers"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
File without changes
File without changes
File without changes