inav-toolkit 2.16.1__tar.gz → 2.17.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.17.0}/PKG-INFO +1 -1
  2. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/__init__.py +1 -1
  3. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/blackbox_analyzer.py +202 -33
  4. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/flight_db.py +1 -1
  5. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/msp.py +1 -1
  6. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/param_analyzer.py +1 -1
  7. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/vtol_configurator.py +1 -1
  8. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/wizard.py +1 -1
  9. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit.egg-info/PKG-INFO +1 -1
  10. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/pyproject.toml +1 -1
  11. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/LICENSE +0 -0
  12. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/README.md +0 -0
  13. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/autotune.py +0 -0
  14. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/i18n.py +0 -0
  15. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/locales/en.json +0 -0
  16. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/locales/es.json +0 -0
  17. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/locales/pt_BR.json +0 -0
  18. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit.egg-info/SOURCES.txt +0 -0
  19. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit.egg-info/dependency_links.txt +0 -0
  20. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit.egg-info/entry_points.txt +0 -0
  21. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit.egg-info/requires.txt +0 -0
  22. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit.egg-info/top_level.txt +0 -0
  23. {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/setup.cfg +0 -0
  24. {inav_toolkit-2.16.1 → inav_toolkit-2.17.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.17.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.17.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.17.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.17.0"
108
108
 
109
109
  # ─── Frame and Prop Profiles ─────────────────────────────────────────────────
110
110
  # Two separate concerns:
@@ -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.17.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,
@@ -6824,7 +6992,7 @@ def _analyze_for_compare(logfile, args, diff_raw=None):
6824
6992
  sr = data["sample_rate"]
6825
6993
 
6826
6994
  # Run all analyses
6827
- hover_osc = detect_hover_oscillation(data, sr)
6995
+ hover_osc = detect_hover_oscillation(data, sr, profile)
6828
6996
  noise_results = [analyze_noise(data, ax, f"gyro_{ax.lower()}", sr) for ax in AXIS_NAMES]
6829
6997
  pid_results = [analyze_pid_response(data, i, sr) for i in range(3)]
6830
6998
  motor_analysis = analyze_motors(data, sr, config)
@@ -7979,8 +8147,9 @@ def _analyze_single_log(logfile, args, diff_raw=None, summary_only=False):
7979
8147
  # ── NAV-ONLY MODE: skip tuning, run navigation health analysis ──
7980
8148
  if nav_mode:
7981
8149
  # 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")]
8150
+ hover_osc = detect_hover_oscillation(data, sr, profile)
8151
+ osc_severe = [h for h in hover_osc
8152
+ if h["severity"] in ("moderate", "severe") and h.get("cause") != "wind_buffeting"]
7984
8153
  tune_warning = False
7985
8154
  if osc_severe:
7986
8155
  tune_warning = True
@@ -8011,7 +8180,7 @@ def _analyze_single_log(logfile, args, diff_raw=None, summary_only=False):
8011
8180
  print(" Enable NAV_POS, NAV_ACC, and Attitude in blackbox settings.")
8012
8181
  return None
8013
8182
 
8014
- hover_osc = detect_hover_oscillation(data, sr)
8183
+ hover_osc = detect_hover_oscillation(data, sr, profile)
8015
8184
  noise_results = [analyze_noise(data, ax, f"gyro_{ax.lower()}", sr) for ax in AXIS_NAMES]
8016
8185
  noise_fp = fingerprint_noise(noise_results, config, prop_harmonics)
8017
8186
  pid_results = [analyze_pid_response(data, i, sr) for i in range(3)]
@@ -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.17.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.17.0"
37
37
 
38
38
  # ─── MSP Command IDs ─────────────────────────────────────────────────────────
39
39
 
@@ -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.17.0"
25
25
 
26
26
 
27
27
  def _enable_ansi_colors():
@@ -25,7 +25,7 @@ import re
25
25
  import sys
26
26
  import textwrap
27
27
 
28
- VERSION = "2.16.1"
28
+ VERSION = "2.17.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.17.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.17.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.17.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