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.
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/PKG-INFO +1 -1
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/__init__.py +1 -1
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/blackbox_analyzer.py +202 -33
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/flight_db.py +1 -1
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/msp.py +1 -1
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/param_analyzer.py +1 -1
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/vtol_configurator.py +1 -1
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/wizard.py +1 -1
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit.egg-info/PKG-INFO +1 -1
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/pyproject.toml +1 -1
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/LICENSE +0 -0
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/README.md +0 -0
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/autotune.py +0 -0
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/i18n.py +0 -0
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/locales/en.json +0 -0
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/locales/es.json +0 -0
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit/locales/pt_BR.json +0 -0
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit.egg-info/SOURCES.txt +0 -0
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit.egg-info/dependency_links.txt +0 -0
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit.egg-info/entry_points.txt +0 -0
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit.egg-info/requires.txt +0 -0
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/inav_toolkit.egg-info/top_level.txt +0 -0
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/setup.cfg +0 -0
- {inav_toolkit-2.16.1 → inav_toolkit-2.17.0}/tests/test_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
INAV Blackbox Analyzer - Multirotor Tuning Tool v2.
|
|
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.
|
|
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
|
|
2644
|
+
if peak_prominence < 3:
|
|
2626
2645
|
dominant_freq = None # No clear dominant frequency
|
|
2627
2646
|
|
|
2628
|
-
# Classify severity
|
|
2629
|
-
|
|
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 <
|
|
2650
|
+
elif gyro_rms < sev_mild:
|
|
2636
2651
|
severity = "mild"
|
|
2637
|
-
elif gyro_rms <
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 == "
|
|
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
|
-
|
|
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}
|
|
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}
|
|
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.
|
|
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
|
|
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)]
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "inav-toolkit"
|
|
7
|
-
version = "2.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|