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/plan.py
ADDED
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
"""An agent's intended plan, checked against the contract before anything executes.
|
|
2
|
+
|
|
3
|
+
Mirrors the contract loader's guards (size cap, no YAML anchors/aliases) so a
|
|
4
|
+
plan file is trusted exactly as much as a lockfile. See ``validate_plan`` for
|
|
5
|
+
the scoring rules, and ``docs/plan_validation.md`` for the product view.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Annotated, Any, Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import Field
|
|
13
|
+
|
|
14
|
+
from lockwire import units
|
|
15
|
+
from lockwire.advice import Alternative
|
|
16
|
+
from lockwire.copy import human_name
|
|
17
|
+
from lockwire.checks import (
|
|
18
|
+
_worst_rail_power,
|
|
19
|
+
power_rollup,
|
|
20
|
+
unknown_draw_message,
|
|
21
|
+
unusable_effort_finding,
|
|
22
|
+
)
|
|
23
|
+
from lockwire.report import Finding, RecordReport
|
|
24
|
+
from lockwire.schema import (
|
|
25
|
+
MAX_CONTRACT_BYTES,
|
|
26
|
+
Base,
|
|
27
|
+
Band,
|
|
28
|
+
Contract,
|
|
29
|
+
DeviceInstance,
|
|
30
|
+
PowerDraw,
|
|
31
|
+
PowerQuantity,
|
|
32
|
+
Rail,
|
|
33
|
+
_load_yaml_guarded,
|
|
34
|
+
_reject_yaml_anchors_aliases,
|
|
35
|
+
_yaml,
|
|
36
|
+
)
|
|
37
|
+
from lockwire.units import UnitError
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class JointTarget(Base):
|
|
41
|
+
kind: Literal["joint_target"] = "joint_target"
|
|
42
|
+
joint: str
|
|
43
|
+
position: float | None = None # rad or m
|
|
44
|
+
velocity: float | None = None # rad/s or m/s
|
|
45
|
+
effort: float | None = None # N*m or N
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RailLoad(Base):
|
|
49
|
+
"""An additional draw placed on a rail."""
|
|
50
|
+
|
|
51
|
+
kind: Literal["rail_load"] = "rail_load"
|
|
52
|
+
rail: str # -> Rail.id
|
|
53
|
+
draw: PowerQuantity
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SetPowerMode(Base):
|
|
57
|
+
"""Switch a device to a declared power mode."""
|
|
58
|
+
|
|
59
|
+
kind: Literal["set_power_mode"] = "set_power_mode"
|
|
60
|
+
device: str # -> DeviceInstance.id
|
|
61
|
+
mode: str # -> PowerMode.name (or "nominal")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Publish(Base):
|
|
65
|
+
"""A ROS message an agent intends to send."""
|
|
66
|
+
|
|
67
|
+
kind: Literal["publish"] = "publish"
|
|
68
|
+
topic: str
|
|
69
|
+
msg_type: str
|
|
70
|
+
msg: dict[str, Any] = {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
Step = Annotated[
|
|
74
|
+
JointTarget | RailLoad | SetPowerMode | Publish,
|
|
75
|
+
Field(discriminator="kind"),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Plan(Base):
|
|
80
|
+
steps: list[Step] = []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AssertionOutcome(Base):
|
|
84
|
+
"""Machine-readable disposition for one assertion submitted in a plan."""
|
|
85
|
+
|
|
86
|
+
path: str
|
|
87
|
+
status: Literal["PASS", "FAIL", "UNSCORED", "ERROR"]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class PlanReport(RecordReport):
|
|
91
|
+
"""Findings plus complete, list-compatible assertion coverage."""
|
|
92
|
+
|
|
93
|
+
outcomes: list[AssertionOutcome]
|
|
94
|
+
errors: int
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
findings: list[Finding] | None = None,
|
|
99
|
+
outcomes: list[AssertionOutcome] | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
self.outcomes = outcomes or []
|
|
102
|
+
self.errors = sum(outcome.status == "ERROR" for outcome in self.outcomes)
|
|
103
|
+
scored = sum(outcome.status in {"PASS", "FAIL"} for outcome in self.outcomes)
|
|
104
|
+
super().__init__(findings, scored=scored)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_plan(path: Path) -> Plan:
|
|
108
|
+
"""Load a plan.yaml with the same size and anchor guards as ``load_contract``."""
|
|
109
|
+
size = path.stat().st_size
|
|
110
|
+
if size > MAX_CONTRACT_BYTES:
|
|
111
|
+
raise ValueError(f"plan file exceeds size limit ({size} bytes > {MAX_CONTRACT_BYTES})")
|
|
112
|
+
with open(path, encoding="utf-8") as f:
|
|
113
|
+
data = _load_yaml_guarded(_yaml(), f)
|
|
114
|
+
_reject_yaml_anchors_aliases(data)
|
|
115
|
+
return Plan.model_validate(data)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------- power helpers
|
|
119
|
+
#
|
|
120
|
+
# Rail base watts is the chain-inclusive rollup checks._worst_rail_power uses
|
|
121
|
+
# for `lockwire check` (via ampacity): it climbs child rails through
|
|
122
|
+
# `upstream`/`efficiency` up to `rail_id`, maximized over nominal and every
|
|
123
|
+
# declared PowerMode. Reusing it here (instead of a plan-local direct-rail
|
|
124
|
+
# sum) keeps `lockwire plan` and `lockwire check` agreeing on one number per
|
|
125
|
+
# rail — see tests/test_plan.py's agreement test.
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _rail_worst_w_with_device_pinned(
|
|
129
|
+
contract: Contract, rail_id: str, device_id: str, w: float
|
|
130
|
+
) -> tuple[float, set[str]]:
|
|
131
|
+
"""``_worst_rail_power(rail_id)`` with ``device_id`` pinned to ``w`` watts.
|
|
132
|
+
|
|
133
|
+
Used for a SetPowerMode step: the agent is committing this one device to
|
|
134
|
+
a named mode, so its contribution should not also vary across the
|
|
135
|
+
rollup's own worst-mode search. Swap the device's declared draw for a
|
|
136
|
+
single fixed value (no other modes) in a shallow contract copy, then call
|
|
137
|
+
the same helper again — the efficiency chain from the device's rail up to
|
|
138
|
+
``rail_id`` is whatever _worst_rail_power already does, not reimplemented.
|
|
139
|
+
Returns ``(watts, unknown_consumers)`` — the caller must not score a peak
|
|
140
|
+
when unknown_consumers is non-empty.
|
|
141
|
+
"""
|
|
142
|
+
device = contract.device(device_id)
|
|
143
|
+
assert device is not None and device.power is not None
|
|
144
|
+
pinned_power = device.power.model_copy(
|
|
145
|
+
update={"draw": PowerQuantity(value=w, unit="W"), "modes": []}
|
|
146
|
+
)
|
|
147
|
+
pinned_device = device.model_copy(update={"power": pinned_power})
|
|
148
|
+
devices = [pinned_device if d.id == device_id else d for d in contract.devices]
|
|
149
|
+
swapped = contract.model_copy(update={"devices": devices})
|
|
150
|
+
watts, _, _, unknown = _worst_rail_power(swapped, rail_id)
|
|
151
|
+
return watts, unknown
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _unknown_finding_for(contract: Contract, path: str, unknown: set[str]) -> Finding:
|
|
155
|
+
"""One not_scored Finding naming an unknown-draw consumer found while
|
|
156
|
+
walking a rail chain (never silently score a peak around a gap)."""
|
|
157
|
+
device_id = sorted(unknown)[0]
|
|
158
|
+
device = contract.device(device_id)
|
|
159
|
+
device_rail = device.power.rail if device is not None and device.power is not None else "?"
|
|
160
|
+
return Finding(
|
|
161
|
+
severity="not_scored",
|
|
162
|
+
quantity=path,
|
|
163
|
+
observed=unknown_draw_message(device_id, device_rail),
|
|
164
|
+
expected="a declared draw for this device",
|
|
165
|
+
action=f"declare {device_id}'s power draw, or accept this step as not scored",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _rail_capacity_chain(
|
|
170
|
+
contract: Contract, rail_id: str, rail_extra: dict[str, float]
|
|
171
|
+
) -> list[tuple[Rail, float, float, set[str]]]:
|
|
172
|
+
"""The named rail plus every ancestor up to the battery, each with its own
|
|
173
|
+
rolled-up watts (``_worst_rail_power``, which already includes descendant
|
|
174
|
+
devices) plus the plan-time extra load propagated onto it. Callers walk
|
|
175
|
+
this list and stop at the first unknown-consumer gap or capacity breach."""
|
|
176
|
+
out: list[tuple[Rail, float, float, set[str]]] = []
|
|
177
|
+
seen: set[str] = set()
|
|
178
|
+
current = contract.rail(rail_id)
|
|
179
|
+
while current is not None and current.id not in seen:
|
|
180
|
+
seen.add(current.id)
|
|
181
|
+
base_w, _, _, unknown = _worst_rail_power(contract, current.id)
|
|
182
|
+
total_w = base_w + rail_extra.get(current.id, 0.0)
|
|
183
|
+
out.append((current, total_w, current.capacity.base(), unknown))
|
|
184
|
+
if current.upstream in (None, "battery"):
|
|
185
|
+
break
|
|
186
|
+
current = contract.rail(current.upstream)
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _with_enforcement_provenance(expected: str, *limits: tuple[str, str | None]) -> str:
|
|
191
|
+
"""Annotate an existing checked limit without changing plan scoring."""
|
|
192
|
+
declarations = [f"{value} ({name})" for name, value in limits if value is not None]
|
|
193
|
+
if not declarations:
|
|
194
|
+
return expected
|
|
195
|
+
return (
|
|
196
|
+
f"{expected} (declared enforcement provenance: {', '.join(declarations)}; "
|
|
197
|
+
"metadata only; does not prove physical protection)"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _resolve_field(msg: dict[str, Any], field: str) -> tuple[float | None, str | None]:
|
|
202
|
+
"""Resolve a dotted path, distinguishing an absent value from a non-number."""
|
|
203
|
+
node: Any = msg
|
|
204
|
+
for part in field.split("."):
|
|
205
|
+
if not isinstance(node, dict) or part not in node:
|
|
206
|
+
return None, "missing"
|
|
207
|
+
node = node[part]
|
|
208
|
+
if isinstance(node, bool) or not isinstance(node, int | float):
|
|
209
|
+
return None, "nonnumeric"
|
|
210
|
+
return float(node), None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _score_max_rate(
|
|
214
|
+
out: list[Finding],
|
|
215
|
+
record: "Callable[[str, Literal['PASS', 'FAIL', 'UNSCORED', 'ERROR']], None]",
|
|
216
|
+
*,
|
|
217
|
+
path: str,
|
|
218
|
+
band: Band,
|
|
219
|
+
previous: float | None,
|
|
220
|
+
current: float,
|
|
221
|
+
rate_hz: float | None,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Score one published value's rate of change against ``Band.max_rate``.
|
|
224
|
+
|
|
225
|
+
A step whose endpoints are both inside the band but
|
|
226
|
+
whose rate of change between them exceeds the declared bound is a finding.
|
|
227
|
+
|
|
228
|
+
Compare now, predict eventually, never actuate: this
|
|
229
|
+
scores and reports only; nothing here reaches a device.
|
|
230
|
+
|
|
231
|
+
Where the rate cannot be computed it is said out loud. A plan carries no
|
|
232
|
+
timestamps, so the elapsed time between two publishes on a topic comes from
|
|
233
|
+
the contract's own ``driver.nominal_rate_hz``. Missing either operand — the
|
|
234
|
+
previous value or the declared rate — is ``not_scored`` naming which one is
|
|
235
|
+
missing, never a silent pass: a rate limit that quietly stops applying is a
|
|
236
|
+
rate limit that has stopped biting.
|
|
237
|
+
"""
|
|
238
|
+
if previous is None:
|
|
239
|
+
out.append(
|
|
240
|
+
Finding(
|
|
241
|
+
severity="not_scored",
|
|
242
|
+
quantity=path,
|
|
243
|
+
observed=f"{current:g} {band.unit} is the first value for this signal in the plan",
|
|
244
|
+
expected=f"two published values, to compare against max_rate {band.max_rate:g} {band.unit}/s",
|
|
245
|
+
action="add the preceding publish step, or accept this rate bound as not scored",
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
record(path, "UNSCORED")
|
|
249
|
+
return
|
|
250
|
+
if rate_hz is None:
|
|
251
|
+
out.append(
|
|
252
|
+
Finding(
|
|
253
|
+
severity="not_scored",
|
|
254
|
+
quantity=path,
|
|
255
|
+
observed="the driver declares no nominal_rate_hz, so the time between steps is unknown",
|
|
256
|
+
expected=f"a declared publish rate, to compare against max_rate {band.max_rate:g} {band.unit}/s",
|
|
257
|
+
action="declare driver.nominal_rate_hz for this topic to score the rate of change",
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
record(path, "UNSCORED")
|
|
261
|
+
return
|
|
262
|
+
rate = abs(current - previous) * rate_hz
|
|
263
|
+
assert band.max_rate is not None
|
|
264
|
+
if rate > band.max_rate:
|
|
265
|
+
out.append(
|
|
266
|
+
Finding(
|
|
267
|
+
severity="violation",
|
|
268
|
+
quantity=path,
|
|
269
|
+
observed=(
|
|
270
|
+
f"{rate:g} {band.unit}/s "
|
|
271
|
+
f"({previous:g} -> {current:g} {band.unit} at {rate_hz:g} Hz)"
|
|
272
|
+
),
|
|
273
|
+
expected=f"<= max_rate {band.max_rate:g} {band.unit}/s{band.why_suffix}",
|
|
274
|
+
action=(
|
|
275
|
+
"both endpoints are inside the band but the move between them is "
|
|
276
|
+
"faster than the declared rate — slow the step or split it"
|
|
277
|
+
),
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
record(path, "FAIL")
|
|
281
|
+
else:
|
|
282
|
+
record(path, "PASS")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def validate_plan(contract: Contract, plan: "Plan") -> PlanReport:
|
|
286
|
+
"""Check an agent's intended plan against the contract before anything executes.
|
|
287
|
+
|
|
288
|
+
Never invents a limit: anything the contract does not declare comes back
|
|
289
|
+
``not_scored``, with ``expected`` naming what is missing.
|
|
290
|
+
"""
|
|
291
|
+
if not plan.steps:
|
|
292
|
+
return PlanReport(
|
|
293
|
+
[
|
|
294
|
+
Finding(
|
|
295
|
+
severity="not_scored",
|
|
296
|
+
quantity="plan/steps",
|
|
297
|
+
observed="no steps",
|
|
298
|
+
expected="at least one step",
|
|
299
|
+
action="add steps to the plan before validating",
|
|
300
|
+
)
|
|
301
|
+
],
|
|
302
|
+
[AssertionOutcome(path="plan/steps", status="UNSCORED")],
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Whole-plan RailLoad totals, keyed by rail id (D-accumulate: two steps on
|
|
306
|
+
# the same rail add up, not just the last one).
|
|
307
|
+
railload_totals: dict[str, float] = {}
|
|
308
|
+
for step in plan.steps:
|
|
309
|
+
if isinstance(step, RailLoad) and contract.rail(step.rail) is not None:
|
|
310
|
+
railload_totals[step.rail] = railload_totals.get(step.rail, 0.0) + step.draw.base()
|
|
311
|
+
|
|
312
|
+
# Propagate every named rail's whole-plan RailLoad total up through the
|
|
313
|
+
# rail tree once (a load on a child rail must also be checked
|
|
314
|
+
# against every ancestor's capacity, and two individually-legal loads on
|
|
315
|
+
# sibling children must combine at a shared parent). Each total is
|
|
316
|
+
# expressed as one synthetic device on its rail and handed to
|
|
317
|
+
# `power_rollup`, whose upstream/efficiency walk is the only copy of that
|
|
318
|
+
# walk — same shallow-model_copy pattern as
|
|
319
|
+
# _rail_worst_w_with_device_pinned. Real devices are swapped out because
|
|
320
|
+
# their watts are already in `_worst_rail_power`'s base figure.
|
|
321
|
+
synthetic = [
|
|
322
|
+
DeviceInstance(
|
|
323
|
+
id=f"plan.railload.{rail_id}",
|
|
324
|
+
model="plan.railload",
|
|
325
|
+
owner="lockwire plan",
|
|
326
|
+
power=PowerDraw(rail=rail_id, draw=PowerQuantity(value=extra_w, unit="W")),
|
|
327
|
+
)
|
|
328
|
+
for rail_id, extra_w in railload_totals.items()
|
|
329
|
+
]
|
|
330
|
+
rail_extra, *_ = power_rollup(contract.model_copy(update={"devices": synthetic}), "nominal")
|
|
331
|
+
|
|
332
|
+
out: list[Finding] = []
|
|
333
|
+
outcomes: list[AssertionOutcome] = []
|
|
334
|
+
|
|
335
|
+
def record(path: str, status: Literal["PASS", "FAIL", "UNSCORED", "ERROR"]) -> None:
|
|
336
|
+
outcomes.append(AssertionOutcome(path=path, status=status))
|
|
337
|
+
|
|
338
|
+
joints = {joint.name: joint for joint in contract.model.joints} if contract.model else {}
|
|
339
|
+
|
|
340
|
+
# The value a signal last carried in this plan, in its
|
|
341
|
+
# hard limit's unit, keyed by (topic, signal) — the left operand a
|
|
342
|
+
# `Band.max_rate` comparison needs. A step can sit inside the band at both
|
|
343
|
+
# ends and still be destructive in between; nothing else in the plan
|
|
344
|
+
# remembers the previous value.
|
|
345
|
+
#
|
|
346
|
+
# Compare now, predict eventually, never actuate. The rate
|
|
347
|
+
# bound is *scored* here and never enforced on a device — a crossing is a
|
|
348
|
+
# Finding, exactly like every other lockwire comparison.
|
|
349
|
+
previous_published: dict[tuple[str, str], float] = {}
|
|
350
|
+
|
|
351
|
+
for i, step in enumerate(plan.steps):
|
|
352
|
+
base = f"plan/steps/{i}"
|
|
353
|
+
|
|
354
|
+
if isinstance(step, JointTarget):
|
|
355
|
+
joint = joints.get(step.joint)
|
|
356
|
+
if joint is None:
|
|
357
|
+
path = f"{base}/joint_target/joint"
|
|
358
|
+
out.append(
|
|
359
|
+
Finding(
|
|
360
|
+
severity="not_scored",
|
|
361
|
+
quantity=path,
|
|
362
|
+
observed=f"joint {step.joint!r} not found",
|
|
363
|
+
expected="a joint declared in the contract model",
|
|
364
|
+
action="check the joint name against the contract",
|
|
365
|
+
alternatives=tuple(
|
|
366
|
+
Alternative(
|
|
367
|
+
text=f"use declared model joint {candidate!r}",
|
|
368
|
+
value=1.0,
|
|
369
|
+
unit="count",
|
|
370
|
+
kind="model",
|
|
371
|
+
)
|
|
372
|
+
for candidate in sorted(joints)
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
record(path, "UNSCORED")
|
|
377
|
+
continue
|
|
378
|
+
|
|
379
|
+
limits = joint.limits
|
|
380
|
+
if step.position is not None:
|
|
381
|
+
path = f"{base}/joint_target/position"
|
|
382
|
+
if limits is None or limits.lower is None or limits.upper is None:
|
|
383
|
+
out.append(
|
|
384
|
+
Finding(
|
|
385
|
+
severity="not_scored",
|
|
386
|
+
quantity=path,
|
|
387
|
+
observed=f"{step.position}",
|
|
388
|
+
expected="the contract declares no position limits for this joint",
|
|
389
|
+
action="declare limits.lower/upper for this joint to score position",
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
record(path, "UNSCORED")
|
|
393
|
+
elif not (limits.lower <= step.position <= limits.upper):
|
|
394
|
+
out.append(
|
|
395
|
+
Finding(
|
|
396
|
+
severity="violation",
|
|
397
|
+
quantity=path,
|
|
398
|
+
observed=f"{step.position}",
|
|
399
|
+
expected=_with_enforcement_provenance(
|
|
400
|
+
f"inside [{limits.lower}, {limits.upper}]",
|
|
401
|
+
("lower", limits.lower_enforced_by),
|
|
402
|
+
("upper", limits.upper_enforced_by),
|
|
403
|
+
),
|
|
404
|
+
action="pick a position inside the joint's declared limits",
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
record(path, "FAIL")
|
|
408
|
+
else:
|
|
409
|
+
record(path, "PASS")
|
|
410
|
+
|
|
411
|
+
if step.velocity is not None:
|
|
412
|
+
path = f"{base}/joint_target/velocity"
|
|
413
|
+
if limits is None or limits.velocity is None:
|
|
414
|
+
out.append(
|
|
415
|
+
Finding(
|
|
416
|
+
severity="not_scored",
|
|
417
|
+
quantity=path,
|
|
418
|
+
observed=f"{step.velocity}",
|
|
419
|
+
expected="the contract declares no velocity limit for this joint",
|
|
420
|
+
action="declare limits.velocity for this joint to score velocity",
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
record(path, "UNSCORED")
|
|
424
|
+
elif abs(step.velocity) > limits.velocity:
|
|
425
|
+
out.append(
|
|
426
|
+
Finding(
|
|
427
|
+
severity="violation",
|
|
428
|
+
quantity=path,
|
|
429
|
+
observed=f"{step.velocity}",
|
|
430
|
+
expected=_with_enforcement_provenance(
|
|
431
|
+
f"<= {limits.velocity}",
|
|
432
|
+
("velocity", limits.velocity_enforced_by),
|
|
433
|
+
),
|
|
434
|
+
action="reduce the commanded velocity",
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
record(path, "FAIL")
|
|
438
|
+
else:
|
|
439
|
+
record(path, "PASS")
|
|
440
|
+
|
|
441
|
+
if step.effort is not None:
|
|
442
|
+
path = f"{base}/joint_target/effort"
|
|
443
|
+
if limits is None or limits.effort is None:
|
|
444
|
+
out.append(
|
|
445
|
+
Finding(
|
|
446
|
+
severity="not_scored",
|
|
447
|
+
quantity=path,
|
|
448
|
+
observed=f"{step.effort}",
|
|
449
|
+
expected="the contract declares no effort limit for this joint",
|
|
450
|
+
action="declare limits.effort for this joint to score effort",
|
|
451
|
+
)
|
|
452
|
+
)
|
|
453
|
+
record(path, "UNSCORED")
|
|
454
|
+
elif limits.effort_limit is None:
|
|
455
|
+
out.append(unusable_effort_finding(path, limits.effort))
|
|
456
|
+
# no outcome row; the warning alone says torque is not checked
|
|
457
|
+
elif abs(step.effort) > limits.effort_limit:
|
|
458
|
+
out.append(
|
|
459
|
+
Finding(
|
|
460
|
+
severity="violation",
|
|
461
|
+
quantity=path,
|
|
462
|
+
observed=f"{step.effort}",
|
|
463
|
+
expected=_with_enforcement_provenance(
|
|
464
|
+
f"<= {limits.effort}",
|
|
465
|
+
("effort", limits.effort_enforced_by),
|
|
466
|
+
),
|
|
467
|
+
action="reduce the commanded effort",
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
record(path, "FAIL")
|
|
471
|
+
else:
|
|
472
|
+
record(path, "PASS")
|
|
473
|
+
|
|
474
|
+
driver_device = next(
|
|
475
|
+
(d for d in contract.devices if d.drives_joint == joint.name), None
|
|
476
|
+
)
|
|
477
|
+
actuator_path = f"{base}/joint_target/effort_actuator"
|
|
478
|
+
if driver_device is None:
|
|
479
|
+
out.append(
|
|
480
|
+
Finding(
|
|
481
|
+
severity="not_scored",
|
|
482
|
+
quantity=actuator_path,
|
|
483
|
+
observed="no device drives this joint",
|
|
484
|
+
expected="a device with drives_joint set to this joint",
|
|
485
|
+
action="declare which device drives this joint to score actuator effort",
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
record(actuator_path, "UNSCORED")
|
|
489
|
+
else:
|
|
490
|
+
model = contract.device_model(driver_device.model)
|
|
491
|
+
actuator = model.actuator if model is not None else None
|
|
492
|
+
if (
|
|
493
|
+
actuator is None
|
|
494
|
+
or actuator.torque_continuous is None
|
|
495
|
+
or actuator.gear_ratio is None
|
|
496
|
+
):
|
|
497
|
+
out.append(
|
|
498
|
+
Finding(
|
|
499
|
+
severity="not_scored",
|
|
500
|
+
quantity=actuator_path,
|
|
501
|
+
observed="actuator missing torque_continuous or gear_ratio",
|
|
502
|
+
expected="both declared on the driving device's model",
|
|
503
|
+
action="declare the actuator's continuous torque and gear ratio",
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
record(actuator_path, "UNSCORED")
|
|
507
|
+
else:
|
|
508
|
+
continuous = actuator.torque_continuous * actuator.gear_ratio
|
|
509
|
+
peak = (
|
|
510
|
+
actuator.torque_peak * actuator.gear_ratio
|
|
511
|
+
if actuator.torque_peak is not None
|
|
512
|
+
else None
|
|
513
|
+
)
|
|
514
|
+
if abs(step.effort) > continuous:
|
|
515
|
+
if peak is not None and abs(step.effort) <= peak:
|
|
516
|
+
out.append(
|
|
517
|
+
Finding(
|
|
518
|
+
severity="warning",
|
|
519
|
+
quantity=actuator_path,
|
|
520
|
+
observed=f"{step.effort} N*m",
|
|
521
|
+
expected=f"<= {continuous:.2f} N*m continuous geared torque",
|
|
522
|
+
action="sustained effort above continuous rating shortens actuator life",
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
record(actuator_path, "PASS")
|
|
526
|
+
else:
|
|
527
|
+
out.append(
|
|
528
|
+
Finding(
|
|
529
|
+
severity="violation",
|
|
530
|
+
quantity=actuator_path,
|
|
531
|
+
observed=f"{step.effort} N*m",
|
|
532
|
+
expected=f"<= {continuous:.2f} N*m continuous geared torque",
|
|
533
|
+
action="reduce the commanded effort or select a stronger actuator",
|
|
534
|
+
)
|
|
535
|
+
)
|
|
536
|
+
record(actuator_path, "FAIL")
|
|
537
|
+
else:
|
|
538
|
+
record(actuator_path, "PASS")
|
|
539
|
+
|
|
540
|
+
if step.position is None and step.velocity is None and step.effort is None:
|
|
541
|
+
path = f"{base}/joint_target"
|
|
542
|
+
out.append(
|
|
543
|
+
Finding(
|
|
544
|
+
severity="not_scored",
|
|
545
|
+
quantity=path,
|
|
546
|
+
observed=f"joint {step.joint!r} selected with no target values",
|
|
547
|
+
expected="at least one of position, velocity, or effort",
|
|
548
|
+
action="add a target value before validating this step",
|
|
549
|
+
)
|
|
550
|
+
)
|
|
551
|
+
record(path, "UNSCORED")
|
|
552
|
+
|
|
553
|
+
elif isinstance(step, RailLoad):
|
|
554
|
+
rail = contract.rail(step.rail)
|
|
555
|
+
if rail is None:
|
|
556
|
+
path = f"{base}/rail_load/rail"
|
|
557
|
+
out.append(
|
|
558
|
+
Finding(
|
|
559
|
+
severity="not_scored",
|
|
560
|
+
quantity=path,
|
|
561
|
+
observed=f"rail {step.rail!r} not found",
|
|
562
|
+
expected="a rail declared in the contract topology",
|
|
563
|
+
action=f"This robot has no rail named {human_name(step.rail)}",
|
|
564
|
+
)
|
|
565
|
+
)
|
|
566
|
+
record(path, "UNSCORED")
|
|
567
|
+
continue
|
|
568
|
+
path = f"{base}/rail_load/draw"
|
|
569
|
+
for checked_rail, total_w, capacity_w, unknown in _rail_capacity_chain(
|
|
570
|
+
contract, step.rail, rail_extra
|
|
571
|
+
):
|
|
572
|
+
if unknown:
|
|
573
|
+
out.append(_unknown_finding_for(contract, path, unknown))
|
|
574
|
+
record(path, "UNSCORED")
|
|
575
|
+
break
|
|
576
|
+
if total_w > capacity_w:
|
|
577
|
+
out.append(
|
|
578
|
+
Finding(
|
|
579
|
+
severity="violation",
|
|
580
|
+
quantity=path,
|
|
581
|
+
observed=f"{total_w:.2f} W rolled-up draw on {checked_rail.id}",
|
|
582
|
+
expected=_with_enforcement_provenance(
|
|
583
|
+
f"<= {capacity_w:.2f} W capacity",
|
|
584
|
+
("capacity", checked_rail.capacity_enforced_by),
|
|
585
|
+
),
|
|
586
|
+
action=f"shed load on rail {checked_rail.id!r} or raise its capacity",
|
|
587
|
+
)
|
|
588
|
+
)
|
|
589
|
+
record(path, "FAIL")
|
|
590
|
+
break
|
|
591
|
+
else:
|
|
592
|
+
record(path, "PASS")
|
|
593
|
+
|
|
594
|
+
elif isinstance(step, SetPowerMode):
|
|
595
|
+
device = contract.device(step.device)
|
|
596
|
+
if device is None or device.power is None:
|
|
597
|
+
path = f"{base}/set_power_mode/device"
|
|
598
|
+
out.append(
|
|
599
|
+
Finding(
|
|
600
|
+
severity="not_scored",
|
|
601
|
+
quantity=path,
|
|
602
|
+
observed=f"device {step.device!r} not found or declares no power",
|
|
603
|
+
expected="a device with a power draw declared",
|
|
604
|
+
action=(
|
|
605
|
+
f"This robot has no device named {human_name(step.device)}"
|
|
606
|
+
if device is None
|
|
607
|
+
else f"{human_name(step.device)} has no power modes declared"
|
|
608
|
+
),
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
record(path, "UNSCORED")
|
|
612
|
+
continue
|
|
613
|
+
mode = next((m for m in device.power.modes if m.name == step.mode), None)
|
|
614
|
+
mode_w = mode.draw.base() if mode is not None else None
|
|
615
|
+
if mode_w is None and step.mode != "nominal":
|
|
616
|
+
path = f"{base}/set_power_mode/mode"
|
|
617
|
+
out.append(
|
|
618
|
+
Finding(
|
|
619
|
+
severity="not_scored",
|
|
620
|
+
quantity=path,
|
|
621
|
+
observed=f"mode {step.mode!r} not declared on {step.device!r}",
|
|
622
|
+
expected="a power mode declared on this device",
|
|
623
|
+
action="check the mode name against the device's declared modes",
|
|
624
|
+
)
|
|
625
|
+
)
|
|
626
|
+
record(path, "UNSCORED")
|
|
627
|
+
continue
|
|
628
|
+
if mode_w is None:
|
|
629
|
+
if device.power.draw is None:
|
|
630
|
+
path = f"{base}/set_power_mode/draw"
|
|
631
|
+
out.append(
|
|
632
|
+
Finding(
|
|
633
|
+
severity="not_scored",
|
|
634
|
+
quantity=path,
|
|
635
|
+
observed=unknown_draw_message(device.id, device.power.rail),
|
|
636
|
+
expected="a declared draw for this device",
|
|
637
|
+
action=(
|
|
638
|
+
f"declare {device.id}'s power draw, or accept this "
|
|
639
|
+
"step as not scored"
|
|
640
|
+
),
|
|
641
|
+
)
|
|
642
|
+
)
|
|
643
|
+
record(path, "UNSCORED")
|
|
644
|
+
continue
|
|
645
|
+
mode_w = device.power.draw.base()
|
|
646
|
+
if device.power.choices is not None and step.mode not in device.power.choices:
|
|
647
|
+
path = f"{base}/set_power_mode/mode"
|
|
648
|
+
out.append(
|
|
649
|
+
Finding(
|
|
650
|
+
severity="violation",
|
|
651
|
+
quantity=path,
|
|
652
|
+
observed=(
|
|
653
|
+
f"mode {step.mode!r} is declared on {step.device!r} "
|
|
654
|
+
"but not allowed by this contract"
|
|
655
|
+
),
|
|
656
|
+
expected="one of: " + ", ".join(device.power.choices),
|
|
657
|
+
action=f"pick an allowed power mode for {step.device!r}",
|
|
658
|
+
)
|
|
659
|
+
)
|
|
660
|
+
record(path, "FAIL")
|
|
661
|
+
continue
|
|
662
|
+
rail = contract.rail(device.power.rail)
|
|
663
|
+
if rail is None:
|
|
664
|
+
path = f"{base}/set_power_mode/rail"
|
|
665
|
+
out.append(
|
|
666
|
+
Finding(
|
|
667
|
+
severity="not_scored",
|
|
668
|
+
quantity=path,
|
|
669
|
+
observed=f"rail {device.power.rail!r} not found",
|
|
670
|
+
expected="a rail declared in the contract topology",
|
|
671
|
+
action="fix the device's rail reference",
|
|
672
|
+
)
|
|
673
|
+
)
|
|
674
|
+
record(path, "UNSCORED")
|
|
675
|
+
continue
|
|
676
|
+
pinned_w, unknown = _rail_worst_w_with_device_pinned(
|
|
677
|
+
contract, device.power.rail, device.id, mode_w
|
|
678
|
+
)
|
|
679
|
+
total_w = pinned_w + railload_totals.get(device.power.rail, 0.0)
|
|
680
|
+
capacity_w = rail.capacity.base()
|
|
681
|
+
path = f"{base}/set_power_mode/draw"
|
|
682
|
+
if unknown:
|
|
683
|
+
out.append(_unknown_finding_for(contract, path, unknown))
|
|
684
|
+
record(path, "UNSCORED")
|
|
685
|
+
elif total_w > capacity_w:
|
|
686
|
+
out.append(
|
|
687
|
+
Finding(
|
|
688
|
+
severity="violation",
|
|
689
|
+
quantity=path,
|
|
690
|
+
observed=f"{total_w:.2f} W rolled-up draw on {device.power.rail} at mode {step.mode!r}",
|
|
691
|
+
expected=_with_enforcement_provenance(
|
|
692
|
+
f"<= {capacity_w:.2f} W capacity",
|
|
693
|
+
("capacity", rail.capacity_enforced_by),
|
|
694
|
+
),
|
|
695
|
+
action=f"pick a lower power mode for {step.device!r} or shed other load",
|
|
696
|
+
)
|
|
697
|
+
)
|
|
698
|
+
record(path, "FAIL")
|
|
699
|
+
else:
|
|
700
|
+
record(path, "PASS")
|
|
701
|
+
|
|
702
|
+
elif isinstance(step, Publish):
|
|
703
|
+
driver_device = next(
|
|
704
|
+
(
|
|
705
|
+
d
|
|
706
|
+
for d in contract.devices
|
|
707
|
+
if d.driver is not None and d.driver.topic == step.topic
|
|
708
|
+
),
|
|
709
|
+
None,
|
|
710
|
+
)
|
|
711
|
+
if driver_device is None:
|
|
712
|
+
path = f"{base}/publish/topic"
|
|
713
|
+
out.append(
|
|
714
|
+
Finding(
|
|
715
|
+
severity="not_scored",
|
|
716
|
+
quantity=path,
|
|
717
|
+
observed=f"no driver declares topic {step.topic!r}",
|
|
718
|
+
expected="a device driver with a matching topic",
|
|
719
|
+
action="check the topic name against the contract's drivers",
|
|
720
|
+
)
|
|
721
|
+
)
|
|
722
|
+
record(path, "UNSCORED")
|
|
723
|
+
continue
|
|
724
|
+
driver = driver_device.driver
|
|
725
|
+
assert driver is not None
|
|
726
|
+
if driver.msg_type is not None and driver.msg_type != step.msg_type:
|
|
727
|
+
path = f"{base}/publish/msg_type"
|
|
728
|
+
out.append(
|
|
729
|
+
Finding(
|
|
730
|
+
severity="not_scored",
|
|
731
|
+
quantity=path,
|
|
732
|
+
observed=f"message type {step.msg_type!r}",
|
|
733
|
+
expected=f"declared message type {driver.msg_type!r}",
|
|
734
|
+
action="use the driver's declared message type before validating fields",
|
|
735
|
+
)
|
|
736
|
+
)
|
|
737
|
+
record(path, "ERROR")
|
|
738
|
+
continue
|
|
739
|
+
if not driver.signals:
|
|
740
|
+
path = f"{base}/publish/signals"
|
|
741
|
+
out.append(
|
|
742
|
+
Finding(
|
|
743
|
+
severity="not_scored",
|
|
744
|
+
quantity=path,
|
|
745
|
+
observed=f"driver for topic {step.topic!r} has an empty signal map",
|
|
746
|
+
expected="at least one signal mapped to a message field",
|
|
747
|
+
action="declare driver.signals mappings before scoring this publish step",
|
|
748
|
+
)
|
|
749
|
+
)
|
|
750
|
+
record(path, "UNSCORED")
|
|
751
|
+
continue
|
|
752
|
+
model = contract.device_model(driver_device.model)
|
|
753
|
+
for signal_name, source in driver.signals.items():
|
|
754
|
+
path = f"{base}/publish/{signal_name}"
|
|
755
|
+
value, resolution = _resolve_field(step.msg, source.field)
|
|
756
|
+
if value is None:
|
|
757
|
+
observed = (
|
|
758
|
+
f"mapped field {source.field!r} is missing from the message"
|
|
759
|
+
if resolution == "missing"
|
|
760
|
+
else f"mapped field {source.field!r} is not numeric"
|
|
761
|
+
)
|
|
762
|
+
out.append(
|
|
763
|
+
Finding(
|
|
764
|
+
severity="not_scored",
|
|
765
|
+
quantity=path,
|
|
766
|
+
observed=observed,
|
|
767
|
+
expected="a numeric value at the mapped field path",
|
|
768
|
+
action="populate the mapped field with a numeric value before validating",
|
|
769
|
+
)
|
|
770
|
+
)
|
|
771
|
+
record(path, "UNSCORED")
|
|
772
|
+
continue
|
|
773
|
+
band: Band | None = (
|
|
774
|
+
model.hard_limits.get(signal_name) if model is not None else None
|
|
775
|
+
)
|
|
776
|
+
if band is None:
|
|
777
|
+
out.append(
|
|
778
|
+
Finding(
|
|
779
|
+
severity="not_scored",
|
|
780
|
+
quantity=path,
|
|
781
|
+
observed=f"{value} {source.unit}",
|
|
782
|
+
expected=f"hard_limits[{signal_name!r}] declared on the device model",
|
|
783
|
+
action="declare a hard limit for this signal to score it",
|
|
784
|
+
)
|
|
785
|
+
)
|
|
786
|
+
record(path, "UNSCORED")
|
|
787
|
+
continue
|
|
788
|
+
try:
|
|
789
|
+
in_band = band.contains(Band(lo=value, hi=value, unit=source.unit))
|
|
790
|
+
except UnitError:
|
|
791
|
+
out.append(
|
|
792
|
+
Finding(
|
|
793
|
+
severity="not_scored",
|
|
794
|
+
quantity=path,
|
|
795
|
+
observed=(
|
|
796
|
+
f"signal unit {source.unit!r} vs hard limit unit {band.unit!r}"
|
|
797
|
+
),
|
|
798
|
+
expected=(
|
|
799
|
+
"the signal and its hard limit declare the same physical dimension"
|
|
800
|
+
),
|
|
801
|
+
action="fix the contract: the signal's unit and hard_limits[...] "
|
|
802
|
+
"unit must share a dimension",
|
|
803
|
+
)
|
|
804
|
+
)
|
|
805
|
+
record(path, "UNSCORED")
|
|
806
|
+
continue
|
|
807
|
+
if not in_band:
|
|
808
|
+
out.append(
|
|
809
|
+
Finding(
|
|
810
|
+
severity="violation",
|
|
811
|
+
quantity=path,
|
|
812
|
+
observed=f"{value} {source.unit}",
|
|
813
|
+
expected=f"inside the contract's declared hard limit {band}",
|
|
814
|
+
action=(
|
|
815
|
+
"correct the value or the declared range before publishing; "
|
|
816
|
+
"physical truth is indeterminate from this comparison"
|
|
817
|
+
),
|
|
818
|
+
)
|
|
819
|
+
)
|
|
820
|
+
record(path, "FAIL")
|
|
821
|
+
else:
|
|
822
|
+
record(path, "PASS")
|
|
823
|
+
|
|
824
|
+
converted = units.convert(value, source.unit, band.unit)
|
|
825
|
+
key = (step.topic, signal_name)
|
|
826
|
+
if band.max_rate is not None:
|
|
827
|
+
_score_max_rate(
|
|
828
|
+
out,
|
|
829
|
+
record,
|
|
830
|
+
path=f"{path}/rate",
|
|
831
|
+
band=band,
|
|
832
|
+
previous=previous_published.get(key),
|
|
833
|
+
current=converted,
|
|
834
|
+
rate_hz=driver.nominal_rate_hz,
|
|
835
|
+
)
|
|
836
|
+
previous_published[key] = converted
|
|
837
|
+
|
|
838
|
+
if contract.model is not None:
|
|
839
|
+
provenance = contract.model.provenance
|
|
840
|
+
for finding in out:
|
|
841
|
+
if (
|
|
842
|
+
"/joint_target/" in finding.quantity
|
|
843
|
+
and finding.quantity.rsplit("/", 1)[-1] in {"joint", "position", "velocity", "effort"}
|
|
844
|
+
):
|
|
845
|
+
finding.source = provenance.source
|
|
846
|
+
finding.witness = provenance.witness
|
|
847
|
+
finding.locked_at = provenance.locked_at
|
|
848
|
+
|
|
849
|
+
return PlanReport(out, outcomes)
|