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/checks.py ADDED
@@ -0,0 +1,3483 @@
1
+ """Record-level checks (rung 1): do the records agree with each other?
2
+
3
+ No robot required — these run in CI on every change to the contract or its
4
+ witnesses. Every check emits the one grammar (report.Finding). The
5
+ false-positive budget is a first-class requirement: a clean contract must
6
+ produce zero findings.
7
+ """
8
+
9
+ import math
10
+ import xml.etree.ElementTree as ET
11
+ from collections.abc import Callable, Iterator, Mapping
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from pydantic import BaseModel
16
+
17
+ from lockwire import plugins, units
18
+ from lockwire.advice import build_rail_alternatives
19
+ from lockwire.catalog import SourcedNumber, load_catalog
20
+ from lockwire.edit import resolve_source, source_is_absolute
21
+ from lockwire.report import (
22
+ CGContributor,
23
+ CGReport,
24
+ Finding,
25
+ RecordReport,
26
+ ScoredRecord,
27
+ format_magnitude,
28
+ )
29
+ from lockwire.mechanism import NAMED_SET, Mechanism, analyse_model
30
+ from lockwire.scene import Scene
31
+ from lockwire.scene_view import resolve_scene
32
+ from lockwire.schema import (
33
+ ArtifactRef,
34
+ Band,
35
+ Contract,
36
+ ModelRecord,
37
+ Quantity,
38
+ Provenance,
39
+ Tolerance,
40
+ )
41
+ from lockwire.sdf import read_sdf_topology
42
+ from lockwire.urdf import describe_structure_drift, read_urdf_topology
43
+
44
+ # the YAML is the truth. This is only how far the model in the scene is
45
+ # allowed to disagree with it before the disagreement is a finding, not a
46
+ # silent picture.
47
+ PLACEMENT_TOLERANCE_M = 0.005
48
+
49
+ # Raw bus bitrate is not usable payload throughput: arbitration, framing, retries,
50
+ # and latency collapse make sustained operation unsafe well before 100% load.
51
+ BUS_UTILIZATION_CEILING = 0.60
52
+
53
+
54
+ def unknown_draw_message(device_id: str, rail_id: str) -> str:
55
+ """one phrasing for "this consumer's draw is undeclared", reused by
56
+ every surface (terminal, page, engineer report, describe) so the same gap
57
+ reads the same way everywhere."""
58
+ return f"{device_id} draws an unknown amount from {rail_id} — declare its draw"
59
+
60
+
61
+ # Presence-floor disclosure when no record-check comparison had both operands
62
+ # Severity-neutral: stub contracts stay exit 0, never a pass.
63
+ EMPTY_SCORE_OBSERVED = "nothing was scored: no record-check comparison had both operands populated"
64
+ EMPTY_SCORE_QUANTITY = "check/scored"
65
+
66
+
67
+ class _ScoreBook:
68
+ """Declarations for which a check compared two independent populated operands.
69
+
70
+ The presence-floor denominator is ``len(self)`` — reported by the checks
71
+ themselves at the moment both operands are confirmed, not inferred from
72
+ how many devices/rails/budgets the contract declares.
73
+ """
74
+
75
+ def __init__(self) -> None:
76
+ self._keys: set[str] = set()
77
+ self.records: list[ScoredRecord] = []
78
+
79
+ def record(self, key: str) -> None:
80
+ self._keys.add(key)
81
+
82
+ def passed(
83
+ self,
84
+ quantity: str,
85
+ observed: str,
86
+ expected: str,
87
+ margin: str,
88
+ *,
89
+ observed_quantity: Quantity | None = None,
90
+ expected_quantity: Quantity | None = None,
91
+ provenance: Provenance | None = None,
92
+ ) -> None:
93
+ """Record a PASSED comparison alongside ``record`` (same key, same call site).
94
+
95
+ Only call this on the branch where the comparison did NOT produce a
96
+ violation/warning finding — one scored comparison yields exactly one
97
+ of {violation, warning, ScoredRecord} so the presence-floor identity
98
+ (``len(scored_records) + violations + warnings == scored``) holds.
99
+
100
+ ``observed_quantity``/``expected_quantity`` are optional typed magnitudes
101
+ (mirrors ``Finding``): a caller with real compared numbers (a preflight
102
+ band/limit check) can pass them so a reader can show the numbers without
103
+ re-deriving them; record checks leave them unset.
104
+ """
105
+ self.records.append(
106
+ ScoredRecord(
107
+ quantity=quantity,
108
+ observed=observed,
109
+ expected=expected,
110
+ margin=margin,
111
+ observed_quantity=observed_quantity,
112
+ expected_quantity=expected_quantity,
113
+ provenance=provenance,
114
+ )
115
+ )
116
+
117
+ @property
118
+ def keys(self) -> frozenset[str]:
119
+ return frozenset(self._keys)
120
+
121
+ def __len__(self) -> int:
122
+ return len(self._keys)
123
+
124
+
125
+ def _empty_score_disclosure() -> Finding:
126
+ return Finding(
127
+ severity="not_scored",
128
+ quantity=EMPTY_SCORE_QUANTITY,
129
+ observed=EMPTY_SCORE_OBSERVED,
130
+ expected="at least one record-check comparison with both operands populated",
131
+ action=(
132
+ "add a comparable pair (e.g. envelope vs hard limit, budget limit vs "
133
+ "witness sum, rail voltage vs upstream, joint effort vs actuator) "
134
+ "so check has something to compare"
135
+ ),
136
+ )
137
+
138
+
139
+ # Check slots filled by the paid package (open-core split). Keys:
140
+ # "actuator_cases", "condition_envelopes". An empty slot never passes silently.
141
+ PAID_CHECKS: dict[str, Callable[..., Any]] = {}
142
+
143
+
144
+ def _paid_check_missing(quantity: str, what: str) -> Finding:
145
+ return Finding(
146
+ severity="not_scored",
147
+ quantity=quantity,
148
+ observed="not run: needs Lockwire Pro",
149
+ expected=f"{what} checked by Lockwire Pro",
150
+ action=f"install Lockwire Pro to check {what}, or remove them from the contract",
151
+ )
152
+
153
+
154
+ def run_record_checks(
155
+ contract: Contract,
156
+ current_conditions: dict[str, float] | None = None,
157
+ *,
158
+ contract_path: Path | None = None,
159
+ current_edge_values: dict[str, float] | None = None,
160
+ actuator_evidence: Mapping[str, ArtifactRef] | None = None,
161
+ evidence_root: Path | None = None,
162
+ contract_sha256: str | None = None,
163
+ ) -> RecordReport:
164
+ """Return judgements and severity-free derived information in separate lanes.
165
+
166
+ ``contract_path`` is the contract's own file, when the caller has it: it
167
+ anchors a relative source to the contract's directory, so without it a
168
+ relative URDF path stays unresolvable and drift stays ``not_scored``.
169
+
170
+ ``current_edge_values`` is the same API-path gap as ``current_conditions``
171
+ below: an edge's tolerance needs a live reading to score drift against,
172
+ the CLI ``check`` command does not supply one today, and callers that do
173
+ (a recording, a live sample) get real findings without inflating the
174
+ ``_ScoreBook`` denominator for a run that never had one.
175
+ """
176
+ plugins.ensure()
177
+ scores = _ScoreBook()
178
+ findings: list[Finding] = []
179
+ findings += check_device_owners(contract)
180
+ findings += check_compared_from(contract)
181
+ findings += check_source_portability(contract)
182
+ findings += check_reference_integrity(contract)
183
+ findings += check_edge_tolerance(contract, current_edge_values, scores=scores)
184
+ findings += check_bus_identities(contract)
185
+ findings += check_electrical_segments(contract, scores=scores)
186
+ findings += check_rail_topology(contract, scores=scores)
187
+ findings += check_voltage_compatibility(contract, scores=scores)
188
+ findings += check_budgets(contract, scores=scores)
189
+ findings += check_ampacity(contract, scores=scores)
190
+ findings += check_rail_current(contract, scores=scores)
191
+ findings += check_torque_budgets(contract, scores=scores)
192
+ findings += check_bus_bandwidth(contract, scores=scores)
193
+ findings += check_zero_mass(contract)
194
+ findings += check_unusable_effort(contract)
195
+ findings += check_mechanisms(contract, scores=scores)
196
+ findings += check_urdf_drift(contract, scores=scores, contract_path=contract_path)
197
+ findings += check_envelopes_vs_hard_limits(contract, scores=scores)
198
+ if current_conditions is not None:
199
+ # No caller of the free path passes conditions today, so an empty slot
200
+ # here cannot be reached from `lockwire check`; skip silently.
201
+ if run := PAID_CHECKS.get("condition_envelopes"):
202
+ findings += run(contract, current_conditions)
203
+ findings += check_thermal_budget(contract, scores=scores)
204
+ findings += check_link_budget(contract, scores=scores)
205
+ actuator_case_reports: list[Any] = []
206
+ if contract.actuator_cases:
207
+ if run := PAID_CHECKS.get("actuator_cases"):
208
+ actuator_findings, actuator_case_reports = run(
209
+ contract,
210
+ actuator_evidence=actuator_evidence,
211
+ evidence_root=evidence_root,
212
+ contract_sha256=contract_sha256,
213
+ )
214
+ findings += actuator_findings
215
+ else:
216
+ findings.append(_paid_check_missing("actuator_cases/conformance", "actuator cases"))
217
+ scored = len(scores)
218
+ if scored == 0:
219
+ findings.append(_empty_score_disclosure())
220
+ return RecordReport(
221
+ findings,
222
+ compute_system_cg(contract),
223
+ scored=scored,
224
+ scored_keys=scores.keys,
225
+ scored_records=scores.records,
226
+ actuator_case_reports=actuator_case_reports,
227
+ )
228
+
229
+
230
+ def _is_accountable_owner(owner: str) -> bool:
231
+ normalized = owner.strip()
232
+ return normalized.casefold() != "unresolved" and any(
233
+ character.isalnum() for character in normalized
234
+ )
235
+
236
+
237
+ def check_device_owners(c: Contract) -> list[Finding]:
238
+ """A device contract must name who is accountable for resolving its assertions."""
239
+ return [
240
+ Finding(
241
+ severity="violation",
242
+ quantity=f"devices/{device.id}/owner",
243
+ observed=repr(device.owner),
244
+ expected="an accountable owner",
245
+ action="name the human or team accountable for this device",
246
+ )
247
+ for device in c.devices
248
+ if not _is_accountable_owner(device.owner)
249
+ ]
250
+
251
+
252
+ # ---------------------------------------------------------------- compared-from
253
+
254
+
255
+ def _catalog_part_mass(ref: str) -> tuple[str, SourcedNumber] | None:
256
+ """Return a concrete catalog mass record with its identity.
257
+
258
+ This narrow path intentionally accepts only a concrete catalog-part id.
259
+ A family id has no single source mass, so it remains an explicit unresolved
260
+ reference rather than choosing or inventing a representative value.
261
+ """
262
+ for part in load_catalog().parts:
263
+ if part.id != ref:
264
+ continue
265
+ mass = part.numbers.get("mass")
266
+ if mass is None:
267
+ return None
268
+ return f"catalog part {part.id}", mass
269
+ return None
270
+
271
+
272
+ def check_compared_from(c: Contract) -> list[Finding]:
273
+ """Check a device mass copied from one concrete seeded catalog part.
274
+
275
+ ``Provenance.compared_from`` records the identity of the copied source; it
276
+ never substitutes a catalog value for the device's own declaration. Only
277
+ a catalog part with a declared mass is resolvable here. Device and family
278
+ references are deliberately outside this catalog-part slice.
279
+ """
280
+ findings: list[Finding] = []
281
+ for device in c.devices:
282
+ provenance = device.provenance
283
+ if provenance is None or provenance.compared_from is None:
284
+ continue
285
+ ref = provenance.compared_from
286
+ quantity = f"devices/{device.id}/provenance/compared_from"
287
+ source = _catalog_part_mass(ref)
288
+ if source is None:
289
+ findings.append(
290
+ Finding(
291
+ severity="warning",
292
+ quantity=quantity,
293
+ observed=f"compared_from {ref!r}",
294
+ expected="a seeded catalog part id with a declared mass",
295
+ action=(
296
+ f"replace {ref!r} with the concrete catalog part whose mass was copied; "
297
+ "family references are not resolved by this check"
298
+ ),
299
+ )
300
+ )
301
+ continue
302
+ source_label, catalog_mass = source
303
+ source_kind = catalog_mass.kind
304
+ source_citation = catalog_mass.source
305
+ if source_kind == "assumed":
306
+ findings.append(
307
+ Finding(
308
+ severity="not_scored",
309
+ quantity=quantity,
310
+ observed=(
311
+ f"{source_label} mass is kind={source_kind}; source={source_citation}"
312
+ ),
313
+ expected="a catalog mass marked datasheet or source_file",
314
+ action=(
315
+ "replace the assumed catalog mass with a sourced value; this check refuses "
316
+ "to compare a declared device mass against a seed assumption"
317
+ ),
318
+ )
319
+ )
320
+ continue
321
+ declared_mass = device.physical.mass if device.physical is not None else None
322
+ if declared_mass is None:
323
+ findings.append(
324
+ Finding(
325
+ severity="not_scored",
326
+ quantity=quantity,
327
+ observed=f"copied from {source_label} (kind={source_kind}; source={source_citation})",
328
+ expected="a declared device physical mass to compare",
329
+ action="declare the device's physical mass; this check will not copy one for it",
330
+ )
331
+ )
332
+ continue
333
+ source_mass = Quantity(value=catalog_mass.value, unit=catalog_mass.unit)
334
+ if math.isclose(declared_mass.base(), source_mass.base(), rel_tol=1e-9):
335
+ continue
336
+ findings.append(
337
+ Finding(
338
+ severity="warning",
339
+ quantity=quantity,
340
+ observed=f"{declared_mass} (declared device mass)",
341
+ expected=(
342
+ f"{source_mass} ({source_label}'s current mass; "
343
+ f"kind={source_kind}; source={source_citation})"
344
+ ),
345
+ action=(
346
+ f"re-confirm {device.id}'s mass against {source_label} or update the declared copy"
347
+ ),
348
+ )
349
+ )
350
+ return findings
351
+
352
+
353
+ # ---------------------------------------------------------------- source portability
354
+
355
+
356
+ def _iter_source_fields(node: Any, path: str) -> Iterator[tuple[str, str]]:
357
+ """Every ``source`` string in the contract tree, with its '/'-separated path.
358
+
359
+ Generic on purpose: ``Provenance.source`` and ``ModelRecord.source`` are the
360
+ two today, and a third one added later is covered without another walker.
361
+ """
362
+ if isinstance(node, BaseModel):
363
+ value = getattr(node, "source", None)
364
+ if isinstance(value, str):
365
+ yield (f"{path}/source" if path else "source"), value
366
+ for name in type(node).model_fields:
367
+ yield from _iter_source_fields(getattr(node, name), f"{path}/{name}" if path else name)
368
+ elif isinstance(node, list):
369
+ for position, item in enumerate(node):
370
+ key = getattr(item, "id", None) or getattr(item, "name", None) or position
371
+ yield from _iter_source_fields(item, f"{path}/{key}")
372
+ elif isinstance(node, dict):
373
+ for key, item in node.items():
374
+ yield from _iter_source_fields(item, f"{path}/{key}")
375
+
376
+
377
+ def check_source_portability(c: Contract) -> list[Finding]:
378
+ """a source is a file beside the contract, a URL or a cited title.
379
+
380
+ Write paths refuse an absolute path outright (``edit.validate_source``); a
381
+ contract that already carries one predates that gate, so it is reported here
382
+ rather than crashing or passing silently. A warning, not a violation: the
383
+ physics still agrees, it is the evidence that cannot travel.
384
+ """
385
+ return [
386
+ Finding(
387
+ severity="warning",
388
+ quantity=quantity,
389
+ observed=repr(source),
390
+ expected="a path relative to the contract, a URL or a cited title",
391
+ source=source,
392
+ action="check the evidence in beside the contract and re-declare this "
393
+ "fact with the relative path",
394
+ )
395
+ for quantity, source in _iter_source_fields(c, "")
396
+ if source_is_absolute(source)
397
+ ]
398
+
399
+
400
+ # ---------------------------------------------------------------- references
401
+
402
+
403
+ def check_reference_integrity(c: Contract) -> list[Finding]:
404
+ """Every cross-entry reference must resolve. A dangling ref is a violation:
405
+ it means the contract describes a robot that doesn't exist in the records."""
406
+ out: list[Finding] = []
407
+ hosts = {host.id for host in c.topology.hosts} if c.topology is not None else set()
408
+ buses = {bus.id for bus in c.topology.buses} if c.topology is not None else set()
409
+ links = {link.name for link in c.model.links} if c.model is not None else set()
410
+ joints = {joint.name for joint in c.model.joints} if c.model is not None else set()
411
+
412
+ for d in c.devices:
413
+ if c.device_model(d.model) is None:
414
+ out.append(
415
+ Finding(
416
+ severity="violation",
417
+ quantity=f"devices/{d.id}/model",
418
+ observed=repr(d.model),
419
+ expected="an id in device_models",
420
+ dependent=", ".join(d.depends) or None,
421
+ owner=d.owner,
422
+ action=f"add device model {d.model!r} or fix the reference",
423
+ )
424
+ )
425
+ if d.power is not None and c.rail(d.power.rail) is None:
426
+ out.append(
427
+ Finding(
428
+ severity="violation",
429
+ quantity=f"devices/{d.id}/power/rail",
430
+ observed=repr(d.power.rail),
431
+ expected="a rail id in topology",
432
+ owner=d.owner,
433
+ action=f"declare rail {d.power.rail!r} in topology or fix the reference",
434
+ )
435
+ )
436
+ if d.identity is not None:
437
+ if d.identity.bus_ref not in buses:
438
+ out.append(
439
+ Finding(
440
+ severity="violation",
441
+ quantity=f"devices/{d.id}/identity/bus_ref",
442
+ observed=repr(d.identity.bus_ref),
443
+ expected="a bus id in topology",
444
+ owner=d.owner,
445
+ action=(
446
+ f"declare bus {d.identity.bus_ref!r} in topology or fix the reference"
447
+ ),
448
+ )
449
+ )
450
+ if d.identity.host not in hosts:
451
+ out.append(
452
+ Finding(
453
+ severity="violation",
454
+ quantity=f"devices/{d.id}/identity/host",
455
+ observed=repr(d.identity.host),
456
+ expected="a host id in topology",
457
+ owner=d.owner,
458
+ action=f"declare host {d.identity.host!r} in topology or fix the reference",
459
+ )
460
+ )
461
+ if d.physical is not None and d.physical.mount_frame is not None:
462
+ if c.model is None:
463
+ out.append(
464
+ Finding(
465
+ severity="warning",
466
+ quantity=f"devices/{d.id}/physical/mount_frame",
467
+ observed=repr(d.physical.mount_frame),
468
+ expected="a model link once a model is recorded",
469
+ owner=d.owner,
470
+ action="record the model before relying on mount-frame integrity",
471
+ )
472
+ )
473
+ elif d.physical.mount_frame not in links:
474
+ out.append(
475
+ Finding(
476
+ severity="violation",
477
+ quantity=f"devices/{d.id}/physical/mount_frame",
478
+ observed=repr(d.physical.mount_frame),
479
+ expected="a link name in model/links",
480
+ owner=d.owner,
481
+ action="fix the mount frame or add the missing link to the model record",
482
+ )
483
+ )
484
+ if d.drives_joint is not None and d.drives_joint not in joints:
485
+ out.append(
486
+ Finding(
487
+ severity="violation",
488
+ quantity=f"devices/{d.id}/drives_joint",
489
+ observed=repr(d.drives_joint),
490
+ expected="a joint name in model/joints",
491
+ owner=d.owner,
492
+ action="fix the driven-joint reference or record the missing joint",
493
+ )
494
+ )
495
+
496
+ for m in c.device_models:
497
+ if c.family(m.family) is None:
498
+ out.append(
499
+ Finding(
500
+ severity="violation",
501
+ quantity=f"device_models/{m.id}/family",
502
+ observed=repr(m.family),
503
+ expected="an id in families",
504
+ action=f"add family {m.family!r} or fix the reference",
505
+ )
506
+ )
507
+
508
+ if c.topology is not None:
509
+ for bus in c.topology.buses:
510
+ if bus.host not in hosts:
511
+ out.append(
512
+ Finding(
513
+ severity="violation",
514
+ quantity=f"topology/buses/{bus.id}/host",
515
+ observed=repr(bus.host),
516
+ expected="a host id in topology",
517
+ action=f"declare host {bus.host!r} in topology or fix the bus reference",
518
+ )
519
+ )
520
+ for rail in c.topology.rails:
521
+ if rail.charged_by is not None and c.device(rail.charged_by) is None:
522
+ out.append(
523
+ Finding(
524
+ severity="violation",
525
+ quantity=f"topology/rails/{rail.id}/charged_by",
526
+ observed=repr(rail.charged_by),
527
+ expected="a device id in devices",
528
+ action=(
529
+ f"declare device {rail.charged_by!r} or fix the charged_by reference"
530
+ ),
531
+ )
532
+ )
533
+ if rail.upstream is None:
534
+ continue
535
+ if rail.upstream == "battery":
536
+ if c.topology.battery is None:
537
+ out.append(
538
+ Finding(
539
+ severity="violation",
540
+ quantity=f"topology/rails/{rail.id}/upstream",
541
+ observed="'battery'",
542
+ expected="a declared topology battery",
543
+ action="declare the battery or fix the rail's upstream reference",
544
+ )
545
+ )
546
+ elif c.rail(rail.upstream) is None:
547
+ out.append(
548
+ Finding(
549
+ severity="violation",
550
+ quantity=f"topology/rails/{rail.id}/upstream",
551
+ observed=repr(rail.upstream),
552
+ expected="a rail id in topology or 'battery'",
553
+ action=f"declare rail {rail.upstream!r} or fix the upstream reference",
554
+ )
555
+ )
556
+
557
+ for e in c.edges:
558
+ try:
559
+ c.resolve(e.quantity)
560
+ except KeyError:
561
+ out.append(
562
+ Finding(
563
+ severity="violation",
564
+ quantity=e.quantity,
565
+ observed="unresolvable path",
566
+ expected="a contract path that resolves",
567
+ dependent=", ".join(e.code),
568
+ owner=e.owner,
569
+ action="fix the edge's quantity path — the code it guards is now unguarded",
570
+ )
571
+ )
572
+
573
+ for b in c.budgets:
574
+ if b.kind == "rail_power" and b.rail is not None and c.rail(b.rail) is None:
575
+ out.append(
576
+ Finding(
577
+ severity="violation",
578
+ quantity=f"budgets/{b.id}/rail",
579
+ observed=repr(b.rail),
580
+ expected="a rail id in topology",
581
+ owner=b.owner,
582
+ action=f"declare rail {b.rail!r} in topology or fix the budget",
583
+ )
584
+ )
585
+
586
+ if c.topology is not None:
587
+ for wire in c.topology.wires:
588
+ if c.rail(wire.rail) is None:
589
+ out.append(
590
+ Finding(
591
+ severity="violation",
592
+ quantity=f"topology/wires/{wire.id}/rail",
593
+ observed=repr(wire.rail),
594
+ expected="a rail id in topology",
595
+ owner=wire.owner,
596
+ action=f"declare rail {wire.rail!r} in topology or fix the wire",
597
+ )
598
+ )
599
+
600
+ return out
601
+
602
+
603
+ def _resolved_magnitude(value: Any) -> tuple[float, str] | None:
604
+ """A resolved contract path as ``(numeric value, unit-or-'')``, or ``None``
605
+ when it resolved to something a tolerance cannot bound (a string, a list,
606
+ a bare dict with no numeric ``value``). Never guessed."""
607
+ if isinstance(value, bool):
608
+ return None
609
+ if isinstance(value, (int, float)):
610
+ return float(value), ""
611
+ if (
612
+ isinstance(value, dict)
613
+ and isinstance(value.get("value"), (int, float))
614
+ and not isinstance(value.get("value"), bool)
615
+ and isinstance(value.get("unit"), str)
616
+ ):
617
+ return float(value["value"]), value["unit"]
618
+ return None
619
+
620
+
621
+ def _edge_tolerance_str(tolerance: Tolerance) -> str:
622
+ if tolerance.rel_pct is not None:
623
+ return f"{tolerance.rel_pct:g}%"
624
+ assert tolerance.abs is not None # Tolerance's own validator requires exactly one
625
+ return format_magnitude(tolerance.abs.value, tolerance.abs.unit)
626
+
627
+
628
+ def check_edge_tolerance(
629
+ c: Contract,
630
+ current_edge_values: dict[str, float] | None = None,
631
+ *,
632
+ scores: _ScoreBook | None = None,
633
+ ) -> list[Finding]:
634
+ """Score an edge's declared tolerance against a live current reading.
635
+
636
+ ``DependencyEdge.tolerance`` bounds how far ``e.quantity`` may drift from
637
+ the contract's own declared value before the code in ``e.code`` should
638
+ stop trusting it. Scoring that drift needs two independently populated
639
+ operands — the contract's declared value (``c.resolve(e.quantity)``) and
640
+ a live/current reading — and ``DependencyEdge`` carries no second,
641
+ distinct field to diff against (its fields are exactly
642
+ quantity/tolerance/code/owner). Inventing one would mean touching
643
+ ``schema.py``, which stays frozen; this
644
+ check follows ``check_condition_envelopes``'s own precedent instead — a
645
+ caller-supplied mapping of fresh readings keyed by the same path,
646
+ threaded through ``run_record_checks`` exactly like ``current_conditions``.
647
+ The CLI ``check`` command does not populate it today (the same
648
+ API-path gap that check documents for itself), so absent a supplied
649
+ reading this stays a disclosed ``not_scored`` coverage gap — never a
650
+ silent pass and never a guessed comparison.
651
+
652
+ A dangling quantity path stays ``check_reference_integrity``'s violation,
653
+ not this check's (skipped here to avoid a duplicate finding on the same
654
+ edge). A declared value this check cannot bind a number to (absent,
655
+ non-numeric) is ``not_scored``, generalizing the existing idiom for
656
+ ``Physical.mass``.
657
+
658
+ Both operands present and the drift falls inside the tolerance band:
659
+ recorded via ``scores`` and silent (the false-positive budget) — this is
660
+ a real two-operand comparison, unlike a structural presence check, so it
661
+ now belongs in the ``_ScoreBook`` denominator.
662
+ """
663
+ out: list[Finding] = []
664
+ for e in c.edges:
665
+ try:
666
+ declared = c.resolve(e.quantity)
667
+ except KeyError:
668
+ continue
669
+
670
+ declared_magnitude = _resolved_magnitude(declared)
671
+ if declared_magnitude is None:
672
+ out.append(
673
+ Finding(
674
+ severity="not_scored",
675
+ quantity=e.quantity,
676
+ observed=(
677
+ "no declared value at this path"
678
+ if declared is None
679
+ else f"declared value {declared!r} is not a magnitude a tolerance can bound"
680
+ ),
681
+ expected=(
682
+ f"a numeric declared value to score the "
683
+ f"{_edge_tolerance_str(e.tolerance)} tolerance against"
684
+ ),
685
+ dependent=", ".join(e.code),
686
+ owner=e.owner,
687
+ action=(
688
+ f"declare a value at {e.quantity}, or drop the edge if "
689
+ f"{', '.join(e.code)} no longer depends on it"
690
+ ),
691
+ )
692
+ )
693
+ continue
694
+
695
+ declared_value, unit = declared_magnitude
696
+ if current_edge_values is None or e.quantity not in current_edge_values:
697
+ out.append(
698
+ Finding(
699
+ severity="not_scored",
700
+ quantity=e.quantity,
701
+ observed="no current reading supplied to score against the declared value",
702
+ expected=(
703
+ f"a current reading for {e.quantity} to score the "
704
+ f"{_edge_tolerance_str(e.tolerance)} tolerance against"
705
+ ),
706
+ dependent=", ".join(e.code),
707
+ owner=e.owner,
708
+ action=(
709
+ "supply a current reading (a recording or a live sample) "
710
+ "via current_edge_values, or the tolerance stays unhonoured"
711
+ ),
712
+ )
713
+ )
714
+ continue
715
+
716
+ current_value = current_edge_values[e.quantity]
717
+ if not math.isfinite(current_value): # NaN/inf is never a pass
718
+ out.append(
719
+ Finding(
720
+ severity="not_scored",
721
+ quantity=e.quantity,
722
+ observed=f"current reading {current_value} is not a finite number",
723
+ expected="a finite current reading",
724
+ dependent=", ".join(e.code),
725
+ owner=e.owner,
726
+ action="supply a finite current reading",
727
+ )
728
+ )
729
+ continue
730
+ if e.tolerance.rel_pct is not None:
731
+ allowed = abs(declared_value) * (e.tolerance.rel_pct / 100.0)
732
+ else:
733
+ assert e.tolerance.abs is not None
734
+ try:
735
+ allowed = (
736
+ units.convert(e.tolerance.abs.value, e.tolerance.abs.unit, unit)
737
+ if unit
738
+ else e.tolerance.abs.value
739
+ )
740
+ except (KeyError, ValueError):
741
+ out.append(
742
+ Finding(
743
+ severity="not_scored",
744
+ quantity=e.quantity,
745
+ observed=(
746
+ f"declared unit {unit!r} is not compatible with the "
747
+ f"tolerance's unit {e.tolerance.abs.unit!r}"
748
+ ),
749
+ expected="a tolerance unit convertible to the declared value's unit",
750
+ dependent=", ".join(e.code),
751
+ owner=e.owner,
752
+ action="fix the tolerance's unit or the declared value's unit",
753
+ )
754
+ )
755
+ continue
756
+
757
+ delta = abs(current_value - declared_value)
758
+ if delta > allowed:
759
+ out.append(
760
+ Finding(
761
+ severity="violation",
762
+ quantity=e.quantity,
763
+ observed=(
764
+ f"current {format_magnitude(current_value, unit)} vs "
765
+ f"declared {format_magnitude(declared_value, unit)}"
766
+ ),
767
+ expected=f"within {_edge_tolerance_str(e.tolerance)} of the declared value",
768
+ observed_quantity=Quantity(value=current_value, unit=unit) if unit else None,
769
+ expected_quantity=Quantity(value=declared_value, unit=unit) if unit else None,
770
+ delta=format_magnitude(delta, unit),
771
+ dependent=", ".join(e.code),
772
+ owner=e.owner,
773
+ action=(
774
+ f"re-verify {', '.join(e.code)} against the drifted value, "
775
+ "or update the declared value"
776
+ ),
777
+ )
778
+ )
779
+ elif scores is not None:
780
+ scores.record(e.quantity)
781
+
782
+ return out
783
+
784
+
785
+ # ---------------------------------------------------------------- identities
786
+
787
+
788
+ def check_electrical_segments(c: Contract, *, scores: _ScoreBook) -> list[Finding]:
789
+ """Bound only explicitly sourced logic, one unbuffered I2C segment and CAN wiring."""
790
+ if c.topology is None:
791
+ return []
792
+ out: list[Finding] = []
793
+
794
+ def finding(
795
+ severity: str,
796
+ quantity: str,
797
+ observed: str,
798
+ expected: str,
799
+ provenance: Provenance | None = None,
800
+ ) -> None:
801
+ out.append(
802
+ Finding(
803
+ severity=severity,
804
+ quantity=quantity,
805
+ observed=observed,
806
+ expected=expected,
807
+ source=provenance.source if provenance else None,
808
+ witness=provenance.witness if provenance else None,
809
+ action="supply sourced electrical operands or correct the declared segment",
810
+ )
811
+ )
812
+
813
+ for pair in c.topology.logic_pairs:
814
+ prefix = f"topology/logic_pairs/{pair.id}"
815
+ values = (
816
+ pair.operating_supply_min,
817
+ pair.operating_supply_max,
818
+ pair.voh_min,
819
+ pair.vol_max,
820
+ pair.vih_min,
821
+ pair.vil_max,
822
+ )
823
+ if (
824
+ pair.provenance is None
825
+ or not pair.provenance.source.strip()
826
+ or not pair.provenance.witness.strip()
827
+ or any(value is None for value in values)
828
+ ):
829
+ finding(
830
+ "not_scored",
831
+ prefix,
832
+ "source or operating-envelope logic limit missing",
833
+ "sourced supply and driver/receiver VOH, VOL, VIH, VIL limits",
834
+ )
835
+ continue
836
+ assert all(value is not None for value in values)
837
+ supply_min, supply_max, voh, vol, vih, vil = values
838
+ if (
839
+ any(not math.isfinite(value.base()) for value in values)
840
+ or supply_min.base() <= 0
841
+ or supply_min.base() > supply_max.base()
842
+ ):
843
+ finding(
844
+ "not_scored",
845
+ prefix,
846
+ "invalid operating supply envelope",
847
+ "positive ordered minimum and maximum supply",
848
+ pair.provenance,
849
+ )
850
+ continue
851
+ for name, margin, observed, expected in (
852
+ ("high_margin", voh.base() - vih.base(), voh, vih),
853
+ ("low_margin", vil.base() - vol.base(), vil, vol),
854
+ ):
855
+ quantity = f"{prefix}/{name}"
856
+ scores.record(quantity)
857
+ if margin < 0:
858
+ finding(
859
+ "violation", quantity, f"{margin:g} V margin", ">= 0 V margin", pair.provenance
860
+ )
861
+ else:
862
+ scores.passed(
863
+ quantity,
864
+ str(observed),
865
+ str(expected),
866
+ f"{margin:g} V margin at declared supply envelope",
867
+ provenance=pair.provenance,
868
+ )
869
+
870
+ for bus in c.topology.buses:
871
+ if bus.i2c_segment is not None:
872
+ segment = bus.i2c_segment
873
+ prefix = f"topology/buses/{bus.id}/i2c_segment"
874
+ values = (
875
+ segment.vdd_min_v,
876
+ segment.vdd_max_v,
877
+ segment.pullup_min_ohm,
878
+ segment.pullup_max_ohm,
879
+ segment.capacitance_max_pf,
880
+ segment.rise_time_max_ns,
881
+ segment.sink_current_min_ma,
882
+ segment.vol_max_v,
883
+ )
884
+ if (
885
+ bus.kind != "i2c"
886
+ or segment.unbuffered is not True
887
+ or segment.mode not in {"Standard", "Fast", "FastPlus"}
888
+ or segment.provenance is None
889
+ or not segment.provenance.source.strip()
890
+ or not segment.provenance.witness.strip()
891
+ or any(value is None for value in values)
892
+ ):
893
+ finding(
894
+ "not_scored",
895
+ prefix,
896
+ "buffered, unsupported or missing segment operands",
897
+ "sourced unbuffered Standard/Fast/FastPlus segment with all bounds",
898
+ )
899
+ continue
900
+ assert all(value is not None for value in values)
901
+ vmin, vmax, rmin, rmax, capacitance, rise_max, sink_ma, vol_max = values
902
+ if (
903
+ any(not math.isfinite(value) for value in values)
904
+ or vmin <= 0
905
+ or vmax < vmin
906
+ or rmin <= 0
907
+ or rmax < rmin
908
+ or capacitance < 0
909
+ or rise_max <= 0
910
+ or sink_ma <= 0
911
+ or vol_max < 0
912
+ or vol_max >= vmin
913
+ ):
914
+ finding(
915
+ "not_scored",
916
+ prefix,
917
+ "invalid segment operating envelope",
918
+ "positive ordered sourced limits",
919
+ segment.provenance,
920
+ )
921
+ continue
922
+ rise_ns = 0.8473 * rmax * capacitance / 1000
923
+ mode_max_ns = {"Standard": 1000, "Fast": 300, "FastPlus": 120}[segment.mode]
924
+ mode_max_pf = {"Standard": 400, "Fast": 400, "FastPlus": 550}[segment.mode]
925
+ rise_ceiling_ns = min(rise_max, mode_max_ns)
926
+ required_rmin = (vmax - vol_max) * 1000 / sink_ma
927
+ for name, observed, expected, good in (
928
+ ("rise_time", rise_ns, rise_ceiling_ns, rise_ns <= rise_ceiling_ns),
929
+ ("capacitance", capacitance, mode_max_pf, capacitance <= mode_max_pf),
930
+ ("pullup_sink", rmin, required_rmin, rmin >= required_rmin),
931
+ ):
932
+ quantity = f"{prefix}/{name}"
933
+ scores.record(quantity)
934
+ if good:
935
+ scores.passed(
936
+ quantity,
937
+ f"{observed:g}",
938
+ f"{expected:g}",
939
+ "bounded unbuffered segment only",
940
+ provenance=segment.provenance,
941
+ )
942
+ else:
943
+ finding(
944
+ "violation", quantity, f"{observed:g}", f"{expected:g}", segment.provenance
945
+ )
946
+ if bus.can_segment is not None:
947
+ segment = bus.can_segment
948
+ prefix = f"topology/buses/{bus.id}/can_segment"
949
+ if (
950
+ bus.kind != "can"
951
+ or segment.linear is not True
952
+ or segment.provenance is None
953
+ or not segment.provenance.source.strip()
954
+ or not segment.provenance.witness.strip()
955
+ or len(segment.nodes) < 2
956
+ or any(not node.strip() for node in segment.nodes)
957
+ or len(segment.nodes) != len(set(segment.nodes))
958
+ or len(segment.termination_nodes) != 2
959
+ or len(segment.termination_ohm) != 2
960
+ or segment.cable_impedance_ohm is None
961
+ or segment.impedance_tolerance_ohm is None
962
+ ):
963
+ finding(
964
+ "not_scored",
965
+ prefix,
966
+ "unsourced, nonlinear or incomplete CAN segment",
967
+ "sourced linear ordered nodes and two end terminations",
968
+ )
969
+ continue
970
+ impedance = segment.cable_impedance_ohm
971
+ tolerance = segment.impedance_tolerance_ohm
972
+ if (
973
+ any(
974
+ not math.isfinite(value)
975
+ for value in (impedance, tolerance, *segment.termination_ohm)
976
+ )
977
+ or impedance <= 0
978
+ or tolerance < 0
979
+ or any(value <= 0 for value in segment.termination_ohm)
980
+ ):
981
+ finding(
982
+ "not_scored",
983
+ prefix,
984
+ "invalid impedance envelope",
985
+ "positive cable/termination resistance and nonnegative tolerance",
986
+ segment.provenance,
987
+ )
988
+ continue
989
+ quantity = f"{prefix}/termination"
990
+ scores.record(quantity)
991
+ ends = [segment.nodes[0], segment.nodes[-1]]
992
+ good = segment.termination_nodes == ends and all(
993
+ abs(value - impedance) <= tolerance for value in segment.termination_ohm
994
+ )
995
+ if good:
996
+ scores.passed(
997
+ quantity,
998
+ f"two ends at {segment.termination_ohm} ohm",
999
+ f"{impedance:g} +/- {tolerance:g} ohm at {ends}",
1000
+ "linear termination only; CAN timing unscored",
1001
+ provenance=segment.provenance,
1002
+ )
1003
+ else:
1004
+ finding(
1005
+ "violation",
1006
+ quantity,
1007
+ f"terminations {segment.termination_nodes} at {segment.termination_ohm} ohm",
1008
+ f"ends {ends} at {impedance:g} +/- {tolerance:g} ohm",
1009
+ segment.provenance,
1010
+ )
1011
+ finding(
1012
+ "not_scored",
1013
+ f"{prefix}/timing",
1014
+ "CAN timing not evaluated",
1015
+ "sourced propagation and transceiver timing evidence",
1016
+ segment.provenance,
1017
+ )
1018
+ return out
1019
+
1020
+
1021
+ def check_bus_identities(c: Contract) -> list[Finding]:
1022
+ """Bus-local IDs must be unique, and duplicate serials cannot prove instance
1023
+ identity. These checks prevent addressing collisions and clone USB devices
1024
+ from being mistaken for distinct physical units."""
1025
+ out: list[Finding] = []
1026
+ devices_with_identity = [device for device in c.devices if device.identity is not None]
1027
+
1028
+ for field in ("can_id", "address"):
1029
+ groups: dict[tuple[str, str], list] = {}
1030
+ for device in devices_with_identity:
1031
+ value = getattr(device.identity, field)
1032
+ if value is not None:
1033
+ groups.setdefault((device.identity.bus_ref, value), []).append(device)
1034
+ for (bus_ref, value), devices in groups.items():
1035
+ if len(devices) < 2:
1036
+ continue
1037
+ ids = ", ".join(device.id for device in devices)
1038
+ out.append(
1039
+ Finding(
1040
+ severity="violation",
1041
+ quantity=f"topology/buses/{bus_ref}/identity/{field}",
1042
+ observed=f"value {value!r} shared by {len(devices)} devices: {ids}",
1043
+ expected=f"one device per {field} on a bus",
1044
+ dependent=ids,
1045
+ owner=", ".join(sorted({device.owner for device in devices})),
1046
+ action=f"assign unique {field} values or correct the bus wiring record",
1047
+ )
1048
+ )
1049
+
1050
+ serials: dict[str, list] = {}
1051
+ for device in devices_with_identity:
1052
+ if device.identity.serial_number is not None:
1053
+ serials.setdefault(device.identity.serial_number, []).append(device)
1054
+ for serial, devices in serials.items():
1055
+ if len(devices) < 2:
1056
+ continue
1057
+ for device in devices:
1058
+ if device.identity.effective_strength() == "instance":
1059
+ device.identity.strength = "model"
1060
+ ids = ", ".join(device.id for device in devices)
1061
+ out.append(
1062
+ Finding(
1063
+ severity="warning",
1064
+ quantity="devices/identity/serial_number",
1065
+ observed=f"serial {serial!r} duplicated on {ids}",
1066
+ expected="a unique per-unit serial for instance-strength identity",
1067
+ delta="identity strength downgraded to model",
1068
+ dependent=ids,
1069
+ owner=", ".join(sorted({device.owner for device in devices})),
1070
+ action="replace clone serials or add a genuinely per-unit identity descriptor",
1071
+ )
1072
+ )
1073
+
1074
+ return out
1075
+
1076
+
1077
+ # ---------------------------------------------------------------- rail topology
1078
+
1079
+
1080
+ def check_rail_topology(c: Contract, *, scores: _ScoreBook | None = None) -> list[Finding]:
1081
+ """Rail ancestry must be acyclic and voltage cannot rise through an
1082
+ unmodeled converter. Otherwise rollups can hang or bless a wiring typo."""
1083
+ if c.topology is None:
1084
+ return []
1085
+
1086
+ out: list[Finding] = []
1087
+ reported_cycles: set[frozenset[str]] = set()
1088
+ for start in c.topology.rails:
1089
+ path: list[str] = []
1090
+ positions: dict[str, int] = {}
1091
+ current = start
1092
+ while True:
1093
+ if current.id in positions:
1094
+ cycle = path[positions[current.id] :] + [current.id]
1095
+ key = frozenset(cycle)
1096
+ if key not in reported_cycles:
1097
+ reported_cycles.add(key)
1098
+ out.append(
1099
+ Finding(
1100
+ severity="violation",
1101
+ quantity=f"topology/rails/{current.id}/upstream",
1102
+ observed=" -> ".join(cycle),
1103
+ expected="an acyclic rail path ending at battery or no upstream",
1104
+ dependent=", ".join(cycle[:-1]),
1105
+ action="break the rail upstream cycle so power rollup terminates",
1106
+ )
1107
+ )
1108
+ break
1109
+ positions[current.id] = len(path)
1110
+ path.append(current.id)
1111
+ if current.upstream in (None, "battery"):
1112
+ break
1113
+ parent = c.rail(current.upstream)
1114
+ if parent is None:
1115
+ break # reference integrity reports the missing rail
1116
+ current = parent
1117
+
1118
+ for rail in c.topology.rails:
1119
+ if rail.voltage is None:
1120
+ # no declared voltage means no upstream/derived comparison is
1121
+ # possible — never treat it as 0 V.
1122
+ if rail.voltage_range is not None:
1123
+ observed = f"{rail.id} declares a voltage range but no nominal voltage"
1124
+ expected = "a declared nominal rail voltage for the upstream comparison"
1125
+ else:
1126
+ observed = f"{rail.id} has no declared voltage"
1127
+ expected = "a declared rail voltage"
1128
+ out.append(
1129
+ Finding(
1130
+ severity="not_scored",
1131
+ quantity=f"topology/rails/{rail.id}/voltage",
1132
+ observed=observed,
1133
+ expected=expected,
1134
+ action=(
1135
+ "declare the rail's voltage, or accept upstream/derived voltage "
1136
+ "checks as not scored"
1137
+ ),
1138
+ )
1139
+ )
1140
+ continue
1141
+ upstream_voltage = None
1142
+ upstream_name = rail.upstream
1143
+ if rail.upstream == "battery" and c.topology.battery is not None:
1144
+ upstream_voltage = c.topology.battery.voltage
1145
+ elif rail.upstream is not None:
1146
+ parent = c.rail(rail.upstream)
1147
+ if parent is not None:
1148
+ upstream_voltage = parent.voltage
1149
+ if upstream_voltage is None:
1150
+ continue
1151
+ child_v = rail.voltage.base()
1152
+ parent_v = upstream_voltage.base()
1153
+
1154
+ # A rail declaring a charging source is not bounded by its battery: an
1155
+ # alternator holds the bus ABOVE the pack in order to charge it.
1156
+ # Such a rail is scored against the charging source's rated output
1157
+ # instead — still two independently authored records.
1158
+ charge_rating: Band | None = None
1159
+ charger = c.device(rail.charged_by) if rail.charged_by is not None else None
1160
+ if charger is not None:
1161
+ charger_model = c.device_model(charger.model)
1162
+ if charger_model is not None:
1163
+ charge_rating = charger_model.hard_limits.get("regulated_voltage")
1164
+
1165
+ if rail.charged_by is not None:
1166
+ if charger is None:
1167
+ continue # reference integrity reports the dangling charged_by
1168
+ if charge_rating is None:
1169
+ out.append(
1170
+ Finding(
1171
+ severity="not_scored",
1172
+ quantity=f"topology/rails/{rail.id}/voltage",
1173
+ observed=(
1174
+ f"charged by {rail.charged_by!r}, which declares no "
1175
+ f"regulated_voltage rating"
1176
+ ),
1177
+ expected="a regulated_voltage hard limit on the charging device's model",
1178
+ dependent=rail.charged_by,
1179
+ action=(
1180
+ f"record the rated output of {rail.charged_by!r} as a "
1181
+ f"'regulated_voltage' hard limit so the rail can be scored"
1182
+ ),
1183
+ )
1184
+ )
1185
+ continue
1186
+ lo = units.to_base(charge_rating.lo, charge_rating.unit)
1187
+ hi = units.to_base(charge_rating.hi, charge_rating.unit)
1188
+ if scores is not None:
1189
+ scores.record(f"rails/{rail.id}")
1190
+ if not (lo <= child_v <= hi):
1191
+ out.append(
1192
+ Finding(
1193
+ severity="violation",
1194
+ quantity=f"topology/rails/{rail.id}/voltage",
1195
+ observed=format_magnitude(child_v, "V"),
1196
+ expected=(
1197
+ f"within {format_magnitude(lo, 'V')}..{format_magnitude(hi, 'V')} "
1198
+ f"rated output of {rail.charged_by}"
1199
+ ),
1200
+ observed_quantity=Quantity(value=child_v, unit="V"),
1201
+ delta=f"{'+' if child_v > hi else '-'}"
1202
+ f"{format_magnitude(abs(child_v - (hi if child_v > hi else lo)), 'V')}",
1203
+ dependent=rail.charged_by,
1204
+ action=(
1205
+ "fix the rail voltage record or the charging source's rated output"
1206
+ ),
1207
+ )
1208
+ )
1209
+ elif scores is not None:
1210
+ headroom_v = min(child_v - lo, hi - child_v)
1211
+ scores.passed(
1212
+ f"topology/rails/{rail.id}/voltage",
1213
+ observed=format_magnitude(child_v, "V"),
1214
+ expected=f"within {format_magnitude(lo, 'V')}..{format_magnitude(hi, 'V')}",
1215
+ margin=(
1216
+ f"{format_magnitude(headroom_v, 'V')} inside the rated output "
1217
+ f"of {rail.charged_by}"
1218
+ ),
1219
+ )
1220
+ continue
1221
+
1222
+ # Both rail voltage and upstream voltage populated — the two-source comparison.
1223
+ if scores is not None:
1224
+ scores.record(f"rails/{rail.id}")
1225
+ efficiency_warning = (
1226
+ not math.isclose(child_v, parent_v, rel_tol=1e-9)
1227
+ and "efficiency" not in rail.model_fields_set
1228
+ )
1229
+ if efficiency_warning:
1230
+ out.append(
1231
+ Finding(
1232
+ severity="warning",
1233
+ quantity=f"topology/rails/{rail.id}/efficiency",
1234
+ observed="default 1.0 on a voltage-converting rail",
1235
+ expected="an explicitly sourced converter efficiency",
1236
+ dependent=upstream_name,
1237
+ action="record the converter efficiency so parent load is not understated",
1238
+ )
1239
+ )
1240
+ if child_v > parent_v:
1241
+ out.append(
1242
+ Finding(
1243
+ severity="violation",
1244
+ quantity=f"topology/rails/{rail.id}/voltage",
1245
+ observed=format_magnitude(child_v, "V"),
1246
+ expected=f"<= {format_magnitude(parent_v, 'V')} from {upstream_name}",
1247
+ observed_quantity=Quantity(value=child_v, unit="V"),
1248
+ expected_quantity=Quantity(value=parent_v, unit="V"),
1249
+ delta=f"+{format_magnitude(child_v - parent_v, 'V')}",
1250
+ dependent=upstream_name,
1251
+ action=(
1252
+ "fix the voltage record or model the missing step-up converter explicitly"
1253
+ ),
1254
+ )
1255
+ )
1256
+ elif scores is not None and not efficiency_warning:
1257
+ # Only when nothing else fired for this scored unit (identity):
1258
+ # an efficiency-default warning on the SAME comparison would otherwise
1259
+ # double-count against len(scored_records)+violations+warnings==scored.
1260
+ headroom_v = parent_v - child_v
1261
+ scores.passed(
1262
+ f"topology/rails/{rail.id}/voltage",
1263
+ observed=format_magnitude(child_v, "V"),
1264
+ expected=f"<= {format_magnitude(parent_v, 'V')} from {upstream_name}",
1265
+ margin=f"{format_magnitude(headroom_v, 'V')} below {upstream_name}",
1266
+ )
1267
+
1268
+ return out
1269
+
1270
+
1271
+ # ---------------------------------------------------------------- budgets
1272
+
1273
+
1274
+ _Matrix3 = tuple[
1275
+ tuple[float, float, float],
1276
+ tuple[float, float, float],
1277
+ tuple[float, float, float],
1278
+ ]
1279
+ _Transform = tuple[_Matrix3, tuple[float, float, float]]
1280
+ _IDENTITY: _Matrix3 = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))
1281
+
1282
+
1283
+ def _matmul(left: _Matrix3, right: _Matrix3) -> _Matrix3:
1284
+ """Compose rotations explicitly so CG needs no robotics/math dependency."""
1285
+ return tuple(
1286
+ tuple(sum(left[row][k] * right[k][col] for k in range(3)) for col in range(3))
1287
+ for row in range(3)
1288
+ ) # type: ignore[return-value]
1289
+
1290
+
1291
+ def _matvec(matrix: _Matrix3, vector: tuple[float, float, float]) -> tuple[float, float, float]:
1292
+ """Apply a recorded frame rotation to a point or translation."""
1293
+ return tuple(sum(matrix[row][col] * vector[col] for col in range(3)) for row in range(3)) # type: ignore[return-value]
1294
+
1295
+
1296
+ def _add(
1297
+ left: tuple[float, float, float], right: tuple[float, float, float]
1298
+ ) -> tuple[float, float, float]:
1299
+ """Keep vector arithmetic named at the frame-composition call sites."""
1300
+ return tuple(left[i] + right[i] for i in range(3)) # type: ignore[return-value]
1301
+
1302
+
1303
+ def _rpy_rotation(rpy: tuple[float, float, float]) -> _Matrix3:
1304
+ """Convert URDF fixed-joint roll/pitch/yaw to its parent-frame rotation."""
1305
+ roll, pitch, yaw = rpy
1306
+ cr, sr = math.cos(roll), math.sin(roll)
1307
+ cp, sp = math.cos(pitch), math.sin(pitch)
1308
+ cy, sy = math.cos(yaw), math.sin(yaw)
1309
+ return (
1310
+ (cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr),
1311
+ (sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr),
1312
+ (-sp, cp * sr, cp * cr),
1313
+ )
1314
+
1315
+
1316
+ def _fixed_frame_transforms(
1317
+ c: Contract,
1318
+ ) -> tuple[str | None, dict[str, _Transform], list[str]]:
1319
+ """Resolve only configuration-independent paths to a single model root.
1320
+
1321
+ An articulated joint intentionally stops traversal: using its recorded
1322
+ origin as a reference configuration would manufacture a configuration that
1323
+ the contract does not declare.
1324
+ """
1325
+ if c.model is None:
1326
+ return None, {}, ["no model is recorded, so mount frames cannot be composed"]
1327
+
1328
+ links = {link.name for link in c.model.links}
1329
+ child_counts: dict[str, int] = {}
1330
+ joints_by_child = {}
1331
+ for joint in c.model.joints:
1332
+ child_counts[joint.child] = child_counts.get(joint.child, 0) + 1
1333
+ joints_by_child[joint.child] = joint
1334
+ roots = sorted(links - set(joints_by_child))
1335
+ if len(roots) != 1:
1336
+ return (
1337
+ None,
1338
+ {},
1339
+ [f"model has {len(roots)} roots; no single frame exists for a system CG"],
1340
+ )
1341
+
1342
+ root = roots[0]
1343
+ transforms: dict[str, _Transform] = {root: (_IDENTITY, (0.0, 0.0, 0.0))}
1344
+ resolving: set[str] = set()
1345
+
1346
+ def resolve(frame: str) -> _Transform | None:
1347
+ if frame in transforms:
1348
+ return transforms[frame]
1349
+ joint = joints_by_child.get(frame)
1350
+ if (
1351
+ joint is None
1352
+ or joint.type != "fixed"
1353
+ or child_counts.get(frame) != 1
1354
+ or frame in resolving
1355
+ ):
1356
+ return None
1357
+ resolving.add(frame)
1358
+ parent = resolve(joint.parent)
1359
+ resolving.discard(frame)
1360
+ if parent is None:
1361
+ return None
1362
+ parent_rotation, parent_translation = parent
1363
+ joint_rotation = _rpy_rotation(joint.origin.rpy)
1364
+ transform = (
1365
+ _matmul(parent_rotation, joint_rotation),
1366
+ _add(parent_translation, _matvec(parent_rotation, joint.origin.xyz)),
1367
+ )
1368
+ transforms[frame] = transform
1369
+ return transform
1370
+
1371
+ for link in links:
1372
+ resolve(link)
1373
+ limitations = []
1374
+ if any(joint.type != "fixed" for joint in c.model.joints):
1375
+ limitations.append(
1376
+ "articulated joint paths are configuration-dependent and are not composed "
1377
+ "without a recorded reference configuration"
1378
+ )
1379
+ return root, transforms, limitations
1380
+
1381
+
1382
+ def _point_in_root(
1383
+ frame: str,
1384
+ point: tuple[float, float, float],
1385
+ transforms: dict[str, _Transform],
1386
+ ) -> tuple[float, float, float] | None:
1387
+ """Place a local point in the root only when its rigid transform is known."""
1388
+ transform = transforms.get(frame)
1389
+ if transform is None:
1390
+ return None
1391
+ rotation, translation = transform
1392
+ return _add(translation, _matvec(rotation, point))
1393
+
1394
+
1395
+ def compute_system_cg(c: Contract) -> list[CGReport]:
1396
+ """Compute severity-free CG reports using each mass budget's source policy.
1397
+
1398
+ Reusing ``Budget.sources`` makes inclusion (and intentional double counting)
1399
+ exactly match the established mass-total convention. A device without a
1400
+ recorded ``Physical.mount_xyz`` remains honestly unpositioned.
1401
+ """
1402
+ root, transforms, frame_limitations = _fixed_frame_transforms(c)
1403
+ reports: list[CGReport] = []
1404
+
1405
+ for budget in c.budgets:
1406
+ if budget.kind != "mass_total":
1407
+ continue
1408
+ contributors: list[CGContributor] = []
1409
+ limitations = list(frame_limitations)
1410
+
1411
+ if budget.sources in ("devices", "links+devices"):
1412
+ mounted_devices = [device for device in c.devices if device.physical is not None]
1413
+ for device in mounted_devices:
1414
+ physical = device.physical
1415
+ assert physical is not None
1416
+ if physical.mass is None:
1417
+ # undeclared mass is a coverage gap, never a 0 kg contributor.
1418
+ limitations.append(f"device {device.id!r} has no declared mass")
1419
+ continue
1420
+ mass = physical.mass.base()
1421
+ if physical.mount_frame is None:
1422
+ # a mass with no mount frame still counts toward the
1423
+ # total (it is a mass) but is excluded from CG, disclosed.
1424
+ position = None
1425
+ limitations.append(
1426
+ f"device {device.id!r} has a mass but no mount frame — CG excludes it"
1427
+ )
1428
+ elif physical.mount_xyz is None:
1429
+ position = None
1430
+ limitations.append(
1431
+ f"device {device.id!r} has no mount_xyz offset in {physical.mount_frame!r}"
1432
+ )
1433
+ else:
1434
+ offset_m = tuple(component.base() for component in physical.mount_xyz)
1435
+ position = _point_in_root(physical.mount_frame, offset_m, transforms)
1436
+ moment = (
1437
+ tuple(mass * coordinate for coordinate in position)
1438
+ if position is not None
1439
+ else None
1440
+ )
1441
+ contributors.append(
1442
+ CGContributor(
1443
+ quantity=f"devices/{device.id}/physical/mass",
1444
+ mass_kg=mass,
1445
+ position_m=position,
1446
+ moment_kgm=moment,
1447
+ )
1448
+ )
1449
+
1450
+ if budget.sources in ("links", "links+devices") and c.model is not None:
1451
+ for link in c.model.links:
1452
+ if link.mass is None:
1453
+ continue
1454
+ mass = link.mass.base()
1455
+ position = None
1456
+ if link.com is None:
1457
+ limitations.append(f"link {link.name!r} has mass but no COM")
1458
+ else:
1459
+ position = _point_in_root(link.frame or link.name, link.com, transforms)
1460
+ moment = (
1461
+ tuple(mass * coordinate for coordinate in position)
1462
+ if position is not None
1463
+ else None
1464
+ )
1465
+ contributors.append(
1466
+ CGContributor(
1467
+ quantity=f"model/links/{link.name}/mass",
1468
+ mass_kg=mass,
1469
+ position_m=position,
1470
+ moment_kgm=moment,
1471
+ )
1472
+ )
1473
+
1474
+ total_mass = sum(item.mass_kg for item in contributors)
1475
+ positioned = [item for item in contributors if item.position_m is not None]
1476
+ positioned_mass = sum(item.mass_kg for item in positioned)
1477
+ cg = None
1478
+ if positioned_mass > 0.0:
1479
+ cg = tuple(
1480
+ sum((item.moment_kgm or (0.0, 0.0, 0.0))[axis] for item in positioned)
1481
+ / positioned_mass
1482
+ for axis in range(3)
1483
+ )
1484
+ complete = bool(contributors) and len(positioned) == len(contributors)
1485
+ if not contributors:
1486
+ limitations.append("the selected source policy has no mass contributors")
1487
+ elif not complete:
1488
+ limitations.append(
1489
+ "system CG is incomplete because one or more contributor positions "
1490
+ "cannot be resolved"
1491
+ )
1492
+
1493
+ def dominance(item: CGContributor) -> tuple[bool, float, float]:
1494
+ moment = item.moment_kgm
1495
+ magnitude = (
1496
+ math.sqrt(sum(component * component for component in moment))
1497
+ if moment is not None
1498
+ else 0.0
1499
+ )
1500
+ return item.position_m is None, magnitude, item.mass_kg
1501
+
1502
+ contributors.sort(key=dominance, reverse=True)
1503
+ reports.append(
1504
+ CGReport(
1505
+ quantity=f"budgets/{budget.id}/cg",
1506
+ sources=budget.sources,
1507
+ frame=root,
1508
+ cg_m=cg,
1509
+ complete=complete,
1510
+ total_mass_kg=total_mass,
1511
+ positioned_mass_kg=positioned_mass,
1512
+ contributors=contributors,
1513
+ limitation="; ".join(dict.fromkeys(limitations)) or None,
1514
+ budget_limit_kg=budget.limit.base(),
1515
+ )
1516
+ )
1517
+
1518
+ return reports
1519
+
1520
+
1521
+ def check_voltage_compatibility(c: Contract, *, scores: _ScoreBook | None = None) -> list[Finding]:
1522
+ """Refuse a device supplied outside its model's declared input range.
1523
+
1524
+ The connection is the same direct device ``power.rail`` edge consumed by
1525
+ power budgets. Reference integrity owns missing models and rails; an absent
1526
+ input range is an optional declaration and intentionally produces nothing.
1527
+ The comparison is inclusive: a rail voltage exactly equal to a declared min or max is compatible — a datasheet min/max includes its endpoints.
1528
+ """
1529
+ out: list[Finding] = []
1530
+ for device in c.devices:
1531
+ if device.power is None:
1532
+ continue
1533
+ model = c.device_model(device.model)
1534
+ rail = c.rail(device.power.rail)
1535
+ if model is None or rail is None or model.input_voltage_range is None:
1536
+ continue
1537
+ if rail.voltage_range is not None:
1538
+ # the rail publishes a whole range — compatible iff it is
1539
+ # fully inside the device's declared input range (inclusive).
1540
+ if scores is not None:
1541
+ scores.record(f"devices/{device.id}/power/rail")
1542
+ rail_min_v = rail.voltage_range.min.base()
1543
+ rail_max_v = rail.voltage_range.max.base()
1544
+ dev_min_v = model.input_voltage_range.min.base()
1545
+ dev_max_v = model.input_voltage_range.max.base()
1546
+ rail_range_str = (
1547
+ f"[{format_magnitude(rail_min_v, 'V')}, {format_magnitude(rail_max_v, 'V')}]"
1548
+ )
1549
+ dev_range_str = (
1550
+ f"[{format_magnitude(dev_min_v, 'V')}, {format_magnitude(dev_max_v, 'V')}]"
1551
+ )
1552
+ if rail_min_v >= dev_min_v and rail_max_v <= dev_max_v:
1553
+ if scores is not None:
1554
+ headroom_v = min(rail_min_v - dev_min_v, dev_max_v - rail_max_v)
1555
+ scores.passed(
1556
+ f"devices/{device.id}/power/rail",
1557
+ observed=rail_range_str,
1558
+ expected=_with_enforcement_provenance(
1559
+ dev_range_str,
1560
+ ("min", model.input_voltage_range.min_enforced_by),
1561
+ ("max", model.input_voltage_range.max_enforced_by),
1562
+ ),
1563
+ margin=f"{format_magnitude(headroom_v, 'V')} inside the {dev_range_str} window",
1564
+ )
1565
+ continue
1566
+
1567
+ sources = []
1568
+ if rail.provenance is not None:
1569
+ sources.append(f"rail {rail.id} voltage_range: {rail.provenance.source}")
1570
+ if model.provenance is not None:
1571
+ sources.append(f"device model {model.id} input range: {model.provenance.source}")
1572
+ if rail_min_v < dev_min_v:
1573
+ delta = f"-{format_magnitude(dev_min_v - rail_min_v, 'V')}"
1574
+ else:
1575
+ delta = f"+{format_magnitude(rail_max_v - dev_max_v, 'V')}"
1576
+ out.append(
1577
+ Finding(
1578
+ severity="violation",
1579
+ quantity=f"devices/{device.id}/power/rail",
1580
+ observed=(
1581
+ f"rail {rail.id!r} publishes {rail_range_str} to device {device.id!r}"
1582
+ ),
1583
+ expected=_with_enforcement_provenance(
1584
+ f"device model {model.id!r} input range {dev_range_str}",
1585
+ ("min", model.input_voltage_range.min_enforced_by),
1586
+ ("max", model.input_voltage_range.max_enforced_by),
1587
+ ),
1588
+ delta=delta,
1589
+ dependent=(
1590
+ f"topology/rails/{rail.id}/voltage_range, "
1591
+ f"device_models/{model.id}/input_voltage_range"
1592
+ ),
1593
+ owner=device.owner,
1594
+ source="; ".join(sources) or None,
1595
+ action=(
1596
+ "supply the device from a compatible rail or correct the sourced "
1597
+ "rail voltage_range and device input range"
1598
+ ),
1599
+ )
1600
+ )
1601
+ continue
1602
+ if rail.voltage is None:
1603
+ out.append(
1604
+ Finding(
1605
+ severity="not_scored",
1606
+ quantity=f"devices/{device.id}/power/rail",
1607
+ observed=f"{rail.id} has no declared voltage",
1608
+ expected="a declared rail voltage",
1609
+ owner=device.owner,
1610
+ action=(
1611
+ "declare the rail's voltage, or accept voltage compatibility as not scored"
1612
+ ),
1613
+ )
1614
+ )
1615
+ continue
1616
+
1617
+ # Rail voltage vs datasheet input range — both sides present.
1618
+ if scores is not None:
1619
+ scores.record(f"devices/{device.id}/power/rail")
1620
+ rail_v = rail.voltage.base()
1621
+ minimum_v = model.input_voltage_range.min.base()
1622
+ maximum_v = model.input_voltage_range.max.base()
1623
+ if minimum_v <= rail_v <= maximum_v:
1624
+ if scores is not None:
1625
+ headroom_v = min(rail_v - minimum_v, maximum_v - rail_v)
1626
+ scores.passed(
1627
+ f"devices/{device.id}/power/rail",
1628
+ observed=format_magnitude(rail_v, "V"),
1629
+ expected=_with_enforcement_provenance(
1630
+ f"[{format_magnitude(minimum_v, 'V')}, {format_magnitude(maximum_v, 'V')}]",
1631
+ ("min", model.input_voltage_range.min_enforced_by),
1632
+ ("max", model.input_voltage_range.max_enforced_by),
1633
+ ),
1634
+ margin=(
1635
+ f"{format_magnitude(headroom_v, 'V')} inside the "
1636
+ f"{format_magnitude(minimum_v, 'V')}-{format_magnitude(maximum_v, 'V')} window"
1637
+ ),
1638
+ )
1639
+ continue
1640
+
1641
+ sources: list[str] = []
1642
+ if rail.provenance is not None:
1643
+ sources.append(f"rail {rail.id} voltage: {rail.provenance.source}")
1644
+ if model.provenance is not None:
1645
+ sources.append(f"device model {model.id} input range: {model.provenance.source}")
1646
+ if rail_v < minimum_v:
1647
+ delta = f"-{format_magnitude(minimum_v - rail_v, 'V')}"
1648
+ else:
1649
+ delta = f"+{format_magnitude(rail_v - maximum_v, 'V')}"
1650
+
1651
+ out.append(
1652
+ Finding(
1653
+ severity="violation",
1654
+ quantity=f"devices/{device.id}/power/rail",
1655
+ observed=(
1656
+ f"rail {rail.id!r} supplies {format_magnitude(rail_v, 'V')} "
1657
+ f"to device {device.id!r}"
1658
+ ),
1659
+ expected=_with_enforcement_provenance(
1660
+ (
1661
+ f"device model {model.id!r} input range "
1662
+ f"[{format_magnitude(minimum_v, 'V')}, {format_magnitude(maximum_v, 'V')}]"
1663
+ ),
1664
+ ("min", model.input_voltage_range.min_enforced_by),
1665
+ ("max", model.input_voltage_range.max_enforced_by),
1666
+ ),
1667
+ observed_quantity=Quantity(value=rail_v, unit="V"),
1668
+ delta=delta,
1669
+ dependent=(
1670
+ f"topology/rails/{rail.id}/voltage, "
1671
+ f"device_models/{model.id}/input_voltage_range"
1672
+ ),
1673
+ owner=device.owner,
1674
+ source="; ".join(sources) or None,
1675
+ action=(
1676
+ "supply the device from a compatible rail or correct the sourced "
1677
+ "rail voltage and device input range"
1678
+ ),
1679
+ )
1680
+ )
1681
+ return out
1682
+
1683
+
1684
+ def power_rollup(
1685
+ c: Contract, draw_mode: str
1686
+ ) -> tuple[dict[str, float], dict[str, set[str]], float, set[str], dict[str, set[str]], set[str]]:
1687
+ """Roll the selected draw mode toward the battery through each converter.
1688
+
1689
+ ``draw`` is the nominal/default value. A named mode overrides it when
1690
+ present; devices with no such override retain their compatible single draw.
1691
+
1692
+ A device with no declared ``draw`` (and, by the schema validator, no
1693
+ modes either — modes require a fallback draw) is an UNKNOWN consumer: it
1694
+ is never summed as 0 W. Instead its id is recorded, keyed by every rail on
1695
+ its path to the battery, in the returned ``rail_unknown``/``battery_unknown``
1696
+ — a caller that depends on any of those totals must not_score itself
1697
+ rather than silently sum around the gap.
1698
+
1699
+ Decision: a budget checking nominal stays SILENT even when the same tree would breach at peak - the contract explicitly selected its mode, and no near-miss warning policy exists. The narrower future enhancement, if ever wanted: warn only when draw_mode is non-nominal and zero devices declare any named mode. Deliberate, disclosed asymmetry: budgets score the contract's selected draw mode, while check_ampacity scores the worst declared mode. A budget is a design target the author owns; ampacity is a fire limit that holds no matter which mode the author had in mind.
1700
+ """
1701
+ rail_totals: dict[str, float] = {}
1702
+ rail_witnesses: dict[str, set[str]] = {}
1703
+ rail_unknown: dict[str, set[str]] = {}
1704
+ battery_w = 0.0
1705
+ battery_witnesses: set[str] = set()
1706
+ battery_unknown: set[str] = set()
1707
+
1708
+ for device in c.devices:
1709
+ if device.power is None:
1710
+ continue
1711
+ rail = c.rail(device.power.rail)
1712
+ if rail is None:
1713
+ continue
1714
+ selected = next(
1715
+ (mode.draw for mode in device.power.modes if mode.name == draw_mode),
1716
+ device.power.draw,
1717
+ )
1718
+ if selected is None:
1719
+ rail_unknown.setdefault(rail.id, set()).add(device.id)
1720
+ seen = {rail.id}
1721
+ current = rail
1722
+ while True:
1723
+ if current.upstream in (None, "battery"):
1724
+ battery_unknown.add(device.id)
1725
+ break
1726
+ parent = c.rail(current.upstream)
1727
+ if parent is None or parent.id in seen:
1728
+ break
1729
+ rail_unknown.setdefault(parent.id, set()).add(device.id)
1730
+ seen.add(parent.id)
1731
+ current = parent
1732
+ continue
1733
+ load_w = selected.base()
1734
+ rail_totals[rail.id] = rail_totals.get(rail.id, 0.0) + load_w
1735
+ rail_witnesses.setdefault(rail.id, set()).add(device.id)
1736
+ seen = {rail.id}
1737
+ current = rail
1738
+ while True:
1739
+ if current.upstream == "battery":
1740
+ battery_w += load_w / current.efficiency
1741
+ battery_witnesses.add(device.id)
1742
+ break
1743
+ if current.upstream is None:
1744
+ battery_w += load_w
1745
+ battery_witnesses.add(device.id)
1746
+ break
1747
+ parent = c.rail(current.upstream)
1748
+ if parent is None or parent.id in seen:
1749
+ break
1750
+ load_w /= current.efficiency
1751
+ rail_totals[parent.id] = rail_totals.get(parent.id, 0.0) + load_w
1752
+ rail_witnesses.setdefault(parent.id, set()).add(device.id)
1753
+ seen.add(parent.id)
1754
+ current = parent
1755
+
1756
+ return rail_totals, rail_witnesses, battery_w, battery_witnesses, rail_unknown, battery_unknown
1757
+
1758
+
1759
+ #: Retained so nothing outside this module has to rename its call sites.
1760
+ _power_rollup = power_rollup
1761
+
1762
+
1763
+ def _format_hours(hours: float) -> str:
1764
+ """Display a duration in the unit a human reads fastest: minutes under an
1765
+ hour, hours at or above it. Display only — the contract and schema stay in
1766
+ hours; only these rendered strings change."""
1767
+ if abs(hours) < 1.0:
1768
+ return f"{hours * 60:.1f} min"
1769
+ return f"{hours:.2f} h"
1770
+
1771
+
1772
+ def unusable_effort_finding(quantity: str, effort: float, **extra: Any) -> Finding:
1773
+ """Warning for a joint effort that is set but not a usable limit (0 = unset, <0 = invalid)."""
1774
+ label = "unset (0)" if effort == 0 else f"invalid (negative) {effort:g}"
1775
+ return Finding(
1776
+ severity="warning",
1777
+ quantity=quantity,
1778
+ observed=f"joint torque limit is {label} in the model; torque is not checked",
1779
+ expected="a positive joint effort limit",
1780
+ action="set the joint effort limit from the datasheet",
1781
+ **extra,
1782
+ )
1783
+
1784
+
1785
+ def _with_enforcement_provenance(expected: str, *limits: tuple[str, str | None]) -> str:
1786
+ """Annotate an existing checked limit without changing check scoring."""
1787
+ declarations = [f"{value} ({name})" for name, value in limits if value is not None]
1788
+ if not declarations:
1789
+ return expected
1790
+ return (
1791
+ f"{expected} (declared enforcement provenance: {', '.join(declarations)}; "
1792
+ "metadata only; does not prove physical protection)"
1793
+ )
1794
+
1795
+
1796
+ def check_budgets(c: Contract, *, scores: _ScoreBook | None = None) -> list[Finding]:
1797
+ """Budgets are derived entries: current value = sum over witnesses, here the
1798
+ contract's own device and model entries. Power follows the efficiency-aware
1799
+ rail tree so one child load constrains its converter, ancestors, and endurance."""
1800
+ out: list[Finding] = []
1801
+ for b in c.budgets:
1802
+ limit = b.limit.base()
1803
+ if limit <= 0.0:
1804
+ out.append(
1805
+ Finding(
1806
+ severity="violation",
1807
+ quantity=f"budgets/{b.id}/limit",
1808
+ observed=str(b.limit),
1809
+ observed_quantity=b.limit,
1810
+ expected=_with_enforcement_provenance(
1811
+ "a positive budget limit", ("limit", b.limit_enforced_by)
1812
+ ),
1813
+ owner=b.owner,
1814
+ source=b.provenance.source if b.provenance is not None else None,
1815
+ witness=b.provenance.witness if b.provenance is not None else None,
1816
+ locked_at=b.provenance.locked_at if b.provenance is not None else None,
1817
+ action="replace the non-positive limit with a sourced physical constraint",
1818
+ )
1819
+ )
1820
+ continue
1821
+
1822
+ if b.kind == "rail_power":
1823
+ rail_totals, rail_witnesses, _, _, rail_unknown, _ = _power_rollup(c, b.draw_mode)
1824
+ contributors = sorted(rail_witnesses.get(b.rail or "", set()))
1825
+ unknown_consumers = sorted(rail_unknown.get(b.rail or "", set()))
1826
+ has_named_modes = any(c.device(device_id).power.modes for device_id in contributors)
1827
+ total_w = rail_totals.get(b.rail or "", 0.0)
1828
+ limit_w = limit
1829
+ rail = c.rail(b.rail) if b.rail is not None else None
1830
+ if rail is not None and limit_w > rail.capacity.base():
1831
+ # This two-number sentence accuses both values, but its quantity and
1832
+ # observed clause name the budget limit, so the one source slot cites
1833
+ # Budget and never falls back to Rail.
1834
+ # Readers now exist for Budget, Rail, Bus; Host.provenance
1835
+ # (schema.py:489) and DeviceFamily.provenance (schema.py:294) remain UNREAD.
1836
+ out.append(
1837
+ Finding(
1838
+ severity="violation",
1839
+ quantity=f"budgets/{b.id}/limit",
1840
+ observed=format_magnitude(limit_w, "W"),
1841
+ expected=_with_enforcement_provenance(
1842
+ f"<= rail capacity {format_magnitude(rail.capacity.base(), 'W')}",
1843
+ ("capacity", rail.capacity_enforced_by),
1844
+ ),
1845
+ observed_quantity=Quantity(value=limit_w, unit="W"),
1846
+ expected_quantity=Quantity(value=rail.capacity.base(), unit="W"),
1847
+ delta=f"+{format_magnitude(limit_w - rail.capacity.base(), 'W')}",
1848
+ dependent=f"topology/rails/{rail.id}/capacity",
1849
+ owner=b.owner,
1850
+ source=b.provenance.source if b.provenance is not None else None,
1851
+ witness=b.provenance.witness if b.provenance is not None else None,
1852
+ locked_at=b.provenance.locked_at if b.provenance is not None else None,
1853
+ action=(
1854
+ "lower the budget limit or correct the rail capacity with provenance"
1855
+ ),
1856
+ )
1857
+ )
1858
+ if unknown_consumers:
1859
+ # a rail with any consumer of unknown draw cannot be safely
1860
+ # summed — never sum an unknown draw as 0 W.
1861
+ device_id = unknown_consumers[0]
1862
+ out.append(
1863
+ Finding(
1864
+ severity="not_scored",
1865
+ quantity=f"budgets/{b.id}",
1866
+ observed=unknown_draw_message(device_id, c.device(device_id).power.rail),
1867
+ expected="every rail consumer to declare a draw",
1868
+ dependent=", ".join(unknown_consumers),
1869
+ owner=b.owner,
1870
+ action=(
1871
+ f"declare {device_id}'s power draw, or accept the budget as not scored"
1872
+ ),
1873
+ )
1874
+ )
1875
+ continue
1876
+ if not contributors:
1877
+ out.append(
1878
+ Finding(
1879
+ severity="warning",
1880
+ quantity=f"budgets/{b.id}",
1881
+ observed="zero contributing power declarations",
1882
+ expected="at least one device on the rail or a descendant rail",
1883
+ owner=b.owner,
1884
+ action="add device power witnesses or remove the empty budget",
1885
+ )
1886
+ )
1887
+ # No witness draw — limit vs .get(..., 0.0) is vacuous; do not score.
1888
+ else:
1889
+ # Score on the rolled-up MAGNITUDE, not on the contributor list.
1890
+ # A non-empty list whose every draw is 0.0 W sums to nothing, and
1891
+ # limit-vs-0.0 passes trivially. Draws are
1892
+ # non-negative, so > 0 separates "a real load was weighed" from
1893
+ # "a list of zeros"; one 0 W device among positive siblings still
1894
+ # leaves the sum positive and still scores.
1895
+ if scores is not None and total_w > 0.0:
1896
+ scores.record(f"budgets/{b.id}")
1897
+ if total_w > limit_w:
1898
+ out.append(
1899
+ Finding(
1900
+ severity="violation",
1901
+ quantity=f"budgets/{b.id}",
1902
+ observed=(
1903
+ f"{format_magnitude(total_w, 'W')} (draw mode: {b.draw_mode})"
1904
+ if has_named_modes
1905
+ else format_magnitude(total_w, "W")
1906
+ ),
1907
+ expected=_with_enforcement_provenance(
1908
+ f"<= {format_magnitude(limit_w, 'W')}",
1909
+ ("limit", b.limit_enforced_by),
1910
+ ),
1911
+ observed_quantity=Quantity(value=total_w, unit="W"),
1912
+ expected_quantity=Quantity(value=limit_w, unit="W"),
1913
+ delta=(
1914
+ f"+{format_magnitude(total_w - limit_w, 'W')} "
1915
+ f"(+{(total_w / limit_w - 1) * 100:.0f}%)"
1916
+ ),
1917
+ dependent=", ".join(contributors),
1918
+ owner=b.owner,
1919
+ source=(
1920
+ rail.provenance.source
1921
+ if rail is not None and rail.provenance is not None
1922
+ else b.provenance.source
1923
+ if b.provenance is not None
1924
+ else None
1925
+ ),
1926
+ action=(
1927
+ f"shed load on rail {b.rail!r} or raise its capacity with provenance"
1928
+ ),
1929
+ alternatives=build_rail_alternatives(
1930
+ c,
1931
+ over_rail_id=b.rail or "",
1932
+ deficit_w=total_w - limit_w,
1933
+ rail_totals=rail_totals,
1934
+ rail_unknown=rail_unknown,
1935
+ check_name="check_budgets",
1936
+ ),
1937
+ )
1938
+ )
1939
+ elif scores is not None and total_w > 0.0:
1940
+ headroom_w = limit_w - total_w
1941
+ scores.passed(
1942
+ f"budgets/{b.id}",
1943
+ observed=format_magnitude(total_w, "W"),
1944
+ expected=_with_enforcement_provenance(
1945
+ f"<= {format_magnitude(limit_w, 'W')}",
1946
+ ("limit", b.limit_enforced_by),
1947
+ ),
1948
+ margin=(
1949
+ f"{format_magnitude(headroom_w, 'W')} headroom "
1950
+ f"({headroom_w / limit_w * 100:.0f}%)"
1951
+ ),
1952
+ )
1953
+
1954
+ elif b.kind == "mass_total":
1955
+ total_kg = 0.0
1956
+ contributors: list[str] = []
1957
+ if b.sources in ("devices", "links+devices"):
1958
+ for d in c.devices:
1959
+ if d.physical is None or d.physical.mass is None:
1960
+ # no physical block and a physical block with no
1961
+ # declared mass are the same coverage gap — never summed
1962
+ # as a 0 kg contributor, always disclosed alike.
1963
+ out.append(
1964
+ Finding(
1965
+ severity="not_scored",
1966
+ quantity=f"devices/{d.id}/physical/mass",
1967
+ observed="mass not declared",
1968
+ expected="a positive mass with units",
1969
+ owner=b.owner,
1970
+ action="declare the device's mass, or accept it excluded from this budget",
1971
+ )
1972
+ )
1973
+ continue
1974
+ total_kg += d.physical.mass.base()
1975
+ contributors.append(d.id)
1976
+ if b.sources in ("links", "links+devices") and c.model is not None:
1977
+ for link in c.model.links:
1978
+ if link.mass is not None:
1979
+ total_kg += link.mass.base()
1980
+ contributors.append(link.name)
1981
+ limit_kg = limit
1982
+ # Score on the summed MAGNITUDE, not on the contributor list: a
1983
+ # non-empty list of zero-mass witnesses sums to 0.0 kg and would
1984
+ # vacuous-pass the limit. Masses are non-negative.
1985
+ if scores is not None and total_kg > 0.0:
1986
+ scores.record(f"budgets/{b.id}")
1987
+ if total_kg > limit_kg:
1988
+ out.append(
1989
+ Finding(
1990
+ severity="violation",
1991
+ quantity=f"budgets/{b.id}",
1992
+ observed=format_magnitude(total_kg, "kg"),
1993
+ expected=_with_enforcement_provenance(
1994
+ f"<= {format_magnitude(limit_kg, 'kg')}",
1995
+ ("limit", b.limit_enforced_by),
1996
+ ),
1997
+ observed_quantity=Quantity(value=total_kg, unit="kg"),
1998
+ expected_quantity=Quantity(value=limit_kg, unit="kg"),
1999
+ delta=f"+{format_magnitude(total_kg - limit_kg, 'kg')}",
2000
+ dependent=", ".join(contributors),
2001
+ owner=b.owner,
2002
+ source=b.provenance.source if b.provenance is not None else None,
2003
+ witness=b.provenance.witness if b.provenance is not None else None,
2004
+ locked_at=b.provenance.locked_at if b.provenance is not None else None,
2005
+ action="remove mass or re-lock the budget limit with provenance",
2006
+ )
2007
+ )
2008
+ elif scores is not None and total_kg > 0.0:
2009
+ headroom_kg = limit_kg - total_kg
2010
+ scores.passed(
2011
+ f"budgets/{b.id}",
2012
+ observed=format_magnitude(total_kg, "kg"),
2013
+ expected=_with_enforcement_provenance(
2014
+ f"<= {format_magnitude(limit_kg, 'kg')}",
2015
+ ("limit", b.limit_enforced_by),
2016
+ ),
2017
+ margin=(
2018
+ f"{format_magnitude(headroom_kg, 'kg')} headroom "
2019
+ f"({headroom_kg / limit_kg * 100:.0f}%)"
2020
+ ),
2021
+ )
2022
+
2023
+ elif b.kind == "endurance":
2024
+ _, _, battery_w, battery_witnesses, _, battery_unknown = _power_rollup(c, b.draw_mode)
2025
+ has_named_modes = any(
2026
+ c.device(device_id).power.modes for device_id in battery_witnesses
2027
+ )
2028
+ if c.topology is None or c.topology.battery is None:
2029
+ out.append(
2030
+ Finding(
2031
+ severity="violation",
2032
+ quantity=f"budgets/{b.id}",
2033
+ observed="no battery declared in topology",
2034
+ expected="topology.battery with an energy capacity",
2035
+ owner=b.owner,
2036
+ action="declare the battery so endurance can be computed",
2037
+ )
2038
+ )
2039
+ continue
2040
+ if battery_unknown:
2041
+ # any device on the way to the battery with unknown draw
2042
+ # makes endurance unsafe to compute — never sum it as 0 W.
2043
+ device_id = sorted(battery_unknown)[0]
2044
+ out.append(
2045
+ Finding(
2046
+ severity="not_scored",
2047
+ quantity=f"budgets/{b.id}",
2048
+ observed=unknown_draw_message(device_id, c.device(device_id).power.rail),
2049
+ expected="every device reaching the battery to declare a draw",
2050
+ dependent=", ".join(sorted(battery_unknown)),
2051
+ owner=b.owner,
2052
+ action=(
2053
+ f"declare {device_id}'s power draw, or accept endurance as not scored"
2054
+ ),
2055
+ )
2056
+ )
2057
+ continue
2058
+ total_w = battery_w
2059
+ if total_w == 0.0:
2060
+ # zero declared draw is an unmeasured
2061
+ # budget, not a warning-worthy anomaly — never score it, and
2062
+ # never let it read as "0.00 min" against the required minutes.
2063
+ out.append(
2064
+ Finding(
2065
+ severity="not_scored",
2066
+ quantity=f"budgets/{b.id}",
2067
+ observed="no draw declared",
2068
+ observed_quantity=Quantity(value=0.0, unit="W"),
2069
+ expected="at least one device with a power entry",
2070
+ owner=b.owner,
2071
+ action="add power draws to devices — endurance is uncheckable until then",
2072
+ )
2073
+ )
2074
+ continue
2075
+ capacity_wh = c.topology.battery.capacity.base()
2076
+ hours = capacity_wh / total_w
2077
+ required_h = units.convert(b.limit.value, b.limit.unit, "h")
2078
+ # Battery capacity/draw vs required endurance — both sides present.
2079
+ if scores is not None:
2080
+ scores.record(f"budgets/{b.id}")
2081
+ if hours < required_h:
2082
+ out.append(
2083
+ Finding(
2084
+ severity="violation",
2085
+ quantity=f"budgets/{b.id}",
2086
+ observed=(
2087
+ f"{_format_hours(hours)} expected endurance (draw mode: {b.draw_mode})"
2088
+ if has_named_modes
2089
+ else f"{_format_hours(hours)} expected endurance"
2090
+ ),
2091
+ expected=_with_enforcement_provenance(
2092
+ f">= {_format_hours(required_h)}", ("limit", b.limit_enforced_by)
2093
+ ),
2094
+ observed_quantity=Quantity(value=hours, unit="h"),
2095
+ expected_quantity=Quantity(value=required_h, unit="h"),
2096
+ delta=f"-{_format_hours(required_h - hours)}",
2097
+ dependent=", ".join(sorted(battery_witnesses)),
2098
+ owner=b.owner,
2099
+ source=b.provenance.source if b.provenance is not None else None,
2100
+ witness=b.provenance.witness if b.provenance is not None else None,
2101
+ locked_at=b.provenance.locked_at if b.provenance is not None else None,
2102
+ action=(
2103
+ "shed power draw, grow the battery, or lower the mission requirement"
2104
+ ),
2105
+ )
2106
+ )
2107
+ continue
2108
+ # Nominal passes: check declared cold-capacity deratings. A failure here
2109
+ # is a go-with-note (MEL-style) — the mission works, but not below the
2110
+ # named pack temperature.
2111
+ derated_warning = False
2112
+ for row in sorted(c.topology.battery.derating, key=lambda r: -r.below_c):
2113
+ derated_h = capacity_wh * row.capacity_factor / total_w
2114
+ if derated_h < required_h:
2115
+ derated_warning = True
2116
+ out.append(
2117
+ Finding(
2118
+ severity="warning",
2119
+ quantity=f"budgets/{b.id}",
2120
+ observed=(
2121
+ f"{_format_hours(derated_h)} expected endurance at "
2122
+ f"pack temp <= {row.below_c:g} C"
2123
+ + (f" (draw mode: {b.draw_mode})" if has_named_modes else "")
2124
+ ),
2125
+ expected=_with_enforcement_provenance(
2126
+ f">= {_format_hours(required_h)}",
2127
+ ("limit", b.limit_enforced_by),
2128
+ ),
2129
+ observed_quantity=Quantity(value=derated_h, unit="h"),
2130
+ expected_quantity=Quantity(value=required_h, unit="h"),
2131
+ delta=f"-{_format_hours(required_h - derated_h)}",
2132
+ dependent=", ".join(sorted(battery_witnesses)),
2133
+ owner=b.owner,
2134
+ source=b.provenance.source if b.provenance is not None else None,
2135
+ witness=b.provenance.witness if b.provenance is not None else None,
2136
+ locked_at=b.provenance.locked_at if b.provenance is not None else None,
2137
+ action=(
2138
+ f"cold ops below {row.below_c:g} C unsupported "
2139
+ "— restrict conditions, preheat pack, or grow "
2140
+ "the battery"
2141
+ ),
2142
+ )
2143
+ )
2144
+ break # coldest failing row already implies all colder rows fail
2145
+ if scores is not None and not derated_warning:
2146
+ headroom_h = hours - required_h
2147
+ scores.passed(
2148
+ f"budgets/{b.id}",
2149
+ observed=f"{_format_hours(hours)}",
2150
+ expected=_with_enforcement_provenance(
2151
+ f">= {_format_hours(required_h)}", ("limit", b.limit_enforced_by)
2152
+ ),
2153
+ margin=f"{_format_hours(headroom_h)} to spare",
2154
+ )
2155
+
2156
+ return out
2157
+
2158
+
2159
+ # ---------------------------------------------------------------- ampacity
2160
+
2161
+
2162
+ # Published copper ampacity (amperes), 90 °C insulation column.
2163
+ # Source: NFPA 70 National Electrical Code (NEC) 2020 —
2164
+ # free_air -> Table 310.17 (single-insulated conductors in free air)
2165
+ # enclosed -> Table 310.16 (≤3 current-carrying conductors in raceway,
2166
+ # cable, or earth) — the same standard's enclosed column, not a
2167
+ # hand-typed derating multiplier (derating honesty).
2168
+ # Gauges absent from these tables are not scored (emit not_scored).
2169
+ #
2170
+ # 18/16 AWG free-air corroboration: checked
2171
+ # against NFPA 70 (NEC) 2020 Table 310.17 copper 90 °C — 18 AWG = 18 A,
2172
+ # 16 AWG = 24 A. Values stand; no correction.
2173
+ AMPACITY_SOURCE = (
2174
+ "NFPA 70 (NEC) 2020 Table 310.17 (free air) / Table 310.16 "
2175
+ "(≤3 CCC in raceway/cable/earth), copper, 90 °C column"
2176
+ )
2177
+ _AMPACITY_A: dict[tuple[int, str], float] = {
2178
+ # (awg, installation) -> amperes
2179
+ (18, "free_air"): 18.0, # NEC 2020 Table 310.17 Cu 90 °C — corroborated
2180
+ (16, "free_air"): 24.0, # NEC 2020 Table 310.17 Cu 90 °C — corroborated
2181
+ (14, "free_air"): 35.0,
2182
+ (12, "free_air"): 40.0,
2183
+ (10, "free_air"): 55.0,
2184
+ (8, "free_air"): 80.0,
2185
+ (6, "free_air"): 105.0,
2186
+ (4, "free_air"): 140.0,
2187
+ (3, "free_air"): 165.0,
2188
+ (2, "free_air"): 190.0,
2189
+ (1, "free_air"): 220.0,
2190
+ (18, "enclosed"): 14.0,
2191
+ (16, "enclosed"): 18.0,
2192
+ (14, "enclosed"): 25.0,
2193
+ (12, "enclosed"): 30.0,
2194
+ (10, "enclosed"): 40.0,
2195
+ (8, "enclosed"): 55.0,
2196
+ (6, "enclosed"): 75.0,
2197
+ (4, "enclosed"): 95.0,
2198
+ (3, "enclosed"): 115.0,
2199
+ (2, "enclosed"): 130.0,
2200
+ (1, "enclosed"): 145.0,
2201
+ }
2202
+
2203
+
2204
+ def _ampacity_draw_modes(c: Contract) -> list[str]:
2205
+ """Nominal plus every named PowerMode declared on any powered device."""
2206
+ names: set[str] = {"nominal"}
2207
+ for device in c.devices:
2208
+ if device.power is None:
2209
+ continue
2210
+ for mode in device.power.modes:
2211
+ names.add(mode.name)
2212
+ return ["nominal", *sorted(n for n in names if n != "nominal")]
2213
+
2214
+
2215
+ def _worst_rail_power(c: Contract, rail_id: str) -> tuple[float, str, set[str], set[str]]:
2216
+ """Maximize rolled-up watts on ``rail_id`` across declared draw modes.
2217
+
2218
+ Ampacity must not silently score a defaulted ``draw_mode="nominal"`` when
2219
+ contributing devices declare higher named modes: that
2220
+ path reports a confident wrong pass on a fire-safety check. Returns
2221
+ ``(load_w, mode_name, witnesses, unknown_consumers)`` for the worst mode
2222
+ (nominal first on ties so a true peak is what gets named when it wins).
2223
+ ``unknown_consumers`` is invariant across draw_mode — a device with no
2224
+ declared draw has no modes either (the schema requires a fallback), so it
2225
+ is unknown under every mode; a non-empty set means the caller cannot
2226
+ safely compute a peak and must not_score instead.
2227
+ """
2228
+ worst_w = -1.0
2229
+ worst_mode = "nominal"
2230
+ worst_witnesses: set[str] = set()
2231
+ unknown: set[str] = set()
2232
+ for mode_name in _ampacity_draw_modes(c):
2233
+ rail_totals, rail_witnesses, _, _, rail_unknown, _ = _power_rollup(c, mode_name)
2234
+ unknown |= rail_unknown.get(rail_id, set())
2235
+ load_w = rail_totals.get(rail_id, 0.0)
2236
+ if load_w > worst_w:
2237
+ worst_w = load_w
2238
+ worst_mode = mode_name
2239
+ worst_witnesses = set(rail_witnesses.get(rail_id, set()))
2240
+ return max(worst_w, 0.0), worst_mode, worst_witnesses, unknown
2241
+
2242
+
2243
+ def check_ampacity(c: Contract, *, scores: _ScoreBook | None = None) -> list[Finding]:
2244
+ """Published AWG ampacity vs computed rail current (two-source).
2245
+
2246
+ Limit side: embedded NEC tables cited in ``AMPACITY_SOURCE``. Draw side:
2247
+ the **worst** rolled-up device watts on the wire's rail across nominal and
2248
+ every declared ``PowerMode`` (``_worst_rail_power``), divided by rail
2249
+ voltage — never the wire's defaulted ``draw_mode`` alone. A gauge or
2250
+ installation absent from the published table emits ``not_scored`` rather
2251
+ than inventing a derating.
2252
+
2253
+ Counterpart to check_budgets, which stays pinned to the contract's selected draw mode; see _power_rollup for that side of the asymmetry.
2254
+ """
2255
+ if c.topology is None:
2256
+ return []
2257
+
2258
+ out: list[Finding] = []
2259
+ for wire in c.topology.wires:
2260
+ limit_a = _AMPACITY_A.get((wire.awg, wire.installation))
2261
+ if limit_a is None:
2262
+ out.append(
2263
+ Finding(
2264
+ severity="not_scored",
2265
+ quantity=f"topology/wires/{wire.id}/ampacity",
2266
+ observed=(
2267
+ f"{wire.awg} AWG {wire.installation}: no published "
2268
+ "ampacity in the cited table; ampacity not scored"
2269
+ ),
2270
+ expected="a gauge+installation pair present in the cited NEC tables",
2271
+ owner=wire.owner,
2272
+ source=AMPACITY_SOURCE,
2273
+ action=(
2274
+ "choose a gauge listed in the cited NEC tables, or accept "
2275
+ "that this wire is excluded from the ampacity gate"
2276
+ ),
2277
+ )
2278
+ )
2279
+ continue
2280
+
2281
+ rail = c.rail(wire.rail)
2282
+ if rail is None:
2283
+ continue # reference integrity owns the dangling rail
2284
+
2285
+ load_w, draw_mode, witnesses, unknown_consumers = _worst_rail_power(c, wire.rail)
2286
+ if unknown_consumers:
2287
+ device_id = sorted(unknown_consumers)[0]
2288
+ out.append(
2289
+ Finding(
2290
+ severity="not_scored",
2291
+ quantity=f"topology/wires/{wire.id}/ampacity",
2292
+ observed=unknown_draw_message(device_id, wire.rail),
2293
+ expected="every consumer on this rail to declare a draw",
2294
+ dependent=", ".join(sorted(unknown_consumers)),
2295
+ owner=wire.owner,
2296
+ source=AMPACITY_SOURCE,
2297
+ action=(
2298
+ f"declare {device_id}'s power draw, or accept ampacity on "
2299
+ "this wire as not scored"
2300
+ ),
2301
+ )
2302
+ )
2303
+ continue
2304
+ if rail.voltage is None:
2305
+ out.append(
2306
+ Finding(
2307
+ severity="not_scored",
2308
+ quantity=f"topology/wires/{wire.id}/ampacity",
2309
+ observed=f"rail {wire.rail!r} has no declared voltage",
2310
+ expected="a declared rail voltage",
2311
+ owner=wire.owner,
2312
+ source=AMPACITY_SOURCE,
2313
+ action="declare the rail's voltage, or accept ampacity on this wire as not scored",
2314
+ )
2315
+ )
2316
+ continue
2317
+ voltage_v = rail.voltage.base()
2318
+ if voltage_v <= 0.0:
2319
+ out.append(
2320
+ Finding(
2321
+ severity="not_scored",
2322
+ quantity=f"topology/wires/{wire.id}/ampacity",
2323
+ observed=f"rail voltage <= 0 ({format_magnitude(voltage_v, 'V')})",
2324
+ expected="a positive rail voltage",
2325
+ owner=wire.owner,
2326
+ source=AMPACITY_SOURCE,
2327
+ action=("record the rail's actual voltage so computed draw can be scored"),
2328
+ )
2329
+ )
2330
+ continue
2331
+ draw_a = load_w / voltage_v
2332
+ contributors = sorted(witnesses)
2333
+ # NEC ampacity vs computed draw — score on the computed CURRENT, not on
2334
+ # the witness list. This comment previously named the vacuous-pass defect
2335
+ # while the guard only checked list non-emptiness: a witness declaring
2336
+ # 0 W yields 0 A against a published ampacity limit, which passes for no
2337
+ # reason. Draws are non-negative, so > 0 means a real load was weighed.
2338
+ if scores is not None and draw_a > 0.0:
2339
+ scores.record(f"wires/{wire.id}")
2340
+ has_named_modes = any(c.device(device_id).power.modes for device_id in contributors)
2341
+
2342
+ if draw_a > limit_a:
2343
+ observed = (
2344
+ f"{format_magnitude(draw_a, 'A')} computed draw on {wire.awg} AWG "
2345
+ f"{wire.installation} (draw mode: {draw_mode})"
2346
+ if has_named_modes
2347
+ else (
2348
+ f"{format_magnitude(draw_a, 'A')} computed draw on {wire.awg} AWG {wire.installation}"
2349
+ )
2350
+ )
2351
+ out.append(
2352
+ Finding(
2353
+ severity="violation",
2354
+ quantity=f"topology/wires/{wire.id}/ampacity",
2355
+ observed=observed,
2356
+ expected=f"<= {format_magnitude(limit_a, 'A')} published ampacity",
2357
+ observed_quantity=Quantity(value=draw_a, unit="A"),
2358
+ expected_quantity=Quantity(value=limit_a, unit="A"),
2359
+ delta=(
2360
+ f"+{format_magnitude(draw_a - limit_a, 'A')} (+{(draw_a / limit_a - 1) * 100:.0f}%)"
2361
+ ),
2362
+ dependent=", ".join(contributors) or None,
2363
+ owner=wire.owner,
2364
+ source=AMPACITY_SOURCE,
2365
+ action=(
2366
+ f"upsize wire {wire.id!r}, shorten/re-route toward free-air, "
2367
+ "or shed load on its rail"
2368
+ ),
2369
+ )
2370
+ )
2371
+ elif scores is not None and draw_a > 0.0:
2372
+ headroom_a = limit_a - draw_a
2373
+ scores.passed(
2374
+ f"topology/wires/{wire.id}/ampacity",
2375
+ observed=format_magnitude(draw_a, "A"),
2376
+ expected=f"<= {format_magnitude(limit_a, 'A')}",
2377
+ margin=(
2378
+ f"{format_magnitude(headroom_a, 'A')} headroom "
2379
+ f"({headroom_a / limit_a * 100:.0f}%)"
2380
+ ),
2381
+ )
2382
+
2383
+ return out
2384
+
2385
+
2386
+ def check_rail_current(c: Contract, *, scores: _ScoreBook | None = None) -> list[Finding]:
2387
+ """Published rail current_limit vs computed draw (sibling to check_ampacity).
2388
+
2389
+ Draw side: the same nominal-mode rolled-up watts a ``rail_power`` budget
2390
+ would sum for this rail (``power_rollup`` — not re-added here), divided by
2391
+ the rail's worst-case voltage: its declared ``voltage_range.min`` when
2392
+ present (the conservative bound — lower voltage means higher current for
2393
+ the same watts), else its single ``voltage``. A rail with no voltage fact
2394
+ (neither) or an unsummable draw (any consumer of unknown draw) is
2395
+ ``not_scored`` rather than guessing.
2396
+ """
2397
+ if c.topology is None:
2398
+ return []
2399
+ out: list[Finding] = []
2400
+ rail_totals, rail_witnesses, _, _, rail_unknown, _ = power_rollup(c, "nominal")
2401
+ for rail in c.topology.rails:
2402
+ if rail.current_limit is None:
2403
+ continue
2404
+ if rail.voltage_range is not None:
2405
+ voltage_v = rail.voltage_range.min.base()
2406
+ elif rail.voltage is not None:
2407
+ voltage_v = rail.voltage.base()
2408
+ else:
2409
+ out.append(
2410
+ Finding(
2411
+ severity="not_scored",
2412
+ quantity=f"topology/rails/{rail.id}/current_limit",
2413
+ observed=f"rail {rail.id!r} has no declared voltage or voltage_range",
2414
+ expected="a declared rail voltage or voltage_range",
2415
+ action=(
2416
+ "declare the rail's voltage or voltage_range, or accept its "
2417
+ "current_limit as not scored"
2418
+ ),
2419
+ )
2420
+ )
2421
+ continue
2422
+
2423
+ unknown_consumers = rail_unknown.get(rail.id, set())
2424
+ if unknown_consumers:
2425
+ device_id = sorted(unknown_consumers)[0]
2426
+ out.append(
2427
+ Finding(
2428
+ severity="not_scored",
2429
+ quantity=f"topology/rails/{rail.id}/current_limit",
2430
+ observed=unknown_draw_message(device_id, rail.id),
2431
+ expected="every consumer on this rail to declare a draw",
2432
+ dependent=", ".join(sorted(unknown_consumers)),
2433
+ action=(
2434
+ f"declare {device_id}'s power draw, or accept this rail's "
2435
+ "current_limit as not scored"
2436
+ ),
2437
+ )
2438
+ )
2439
+ continue
2440
+
2441
+ load_w = rail_totals.get(rail.id, 0.0)
2442
+ contributors = sorted(rail_witnesses.get(rail.id, set()))
2443
+ if not contributors:
2444
+ out.append(
2445
+ Finding(
2446
+ severity="not_scored",
2447
+ quantity=f"topology/rails/{rail.id}/current_limit",
2448
+ observed="no summable power declarations on this rail",
2449
+ expected="at least one device on the rail or a descendant rail",
2450
+ action="add device power witnesses, or accept this rail's current_limit as not scored",
2451
+ )
2452
+ )
2453
+ continue
2454
+
2455
+ draw_a = load_w / voltage_v
2456
+ limit_a = rail.current_limit.base()
2457
+ # Score on the computed CURRENT, not on the witness list — a witness
2458
+ # declaring 0 W yields 0 A and would vacuous-pass for no reason.
2459
+ if scores is not None and draw_a > 0.0:
2460
+ scores.record(f"topology/rails/{rail.id}/current_limit")
2461
+ if draw_a > limit_a:
2462
+ out.append(
2463
+ Finding(
2464
+ severity="violation",
2465
+ quantity=f"topology/rails/{rail.id}/current_limit",
2466
+ observed=f"{format_magnitude(draw_a, 'A')} computed draw on rail {rail.id!r}",
2467
+ expected=_with_enforcement_provenance(
2468
+ f"<= {format_magnitude(limit_a, 'A')} published current_limit",
2469
+ ("current_limit", rail.current_limit_enforced_by),
2470
+ ),
2471
+ observed_quantity=Quantity(value=draw_a, unit="A"),
2472
+ expected_quantity=Quantity(value=limit_a, unit="A"),
2473
+ delta=(
2474
+ f"+{format_magnitude(draw_a - limit_a, 'A')} "
2475
+ f"(+{(draw_a / limit_a - 1) * 100:.0f}%)"
2476
+ ),
2477
+ dependent=", ".join(contributors),
2478
+ action=(
2479
+ f"shed load on rail {rail.id!r} or raise its published current_limit "
2480
+ "with provenance"
2481
+ ),
2482
+ )
2483
+ )
2484
+ elif scores is not None and draw_a > 0.0:
2485
+ headroom_a = limit_a - draw_a
2486
+ scores.passed(
2487
+ f"topology/rails/{rail.id}/current_limit",
2488
+ observed=format_magnitude(draw_a, "A"),
2489
+ expected=_with_enforcement_provenance(
2490
+ f"<= {format_magnitude(limit_a, 'A')}",
2491
+ ("current_limit", rail.current_limit_enforced_by),
2492
+ ),
2493
+ margin=(
2494
+ f"{format_magnitude(headroom_a, 'A')} headroom "
2495
+ f"({headroom_a / limit_a * 100:.0f}%)"
2496
+ ),
2497
+ )
2498
+ return out
2499
+
2500
+
2501
+ # ---------------------------------------------------------------- actuator and bus budgets
2502
+
2503
+
2504
+ def check_torque_budgets(c: Contract, *, scores: _ScoreBook | None = None) -> list[Finding]:
2505
+ """A joint's modeled effort demand must not exceed the paired actuator's
2506
+ continuous geared output; peak torque is not a sustainable design budget."""
2507
+ if c.model is None:
2508
+ return []
2509
+ joints = {joint.name: joint for joint in c.model.joints}
2510
+ out: list[Finding] = []
2511
+ for device in c.devices:
2512
+ if device.drives_joint is None:
2513
+ continue
2514
+ joint = joints.get(device.drives_joint)
2515
+ model = c.device_model(device.model)
2516
+ if (
2517
+ joint is None
2518
+ or joint.type not in ("revolute", "continuous")
2519
+ or joint.limits is None
2520
+ or joint.limits.effort is None
2521
+ ):
2522
+ continue
2523
+ actuator = model.actuator if model is not None else None
2524
+ if actuator is None or actuator.torque_continuous is None or actuator.gear_ratio is None:
2525
+ continue
2526
+ if joint.limits.effort_limit is None:
2527
+ out.append(
2528
+ unusable_effort_finding(
2529
+ f"devices/{device.id}/drives_joint",
2530
+ joint.limits.effort,
2531
+ dependent=f"model/joints/{joint.name}/limits/effort",
2532
+ owner=device.owner,
2533
+ )
2534
+ )
2535
+ continue
2536
+ if scores is not None:
2537
+ scores.record(f"devices/{device.id}/drives_joint")
2538
+ available = actuator.torque_continuous * actuator.gear_ratio
2539
+ required = joint.limits.effort
2540
+ if required > available:
2541
+ out.append(
2542
+ Finding(
2543
+ severity="violation",
2544
+ quantity=f"devices/{device.id}/drives_joint",
2545
+ observed=f"{available:.2f} N*m continuous geared torque",
2546
+ expected=_with_enforcement_provenance(
2547
+ f">= {required:.2f} N*m joint effort limit",
2548
+ ("effort", joint.limits.effort_enforced_by),
2549
+ ),
2550
+ delta=f"-{required - available:.2f} N*m",
2551
+ dependent=f"model/joints/{joint.name}/limits/effort",
2552
+ owner=device.owner,
2553
+ action=(
2554
+ "select a stronger actuator, change gearing, or correct the sourced limits"
2555
+ ),
2556
+ )
2557
+ )
2558
+ elif scores is not None:
2559
+ headroom_nm = available - required
2560
+ scores.passed(
2561
+ f"devices/{device.id}/drives_joint",
2562
+ observed=f"{required:.2f} N*m joint effort limit",
2563
+ expected=_with_enforcement_provenance(
2564
+ f"<= {available:.2f} N*m continuous geared torque",
2565
+ ("effort", joint.limits.effort_enforced_by),
2566
+ ),
2567
+ margin=(
2568
+ f"{headroom_nm:.2f} N*m headroom ({headroom_nm / available * 100:.0f}%)"
2569
+ if available > 0.0
2570
+ else f"{headroom_nm:.2f} N*m headroom"
2571
+ ),
2572
+ )
2573
+ return out
2574
+
2575
+
2576
+ def check_bus_bandwidth(c: Contract, *, scores: _ScoreBook | None = None) -> list[Finding]:
2577
+ """Declared traffic must fit below a conservative share of raw bitrate;
2578
+ protocol overhead and contention make 100% raw utilization unusable.
2579
+
2580
+ A bus with ``bitrate is None`` is not scored: emit a ``not_scored`` finding so
2581
+ GO cannot be read as "bandwidth verified" when utilization was never checked.
2582
+ A bus with a bitrate but no driver declaring both ``nominal_rate_hz`` and
2583
+ ``payload_bytes`` is the same disclosure the other way round: there is
2584
+ nothing to weigh against the known bitrate, so it is also ``not_scored``
2585
+ rather than silently emitting nothing.
2586
+ ``not_scored`` is neither a violation nor a warning (false-positive budget;
2587
+ must not obscure headline defects with warning noise).
2588
+ """
2589
+ if c.topology is None:
2590
+ return []
2591
+ out: list[Finding] = []
2592
+ for bus in c.topology.buses:
2593
+ if bus.bitrate is None:
2594
+ out.append(
2595
+ Finding(
2596
+ severity="not_scored",
2597
+ quantity=f"topology/buses/{bus.id}/bandwidth",
2598
+ observed="bitrate unrated; bandwidth not scored",
2599
+ expected="a positive bitrate so utilization can be scored",
2600
+ source=bus.provenance.source if bus.provenance is not None else None,
2601
+ witness=bus.provenance.witness if bus.provenance is not None else None,
2602
+ locked_at=bus.provenance.locked_at if bus.provenance is not None else None,
2603
+ action=(
2604
+ "lock a cited bitrate, or accept that this bus is excluded "
2605
+ "from the bandwidth gate"
2606
+ ),
2607
+ )
2608
+ )
2609
+ continue
2610
+ contributors = [
2611
+ device
2612
+ for device in c.devices
2613
+ if device.identity is not None
2614
+ and device.identity.bus_ref == bus.id
2615
+ and device.driver is not None
2616
+ and device.driver.nominal_rate_hz is not None
2617
+ and device.driver.payload_bytes is not None
2618
+ ]
2619
+ used_bps = sum(
2620
+ device.driver.nominal_rate_hz * device.driver.payload_bytes * 8
2621
+ for device in contributors
2622
+ )
2623
+ ceiling_bps = bus.bitrate * BUS_UTILIZATION_CEILING
2624
+ # Bitrate ceiling vs declared traffic — score on the summed TRAFFIC, not
2625
+ # on the contributor list. A device declaring nominal_rate_hz = 0 is a
2626
+ # contributor that carries nothing: 0 bps against the ceiling passes for
2627
+ # no reason.
2628
+ if scores is not None and used_bps > 0:
2629
+ scores.record(f"buses/{bus.id}")
2630
+ if used_bps == 0:
2631
+ out.append(
2632
+ Finding(
2633
+ severity="not_scored",
2634
+ quantity=f"topology/buses/{bus.id}/bandwidth",
2635
+ observed="no declared traffic on this bus",
2636
+ expected=(
2637
+ "at least one driver on the bus declaring nominal_rate_hz and payload_bytes"
2638
+ ),
2639
+ source=bus.provenance.source if bus.provenance is not None else None,
2640
+ witness=bus.provenance.witness if bus.provenance is not None else None,
2641
+ locked_at=bus.provenance.locked_at if bus.provenance is not None else None,
2642
+ action=(
2643
+ "declare the traffic each driver puts on this bus, or "
2644
+ "accept that its bandwidth is unchecked"
2645
+ ),
2646
+ )
2647
+ )
2648
+ continue
2649
+ if used_bps > ceiling_bps:
2650
+ out.append(
2651
+ Finding(
2652
+ severity="violation",
2653
+ quantity=f"topology/buses/{bus.id}/bandwidth",
2654
+ observed=f"{used_bps:.0f} bit/s payload",
2655
+ expected=(
2656
+ f"<= {ceiling_bps:.0f} bit/s ({BUS_UTILIZATION_CEILING:.0%} of raw bitrate)"
2657
+ ),
2658
+ delta=f"+{used_bps - ceiling_bps:.0f} bit/s",
2659
+ dependent=", ".join(device.id for device in contributors),
2660
+ source=bus.provenance.source if bus.provenance is not None else None,
2661
+ witness=bus.provenance.witness if bus.provenance is not None else None,
2662
+ locked_at=bus.provenance.locked_at if bus.provenance is not None else None,
2663
+ action="reduce rates or payloads, split the bus, or increase its bitrate",
2664
+ )
2665
+ )
2666
+ elif scores is not None and used_bps > 0:
2667
+ headroom_bps = ceiling_bps - used_bps
2668
+ scores.passed(
2669
+ f"topology/buses/{bus.id}/bandwidth",
2670
+ observed=f"{used_bps:.0f} bit/s payload",
2671
+ expected=(
2672
+ f"<= {ceiling_bps:.0f} bit/s ({BUS_UTILIZATION_CEILING:.0%} of raw bitrate)"
2673
+ ),
2674
+ margin=(
2675
+ f"{headroom_bps:.0f} bit/s headroom ({headroom_bps / ceiling_bps * 100:.0f}%)"
2676
+ ),
2677
+ )
2678
+ return out
2679
+
2680
+
2681
+ # ---------------------------------------------------------------- model sanity
2682
+
2683
+
2684
+ def check_zero_mass(c: Contract) -> list[Finding]:
2685
+ """Missing or non-positive link mass is derived from the canonical link
2686
+ records so a stale zero_mass_links cache cannot hide incomplete inertia."""
2687
+ if c.model is None:
2688
+ return []
2689
+ return [
2690
+ Finding(
2691
+ severity="warning",
2692
+ quantity=f"model/links/{name}/mass",
2693
+ observed="zero or missing mass",
2694
+ expected="a positive mass with units",
2695
+ action=(
2696
+ "fill in the link's inertial in the source model, or accept "
2697
+ "as massless with a Tier C assumption"
2698
+ ),
2699
+ )
2700
+ for link in c.model.links
2701
+ if link.mass is None or link.mass.base() <= 0.0
2702
+ for name in [link.name]
2703
+ ]
2704
+
2705
+
2706
+ def check_unusable_effort(c: Contract) -> list[Finding]:
2707
+ """a model joint effort <= 0 is unset, not a limit; warn on a fresh check."""
2708
+ if c.model is None:
2709
+ return []
2710
+ # check_torque_budgets already warns for a joint with a usable actuator; don't double up.
2711
+ budgeted = {
2712
+ d.drives_joint
2713
+ for d in c.devices
2714
+ if d.drives_joint is not None
2715
+ and (m := c.device_model(d.model)) is not None
2716
+ and m.actuator is not None
2717
+ and m.actuator.torque_continuous is not None
2718
+ and m.actuator.gear_ratio is not None
2719
+ }
2720
+ return [
2721
+ unusable_effort_finding(f"model/joints/{j.name}/limits/effort", j.limits.effort)
2722
+ for j in c.model.joints
2723
+ if j.limits is not None
2724
+ and j.limits.effort is not None
2725
+ and j.limits.effort_limit is None
2726
+ and not (j.type in ("revolute", "continuous") and j.name in budgeted)
2727
+ ]
2728
+
2729
+
2730
+ def _mechanism_quantity(m: Mechanism) -> str:
2731
+ return "model/mechanisms/" + "+".join(m.links)
2732
+
2733
+
2734
+ def check_mechanisms(c: Contract, *, scores: _ScoreBook | None = None) -> list[Finding]:
2735
+ """Does a joint declared to spin all the way round actually have the geometry to?
2736
+
2737
+ The comparison is *declared vs measured* like every other check here: the
2738
+ declaration is a loop joint of type ``continuous``, and the second operand is the
2739
+ closed-form Grashof answer computed from the loop's four measured link lengths
2740
+ (``mechanism.py``). No solver is involved and no tolerance is argued — Grashof is
2741
+ an exact inequality on four numbers.
2742
+
2743
+ Two silences are deliberate. A loop where **nothing** is declared ``continuous``
2744
+ produces no finding at all: a four-bar of limited revolute joints is a
2745
+ double-rocker, which is a legitimate machine and not a defect, and a check that
2746
+ spoke there would spend the false-positive budget on every parallel linkage in
2747
+ every model. And a loop that IS declared continuous but is not a four-bar — a
2748
+ five-bar, a coupled linkage, a loop whose axes are not recorded — is
2749
+ ``not_scored`` **naming the kind it found**, never scored as a four-bar built from
2750
+ four of its links. That silent-approximation path is the one this check exists to
2751
+ close: it would produce a confident, plausible, wrong answer.
2752
+ """
2753
+ if c.model is None:
2754
+ return []
2755
+ out: list[Finding] = []
2756
+ for m in analyse_model(c.model):
2757
+ if not m.declared_fully_rotating:
2758
+ continue
2759
+ declared = ", ".join(m.declared_fully_rotating)
2760
+ quantity = _mechanism_quantity(m)
2761
+ if m.verdict == "not-scored":
2762
+ out.append(
2763
+ Finding(
2764
+ severity="not_scored",
2765
+ quantity=quantity,
2766
+ observed=m.text,
2767
+ expected=(
2768
+ f"a mechanism in the named set ({', '.join(NAMED_SET)}) with the "
2769
+ "geometry recorded to answer it"
2770
+ ),
2771
+ action=(
2772
+ f"{declared} is declared to turn all the way round and nothing here "
2773
+ "checked whether it can — verify this loop by hand, or record the "
2774
+ "geometry named above so the check can answer it"
2775
+ ),
2776
+ )
2777
+ )
2778
+ continue
2779
+ if scores is not None:
2780
+ scores.record(quantity)
2781
+ if m.verdict == "rotates":
2782
+ if scores is not None:
2783
+ scores.passed(
2784
+ quantity=quantity,
2785
+ observed=m.text,
2786
+ expected=f"{declared} declared to turn all the way round",
2787
+ margin=(
2788
+ f"p + q exceeds s + l by {format_magnitude(m.p_plus_q - m.s_plus_l, 'm')}"
2789
+ ),
2790
+ )
2791
+ continue
2792
+ out.append(
2793
+ Finding(
2794
+ severity="violation" if m.verdict == "locking" else "warning",
2795
+ quantity=quantity,
2796
+ observed=m.text,
2797
+ expected=f"{declared} declared to turn all the way round (type continuous)",
2798
+ action=(
2799
+ f"change the link lengths so s + l <= p + q, or re-declare {declared} as a "
2800
+ "revolute joint with the limits it actually reaches"
2801
+ ),
2802
+ )
2803
+ )
2804
+ return out
2805
+
2806
+
2807
+ def _urdf_path_candidate(model: ModelRecord) -> str | None:
2808
+ """Return the URDF/SDF path named by provenance (preferred) or model.source."""
2809
+ for candidate in (model.provenance.source, model.source):
2810
+ if candidate.lower().endswith((".urdf", ".sdf")):
2811
+ return candidate
2812
+ return None
2813
+
2814
+
2815
+ def check_urdf_drift(
2816
+ c: Contract, *, scores: _ScoreBook | None = None, contract_path: Path | None = None
2817
+ ) -> list[Finding]:
2818
+ """Compare the contract's structure_hash against the on-disk URDF.
2819
+
2820
+ Tier A promises the locked model matches the URDF named in provenance. A
2821
+ hash mismatch is a violation that names the structural delta (links/joints
2822
+ added, removed, or moved). Absence / unreadability / missing URDF
2823
+ provenance is ``not_scored``: visible on GO, never a silent
2824
+ pass and never a false NO-GO.
2825
+
2826
+ A relative URDF path resolves from the contract's own directory when the
2827
+ caller passed ``contract_path`` — never from the working directory, so
2828
+ the same contract scores the same from anywhere. Without that anchor it stays
2829
+ ``not_scored``: a coverage gap is strictly better than an unearned GO.
2830
+ Absolute paths that are missing are likewise ``not_scored``.
2831
+ """
2832
+ if c.model is None:
2833
+ return []
2834
+
2835
+ named = _urdf_path_candidate(c.model)
2836
+ if named is None:
2837
+ return [
2838
+ Finding(
2839
+ severity="not_scored",
2840
+ quantity="model/structure_hash",
2841
+ observed="no URDF provenance; structure drift not scored",
2842
+ expected="a URDF path in model provenance so structure can be scored",
2843
+ source=c.model.provenance.source,
2844
+ witness=c.model.provenance.witness,
2845
+ locked_at=c.model.provenance.locked_at,
2846
+ action=(
2847
+ "lock model provenance to the on-disk URDF, or accept that "
2848
+ "URDF drift is unchecked"
2849
+ ),
2850
+ )
2851
+ ]
2852
+
2853
+ path = Path(named)
2854
+ if contract_path is not None:
2855
+ path = resolve_source(contract_path, named) or path
2856
+ if not path.is_file():
2857
+ if path.is_absolute():
2858
+ observed = f"URDF {path} absent; structure drift not scored"
2859
+ else:
2860
+ observed = (
2861
+ f"URDF {path}: relative path, no contract base dir — "
2862
+ "not resolvable; structure drift not scored"
2863
+ )
2864
+ return [
2865
+ Finding(
2866
+ severity="not_scored",
2867
+ quantity="model/structure_hash",
2868
+ observed=observed,
2869
+ expected="a readable URDF at the provenance path",
2870
+ source=named,
2871
+ action="restore the URDF or correct model provenance to its on-disk path",
2872
+ )
2873
+ ]
2874
+
2875
+ try:
2876
+ topology = (
2877
+ read_sdf_topology(path) if path.suffix.lower() == ".sdf" else read_urdf_topology(path)
2878
+ )
2879
+ except (OSError, ET.ParseError, ValueError, TypeError) as error:
2880
+ return [
2881
+ Finding(
2882
+ severity="not_scored",
2883
+ quantity="model/structure_hash",
2884
+ observed=f"URDF unreadable ({error}); structure drift not scored",
2885
+ expected="a readable URDF at the provenance path",
2886
+ source=str(path),
2887
+ action="fix or replace the URDF so structure drift can be scored",
2888
+ )
2889
+ ]
2890
+
2891
+ # v0.2 records with joints derive structure_hash from Link/Joint rows; v0.1
2892
+ # init records keep the URDF kinematic topology hash.
2893
+ disk_hash = topology.record_hash if c.model.joints else topology.topology_hash
2894
+ # Contract structure_hash vs on-disk URDF hash — both sides present.
2895
+ if scores is not None:
2896
+ scores.record("model/structure_hash")
2897
+ if disk_hash == c.model.structure_hash:
2898
+ return []
2899
+
2900
+ return [
2901
+ Finding(
2902
+ severity="violation",
2903
+ quantity="model/structure_hash",
2904
+ observed=describe_structure_drift(c.model, topology),
2905
+ expected="structure hash matching the on-disk URDF",
2906
+ delta=(f"contract {c.model.structure_hash[:12]}... != urdf {disk_hash[:12]}..."),
2907
+ source=str(path),
2908
+ action="re-lock the model from the URDF after reviewing the structural change",
2909
+ )
2910
+ ]
2911
+
2912
+
2913
+ # ---------------------------------------------------------------- envelopes
2914
+
2915
+
2916
+ def check_envelopes_vs_hard_limits(
2917
+ c: Contract, *, scores: _ScoreBook | None = None
2918
+ ) -> list[Finding]:
2919
+ """A locked envelope outside its model's hard limits means the baseline was
2920
+ almost certainly captured on a sick sensor (spec: baseline-on-broken-sensor
2921
+ trap). Hard limits win; the envelope must be re-derived."""
2922
+ out: list[Finding] = []
2923
+ for d in c.devices:
2924
+ model = c.device_model(d.model)
2925
+ if model is None:
2926
+ continue # reference check already flagged it
2927
+ # `Band.max_rate` is a rate of change, and a rate
2928
+ # needs two samples and the time between them. `check` is a static
2929
+ # comparison of one contract against itself — no time series reaches
2930
+ # it, and an envelope is a range, not a trajectory — so there is no
2931
+ # previous value here to compute a rate from. That decision is said out
2932
+ # loud, per declared bound, rather than left silent: a rate limit that
2933
+ # quietly does not apply is a rate limit that has stopped biting.
2934
+ # It is scored where the operands exist: `lockwire plan`
2935
+ # (plan._score_max_rate): compare now, predict
2936
+ # eventually, never actuate — scored, never enforced on a device.
2937
+ # Deliberately not recorded in the ScoreBook: nothing was compared.
2938
+ for channel, limit in sorted(model.hard_limits.items()):
2939
+ if limit.max_rate is None:
2940
+ continue
2941
+ out.append(
2942
+ Finding(
2943
+ severity="not_scored",
2944
+ quantity=f"devices/{d.id}/hard_limits/{channel}/max_rate",
2945
+ observed=f"declared max_rate {limit.max_rate:g} {limit.unit}/s",
2946
+ expected="two timed samples of this signal, which a contract check never has",
2947
+ owner=d.owner,
2948
+ action=(
2949
+ "score this bound with `lockwire plan`, which compares consecutive "
2950
+ "published values; `check` compares declarations, not trajectories"
2951
+ ),
2952
+ )
2953
+ )
2954
+ for env in d.envelopes:
2955
+ for channel, sig in env.signals.items():
2956
+ limit: Band | None = model.hard_limits.get(channel)
2957
+ if limit is None:
2958
+ continue
2959
+ # Envelope band vs datasheet hard limit — both sides present.
2960
+ quantity = f"devices/{d.id}/envelopes/{env.context}/{channel}"
2961
+ if scores is not None:
2962
+ scores.record(quantity)
2963
+ if units.dimension(sig.band.unit) != units.dimension(limit.unit):
2964
+ out.append(
2965
+ Finding(
2966
+ severity="violation",
2967
+ quantity=quantity,
2968
+ observed=f"unit {sig.band.unit!r}",
2969
+ expected=f"same dimension as hard limit unit {limit.unit!r}",
2970
+ owner=d.owner,
2971
+ action=(
2972
+ "fix the channel's units — envelope and hard limit are incomparable"
2973
+ ),
2974
+ )
2975
+ )
2976
+ continue
2977
+ if not limit.contains(sig.band):
2978
+ out.append(
2979
+ Finding(
2980
+ severity="violation",
2981
+ quantity=quantity,
2982
+ observed=str(sig.band),
2983
+ expected=f"inside hard limit {limit}",
2984
+ dependent=", ".join(d.depends) or None,
2985
+ owner=d.owner,
2986
+ action=(
2987
+ "re-baseline — envelope exceeds absolute ratings, "
2988
+ "baseline likely captured on a sick sensor"
2989
+ ),
2990
+ )
2991
+ )
2992
+ elif scores is not None:
2993
+ band_lo = units.convert(sig.band.lo, sig.band.unit, limit.unit)
2994
+ band_hi = units.convert(sig.band.hi, sig.band.unit, limit.unit)
2995
+ headroom = min(band_lo - limit.lo, limit.hi - band_hi)
2996
+ scores.passed(
2997
+ quantity,
2998
+ observed=str(sig.band),
2999
+ expected=f"inside hard limit {limit}",
3000
+ margin=f"{format_magnitude(headroom, limit.unit)} headroom",
3001
+ )
3002
+ return out
3003
+
3004
+
3005
+ # ---------------------------------------------------------------- thermal budget
3006
+
3007
+
3008
+ # Published T.SoC junction trip points for Jetson Orin-class modules.
3009
+ # Source: NVIDIA Thermal Design Guide TDG-11127-001_v1.5 (May 2025) —
3010
+ # software throttle 99 °C, hardware shutdown 105 °C, both the composite
3011
+ # equal-ratio average of CPU / GPU / SoC zones. NVIDIA publishes no
3012
+ # discrete per-zone trip points; the check's operand is that composite.
3013
+ # The actionable gate is throttle; shutdown is finding context only.
3014
+ THERMAL_TRIP_SOURCE = (
3015
+ "NVIDIA Thermal Design Guide TDG-11127-001_v1.5 (May 2025), "
3016
+ "T.SoC junction (equal-ratio CPU/GPU/SoC): software throttle 99 °C, "
3017
+ "hardware shutdown 105 °C"
3018
+ )
3019
+ _THERMAL_SHUTDOWN_C = 105.0
3020
+
3021
+
3022
+ def _device_worst_draw_w(device) -> tuple[float, str] | None:
3023
+ """Maximize device watts across nominal and every declared PowerMode.
3024
+
3025
+ Thermal trip is a hard physical limit (sibling to ampacity): score the
3026
+ worst declared mode rather than a silently-defaulted nominal. Returns
3027
+ ``(watts, mode_name)`` or ``None`` when the device declares no power.
3028
+ """
3029
+ if device.power is None or device.power.draw is None:
3030
+ # an undeclared draw is unknown, never 0 W — same "no power" gap
3031
+ # this already reported before draw could be optional.
3032
+ return None
3033
+ worst_w = device.power.draw.base()
3034
+ worst_mode = "nominal"
3035
+ for mode in device.power.modes:
3036
+ load_w = mode.draw.base()
3037
+ if load_w > worst_w:
3038
+ worst_w = load_w
3039
+ worst_mode = mode.name
3040
+ return worst_w, worst_mode
3041
+
3042
+
3043
+ def check_thermal_budget(c: Contract, *, scores: _ScoreBook | None = None) -> list[Finding]:
3044
+ """Junction-temperature estimate vs published module thermal limit.
3045
+
3046
+ Estimate: device dissipated power × published enclosure/heatsink RθJA
3047
+ (junction-to-ambient) + declared ambient. Limit side: the model's
3048
+ declared ``thermal_limit`` with ``kind="throttle"`` and required
3049
+ provenance. Resistance side: the instance's ``thermal.resistance`` from
3050
+ the author's heatsink/enclosure datasheet. A declared limit with missing
3051
+ resistance, ambient, or draw emits ``not_scored`` naming the gap — never
3052
+ a guessed RθJA (two-source).
3053
+
3054
+ Dissipation uses the worst declared device power mode (ampacity sibling):
3055
+ a throttle limit must hold regardless of which named mode the author had
3056
+ in mind. ``kind="shutdown"`` alone is ``not_scored`` — shutdown is
3057
+ context (see ``_THERMAL_SHUTDOWN_C``), not the fire threshold.
3058
+ """
3059
+ out: list[Finding] = []
3060
+ for device in c.devices:
3061
+ model = c.device_model(device.model)
3062
+ if model is None or model.thermal_limit is None:
3063
+ continue
3064
+
3065
+ limit = model.thermal_limit
3066
+ limit_source = limit.provenance.source
3067
+ sources = [THERMAL_TRIP_SOURCE, f"device model {model.id} thermal limit: {limit_source}"]
3068
+
3069
+ if limit.kind != "throttle":
3070
+ out.append(
3071
+ Finding(
3072
+ severity="not_scored",
3073
+ quantity=f"devices/{device.id}/thermal",
3074
+ observed=(
3075
+ f"device model {model.id!r} declares a {limit.kind} trip at "
3076
+ f"{limit.temperature}; thermal budget scores the software-throttle "
3077
+ "trip point only"
3078
+ ),
3079
+ expected=(
3080
+ "a thermal_limit with kind 'throttle' (shutdown may be cited as "
3081
+ f"context; published hardware shutdown is "
3082
+ f"{format_magnitude(_THERMAL_SHUTDOWN_C, 'C')})"
3083
+ ),
3084
+ owner=device.owner,
3085
+ source="; ".join(sources),
3086
+ action=(
3087
+ "declare the module's software-throttle trip point with provenance, "
3088
+ "or accept that this device is excluded from the thermal-budget gate"
3089
+ ),
3090
+ )
3091
+ )
3092
+ continue
3093
+
3094
+ thermal = device.thermal
3095
+ missing: list[str] = []
3096
+ if thermal is None or thermal.resistance is None:
3097
+ missing.append("published thermal resistance (RθJA)")
3098
+ if thermal is None or thermal.ambient is None:
3099
+ missing.append("declared ambient")
3100
+ draw = _device_worst_draw_w(device)
3101
+ if draw is None:
3102
+ missing.append("device power draw")
3103
+
3104
+ if missing:
3105
+ gap = ", ".join(missing)
3106
+ out.append(
3107
+ Finding(
3108
+ severity="not_scored",
3109
+ quantity=f"devices/{device.id}/thermal",
3110
+ observed=(
3111
+ f"device model {model.id!r} declares throttle limit "
3112
+ f"{limit.temperature}, but {gap} "
3113
+ f"{'is' if len(missing) == 1 else 'are'} missing; "
3114
+ "thermal budget not scored"
3115
+ ),
3116
+ expected=(
3117
+ "published enclosure/heatsink RθJA with provenance, declared "
3118
+ "ambient, and device power draw so Tj = P·RθJA + Tamb can be scored"
3119
+ ),
3120
+ owner=device.owner,
3121
+ source="; ".join(sources),
3122
+ action=(
3123
+ "lock the missing thermal operands with datasheet provenance, "
3124
+ "or accept that this device is excluded from the thermal-budget gate"
3125
+ ),
3126
+ )
3127
+ )
3128
+ continue
3129
+
3130
+ assert thermal is not None and thermal.resistance is not None
3131
+ assert thermal.ambient is not None and draw is not None
3132
+ # Tj estimate vs published throttle limit — score on the DISSIPATION, not
3133
+ # merely on the operands being present. With a declared draw of 0 W,
3134
+ # Tj = 0·RθJA + Tamb collapses to ambient and "passes" a throttle limit
3135
+ # that was never tested against a load (the same vacuous-pass shape as the
3136
+ # other scored checks).
3137
+ if scores is not None and draw[0] > 0.0:
3138
+ scores.record(f"devices/{device.id}")
3139
+ if thermal.provenance is not None:
3140
+ sources.append(f"device {device.id} thermal resistance: {thermal.provenance.source}")
3141
+
3142
+ power_w, draw_mode = draw
3143
+ r_th = thermal.resistance.base()
3144
+ ambient_c = thermal.ambient.base()
3145
+ estimated_c = ambient_c + power_w * r_th
3146
+ limit_c = limit.temperature.base()
3147
+
3148
+ if estimated_c < limit_c:
3149
+ if scores is not None:
3150
+ headroom_c = limit_c - estimated_c
3151
+ scores.passed(
3152
+ f"devices/{device.id}/thermal",
3153
+ observed=f"estimated junction {format_magnitude(estimated_c, 'C')}",
3154
+ expected=_with_enforcement_provenance(
3155
+ f"<= {format_magnitude(limit_c, 'C')} throttle limit",
3156
+ ("temperature", limit.temperature_enforced_by),
3157
+ ),
3158
+ margin=f"{format_magnitude(headroom_c, 'C')} headroom",
3159
+ )
3160
+ continue
3161
+
3162
+ mode_note = f" (draw mode: {draw_mode})" if draw_mode != "nominal" else ""
3163
+ margin_c = estimated_c - limit_c
3164
+ out.append(
3165
+ Finding(
3166
+ severity="violation",
3167
+ quantity=f"devices/{device.id}/thermal",
3168
+ observed=(
3169
+ f"estimated junction {format_magnitude(estimated_c, 'C')} "
3170
+ f"(P={format_magnitude(power_w, 'W')} × "
3171
+ f"RθJA={format_magnitude(r_th, 'C/W')} + "
3172
+ f"Tamb={format_magnitude(ambient_c, 'C')}){mode_note}"
3173
+ ),
3174
+ expected=_with_enforcement_provenance(
3175
+ (
3176
+ f"<= {format_magnitude(limit_c, 'C')} published software-throttle "
3177
+ f"limit (hardware shutdown context: "
3178
+ f"{format_magnitude(_THERMAL_SHUTDOWN_C, 'C')})"
3179
+ ),
3180
+ ("temperature", limit.temperature_enforced_by),
3181
+ ),
3182
+ observed_quantity=Quantity(value=estimated_c, unit="C"),
3183
+ expected_quantity=Quantity(value=limit_c, unit="C"),
3184
+ delta=(
3185
+ f"+{format_magnitude(margin_c, 'C')} above throttle "
3186
+ f"({format_magnitude(limit_c, 'C')})"
3187
+ ),
3188
+ dependent=", ".join(device.depends) or None,
3189
+ owner=device.owner,
3190
+ source="; ".join(sources),
3191
+ action=(
3192
+ "reduce dissipation, improve the published thermal path (lower RθJA), "
3193
+ "lower ambient, or correct the sourced throttle limit"
3194
+ ),
3195
+ )
3196
+ )
3197
+
3198
+ return out
3199
+
3200
+
3201
+ # ---------------------------------------------------------------- RF / telemetry link budget
3202
+
3203
+
3204
+ # Friis free-space path loss uses SI metres and hertz; c is exact SI definition.
3205
+ _SPEED_OF_LIGHT_M_S = 299_792_458.0
3206
+
3207
+
3208
+ def free_space_path_loss_db(range_m: float, frequency_hz: float) -> float:
3209
+ """Free-space path loss (dB) from Friis: 20·log10(4π·d·f/c).
3210
+
3211
+ Operates on linear SI operands already extracted from Quantity; the result
3212
+ is a log-space dB figure for the link-budget sum. Not a units.py conversion.
3213
+ """
3214
+ if range_m <= 0.0 or frequency_hz <= 0.0:
3215
+ raise ValueError(
3216
+ f"FSPL requires positive range and frequency, got range_m={range_m}, "
3217
+ f"frequency_hz={frequency_hz}"
3218
+ )
3219
+ return 20.0 * math.log10(4.0 * math.pi * range_m * frequency_hz / _SPEED_OF_LIGHT_M_S)
3220
+
3221
+
3222
+ def check_link_budget(c: Contract, *, scores: _ScoreBook | None = None) -> list[Finding]:
3223
+ """Compare RF link fade margin at a declared range against the requirement.
3224
+
3225
+ A LINK BUDGET: transmit power + TX/RX antenna gains − free-space path loss
3226
+ − installation loss (feedline / connector / misc) versus receiver
3227
+ sensitivity, at the declared required range, leaving a fade margin. Fires
3228
+ when that margin falls below the contract's declared requirement (field
3229
+ design goal 20–30 dB). Under-specified fade margin is the leading
3230
+ documented cause of intermittent link loss.
3231
+
3232
+ This is NOT the RF band-overlap check, which was rejected: band
3233
+ overlap has no true-positive class (2.4 GHz RC and WiFi coexist by
3234
+ design). A link budget has a real failure mode and a true-positive class.
3235
+
3236
+ Engagement: only devices that declare ``link_budget`` are considered; an
3237
+ absent block is silent (optional, like an undeclared input-voltage range).
3238
+ ``installation_loss`` is mandatory on that block: omitting it fails
3239
+ schema validation so no operator ever sees an unqualified margin that
3240
+ silently ignored feedline loss. Missing radio datasheet operands, carrier
3241
+ frequency, or either antenna gain emit ``not_scored`` — never a bundled
3242
+ 2 dBi monopole and never an assumed mid-band channel (e.g. 915 MHz).
3243
+ Arithmetic stays in log space (dBm + dBi − dB = dBm); dimensions are
3244
+ validated via Quantity.
3245
+ """
3246
+ out: list[Finding] = []
3247
+ for device in c.devices:
3248
+ budget = device.link_budget
3249
+ if budget is None:
3250
+ continue
3251
+
3252
+ quantity = f"devices/{device.id}/link_budget"
3253
+ model = c.device_model(device.model)
3254
+ radio = model.radio if model is not None else None
3255
+
3256
+ gaps: list[str] = []
3257
+ if radio is None:
3258
+ gaps.append(
3259
+ "device model radio datasheet operands (tx_power, rx_sensitivity, air_rate)"
3260
+ )
3261
+ if budget.carrier_frequency is None:
3262
+ gaps.append("carrier_frequency")
3263
+ if budget.tx_antenna is None:
3264
+ gaps.append("tx_antenna gain")
3265
+ if budget.rx_antenna is None:
3266
+ gaps.append("rx_antenna gain")
3267
+
3268
+ if gaps:
3269
+ out.append(
3270
+ Finding(
3271
+ severity="not_scored",
3272
+ quantity=quantity,
3273
+ observed=(
3274
+ "link budget incomplete (" + ", ".join(gaps) + "); link budget not scored"
3275
+ ),
3276
+ expected=(
3277
+ "radio datasheet operands, carrier frequency, and TX/RX "
3278
+ "antenna gains with antenna-datasheet provenance"
3279
+ ),
3280
+ owner=device.owner,
3281
+ source=(
3282
+ model.provenance.source
3283
+ if model is not None and model.provenance is not None
3284
+ else None
3285
+ ),
3286
+ action=(
3287
+ "declare the missing RF operands with provenance, or accept "
3288
+ "that this radio's link budget is unchecked"
3289
+ ),
3290
+ )
3291
+ )
3292
+ continue
3293
+
3294
+ assert radio is not None
3295
+ assert budget.carrier_frequency is not None
3296
+ assert budget.tx_antenna is not None
3297
+ assert budget.rx_antenna is not None
3298
+
3299
+ # Fade margin vs required — all RF operands populated.
3300
+ if scores is not None:
3301
+ scores.record(quantity)
3302
+
3303
+ # Log-space sum: Pt + Gt + Gr − FSPL − L_install − Sr
3304
+ # (all dBm / dBi / dB). Quantity.base yields the dimension base
3305
+ # (dBm, dB, m, Hz) — no dBm↔W conversion; that belongs nowhere in
3306
+ # units.py.
3307
+ pt_dbm = radio.tx_power.base()
3308
+ sr_dbm = radio.rx_sensitivity.base()
3309
+ gt_dbi = budget.tx_antenna.gain.base()
3310
+ gr_dbi = budget.rx_antenna.gain.base()
3311
+ range_m = budget.required_range.base()
3312
+ freq_hz = budget.carrier_frequency.base()
3313
+ required_db = budget.fade_margin_required.base()
3314
+ install_loss_db = budget.installation_loss.base()
3315
+
3316
+ fspl_db = free_space_path_loss_db(range_m, freq_hz)
3317
+ margin_db = pt_dbm + gt_dbi + gr_dbi - fspl_db - install_loss_db - sr_dbm
3318
+
3319
+ if margin_db >= required_db:
3320
+ if scores is not None:
3321
+ headroom_db = margin_db - required_db
3322
+ scores.passed(
3323
+ quantity,
3324
+ observed=f"{format_magnitude(margin_db, 'dB')} fade margin",
3325
+ expected=_with_enforcement_provenance(
3326
+ f">= {format_magnitude(required_db, 'dB')} required",
3327
+ ("fade_margin_required", budget.fade_margin_required_enforced_by),
3328
+ ),
3329
+ margin=f"{format_magnitude(headroom_db, 'dB')} to spare",
3330
+ )
3331
+ continue
3332
+
3333
+ sources: list[str] = []
3334
+ if model is not None and model.provenance is not None:
3335
+ sources.append(f"device model {model.id} radio: {model.provenance.source}")
3336
+ sources.append(f"tx antenna: {budget.tx_antenna.provenance.source}")
3337
+ sources.append(f"rx antenna: {budget.rx_antenna.provenance.source}")
3338
+ shortfall_db = required_db - margin_db
3339
+
3340
+ out.append(
3341
+ Finding(
3342
+ severity="violation",
3343
+ quantity=quantity,
3344
+ observed=(
3345
+ f"fade margin {format_magnitude(margin_db, 'dB')} at "
3346
+ f"{format_magnitude(range_m, 'm')} "
3347
+ f"(FSPL {format_magnitude(fspl_db, 'dB')}, "
3348
+ f"installation loss {format_magnitude(install_loss_db, 'dB')}, "
3349
+ f"air rate {radio.air_rate})"
3350
+ ),
3351
+ expected=_with_enforcement_provenance(
3352
+ f">= {format_magnitude(required_db, 'dB')} required fade margin",
3353
+ ("fade_margin_required", budget.fade_margin_required_enforced_by),
3354
+ ),
3355
+ observed_quantity=Quantity(value=margin_db, unit="dB"),
3356
+ expected_quantity=Quantity(value=required_db, unit="dB"),
3357
+ delta=f"-{format_magnitude(shortfall_db, 'dB')}",
3358
+ dependent=(f"device_models/{device.model}/radio, devices/{device.id}/link_budget"),
3359
+ owner=device.owner,
3360
+ source="; ".join(sources),
3361
+ action=(
3362
+ "raise TX power, improve antennas, reduce required range, "
3363
+ "cut feedline/connector loss, or lower the air rate to a "
3364
+ "more sensitive row — or correct the sourced operands"
3365
+ ),
3366
+ )
3367
+ )
3368
+ return out
3369
+
3370
+
3371
+ # ---------------------------------------------------------------- scene placement
3372
+
3373
+
3374
+ def _fmt_point(point: tuple[float, float, float]) -> str:
3375
+ return "(" + ", ".join(f"{c:.3f}" for c in point) + ")"
3376
+
3377
+
3378
+ def check_scene_placement(
3379
+ c: Contract, scene: Scene, *, scores: _ScoreBook | None = None
3380
+ ) -> list[Finding]:
3381
+ """Compare a device's declared ``mount_xyz`` to where the scene actually put it.
3382
+
3383
+ Never run by ``lockwire check`` (the terminal never draws); this is a
3384
+ page-only check called from ``gui.build_payload`` when a scene is given.
3385
+ The YAML is the truth: this only scores whether the model agrees,
3386
+ within ``PLACEMENT_TOLERANCE_M``, in the model's single root-link frame.
3387
+ """
3388
+ resolved = resolve_scene(c, scene)
3389
+ parts_by_id: dict[str, "object"] = {}
3390
+ for part, part_id in zip(scene.parts, resolved.part_ids, strict=True):
3391
+ if part_id is not None:
3392
+ parts_by_id.setdefault(part_id, part) # one id, first mesh's origin
3393
+
3394
+ root, _transforms, _limitations = _fixed_frame_transforms(c)
3395
+
3396
+ out: list[Finding] = []
3397
+ for device in c.devices:
3398
+ quantity = f"devices/{device.id}/placement"
3399
+ part = parts_by_id.get(device.id)
3400
+ if part is None:
3401
+ out.append(
3402
+ Finding(
3403
+ severity="not_scored",
3404
+ quantity=quantity,
3405
+ observed="not in the model",
3406
+ expected="a part in the scene named by this device's id",
3407
+ action=(
3408
+ "add a node with extras.lockwire.id set to this device's id, "
3409
+ "or name the node the id"
3410
+ ),
3411
+ )
3412
+ )
3413
+ continue
3414
+
3415
+ physical = device.physical
3416
+ if physical is None or physical.mount_frame is None:
3417
+ out.append(
3418
+ Finding(
3419
+ severity="not_scored",
3420
+ quantity=quantity,
3421
+ observed="no mount frame",
3422
+ expected="a declared mount frame",
3423
+ action="declare mount_frame (and mount_xyz) with a source and witness",
3424
+ )
3425
+ )
3426
+ continue
3427
+ if physical.mount_xyz is None:
3428
+ out.append(
3429
+ Finding(
3430
+ severity="not_scored",
3431
+ quantity=quantity,
3432
+ observed="drawn where the model put it",
3433
+ expected="a declared mount point",
3434
+ action="declare mount_xyz with a source and witness",
3435
+ )
3436
+ )
3437
+ continue
3438
+
3439
+ expected_m = tuple(component.base() for component in physical.mount_xyz)
3440
+ frame_part = parts_by_id.get(physical.mount_frame)
3441
+ if physical.mount_frame != root and frame_part is not None:
3442
+ observed_m = tuple(
3443
+ a - b for a, b in zip(part.translation, frame_part.translation, strict=True)
3444
+ )
3445
+ else:
3446
+ observed_m = part.translation
3447
+ distance_m = math.sqrt(
3448
+ sum((a - b) ** 2 for a, b in zip(expected_m, observed_m, strict=True))
3449
+ )
3450
+
3451
+ if scores is not None:
3452
+ scores.record(quantity)
3453
+ if distance_m <= PLACEMENT_TOLERANCE_M:
3454
+ if scores is not None:
3455
+ scores.passed(
3456
+ quantity,
3457
+ observed=f"{_fmt_point(observed_m)} m",
3458
+ expected=f"{_fmt_point(expected_m)} m",
3459
+ margin=f"{distance_m * 1000:.1f} mm off",
3460
+ )
3461
+ continue
3462
+ out.append(
3463
+ Finding(
3464
+ severity="warning",
3465
+ quantity=quantity,
3466
+ observed=f"{_fmt_point(observed_m)} m in the model",
3467
+ expected=f"within 5 mm of {_fmt_point(expected_m)} m declared",
3468
+ delta=f"{distance_m * 1000:.1f} mm",
3469
+ action="move the part in the model, or declare the measured position with a witness",
3470
+ )
3471
+ )
3472
+
3473
+ for lw_id in resolved.scene_only:
3474
+ out.append(
3475
+ Finding(
3476
+ severity="not_scored",
3477
+ quantity=f"scene/{lw_id}/unclaimed",
3478
+ observed="the model has a part the contract doesn't name",
3479
+ expected="a matching device, rail, bus or link in the contract",
3480
+ action="add it to the contract, or remove the id from the scene",
3481
+ )
3482
+ )
3483
+ return out