lockwire 0.2.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.
- lockwire/__init__.py +9 -0
- lockwire/__main__.py +9 -0
- lockwire/advice.py +119 -0
- lockwire/api.py +1216 -0
- lockwire/api_payload.py +985 -0
- lockwire/bench.py +182 -0
- lockwire/blame.py +100 -0
- lockwire/catalog.py +100 -0
- lockwire/catalog_seed.yaml +545 -0
- lockwire/checks.py +3483 -0
- lockwire/cli.py +1799 -0
- lockwire/cli_line.py +73 -0
- lockwire/constraints.py +313 -0
- lockwire/copy.py +302 -0
- lockwire/describe.py +682 -0
- lockwire/edit.py +2098 -0
- lockwire/emit_ts.py +150 -0
- lockwire/export_mhs.py +115 -0
- lockwire/factdiff.py +254 -0
- lockwire/forces.py +334 -0
- lockwire/gitref.py +188 -0
- lockwire/hw.py +388 -0
- lockwire/mcp_server.py +276 -0
- lockwire/mechanism.py +379 -0
- lockwire/mesh_dae.py +555 -0
- lockwire/mesh_simple.py +198 -0
- lockwire/page.py +1485 -0
- lockwire/plan.py +849 -0
- lockwire/plan_payload.py +151 -0
- lockwire/plugins.py +70 -0
- lockwire/report.py +848 -0
- lockwire/report_html.py +707 -0
- lockwire/report_json.py +168 -0
- lockwire/review_draft.py +315 -0
- lockwire/scan.py +732 -0
- lockwire/scene.py +535 -0
- lockwire/scene_import.py +309 -0
- lockwire/scene_urdf.py +750 -0
- lockwire/scene_view.py +376 -0
- lockwire/schema.py +3616 -0
- lockwire/sdf.py +240 -0
- lockwire/serve.py +1256 -0
- lockwire/start_operation.py +92 -0
- lockwire/statics.py +77 -0
- lockwire/units.py +115 -0
- lockwire/urdf.py +348 -0
- lockwire/vcs.py +402 -0
- lockwire/whatif.py +161 -0
- lockwire-0.2.0.dist-info/METADATA +192 -0
- lockwire-0.2.0.dist-info/RECORD +52 -0
- lockwire-0.2.0.dist-info/WHEEL +4 -0
- lockwire-0.2.0.dist-info/entry_points.txt +3 -0
lockwire/cli_line.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""One function that turns a command's parts into a shell-safe line.
|
|
2
|
+
|
|
3
|
+
Every door that prints or returns a paste-able ``lockwire ...`` command
|
|
4
|
+
composes its parts here — the CLI's own ``ran:`` lines, the ``gui``/``start``
|
|
5
|
+
page footer's line, and ``scan.py``'s docker ``bash -lc`` line — so there is
|
|
6
|
+
exactly one place quoting can go wrong, and it uses ``shlex.quote`` (stdlib),
|
|
7
|
+
not hand-rolled or Python (``repr``/``!r``) quoting.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shlex
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cli_line(*parts: object) -> str:
|
|
20
|
+
"""Shell-quote every part and join with spaces.
|
|
21
|
+
|
|
22
|
+
Callers that want a literal ``lockwire ...`` line to paste back in pass
|
|
23
|
+
``"lockwire"`` as the first part themselves; this function only quotes
|
|
24
|
+
and joins.
|
|
25
|
+
"""
|
|
26
|
+
return " ".join(shlex.quote(str(part)) for part in parts)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def argv_from_context(ctx: "typer.Context", exclude: "set[str] | None" = None) -> list[str]:
|
|
30
|
+
"""Rebuild the argv actually typed for this invocation.
|
|
31
|
+
|
|
32
|
+
Walks the command's own parameters (``ctx.command.params``) and each
|
|
33
|
+
one's source (``ctx.get_parameter_source``), including only what the
|
|
34
|
+
user actually passed on the command line — instead of a hand-written
|
|
35
|
+
flag list, which is exactly how ``add`` dropped its value flags,
|
|
36
|
+
how ``scene import`` dropped ``--package-root``, and how ``declare``
|
|
37
|
+
dropped ``--note``/``--locked-at``.
|
|
38
|
+
|
|
39
|
+
Bools carry their own hazard and get their own handling: a true flag
|
|
40
|
+
emits its option, a false one emits its ``--no-...`` form if it has one
|
|
41
|
+
and otherwise **nothing**. Both directions matter — a replayed
|
|
42
|
+
``apply --dry-run`` line that lost the flag performs a real write, and a
|
|
43
|
+
line that grew a ``--dry-run`` nobody typed describes a run that wrote
|
|
44
|
+
nothing when the real one wrote.
|
|
45
|
+
|
|
46
|
+
``exclude`` names parameters the caller renders itself; the only one in
|
|
47
|
+
the tree is ``gui``'s ``--out``, deliberately printed as ``<this file>``
|
|
48
|
+
so the page's bytes do not depend on its own filename.
|
|
49
|
+
"""
|
|
50
|
+
argv: list[str] = []
|
|
51
|
+
skip = exclude or set()
|
|
52
|
+
for param in ctx.command.params:
|
|
53
|
+
if param.name in skip:
|
|
54
|
+
continue
|
|
55
|
+
source = ctx.get_parameter_source(param.name)
|
|
56
|
+
if source is None or source.name != "COMMANDLINE":
|
|
57
|
+
continue
|
|
58
|
+
value = ctx.params[param.name]
|
|
59
|
+
if param.param_type_name == "argument":
|
|
60
|
+
argv.append(str(value))
|
|
61
|
+
elif isinstance(value, bool):
|
|
62
|
+
if value:
|
|
63
|
+
argv.append(param.opts[0])
|
|
64
|
+
elif param.secondary_opts:
|
|
65
|
+
argv.append(param.secondary_opts[0])
|
|
66
|
+
elif isinstance(value, (list, tuple)):
|
|
67
|
+
for item in value:
|
|
68
|
+
argv.append(param.opts[0])
|
|
69
|
+
argv.append(str(item))
|
|
70
|
+
else:
|
|
71
|
+
argv.append(param.opts[0])
|
|
72
|
+
argv.append(str(value))
|
|
73
|
+
return argv
|
lockwire/constraints.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""the constraints each part participates in, for the app's badges and margin bars.
|
|
2
|
+
|
|
3
|
+
One row per constraint instance the engine evaluates. Numbers are the checks' own:
|
|
4
|
+
``report_html._budget_rows`` for budgets (itself ``power_rollup``), ``power_rollup`` for rail
|
|
5
|
+
current, the same drivers x bit/s sum for buses, the same torque/thermal arithmetic. A row with
|
|
6
|
+
no used or limit value is ``unset`` rather than a guess. No second scorer, no schema change.
|
|
7
|
+
|
|
8
|
+
Domain mapping (the graph lens ids stay as they are): lens ``mechanical`` -> "mechanical",
|
|
9
|
+
lens ``power`` -> "electrical", lens ``data`` -> "data", lens ``health`` -> "health";
|
|
10
|
+
thermal has no lens and gets its own "thermal".
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from lockwire.checks import BUS_UTILIZATION_CEILING, _device_worst_draw_w, power_rollup
|
|
18
|
+
from lockwire.scene_view import _bus_consumers, _rail_consumers
|
|
19
|
+
from lockwire.schema import Contract
|
|
20
|
+
|
|
21
|
+
#: Under this much headroom (percent of the limit) an in-limits constraint is "warn".
|
|
22
|
+
WARN_MARGIN_PCT = 10.0
|
|
23
|
+
|
|
24
|
+
_HEALTH_STATUS = {
|
|
25
|
+
"healthy": "ok",
|
|
26
|
+
"degrading": "warn",
|
|
27
|
+
"stale": "warn",
|
|
28
|
+
"faulty": "bad",
|
|
29
|
+
"watched": "unset",
|
|
30
|
+
"not_watched": "unset",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _row(
|
|
35
|
+
id_: str,
|
|
36
|
+
label: str,
|
|
37
|
+
domain: str,
|
|
38
|
+
*,
|
|
39
|
+
used: float | None,
|
|
40
|
+
limit: float | None,
|
|
41
|
+
unit: str,
|
|
42
|
+
used_kind: str | None,
|
|
43
|
+
limit_kind: str | None,
|
|
44
|
+
components: list[str],
|
|
45
|
+
direction: str = "max",
|
|
46
|
+
over: bool | None = None,
|
|
47
|
+
scale: float | None = None,
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
"""``over`` overrides the used-vs-limit comparison (a budget's own verdict);
|
|
50
|
+
``scale`` is the margin's denominator (default: the limit)."""
|
|
51
|
+
margin: float | None = None
|
|
52
|
+
if used is not None and limit is not None and (scale or limit) > 0:
|
|
53
|
+
gap = limit - used if direction == "max" else used - limit
|
|
54
|
+
margin = round(gap / (scale or limit) * 100.0, 1)
|
|
55
|
+
if margin is None and over is None:
|
|
56
|
+
status = "unset"
|
|
57
|
+
elif over or (over is None and margin is not None and margin < 0):
|
|
58
|
+
status = "bad"
|
|
59
|
+
elif margin is not None and margin < WARN_MARGIN_PCT:
|
|
60
|
+
status = "warn"
|
|
61
|
+
else:
|
|
62
|
+
status = "ok" if margin is not None else "unset"
|
|
63
|
+
return {
|
|
64
|
+
"id": id_,
|
|
65
|
+
"label": label,
|
|
66
|
+
"domain": domain,
|
|
67
|
+
"used": used,
|
|
68
|
+
"limit": limit,
|
|
69
|
+
"unit": unit,
|
|
70
|
+
"margin_pct": margin,
|
|
71
|
+
"status": status,
|
|
72
|
+
"used_kind": used_kind if used is not None else None,
|
|
73
|
+
"limit_kind": limit_kind if limit is not None else None,
|
|
74
|
+
"parts": [],
|
|
75
|
+
"components": list(dict.fromkeys(components)),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _budget_constraints(contract: Contract, budgets: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
80
|
+
out = []
|
|
81
|
+
by_id = {b.id: b for b in contract.budgets}
|
|
82
|
+
for row in budgets:
|
|
83
|
+
budget = by_id[row["id"]]
|
|
84
|
+
components: list[str] = []
|
|
85
|
+
if budget.kind == "rail_power" and budget.rail:
|
|
86
|
+
components = [budget.rail, *sorted(_rail_consumers(contract, budget.rail))]
|
|
87
|
+
elif budget.kind == "mass_total":
|
|
88
|
+
if budget.sources in ("devices", "links+devices"):
|
|
89
|
+
components += [
|
|
90
|
+
d.id
|
|
91
|
+
for d in contract.devices
|
|
92
|
+
if d.physical is not None and d.physical.mass is not None
|
|
93
|
+
]
|
|
94
|
+
if budget.sources in ("links", "links+devices") and contract.model is not None:
|
|
95
|
+
components += [ln.name for ln in contract.model.links if ln.mass is not None]
|
|
96
|
+
label = {"rail_power": "Rail power", "mass_total": "Mass", "endurance": "Endurance"}[
|
|
97
|
+
budget.kind
|
|
98
|
+
]
|
|
99
|
+
out.append(
|
|
100
|
+
_row(
|
|
101
|
+
f"budget.{row['id']}",
|
|
102
|
+
label,
|
|
103
|
+
"mechanical" if budget.kind == "mass_total" else "electrical",
|
|
104
|
+
used=row["value"],
|
|
105
|
+
limit=row["limit"],
|
|
106
|
+
unit=row["unit"],
|
|
107
|
+
used_kind="derived",
|
|
108
|
+
limit_kind="declared",
|
|
109
|
+
components=components,
|
|
110
|
+
direction=row["direction"],
|
|
111
|
+
over=True if row["state"] == "breach" else None if row["value"] is None else False,
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _rail_current_constraints(contract: Contract) -> list[dict[str, Any]]:
|
|
118
|
+
if contract.topology is None:
|
|
119
|
+
return []
|
|
120
|
+
totals, witnesses, _, _, unknown, _ = power_rollup(contract, "nominal")
|
|
121
|
+
out = []
|
|
122
|
+
for rail in contract.topology.rails:
|
|
123
|
+
if rail.current_limit is None:
|
|
124
|
+
continue
|
|
125
|
+
volts = (
|
|
126
|
+
rail.voltage_range.min.base()
|
|
127
|
+
if rail.voltage_range is not None
|
|
128
|
+
else rail.voltage.base()
|
|
129
|
+
if rail.voltage is not None
|
|
130
|
+
else None
|
|
131
|
+
)
|
|
132
|
+
summable = volts and witnesses.get(rail.id) and not unknown.get(rail.id)
|
|
133
|
+
out.append(
|
|
134
|
+
_row(
|
|
135
|
+
f"rail-current.{rail.id}",
|
|
136
|
+
"Rail current",
|
|
137
|
+
"electrical",
|
|
138
|
+
used=totals.get(rail.id, 0.0) / volts if summable else None,
|
|
139
|
+
limit=rail.current_limit.base(),
|
|
140
|
+
unit="A",
|
|
141
|
+
used_kind="derived",
|
|
142
|
+
limit_kind="declared",
|
|
143
|
+
components=[rail.id, *sorted(_rail_consumers(contract, rail.id))],
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _bus_constraints(contract: Contract) -> list[dict[str, Any]]:
|
|
150
|
+
out = []
|
|
151
|
+
for bus in contract.topology.buses if contract.topology is not None else []:
|
|
152
|
+
drivers = [
|
|
153
|
+
d
|
|
154
|
+
for d in contract.devices
|
|
155
|
+
if d.identity is not None
|
|
156
|
+
and d.identity.bus_ref == bus.id
|
|
157
|
+
and d.driver is not None
|
|
158
|
+
and d.driver.nominal_rate_hz is not None
|
|
159
|
+
and d.driver.payload_bytes is not None
|
|
160
|
+
]
|
|
161
|
+
used = sum(d.driver.nominal_rate_hz * d.driver.payload_bytes * 8 for d in drivers)
|
|
162
|
+
out.append(
|
|
163
|
+
_row(
|
|
164
|
+
f"bus.{bus.id}",
|
|
165
|
+
"Bus bandwidth",
|
|
166
|
+
"data",
|
|
167
|
+
used=used if used > 0 else None,
|
|
168
|
+
limit=bus.bitrate * BUS_UTILIZATION_CEILING if bus.bitrate else None,
|
|
169
|
+
unit="bit/s",
|
|
170
|
+
used_kind="derived",
|
|
171
|
+
limit_kind="derived",
|
|
172
|
+
components=[bus.id, *sorted(_bus_consumers(contract, bus.id))],
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _joint_constraints(contract: Contract) -> list[dict[str, Any]]:
|
|
179
|
+
if contract.model is None:
|
|
180
|
+
return []
|
|
181
|
+
out = []
|
|
182
|
+
driver = {d.drives_joint: d for d in contract.devices if d.drives_joint is not None}
|
|
183
|
+
for joint in contract.model.joints:
|
|
184
|
+
lim = joint.limits
|
|
185
|
+
if lim is None or joint.type == "fixed":
|
|
186
|
+
continue
|
|
187
|
+
rot = joint.type in ("revolute", "continuous")
|
|
188
|
+
dev = driver.get(joint.name)
|
|
189
|
+
model = contract.device_model(dev.model) if dev is not None else None
|
|
190
|
+
act = model.actuator if model is not None else None
|
|
191
|
+
comps = [joint.name, joint.child, *([dev.id] if dev is not None else [])]
|
|
192
|
+
if lim.effort is not None: # <= 0 is URDF's "unset": still a row, used=None
|
|
193
|
+
have = act is not None and act.torque_continuous and act.gear_ratio
|
|
194
|
+
out.append(
|
|
195
|
+
_row(
|
|
196
|
+
f"joint-torque.{joint.name}",
|
|
197
|
+
"Joint torque",
|
|
198
|
+
"mechanical",
|
|
199
|
+
used=lim.effort_limit,
|
|
200
|
+
limit=act.torque_continuous * act.gear_ratio if have else None,
|
|
201
|
+
unit="N*m" if rot else "N",
|
|
202
|
+
used_kind="declared",
|
|
203
|
+
limit_kind="derived",
|
|
204
|
+
components=comps,
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
if lim.velocity is not None:
|
|
208
|
+
out.append(
|
|
209
|
+
_row(
|
|
210
|
+
f"joint-speed.{joint.name}",
|
|
211
|
+
"Joint speed",
|
|
212
|
+
"mechanical",
|
|
213
|
+
used=None,
|
|
214
|
+
limit=lim.velocity if lim.velocity > 0 else None, # 0 = unset
|
|
215
|
+
unit="rad/s" if rot else "m/s",
|
|
216
|
+
used_kind=None,
|
|
217
|
+
limit_kind="declared",
|
|
218
|
+
components=comps,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
if lim.lower is not None and lim.upper is not None and joint.type != "continuous":
|
|
222
|
+
out.append(
|
|
223
|
+
_row(
|
|
224
|
+
f"joint-travel.{joint.name}",
|
|
225
|
+
"Joint travel",
|
|
226
|
+
"mechanical",
|
|
227
|
+
used=None,
|
|
228
|
+
limit=lim.upper - lim.lower,
|
|
229
|
+
unit="rad" if rot else "m",
|
|
230
|
+
used_kind=None,
|
|
231
|
+
limit_kind="derived",
|
|
232
|
+
components=comps,
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _thermal_constraints(contract: Contract) -> list[dict[str, Any]]:
|
|
239
|
+
out = []
|
|
240
|
+
for device in contract.devices:
|
|
241
|
+
model = contract.device_model(device.model)
|
|
242
|
+
limit = model.thermal_limit if model is not None else None
|
|
243
|
+
if limit is None:
|
|
244
|
+
continue
|
|
245
|
+
t, draw = device.thermal, _device_worst_draw_w(device)
|
|
246
|
+
used = None
|
|
247
|
+
if (
|
|
248
|
+
limit.kind == "throttle"
|
|
249
|
+
and t is not None
|
|
250
|
+
and t.resistance is not None
|
|
251
|
+
and t.ambient is not None
|
|
252
|
+
and draw is not None
|
|
253
|
+
):
|
|
254
|
+
used = t.ambient.base() + draw[0] * t.resistance.base()
|
|
255
|
+
limit_c = limit.temperature.base()
|
|
256
|
+
out.append(
|
|
257
|
+
_row(
|
|
258
|
+
f"thermal.{device.id}",
|
|
259
|
+
"Junction temp",
|
|
260
|
+
"thermal",
|
|
261
|
+
used=used,
|
|
262
|
+
limit=limit_c,
|
|
263
|
+
unit="C",
|
|
264
|
+
used_kind="derived",
|
|
265
|
+
limit_kind="declared",
|
|
266
|
+
components=[device.id],
|
|
267
|
+
# the check fires at estimate >= limit, so the cap is "over" at equality
|
|
268
|
+
over=None if used is None else used >= limit_c,
|
|
269
|
+
scale=limit_c + 273.15, # percent of absolute temperature, not of a Celsius figure
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
return out
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _health_constraints(health_devices: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
276
|
+
return [
|
|
277
|
+
{
|
|
278
|
+
**_row(
|
|
279
|
+
f"health.{h['id']}",
|
|
280
|
+
"Health",
|
|
281
|
+
"health",
|
|
282
|
+
used=None,
|
|
283
|
+
limit=None,
|
|
284
|
+
unit="",
|
|
285
|
+
used_kind=None,
|
|
286
|
+
limit_kind=None,
|
|
287
|
+
components=[h["id"]],
|
|
288
|
+
),
|
|
289
|
+
"status": _HEALTH_STATUS.get(h["state"], "unset"),
|
|
290
|
+
}
|
|
291
|
+
for h in health_devices or []
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def constraint_rows(
|
|
296
|
+
contract: Contract,
|
|
297
|
+
budgets: list[dict[str, Any]],
|
|
298
|
+
*,
|
|
299
|
+
health_devices: list[dict[str, Any]] | None = None,
|
|
300
|
+
scene_part_ids: set[str] | None = None,
|
|
301
|
+
) -> list[dict[str, Any]]:
|
|
302
|
+
"""``parts`` is ``components`` narrowed to ids that have a scene part."""
|
|
303
|
+
rows = [
|
|
304
|
+
*_budget_constraints(contract, budgets),
|
|
305
|
+
*_rail_current_constraints(contract),
|
|
306
|
+
*_bus_constraints(contract),
|
|
307
|
+
*_joint_constraints(contract),
|
|
308
|
+
*_thermal_constraints(contract),
|
|
309
|
+
*_health_constraints(health_devices),
|
|
310
|
+
]
|
|
311
|
+
for row in rows:
|
|
312
|
+
row["parts"] = [c for c in row["components"] if c in (scene_part_ids or ())]
|
|
313
|
+
return rows
|
lockwire/copy.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""The one plain-words map: check labels and human part names.
|
|
2
|
+
|
|
3
|
+
A label is <= 6 everyday words, no snake_case, no ids. Anything the app shows as a row
|
|
4
|
+
title or chip comes from here (via gui.py), so the app never parses a contract path.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
# Path segments that only say where a thing lives, never what was checked.
|
|
12
|
+
STRUCTURE = frozenset(
|
|
13
|
+
{
|
|
14
|
+
"scene",
|
|
15
|
+
"devices",
|
|
16
|
+
"buses",
|
|
17
|
+
"rails",
|
|
18
|
+
"hosts",
|
|
19
|
+
"budgets",
|
|
20
|
+
"signals",
|
|
21
|
+
"envelopes",
|
|
22
|
+
"links",
|
|
23
|
+
"joints",
|
|
24
|
+
"cross_signal",
|
|
25
|
+
"coverage",
|
|
26
|
+
"stage",
|
|
27
|
+
"plan",
|
|
28
|
+
"preflight",
|
|
29
|
+
"steps",
|
|
30
|
+
"topology",
|
|
31
|
+
"wires",
|
|
32
|
+
"live",
|
|
33
|
+
"physical",
|
|
34
|
+
"timestamps",
|
|
35
|
+
"provenance",
|
|
36
|
+
"actuator_cases",
|
|
37
|
+
"device_models",
|
|
38
|
+
"health",
|
|
39
|
+
"identity",
|
|
40
|
+
"driver",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Matched against the trailing non-structure segments, longest first.
|
|
45
|
+
CHECK_LABELS: dict[str, str] = {
|
|
46
|
+
"budgets": "Budget total",
|
|
47
|
+
"variance": "Signal not frozen",
|
|
48
|
+
"range": "Reading in range",
|
|
49
|
+
"nan": "No bad readings",
|
|
50
|
+
"hard_limit": "Within hard limit",
|
|
51
|
+
"rate_hz": "Update rate",
|
|
52
|
+
"recv_gap": "Gaps between messages",
|
|
53
|
+
"enumeration": "Device seen",
|
|
54
|
+
"liveness": "Device alive",
|
|
55
|
+
"liveness/topic": "Topic alive",
|
|
56
|
+
"sustained_rate_liveness": "Stays alive",
|
|
57
|
+
"thermal": "Temperature",
|
|
58
|
+
"rail": "Power rail",
|
|
59
|
+
"stationary_|g|": "IMU at rest",
|
|
60
|
+
"redundant_sensors": "Sensors agree",
|
|
61
|
+
"camera_histogram": "Camera image sane",
|
|
62
|
+
"flow": "Data flowing",
|
|
63
|
+
"cross_record": "Records agree",
|
|
64
|
+
"conditions": "Conditions match",
|
|
65
|
+
"monotonicity/live_path": "Clock moves forward",
|
|
66
|
+
"wall_clock_currency": "Clock is current",
|
|
67
|
+
"recording": "Recording present",
|
|
68
|
+
"record": "Commands recorded",
|
|
69
|
+
"channel": "Commands on channel",
|
|
70
|
+
"in_recording": "Commands in recording",
|
|
71
|
+
"mass": "Mass declared",
|
|
72
|
+
"mount_frame": "Mount frame",
|
|
73
|
+
"mount_xyz": "Mount position",
|
|
74
|
+
"mount_shape": "Mount shape",
|
|
75
|
+
"upstream": "Rail source",
|
|
76
|
+
"voltage": "Rail voltage",
|
|
77
|
+
"current_limit": "Current limit",
|
|
78
|
+
"efficiency": "Converter efficiency",
|
|
79
|
+
"charged_by": "Charged by",
|
|
80
|
+
"ampacity": "Wire current",
|
|
81
|
+
"bandwidth": "Bus load",
|
|
82
|
+
"host": "Host link",
|
|
83
|
+
"bus_ref": "Bus link",
|
|
84
|
+
"structure_hash": "CAD file linked",
|
|
85
|
+
"placement": "Placement",
|
|
86
|
+
"drives_joint": "Joint driven",
|
|
87
|
+
"limit": "Budget limit",
|
|
88
|
+
"cg": "Centre of gravity",
|
|
89
|
+
"effort": "Joint effort",
|
|
90
|
+
"origin/xyz": "Joint position",
|
|
91
|
+
"origin/rpy": "Joint angles",
|
|
92
|
+
"parent": "Joint parent",
|
|
93
|
+
"model": "Device model",
|
|
94
|
+
"owner": "Owner",
|
|
95
|
+
"unclaimed": "Part not declared",
|
|
96
|
+
"scored": "Checks compared",
|
|
97
|
+
"rail_load": "Rail load",
|
|
98
|
+
"rail_load/rail": "Rail load",
|
|
99
|
+
"joint_target/effort": "Joint effort",
|
|
100
|
+
"joint_target": "Joint target",
|
|
101
|
+
"link_budget": "Radio link",
|
|
102
|
+
"serial_number": "Serial number",
|
|
103
|
+
"family": "Device family",
|
|
104
|
+
"evidence": "Test evidence",
|
|
105
|
+
"i2c-sysfs": "I2C bus read",
|
|
106
|
+
"device-tree": "Device tree read",
|
|
107
|
+
"comm_links_under_representative_load": "Comm links under load",
|
|
108
|
+
"live_hardware_software_reconciliation": "Hardware matches software",
|
|
109
|
+
"dds_requested_deadline": "Message deadline",
|
|
110
|
+
"compared_from": "Compared from",
|
|
111
|
+
"stationary_g": "IMU at rest",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def check_label(quantity: str) -> str:
|
|
116
|
+
"""The plain label of the check behind a contract path, e.g. "Update rate"."""
|
|
117
|
+
parts = quantity.split("/")
|
|
118
|
+
if "commands" in parts[:-1]: # preflight/commands/<record|channel>/<command id>
|
|
119
|
+
parts = parts[parts.index("commands") + 1 : parts.index("commands") + 2]
|
|
120
|
+
words = [
|
|
121
|
+
p
|
|
122
|
+
for p in parts
|
|
123
|
+
if p not in STRUCTURE and not p.isdigit() and "." not in p and not p.startswith(("{", "<"))
|
|
124
|
+
]
|
|
125
|
+
for size in (2, 1):
|
|
126
|
+
key = "/".join(words[-size:])
|
|
127
|
+
if len(words) >= size and key in CHECK_LABELS:
|
|
128
|
+
return CHECK_LABELS[key]
|
|
129
|
+
if not words and parts[0] == "budgets":
|
|
130
|
+
# "bud.mass-model" -> "mass": the budget's own word, so two totals read differently.
|
|
131
|
+
kind = re.split(r"[.-]", parts[1].removeprefix("bud."))[0] if len(parts) > 1 else ""
|
|
132
|
+
return f"{CHECK_LABELS['budgets']} \u00b7 {kind}" if kind else CHECK_LABELS["budgets"]
|
|
133
|
+
# Unmapped: still readable, still no underscores (the test lists every known path).
|
|
134
|
+
last = (words[-1] if words else quantity.split("/")[-1]).replace("_", " ").replace("-", " ")
|
|
135
|
+
text = " ".join(last.replace("|", "").split())
|
|
136
|
+
return text[:1].upper() + text[1:] or "Check"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
ACRONYMS = {"imu", "esc", "gnss", "gps", "cpu", "can", "uart", "usb", "spi", "hbc"}
|
|
140
|
+
_PART_NUMBER = re.compile(r"^[a-z]+-\d+-[a-z]", re.I) # "icm-42688-p": one part number, keep its hyphens
|
|
141
|
+
# ponytail: fixed list of the standard legged-robot convention (ANYmal); extend when another
|
|
142
|
+
# convention shows up. Matched only on the upper-case token, as written in the id.
|
|
143
|
+
LEG_CODES = {
|
|
144
|
+
"LF": "left front",
|
|
145
|
+
"RF": "right front",
|
|
146
|
+
"LH": "left hind",
|
|
147
|
+
"RH": "right hind",
|
|
148
|
+
"HAA": "hip abduction",
|
|
149
|
+
"HFE": "hip flexion",
|
|
150
|
+
"KFE": "knee",
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def human_name(identifier: str) -> str:
|
|
155
|
+
"""A person-readable part name from a contract id: "dev.adis_imu" -> "Adis IMU".
|
|
156
|
+
|
|
157
|
+
Drops the kind prefix, splits snake/kebab case, drops a word repeated as the last word
|
|
158
|
+
("camera ... camera") and a word that opens the next one ("esc esc1" -> "ESC1"). Short
|
|
159
|
+
codes with a digit and known acronyms are upper-cased ("pm02" -> "PM02"); a long word with
|
|
160
|
+
a digit is split ("computer1" -> "Computer 1"); other words are title-cased.
|
|
161
|
+
"""
|
|
162
|
+
for prefix in ("dev.", "rail.", "bus.", "bud.", "host.", "fam.", "mdl."):
|
|
163
|
+
if identifier.startswith(prefix):
|
|
164
|
+
identifier = identifier[len(prefix) :]
|
|
165
|
+
identifier = re.sub(r"(\d)([vV])\b", r"\1 V", identifier)
|
|
166
|
+
words: list[str] = []
|
|
167
|
+
for token in identifier.replace(".", " ").replace("_", " ").split():
|
|
168
|
+
words += [token] if _PART_NUMBER.match(token) else token.split("-")
|
|
169
|
+
words = [x for w in words for x in LEG_CODES.get(w, w).split() if w]
|
|
170
|
+
kept = [
|
|
171
|
+
w
|
|
172
|
+
for i, w in enumerate(words)
|
|
173
|
+
if not (w == words[-1] and i + 1 < len(words)) and not (len(w) >= 3 and i + 1 < len(words) and words[i + 1].startswith(w))
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
def case(w: str) -> str:
|
|
177
|
+
if w.lower() in ACRONYMS:
|
|
178
|
+
return w.upper()
|
|
179
|
+
m = re.match(r"([a-z]{6,})(\d+)$", w, re.I)
|
|
180
|
+
if m:
|
|
181
|
+
return f"{m[1].capitalize()} {m[2]}"
|
|
182
|
+
if len(w) <= 11 and any(c.isdigit() for c in w):
|
|
183
|
+
return w.upper()
|
|
184
|
+
return w.capitalize()
|
|
185
|
+
|
|
186
|
+
return " ".join(case(w) for w in kept) or identifier
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
_QUOTED = re.compile(r"'([^']+)'")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def instead_row(text: str, kind: str) -> dict[str, str]:
|
|
193
|
+
"""One short "Instead" row: a verb, the number, and the kind word. The quoted ids in the
|
|
194
|
+
engine's sentence become part names, and "derived-by-<check>" becomes "derived"."""
|
|
195
|
+
m = re.match(r"move the device to rail '([^']+)', which has (.+) free$", text)
|
|
196
|
+
if m:
|
|
197
|
+
short = f"Move to {human_name(m[1])} rail, {m[2]} free"
|
|
198
|
+
else:
|
|
199
|
+
m = re.match(r"add a (.+) regulator fed from rail '([^']+)'$", text)
|
|
200
|
+
if m:
|
|
201
|
+
short = f"Add {m[1]} regulator"
|
|
202
|
+
elif text.startswith("no other rail has headroom"):
|
|
203
|
+
short = "No other rail has room"
|
|
204
|
+
else:
|
|
205
|
+
short = _QUOTED.sub(lambda q: human_name(q[1]), text)
|
|
206
|
+
short = short[:1].upper() + short[1:]
|
|
207
|
+
return {"text": short, "kind": "derived" if kind.startswith("derived-by-") else kind}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# Engine fix lines too long or too technical for the app, by their stable start.
|
|
211
|
+
_PLAIN_FIX = (
|
|
212
|
+
("declare the traffic each driver", "Declare each device's traffic on this bus"),
|
|
213
|
+
("lock a cited bitrate", "Set this bus's speed from its datasheet"),
|
|
214
|
+
("lock model provenance", "Re-lock the model file so changes are caught"),
|
|
215
|
+
("restore the URDF", "Put the model file back, or fix its path"),
|
|
216
|
+
("fill in the link's inertial", "Add this part's mass in the robot model"),
|
|
217
|
+
(
|
|
218
|
+
"declare the rail's voltage, or accept upstream",
|
|
219
|
+
"Declare the rail's voltage, or skip its voltage checks",
|
|
220
|
+
),
|
|
221
|
+
(
|
|
222
|
+
"add device power witnesses or remove",
|
|
223
|
+
"Add each device's power use, or remove the empty budget",
|
|
224
|
+
),
|
|
225
|
+
("add power draws to devices", "Add each device's power use to see battery time"),
|
|
226
|
+
("fix the driven-joint reference", "Fix the joint a device drives, or add the missing joint"),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def fix_text(action: str) -> str:
|
|
231
|
+
"""The engine's fix line in plain words (Finding.action itself stays engineer text)."""
|
|
232
|
+
for start, plain in _PLAIN_FIX:
|
|
233
|
+
if action.startswith(start):
|
|
234
|
+
return plain
|
|
235
|
+
m = re.match(r"shed load on rail '([^']+)'", action)
|
|
236
|
+
if m:
|
|
237
|
+
return f"Use less power on {human_name(m[1])}, or raise its limit"
|
|
238
|
+
if action.startswith("remove mass or re-lock"):
|
|
239
|
+
return "Remove weight, or raise the weight limit"
|
|
240
|
+
text = _QUOTED.sub(lambda q: human_name(q[1]), action.replace(" with provenance", ""))
|
|
241
|
+
return text[:1].upper() + text[1:]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# Row title for N repeats of one kind of finding, keyed by the trailing path shape.
|
|
245
|
+
GROUP_TITLES: dict[str, str] = {
|
|
246
|
+
"drives_joint": "{n} drives unmapped",
|
|
247
|
+
"physical/mass": "{n} parts missing mass",
|
|
248
|
+
"placement": "{n} positions not declared",
|
|
249
|
+
"unclaimed": "{n} parts not declared",
|
|
250
|
+
"rate_hz": "{n} update rates off",
|
|
251
|
+
"recv_gap": "{n} devices gap out",
|
|
252
|
+
"enumeration": "{n} devices not seen",
|
|
253
|
+
"liveness": "{n} devices not alive",
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def group_title(shape: str) -> str:
|
|
258
|
+
"""The "{n} ..." row title for a repeated finding, or "" (group only identical titles)."""
|
|
259
|
+
for key, title in GROUP_TITLES.items():
|
|
260
|
+
if shape.endswith(key):
|
|
261
|
+
return title
|
|
262
|
+
return ""
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def commit_summary(message: str) -> str:
|
|
266
|
+
"""A commit's first line as a sentence: no "docs:" prefix, first letter capital."""
|
|
267
|
+
line = message.strip().splitlines()[0] if message.strip() else ""
|
|
268
|
+
line = re.sub(r"^\w+(\([^)]*\))?!?:\s*", "", line)
|
|
269
|
+
return line[:1].upper() + line[1:]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# Declare list: a value's plain name, from the tail of its contract path (longest tail wins).
|
|
273
|
+
_VALUE_NAMES: dict[tuple[str, ...], str] = {
|
|
274
|
+
("input_voltage_range", "min"): "Lowest voltage",
|
|
275
|
+
("input_voltage_range", "max"): "Highest voltage",
|
|
276
|
+
("voltage_range", "min"): "Lowest voltage",
|
|
277
|
+
("voltage_range", "max"): "Highest voltage",
|
|
278
|
+
("thermal_limit", "temperature"): "Temperature limit",
|
|
279
|
+
("radio", "tx_power"): "Radio power",
|
|
280
|
+
("radio", "rx_sensitivity"): "Radio sensitivity",
|
|
281
|
+
("radio", "air_rate"): "Radio data rate",
|
|
282
|
+
("runtime", "max_wall_time"): "Longest run time",
|
|
283
|
+
("physical", "mass"): "Device mass",
|
|
284
|
+
("mass",): "Model mass", # a model link's own mass; the device on the same part says "Device mass"
|
|
285
|
+
("physical", "mount_xyz"): "Mount position",
|
|
286
|
+
("power", "draw"): "Power draw",
|
|
287
|
+
("thermal", "resistance"): "Heat resistance",
|
|
288
|
+
("thermal", "ambient"): "Surrounding temperature",
|
|
289
|
+
("tx_antenna", "gain"): "Transmit antenna gain",
|
|
290
|
+
("rx_antenna", "gain"): "Receive antenna gain",
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def value_name(parts: tuple[str, ...]) -> str:
|
|
295
|
+
""""input_voltage_range/min" -> "Lowest voltage"; anything unmapped is just capitalised words."""
|
|
296
|
+
for n in (2, 1):
|
|
297
|
+
if (hit := _VALUE_NAMES.get(parts[-n:])) is not None and len(parts) >= n:
|
|
298
|
+
return hit
|
|
299
|
+
if parts[:1] == ("link_budget",) and len(parts) > 1:
|
|
300
|
+
parts = parts[1:] # "link budget required range" -> "Required range"
|
|
301
|
+
text = " ".join(p.replace("_", " ") for p in parts)
|
|
302
|
+
return text[:1].upper() + text[1:]
|