inav-toolkit 2.3.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,674 @@
1
+ #!/usr/bin/env python3
2
+ """INAV Flight History Database.
3
+
4
+ Stores flight analysis results in SQLite for progression tracking.
5
+ Each analyzed flight is recorded with scores, per-axis measurements,
6
+ motor data, configuration snapshot, and recommended actions.
7
+
8
+ Usage:
9
+ from inav_toolkit.flight_db import FlightDB
10
+
11
+ db = FlightDB("./inav_flights.db")
12
+ flight_id = db.store_flight(plan, config, data, hover_osc, motor_analysis,
13
+ pid_results, noise_results, config_raw=None)
14
+
15
+ # Get progression for a craft
16
+ history = db.get_craft_history("NAZGUL 10", limit=10)
17
+ """
18
+
19
+ import sqlite3
20
+ import json
21
+ import os
22
+ from datetime import datetime
23
+
24
+ VERSION = "2.3.0"
25
+
26
+ SCHEMA_VERSION = 1
27
+
28
+ SCHEMA = """
29
+ CREATE TABLE IF NOT EXISTS schema_info (
30
+ key TEXT PRIMARY KEY,
31
+ value TEXT
32
+ );
33
+
34
+ CREATE TABLE IF NOT EXISTS flights (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ timestamp TEXT NOT NULL,
37
+ craft TEXT NOT NULL,
38
+ firmware TEXT,
39
+ board TEXT,
40
+ duration_s REAL,
41
+ sample_rate REAL,
42
+ total_frames INTEGER,
43
+ log_file TEXT,
44
+ -- Scores
45
+ overall_score REAL,
46
+ noise_score REAL,
47
+ pid_score REAL,
48
+ pid_measurable INTEGER,
49
+ motor_score REAL,
50
+ osc_score REAL,
51
+ verdict TEXT,
52
+ verdict_text TEXT,
53
+ -- Raw diff output
54
+ diff_raw TEXT
55
+ );
56
+
57
+ CREATE TABLE IF NOT EXISTS flight_axes (
58
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
59
+ flight_id INTEGER NOT NULL,
60
+ axis TEXT NOT NULL,
61
+ -- Hover oscillation
62
+ hover_severity TEXT,
63
+ hover_rms REAL,
64
+ hover_p2p REAL,
65
+ hover_freq_hz REAL,
66
+ hover_cause TEXT,
67
+ hover_seconds REAL,
68
+ -- PID response
69
+ overshoot_pct REAL,
70
+ delay_ms REAL,
71
+ tracking_error REAL,
72
+ n_steps INTEGER,
73
+ -- Config at time of flight
74
+ p_value INTEGER,
75
+ i_value INTEGER,
76
+ d_value INTEGER,
77
+ ff_value INTEGER,
78
+ FOREIGN KEY (flight_id) REFERENCES flights(id) ON DELETE CASCADE
79
+ );
80
+
81
+ CREATE TABLE IF NOT EXISTS flight_motors (
82
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
83
+ flight_id INTEGER NOT NULL,
84
+ motor_num INTEGER NOT NULL,
85
+ avg_pct REAL,
86
+ saturation_pct REAL,
87
+ FOREIGN KEY (flight_id) REFERENCES flights(id) ON DELETE CASCADE
88
+ );
89
+
90
+ CREATE TABLE IF NOT EXISTS flight_config (
91
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ flight_id INTEGER NOT NULL,
93
+ param TEXT NOT NULL,
94
+ value TEXT,
95
+ FOREIGN KEY (flight_id) REFERENCES flights(id) ON DELETE CASCADE
96
+ );
97
+
98
+ CREATE TABLE IF NOT EXISTS flight_actions (
99
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
100
+ flight_id INTEGER NOT NULL,
101
+ priority INTEGER,
102
+ urgency TEXT,
103
+ category TEXT,
104
+ action TEXT,
105
+ reason TEXT,
106
+ deferred INTEGER DEFAULT 0,
107
+ FOREIGN KEY (flight_id) REFERENCES flights(id) ON DELETE CASCADE
108
+ );
109
+
110
+ CREATE INDEX IF NOT EXISTS idx_flights_craft ON flights(craft);
111
+ CREATE INDEX IF NOT EXISTS idx_flights_timestamp ON flights(timestamp);
112
+ CREATE INDEX IF NOT EXISTS idx_flight_axes_flight ON flight_axes(flight_id);
113
+ CREATE INDEX IF NOT EXISTS idx_flight_motors_flight ON flight_motors(flight_id);
114
+ CREATE INDEX IF NOT EXISTS idx_flight_config_flight ON flight_config(flight_id);
115
+ CREATE INDEX IF NOT EXISTS idx_flight_actions_flight ON flight_actions(flight_id);
116
+ """
117
+
118
+
119
+ class FlightDB:
120
+ """SQLite database for storing and querying flight analysis history."""
121
+
122
+ def __init__(self, db_path="./inav_flights.db"):
123
+ self.db_path = db_path
124
+ self._conn = None
125
+
126
+ def _connect(self):
127
+ if self._conn is None:
128
+ self._conn = sqlite3.connect(self.db_path)
129
+ self._conn.row_factory = sqlite3.Row
130
+ self._conn.execute("PRAGMA journal_mode=WAL")
131
+ self._conn.execute("PRAGMA foreign_keys=ON")
132
+ self._init_schema()
133
+ return self._conn
134
+
135
+ def _init_schema(self):
136
+ conn = self._conn
137
+ conn.executescript(SCHEMA)
138
+ # Check/set schema version
139
+ cur = conn.execute(
140
+ "SELECT value FROM schema_info WHERE key='schema_version'")
141
+ row = cur.fetchone()
142
+ if row is None:
143
+ conn.execute(
144
+ "INSERT INTO schema_info (key, value) VALUES ('schema_version', ?)",
145
+ (str(SCHEMA_VERSION),))
146
+ conn.commit()
147
+
148
+ def close(self):
149
+ if self._conn:
150
+ self._conn.close()
151
+ self._conn = None
152
+
153
+ def store_flight(self, plan, config, data, hover_osc=None,
154
+ motor_analysis=None, pid_results=None,
155
+ noise_results=None, log_file=None, config_raw=None):
156
+ """Store a complete flight analysis.
157
+
158
+ Args:
159
+ plan: Analysis plan dict (from generate_action_plan)
160
+ config: Parsed config dict
161
+ data: Parsed flight data dict
162
+ hover_osc: Hover oscillation results list
163
+ motor_analysis: Motor analysis dict
164
+ pid_results: PID analysis results list
165
+ noise_results: Noise analysis results list
166
+ log_file: Path to the log file
167
+ config_raw: Raw CLI dump/diff output string
168
+
169
+ Returns:
170
+ flight_id (int), or existing flight_id if duplicate detected
171
+ """
172
+ conn = self._connect()
173
+ scores = plan.get("scores", {})
174
+
175
+ # Extract metadata
176
+ craft = config.get("craft_name", "unknown")
177
+ firmware = config.get("firmware_version", "")
178
+ board = config.get("board", "")
179
+ duration = float(data["time_s"][-1]) if "time_s" in data and len(data["time_s"]) > 0 else 0
180
+ sr = data.get("sample_rate", 0)
181
+ total_frames = len(data.get("time_s", []))
182
+
183
+ # ── Deduplication ──
184
+ # Content-based: match on craft + total_frames + duration + firmware.
185
+ # This catches re-downloads of the same flash (different filenames)
186
+ # as well as re-analysis of the same file.
187
+ existing = conn.execute("""
188
+ SELECT id FROM flights
189
+ WHERE craft = ? AND total_frames = ?
190
+ AND ABS(duration_s - ?) < 0.1
191
+ AND firmware = ?
192
+ """, (craft, total_frames, duration, firmware)).fetchone()
193
+ if existing:
194
+ return existing[0], False # (flight_id, is_new=False)
195
+
196
+ # Insert flight record
197
+ cur = conn.execute("""
198
+ INSERT INTO flights (timestamp, craft, firmware, board, duration_s,
199
+ sample_rate, total_frames, log_file,
200
+ overall_score, noise_score, pid_score, pid_measurable,
201
+ motor_score, osc_score, verdict, verdict_text, diff_raw)
202
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
203
+ """, (
204
+ datetime.now().isoformat(),
205
+ craft, firmware, board, duration, sr, total_frames, log_file,
206
+ scores.get("overall"),
207
+ scores.get("noise"),
208
+ scores.get("pid"),
209
+ 1 if scores.get("pid_measurable", False) else 0,
210
+ scores.get("motor"),
211
+ scores.get("gyro_oscillation"),
212
+ plan.get("verdict"),
213
+ plan.get("verdict_text"),
214
+ config_raw, # stored as diff_raw column
215
+ ))
216
+ flight_id = cur.lastrowid
217
+
218
+ # Store per-axis data
219
+ axis_names = ["Roll", "Pitch", "Yaw"]
220
+ for i, axis in enumerate(axis_names):
221
+ axis_l = axis.lower()
222
+ # Hover oscillation
223
+ h_sev = h_rms = h_p2p = h_freq = h_cause = h_sec = None
224
+ if hover_osc:
225
+ for osc in hover_osc:
226
+ if osc["axis"] == axis:
227
+ h_sev = osc["severity"]
228
+ h_rms = osc["gyro_rms"]
229
+ h_p2p = osc["gyro_p2p"]
230
+ h_freq = osc.get("dominant_freq_hz")
231
+ h_cause = osc.get("cause")
232
+ h_sec = osc.get("hover_seconds")
233
+ break
234
+
235
+ # PID response
236
+ os_pct = dl_ms = trk_err = n_steps = None
237
+ if pid_results and i < len(pid_results) and pid_results[i]:
238
+ pid = pid_results[i]
239
+ os_pct = pid.get("avg_overshoot_pct")
240
+ dl_ms = pid.get("tracking_delay_ms")
241
+ trk_err = pid.get("rms_error")
242
+ n_steps = pid.get("n_steps")
243
+
244
+ # Config values
245
+ p_val = config.get(f"{axis_l}_p")
246
+ i_val = config.get(f"{axis_l}_i")
247
+ d_val = config.get(f"{axis_l}_d")
248
+ ff_val = config.get(f"{axis_l}_ff")
249
+
250
+ conn.execute("""
251
+ INSERT INTO flight_axes (flight_id, axis,
252
+ hover_severity, hover_rms, hover_p2p, hover_freq_hz,
253
+ hover_cause, hover_seconds,
254
+ overshoot_pct, delay_ms, tracking_error, n_steps,
255
+ p_value, i_value, d_value, ff_value)
256
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
257
+ """, (
258
+ flight_id, axis,
259
+ h_sev, h_rms, h_p2p, h_freq, h_cause, h_sec,
260
+ os_pct, dl_ms, trk_err, n_steps,
261
+ p_val, i_val, d_val, ff_val,
262
+ ))
263
+
264
+ # Store motor data
265
+ if motor_analysis and "motors" in motor_analysis:
266
+ for m in motor_analysis["motors"]:
267
+ conn.execute("""
268
+ INSERT INTO flight_motors (flight_id, motor_num, avg_pct, saturation_pct)
269
+ VALUES (?, ?, ?, ?)
270
+ """, (flight_id, m["motor"], m["avg_pct"], m["saturation_pct"]))
271
+
272
+ # Store config snapshot (meaningful params only)
273
+ config_params = [
274
+ "gyro_lpf_hz", "dterm_lpf_hz", "dyn_notch_min_hz", "dyn_notch_q",
275
+ "rpm_filter_enabled", "motor_protocol", "looptime",
276
+ "roll_p", "roll_i", "roll_d", "roll_ff",
277
+ "pitch_p", "pitch_i", "pitch_d", "pitch_ff",
278
+ "yaw_p", "yaw_i", "yaw_d", "yaw_ff",
279
+ "craft_name", "firmware_version", "board",
280
+ ]
281
+ for param in config_params:
282
+ val = config.get(param)
283
+ if val is not None:
284
+ conn.execute("""
285
+ INSERT INTO flight_config (flight_id, param, value)
286
+ VALUES (?, ?, ?)
287
+ """, (flight_id, param, str(val)))
288
+
289
+ # Store diff config if available
290
+ if config_raw:
291
+ diff_config = parse_diff_output(config_raw)
292
+ for param, val in diff_config.items():
293
+ # Avoid duplicates with blackbox config
294
+ if param not in config_params:
295
+ conn.execute("""
296
+ INSERT INTO flight_config (flight_id, param, value)
297
+ VALUES (?, ?, ?)
298
+ """, (flight_id, param, str(val)))
299
+
300
+ # Store actions
301
+ for a in plan.get("actions", []):
302
+ conn.execute("""
303
+ INSERT INTO flight_actions (flight_id, priority, urgency,
304
+ category, action, reason, deferred)
305
+ VALUES (?, ?, ?, ?, ?, ?, ?)
306
+ """, (
307
+ flight_id,
308
+ a.get("priority"),
309
+ a.get("urgency"),
310
+ a.get("category"),
311
+ a.get("action"),
312
+ a.get("reason"),
313
+ 1 if a.get("deferred") else 0,
314
+ ))
315
+
316
+ conn.commit()
317
+ return flight_id, True # (flight_id, is_new=True)
318
+
319
+ def get_craft_history(self, craft, limit=20):
320
+ """Get flight history for a specific craft, newest first.
321
+
322
+ Returns list of dicts with scores and key metrics.
323
+ """
324
+ conn = self._connect()
325
+ rows = conn.execute("""
326
+ SELECT f.*,
327
+ GROUP_CONCAT(DISTINCT fa.axis || ':' || COALESCE(fa.hover_severity, 'none'))
328
+ AS osc_summary
329
+ FROM flights f
330
+ LEFT JOIN flight_axes fa ON fa.flight_id = f.id
331
+ WHERE f.craft = ?
332
+ GROUP BY f.id
333
+ ORDER BY f.timestamp DESC
334
+ LIMIT ?
335
+ """, (craft, limit)).fetchall()
336
+
337
+ history = []
338
+ for row in rows:
339
+ flight = dict(row)
340
+ # Get axis details
341
+ axes = conn.execute("""
342
+ SELECT * FROM flight_axes WHERE flight_id = ?
343
+ """, (row["id"],)).fetchall()
344
+ flight["axes"] = [dict(a) for a in axes]
345
+
346
+ # Get motor data
347
+ motors = conn.execute("""
348
+ SELECT * FROM flight_motors WHERE flight_id = ?
349
+ """, (row["id"],)).fetchall()
350
+ flight["motors"] = [dict(m) for m in motors]
351
+
352
+ history.append(flight)
353
+
354
+ return history
355
+
356
+ def get_progression(self, craft, limit=10):
357
+ """Get a progression summary for a craft.
358
+
359
+ Returns a dict with:
360
+ flights: list of simplified flight records (oldest first)
361
+ trend: "improving", "stable", "degrading", or "insufficient"
362
+ changes: list of notable changes between flights
363
+
364
+ Ground-only flights (armed but never flew) are excluded from
365
+ progression since they have no meaningful scores.
366
+ """
367
+ history = self.get_craft_history(craft, limit)
368
+ if not history:
369
+ return {"flights": [], "trend": "insufficient", "changes": []}
370
+
371
+ # Reverse to chronological order, filter out ground-only flights
372
+ flights = [f for f in reversed(history)
373
+ if f.get("verdict") != "GROUND_ONLY"
374
+ and f.get("overall_score") is not None]
375
+
376
+ if not flights:
377
+ return {"flights": [], "trend": "insufficient", "changes": []}
378
+
379
+ simplified = []
380
+ for f in flights:
381
+ simplified.append({
382
+ "id": f["id"],
383
+ "timestamp": f["timestamp"],
384
+ "score": f["overall_score"],
385
+ "noise": f["noise_score"],
386
+ "pid": f["pid_score"],
387
+ "motor": f["motor_score"],
388
+ "osc": f["osc_score"],
389
+ "verdict": f["verdict"],
390
+ "duration": f["duration_s"],
391
+ "axes": f["axes"],
392
+ })
393
+
394
+ # Determine trend
395
+ changes = []
396
+ if len(simplified) >= 2:
397
+ prev = simplified[-2]
398
+ curr = simplified[-1]
399
+ delta = (curr["score"] or 0) - (prev["score"] or 0)
400
+
401
+ if delta > 10:
402
+ trend = "improving"
403
+ elif delta < -10:
404
+ trend = "degrading"
405
+ else:
406
+ trend = "stable"
407
+
408
+ # Note specific changes
409
+ if prev["score"] and curr["score"]:
410
+ changes.append(
411
+ f"Score: {prev['score']:.0f} → {curr['score']:.0f} "
412
+ f"({'↑' if delta > 0 else '↓'}{abs(delta):.0f})")
413
+
414
+ # Compare axes
415
+ for i, axis in enumerate(["Roll", "Pitch", "Yaw"]):
416
+ prev_ax = next((a for a in prev["axes"] if a["axis"] == axis), None)
417
+ curr_ax = next((a for a in curr["axes"] if a["axis"] == axis), None)
418
+ if prev_ax and curr_ax:
419
+ if prev_ax["hover_rms"] and curr_ax["hover_rms"]:
420
+ rms_delta = curr_ax["hover_rms"] - prev_ax["hover_rms"]
421
+ if abs(rms_delta) > 2:
422
+ changes.append(
423
+ f"{axis} hover: {prev_ax['hover_rms']:.1f} → "
424
+ f"{curr_ax['hover_rms']:.1f}°/s "
425
+ f"({'↑worse' if rms_delta > 0 else '↓better'})")
426
+ if prev_ax["p_value"] and curr_ax["p_value"]:
427
+ if prev_ax["p_value"] != curr_ax["p_value"]:
428
+ changes.append(
429
+ f"{axis} P: {prev_ax['p_value']} → {curr_ax['p_value']}")
430
+ else:
431
+ trend = "insufficient"
432
+
433
+ return {"flights": simplified, "trend": trend, "changes": changes}
434
+
435
+ def get_flight_count(self, craft=None):
436
+ """Get total number of stored flights, optionally filtered by craft."""
437
+ conn = self._connect()
438
+ if craft:
439
+ row = conn.execute(
440
+ "SELECT COUNT(*) FROM flights WHERE craft = ?", (craft,)).fetchone()
441
+ else:
442
+ row = conn.execute("SELECT COUNT(*) FROM flights").fetchone()
443
+ return row[0]
444
+
445
+ def get_flight_diff(self, craft, current_flight_id):
446
+ """Compare current flight with the previous flight for the same craft.
447
+
448
+ Returns dict with:
449
+ has_previous (bool): True if a previous flight exists
450
+ current (dict): Current flight summary
451
+ previous (dict): Previous flight summary
452
+ score_delta (float): Score change
453
+ config_changes (list): [{param, old, new}]
454
+ metric_changes (list): [{metric, axis, old, new, direction}]
455
+ verdict (str): Human-readable interpretation
456
+ """
457
+ conn = self._connect()
458
+
459
+ # Get previous flight (most recent before current)
460
+ current_ts = conn.execute(
461
+ "SELECT timestamp FROM flights WHERE id = ?",
462
+ (current_flight_id,)).fetchone()
463
+ if not current_ts:
464
+ return {"has_previous": False}
465
+
466
+ prev = conn.execute("""
467
+ SELECT * FROM flights
468
+ WHERE craft = ? AND id != ? AND timestamp < ?
469
+ AND verdict != 'GROUND_ONLY' AND overall_score IS NOT NULL
470
+ ORDER BY timestamp DESC LIMIT 1
471
+ """, (craft, current_flight_id, current_ts["timestamp"])).fetchone()
472
+
473
+ if not prev:
474
+ return {"has_previous": False}
475
+
476
+ curr = conn.execute(
477
+ "SELECT * FROM flights WHERE id = ?",
478
+ (current_flight_id,)).fetchone()
479
+
480
+ # Get axis data for both
481
+ def get_axes(fid):
482
+ rows = conn.execute(
483
+ "SELECT * FROM flight_axes WHERE flight_id = ?", (fid,)).fetchall()
484
+ return {r["axis"]: dict(r) for r in rows}
485
+
486
+ curr_axes = get_axes(curr["id"])
487
+ prev_axes = get_axes(prev["id"])
488
+
489
+ # Get config for both
490
+ def get_config(fid):
491
+ rows = conn.execute(
492
+ "SELECT param, value FROM flight_config WHERE flight_id = ?",
493
+ (fid,)).fetchall()
494
+ return {r["param"]: r["value"] for r in rows}
495
+
496
+ curr_config = get_config(curr["id"])
497
+ prev_config = get_config(prev["id"])
498
+
499
+ # Build summaries
500
+ def flight_summary(f, axes):
501
+ return {
502
+ "id": f["id"], "timestamp": f["timestamp"],
503
+ "score": f["overall_score"], "noise": f["noise_score"],
504
+ "pid": f["pid_score"], "motor": f["motor_score"],
505
+ "verdict": f["verdict"], "duration": f["duration_s"],
506
+ }
507
+
508
+ result = {
509
+ "has_previous": True,
510
+ "current": flight_summary(curr, curr_axes),
511
+ "previous": flight_summary(prev, prev_axes),
512
+ "score_delta": (curr["overall_score"] or 0) - (prev["overall_score"] or 0),
513
+ "config_changes": [],
514
+ "metric_changes": [],
515
+ "verdict": "",
516
+ }
517
+
518
+ # Config changes (PID-relevant params)
519
+ interesting_params = [
520
+ "roll_p", "roll_i", "roll_d", "roll_ff",
521
+ "pitch_p", "pitch_i", "pitch_d", "pitch_ff",
522
+ "yaw_p", "yaw_i", "yaw_d", "yaw_ff",
523
+ "gyro_lpf_hz", "dterm_lpf_hz", "dyn_notch_min_hz",
524
+ "rpm_filter_enabled", "motor_protocol",
525
+ "gyro_main_lpf_hz", "dterm_lpf_type",
526
+ "mc_p_roll", "mc_i_roll", "mc_d_roll", "mc_cd_roll",
527
+ "mc_p_pitch", "mc_i_pitch", "mc_d_pitch", "mc_cd_pitch",
528
+ "mc_p_yaw", "mc_i_yaw", "mc_d_yaw", "mc_cd_yaw",
529
+ ]
530
+ all_params = set(list(curr_config.keys()) + list(prev_config.keys()))
531
+ for param in sorted(all_params):
532
+ if param not in interesting_params:
533
+ continue
534
+ old_val = prev_config.get(param)
535
+ new_val = curr_config.get(param)
536
+ if old_val != new_val and old_val is not None and new_val is not None:
537
+ result["config_changes"].append({
538
+ "param": param, "old": old_val, "new": new_val
539
+ })
540
+
541
+ # Metric changes per axis
542
+ for axis in ["Roll", "Pitch", "Yaw"]:
543
+ ca = curr_axes.get(axis, {})
544
+ pa = prev_axes.get(axis, {})
545
+
546
+ # Overshoot
547
+ co = ca.get("overshoot_pct")
548
+ po = pa.get("overshoot_pct")
549
+ if co is not None and po is not None:
550
+ delta = co - po
551
+ if abs(delta) > 3:
552
+ result["metric_changes"].append({
553
+ "metric": "overshoot", "axis": axis,
554
+ "old": po, "new": co, "unit": "%",
555
+ "direction": "worse" if delta > 0 else "better",
556
+ })
557
+
558
+ # Delay
559
+ cd = ca.get("delay_ms")
560
+ pd = pa.get("delay_ms")
561
+ if cd is not None and pd is not None:
562
+ delta = cd - pd
563
+ if abs(delta) > 2:
564
+ result["metric_changes"].append({
565
+ "metric": "delay", "axis": axis,
566
+ "old": pd, "new": cd, "unit": "ms",
567
+ "direction": "worse" if delta > 0 else "better",
568
+ })
569
+
570
+ # Hover oscillation
571
+ cr = ca.get("hover_rms")
572
+ pr = pa.get("hover_rms")
573
+ if cr is not None and pr is not None:
574
+ delta = cr - pr
575
+ if abs(delta) > 1.5:
576
+ result["metric_changes"].append({
577
+ "metric": "hover RMS", "axis": axis,
578
+ "old": pr, "new": cr, "unit": "°/s",
579
+ "direction": "worse" if delta > 0 else "better",
580
+ })
581
+
582
+ # Score sub-components
583
+ for label, key in [("Noise", "noise_score"), ("PID", "pid_score"), ("Motor", "motor_score")]:
584
+ cv = curr.get(key)
585
+ pv = prev.get(key)
586
+ if cv is not None and pv is not None:
587
+ delta = cv - pv
588
+ if abs(delta) > 3:
589
+ result["metric_changes"].append({
590
+ "metric": label + " score", "axis": "",
591
+ "old": pv, "new": cv, "unit": "",
592
+ "direction": "better" if delta > 0 else "worse",
593
+ })
594
+
595
+ # Generate verdict
596
+ result["verdict"] = _generate_diff_verdict(result)
597
+ return result
598
+
599
+
600
+ def _generate_diff_verdict(diff):
601
+ """Generate a human-readable verdict explaining why the score changed."""
602
+ delta = diff["score_delta"]
603
+ changes = diff["config_changes"]
604
+ metrics = diff["metric_changes"]
605
+
606
+ if abs(delta) < 3:
607
+ return "Score is stable — changes had minimal effect."
608
+
609
+ parts = []
610
+ direction = "improved" if delta > 0 else "dropped"
611
+
612
+ # Correlate config changes with metric changes
613
+ p_changed = any("_p" in c["param"] or c["param"].startswith("mc_p_") for c in changes)
614
+ d_changed = any("_d" in c["param"] or c["param"].startswith("mc_d_") for c in changes)
615
+ ff_changed = any("_ff" in c["param"] or c["param"].startswith("mc_cd_") for c in changes)
616
+ lpf_changed = any("lpf" in c["param"] for c in changes)
617
+
618
+ os_worse = any(m["metric"] == "overshoot" and m["direction"] == "worse" for m in metrics)
619
+ os_better = any(m["metric"] == "overshoot" and m["direction"] == "better" for m in metrics)
620
+ delay_better = any(m["metric"] == "delay" and m["direction"] == "better" for m in metrics)
621
+ delay_worse = any(m["metric"] == "delay" and m["direction"] == "worse" for m in metrics)
622
+ hover_worse = any(m["metric"] == "hover RMS" and m["direction"] == "worse" for m in metrics)
623
+ hover_better = any(m["metric"] == "hover RMS" and m["direction"] == "better" for m in metrics)
624
+
625
+ if p_changed and os_worse:
626
+ parts.append("P increase caused more overshoot")
627
+ if p_changed and delay_better:
628
+ parts.append("P increase reduced delay")
629
+ if p_changed and hover_worse:
630
+ parts.append("P increase destabilized hover")
631
+ if p_changed and hover_better:
632
+ parts.append("P reduction stabilized hover")
633
+ if d_changed and os_better:
634
+ parts.append("D increase helped dampen overshoot")
635
+ if ff_changed and os_worse:
636
+ parts.append("FF increase added overshoot")
637
+ if ff_changed and os_better:
638
+ parts.append("FF reduction helped overshoot")
639
+ if lpf_changed and hover_worse:
640
+ parts.append("LPF change let more noise through")
641
+ if lpf_changed and hover_better:
642
+ parts.append("LPF change reduced noise in hover")
643
+
644
+ if not parts:
645
+ if delta > 0:
646
+ parts.append("overall metrics improved")
647
+ else:
648
+ parts.append("overall metrics degraded")
649
+
650
+ return f"Score {direction} by {abs(delta):.0f} points. {'; '.join(parts)}."
651
+
652
+
653
+ def parse_diff_output(diff_text):
654
+ """Parse INAV CLI 'diff all' output into key-value dict.
655
+
656
+ Handles lines like:
657
+ set gyro_main_lpf_hz = 40
658
+ set mc_p_roll = 32
659
+ # master
660
+ # profile
661
+ """
662
+ config = {}
663
+ if not diff_text:
664
+ return config
665
+ for line in diff_text.splitlines():
666
+ line = line.strip()
667
+ if line.startswith("set ") and " = " in line:
668
+ # "set param_name = value"
669
+ parts = line[4:].split(" = ", 1)
670
+ if len(parts) == 2:
671
+ param = parts[0].strip()
672
+ value = parts[1].strip()
673
+ config[param] = value
674
+ return config