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,467 @@
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, diff_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.15.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, diff_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
+ diff_raw: Raw CLI 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
+ diff_raw,
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 diff_raw:
291
+ diff_config = parse_diff_output(diff_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
+
446
+ def parse_diff_output(diff_text):
447
+ """Parse INAV CLI 'diff all' output into key-value dict.
448
+
449
+ Handles lines like:
450
+ set gyro_main_lpf_hz = 40
451
+ set mc_p_roll = 32
452
+ # master
453
+ # profile
454
+ """
455
+ config = {}
456
+ if not diff_text:
457
+ return config
458
+ for line in diff_text.splitlines():
459
+ line = line.strip()
460
+ if line.startswith("set ") and " = " in line:
461
+ # "set param_name = value"
462
+ parts = line[4:].split(" = ", 1)
463
+ if len(parts) == 2:
464
+ param = parts[0].strip()
465
+ value = parts[1].strip()
466
+ config[param] = value
467
+ return config