inav-toolkit 2.15.0__py3-none-any.whl
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/__init__.py +3 -0
- inav_toolkit/autotune.py +504 -0
- inav_toolkit/blackbox_analyzer.py +8017 -0
- inav_toolkit/flight_db.py +467 -0
- inav_toolkit/msp.py +1115 -0
- inav_toolkit/param_analyzer.py +2715 -0
- inav_toolkit/vtol_configurator.py +856 -0
- inav_toolkit/wizard.py +1095 -0
- inav_toolkit-2.15.0.dist-info/METADATA +375 -0
- inav_toolkit-2.15.0.dist-info/RECORD +14 -0
- inav_toolkit-2.15.0.dist-info/WHEEL +5 -0
- inav_toolkit-2.15.0.dist-info/entry_points.txt +5 -0
- inav_toolkit-2.15.0.dist-info/licenses/LICENSE +674 -0
- inav_toolkit-2.15.0.dist-info/top_level.txt +1 -0
inav_toolkit/__init__.py
ADDED
inav_toolkit/autotune.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
INAV Autotune Orchestrator
|
|
4
|
+
───────────────────────────
|
|
5
|
+
Automated PID tuning loop for INAV multirotors.
|
|
6
|
+
|
|
7
|
+
Connects to the FC via USB/UART, captures blackbox data, runs the
|
|
8
|
+
analyzer, computes new parameters, and optionally pushes them back.
|
|
9
|
+
|
|
10
|
+
Same code runs on:
|
|
11
|
+
- Your laptop at the bench (USB cable to FC)
|
|
12
|
+
- A Pi Zero 2W strapped to the quad (USB OTG to FC)
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
# Advisory mode - show recommendations, don't apply
|
|
16
|
+
python3 -m inav_toolkit.autotune /dev/ttyACM0 --frame 5 --blades 3
|
|
17
|
+
|
|
18
|
+
# Auto mode - apply changes automatically between captures
|
|
19
|
+
python3 -m inav_toolkit.autotune /dev/ttyACM0 --frame 10 --blades 2 --auto
|
|
20
|
+
|
|
21
|
+
# Single analysis of an existing blackbox file (no FC connection)
|
|
22
|
+
python3 -m inav_toolkit.autotune --file flight.bbl --frame 7
|
|
23
|
+
|
|
24
|
+
Requires: inav_toolkit package (pip install inav-toolkit)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
import copy
|
|
33
|
+
import logging
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger("autotune")
|
|
36
|
+
|
|
37
|
+
# ─── Import our modules ──────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
from inav_toolkit.msp import INAVLink
|
|
41
|
+
except ImportError:
|
|
42
|
+
try:
|
|
43
|
+
from inav_msp import INAVLink
|
|
44
|
+
except ImportError:
|
|
45
|
+
INAVLink = None
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
from inav_toolkit.blackbox_analyzer import (
|
|
49
|
+
get_frame_profile, parse_csv_log, analyze_noise, analyze_pid_response,
|
|
50
|
+
analyze_motors, analyze_dterm_noise, generate_action_plan,
|
|
51
|
+
analyze_motor_response, estimate_rpm_range, estimate_prop_harmonics,
|
|
52
|
+
estimate_total_phase_lag, config_has_pid, config_has_filters,
|
|
53
|
+
AXIS_NAMES, REPORT_VERSION,
|
|
54
|
+
)
|
|
55
|
+
except ImportError:
|
|
56
|
+
from inav_blackbox_analyzer import (
|
|
57
|
+
get_frame_profile, parse_csv_log, analyze_noise, analyze_pid_response,
|
|
58
|
+
analyze_motors, analyze_dterm_noise, generate_action_plan,
|
|
59
|
+
analyze_motor_response, estimate_rpm_range, estimate_prop_harmonics,
|
|
60
|
+
estimate_total_phase_lag, config_has_pid, config_has_filters,
|
|
61
|
+
AXIS_NAMES, REPORT_VERSION,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ─── Safety Clamps ───────────────────────────────────────────────────────────
|
|
66
|
+
# These are absolute limits that can NEVER be exceeded, regardless of what
|
|
67
|
+
# the analyzer recommends. They protect against bugs, bad data, and edge cases.
|
|
68
|
+
# The frame profile provides tighter, class-specific limits on top of these.
|
|
69
|
+
|
|
70
|
+
ABSOLUTE_CLAMPS = {
|
|
71
|
+
"roll_p": (10, 200), "roll_i": (5, 200), "roll_d": (0, 150),
|
|
72
|
+
"pitch_p": (10, 200), "pitch_i": (5, 200), "pitch_d": (0, 150),
|
|
73
|
+
"yaw_p": (10, 200), "yaw_i": (5, 200), "yaw_d": (0, 100),
|
|
74
|
+
"gyro_lowpass_hz": (10, 500),
|
|
75
|
+
"dterm_lpf_hz": (10, 300),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Maximum change per parameter per iteration (as fraction of current value)
|
|
79
|
+
MAX_CHANGE_PER_STEP = 0.20 # never change more than 20% in one step
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def clamp_value(param, value, profile=None):
|
|
83
|
+
"""Apply safety clamps to a parameter value."""
|
|
84
|
+
value = int(round(value))
|
|
85
|
+
if param in ABSOLUTE_CLAMPS:
|
|
86
|
+
lo, hi = ABSOLUTE_CLAMPS[param]
|
|
87
|
+
value = max(lo, min(hi, value))
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def limit_change(param, old_value, new_value):
|
|
92
|
+
"""Limit the magnitude of change per step."""
|
|
93
|
+
if old_value == 0:
|
|
94
|
+
return new_value
|
|
95
|
+
max_delta = abs(old_value * MAX_CHANGE_PER_STEP)
|
|
96
|
+
delta = new_value - old_value
|
|
97
|
+
if abs(delta) > max_delta:
|
|
98
|
+
clamped = old_value + (max_delta if delta > 0 else -max_delta)
|
|
99
|
+
log.info(f" Rate-limited {param}: wanted {old_value}→{new_value}, "
|
|
100
|
+
f"capped to {old_value}→{int(clamped)}")
|
|
101
|
+
return int(clamped)
|
|
102
|
+
return new_value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ─── Tuning Session ──────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
class TuningSession:
|
|
108
|
+
"""Manages an iterative tuning session.
|
|
109
|
+
|
|
110
|
+
Tracks the history of parameters and scores across iterations,
|
|
111
|
+
enforces safety limits, and can revert to any previous state.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, profile, motor_kv=None, cell_count=None):
|
|
115
|
+
self.profile = profile
|
|
116
|
+
self.motor_kv = motor_kv
|
|
117
|
+
self.cell_count = cell_count
|
|
118
|
+
self.iterations = [] # list of {params, plan, score, timestamp}
|
|
119
|
+
self.initial_params = None # snapshot before any changes
|
|
120
|
+
self.best_score = 0
|
|
121
|
+
self.best_iteration = 0
|
|
122
|
+
|
|
123
|
+
def record_iteration(self, params, plan):
|
|
124
|
+
"""Record the results of one analyze-recommend cycle."""
|
|
125
|
+
score = plan["scores"]["overall"]
|
|
126
|
+
entry = {
|
|
127
|
+
"iteration": len(self.iterations) + 1,
|
|
128
|
+
"params": copy.deepcopy(params),
|
|
129
|
+
"score": score,
|
|
130
|
+
"verdict": plan["verdict"],
|
|
131
|
+
"n_actions": len(plan["actions"]),
|
|
132
|
+
"timestamp": time.time(),
|
|
133
|
+
}
|
|
134
|
+
self.iterations.append(entry)
|
|
135
|
+
|
|
136
|
+
if score > self.best_score:
|
|
137
|
+
self.best_score = score
|
|
138
|
+
self.best_iteration = entry["iteration"]
|
|
139
|
+
|
|
140
|
+
return entry
|
|
141
|
+
|
|
142
|
+
def get_safe_changes(self, plan, current_params):
|
|
143
|
+
"""Extract parameter changes from the action plan, apply safety clamps
|
|
144
|
+
and rate limiting. Returns dict of {param: new_value} ready to apply."""
|
|
145
|
+
changes = {}
|
|
146
|
+
|
|
147
|
+
for action in plan["actions"]:
|
|
148
|
+
param = action.get("param")
|
|
149
|
+
new_val = action.get("new")
|
|
150
|
+
|
|
151
|
+
if param is None or new_val is None:
|
|
152
|
+
continue
|
|
153
|
+
if isinstance(new_val, str):
|
|
154
|
+
continue # skip non-numeric recommendations like "see action"
|
|
155
|
+
if param in ("motor_saturation", "motor_balance", "motor_response",
|
|
156
|
+
"filter_chain", "dynamic_notch"):
|
|
157
|
+
continue # skip diagnostic-only actions
|
|
158
|
+
|
|
159
|
+
# Get current value
|
|
160
|
+
old_val = current_params.get(param)
|
|
161
|
+
if old_val is None or isinstance(old_val, str):
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
new_val = int(new_val)
|
|
165
|
+
old_val = int(old_val)
|
|
166
|
+
|
|
167
|
+
# Apply rate limiting
|
|
168
|
+
new_val = limit_change(param, old_val, new_val)
|
|
169
|
+
|
|
170
|
+
# Apply absolute clamps
|
|
171
|
+
new_val = clamp_value(param, new_val, self.profile)
|
|
172
|
+
|
|
173
|
+
# Only include if actually changed
|
|
174
|
+
if new_val != old_val:
|
|
175
|
+
changes[param] = new_val
|
|
176
|
+
|
|
177
|
+
return changes
|
|
178
|
+
|
|
179
|
+
def is_converged(self, min_score=70, min_iterations=2):
|
|
180
|
+
"""Check if tuning has converged (good enough to stop)."""
|
|
181
|
+
if len(self.iterations) < min_iterations:
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
latest = self.iterations[-1]
|
|
185
|
+
if latest["score"] >= min_score and latest["n_actions"] <= 2:
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
# Check if score stopped improving
|
|
189
|
+
if len(self.iterations) >= 3:
|
|
190
|
+
recent_scores = [it["score"] for it in self.iterations[-3:]]
|
|
191
|
+
if max(recent_scores) - min(recent_scores) < 3:
|
|
192
|
+
return True # plateau
|
|
193
|
+
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
def summary(self):
|
|
197
|
+
"""Print a summary of the tuning session."""
|
|
198
|
+
lines = []
|
|
199
|
+
lines.append(f"\n ═══ Tuning Session Summary ═══")
|
|
200
|
+
lines.append(f" Profile: {self.profile['name']} ({self.profile['class']})")
|
|
201
|
+
lines.append(f" Iterations: {len(self.iterations)}")
|
|
202
|
+
|
|
203
|
+
if self.iterations:
|
|
204
|
+
lines.append(f" Best score: {self.best_score:.0f} (iteration {self.best_iteration})")
|
|
205
|
+
lines.append(f" Latest: score={self.iterations[-1]['score']:.0f}, "
|
|
206
|
+
f"verdict={self.iterations[-1]['verdict']}")
|
|
207
|
+
lines.append(f"")
|
|
208
|
+
lines.append(f" {'#':>3s} {'Score':>6s} {'Actions':>7s} Verdict")
|
|
209
|
+
lines.append(f" {'─'*3} {'─'*6} {'─'*7} {'─'*20}")
|
|
210
|
+
for it in self.iterations:
|
|
211
|
+
marker = " ◄" if it["iteration"] == self.best_iteration else ""
|
|
212
|
+
lines.append(f" {it['iteration']:3d} {it['score']:6.0f} {it['n_actions']:7d} "
|
|
213
|
+
f"{it['verdict']}{marker}")
|
|
214
|
+
|
|
215
|
+
return "\n".join(lines)
|
|
216
|
+
|
|
217
|
+
def save(self, path):
|
|
218
|
+
"""Save session state to JSON file."""
|
|
219
|
+
data = {
|
|
220
|
+
"profile": self.profile["name"],
|
|
221
|
+
"iterations": self.iterations,
|
|
222
|
+
"best_score": self.best_score,
|
|
223
|
+
"best_iteration": self.best_iteration,
|
|
224
|
+
}
|
|
225
|
+
with open(path, "w") as f:
|
|
226
|
+
json.dump(data, f, indent=2, default=str)
|
|
227
|
+
log.info(f"Session saved to {path}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ─── Analyze from FC parameters ──────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
def analyze_from_csv(csv_path, config, profile, motor_kv=None, cell_count=None):
|
|
233
|
+
"""Run the full analysis pipeline on a CSV blackbox file.
|
|
234
|
+
Returns (plan, noise_results, pid_results, motor_analysis)."""
|
|
235
|
+
|
|
236
|
+
data = parse_csv_log(csv_path)
|
|
237
|
+
sr = data["sample_rate"]
|
|
238
|
+
|
|
239
|
+
noise_results = [analyze_noise(data, ax, f"gyro_{ax.lower()}", sr) for ax in AXIS_NAMES]
|
|
240
|
+
pid_results = [analyze_pid_response(data, i, sr) for i in range(3)]
|
|
241
|
+
motor_analysis = analyze_motors(data, sr)
|
|
242
|
+
dterm_results = analyze_dterm_noise(data, sr)
|
|
243
|
+
motor_response = analyze_motor_response(data, sr)
|
|
244
|
+
|
|
245
|
+
# RPM prediction
|
|
246
|
+
rpm_range = estimate_rpm_range(motor_kv, cell_count)
|
|
247
|
+
prop_harmonics = estimate_prop_harmonics(rpm_range, profile.get("n_blades", 3)) if rpm_range else None
|
|
248
|
+
|
|
249
|
+
# Phase lag
|
|
250
|
+
phase_lag = None
|
|
251
|
+
if config_has_filters(config):
|
|
252
|
+
sig_freq = (profile["noise_band_mid"][0] + profile["noise_band_mid"][1]) / 4
|
|
253
|
+
phase_lag = estimate_total_phase_lag(config, profile, sig_freq)
|
|
254
|
+
|
|
255
|
+
plan = generate_action_plan(
|
|
256
|
+
noise_results, pid_results, motor_analysis, dterm_results,
|
|
257
|
+
config, data, profile, phase_lag, motor_response, rpm_range, prop_harmonics
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
return plan, data, noise_results, pid_results, motor_analysis
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ─── Main ─────────────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
def main():
|
|
266
|
+
parser = argparse.ArgumentParser(
|
|
267
|
+
description="INAV Autotune - Iterative PID Tuning",
|
|
268
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
269
|
+
epilog="""
|
|
270
|
+
Examples:
|
|
271
|
+
# Bench mode: FC connected via USB, analyze a blackbox file
|
|
272
|
+
python3 -m inav_toolkit.autotune /dev/ttyACM0 --file flight.bbl --frame 5
|
|
273
|
+
|
|
274
|
+
# Read current PIDs from FC and show status
|
|
275
|
+
python3 -m inav_toolkit.autotune /dev/ttyACM0 --frame 5 --status
|
|
276
|
+
|
|
277
|
+
# Analyze a blackbox file with FC params, show recommendations
|
|
278
|
+
python3 -m inav_toolkit.autotune /dev/ttyACM0 --file flight.bbl --frame 10 --blades 2
|
|
279
|
+
|
|
280
|
+
# Auto-apply recommendations to FC (bench tuning with USB)
|
|
281
|
+
python3 -m inav_toolkit.autotune /dev/ttyACM0 --file flight.bbl --frame 5 --apply
|
|
282
|
+
|
|
283
|
+
# Offline mode (no FC): analyze a file only
|
|
284
|
+
python3 -m inav_toolkit.autotune --file flight.bbl --frame 7 --blades 2
|
|
285
|
+
""")
|
|
286
|
+
|
|
287
|
+
parser.add_argument("--version", action="version", version=f"inav-autotune {REPORT_VERSION}")
|
|
288
|
+
parser.add_argument("port", nargs="?", help="Serial port to FC (e.g., /dev/ttyACM0)")
|
|
289
|
+
parser.add_argument("--file", help="Blackbox log file (.bbl or .csv) to analyze")
|
|
290
|
+
parser.add_argument("--decoder", help="Path to blackbox_decode binary")
|
|
291
|
+
|
|
292
|
+
# Frame profile
|
|
293
|
+
parser.add_argument("--frame", type=int, metavar="INCHES",
|
|
294
|
+
help="Frame size in inches (determines PID thresholds)")
|
|
295
|
+
parser.add_argument("--props", type=int, metavar="INCHES",
|
|
296
|
+
help="Prop diameter in inches (determines filter ranges)")
|
|
297
|
+
parser.add_argument("--blades", type=int, default=3, metavar="N",
|
|
298
|
+
help="Prop blade count (default: 3)")
|
|
299
|
+
parser.add_argument("--cells", type=int, metavar="S", help="Battery cell count")
|
|
300
|
+
parser.add_argument("--kv", type=int, metavar="KV", help="Motor KV rating")
|
|
301
|
+
|
|
302
|
+
# Operation modes
|
|
303
|
+
parser.add_argument("--status", action="store_true",
|
|
304
|
+
help="Read and display FC status and current PIDs")
|
|
305
|
+
parser.add_argument("--apply", action="store_true",
|
|
306
|
+
help="Apply recommended changes to FC (with confirmation)")
|
|
307
|
+
parser.add_argument("--auto", action="store_true",
|
|
308
|
+
help="Auto-apply without confirmation (use with caution)")
|
|
309
|
+
parser.add_argument("--dry-run", action="store_true",
|
|
310
|
+
help="Show what would change without applying")
|
|
311
|
+
parser.add_argument("--save", action="store_true",
|
|
312
|
+
help="Save applied changes to EEPROM (persistent)")
|
|
313
|
+
parser.add_argument("--revert", action="store_true",
|
|
314
|
+
help="Revert FC to initial params from session start")
|
|
315
|
+
parser.add_argument("--session", help="Session state file for tracking iterations")
|
|
316
|
+
|
|
317
|
+
args = parser.parse_args()
|
|
318
|
+
|
|
319
|
+
logging.basicConfig(level=logging.INFO, format=" %(message)s")
|
|
320
|
+
|
|
321
|
+
# ── Build profile ──
|
|
322
|
+
frame_inches = args.frame
|
|
323
|
+
prop_inches = args.props
|
|
324
|
+
if frame_inches and not prop_inches:
|
|
325
|
+
prop_inches = frame_inches
|
|
326
|
+
elif prop_inches and not frame_inches:
|
|
327
|
+
frame_inches = prop_inches
|
|
328
|
+
profile = get_frame_profile(frame_inches, prop_inches, args.blades)
|
|
329
|
+
|
|
330
|
+
print(f"\n ▲ INAV Autotune v{REPORT_VERSION}")
|
|
331
|
+
print(f" Profile: {profile['name']} ({profile['class']})")
|
|
332
|
+
|
|
333
|
+
# ── Connect to FC if port provided ──
|
|
334
|
+
fc = None
|
|
335
|
+
fc_params = {}
|
|
336
|
+
|
|
337
|
+
if args.port:
|
|
338
|
+
if INAVLink is None:
|
|
339
|
+
print(" ERROR: MSP module not found or pyserial not installed")
|
|
340
|
+
print(" pip install inav-toolkit")
|
|
341
|
+
sys.exit(1)
|
|
342
|
+
|
|
343
|
+
fc = INAVLink(args.port)
|
|
344
|
+
try:
|
|
345
|
+
info = fc.connect()
|
|
346
|
+
print(f" FC: {info['fc_variant']} {'.'.join(str(v) for v in info['fc_version'])}")
|
|
347
|
+
print(f" Board: {info['board_name']} | Craft: {info.get('craft_name', '?')}")
|
|
348
|
+
print(f" MSP: {'V2' if info['msp_v2'] else 'V1'}")
|
|
349
|
+
|
|
350
|
+
fc_params = fc.read_tuning_params()
|
|
351
|
+
|
|
352
|
+
if args.status:
|
|
353
|
+
status = fc.get_status()
|
|
354
|
+
if status:
|
|
355
|
+
armed = "ARMED" if status["is_armed"] else "DISARMED"
|
|
356
|
+
print(f"\n Status: {armed}")
|
|
357
|
+
|
|
358
|
+
analog = fc.get_analog()
|
|
359
|
+
if analog:
|
|
360
|
+
print(f" Battery: {analog['vbat']:.1f}V | {analog['amps']:.1f}A")
|
|
361
|
+
|
|
362
|
+
print(f"\n Current PID Values:")
|
|
363
|
+
for axis in ["roll", "pitch", "yaw"]:
|
|
364
|
+
p = fc_params.get(f"{axis}_p", "?")
|
|
365
|
+
i = fc_params.get(f"{axis}_i", "?")
|
|
366
|
+
d = fc_params.get(f"{axis}_d", "?")
|
|
367
|
+
print(f" {axis.capitalize():6s} P={p:>3s} I={i:>3s} D={d:>3s}" if isinstance(p, str)
|
|
368
|
+
else f" {axis.capitalize():6s} P={p:3d} I={i:3d} D={d:3d}")
|
|
369
|
+
|
|
370
|
+
filters = fc_params.get("_raw_filters", {})
|
|
371
|
+
if filters:
|
|
372
|
+
print(f"\n Current Filters:")
|
|
373
|
+
for k, v in filters.items():
|
|
374
|
+
if v and v > 0:
|
|
375
|
+
print(f" {k}: {v}")
|
|
376
|
+
|
|
377
|
+
if not args.file:
|
|
378
|
+
print()
|
|
379
|
+
fc.close()
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
except Exception as e:
|
|
383
|
+
print(f" ERROR connecting to FC: {e}")
|
|
384
|
+
sys.exit(1)
|
|
385
|
+
|
|
386
|
+
elif not args.file:
|
|
387
|
+
parser.print_help()
|
|
388
|
+
print("\n ERROR: provide either a serial port or --file (or both)")
|
|
389
|
+
sys.exit(1)
|
|
390
|
+
|
|
391
|
+
# ── Analyze blackbox file ──
|
|
392
|
+
if args.file:
|
|
393
|
+
try:
|
|
394
|
+
from inav_toolkit.blackbox_analyzer import parse_headers_from_bbl, decode_blackbox, extract_fc_config
|
|
395
|
+
except ImportError:
|
|
396
|
+
from inav_blackbox_analyzer import parse_headers_from_bbl, decode_blackbox, extract_fc_config
|
|
397
|
+
import shutil
|
|
398
|
+
|
|
399
|
+
logfile = args.file
|
|
400
|
+
if not os.path.isfile(logfile):
|
|
401
|
+
print(f" ERROR: File not found: {logfile}")
|
|
402
|
+
sys.exit(1)
|
|
403
|
+
|
|
404
|
+
print(f"\n Analyzing: {logfile}")
|
|
405
|
+
|
|
406
|
+
tmpdir = None
|
|
407
|
+
ext = os.path.splitext(logfile)[1].lower()
|
|
408
|
+
if ext in (".bbl", ".bfl", ".bbs"):
|
|
409
|
+
raw_params = parse_headers_from_bbl(logfile)
|
|
410
|
+
csv_path, tmpdir = decode_blackbox(logfile, args.decoder)
|
|
411
|
+
file_config = extract_fc_config(raw_params)
|
|
412
|
+
else:
|
|
413
|
+
csv_path = logfile
|
|
414
|
+
file_config = {}
|
|
415
|
+
|
|
416
|
+
# Merge: FC live params take priority over file headers
|
|
417
|
+
config = {**file_config, **{k: v for k, v in fc_params.items() if not k.startswith("_")}}
|
|
418
|
+
|
|
419
|
+
plan, data, noise_results, pid_results, motor_analysis = analyze_from_csv(
|
|
420
|
+
csv_path, config, profile, args.kv, args.cells
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
if tmpdir:
|
|
424
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
425
|
+
|
|
426
|
+
# ── Display results ──
|
|
427
|
+
score = plan["scores"]["overall"]
|
|
428
|
+
print(f"\n Quality Score: {score:.0f}/100 - {plan['verdict']}")
|
|
429
|
+
print(f" Actions: {len(plan['actions'])}")
|
|
430
|
+
|
|
431
|
+
if plan["actions"]:
|
|
432
|
+
print(f"\n Recommended Changes:")
|
|
433
|
+
print(f" {'#':>3s} {'Priority':>8s} Action")
|
|
434
|
+
print(f" {'─'*3} {'─'*8} {'─'*50}")
|
|
435
|
+
for i, action in enumerate(sorted(plan["actions"], key=lambda a: a["priority"])):
|
|
436
|
+
urg = f"[{action['urgency']}]" if action.get("urgency") else ""
|
|
437
|
+
print(f" {i+1:3d} P{action['priority']:d} {urg:>10s} {action['action']}")
|
|
438
|
+
|
|
439
|
+
# ── Session tracking ──
|
|
440
|
+
session = TuningSession(profile, args.kv, args.cells)
|
|
441
|
+
if session.initial_params is None:
|
|
442
|
+
session.initial_params = copy.deepcopy(config)
|
|
443
|
+
session.record_iteration(config, plan)
|
|
444
|
+
|
|
445
|
+
# ── Apply changes ──
|
|
446
|
+
if (args.apply or args.auto or args.dry_run) and fc:
|
|
447
|
+
changes = session.get_safe_changes(plan, config)
|
|
448
|
+
|
|
449
|
+
if not changes:
|
|
450
|
+
print(f"\n No applicable parameter changes.")
|
|
451
|
+
else:
|
|
452
|
+
print(f"\n Proposed Changes (safety-clamped):")
|
|
453
|
+
for param, new_val in changes.items():
|
|
454
|
+
old_val = config.get(param, "?")
|
|
455
|
+
print(f" {param}: {old_val} → {new_val}")
|
|
456
|
+
|
|
457
|
+
if args.dry_run:
|
|
458
|
+
print(f"\n DRY RUN - no changes applied")
|
|
459
|
+
elif args.auto:
|
|
460
|
+
applied = fc.apply_tuning_params(changes)
|
|
461
|
+
print(f"\n ✓ Applied {len(applied)} changes to FC (RAM)")
|
|
462
|
+
if args.save:
|
|
463
|
+
fc.save_to_eeprom()
|
|
464
|
+
print(f" ✓ Saved to EEPROM")
|
|
465
|
+
elif args.apply:
|
|
466
|
+
confirm = input(f"\n Apply {len(changes)} changes to FC? [y/N] ").strip().lower()
|
|
467
|
+
if confirm == 'y':
|
|
468
|
+
applied = fc.apply_tuning_params(changes)
|
|
469
|
+
print(f"\n ✓ Applied {len(applied)} changes to FC (RAM)")
|
|
470
|
+
if args.save:
|
|
471
|
+
fc.save_to_eeprom()
|
|
472
|
+
print(f" ✓ Saved to EEPROM")
|
|
473
|
+
else:
|
|
474
|
+
print(f" ⚠ Changes in RAM only - use --save to persist, or reboot to discard")
|
|
475
|
+
else:
|
|
476
|
+
print(f" Cancelled.")
|
|
477
|
+
|
|
478
|
+
# ── Revert ──
|
|
479
|
+
if args.revert and fc and session.initial_params:
|
|
480
|
+
print(f"\n Reverting to initial parameters...")
|
|
481
|
+
revert_changes = {}
|
|
482
|
+
for param in ABSOLUTE_CLAMPS.keys():
|
|
483
|
+
if param in session.initial_params:
|
|
484
|
+
revert_changes[param] = session.initial_params[param]
|
|
485
|
+
if revert_changes:
|
|
486
|
+
fc.apply_tuning_params(revert_changes)
|
|
487
|
+
if args.save:
|
|
488
|
+
fc.save_to_eeprom()
|
|
489
|
+
print(f" ✓ Reverted {len(revert_changes)} parameters")
|
|
490
|
+
|
|
491
|
+
# ── Save session ──
|
|
492
|
+
if args.session:
|
|
493
|
+
session.save(args.session)
|
|
494
|
+
|
|
495
|
+
print(session.summary())
|
|
496
|
+
|
|
497
|
+
# ── Cleanup ──
|
|
498
|
+
if fc:
|
|
499
|
+
fc.close()
|
|
500
|
+
print()
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
if __name__ == "__main__":
|
|
504
|
+
main()
|