fpv-tools 0.1.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.
Files changed (40) hide show
  1. fpv_tools/__init__.py +1 -0
  2. fpv_tools/__main__.py +5 -0
  3. fpv_tools/app/__init__.py +91 -0
  4. fpv_tools/app/bb.py +580 -0
  5. fpv_tools/app/common.py +140 -0
  6. fpv_tools/app/fc.py +362 -0
  7. fpv_tools/app/helpformat.py +103 -0
  8. fpv_tools/blackbox/__init__.py +1 -0
  9. fpv_tools/blackbox/bbl.py +648 -0
  10. fpv_tools/blackbox/decoder.py +132 -0
  11. fpv_tools/blackbox/dsp.py +78 -0
  12. fpv_tools/blackbox/expr.py +147 -0
  13. fpv_tools/blackbox/fcmodel.py +107 -0
  14. fpv_tools/blackbox/flight.py +259 -0
  15. fpv_tools/blackbox/headers.py +61 -0
  16. fpv_tools/blackbox/overview.py +99 -0
  17. fpv_tools/blackbox/probes.py +220 -0
  18. fpv_tools/blackbox/reference.py +48 -0
  19. fpv_tools/blackbox/response.py +86 -0
  20. fpv_tools/fc/codec.py +107 -0
  21. fpv_tools/fc/console.py +130 -0
  22. fpv_tools/fc/dataflash.py +97 -0
  23. fpv_tools/fc/link.py +100 -0
  24. fpv_tools/fc/msp.py +99 -0
  25. fpv_tools/fc/msp_codes.py +279 -0
  26. fpv_tools/firmware/__init__.py +147 -0
  27. fpv_tools/firmware/layout.py +13 -0
  28. fpv_tools/firmware/msp_effects.py +121 -0
  29. fpv_tools/firmware/msp_layouts.py +121 -0
  30. fpv_tools/firmware/tables/__init__.py +1 -0
  31. fpv_tools/firmware/tables/betaflight_2025_12.py +269 -0
  32. fpv_tools/firmware/tables/betaflight_2026_6.py +273 -0
  33. fpv_tools/firmware/tables/betaflight_4_5.py +251 -0
  34. fpv_tools/output.py +81 -0
  35. fpv_tools/settings.py +143 -0
  36. fpv_tools-0.1.0.dist-info/METADATA +82 -0
  37. fpv_tools-0.1.0.dist-info/RECORD +40 -0
  38. fpv_tools-0.1.0.dist-info/WHEEL +4 -0
  39. fpv_tools-0.1.0.dist-info/entry_points.txt +3 -0
  40. fpv_tools-0.1.0.dist-info/licenses/LICENSE +674 -0
