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.
Files changed (52) hide show
  1. lockwire/__init__.py +9 -0
  2. lockwire/__main__.py +9 -0
  3. lockwire/advice.py +119 -0
  4. lockwire/api.py +1216 -0
  5. lockwire/api_payload.py +985 -0
  6. lockwire/bench.py +182 -0
  7. lockwire/blame.py +100 -0
  8. lockwire/catalog.py +100 -0
  9. lockwire/catalog_seed.yaml +545 -0
  10. lockwire/checks.py +3483 -0
  11. lockwire/cli.py +1799 -0
  12. lockwire/cli_line.py +73 -0
  13. lockwire/constraints.py +313 -0
  14. lockwire/copy.py +302 -0
  15. lockwire/describe.py +682 -0
  16. lockwire/edit.py +2098 -0
  17. lockwire/emit_ts.py +150 -0
  18. lockwire/export_mhs.py +115 -0
  19. lockwire/factdiff.py +254 -0
  20. lockwire/forces.py +334 -0
  21. lockwire/gitref.py +188 -0
  22. lockwire/hw.py +388 -0
  23. lockwire/mcp_server.py +276 -0
  24. lockwire/mechanism.py +379 -0
  25. lockwire/mesh_dae.py +555 -0
  26. lockwire/mesh_simple.py +198 -0
  27. lockwire/page.py +1485 -0
  28. lockwire/plan.py +849 -0
  29. lockwire/plan_payload.py +151 -0
  30. lockwire/plugins.py +70 -0
  31. lockwire/report.py +848 -0
  32. lockwire/report_html.py +707 -0
  33. lockwire/report_json.py +168 -0
  34. lockwire/review_draft.py +315 -0
  35. lockwire/scan.py +732 -0
  36. lockwire/scene.py +535 -0
  37. lockwire/scene_import.py +309 -0
  38. lockwire/scene_urdf.py +750 -0
  39. lockwire/scene_view.py +376 -0
  40. lockwire/schema.py +3616 -0
  41. lockwire/sdf.py +240 -0
  42. lockwire/serve.py +1256 -0
  43. lockwire/start_operation.py +92 -0
  44. lockwire/statics.py +77 -0
  45. lockwire/units.py +115 -0
  46. lockwire/urdf.py +348 -0
  47. lockwire/vcs.py +402 -0
  48. lockwire/whatif.py +161 -0
  49. lockwire-0.2.0.dist-info/METADATA +192 -0
  50. lockwire-0.2.0.dist-info/RECORD +52 -0
  51. lockwire-0.2.0.dist-info/WHEEL +4 -0
  52. 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
@@ -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:]