lockwire 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lockwire/__init__.py +9 -0
- lockwire/__main__.py +9 -0
- lockwire/advice.py +119 -0
- lockwire/api.py +1216 -0
- lockwire/api_payload.py +985 -0
- lockwire/bench.py +182 -0
- lockwire/blame.py +100 -0
- lockwire/catalog.py +100 -0
- lockwire/catalog_seed.yaml +545 -0
- lockwire/checks.py +3483 -0
- lockwire/cli.py +1799 -0
- lockwire/cli_line.py +73 -0
- lockwire/constraints.py +313 -0
- lockwire/copy.py +302 -0
- lockwire/describe.py +682 -0
- lockwire/edit.py +2098 -0
- lockwire/emit_ts.py +150 -0
- lockwire/export_mhs.py +115 -0
- lockwire/factdiff.py +254 -0
- lockwire/forces.py +334 -0
- lockwire/gitref.py +188 -0
- lockwire/hw.py +388 -0
- lockwire/mcp_server.py +276 -0
- lockwire/mechanism.py +379 -0
- lockwire/mesh_dae.py +555 -0
- lockwire/mesh_simple.py +198 -0
- lockwire/page.py +1485 -0
- lockwire/plan.py +849 -0
- lockwire/plan_payload.py +151 -0
- lockwire/plugins.py +70 -0
- lockwire/report.py +848 -0
- lockwire/report_html.py +707 -0
- lockwire/report_json.py +168 -0
- lockwire/review_draft.py +315 -0
- lockwire/scan.py +732 -0
- lockwire/scene.py +535 -0
- lockwire/scene_import.py +309 -0
- lockwire/scene_urdf.py +750 -0
- lockwire/scene_view.py +376 -0
- lockwire/schema.py +3616 -0
- lockwire/sdf.py +240 -0
- lockwire/serve.py +1256 -0
- lockwire/start_operation.py +92 -0
- lockwire/statics.py +77 -0
- lockwire/units.py +115 -0
- lockwire/urdf.py +348 -0
- lockwire/vcs.py +402 -0
- lockwire/whatif.py +161 -0
- lockwire-0.2.0.dist-info/METADATA +192 -0
- lockwire-0.2.0.dist-info/RECORD +52 -0
- lockwire-0.2.0.dist-info/WHEEL +4 -0
- lockwire-0.2.0.dist-info/entry_points.txt +3 -0
lockwire/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
|