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.
- inav_toolkit/__init__.py +3 -0
- inav_toolkit/autotune.py +504 -0
- inav_toolkit/blackbox_analyzer.py +11070 -0
- inav_toolkit/flight_db.py +674 -0
- inav_toolkit/i18n.py +192 -0
- inav_toolkit/locales/en.json +187 -0
- inav_toolkit/locales/es.json +187 -0
- inav_toolkit/locales/pt_BR.json +187 -0
- inav_toolkit/msp.py +1293 -0
- inav_toolkit/param_analyzer.py +2832 -0
- inav_toolkit/vtol_configurator.py +856 -0
- inav_toolkit/wizard.py +1095 -0
- inav_toolkit-2.3.0.dist-info/METADATA +296 -0
- inav_toolkit-2.3.0.dist-info/RECORD +18 -0
- inav_toolkit-2.3.0.dist-info/WHEEL +5 -0
- inav_toolkit-2.3.0.dist-info/entry_points.txt +5 -0
- inav_toolkit-2.3.0.dist-info/licenses/LICENSE +674 -0
- inav_toolkit-2.3.0.dist-info/top_level.txt +1 -0
|
@@ -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
|