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.
- fpv_tools/__init__.py +1 -0
- fpv_tools/__main__.py +5 -0
- fpv_tools/app/__init__.py +91 -0
- fpv_tools/app/bb.py +580 -0
- fpv_tools/app/common.py +140 -0
- fpv_tools/app/fc.py +362 -0
- fpv_tools/app/helpformat.py +103 -0
- fpv_tools/blackbox/__init__.py +1 -0
- fpv_tools/blackbox/bbl.py +648 -0
- fpv_tools/blackbox/decoder.py +132 -0
- fpv_tools/blackbox/dsp.py +78 -0
- fpv_tools/blackbox/expr.py +147 -0
- fpv_tools/blackbox/fcmodel.py +107 -0
- fpv_tools/blackbox/flight.py +259 -0
- fpv_tools/blackbox/headers.py +61 -0
- fpv_tools/blackbox/overview.py +99 -0
- fpv_tools/blackbox/probes.py +220 -0
- fpv_tools/blackbox/reference.py +48 -0
- fpv_tools/blackbox/response.py +86 -0
- fpv_tools/fc/codec.py +107 -0
- fpv_tools/fc/console.py +130 -0
- fpv_tools/fc/dataflash.py +97 -0
- fpv_tools/fc/link.py +100 -0
- fpv_tools/fc/msp.py +99 -0
- fpv_tools/fc/msp_codes.py +279 -0
- fpv_tools/firmware/__init__.py +147 -0
- fpv_tools/firmware/layout.py +13 -0
- fpv_tools/firmware/msp_effects.py +121 -0
- fpv_tools/firmware/msp_layouts.py +121 -0
- fpv_tools/firmware/tables/__init__.py +1 -0
- fpv_tools/firmware/tables/betaflight_2025_12.py +269 -0
- fpv_tools/firmware/tables/betaflight_2026_6.py +273 -0
- fpv_tools/firmware/tables/betaflight_4_5.py +251 -0
- fpv_tools/output.py +81 -0
- fpv_tools/settings.py +143 -0
- fpv_tools-0.1.0.dist-info/METADATA +82 -0
- fpv_tools-0.1.0.dist-info/RECORD +40 -0
- fpv_tools-0.1.0.dist-info/WHEEL +4 -0
- fpv_tools-0.1.0.dist-info/entry_points.txt +3 -0
- 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,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
|