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.
@@ -0,0 +1,3 @@
1
+ """INAV Toolkit - Blackbox analyzer, parameter checker, and tuning wizard for INAV flight controllers."""
2
+
3
+ __version__ = "2.15.0"
@@ -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()