fpv_tools/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Utilities for talking to Betaflight flight controllers over USB/MSP."""
fpv_tools/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from fpv_tools.app import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,91 @@
1
+ """The `fpv` command."""
2
+
3
+ import sys
4
+ import warnings
5
+
6
+ from cyclopts import App, Parameter
7
+ from rich.console import Console
8
+ from rich.text import Text
9
+
10
+ from fpv_tools.app import bb, fc
11
+ from fpv_tools.app.helpformat import HelpFormatter
12
+
13
+
14
+ def _shown_default(value: object) -> str | None:
15
+ """Defaults worth showing in help: not unset ones, off-by-default flags or empty lists."""
16
+ return None if value is None or value is False or value == () else str(value)
17
+
18
+
19
+ app = App(
20
+ name="fpv",
21
+ help="Talk to Betaflight flight controllers and analyse their blackbox logs.",
22
+ help_format="plaintext",
23
+ group_parameters="Options",
24
+ help_formatter=HelpFormatter(),
25
+ # Errors as one line like fpv's own: no box, and wrapped by the terminal rather than at the window width
26
+ error_console=Console(stderr=True, soft_wrap=True),
27
+ error_formatter=lambda e: Text(f"fpv: {e}"),
28
+ # Flags are opt-in: no generated --no-X / --empty-X
29
+ default_parameter=Parameter(negative=(), show_default=_shown_default),
30
+ )
31
+ app.command(fc.cli, usage="Usage: fpv cli [OPTIONS] [COMMANDS...]")
32
+ app.command(fc.msp, usage="Usage: fpv msp [OPTIONS] [MESSAGE [FIELDS...]]")
33
+ app.command(bb.app)
34
+
35
+
36
+ def _warning(
37
+ message: Warning | str,
38
+ category: type[Warning],
39
+ filename: str,
40
+ lineno: int,
41
+ file: object = None,
42
+ line: str | None = None,
43
+ ) -> None:
44
+ print(f"fpv: warning: {message}", file=sys.stderr)
45
+
46
+
47
+ def _expected_errors() -> tuple[type[Exception], ...]:
48
+ """Errors that are the user's to fix: reported as one line, without a traceback. Imported only once something
49
+ failed, so that commands (and --help) do not import every module up front."""
50
+ from fpv_tools.blackbox.decoder import DecodeError
51
+ from fpv_tools.blackbox.expr import ExprError
52
+ from fpv_tools.blackbox.flight import SignalError
53
+ from fpv_tools.blackbox.headers import MissingHeader
54
+ from fpv_tools.fc.codec import CodecError
55
+ from fpv_tools.fc.console import ConsoleError
56
+ from fpv_tools.fc.dataflash import DataflashError
57
+ from fpv_tools.fc.link import LinkError
58
+ from fpv_tools.fc.msp import MspError
59
+ from fpv_tools.firmware import FirmwareError
60
+ from fpv_tools.settings import SettingMissing
61
+
62
+ return (
63
+ fc.CommandFailed,
64
+ DecodeError,
65
+ ExprError,
66
+ SignalError,
67
+ MissingHeader,
68
+ CodecError,
69
+ ConsoleError,
70
+ DataflashError,
71
+ LinkError,
72
+ MspError,
73
+ FirmwareError,
74
+ SettingMissing,
75
+ ValueError,
76
+ OSError,
77
+ )
78
+
79
+
80
+ def main() -> int:
81
+ warnings.showwarning = _warning
82
+ try:
83
+ app(exit_on_error=True)
84
+ except BrokenPipeError:
85
+ return 0 # the reader went away (e.g. `| head`)
86
+ except Exception as e:
87
+ if not isinstance(e, _expected_errors()):
88
+ raise
89
+ print(f"fpv: {e}", file=sys.stderr)
90
+ return 1
91
+ return 0
fpv_tools/app/bb.py ADDED
@@ -0,0 +1,580 @@
1
+ """`fpv bb`: blackbox logs.
2
+
3
+ numpy, the decoder and the FC link are imported where a command needs them, so that `--help` is quick.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import sys
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Annotated, Any
15
+
16
+ from cyclopts import App, Parameter
17
+
18
+ from fpv_tools.app.common import IGNORE_VERSION, LogOptions, Port, Timeout, confirm, transfer, waiting
19
+ from fpv_tools.blackbox.reference import CONDITIONS, SIGNALS
20
+ from fpv_tools.output import finite, number, table, write_csv, write_json
21
+
22
+ if TYPE_CHECKING:
23
+ from fpv_tools.blackbox import probes
24
+ from fpv_tools.blackbox.flight import Flight
25
+ from fpv_tools.fc.msp import MspClient
26
+
27
+ app = App(
28
+ name="bb",
29
+ group_parameters="Options",
30
+ help=f"""Blackbox logs: pull them off the FC, see what they contain, probe signals, export data.
31
+
32
+ Logs are given as FILE, FILE:N (its flight N), FILE:N-M or a directory of logs.
33
+
34
+ {SIGNALS}
35
+ {CONDITIONS}""",
36
+ )
37
+
38
+ Log = Annotated[str, Parameter(required=True, help="FILE, FILE:N (its flight N), FILE:N-M or a directory of logs.")]
39
+ Seconds = Annotated[float | None, Parameter(metavar="SECONDS")]
40
+
41
+
42
+ def _short_names(flights: list[Flight]) -> dict[str, str]:
43
+ """Flight labels for text output: "#N" when all flights come from one file, else "FILE:N"."""
44
+ if len({f.source for f in flights}) == 1:
45
+ return {f.name: f"#{f.index}" for f in flights}
46
+ return {f.name: f.name for f in flights}
47
+
48
+
49
+ def _shorten(value: Any, names: dict[str, str]) -> Any:
50
+ """Replace full flight names by their short labels in a (nested) result, for text output."""
51
+ match value:
52
+ case dict():
53
+ items: dict[str, Any] = value # pyright: ignore[reportUnknownVariableType]
54
+ return {k: names.get(v, v) if k in ("flight", "flights") else _shorten(v, names) for k, v in items.items()}
55
+ case list():
56
+ entries: list[Any] = value # pyright: ignore[reportUnknownVariableType]
57
+ return [_shorten(v, names) for v in entries]
58
+ case _:
59
+ return value
60
+
61
+
62
+ def _flights(logs: tuple[str, ...], options: LogOptions) -> list[Flight]:
63
+ from fpv_tools.blackbox.decoder import load
64
+
65
+ if not logs:
66
+ raise ValueError("no logs given")
67
+ flights = load(logs, settings=options.settings(), ignore_version=options.ignore_version)
68
+ if not flights:
69
+ raise ValueError("no flights in the given logs")
70
+ return flights
71
+
72
+
73
+ # --- pull, erase ------------------------------------------------------------------------------------------------
74
+
75
+
76
+ @app.command(usage="Usage: fpv bb pull [OPTIONS] [FILE]")
77
+ def pull(
78
+ file: Annotated[Path | None, Parameter(allow_leading_hyphen=True)] = None,
79
+ /,
80
+ *,
81
+ output_dir: Annotated[Path | None, Parameter(name=["--output-dir", "-O"], metavar="DIR")] = None,
82
+ erase: bool = False,
83
+ port: Port = None,
84
+ timeout: Timeout = 2.0,
85
+ ) -> None:
86
+ """Copy the blackbox log off the FC's onboard flash into a file.
87
+
88
+ Parameters
89
+ ----------
90
+ file
91
+ File to write, or - for stdout. Default: named as the Betaflight app names it, e.g.
92
+ BTFL_BLACKBOX_LOG_CRAFT_20261109_143000_BOARD.BBL.
93
+ output_dir
94
+ Directory for the default-named file.
95
+ erase
96
+ Erase the flash after a complete read.
97
+ """
98
+ from fpv_tools.fc import dataflash
99
+ from fpv_tools.fc.link import open_port
100
+ from fpv_tools.fc.msp import MspClient
101
+
102
+ if file is not None and output_dir is not None:
103
+ raise ValueError("FILE and --output-dir exclude each other")
104
+ with open_port(port, timeout) as serial, MspClient(serial) as fc:
105
+ with transfer("reading") as update:
106
+ data = dataflash.read(fc, update)
107
+ if file == Path("-"):
108
+ sys.stdout.buffer.write(data)
109
+ sys.stdout.flush()
110
+ else:
111
+ target = file or (output_dir or Path()) / _log_name(fc)
112
+ target.write_bytes(data)
113
+ print(target)
114
+ if erase:
115
+ with waiting("erasing"):
116
+ dataflash.erase(fc)
117
+ print("flash erased", file=sys.stderr)
118
+
119
+
120
+ @app.command
121
+ def erase(*, yes: Annotated[bool, Parameter(alias="-y")] = False, port: Port = None, timeout: Timeout = 2.0) -> None:
122
+ """Erase the FC's onboard flash, deleting the logs on it.
123
+
124
+ Parameters
125
+ ----------
126
+ yes
127
+ Do not ask for confirmation.
128
+ """
129
+ from fpv_tools.fc import dataflash
130
+ from fpv_tools.fc.link import open_port
131
+ from fpv_tools.fc.msp import MspClient
132
+
133
+ with open_port(port, timeout) as serial, MspClient(serial) as fc:
134
+ if not yes and not confirm(f"Erase {dataflash.summary(fc).used_size / 1e6:.1f} MB of logs on the FC's flash?"):
135
+ return
136
+ with waiting("erasing"):
137
+ dataflash.erase(fc)
138
+ print("flash erased", file=sys.stderr)
139
+
140
+
141
+ def _log_name(fc: MspClient) -> str:
142
+ """{variant}_BLACKBOX_LOG_{CRAFT}_{yyyymmdd}_{hhmmss}_{board}.BBL, as the Betaflight app's generateFilename()."""
143
+ from fpv_tools.fc.msp_codes import CODES
144
+
145
+ variant = fc.request(CODES["MSP_FC_VARIANT"]).decode("ascii", "replace").strip("\0") or "UNKNOWN"
146
+ craft = re.sub(r"\s+", "_", fc.request(CODES["MSP_NAME"]).decode("ascii", "replace").strip("\0").strip()).upper()
147
+ board_info = fc.request(CODES["MSP_BOARD_INFO"])
148
+ target_len = board_info[8]
149
+ board_at = 9 + target_len
150
+ board = (
151
+ board_info[board_at + 1 : board_at + 1 + board_info[board_at]].decode("ascii", "replace")
152
+ if len(board_info) > board_at
153
+ else ""
154
+ )
155
+ name = f"{variant}_BLACKBOX_LOG" + (f"_{craft}" if craft else "") + datetime.now().strftime("_%Y%m%d_%H%M%S")
156
+ return name + (f"_{board}" if board else "") + ".BBL"
157
+
158
+
159
+ # --- info ------------------------------------------------------------------------------------------------------
160
+
161
+
162
+ @app.command
163
+ def info(
164
+ *logs: Log,
165
+ headers: Annotated[str | None, Parameter(name="--headers", metavar="REGEX")] = None,
166
+ signals: bool = False,
167
+ json: bool = False,
168
+ ignore_version: Annotated[bool, Parameter(help=IGNORE_VERSION)] = False,
169
+ ) -> None:
170
+ """What logs contain: firmware, craft, flights with timing, events and headers.
171
+
172
+ Parameters
173
+ ----------
174
+ headers
175
+ Print headers whose name matches this regex ('.' for all).
176
+ signals
177
+ List the signals each flight provides (except those that need FC settings).
178
+ json
179
+ Everything as one JSON document.
180
+ """
181
+ from fpv_tools.blackbox import overview
182
+
183
+ flights = _flights(logs, LogOptions(ignore_version=ignore_version))
184
+ files = overview.describe(flights)
185
+ if signals:
186
+ for f, entry in zip(flights, (fl for file in files for fl in file["flights"]), strict=True):
187
+ entry["signals"] = f.signal_names()
188
+ if json:
189
+ write_json(finite(files))
190
+ return
191
+ pattern = re.compile(headers) if headers is not None else None
192
+ for file in files:
193
+ print(f"{file['file']}\n {file['firmware']}, {file['board']}, craft {file['craft']!r}")
194
+ table(
195
+ [
196
+ {
197
+ "flight": fl["index"],
198
+ "power-up": fl["power_up"],
199
+ "duration_s": fl["duration_s"],
200
+ "rate_hz": fl["rate_hz"],
201
+ "frames": fl["frames"],
202
+ "debug_mode": fl["debug_mode"],
203
+ "events": len(fl["events"]),
204
+ "truncated": bool(fl["decoding"].get("truncated_end")),
205
+ "corrupt_frames": fl["decoding"].get("corrupt", 0),
206
+ }
207
+ for fl in file["flights"]
208
+ ]
209
+ )
210
+ for fl in file["flights"]:
211
+ if fl["events"]:
212
+ print(f"\nflight {fl['index']} events:")
213
+ table([{k: v for k, v in e.items()} for e in fl["events"]])
214
+ if signals:
215
+ for fl in file["flights"]:
216
+ print(f"\nflight {fl['index']} signals: {' '.join(fl['signals'])}")
217
+ if pattern:
218
+ shown = {k: v for k, v in file["headers"].items() if pattern.search(k)}
219
+ print()
220
+ table([{"header": k, "value": v} for k, v in shown.items()])
221
+ differing = {
222
+ (fl["index"], k): v for fl in file["flights"] for k, v in fl["headers"].items() if pattern.search(k)
223
+ }
224
+ if differing:
225
+ print("\nper flight:")
226
+ table([{"flight": i, "header": k, "value": v} for (i, k), v in differing.items()])
227
+ print()
228
+
229
+
230
+ # --- probes: stats, runs, changes, spectrum, step ---------------------------------------------------------------
231
+
232
+
233
+ type Row = dict[str, Any]
234
+
235
+
236
+ @Parameter(name="*")
237
+ @dataclass(frozen=True)
238
+ class Probe:
239
+ """The signals to probe and the samples to look at, shared by the probe commands."""
240
+
241
+ signal: Annotated[tuple[str, ...], Parameter(name=["--signal", "-s"], metavar="NAME")]
242
+ "Signal to probe, e.g. gyro.roll, thr, mode (`fpv bb --help` lists them); repeatable."
243
+
244
+ when: Annotated[str | None, Parameter(metavar="EXPR")] = None
245
+ """Only samples where this condition holds, e.g. 'thr > 5 and mode == "ACRO"' (`fpv bb --help` has the
246
+ syntax)."""
247
+
248
+ start: Annotated[Seconds, Parameter(name="--from")] = None
249
+ "Only from this time (s since the flight's first frame)."
250
+
251
+ end: Annotated[Seconds, Parameter(name="--to")] = None
252
+ "Only up to this time."
253
+
254
+ pool: bool = False
255
+ "Combine all flights into one result instead of one per flight."
256
+
257
+ json: bool = False
258
+ "Results as one JSON document."
259
+
260
+ def results(self, flights: list[Flight], probe: Callable[[probes.Selection, str], dict[str, Any]]) -> list[Row]:
261
+ from fpv_tools.blackbox import expr, probes
262
+
263
+ parts = [probes.Part(f, f.between(self.start, self.end) & expr.mask(f, self.when)) for f in flights]
264
+ groups = [("all flights", parts)] if self.pool else [(p.flight.name, [p]) for p in parts]
265
+ return [{"flights": label, "signal": name, **probe(sel, name)} for label, sel in groups for name in self.signal]
266
+
267
+ def report(self, flights: list[Flight], results: list[Row]) -> None:
268
+ if self.json:
269
+ write_json(finite(results))
270
+ return
271
+ names = _short_names(flights)
272
+ if len({f.source for f in flights}) == 1:
273
+ print(f"# {flights[0].source}")
274
+ for result in results:
275
+ _print_result(_shorten(result, names))
276
+
277
+
278
+ def _numbers(text: str | None, option: str) -> list[float]:
279
+ try:
280
+ return [float(x) for x in (text or "").split(",") if x.strip()]
281
+ except ValueError:
282
+ raise ValueError(f"{option} expects comma-separated numbers, got {text!r}") from None
283
+
284
+
285
+ def _band(text: str, option: str = "--band") -> tuple[float, float | None]:
286
+ lo, _, hi = text.partition("-")
287
+ try:
288
+ return float(lo or 0), float(hi) if hi else None
289
+ except ValueError:
290
+ raise ValueError(f"{option} expects ranges like 80- or 10-100, got {text!r}") from None
291
+
292
+
293
+ @app.command
294
+ def stats(
295
+ *logs: Log,
296
+ probe: Probe,
297
+ percentiles: Annotated[str, Parameter(metavar="P,...")] = "1,5,50,95,99",
298
+ below: Annotated[str | None, Parameter(metavar="V,...")] = None,
299
+ above: Annotated[str | None, Parameter(metavar="V,...")] = None,
300
+ options: LogOptions = LogOptions(),
301
+ ) -> None:
302
+ """Distribution of signals: min and max with when they occur, mean, std, percentiles, time beyond thresholds.
303
+
304
+ Parameters
305
+ ----------
306
+ percentiles
307
+ Percentiles to report, comma-separated.
308
+ below
309
+ Also report the seconds below these values, comma-separated.
310
+ above
311
+ Also report the seconds above these values, comma-separated.
312
+ """
313
+ from fpv_tools.blackbox import probes
314
+
315
+ ps, lows, highs = _numbers(percentiles, "--percentiles"), _numbers(below, "--below"), _numbers(above, "--above")
316
+
317
+ def probe_one(sel: probes.Selection, name: str) -> Row:
318
+ result: Row = {"distribution": probes.distribution(sel, name, ps)}
319
+ if lows or highs:
320
+ result["seconds_beyond"] = probes.time_beyond(sel, name, lows, highs)
321
+ return result
322
+
323
+ flights = _flights(logs, options)
324
+ probe.report(flights, probe.results(flights, probe_one))
325
+
326
+
327
+ @app.command
328
+ def runs(
329
+ *logs: Log,
330
+ probe: Probe,
331
+ min_duration: Annotated[float, Parameter(metavar="SECONDS")] = 0.0,
332
+ options: LogOptions = LogOptions(),
333
+ ) -> None:
334
+ """Periods where the selection holds (--when, --from/--to), with each signal's range in each.
335
+
336
+ E.g. `-s acc --when 'acc > 3'` lists the stretches of high acceleration.
337
+
338
+ Parameters
339
+ ----------
340
+ min_duration
341
+ Drop periods shorter than this (s).
342
+ """
343
+ from fpv_tools.blackbox import probes
344
+
345
+ flights = _flights(logs, options)
346
+ probe.report(flights, probe.results(flights, lambda sel, name: {"runs": probes.runs(sel, name, min_duration)}))
347
+
348
+
349
+ @app.command
350
+ def changes(*logs: Log, probe: Probe, options: LogOptions = LogOptions()) -> None:
351
+ """Every change of value, for discrete signals: mode, failsafe, rx, box.* and the like."""
352
+ from fpv_tools.blackbox import probes
353
+
354
+ flights = _flights(logs, options)
355
+ probe.report(flights, probe.results(flights, lambda sel, name: {"changes": probes.changes(sel, name)}))
356
+
357
+
358
+ @app.command
359
+ def spectrum(
360
+ *logs: Log,
361
+ probe: Probe,
362
+ peaks: Annotated[int, Parameter(metavar="N")] = 3,
363
+ min_hz: Annotated[float, Parameter(metavar="HZ")] = 20.0,
364
+ separation: Annotated[float, Parameter(metavar="HZ")] = 30.0,
365
+ band: Annotated[str | None, Parameter(metavar="LO-HI,...")] = None,
366
+ nperseg: Annotated[int, Parameter(metavar="N")] = 256,
367
+ vs_throttle: Annotated[float | None, Parameter(metavar="WIDTH")] = None,
368
+ options: LogOptions = LogOptions(),
369
+ ) -> None:
370
+ """Spectrum of signals (Welch): the strongest peaks, and RMS in frequency bands.
371
+
372
+ Parameters
373
+ ----------
374
+ peaks
375
+ Number of peaks to report.
376
+ min_hz
377
+ Ignore peaks below this frequency (Hz).
378
+ separation
379
+ Minimum distance between peaks (Hz).
380
+ band
381
+ Also report the RMS within these bands (Hz), comma-separated, e.g. 80- or 10-100,100-.
382
+ nperseg
383
+ Segment length (samples): the frequency resolution is the log rate divided by this. Stretches of the
384
+ selection shorter than this are left out.
385
+ vs_throttle
386
+ Also the spectrum per throttle band of this width (%, e.g. 10): time spent, strongest peak and RMS above
387
+ --min-hz.
388
+ """
389
+ from fpv_tools.blackbox import probes
390
+
391
+ bands = [_band(b) for b in (band or "").split(",") if b.strip()]
392
+
393
+ def probe_one(sel: probes.Selection, name: str) -> Row:
394
+ result: Row = {
395
+ "spectrum": probes.spectrum(
396
+ sel, name, peaks=peaks, min_hz=min_hz, separation=separation, bands=bands, nperseg=nperseg
397
+ )
398
+ }
399
+ if vs_throttle is not None:
400
+ result["vs_throttle"] = probes.vs_throttle(sel, name, step=vs_throttle, min_hz=min_hz, nperseg=nperseg)
401
+ return result
402
+
403
+ flights = _flights(logs, options)
404
+ probe.report(flights, probe.results(flights, probe_one))
405
+
406
+
407
+ @app.command
408
+ def step(
409
+ *logs: Log,
410
+ probe: Probe,
411
+ reference: Annotated[str, Parameter(alias="-r", metavar="NAME")],
412
+ window: Annotated[float, Parameter(metavar="SECONDS")] = 1.0,
413
+ min_amplitude: Annotated[float, Parameter(metavar="VALUE")] = 20.0,
414
+ settle: Annotated[str, Parameter(metavar="FROM-TO")] = "0.15-0.3",
415
+ split: Annotated[float | None, Parameter(metavar="VALUE")] = None,
416
+ curve: bool = False,
417
+ options: LogOptions = LogOptions(),
418
+ ) -> None:
419
+ """Step response of signals to a reference: steady-state gain, and rise times and peak relative to it.
420
+
421
+ E.g. `-s gyro.roll -r setpoint.roll --when 'thr > 5'`: how gyro follows setpoint while in the air.
422
+
423
+ Parameters
424
+ ----------
425
+ reference
426
+ The signal the probed signals respond to.
427
+ window
428
+ Length of the windows the response is estimated over (s). Windows where the selection does not hold throughout
429
+ are left out.
430
+ min_amplitude
431
+ Leave out windows where the reference swings less than this (in its unit, e.g. deg/s).
432
+ settle
433
+ Time after the step (s) whose median response is the steady state. The response is estimated up to its end.
434
+ split
435
+ Report windows with a reference swing up to and above this separately (small vs large moves).
436
+ curve
437
+ Print the median response curves as CSV instead (ms, one column per signal and group), per unit of
438
+ reference step: 1 means following exactly.
439
+ """
440
+ from fpv_tools.blackbox import probes
441
+
442
+ if curve and probe.json:
443
+ raise ValueError("--curve and --json exclude each other")
444
+ settle_from, settle_to = _band(settle, "--settle")
445
+ if settle_to is None:
446
+ raise ValueError(f"--settle expects a range like 0.15-0.3, got {settle!r}")
447
+ flights = _flights(logs, options)
448
+ results = probe.results(
449
+ flights,
450
+ lambda sel, name: {
451
+ "step_response": probes.step_response(
452
+ sel,
453
+ name,
454
+ reference,
455
+ window=window,
456
+ min_amplitude=min_amplitude,
457
+ settle=(settle_from, settle_to),
458
+ split=split,
459
+ )
460
+ },
461
+ )
462
+ if curve:
463
+ _print_curves(results)
464
+ else:
465
+ probe.report(flights, results)
466
+
467
+
468
+ def _print_curves(results: list[dict[str, Any]]) -> None:
469
+ curves = {
470
+ f"{r['flights']} {r['signal']} {g['reference_swing']}": g["curve"]
471
+ for r in results
472
+ for g in r.get("step_response", [])
473
+ if "curve" in g
474
+ }
475
+ if not curves:
476
+ raise ValueError("no step response curves (too few windows with enough reference swing)")
477
+ length = max(len(c) for c in curves.values())
478
+ rows = ({"ms": i, **{k: c[i] if i < len(c) else None for k, c in curves.items()}} for i in range(length))
479
+ write_csv(rows, ["ms", *curves])
480
+
481
+
482
+ def _print_result(result: dict[str, Any]) -> None:
483
+ print(f"== {result['signal']} ({result['flights']})")
484
+ if dist := result.get("distribution"):
485
+ if not dist.get("samples"):
486
+ print(" no samples selected\n")
487
+ return
488
+ pct = " ".join(f"p{k} {number(v)}" for k, v in dist["percentiles"].items())
489
+ print(
490
+ f" {dist['samples']} samples, {number(dist['seconds'])} s: min {number(dist['min'])} at "
491
+ f"{dist['min_at']['flight']} {dist['min_at']['t']:.2f}s, max {number(dist['max'])} at "
492
+ f"{dist['max_at']['flight']} {dist['max_at']['t']:.2f}s, "
493
+ f"mean {number(dist['mean'])}, std {number(dist['std'])}"
494
+ )
495
+ print(f" {pct}")
496
+ if beyond := result.get("seconds_beyond"):
497
+ print(" seconds " + ", ".join(f"{k}: {number(v)}" for k, v in beyond.items()))
498
+ for key in ("runs", "changes"):
499
+ if key in result:
500
+ print(f" {key}:" if result[key] else f" {key}: none")
501
+ table(result[key])
502
+ if spec := result.get("spectrum"):
503
+ if not spec.get("peaks") and not spec.get("bands"):
504
+ print(" spectrum: no stretch long enough")
505
+ else:
506
+ print(
507
+ f" spectrum over {number(spec['seconds'])} s (resolution {number(spec['resolution_hz'])} Hz): peaks "
508
+ + ", ".join(f"{p['hz']:.0f} Hz ({number(p['rms'])})" for p in spec["peaks"])
509
+ )
510
+ for b in spec["bands"]:
511
+ print(
512
+ f" rms {b['from_hz']:g}-{format(b['to_hz'], 'g') if b['to_hz'] is not None else 'Nyquist'} Hz: "
513
+ f"{number(b['rms'])}"
514
+ )
515
+ if "vs_throttle" in result:
516
+ print(" spectrum by throttle:")
517
+ table(result["vs_throttle"])
518
+ if "step_response" in result:
519
+ print(" step response:")
520
+ table([{k: v for k, v in g.items() if k != "curve"} for g in result["step_response"]])
521
+ print()
522
+
523
+
524
+ # --- export ----------------------------------------------------------------------------------------------------
525
+
526
+
527
+ @app.command
528
+ def export(
529
+ *logs: Log,
530
+ name: Annotated[tuple[str, ...], Parameter(name=["--signal", "-s"], metavar="NAME")] = (),
531
+ when: Annotated[str | None, Parameter(metavar="EXPR")] = None,
532
+ start: Annotated[Seconds, Parameter(name="--from")] = None,
533
+ end: Annotated[Seconds, Parameter(name="--to")] = None,
534
+ every: Seconds = None,
535
+ output: Annotated[str, Parameter(alias="-o", allow_leading_hyphen=True, metavar="FILE")] = "-",
536
+ options: LogOptions = LogOptions(),
537
+ ) -> None:
538
+ """Signals as CSV: flight, t, then one column per signal.
539
+
540
+ Parameters
541
+ ----------
542
+ name
543
+ Signals to export; repeatable. Default: all that `bb info --signals` lists.
544
+ when
545
+ Only rows where this condition holds (`fpv bb --help` has the syntax).
546
+ start
547
+ Only from this time (s since the flight's first frame).
548
+ end
549
+ Only up to this time.
550
+ every
551
+ At most one row per this many seconds (rows are picked, not averaged).
552
+ output
553
+ File to write, or - for stdout.
554
+ """
555
+ import numpy as np
556
+
557
+ from fpv_tools.blackbox import expr
558
+
559
+ flights = _flights(logs, options)
560
+ names = list(name) or [n for n in flights[0].signal_names() if n != "t"]
561
+
562
+ def rows() -> Any:
563
+ for f in flights:
564
+ idx = np.flatnonzero(f.between(start, end) & expr.mask(f, when))
565
+ if every:
566
+ idx = idx[np.unique(np.floor(f.t[idx] / every), return_index=True)[1]]
567
+ columns = {n: f[n] for n in names}
568
+ for i in idx:
569
+ yield {"flight": f.name, "t": round(float(f.t[i]), 6), **{n: _cell(columns[n][i]) for n in names}}
570
+
571
+ if output == "-":
572
+ write_csv(rows(), ["flight", "t", *names])
573
+ else:
574
+ with Path(output).open("w", newline="") as out:
575
+ write_csv(rows(), ["flight", "t", *names], out)
576
+
577
+
578
+ def _cell(value: Any) -> Any:
579
+ value = value.item() if hasattr(value, "item") else value
580
+ return round(value, 6) if isinstance(value, float) else value