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/schema.py
ADDED
|
@@ -0,0 +1,3616 @@
|
|
|
1
|
+
"""Contract schema v0.4 — the physics lockfile.
|
|
2
|
+
|
|
3
|
+
One YAML file, checked into the robot's repo, recording everything the software
|
|
4
|
+
silently assumes about the physical system. Structure follows spec v3:
|
|
5
|
+
|
|
6
|
+
- Tier A model: per-link mass/COM/principal inertia + structure hash,
|
|
7
|
+
generated from URDF by `lockwire init`
|
|
8
|
+
- Tier B edges: declared quantity -> code dependency edges
|
|
9
|
+
- Tier C assumptions: tracked-but-uncheckable register (surfaced, never verified)
|
|
10
|
+
- Tier D devices: family -> model -> instance split; instances carry
|
|
11
|
+
identity, driver, physical, power, context envelopes
|
|
12
|
+
- budgets: system-level derived entries (checker sums witnesses)
|
|
13
|
+
- topology: hosts / buses / rails / wires / battery
|
|
14
|
+
|
|
15
|
+
Invariants enforced here rather than by convention:
|
|
16
|
+
- every physical quantity is a Quantity{value, unit}; unknown units fail
|
|
17
|
+
validation; frame-dependent quantities carry a frame
|
|
18
|
+
- unknown keys are rejected (extra="forbid") — a typo is a violation, not a
|
|
19
|
+
silently ignored field
|
|
20
|
+
- locked entries carry a Provenance block
|
|
21
|
+
|
|
22
|
+
Cross-entry references use '/'-separated paths ("devices/<id>/physical/mass"),
|
|
23
|
+
never dots — device ids themselves contain dots ("dev.imu").
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import datetime as dt
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import math
|
|
30
|
+
import re
|
|
31
|
+
from collections import Counter
|
|
32
|
+
from copy import deepcopy
|
|
33
|
+
from decimal import Decimal
|
|
34
|
+
from io import StringIO
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Annotated, Any, Literal, Self
|
|
37
|
+
|
|
38
|
+
from pydantic import (
|
|
39
|
+
AfterValidator,
|
|
40
|
+
BaseModel,
|
|
41
|
+
ConfigDict,
|
|
42
|
+
Field,
|
|
43
|
+
field_validator,
|
|
44
|
+
model_validator,
|
|
45
|
+
)
|
|
46
|
+
from ruamel.yaml import YAML
|
|
47
|
+
|
|
48
|
+
import numpy as np
|
|
49
|
+
|
|
50
|
+
from lockwire import units
|
|
51
|
+
|
|
52
|
+
SCHEMA_VERSION = "0.4"
|
|
53
|
+
SUPPORTED_SCHEMA_VERSIONS = frozenset({"0.2", "0.3", SCHEMA_VERSION})
|
|
54
|
+
FITTED_BAND_ALPHA_GATE = 0.005
|
|
55
|
+
|
|
56
|
+
CONTEXTS = (
|
|
57
|
+
"bench-idle",
|
|
58
|
+
"armed-idle",
|
|
59
|
+
"nominal-op",
|
|
60
|
+
) # start with 3 max (spec: context proliferation trap)
|
|
61
|
+
|
|
62
|
+
# Real checked-in lockfiles are a few KB (fixtures ≈ 4–13 KiB; largest ≈ 11 KiB).
|
|
63
|
+
# 256 KiB is ~23× headroom over that, while bounding worst-case ruamel
|
|
64
|
+
# flow-collection parse to ~5s (board-measured ~19s/MB). The old 2 MiB
|
|
65
|
+
# ceiling did not bound that super-linear path (~1.8 MiB flow list hung).
|
|
66
|
+
# Path.stat (no read) still rejects oversized input before the scanner runs.
|
|
67
|
+
MAX_CONTRACT_BYTES = 256 * 1024
|
|
68
|
+
|
|
69
|
+
# lockwire lockfiles are declarative records and need no YAML anchors/aliases.
|
|
70
|
+
# After the byte cap and a cheap ruamel load (aliases stay as shared refs),
|
|
71
|
+
# the composed-tree walk rejects any node that carries anchor metadata or
|
|
72
|
+
# appears twice via shared identity. Mutation bites flip this to False to
|
|
73
|
+
# prove the hang returns when the walk is disabled.
|
|
74
|
+
REJECT_YAML_ANCHORS_ALIASES = True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Base(BaseModel):
|
|
78
|
+
"""Closed, assignment-validated base for every lockfile record.
|
|
79
|
+
|
|
80
|
+
Attribute assignment reruns pydantic validation, so programmatically built
|
|
81
|
+
records retain the same field guarantees as loaded records. In-place
|
|
82
|
+
mutations such as ``contract.devices.append(...)`` are not assignments and
|
|
83
|
+
therefore cannot trigger pydantic; call ``revalidate`` after such edits.
|
|
84
|
+
Rebuilding these small lockfile documents is negligible compared with robot
|
|
85
|
+
enumeration or file I/O.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
model_config = ConfigDict(extra="forbid", validate_assignment=True, allow_inf_nan=False)
|
|
89
|
+
|
|
90
|
+
def revalidate(self) -> Self:
|
|
91
|
+
"""Return a fully revalidated copy, including in-place container edits."""
|
|
92
|
+
|
|
93
|
+
return type(self).model_validate(self.model_dump(mode="python"))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Quantity(Base):
|
|
97
|
+
value: float
|
|
98
|
+
unit: str
|
|
99
|
+
frame: str | None = None
|
|
100
|
+
|
|
101
|
+
@field_validator("unit")
|
|
102
|
+
@classmethod
|
|
103
|
+
def _known_unit(cls, v: str) -> str:
|
|
104
|
+
if not units.known(v):
|
|
105
|
+
raise ValueError(f"unknown unit {v!r}")
|
|
106
|
+
return v
|
|
107
|
+
|
|
108
|
+
def base(self) -> float:
|
|
109
|
+
return units.to_base(self.value, self.unit)
|
|
110
|
+
|
|
111
|
+
def dimension(self) -> str:
|
|
112
|
+
return units.dimension(self.unit)
|
|
113
|
+
|
|
114
|
+
def __str__(self) -> str:
|
|
115
|
+
return f"{self.value:g} {self.unit}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _quantity_dimension(
|
|
119
|
+
expected: str,
|
|
120
|
+
role: str,
|
|
121
|
+
*,
|
|
122
|
+
nonnegative: bool = False,
|
|
123
|
+
positive: bool = False,
|
|
124
|
+
finite: bool = False,
|
|
125
|
+
):
|
|
126
|
+
"""Build a reusable Quantity validator for a physical field's role."""
|
|
127
|
+
|
|
128
|
+
def validate(value: Quantity) -> Quantity:
|
|
129
|
+
actual = value.dimension()
|
|
130
|
+
if actual != expected:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"{role} requires dimension {expected!r}, got {actual!r} ({value.unit!r})"
|
|
133
|
+
)
|
|
134
|
+
if nonnegative and value.base() < 0:
|
|
135
|
+
raise ValueError(f"{role} must be >= 0, got {value}")
|
|
136
|
+
if positive and value.base() <= 0:
|
|
137
|
+
raise ValueError(f"{role} must be > 0, got {value}")
|
|
138
|
+
if finite and not math.isfinite(value.base()):
|
|
139
|
+
raise ValueError(f"{role} must be finite, got {value}")
|
|
140
|
+
return value
|
|
141
|
+
|
|
142
|
+
return validate
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
PowerQuantity = Annotated[Quantity, AfterValidator(_quantity_dimension("power", "power quantity"))]
|
|
146
|
+
MassQuantity = Annotated[
|
|
147
|
+
Quantity, AfterValidator(_quantity_dimension("mass", "mass quantity", nonnegative=True))
|
|
148
|
+
]
|
|
149
|
+
EnergyQuantity = Annotated[
|
|
150
|
+
Quantity, AfterValidator(_quantity_dimension("energy", "energy quantity"))
|
|
151
|
+
]
|
|
152
|
+
TimeQuantity = Annotated[Quantity, AfterValidator(_quantity_dimension("time", "time quantity"))]
|
|
153
|
+
VoltageQuantity = Annotated[
|
|
154
|
+
Quantity, AfterValidator(_quantity_dimension("voltage", "voltage quantity"))
|
|
155
|
+
]
|
|
156
|
+
CurrentQuantity = Annotated[
|
|
157
|
+
Quantity, AfterValidator(_quantity_dimension("current", "current quantity"))
|
|
158
|
+
]
|
|
159
|
+
ResistanceQuantity = Annotated[
|
|
160
|
+
Quantity, AfterValidator(_quantity_dimension("resistance", "resistance quantity"))
|
|
161
|
+
]
|
|
162
|
+
LengthQuantity = Annotated[
|
|
163
|
+
Quantity, AfterValidator(_quantity_dimension("length", "length quantity"))
|
|
164
|
+
]
|
|
165
|
+
TemperatureQuantity = Annotated[
|
|
166
|
+
Quantity, AfterValidator(_quantity_dimension("temperature", "temperature quantity"))
|
|
167
|
+
]
|
|
168
|
+
ThermalResistanceQuantity = Annotated[
|
|
169
|
+
Quantity,
|
|
170
|
+
AfterValidator(_quantity_dimension("thermal_resistance", "thermal resistance quantity")),
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
PowerLevelQuantity = Annotated[
|
|
174
|
+
Quantity, AfterValidator(_quantity_dimension("power_level", "RF power-level quantity"))
|
|
175
|
+
]
|
|
176
|
+
GainQuantity = Annotated[Quantity, AfterValidator(_quantity_dimension("gain", "RF gain quantity"))]
|
|
177
|
+
DataRateQuantity = Annotated[
|
|
178
|
+
Quantity, AfterValidator(_quantity_dimension("data_rate", "data-rate quantity"))
|
|
179
|
+
]
|
|
180
|
+
# Carrier frequency uses the existing rate/Hz unit — the registry has no MHz spelling.
|
|
181
|
+
FrequencyQuantity = Annotated[
|
|
182
|
+
Quantity, AfterValidator(_quantity_dimension("rate", "carrier-frequency quantity"))
|
|
183
|
+
]
|
|
184
|
+
AngleQuantity = Annotated[Quantity, AfterValidator(_quantity_dimension("angle", "angle quantity"))]
|
|
185
|
+
TorqueQuantity = Annotated[
|
|
186
|
+
Quantity, AfterValidator(_quantity_dimension("torque", "torque quantity"))
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
# Structural/simulation v1 roles deliberately remain distinct even where the
|
|
190
|
+
# unit table maps them to the same physical dimension. This keeps, for example,
|
|
191
|
+
# an elastic modulus from silently serving as a contact pressure parameter.
|
|
192
|
+
AreaQuantity = Annotated[
|
|
193
|
+
Quantity, AfterValidator(_quantity_dimension("area", "area", positive=True, finite=True))
|
|
194
|
+
]
|
|
195
|
+
SecondMomentQuantity = Annotated[
|
|
196
|
+
Quantity,
|
|
197
|
+
AfterValidator(_quantity_dimension("second_moment", "second moment", positive=True, finite=True)),
|
|
198
|
+
]
|
|
199
|
+
PressureQuantity = Annotated[
|
|
200
|
+
Quantity, AfterValidator(_quantity_dimension("pressure", "pressure", finite=True))
|
|
201
|
+
]
|
|
202
|
+
PositivePressureQuantity = Annotated[
|
|
203
|
+
Quantity, AfterValidator(_quantity_dimension("pressure", "pressure", positive=True, finite=True))
|
|
204
|
+
]
|
|
205
|
+
DensityQuantity = Annotated[
|
|
206
|
+
Quantity, AfterValidator(_quantity_dimension("density", "density", positive=True, finite=True))
|
|
207
|
+
]
|
|
208
|
+
ForceQuantity = Annotated[
|
|
209
|
+
Quantity, AfterValidator(_quantity_dimension("force", "force", finite=True))
|
|
210
|
+
]
|
|
211
|
+
PositiveLengthQuantity = Annotated[
|
|
212
|
+
Quantity, AfterValidator(_quantity_dimension("length", "length", positive=True, finite=True))
|
|
213
|
+
]
|
|
214
|
+
PositiveTimeQuantity = Annotated[
|
|
215
|
+
Quantity, AfterValidator(_quantity_dimension("time", "time", positive=True, finite=True))
|
|
216
|
+
]
|
|
217
|
+
LinearStiffnessQuantity = Annotated[
|
|
218
|
+
Quantity,
|
|
219
|
+
AfterValidator(
|
|
220
|
+
_quantity_dimension("linear_stiffness", "translational stiffness", positive=True, finite=True)
|
|
221
|
+
),
|
|
222
|
+
]
|
|
223
|
+
RotationalStiffnessQuantity = Annotated[
|
|
224
|
+
Quantity,
|
|
225
|
+
AfterValidator(
|
|
226
|
+
_quantity_dimension("rotational_stiffness", "rotational stiffness", positive=True, finite=True)
|
|
227
|
+
),
|
|
228
|
+
]
|
|
229
|
+
LinearDampingQuantity = Annotated[
|
|
230
|
+
Quantity,
|
|
231
|
+
AfterValidator(
|
|
232
|
+
_quantity_dimension("linear_damping", "translational damping", positive=True, finite=True)
|
|
233
|
+
),
|
|
234
|
+
]
|
|
235
|
+
RotationalDampingQuantity = Annotated[
|
|
236
|
+
Quantity,
|
|
237
|
+
AfterValidator(
|
|
238
|
+
_quantity_dimension("rotational_damping", "rotational damping", positive=True, finite=True)
|
|
239
|
+
),
|
|
240
|
+
]
|
|
241
|
+
ContactStiffnessQuantity = Annotated[
|
|
242
|
+
Quantity,
|
|
243
|
+
AfterValidator(
|
|
244
|
+
_quantity_dimension("contact_stiffness", "contact slope", positive=True, finite=True)
|
|
245
|
+
),
|
|
246
|
+
]
|
|
247
|
+
ContactDampingQuantity = Annotated[
|
|
248
|
+
Quantity,
|
|
249
|
+
AfterValidator(
|
|
250
|
+
_quantity_dimension("contact_damping", "contact damping", positive=True, finite=True)
|
|
251
|
+
),
|
|
252
|
+
]
|
|
253
|
+
InverseTimeQuantity = Annotated[
|
|
254
|
+
Quantity, AfterValidator(_quantity_dimension("inverse_time", "Rayleigh alpha", nonnegative=True, finite=True))
|
|
255
|
+
]
|
|
256
|
+
VelocityQuantity = Annotated[
|
|
257
|
+
Quantity, AfterValidator(_quantity_dimension("velocity", "linear velocity", finite=True))
|
|
258
|
+
]
|
|
259
|
+
AngularRateQuantity = Annotated[
|
|
260
|
+
Quantity, AfterValidator(_quantity_dimension("angular_rate", "angular velocity", finite=True))
|
|
261
|
+
]
|
|
262
|
+
AngularAccelerationQuantity = Annotated[
|
|
263
|
+
Quantity,
|
|
264
|
+
AfterValidator(_quantity_dimension("angular_acceleration", "angular acceleration", finite=True)),
|
|
265
|
+
]
|
|
266
|
+
InertiaQuantity = Annotated[
|
|
267
|
+
Quantity, AfterValidator(_quantity_dimension("inertia", "rotational inertia", finite=True))
|
|
268
|
+
]
|
|
269
|
+
AccelerationQuantity = Annotated[
|
|
270
|
+
Quantity, AfterValidator(_quantity_dimension("acceleration", "acceleration", finite=True))
|
|
271
|
+
]
|
|
272
|
+
DimensionlessQuantity = Annotated[
|
|
273
|
+
Quantity, AfterValidator(_quantity_dimension("dimensionless", "dimensionless quantity", finite=True))
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
DeclaredEnforcement = Literal["fuse", "firmware", "driver", "this file"]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class Band(Base):
|
|
281
|
+
"""Closed interval in a declared unit."""
|
|
282
|
+
|
|
283
|
+
lo: float
|
|
284
|
+
hi: float
|
|
285
|
+
unit: str
|
|
286
|
+
# `why` (<=512 chars) and `max_rate` (units/s) are part of the schema.
|
|
287
|
+
# Both are optional and additive: SCHEMA_VERSION does not move and every
|
|
288
|
+
# checked-in fixture still loads. `why` is quoted back verbatim wherever a
|
|
289
|
+
# bound is crossed (checks, plan, preflight sanity) and printed by
|
|
290
|
+
# `describe`. `max_rate` is the limit's rate of change in <unit>/s; scoring it
|
|
291
|
+
# is another module's job, this entry only declares it.
|
|
292
|
+
why: str | None = Field(default=None, max_length=512)
|
|
293
|
+
max_rate: float | None = Field(default=None, gt=0)
|
|
294
|
+
enforced_by: DeclaredEnforcement | None = None
|
|
295
|
+
|
|
296
|
+
@field_validator("unit")
|
|
297
|
+
@classmethod
|
|
298
|
+
def _known_unit(cls, v: str) -> str:
|
|
299
|
+
if not units.known(v):
|
|
300
|
+
raise ValueError(f"unknown unit {v!r}")
|
|
301
|
+
return v
|
|
302
|
+
|
|
303
|
+
@model_validator(mode="after")
|
|
304
|
+
def _ordered(self) -> "Band":
|
|
305
|
+
if self.lo > self.hi:
|
|
306
|
+
raise ValueError(f"band lo {self.lo} > hi {self.hi}")
|
|
307
|
+
return self
|
|
308
|
+
|
|
309
|
+
def contains(self, other: "Band") -> bool:
|
|
310
|
+
lo = units.convert(other.lo, other.unit, self.unit)
|
|
311
|
+
hi = units.convert(other.hi, other.unit, self.unit)
|
|
312
|
+
return self.lo <= lo and hi <= self.hi
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def why_suffix(self) -> str:
|
|
316
|
+
"""The declared reason, quoted verbatim, or nothing when undeclared.
|
|
317
|
+
|
|
318
|
+
One helper so every refusal path -- ``checks``, ``plan`` and
|
|
319
|
+
preflight's ``sanity`` stage -- quotes the same text.
|
|
320
|
+
"""
|
|
321
|
+
return f" (why: {self.why})" if self.why else ""
|
|
322
|
+
|
|
323
|
+
def __str__(self) -> str:
|
|
324
|
+
return f"[{self.lo:g}, {self.hi:g}] {self.unit}{self.why_suffix}"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class ReviewNote(Base):
|
|
328
|
+
"""A reviewer's judgement of a declared value, written into the file.
|
|
329
|
+
|
|
330
|
+
`state` is exactly one of these two words. The witness gate is the
|
|
331
|
+
*existing* `validate_witness` (edit.py) — imported lazily inside the
|
|
332
|
+
validator to avoid a module-level cycle (edit.py imports from schema.py).
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
state: Literal["approved", "questioned"]
|
|
336
|
+
witness: str
|
|
337
|
+
at: dt.date # required: a review without a date is not a record
|
|
338
|
+
note: str | None = None
|
|
339
|
+
|
|
340
|
+
@field_validator("witness")
|
|
341
|
+
@classmethod
|
|
342
|
+
def _validated_witness(cls, v: str) -> str:
|
|
343
|
+
from lockwire.edit import validate_witness
|
|
344
|
+
|
|
345
|
+
validate_witness(
|
|
346
|
+
v, refusal='a review witness must not be blank, "you", or an assistant name'
|
|
347
|
+
)
|
|
348
|
+
return v
|
|
349
|
+
|
|
350
|
+
@model_validator(mode="after")
|
|
351
|
+
def _questioned_requires_a_note(self) -> "ReviewNote":
|
|
352
|
+
if self.state == "questioned" and not (self.note and self.note.strip()):
|
|
353
|
+
raise ValueError("a questioned review note requires a note")
|
|
354
|
+
return self
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class Provenance(Base):
|
|
358
|
+
witness: str # w0..w6 | live | human
|
|
359
|
+
source: str # file path, datasheet, bag id
|
|
360
|
+
locked_at: dt.date
|
|
361
|
+
bag_sha256: str | None = None # keep raw bags; re-deriving later must be possible
|
|
362
|
+
conditions: dict[str, float] | None = None # env channel -> value at lock time
|
|
363
|
+
# Free text for a human reader. No checker reads it: prose never moves a
|
|
364
|
+
# verdict, and the MCP surface hands it to an agent fenced as untrusted data
|
|
365
|
+
# (tests/test_prose_quarantine.py).
|
|
366
|
+
note: str | None = None
|
|
367
|
+
# Catalog-part slice: the seeded part whose declared mass was
|
|
368
|
+
# copied. Optional and additive so existing lockfiles remain valid. Family
|
|
369
|
+
# references deliberately remain unresolved until their semantics are set.
|
|
370
|
+
compared_from: str | None = None
|
|
371
|
+
# a reviewer's approve/question notes on this declared value.
|
|
372
|
+
# Optional so a contract with none is written back with the key
|
|
373
|
+
# absent, not `reviews: []` -- exclude_none in save_contract then drops it.
|
|
374
|
+
reviews: list[ReviewNote] | None = None
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class ArtifactRef(Base):
|
|
378
|
+
"""A content-addressed file below a caller-selected evidence root."""
|
|
379
|
+
|
|
380
|
+
path: str
|
|
381
|
+
sha256: str
|
|
382
|
+
|
|
383
|
+
@field_validator("path")
|
|
384
|
+
@classmethod
|
|
385
|
+
def _contained_relative_path(cls, value: str) -> str:
|
|
386
|
+
candidate = Path(value)
|
|
387
|
+
if (
|
|
388
|
+
not value
|
|
389
|
+
or candidate.is_absolute()
|
|
390
|
+
or "://" in value
|
|
391
|
+
or ":" in value
|
|
392
|
+
or "\\" in value
|
|
393
|
+
or any(part in {"", ".", ".."} for part in candidate.parts)
|
|
394
|
+
):
|
|
395
|
+
raise ValueError("artifact path must be a non-empty contained relative path")
|
|
396
|
+
return value
|
|
397
|
+
|
|
398
|
+
@field_validator("sha256")
|
|
399
|
+
@classmethod
|
|
400
|
+
def _sha256(cls, value: str) -> str:
|
|
401
|
+
if len(value) != 64 or any(char not in "0123456789abcdef" for char in value):
|
|
402
|
+
raise ValueError("artifact sha256 must be 64 lowercase hexadecimal characters")
|
|
403
|
+
return value
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _artifact_path(ref: ArtifactRef, root: Path, *, max_bytes: int) -> Path:
|
|
407
|
+
"""Return a contained, regular artifact path after its cheap checks."""
|
|
408
|
+
|
|
409
|
+
if max_bytes <= 0:
|
|
410
|
+
raise ValueError("artifact size limit must be positive")
|
|
411
|
+
resolved_root = root.resolve(strict=True)
|
|
412
|
+
candidate = (resolved_root / ref.path).resolve(strict=True)
|
|
413
|
+
if not candidate.is_relative_to(resolved_root) or not candidate.is_file():
|
|
414
|
+
raise ValueError(f"artifact {ref.path!r} escapes or is not a file below the evidence root")
|
|
415
|
+
if candidate.stat().st_size > max_bytes:
|
|
416
|
+
raise ValueError(f"artifact {ref.path!r} exceeds size limit ({max_bytes} bytes)")
|
|
417
|
+
return candidate
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def read_artifact(ref: ArtifactRef, root: Path, *, max_bytes: int = MAX_CONTRACT_BYTES) -> bytes:
|
|
421
|
+
"""Read and hash-verify one contained local artifact's exact bytes."""
|
|
422
|
+
|
|
423
|
+
candidate = _artifact_path(ref, root, max_bytes=max_bytes)
|
|
424
|
+
digest = hashlib.sha256()
|
|
425
|
+
chunks: list[bytes] = []
|
|
426
|
+
total = 0
|
|
427
|
+
with candidate.open("rb") as handle:
|
|
428
|
+
for block in iter(lambda: handle.read(64 * 1024), b""):
|
|
429
|
+
total += len(block)
|
|
430
|
+
if total > max_bytes:
|
|
431
|
+
raise ValueError(f"artifact {ref.path!r} exceeds size limit ({max_bytes} bytes)")
|
|
432
|
+
digest.update(block)
|
|
433
|
+
chunks.append(block)
|
|
434
|
+
if digest.hexdigest() != ref.sha256:
|
|
435
|
+
raise ValueError(
|
|
436
|
+
f"artifact {ref.path!r} sha256 does not match: "
|
|
437
|
+
f"observed {digest.hexdigest()}, expected {ref.sha256}"
|
|
438
|
+
)
|
|
439
|
+
return b"".join(chunks)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# ---------------------------------------------------------------- Tier A: model
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class PrincipalInertia(Base):
|
|
446
|
+
"""Principal moments + axes, never raw tensor elements (spec: frame trap)."""
|
|
447
|
+
|
|
448
|
+
moments_kgm2: tuple[float, float, float] # ascending
|
|
449
|
+
axes: tuple[
|
|
450
|
+
tuple[float, float, float],
|
|
451
|
+
tuple[float, float, float],
|
|
452
|
+
tuple[float, float, float],
|
|
453
|
+
] # rows = unit eigenvectors in the link inertial frame (URDF: about COM)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class Link(Base):
|
|
457
|
+
name: str
|
|
458
|
+
mass: MassQuantity | None = None # None -> zero-mass, flagged at check time
|
|
459
|
+
com: tuple[float, float, float] | None = None # meters, in `frame`
|
|
460
|
+
frame: str | None = None
|
|
461
|
+
inertia: PrincipalInertia | None = None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class JointOrigin(Base):
|
|
465
|
+
"""Joint pose in its parent link frame; consumed by URDF drift."""
|
|
466
|
+
|
|
467
|
+
xyz: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
|
468
|
+
rpy: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class JointLimits(Base):
|
|
472
|
+
"""URDF-native SI limits consumed by dependency edges and the torque budget."""
|
|
473
|
+
|
|
474
|
+
lower: float | None = None # radians or meters, according to joint type
|
|
475
|
+
upper: float | None = None # radians or meters, according to joint type
|
|
476
|
+
effort: float | None = None # N*m for rotary joints, N for prismatic joints
|
|
477
|
+
velocity: float | None = None # rad/s for rotary joints, m/s for prismatic joints
|
|
478
|
+
lower_enforced_by: DeclaredEnforcement | None = None
|
|
479
|
+
upper_enforced_by: DeclaredEnforcement | None = None
|
|
480
|
+
effort_enforced_by: DeclaredEnforcement | None = None
|
|
481
|
+
velocity_enforced_by: DeclaredEnforcement | None = None
|
|
482
|
+
|
|
483
|
+
@property
|
|
484
|
+
def effort_limit(self) -> float | None:
|
|
485
|
+
"""The effort as a hard limit; URDF effort <= 0 means unset/invalid, not a limit."""
|
|
486
|
+
return self.effort if self.effort is not None and self.effort > 0 else None
|
|
487
|
+
|
|
488
|
+
@model_validator(mode="after")
|
|
489
|
+
def _ordered(self) -> "JointLimits":
|
|
490
|
+
if self.lower is not None and self.upper is not None and self.lower > self.upper:
|
|
491
|
+
raise ValueError(f"joint limit lower {self.lower} > upper {self.upper}")
|
|
492
|
+
for limit in ("lower", "upper", "effort", "velocity"):
|
|
493
|
+
if getattr(self, f"{limit}_enforced_by") is not None and getattr(self, limit) is None:
|
|
494
|
+
raise ValueError(f"{limit}_enforced_by requires {limit}")
|
|
495
|
+
return self
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class Joint(Base):
|
|
499
|
+
"""Kinematic joint consumed by joint-limit dependency edges and URDF drift."""
|
|
500
|
+
|
|
501
|
+
name: str
|
|
502
|
+
type: Literal["fixed", "revolute", "continuous", "prismatic", "floating", "planar"]
|
|
503
|
+
parent: str
|
|
504
|
+
child: str
|
|
505
|
+
origin: JointOrigin = JointOrigin()
|
|
506
|
+
axis: tuple[float, float, float] | None = None
|
|
507
|
+
limits: JointLimits | None = None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _model_structure_hash(links: list[Link], joints: list[Joint]) -> str:
|
|
511
|
+
"""Canonical v0.2 hash; axis and limits are load-bearing joint structure."""
|
|
512
|
+
|
|
513
|
+
payload = {
|
|
514
|
+
"links": sorted(link.name for link in links),
|
|
515
|
+
"joints": sorted(
|
|
516
|
+
(joint.model_dump(mode="json", exclude_none=True) for joint in joints),
|
|
517
|
+
key=lambda joint: joint["name"],
|
|
518
|
+
),
|
|
519
|
+
}
|
|
520
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
521
|
+
return hashlib.sha256(canonical.encode()).hexdigest()
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
class ModelRecord(Base):
|
|
525
|
+
source: str
|
|
526
|
+
source_sha256: str
|
|
527
|
+
structure_hash: str # derived from links+joints when joints are recorded
|
|
528
|
+
# Exact bytes for external URDF/SDF mesh files, relative to the contract.
|
|
529
|
+
# A selected model with mesh geometry refuses an undeclared or changed file.
|
|
530
|
+
external_assets: list[ArtifactRef] = []
|
|
531
|
+
links: list[Link] = []
|
|
532
|
+
joints: list[Joint] = [] # dependency edges target limits; URDF drift consumes the full row
|
|
533
|
+
zero_mass_links: list[str] = []
|
|
534
|
+
provenance: Provenance
|
|
535
|
+
|
|
536
|
+
@model_validator(mode="after")
|
|
537
|
+
def _unique_names(self) -> "ModelRecord":
|
|
538
|
+
for kind, rows in (("link", self.links), ("joint", self.joints)):
|
|
539
|
+
names = [row.name for row in rows]
|
|
540
|
+
duplicates = sorted({name for name in names if names.count(name) > 1})
|
|
541
|
+
if duplicates:
|
|
542
|
+
raise ValueError(f"duplicate {kind} name(s): {', '.join(duplicates)}")
|
|
543
|
+
return self
|
|
544
|
+
|
|
545
|
+
@model_validator(mode="after")
|
|
546
|
+
def _unique_external_assets(self) -> "ModelRecord":
|
|
547
|
+
paths = [asset.path for asset in self.external_assets]
|
|
548
|
+
duplicates = sorted({path for path in paths if paths.count(path) > 1})
|
|
549
|
+
if duplicates:
|
|
550
|
+
raise ValueError(f"duplicate external asset path(s): {', '.join(duplicates)}")
|
|
551
|
+
return self
|
|
552
|
+
|
|
553
|
+
@model_validator(mode="after")
|
|
554
|
+
def _derive_structure_hash(self) -> "ModelRecord":
|
|
555
|
+
# v0.1 init records have no joints and keep their historical URDF hash. Once
|
|
556
|
+
# joints are present, there is one source of truth: the recorded structure.
|
|
557
|
+
if self.joints:
|
|
558
|
+
# This derived write lives inside the model validator. Normal assignment
|
|
559
|
+
# here would recursively invoke this validator under validate_assignment.
|
|
560
|
+
object.__setattr__(
|
|
561
|
+
self,
|
|
562
|
+
"structure_hash",
|
|
563
|
+
_model_structure_hash(self.links, self.joints),
|
|
564
|
+
)
|
|
565
|
+
return self
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# ---------------------------------------------------------------- Tier B: edges
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class Tolerance(Base):
|
|
572
|
+
rel_pct: float | None = None
|
|
573
|
+
abs: Quantity | None = None
|
|
574
|
+
|
|
575
|
+
@model_validator(mode="after")
|
|
576
|
+
def _exactly_one(self) -> "Tolerance":
|
|
577
|
+
if (self.rel_pct is None) == (self.abs is None):
|
|
578
|
+
raise ValueError("tolerance needs exactly one of rel_pct / abs")
|
|
579
|
+
return self
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
class DependencyEdge(Base):
|
|
583
|
+
quantity: str # contract path, e.g. "devices/dev.imu/physical/mass"
|
|
584
|
+
# SCORED: shape is validated (`Tolerance._exactly_one`) and
|
|
585
|
+
# an observed value IS compared against this band -- `checks.py::check_edge_tolerance`
|
|
586
|
+
# reads `.tolerance` and is joined into `run_record_checks`. This field was
|
|
587
|
+
# previously declared-for-future (validated but unscored); it is now
|
|
588
|
+
# scored. If `checks.py` ever stops reading `.tolerance`, update this comment back
|
|
589
|
+
# (see tests/test_schema.py::test_tolerance_is_scored_by_checks).
|
|
590
|
+
tolerance: Tolerance
|
|
591
|
+
code: list[str] # repo paths that assume this quantity
|
|
592
|
+
owner: str
|
|
593
|
+
|
|
594
|
+
@field_validator("code")
|
|
595
|
+
@classmethod
|
|
596
|
+
def _nonempty(cls, v: list[str]) -> list[str]:
|
|
597
|
+
if not v:
|
|
598
|
+
raise ValueError("edge needs at least one code path")
|
|
599
|
+
return v
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
# ---------------------------------------------------------------- Tier C: register
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
class Assumption(Base):
|
|
606
|
+
"""Named, owned, last-reviewed. A list, never a verifier."""
|
|
607
|
+
|
|
608
|
+
id: str
|
|
609
|
+
statement: str
|
|
610
|
+
owner: str
|
|
611
|
+
last_reviewed: dt.date
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
# ---------------------------------------------------------------- Tier D: devices
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
class DeviceFamily(Base):
|
|
618
|
+
"""Device-class defaults consumed by baseline checks; provenance supports report citation."""
|
|
619
|
+
|
|
620
|
+
id: str # e.g. "fam.imu.mems"
|
|
621
|
+
name: str
|
|
622
|
+
min_capture_window_s: float | None = None # 1 Hz GPS needs minutes, not 30 s
|
|
623
|
+
notes: str | None = None
|
|
624
|
+
provenance: Provenance | None = None
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
class Actuator(Base):
|
|
628
|
+
"""Datasheet facts inherited by instances and consumed by the torque budget.
|
|
629
|
+
|
|
630
|
+
Scalars use SI: torque_constant in N*m/A and torque ratings in N*m. These
|
|
631
|
+
units are fixed because the deliberately-small unit registry has no torque
|
|
632
|
+
dimension yet; making the convention explicit prevents unitless ambiguity.
|
|
633
|
+
"""
|
|
634
|
+
|
|
635
|
+
gear_ratio: float | None = None
|
|
636
|
+
torque_constant: float | None = None
|
|
637
|
+
torque_continuous: float | None = None
|
|
638
|
+
torque_peak: float | None = None
|
|
639
|
+
encoder_cpr: int | None = None
|
|
640
|
+
|
|
641
|
+
@field_validator(
|
|
642
|
+
"gear_ratio",
|
|
643
|
+
"torque_constant",
|
|
644
|
+
"torque_continuous",
|
|
645
|
+
"torque_peak",
|
|
646
|
+
"encoder_cpr",
|
|
647
|
+
)
|
|
648
|
+
@classmethod
|
|
649
|
+
def _positive(cls, v: float | int | None) -> float | int | None:
|
|
650
|
+
if v is not None and v <= 0:
|
|
651
|
+
raise ValueError(f"actuator values must be positive, got {v}")
|
|
652
|
+
return v
|
|
653
|
+
|
|
654
|
+
@model_validator(mode="after")
|
|
655
|
+
def _peak_at_least_continuous(self) -> "Actuator":
|
|
656
|
+
if (
|
|
657
|
+
self.torque_peak is not None
|
|
658
|
+
and self.torque_continuous is not None
|
|
659
|
+
and self.torque_peak < self.torque_continuous
|
|
660
|
+
):
|
|
661
|
+
raise ValueError(
|
|
662
|
+
f"torque_peak ({self.torque_peak}) must be >= "
|
|
663
|
+
f"torque_continuous ({self.torque_continuous})"
|
|
664
|
+
)
|
|
665
|
+
return self
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
class InputVoltageRange(Base):
|
|
669
|
+
"""Device input limits consumed by the voltage-compatibility check."""
|
|
670
|
+
|
|
671
|
+
min: VoltageQuantity
|
|
672
|
+
max: VoltageQuantity
|
|
673
|
+
min_enforced_by: DeclaredEnforcement | None = None
|
|
674
|
+
max_enforced_by: DeclaredEnforcement | None = None
|
|
675
|
+
|
|
676
|
+
@model_validator(mode="after")
|
|
677
|
+
def _ordered(self) -> "InputVoltageRange":
|
|
678
|
+
if self.min.base() > self.max.base():
|
|
679
|
+
raise ValueError(f"input voltage range min {self.min} > max {self.max}")
|
|
680
|
+
return self
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
class ThermalLimit(Base):
|
|
684
|
+
"""Published junction trip point consumed by the thermal-budget check.
|
|
685
|
+
|
|
686
|
+
``kind`` names the trip: ``throttle`` is the actionable gate; ``shutdown``
|
|
687
|
+
is the hard stop and may appear as finding context, not the fire threshold.
|
|
688
|
+
For Jetson Orin-class modules the published operand is the composite
|
|
689
|
+
T.SoC junction (equal-ratio CPU/GPU/SoC) — no discrete per-zone trip
|
|
690
|
+
points are published. ``provenance`` is required: a limit without a named
|
|
691
|
+
published source is a hand-typed guess.
|
|
692
|
+
"""
|
|
693
|
+
|
|
694
|
+
temperature: TemperatureQuantity
|
|
695
|
+
kind: Literal["throttle", "shutdown"]
|
|
696
|
+
provenance: Provenance
|
|
697
|
+
temperature_enforced_by: DeclaredEnforcement | None = None
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
class Radio(Base):
|
|
701
|
+
"""Telemetry-radio datasheet facts consumed by the link-budget check.
|
|
702
|
+
|
|
703
|
+
RX sensitivity is rate-dependent (e.g. RFD900x 10⁻⁵ BER table); ``air_rate``
|
|
704
|
+
binds the sensitivity figure — a sensitivity without its rate is meaningless.
|
|
705
|
+
Carrier frequency is an instance/link-budget fact (selected channel), not a
|
|
706
|
+
model default: an undeclared frequency is ``not_scored``, never an assumed
|
|
707
|
+
mid-band channel.
|
|
708
|
+
"""
|
|
709
|
+
|
|
710
|
+
tx_power: PowerLevelQuantity # dBm
|
|
711
|
+
rx_sensitivity: PowerLevelQuantity # dBm at air_rate
|
|
712
|
+
air_rate: DataRateQuantity # bit/s (or kbit/s / Mbit/s)
|
|
713
|
+
|
|
714
|
+
@field_validator("air_rate")
|
|
715
|
+
@classmethod
|
|
716
|
+
def _positive_air_rate(cls, v: Quantity) -> Quantity:
|
|
717
|
+
if v.base() <= 0:
|
|
718
|
+
raise ValueError(f"air_rate must be positive, got {v}")
|
|
719
|
+
return v
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
class AntennaGain(Base):
|
|
723
|
+
"""Installed antenna gain with its own datasheet provenance.
|
|
724
|
+
|
|
725
|
+
The radio datasheet may publish a monopole figure without binding it to a
|
|
726
|
+
SKU; the contract must declare the antenna. The check never assumes a
|
|
727
|
+
bundled antenna.
|
|
728
|
+
"""
|
|
729
|
+
|
|
730
|
+
gain: GainQuantity # dBi
|
|
731
|
+
provenance: Provenance
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
class LinkBudget(Base):
|
|
735
|
+
"""Mission RF requirements and installed antennas for one telemetry radio.
|
|
736
|
+
|
|
737
|
+
Consumed by ``check_link_budget``. Missing carrier frequency or either
|
|
738
|
+
antenna emits ``not_scored`` rather than inventing a channel or gain.
|
|
739
|
+
|
|
740
|
+
``installation_loss`` is mandatory: real links carry
|
|
741
|
+
feedline, connector, and miscellaneous cable loss (typically 1–3 dB). A
|
|
742
|
+
silent default of zero would report an optimistic margin — the wrong
|
|
743
|
+
direction on a check whose job is catching inadequate links. Omission
|
|
744
|
+
fails validation; an author who knows their run has 3 dB of feedline
|
|
745
|
+
loss declares it here.
|
|
746
|
+
"""
|
|
747
|
+
|
|
748
|
+
required_range: LengthQuantity
|
|
749
|
+
fade_margin_required: GainQuantity # dB
|
|
750
|
+
fade_margin_required_enforced_by: DeclaredEnforcement | None = None
|
|
751
|
+
installation_loss: GainQuantity # dB; feedline + connector + misc — no default
|
|
752
|
+
carrier_frequency: FrequencyQuantity | None = None # Hz; None -> not_scored
|
|
753
|
+
tx_antenna: AntennaGain | None = None
|
|
754
|
+
rx_antenna: AntennaGain | None = None
|
|
755
|
+
|
|
756
|
+
@field_validator("required_range")
|
|
757
|
+
@classmethod
|
|
758
|
+
def _positive_range(cls, v: Quantity) -> Quantity:
|
|
759
|
+
if v.base() <= 0:
|
|
760
|
+
raise ValueError(f"required_range must be positive, got {v}")
|
|
761
|
+
return v
|
|
762
|
+
|
|
763
|
+
@field_validator("fade_margin_required")
|
|
764
|
+
@classmethod
|
|
765
|
+
def _nonnegative_margin(cls, v: Quantity) -> Quantity:
|
|
766
|
+
if v.base() < 0:
|
|
767
|
+
raise ValueError(f"fade_margin_required must be >= 0, got {v}")
|
|
768
|
+
return v
|
|
769
|
+
|
|
770
|
+
@field_validator("installation_loss")
|
|
771
|
+
@classmethod
|
|
772
|
+
def _nonnegative_installation_loss(cls, v: Quantity) -> Quantity:
|
|
773
|
+
if v.base() < 0:
|
|
774
|
+
raise ValueError(f"installation_loss must be >= 0, got {v}")
|
|
775
|
+
return v
|
|
776
|
+
|
|
777
|
+
@field_validator("carrier_frequency")
|
|
778
|
+
@classmethod
|
|
779
|
+
def _positive_frequency(cls, v: Quantity | None) -> Quantity | None:
|
|
780
|
+
if v is not None and v.base() <= 0:
|
|
781
|
+
raise ValueError(f"carrier_frequency must be positive, got {v}")
|
|
782
|
+
return v
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
class DeviceModel(Base):
|
|
786
|
+
"""Shareable registry artifact: checks, limits, failure modes. Never local config.
|
|
787
|
+
|
|
788
|
+
Invariant (day one, before any registry exists): model artifacts are
|
|
789
|
+
DECLARATIVE DATA — envelopes, limits, failure-mode strings. Never code,
|
|
790
|
+
templates-with-logic, or anything evaluated at check time. The checker is
|
|
791
|
+
the only code that runs; a community-published model must not be able to
|
|
792
|
+
execute anything on robot infrastructure.
|
|
793
|
+
"""
|
|
794
|
+
|
|
795
|
+
id: str # e.g. "mdl.imu.bmi088"
|
|
796
|
+
family: str # -> DeviceFamily.id
|
|
797
|
+
match: dict[str, str] = {} # stable identity descriptors (vid_pid, can_id, ...)
|
|
798
|
+
hard_limits: dict[str, Band] = {} # channel -> absolute rating (hand-typed, sparse)
|
|
799
|
+
known_failure_modes: list[str] = []
|
|
800
|
+
actuator: Actuator | None = None # datasheet operands consumed by the torque budget
|
|
801
|
+
input_voltage_range: InputVoltageRange | None = None
|
|
802
|
+
provenance: Provenance | None = None
|
|
803
|
+
thermal_limit: ThermalLimit | None = None # datasheet trip; thermal-budget check
|
|
804
|
+
radio: Radio | None = None # datasheet RF operands consumed by the link-budget check
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
IdentityStrength = Literal["instance", "model", "positional"]
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
class DeviceIdentity(Base):
|
|
811
|
+
"""Stable descriptors only — never port or enumeration order (spec: USB re-enum trap).
|
|
812
|
+
|
|
813
|
+
`bus_ref` is consumed by reference integrity, bandwidth budgeting, and the
|
|
814
|
+
bus identity-collision check.
|
|
815
|
+
|
|
816
|
+
Identity strength varies by bus — the gate must know how
|
|
817
|
+
much "the same device is present" can mean here:
|
|
818
|
+
- instance: a per-unit id exists (DroneCAN 128-bit UID, GigE MAC, 1-Wire ROM,
|
|
819
|
+
a genuine USB iSerialNumber)
|
|
820
|
+
- model: only part-level descriptors (VID:PID, WHO_AM_I, CANopen vendor+product;
|
|
821
|
+
USB clones ship duplicate serials — override to "model" if suspect)
|
|
822
|
+
- positional: identity is really wiring position (I2C address straps, mux channel)
|
|
823
|
+
`strength` overrides; otherwise derived from which descriptors are present.
|
|
824
|
+
"""
|
|
825
|
+
|
|
826
|
+
bus_ref: str # -> Bus.id; reference integrity, bandwidth, identity-collision checks
|
|
827
|
+
host: str # -> Host.id
|
|
828
|
+
vid_pid: str | None = None
|
|
829
|
+
serial_number: str | None = None
|
|
830
|
+
can_id: str | None = None
|
|
831
|
+
address: str | None = None
|
|
832
|
+
strength: IdentityStrength | None = None
|
|
833
|
+
|
|
834
|
+
def effective_strength(self) -> IdentityStrength:
|
|
835
|
+
if self.strength is not None:
|
|
836
|
+
return self.strength
|
|
837
|
+
if self.serial_number is not None:
|
|
838
|
+
return "instance"
|
|
839
|
+
if self.vid_pid is not None or self.can_id is not None:
|
|
840
|
+
return "model"
|
|
841
|
+
return "positional"
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
class SignalSource(Base):
|
|
845
|
+
"""Where one lockwire signal lives inside a ROS message, and in what unit.
|
|
846
|
+
|
|
847
|
+
`field` is a dotted path into the message (`linear_acceleration.z`).
|
|
848
|
+
|
|
849
|
+
**`unit` is required and never inferred** — type it. A `sensor_msgs/msg/Imu` linear acceleration is m/s² by REP 145
|
|
850
|
+
and could be derived from the message type, and that is exactly the shortcut
|
|
851
|
+
this refuses: an inferred unit is a number nobody typed and nobody checked.
|
|
852
|
+
Unit handling has already produced three defects. Adding
|
|
853
|
+
inference as a fallback re-opens all of them.
|
|
854
|
+
"""
|
|
855
|
+
|
|
856
|
+
field: str
|
|
857
|
+
unit: str
|
|
858
|
+
|
|
859
|
+
@field_validator("field")
|
|
860
|
+
@classmethod
|
|
861
|
+
def _nonempty_field(cls, v: str) -> str:
|
|
862
|
+
if not v.strip():
|
|
863
|
+
raise ValueError("signal field path must not be blank")
|
|
864
|
+
return v
|
|
865
|
+
|
|
866
|
+
@field_validator("unit")
|
|
867
|
+
@classmethod
|
|
868
|
+
def _known_unit(cls, v: str) -> str:
|
|
869
|
+
# Same gate every other unit in the contract passes through (see Quantity).
|
|
870
|
+
if not units.known(v):
|
|
871
|
+
raise ValueError(f"unknown unit {v!r}")
|
|
872
|
+
return v
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
class DriverProcedure(Base):
|
|
876
|
+
"""A discrete reset/watchdog/update marker observed on a declared signal.
|
|
877
|
+
|
|
878
|
+
This is evidence that a procedure marker arrived at the capture subscriber;
|
|
879
|
+
it does not command a device or imply that the device performed an action.
|
|
880
|
+
"""
|
|
881
|
+
|
|
882
|
+
id: str
|
|
883
|
+
kind: Literal["reset", "watchdog", "update"]
|
|
884
|
+
signal: str
|
|
885
|
+
code: Annotated[int, Field(strict=True)]
|
|
886
|
+
|
|
887
|
+
@field_validator("id", "signal")
|
|
888
|
+
@classmethod
|
|
889
|
+
def _nonblank_identity(cls, value: str) -> str:
|
|
890
|
+
if not value.strip():
|
|
891
|
+
raise ValueError("driver procedure id and signal must not be blank")
|
|
892
|
+
return value
|
|
893
|
+
|
|
894
|
+
@field_validator("code")
|
|
895
|
+
@classmethod
|
|
896
|
+
def _nonnegative_code(cls, value: int) -> int:
|
|
897
|
+
if isinstance(value, bool) or value < 0:
|
|
898
|
+
raise ValueError("driver procedure code must be a nonnegative integer")
|
|
899
|
+
return value
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
class Driver(Base):
|
|
903
|
+
"""Runtime feed metadata; rate × payload is consumed by the bandwidth budget."""
|
|
904
|
+
|
|
905
|
+
node: str | None = None
|
|
906
|
+
topic: str | None = None
|
|
907
|
+
# A watched write channel is distinct from this driver's measurement topic.
|
|
908
|
+
# The owning DeviceInstance's identity.bus_ref remains the physical topology
|
|
909
|
+
# binding; no second bus or logic-pair association is declared here.
|
|
910
|
+
command_topic: str | None = None
|
|
911
|
+
nominal_rate_hz: float | None = None
|
|
912
|
+
dds_deadline_s: float | None = None # opt-in ROS 2 requested deadline, not packet loss
|
|
913
|
+
payload_bytes: int | None = None # bandwidth-budget summand
|
|
914
|
+
# ROS 2 capture operands. Optional and read by nothing in the check
|
|
915
|
+
# path — `lockwire_ros`'s capture node is the only consumer, so a contract
|
|
916
|
+
# without them behaves exactly as it did before schema 0.3 gained them.
|
|
917
|
+
# ULog could hardcode its topic->signal map (`ulog.py`) because PX4's
|
|
918
|
+
# vocabulary is fixed; ROS 2 topics are named per robot, so the map has to
|
|
919
|
+
# live in the contract.
|
|
920
|
+
msg_type: str | None = None # e.g. "sensor_msgs/msg/Imu"
|
|
921
|
+
signals: dict[str, SignalSource] = {} # lockwire signal name -> where it lives
|
|
922
|
+
procedures: list[DriverProcedure] = [] # observed reset/watchdog/update markers
|
|
923
|
+
|
|
924
|
+
@field_validator("dds_deadline_s")
|
|
925
|
+
@classmethod
|
|
926
|
+
def _positive_dds_deadline(cls, v: float | None) -> float | None:
|
|
927
|
+
if v is not None and (not math.isfinite(v) or v < 1e-9):
|
|
928
|
+
raise ValueError("dds_deadline_s must be finite and at least 1 ns")
|
|
929
|
+
if v is not None:
|
|
930
|
+
nanoseconds = Decimal(str(v)) * Decimal("1e9")
|
|
931
|
+
if nanoseconds != nanoseconds.to_integral_value():
|
|
932
|
+
raise ValueError("dds_deadline_s must be an integral number of nanoseconds")
|
|
933
|
+
return v
|
|
934
|
+
|
|
935
|
+
@field_validator("payload_bytes")
|
|
936
|
+
@classmethod
|
|
937
|
+
def _positive_payload(cls, v: int | None) -> int | None:
|
|
938
|
+
if v is not None and v <= 0:
|
|
939
|
+
raise ValueError(f"payload_bytes must be positive, got {v}")
|
|
940
|
+
return v
|
|
941
|
+
|
|
942
|
+
@field_validator("command_topic")
|
|
943
|
+
@classmethod
|
|
944
|
+
def _nonblank_command_topic(cls, v: str | None) -> str | None:
|
|
945
|
+
if v is not None and not v.strip():
|
|
946
|
+
raise ValueError("command_topic must not be blank")
|
|
947
|
+
return v
|
|
948
|
+
|
|
949
|
+
@model_validator(mode="after")
|
|
950
|
+
def _signals_need_a_message_type(self) -> "Driver":
|
|
951
|
+
"""A field path means nothing without the message it indexes into."""
|
|
952
|
+
if self.signals and self.msg_type is None:
|
|
953
|
+
raise ValueError(
|
|
954
|
+
f"driver declares signals {sorted(self.signals)} but no msg_type; "
|
|
955
|
+
"a field path cannot be resolved without the message type"
|
|
956
|
+
)
|
|
957
|
+
procedure_ids = [procedure.id for procedure in self.procedures]
|
|
958
|
+
if len(set(procedure_ids)) != len(procedure_ids):
|
|
959
|
+
raise ValueError("driver procedure IDs must be unique")
|
|
960
|
+
missing = sorted({procedure.signal for procedure in self.procedures} - set(self.signals))
|
|
961
|
+
if missing:
|
|
962
|
+
raise ValueError(
|
|
963
|
+
f"driver procedures name undeclared signals {missing}; "
|
|
964
|
+
"a procedure marker must use a declared signal"
|
|
965
|
+
)
|
|
966
|
+
return self
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
class MountShape(Base):
|
|
970
|
+
"""Small mounting geometry beside a device's declared offset."""
|
|
971
|
+
|
|
972
|
+
kind: Literal["plate", "bracket", "tube", "custom-mesh"]
|
|
973
|
+
dimensions_mm: tuple[float, float, float]
|
|
974
|
+
fastener_pattern: str | None = None
|
|
975
|
+
mesh_path: str | None = None
|
|
976
|
+
|
|
977
|
+
@field_validator("dimensions_mm")
|
|
978
|
+
@classmethod
|
|
979
|
+
def _positive_dimensions(cls, value: tuple[float, float, float]) -> tuple[float, float, float]:
|
|
980
|
+
if any(not math.isfinite(dimension) or dimension <= 0 for dimension in value):
|
|
981
|
+
raise ValueError("mount_shape dimensions_mm must be three positive finite numbers")
|
|
982
|
+
return value
|
|
983
|
+
|
|
984
|
+
@field_validator("fastener_pattern")
|
|
985
|
+
@classmethod
|
|
986
|
+
def _nonblank_fastener_pattern(cls, value: str | None) -> str | None:
|
|
987
|
+
if value is not None and not value.strip():
|
|
988
|
+
raise ValueError("mount_shape fastener_pattern must not be blank")
|
|
989
|
+
return value
|
|
990
|
+
|
|
991
|
+
@model_validator(mode="after")
|
|
992
|
+
def _mesh_matches_kind(self) -> "MountShape":
|
|
993
|
+
if self.kind == "custom-mesh":
|
|
994
|
+
if not self.mesh_path or not self.mesh_path.strip():
|
|
995
|
+
raise ValueError("custom-mesh mount_shape requires mesh_path")
|
|
996
|
+
from lockwire.edit import source_is_absolute
|
|
997
|
+
|
|
998
|
+
if source_is_absolute(self.mesh_path):
|
|
999
|
+
raise ValueError("custom-mesh mesh_path must be non-absolute")
|
|
1000
|
+
elif self.mesh_path is not None:
|
|
1001
|
+
raise ValueError("mesh_path is only valid for a custom-mesh mount_shape")
|
|
1002
|
+
return self
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
class Physical(Base):
|
|
1006
|
+
"""Installed facts consumed by mass budgets, reference integrity, and CG reporting."""
|
|
1007
|
+
|
|
1008
|
+
mass: MassQuantity | None = None # absent -> not scored, never summed as 0
|
|
1009
|
+
mount_frame: str | None = None # absent -> not scored, never crashes a consumer
|
|
1010
|
+
mount_xyz: tuple[LengthQuantity, LengthQuantity, LengthQuantity] | None = None
|
|
1011
|
+
"""Device offset in ``mount_frame``; consumed by severity-free CG reporting."""
|
|
1012
|
+
mount_shape: MountShape | None = None
|
|
1013
|
+
|
|
1014
|
+
@model_validator(mode="after")
|
|
1015
|
+
def _mount_xyz_needs_a_frame(self) -> "Physical":
|
|
1016
|
+
if self.mount_xyz is not None and self.mount_frame is None:
|
|
1017
|
+
raise ValueError("mount_xyz requires mount_frame")
|
|
1018
|
+
return self
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
class PowerMode(Base):
|
|
1022
|
+
"""Named alternate draw consumed by power-budget mode selection."""
|
|
1023
|
+
|
|
1024
|
+
name: str
|
|
1025
|
+
draw: PowerQuantity
|
|
1026
|
+
|
|
1027
|
+
@field_validator("name")
|
|
1028
|
+
@classmethod
|
|
1029
|
+
def _nonempty_name(cls, v: str) -> str:
|
|
1030
|
+
if not v.strip():
|
|
1031
|
+
raise ValueError("power mode name must not be blank")
|
|
1032
|
+
return v
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
class PowerDraw(Base):
|
|
1036
|
+
"""Nominal draw plus sparse named overrides consumed by power budgets."""
|
|
1037
|
+
|
|
1038
|
+
rail: str # -> Rail.id
|
|
1039
|
+
draw: PowerQuantity | None = None # absent -> not scored, never summed as 0
|
|
1040
|
+
modes: list[PowerMode] = []
|
|
1041
|
+
choices: list[str] | None = None # optional allowed power-mode names
|
|
1042
|
+
|
|
1043
|
+
@model_validator(mode="after")
|
|
1044
|
+
def _unique_mode_names(self) -> "PowerDraw":
|
|
1045
|
+
names = [mode.name for mode in self.modes]
|
|
1046
|
+
duplicates = sorted({name for name in names if names.count(name) > 1})
|
|
1047
|
+
if duplicates:
|
|
1048
|
+
raise ValueError(f"duplicate power mode name(s): {', '.join(duplicates)}")
|
|
1049
|
+
return self
|
|
1050
|
+
|
|
1051
|
+
@model_validator(mode="after")
|
|
1052
|
+
def _modes_need_a_fallback_draw(self) -> "PowerDraw":
|
|
1053
|
+
if self.modes and self.draw is None:
|
|
1054
|
+
raise ValueError("power modes require a nominal 'draw' as their fallback")
|
|
1055
|
+
return self
|
|
1056
|
+
|
|
1057
|
+
@model_validator(mode="after")
|
|
1058
|
+
def _choices_are_declared_modes(self) -> "PowerDraw":
|
|
1059
|
+
if self.choices is None:
|
|
1060
|
+
return self
|
|
1061
|
+
if not self.choices or any(not choice.strip() for choice in self.choices):
|
|
1062
|
+
raise ValueError("power choices must not be empty or blank")
|
|
1063
|
+
duplicates = sorted({choice for choice in self.choices if self.choices.count(choice) > 1})
|
|
1064
|
+
if duplicates:
|
|
1065
|
+
raise ValueError(f"duplicate power choice(s): {', '.join(duplicates)}")
|
|
1066
|
+
declared = {"nominal", *(mode.name for mode in self.modes)}
|
|
1067
|
+
unknown = sorted(set(self.choices) - declared)
|
|
1068
|
+
if unknown:
|
|
1069
|
+
raise ValueError("power choices must name declared modes: " + ", ".join(unknown))
|
|
1070
|
+
return self
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
class EnvelopeEvidence(Base):
|
|
1074
|
+
"""Wilks evidence consumed by `baseline review` and under-sampling reporting."""
|
|
1075
|
+
|
|
1076
|
+
n: int | None = None
|
|
1077
|
+
n_eff: float | None = None
|
|
1078
|
+
coverage: float | None = None
|
|
1079
|
+
confidence: float | None = None
|
|
1080
|
+
|
|
1081
|
+
@model_validator(mode="after")
|
|
1082
|
+
def _valid_evidence(self) -> "EnvelopeEvidence":
|
|
1083
|
+
if self.n is not None and self.n <= 0:
|
|
1084
|
+
raise ValueError(f"n must be positive, got {self.n}")
|
|
1085
|
+
if self.n_eff is not None and self.n_eff <= 0:
|
|
1086
|
+
raise ValueError(f"n_eff must be positive, got {self.n_eff}")
|
|
1087
|
+
if self.n is not None and self.n_eff is not None and self.n_eff > self.n:
|
|
1088
|
+
raise ValueError(f"n_eff {self.n_eff} cannot exceed n {self.n}")
|
|
1089
|
+
if (self.coverage is None) != (self.confidence is None):
|
|
1090
|
+
raise ValueError("coverage and confidence must be provided together")
|
|
1091
|
+
for name, value in (("coverage", self.coverage), ("confidence", self.confidence)):
|
|
1092
|
+
if value is not None and not 0.0 < value <= 1.0:
|
|
1093
|
+
raise ValueError(f"{name} must be in (0, 1], got {value}")
|
|
1094
|
+
return self
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
class SignalEnvelope(EnvelopeEvidence):
|
|
1098
|
+
"""Wilks signal envelope consumed by baseline review and preflight."""
|
|
1099
|
+
|
|
1100
|
+
band: Band
|
|
1101
|
+
variance_floor: float | None = None # CUSUM reference value
|
|
1102
|
+
# Stage-3 one-shot floor (preflight ``sanity``): the capture's minimum
|
|
1103
|
+
# sliding-window population variance must be >= this value. Not the CUSUM
|
|
1104
|
+
# reference — that is ``variance_floor`` (docs/stats_design.md);
|
|
1105
|
+
# baseline/CUSUM must not read this field.
|
|
1106
|
+
oneshot_variance_min: float | None = None
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
class FittedBandEvidence(Base):
|
|
1110
|
+
"""Locked operands for the §5 fitted-band decision."""
|
|
1111
|
+
|
|
1112
|
+
rank: int
|
|
1113
|
+
n_eff: int
|
|
1114
|
+
alpha: float
|
|
1115
|
+
|
|
1116
|
+
@field_validator("rank", "n_eff", mode="before")
|
|
1117
|
+
@classmethod
|
|
1118
|
+
def _strict_positive_integer(cls, value: object) -> object:
|
|
1119
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
1120
|
+
raise ValueError("rank and n_eff must be positive integers")
|
|
1121
|
+
return value
|
|
1122
|
+
|
|
1123
|
+
@model_validator(mode="after")
|
|
1124
|
+
def _valid_fitted_evidence(self) -> "FittedBandEvidence":
|
|
1125
|
+
if self.rank > self.n_eff:
|
|
1126
|
+
raise ValueError(f"rank {self.rank} cannot exceed fitted n_eff {self.n_eff}")
|
|
1127
|
+
if not math.isfinite(self.alpha) or not 0.0 < self.alpha <= 1.0:
|
|
1128
|
+
raise ValueError(f"alpha must be finite and in (0, 1], got {self.alpha}")
|
|
1129
|
+
return self
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
class ContextEnvelope(EnvelopeEvidence):
|
|
1133
|
+
"""Wilks context envelope consumed by baseline review and preflight."""
|
|
1134
|
+
|
|
1135
|
+
context: str
|
|
1136
|
+
rate_hz: Band | None = None
|
|
1137
|
+
startup_ms_p95: float | None = None
|
|
1138
|
+
signals: dict[str, SignalEnvelope] = {}
|
|
1139
|
+
conditions: dict[str, Band] = {} # env channel -> range actually baselined (condition envelope)
|
|
1140
|
+
fitted_bands: dict[str, FittedBandEvidence] = {}
|
|
1141
|
+
provenance: Provenance
|
|
1142
|
+
|
|
1143
|
+
@field_validator("context")
|
|
1144
|
+
@classmethod
|
|
1145
|
+
def _known_context(cls, v: str) -> str:
|
|
1146
|
+
if v not in CONTEXTS:
|
|
1147
|
+
raise ValueError(f"unknown context {v!r}; allowed: {CONTEXTS}")
|
|
1148
|
+
return v
|
|
1149
|
+
|
|
1150
|
+
@model_validator(mode="after")
|
|
1151
|
+
def _valid_fitted_band_paths(self) -> "ContextEnvelope":
|
|
1152
|
+
declared = set()
|
|
1153
|
+
if self.rate_hz is not None:
|
|
1154
|
+
declared.add("rate_hz")
|
|
1155
|
+
declared.update(f"signals/{name}" for name in self.signals)
|
|
1156
|
+
declared.update(f"conditions/{name}" for name in self.conditions)
|
|
1157
|
+
unknown = sorted(set(self.fitted_bands) - declared)
|
|
1158
|
+
if unknown:
|
|
1159
|
+
raise ValueError("fitted_bands refers to undeclared band(s): " + ", ".join(unknown))
|
|
1160
|
+
if self.fitted_bands:
|
|
1161
|
+
if self.provenance.witness != "live":
|
|
1162
|
+
raise ValueError("fitted_bands requires provenance witness 'live'")
|
|
1163
|
+
if self.provenance.bag_sha256 is None:
|
|
1164
|
+
raise ValueError("fitted_bands requires provenance bag_sha256")
|
|
1165
|
+
return self
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
class FittedBandAllocation(Base):
|
|
1169
|
+
"""One fit-time cluster allocation and its fitted inventory."""
|
|
1170
|
+
|
|
1171
|
+
context: str
|
|
1172
|
+
channels: list[str]
|
|
1173
|
+
fitted_bands: list[str] = []
|
|
1174
|
+
denominator: int
|
|
1175
|
+
|
|
1176
|
+
@field_validator("context")
|
|
1177
|
+
@classmethod
|
|
1178
|
+
def _known_context(cls, value: str) -> str:
|
|
1179
|
+
if value not in CONTEXTS:
|
|
1180
|
+
raise ValueError(f"unknown context {value!r}; allowed: {CONTEXTS}")
|
|
1181
|
+
return value
|
|
1182
|
+
|
|
1183
|
+
@field_validator("denominator", mode="before")
|
|
1184
|
+
@classmethod
|
|
1185
|
+
def _strict_positive_denominator(cls, value: object) -> object:
|
|
1186
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
1187
|
+
raise ValueError("denominator must be a positive integer")
|
|
1188
|
+
return value
|
|
1189
|
+
|
|
1190
|
+
@model_validator(mode="after")
|
|
1191
|
+
def _valid_inventory(self) -> "FittedBandAllocation":
|
|
1192
|
+
if not self.channels:
|
|
1193
|
+
raise ValueError("allocation channels must not be empty")
|
|
1194
|
+
if len(self.channels) != len(set(self.channels)):
|
|
1195
|
+
raise ValueError("allocation channels must be unique")
|
|
1196
|
+
if len(self.fitted_bands) != len(set(self.fitted_bands)):
|
|
1197
|
+
raise ValueError("allocation fitted_bands must be unique")
|
|
1198
|
+
if any(not value for value in self.channels):
|
|
1199
|
+
raise ValueError("allocation channel ids must not be empty")
|
|
1200
|
+
if any(not value for value in self.fitted_bands):
|
|
1201
|
+
raise ValueError("allocation fitted-band paths must not be empty")
|
|
1202
|
+
return self
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
class DeviceThermal(Base):
|
|
1206
|
+
"""Installed thermal path consumed by the thermal-budget check.
|
|
1207
|
+
|
|
1208
|
+
``resistance`` is published enclosure/heatsink RθJA (junction-to-ambient)
|
|
1209
|
+
from the contract author's datasheet; ``ambient`` is the declared
|
|
1210
|
+
operating air temperature. Declared per-device: it does not account for
|
|
1211
|
+
co-located dissipation, so a device packed beside hot neighbours can run
|
|
1212
|
+
hotter than this figure implies. Either may be absent — the check emits
|
|
1213
|
+
``not_scored`` naming the gap rather than guessing a resistance.
|
|
1214
|
+
"""
|
|
1215
|
+
|
|
1216
|
+
resistance: ThermalResistanceQuantity | None = None
|
|
1217
|
+
ambient: TemperatureQuantity | None = None
|
|
1218
|
+
provenance: Provenance | None = None
|
|
1219
|
+
|
|
1220
|
+
@model_validator(mode="after")
|
|
1221
|
+
def _resistance_needs_provenance(self) -> "DeviceThermal":
|
|
1222
|
+
if self.resistance is not None and self.provenance is None:
|
|
1223
|
+
raise ValueError(
|
|
1224
|
+
"thermal resistance requires provenance (enclosure/heatsink datasheet)"
|
|
1225
|
+
)
|
|
1226
|
+
return self
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
class DeviceInstance(Base):
|
|
1230
|
+
"""Local facts; identity is optional for devices that do not sit on a data bus.
|
|
1231
|
+
|
|
1232
|
+
When present, `identity` is consumed by reference integrity, bandwidth
|
|
1233
|
+
budgeting, and identity checks. `drives_joint` pairs torque-budget model
|
|
1234
|
+
and joint operands.
|
|
1235
|
+
"""
|
|
1236
|
+
|
|
1237
|
+
id: str # e.g. "dev.imu"
|
|
1238
|
+
model: str # -> DeviceModel.id
|
|
1239
|
+
drives_joint: str | None = None # -> ModelRecord.joints.name; torque-budget pairing
|
|
1240
|
+
identity: DeviceIdentity | None = None
|
|
1241
|
+
driver: Driver | None = None
|
|
1242
|
+
physical: Physical | None = None
|
|
1243
|
+
power: PowerDraw | None = None
|
|
1244
|
+
envelopes: list[ContextEnvelope] = []
|
|
1245
|
+
depends: list[str] = [] # code paths (sugar for Tier B edges on this device)
|
|
1246
|
+
owner: str
|
|
1247
|
+
provenance: Provenance | None = None
|
|
1248
|
+
thermal: DeviceThermal | None = None # installed RθJA + ambient; thermal-budget check
|
|
1249
|
+
link_budget: LinkBudget | None = None # mission RF requirements + installed antennas
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
# ---------------------------------------------------------------- topology & budgets
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
class Host(Base):
|
|
1256
|
+
"""Topology host consumed by reference integrity; provenance supports report citation."""
|
|
1257
|
+
|
|
1258
|
+
id: str
|
|
1259
|
+
notes: str | None = None
|
|
1260
|
+
provenance: Provenance | None = None
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
class LogicPair(Base):
|
|
1264
|
+
id: str
|
|
1265
|
+
driver: str
|
|
1266
|
+
receiver: str
|
|
1267
|
+
operating_supply_min: VoltageQuantity | None = None
|
|
1268
|
+
operating_supply_max: VoltageQuantity | None = None
|
|
1269
|
+
voh_min: VoltageQuantity | None = None
|
|
1270
|
+
vol_max: VoltageQuantity | None = None
|
|
1271
|
+
vih_min: VoltageQuantity | None = None
|
|
1272
|
+
vil_max: VoltageQuantity | None = None
|
|
1273
|
+
provenance: Provenance | None = None
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
class I2CSegment(Base):
|
|
1277
|
+
unbuffered: bool | None = None
|
|
1278
|
+
mode: str | None = None
|
|
1279
|
+
vdd_min_v: float | None = None
|
|
1280
|
+
vdd_max_v: float | None = None
|
|
1281
|
+
pullup_min_ohm: float | None = None
|
|
1282
|
+
pullup_max_ohm: float | None = None
|
|
1283
|
+
capacitance_max_pf: float | None = None
|
|
1284
|
+
rise_time_max_ns: float | None = None
|
|
1285
|
+
sink_current_min_ma: float | None = None
|
|
1286
|
+
vol_max_v: float | None = None
|
|
1287
|
+
provenance: Provenance | None = None
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
class CANSegment(Base):
|
|
1291
|
+
linear: bool | None = None
|
|
1292
|
+
nodes: list[str] = []
|
|
1293
|
+
termination_nodes: list[str] = []
|
|
1294
|
+
termination_ohm: list[float] = []
|
|
1295
|
+
cable_impedance_ohm: float | None = None
|
|
1296
|
+
impedance_tolerance_ohm: float | None = None
|
|
1297
|
+
provenance: Provenance | None = None
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
class Bus(Base):
|
|
1301
|
+
"""Physical bus; bitrate feeds bandwidth checks and provenance supports report citation."""
|
|
1302
|
+
|
|
1303
|
+
id: str
|
|
1304
|
+
kind: Literal["usb", "ethernet", "serial", "can", "i2c", "spi"]
|
|
1305
|
+
host: str # -> Host.id
|
|
1306
|
+
bitrate: float | None = None # raw bits/s consumed by the bandwidth budget
|
|
1307
|
+
provenance: Provenance | None = None
|
|
1308
|
+
i2c_segment: I2CSegment | None = None
|
|
1309
|
+
can_segment: CANSegment | None = None
|
|
1310
|
+
|
|
1311
|
+
@field_validator("bitrate")
|
|
1312
|
+
@classmethod
|
|
1313
|
+
def _positive_bitrate(cls, v: float | None) -> float | None:
|
|
1314
|
+
if v is not None and v <= 0:
|
|
1315
|
+
raise ValueError(f"bitrate must be positive, got {v}")
|
|
1316
|
+
return v
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
class Rail(Base):
|
|
1320
|
+
"""Electrical rail consumed by power checks; provenance supports report citation."""
|
|
1321
|
+
|
|
1322
|
+
id: str
|
|
1323
|
+
voltage: VoltageQuantity | None = None # absent -> not scored, never crashes a consumer
|
|
1324
|
+
voltage_range: InputVoltageRange | None = None # published min/max V, optional
|
|
1325
|
+
current_limit: CurrentQuantity | None = None # published max A, optional
|
|
1326
|
+
capacity: PowerQuantity # power the rail can deliver
|
|
1327
|
+
current_limit_enforced_by: DeclaredEnforcement | None = None
|
|
1328
|
+
capacity_enforced_by: DeclaredEnforcement | None = None
|
|
1329
|
+
upstream: str | None = None # -> parent Rail.id or "battery"; rail-tree rollup
|
|
1330
|
+
efficiency: float = 1.0 # converter efficiency consumed by rail-tree rollup
|
|
1331
|
+
charged_by: str | None = None # -> Device.id of an alternator/generator feeding this rail
|
|
1332
|
+
provenance: Provenance | None = None
|
|
1333
|
+
|
|
1334
|
+
@field_validator("efficiency")
|
|
1335
|
+
@classmethod
|
|
1336
|
+
def _valid_efficiency(cls, v: float) -> float:
|
|
1337
|
+
if not 0.0 < v <= 1.0:
|
|
1338
|
+
raise ValueError(f"efficiency must be in (0, 1], got {v}")
|
|
1339
|
+
return v
|
|
1340
|
+
|
|
1341
|
+
@model_validator(mode="after")
|
|
1342
|
+
def _voltage_within_range(self) -> "Rail":
|
|
1343
|
+
if self.current_limit_enforced_by is not None and self.current_limit is None:
|
|
1344
|
+
raise ValueError("current_limit_enforced_by requires current_limit")
|
|
1345
|
+
if self.voltage is not None and self.voltage_range is not None:
|
|
1346
|
+
v = self.voltage.base()
|
|
1347
|
+
lo, hi = self.voltage_range.min.base(), self.voltage_range.max.base()
|
|
1348
|
+
if not (lo <= v <= hi):
|
|
1349
|
+
raise ValueError(
|
|
1350
|
+
f"rail voltage {self.voltage} outside declared voltage_range "
|
|
1351
|
+
f"{self.voltage_range.min}-{self.voltage_range.max}"
|
|
1352
|
+
)
|
|
1353
|
+
return self
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
class CapacityDerating(Base):
|
|
1357
|
+
"""Usable-capacity factor at or below a pack temperature.
|
|
1358
|
+
|
|
1359
|
+
Cold is the dominant endurance threat for Li-ion (Peukert/C-rate ≈ negligible):
|
|
1360
|
+
~20-35% capacity loss at 0 C, 40%+ below -10 C, plus IR-driven sag cutoffs.
|
|
1361
|
+
Declared rows make the endurance budget condition-aware.
|
|
1362
|
+
"""
|
|
1363
|
+
|
|
1364
|
+
below_c: float # applies at pack temperatures <= this
|
|
1365
|
+
capacity_factor: float # usable fraction of nominal capacity
|
|
1366
|
+
|
|
1367
|
+
@field_validator("capacity_factor")
|
|
1368
|
+
@classmethod
|
|
1369
|
+
def _sane_factor(cls, v: float) -> float:
|
|
1370
|
+
if not 0.0 < v <= 1.0:
|
|
1371
|
+
raise ValueError(f"capacity_factor must be in (0, 1], got {v}")
|
|
1372
|
+
return v
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
class Battery(Base):
|
|
1376
|
+
"""Pack facts consumed by endurance and rail-voltage consistency checks."""
|
|
1377
|
+
|
|
1378
|
+
capacity: EnergyQuantity # energy (nominal, at room temperature)
|
|
1379
|
+
voltage: VoltageQuantity | None = None # rail-voltage consistency; endurance interpretation
|
|
1380
|
+
derating: list[CapacityDerating] = []
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
class Wire(Base):
|
|
1384
|
+
"""Power conductor; ampacity check compares published AWG limit to rail draw.
|
|
1385
|
+
|
|
1386
|
+
``installation`` is mandatory: free-air and enclosed ampacities differ
|
|
1387
|
+
2–3× in published tables, so a silent default would report a confident wrong
|
|
1388
|
+
answer. AWG is a gauge index (not a Quantity); ampacity current is the
|
|
1389
|
+
**worst** rolled-up rail watts across nominal and every declared device
|
|
1390
|
+
``PowerMode``, divided by rail volts — never a silently-defaulted single
|
|
1391
|
+
mode. ``draw_mode`` is retained for Budget-parity / explicit
|
|
1392
|
+
documentation but is not the ampacity selection path.
|
|
1393
|
+
"""
|
|
1394
|
+
|
|
1395
|
+
id: str
|
|
1396
|
+
awg: int # American Wire Gauge (positive integer sizes; e.g. 16)
|
|
1397
|
+
installation: Literal["free_air", "enclosed"] # no default — required at validation
|
|
1398
|
+
rail: str # -> Rail.id; computed draw is the rolled-up load on this rail
|
|
1399
|
+
draw_mode: str = "nominal" # retained; ampacity evaluates worst declared mode
|
|
1400
|
+
owner: str
|
|
1401
|
+
provenance: Provenance | None = None
|
|
1402
|
+
|
|
1403
|
+
@field_validator("awg")
|
|
1404
|
+
@classmethod
|
|
1405
|
+
def _positive_awg(cls, v: int) -> int:
|
|
1406
|
+
if v <= 0:
|
|
1407
|
+
raise ValueError(f"awg must be a positive gauge number, got {v}")
|
|
1408
|
+
return v
|
|
1409
|
+
|
|
1410
|
+
@field_validator("draw_mode")
|
|
1411
|
+
@classmethod
|
|
1412
|
+
def _nonempty_draw_mode(cls, v: str) -> str:
|
|
1413
|
+
if not v.strip():
|
|
1414
|
+
raise ValueError("wire draw_mode must not be blank")
|
|
1415
|
+
return v
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
class Topology(Base):
|
|
1419
|
+
hosts: list[Host] = []
|
|
1420
|
+
buses: list[Bus] = []
|
|
1421
|
+
logic_pairs: list[LogicPair] = []
|
|
1422
|
+
rails: list[Rail] = []
|
|
1423
|
+
wires: list[Wire] = []
|
|
1424
|
+
battery: Battery | None = None
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
class Budget(Base):
|
|
1428
|
+
"""Derived entry consumed by budget checks; provenance supports report citation."""
|
|
1429
|
+
|
|
1430
|
+
id: str
|
|
1431
|
+
kind: Literal["rail_power", "endurance", "mass_total"]
|
|
1432
|
+
limit: Quantity # dimension is selected by kind below
|
|
1433
|
+
limit_enforced_by: DeclaredEnforcement | None = None
|
|
1434
|
+
rail: str | None = None # rail_power only
|
|
1435
|
+
draw_mode: str = "nominal" # named device-power override selected by power budget checks
|
|
1436
|
+
sources: Literal["devices", "links", "links+devices"] = "devices" # mass_total: what to
|
|
1437
|
+
# sum — explicit so double-counting a device that also appears in the URDF is a declared
|
|
1438
|
+
# choice, not an accident
|
|
1439
|
+
owner: str
|
|
1440
|
+
provenance: Provenance | None = None
|
|
1441
|
+
|
|
1442
|
+
@field_validator("draw_mode")
|
|
1443
|
+
@classmethod
|
|
1444
|
+
def _nonempty_draw_mode(cls, v: str) -> str:
|
|
1445
|
+
if not v.strip():
|
|
1446
|
+
raise ValueError("budget draw_mode must not be blank")
|
|
1447
|
+
return v
|
|
1448
|
+
|
|
1449
|
+
@model_validator(mode="after")
|
|
1450
|
+
def _rail_iff_rail_power(self) -> "Budget":
|
|
1451
|
+
if (self.kind == "rail_power") != (self.rail is not None):
|
|
1452
|
+
raise ValueError("rail must be set exactly when kind == rail_power")
|
|
1453
|
+
expected = {
|
|
1454
|
+
"rail_power": "power",
|
|
1455
|
+
"endurance": "time",
|
|
1456
|
+
"mass_total": "mass",
|
|
1457
|
+
}[self.kind]
|
|
1458
|
+
_quantity_dimension(expected, f"{self.kind} budget limit")(self.limit)
|
|
1459
|
+
return self
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
class RobotInfo(Base):
|
|
1463
|
+
name: str
|
|
1464
|
+
id: str | None = None
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
EvidenceClass = Literal["measured", "synthetic"]
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
class ActuatorSignalBinding(Base):
|
|
1471
|
+
"""One named signal the selected actuator case consumes."""
|
|
1472
|
+
|
|
1473
|
+
signal: str
|
|
1474
|
+
scope: Literal["command", "observation"]
|
|
1475
|
+
unit: Literal["rad", "rad/s", "N*m", "V", "A"]
|
|
1476
|
+
frame: str | None = None
|
|
1477
|
+
provenance_class: EvidenceClass
|
|
1478
|
+
|
|
1479
|
+
@field_validator("signal")
|
|
1480
|
+
@classmethod
|
|
1481
|
+
def _nonempty_signal(cls, value: str) -> str:
|
|
1482
|
+
if not value.strip():
|
|
1483
|
+
raise ValueError("actuator signal binding must name a signal")
|
|
1484
|
+
return value
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
class ActuatorEventBinding(Base):
|
|
1488
|
+
event: str
|
|
1489
|
+
scope: Literal["command", "observation"]
|
|
1490
|
+
provenance_class: EvidenceClass
|
|
1491
|
+
|
|
1492
|
+
@field_validator("event")
|
|
1493
|
+
@classmethod
|
|
1494
|
+
def _nonempty_event(cls, value: str) -> str:
|
|
1495
|
+
if not value.strip():
|
|
1496
|
+
raise ValueError("actuator event binding must name an event")
|
|
1497
|
+
return value
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
class IndependentLoadBinding(Base):
|
|
1501
|
+
"""The independently calibrated load witness; never a command-derived torque."""
|
|
1502
|
+
|
|
1503
|
+
signal: str
|
|
1504
|
+
scope: Literal["observation"]
|
|
1505
|
+
unit: Literal["N*m"]
|
|
1506
|
+
joint: str
|
|
1507
|
+
frame: str
|
|
1508
|
+
sign: Literal[-1, 1]
|
|
1509
|
+
application_point: tuple[float, float, float]
|
|
1510
|
+
axis: tuple[float, float, float]
|
|
1511
|
+
applicability: Literal["static", "dynamic"]
|
|
1512
|
+
provenance_class: Literal["measured"]
|
|
1513
|
+
|
|
1514
|
+
@field_validator("signal", "joint", "frame")
|
|
1515
|
+
@classmethod
|
|
1516
|
+
def _nonempty_load_text(cls, value: str) -> str:
|
|
1517
|
+
if not value.strip():
|
|
1518
|
+
raise ValueError("independent load binding fields must not be blank")
|
|
1519
|
+
return value
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
class ActuatorCaseBindings(Base):
|
|
1523
|
+
command_position: ActuatorSignalBinding
|
|
1524
|
+
command_velocity: ActuatorSignalBinding
|
|
1525
|
+
command_torque: ActuatorSignalBinding
|
|
1526
|
+
measured_position: ActuatorSignalBinding
|
|
1527
|
+
measured_joint_torque: ActuatorSignalBinding
|
|
1528
|
+
receiver_voltage: ActuatorSignalBinding | None = None
|
|
1529
|
+
command_issued_event: ActuatorEventBinding
|
|
1530
|
+
feedback_event: ActuatorEventBinding
|
|
1531
|
+
independent_load: IndependentLoadBinding
|
|
1532
|
+
source_voltage: ActuatorSignalBinding | None = None
|
|
1533
|
+
load_current: ActuatorSignalBinding | None = None
|
|
1534
|
+
|
|
1535
|
+
@model_validator(mode="after")
|
|
1536
|
+
def _roles_are_explicit(self) -> "ActuatorCaseBindings":
|
|
1537
|
+
expected = {
|
|
1538
|
+
"command_position": ("command", "rad"),
|
|
1539
|
+
"command_velocity": ("command", "rad/s"),
|
|
1540
|
+
"command_torque": ("command", "N*m"),
|
|
1541
|
+
"measured_position": ("observation", "rad"),
|
|
1542
|
+
"measured_joint_torque": ("observation", "N*m"),
|
|
1543
|
+
"receiver_voltage": ("observation", "V"),
|
|
1544
|
+
}
|
|
1545
|
+
for name, (scope, unit) in expected.items():
|
|
1546
|
+
row = getattr(self, name)
|
|
1547
|
+
if row is None:
|
|
1548
|
+
continue
|
|
1549
|
+
if row.scope != scope or row.unit != unit:
|
|
1550
|
+
raise ValueError(f"{name} requires scope {scope!r} and unit {unit!r}")
|
|
1551
|
+
if self.measured_position.frame is None or self.measured_joint_torque.frame is None:
|
|
1552
|
+
raise ValueError(
|
|
1553
|
+
"measured position and joint torque bindings require an explicit frame"
|
|
1554
|
+
)
|
|
1555
|
+
if (
|
|
1556
|
+
self.command_issued_event.scope != "command"
|
|
1557
|
+
or self.feedback_event.scope != "observation"
|
|
1558
|
+
):
|
|
1559
|
+
raise ValueError(
|
|
1560
|
+
"command_issued_event and feedback_event require command/observation scope"
|
|
1561
|
+
)
|
|
1562
|
+
for name, unit in (("source_voltage", "V"), ("load_current", "A")):
|
|
1563
|
+
row = getattr(self, name)
|
|
1564
|
+
if row is not None and (row.scope != "observation" or row.unit != unit):
|
|
1565
|
+
raise ValueError(f"{name} requires scope 'observation' and unit {unit!r}")
|
|
1566
|
+
return self
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
class DCRailDeliveryTarget(Base):
|
|
1570
|
+
"""Declared worst-case operands for one steady, single-series DC path."""
|
|
1571
|
+
|
|
1572
|
+
applicability: Literal["single_series_dc_steady"]
|
|
1573
|
+
condition_id: str
|
|
1574
|
+
source_node: str
|
|
1575
|
+
receiver_node: str
|
|
1576
|
+
return_node: str
|
|
1577
|
+
source_voltage_min: VoltageQuantity
|
|
1578
|
+
source_resistance_max: ResistanceQuantity
|
|
1579
|
+
outbound_resistance_max: ResistanceQuantity
|
|
1580
|
+
return_resistance_max: ResistanceQuantity
|
|
1581
|
+
contact_resistance_max: ResistanceQuantity
|
|
1582
|
+
peak_current_max: CurrentQuantity
|
|
1583
|
+
provenance_artifacts: list[ArtifactRef]
|
|
1584
|
+
|
|
1585
|
+
@field_validator("condition_id", "source_node", "receiver_node", "return_node")
|
|
1586
|
+
@classmethod
|
|
1587
|
+
def _nonempty_text(cls, value: str) -> str:
|
|
1588
|
+
if not value.strip():
|
|
1589
|
+
raise ValueError("DC delivery condition and nodes must not be blank")
|
|
1590
|
+
return value
|
|
1591
|
+
|
|
1592
|
+
@model_validator(mode="after")
|
|
1593
|
+
def _physical_operands(self) -> "DCRailDeliveryTarget":
|
|
1594
|
+
if self.source_voltage_min.base() <= 0 or self.peak_current_max.base() <= 0:
|
|
1595
|
+
raise ValueError("DC delivery source voltage and peak current must be positive")
|
|
1596
|
+
for name in (
|
|
1597
|
+
"source_resistance_max",
|
|
1598
|
+
"outbound_resistance_max",
|
|
1599
|
+
"return_resistance_max",
|
|
1600
|
+
"contact_resistance_max",
|
|
1601
|
+
):
|
|
1602
|
+
if getattr(self, name).base() < 0:
|
|
1603
|
+
raise ValueError(f"{name} must be nonnegative")
|
|
1604
|
+
if not self.provenance_artifacts:
|
|
1605
|
+
raise ValueError("DC delivery requires at least one provenance artifact")
|
|
1606
|
+
return self
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
class TransientDCEvidenceBinding(Base):
|
|
1610
|
+
"""Opt-in join between one actuator response and its measured DC evidence.
|
|
1611
|
+
|
|
1612
|
+
This does not reinterpret ``DCRailDeliveryTarget``'s steady single-series
|
|
1613
|
+
calculation. It names the measured channel/part/supply identity, common
|
|
1614
|
+
mapped time basis, and case-specific transient limits instead.
|
|
1615
|
+
"""
|
|
1616
|
+
|
|
1617
|
+
applicability: Literal["actuator_command_response_transient"] | None = None
|
|
1618
|
+
channel: str | None = None
|
|
1619
|
+
part: str | None = None
|
|
1620
|
+
supply_rail: str | None = None
|
|
1621
|
+
source_node: str | None = None
|
|
1622
|
+
receiver_node: str | None = None
|
|
1623
|
+
return_node: str | None = None
|
|
1624
|
+
time_basis_clock_domain: str | None = None
|
|
1625
|
+
time_basis_epoch: str | None = None
|
|
1626
|
+
voltage_min: VoltageQuantity | None = None
|
|
1627
|
+
current_max: CurrentQuantity | None = None
|
|
1628
|
+
response_latency_max: TimeQuantity | None = None
|
|
1629
|
+
evidence_artifact: ArtifactRef | None = None
|
|
1630
|
+
provenance_artifacts: list[ArtifactRef] = Field(default_factory=list)
|
|
1631
|
+
|
|
1632
|
+
@field_validator(
|
|
1633
|
+
"channel", "part", "supply_rail", "source_node", "receiver_node", "return_node",
|
|
1634
|
+
"time_basis_clock_domain", "time_basis_epoch",
|
|
1635
|
+
)
|
|
1636
|
+
@classmethod
|
|
1637
|
+
def _nonempty_text(cls, value: str | None) -> str | None:
|
|
1638
|
+
if value is not None and not value.strip():
|
|
1639
|
+
raise ValueError("transient DC binding identity fields must not be blank")
|
|
1640
|
+
return value
|
|
1641
|
+
|
|
1642
|
+
@model_validator(mode="after")
|
|
1643
|
+
def _positive_limits_and_provenance(self) -> "TransientDCEvidenceBinding":
|
|
1644
|
+
if any(limit is not None and limit.base() <= 0 for limit in (
|
|
1645
|
+
self.voltage_min, self.current_max, self.response_latency_max
|
|
1646
|
+
)):
|
|
1647
|
+
raise ValueError("transient DC voltage, current, and latency limits must be positive")
|
|
1648
|
+
return self
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
class ActuatorCase(Base):
|
|
1652
|
+
"""The small v0.4 actuator proof's declared operands, not a universal model."""
|
|
1653
|
+
|
|
1654
|
+
id: str
|
|
1655
|
+
actuator_device: str
|
|
1656
|
+
joint: str
|
|
1657
|
+
rail: str
|
|
1658
|
+
load_torque: TorqueQuantity # design input only; never combined with command torque
|
|
1659
|
+
load_applicability: Literal["static", "dynamic"]
|
|
1660
|
+
position_tracking_max_error: AngleQuantity
|
|
1661
|
+
feedback_deadline_max: TimeQuantity
|
|
1662
|
+
clock_alignment_max_error: TimeQuantity
|
|
1663
|
+
prediction_position_rmse_max: AngleQuantity
|
|
1664
|
+
prediction_position_peak_max: AngleQuantity
|
|
1665
|
+
model_input_manifest: ArtifactRef
|
|
1666
|
+
parameter_artifacts: list[ArtifactRef] = []
|
|
1667
|
+
calibration_artifacts: list[ArtifactRef] = []
|
|
1668
|
+
clock_mapping_artifacts: list[ArtifactRef]
|
|
1669
|
+
bindings: ActuatorCaseBindings
|
|
1670
|
+
dc_delivery: DCRailDeliveryTarget | None = None
|
|
1671
|
+
transient_dc: TransientDCEvidenceBinding | None = None
|
|
1672
|
+
transient_dc_agreement_required: bool | None = None
|
|
1673
|
+
feedback_loss_max: Annotated[int, Field(strict=True, ge=0)] | None = None
|
|
1674
|
+
|
|
1675
|
+
@field_validator("id", "actuator_device", "joint", "rail")
|
|
1676
|
+
@classmethod
|
|
1677
|
+
def _nonempty_case_text(cls, value: str) -> str:
|
|
1678
|
+
if not value.strip():
|
|
1679
|
+
raise ValueError("actuator case identifiers must not be blank")
|
|
1680
|
+
return value
|
|
1681
|
+
|
|
1682
|
+
@model_validator(mode="after")
|
|
1683
|
+
def _positive_limits_and_matching_load(self) -> "ActuatorCase":
|
|
1684
|
+
for name in (
|
|
1685
|
+
"position_tracking_max_error",
|
|
1686
|
+
"feedback_deadline_max",
|
|
1687
|
+
"clock_alignment_max_error",
|
|
1688
|
+
"prediction_position_rmse_max",
|
|
1689
|
+
"prediction_position_peak_max",
|
|
1690
|
+
):
|
|
1691
|
+
if getattr(self, name).base() <= 0:
|
|
1692
|
+
raise ValueError(f"{name} must be positive")
|
|
1693
|
+
if self.bindings.independent_load.joint != self.joint:
|
|
1694
|
+
raise ValueError("independent load binding must name the actuator case joint")
|
|
1695
|
+
if self.bindings.independent_load.applicability != self.load_applicability:
|
|
1696
|
+
raise ValueError("independent load applicability must match the declared load context")
|
|
1697
|
+
if not self.clock_mapping_artifacts:
|
|
1698
|
+
raise ValueError("actuator cases require at least one declared clock mapping artifact")
|
|
1699
|
+
hashes = [self.model_input_manifest.sha256]
|
|
1700
|
+
hashes += [
|
|
1701
|
+
item.sha256
|
|
1702
|
+
for item in (
|
|
1703
|
+
self.parameter_artifacts + self.calibration_artifacts + self.clock_mapping_artifacts
|
|
1704
|
+
)
|
|
1705
|
+
]
|
|
1706
|
+
if self.dc_delivery is not None:
|
|
1707
|
+
hashes += [item.sha256 for item in self.dc_delivery.provenance_artifacts]
|
|
1708
|
+
if self.transient_dc is not None:
|
|
1709
|
+
if (
|
|
1710
|
+
self.transient_dc.part is not None
|
|
1711
|
+
and self.transient_dc.part != self.actuator_device
|
|
1712
|
+
):
|
|
1713
|
+
raise ValueError("transient DC part must match actuator_device")
|
|
1714
|
+
if (
|
|
1715
|
+
self.transient_dc.supply_rail is not None
|
|
1716
|
+
and self.transient_dc.supply_rail != self.rail
|
|
1717
|
+
):
|
|
1718
|
+
raise ValueError("transient DC supply_rail must match actuator case rail")
|
|
1719
|
+
if self.transient_dc.evidence_artifact is not None:
|
|
1720
|
+
hashes += [self.transient_dc.evidence_artifact.sha256]
|
|
1721
|
+
hashes += [item.sha256 for item in self.transient_dc.provenance_artifacts]
|
|
1722
|
+
if len(hashes) != len(set(hashes)):
|
|
1723
|
+
raise ValueError("actuator case artifact hashes must be distinct")
|
|
1724
|
+
return self
|
|
1725
|
+
|
|
1726
|
+
|
|
1727
|
+
# ------------------------------------------------ structural + simulation v1
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
def _nonblank(value: str) -> str:
|
|
1731
|
+
if not value.strip():
|
|
1732
|
+
raise ValueError("identifier must not be blank")
|
|
1733
|
+
return value
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
def _unique_row_ids(label: str, rows: list[Any]) -> None:
|
|
1737
|
+
ids = [row.id for row in rows]
|
|
1738
|
+
duplicates = sorted({row_id for row_id in ids if ids.count(row_id) > 1})
|
|
1739
|
+
if duplicates:
|
|
1740
|
+
raise ValueError(f"duplicate id(s) in {label}: {', '.join(duplicates)}")
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
class VectorQuantity(Base):
|
|
1744
|
+
"""A framed physical vector; its semantic consumer validates the role dimension."""
|
|
1745
|
+
|
|
1746
|
+
values: tuple[float, float, float]
|
|
1747
|
+
unit: str
|
|
1748
|
+
frame: str
|
|
1749
|
+
|
|
1750
|
+
@field_validator("values")
|
|
1751
|
+
@classmethod
|
|
1752
|
+
def _finite_values(cls, value: tuple[float, float, float]) -> tuple[float, float, float]:
|
|
1753
|
+
if not all(math.isfinite(component) for component in value):
|
|
1754
|
+
raise ValueError("vector values must be finite")
|
|
1755
|
+
return value
|
|
1756
|
+
|
|
1757
|
+
@field_validator("unit")
|
|
1758
|
+
@classmethod
|
|
1759
|
+
def _known_unit(cls, value: str) -> str:
|
|
1760
|
+
if not units.known(value):
|
|
1761
|
+
raise ValueError(f"unknown unit {value!r}")
|
|
1762
|
+
return value
|
|
1763
|
+
|
|
1764
|
+
@field_validator("frame")
|
|
1765
|
+
@classmethod
|
|
1766
|
+
def _frame_not_blank(cls, value: str) -> str:
|
|
1767
|
+
return _nonblank(value)
|
|
1768
|
+
|
|
1769
|
+
def require_dimension(self, expected: str, role: str) -> None:
|
|
1770
|
+
if units.dimension(self.unit) != expected:
|
|
1771
|
+
raise ValueError(f"{role} requires dimension {expected!r}, got {self.unit!r}")
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
class FrameInput(Base):
|
|
1775
|
+
id: str
|
|
1776
|
+
parent: str
|
|
1777
|
+
translation: VectorQuantity
|
|
1778
|
+
rotation_xyzw: tuple[float, float, float, float]
|
|
1779
|
+
provenance: Provenance
|
|
1780
|
+
|
|
1781
|
+
@field_validator("id", "parent")
|
|
1782
|
+
@classmethod
|
|
1783
|
+
def _text(cls, value: str) -> str:
|
|
1784
|
+
return _nonblank(value)
|
|
1785
|
+
|
|
1786
|
+
@model_validator(mode="after")
|
|
1787
|
+
def _valid_transform(self) -> "FrameInput":
|
|
1788
|
+
self.translation.require_dimension("length", "frame translation")
|
|
1789
|
+
if self.translation.frame != self.parent:
|
|
1790
|
+
raise ValueError("frame translation must be expressed in its parent frame")
|
|
1791
|
+
if not all(math.isfinite(component) for component in self.rotation_xyzw):
|
|
1792
|
+
raise ValueError("frame quaternion must be finite")
|
|
1793
|
+
norm = math.sqrt(sum(component * component for component in self.rotation_xyzw))
|
|
1794
|
+
if not math.isclose(norm, 1.0, rel_tol=0.0, abs_tol=1e-9):
|
|
1795
|
+
raise ValueError("frame quaternion must be normalized")
|
|
1796
|
+
return self
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
class InputArtifact(Base):
|
|
1800
|
+
role: str
|
|
1801
|
+
ref: ArtifactRef
|
|
1802
|
+
format: Literal[
|
|
1803
|
+
"urdf",
|
|
1804
|
+
"sdf",
|
|
1805
|
+
"calculix_inp",
|
|
1806
|
+
"mesh",
|
|
1807
|
+
"json",
|
|
1808
|
+
"yaml",
|
|
1809
|
+
"csv",
|
|
1810
|
+
"world",
|
|
1811
|
+
"controller_config",
|
|
1812
|
+
]
|
|
1813
|
+
provenance: Provenance
|
|
1814
|
+
|
|
1815
|
+
@field_validator("role")
|
|
1816
|
+
@classmethod
|
|
1817
|
+
def _role(cls, value: str) -> str:
|
|
1818
|
+
return _nonblank(value)
|
|
1819
|
+
|
|
1820
|
+
|
|
1821
|
+
class RuntimeSelection(Base):
|
|
1822
|
+
id: str
|
|
1823
|
+
package_profile: str
|
|
1824
|
+
max_wall_time: PositiveTimeQuantity
|
|
1825
|
+
provenance: Provenance
|
|
1826
|
+
|
|
1827
|
+
@field_validator("id")
|
|
1828
|
+
@classmethod
|
|
1829
|
+
def _id(cls, value: str) -> str:
|
|
1830
|
+
return _nonblank(value)
|
|
1831
|
+
|
|
1832
|
+
@field_validator("package_profile")
|
|
1833
|
+
@classmethod
|
|
1834
|
+
def _safe_manifest_key(cls, value: str) -> str:
|
|
1835
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", value):
|
|
1836
|
+
raise ValueError("package_profile must be a safe manifest key")
|
|
1837
|
+
return value
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
class StructuralModelBinding(Base):
|
|
1841
|
+
source_sha256: str
|
|
1842
|
+
structure_hash: str
|
|
1843
|
+
source_frame: str
|
|
1844
|
+
model_artifact: InputArtifact | None = None
|
|
1845
|
+
mesh_artifact: InputArtifact | None = None
|
|
1846
|
+
provenance: Provenance
|
|
1847
|
+
|
|
1848
|
+
@field_validator("source_sha256", "structure_hash")
|
|
1849
|
+
@classmethod
|
|
1850
|
+
def _hashes(cls, value: str) -> str:
|
|
1851
|
+
if len(value) != 64 or any(char not in "0123456789abcdef" for char in value):
|
|
1852
|
+
raise ValueError("model binding hashes must be 64 lowercase hexadecimal characters")
|
|
1853
|
+
return value
|
|
1854
|
+
|
|
1855
|
+
@field_validator("source_frame")
|
|
1856
|
+
@classmethod
|
|
1857
|
+
def _source_frame(cls, value: str) -> str:
|
|
1858
|
+
return _nonblank(value)
|
|
1859
|
+
|
|
1860
|
+
@model_validator(mode="after")
|
|
1861
|
+
def _artifact_binding(self) -> "StructuralModelBinding":
|
|
1862
|
+
if self.model_artifact is not None and self.model_artifact.ref.sha256 != self.source_sha256:
|
|
1863
|
+
raise ValueError("structural model artifact must match the bound model source hash")
|
|
1864
|
+
return self
|
|
1865
|
+
|
|
1866
|
+
|
|
1867
|
+
class RegionSelector(Base):
|
|
1868
|
+
kind: Literal["model_link", "node_set", "element_set", "surface_set", "mesh_region"]
|
|
1869
|
+
name: str
|
|
1870
|
+
source: Literal["contract.model"] = "contract.model"
|
|
1871
|
+
|
|
1872
|
+
@field_validator("name")
|
|
1873
|
+
@classmethod
|
|
1874
|
+
def _name(cls, value: str) -> str:
|
|
1875
|
+
return _nonblank(value)
|
|
1876
|
+
|
|
1877
|
+
|
|
1878
|
+
Axis = Literal["x", "y", "z"]
|
|
1879
|
+
|
|
1880
|
+
|
|
1881
|
+
class DofSelector(Base):
|
|
1882
|
+
translation: list[Axis] = []
|
|
1883
|
+
rotation: list[Axis] = []
|
|
1884
|
+
frame: str
|
|
1885
|
+
prescribed_translation: dict[Axis, Quantity] = {}
|
|
1886
|
+
prescribed_rotation: dict[Axis, Quantity] = {}
|
|
1887
|
+
|
|
1888
|
+
@field_validator("frame")
|
|
1889
|
+
@classmethod
|
|
1890
|
+
def _frame(cls, value: str) -> str:
|
|
1891
|
+
return _nonblank(value)
|
|
1892
|
+
|
|
1893
|
+
@model_validator(mode="after")
|
|
1894
|
+
def _valid_dofs(self) -> "DofSelector":
|
|
1895
|
+
if not self.translation and not self.rotation:
|
|
1896
|
+
raise ValueError("DOF selector must select at least one axis")
|
|
1897
|
+
if len(self.translation) != len(set(self.translation)) or len(self.rotation) != len(
|
|
1898
|
+
set(self.rotation)
|
|
1899
|
+
):
|
|
1900
|
+
raise ValueError("DOF selector axes must be unique")
|
|
1901
|
+
if not set(self.prescribed_translation).issubset(self.translation):
|
|
1902
|
+
raise ValueError("prescribed translation axes must be selected")
|
|
1903
|
+
if not set(self.prescribed_rotation).issubset(self.rotation):
|
|
1904
|
+
raise ValueError("prescribed rotation axes must be selected")
|
|
1905
|
+
for value in self.prescribed_translation.values():
|
|
1906
|
+
if value.dimension() != "length" or value.frame not in {None, self.frame}:
|
|
1907
|
+
raise ValueError("prescribed translations require length in the selector frame")
|
|
1908
|
+
for value in self.prescribed_rotation.values():
|
|
1909
|
+
if value.dimension() != "angle" or value.frame not in {None, self.frame}:
|
|
1910
|
+
raise ValueError("prescribed rotations require angle in the selector frame")
|
|
1911
|
+
return self
|
|
1912
|
+
|
|
1913
|
+
|
|
1914
|
+
class SectionOrientation(Base):
|
|
1915
|
+
id: str
|
|
1916
|
+
frame: str
|
|
1917
|
+
longitudinal: tuple[float, float, float]
|
|
1918
|
+
in_plane_reference: tuple[float, float, float]
|
|
1919
|
+
rotation_about_local_axis: AngleQuantity | None = None
|
|
1920
|
+
provenance: Provenance
|
|
1921
|
+
|
|
1922
|
+
@field_validator("id", "frame")
|
|
1923
|
+
@classmethod
|
|
1924
|
+
def _text(cls, value: str) -> str:
|
|
1925
|
+
return _nonblank(value)
|
|
1926
|
+
|
|
1927
|
+
@model_validator(mode="after")
|
|
1928
|
+
def _right_handed_basis(self) -> "SectionOrientation":
|
|
1929
|
+
first = np.asarray(self.longitudinal, dtype=float)
|
|
1930
|
+
reference = np.asarray(self.in_plane_reference, dtype=float)
|
|
1931
|
+
if not np.isfinite(first).all() or not np.isfinite(reference).all():
|
|
1932
|
+
raise ValueError("section orientation vectors must be finite")
|
|
1933
|
+
first_norm = np.linalg.norm(first)
|
|
1934
|
+
if first_norm <= 1e-12 or np.linalg.norm(reference) <= 1e-12:
|
|
1935
|
+
raise ValueError("section orientation vectors must be nonzero and non-collinear")
|
|
1936
|
+
reference_orthogonal = reference - first * np.dot(reference, first) / (first_norm**2)
|
|
1937
|
+
second_norm = np.linalg.norm(reference_orthogonal)
|
|
1938
|
+
if second_norm <= 1e-12:
|
|
1939
|
+
raise ValueError("section orientation vectors must be nonzero and non-collinear")
|
|
1940
|
+
first /= first_norm
|
|
1941
|
+
second = reference_orthogonal / second_norm
|
|
1942
|
+
third = np.cross(first, second)
|
|
1943
|
+
basis = np.column_stack((first, second, third))
|
|
1944
|
+
if not np.allclose(basis.T @ basis, np.eye(3), atol=1e-9) or np.linalg.det(basis) <= 0:
|
|
1945
|
+
raise ValueError("section orientation must yield a right-handed orthonormal basis")
|
|
1946
|
+
return self
|
|
1947
|
+
|
|
1948
|
+
|
|
1949
|
+
class PlasticPoint(Base):
|
|
1950
|
+
von_mises_yield_stress: PositivePressureQuantity
|
|
1951
|
+
equivalent_plastic_strain: DimensionlessQuantity
|
|
1952
|
+
|
|
1953
|
+
|
|
1954
|
+
class MaterialInput(Base):
|
|
1955
|
+
id: str
|
|
1956
|
+
kind: Literal["linear_isotropic", "isotropic_elastoplastic"]
|
|
1957
|
+
youngs_modulus: PositivePressureQuantity
|
|
1958
|
+
poisson_ratio: DimensionlessQuantity
|
|
1959
|
+
density: DensityQuantity | None = None
|
|
1960
|
+
plastic_curve: list[PlasticPoint] = []
|
|
1961
|
+
provenance: Provenance
|
|
1962
|
+
|
|
1963
|
+
@field_validator("id")
|
|
1964
|
+
@classmethod
|
|
1965
|
+
def _id(cls, value: str) -> str:
|
|
1966
|
+
return _nonblank(value)
|
|
1967
|
+
|
|
1968
|
+
@model_validator(mode="after")
|
|
1969
|
+
def _material_law(self) -> "MaterialInput":
|
|
1970
|
+
if not -1.0 < self.poisson_ratio.base() < 0.5:
|
|
1971
|
+
raise ValueError("Poisson ratio must be strictly between -1 and 0.5")
|
|
1972
|
+
if self.kind == "linear_isotropic" and self.plastic_curve:
|
|
1973
|
+
raise ValueError("linear isotropic material cannot declare a plastic curve")
|
|
1974
|
+
if self.kind == "isotropic_elastoplastic" and not self.plastic_curve:
|
|
1975
|
+
raise ValueError("isotropic elastoplastic material requires a plastic curve")
|
|
1976
|
+
if self.plastic_curve:
|
|
1977
|
+
strains = [point.equivalent_plastic_strain.base() for point in self.plastic_curve]
|
|
1978
|
+
stresses = [point.von_mises_yield_stress.base() for point in self.plastic_curve]
|
|
1979
|
+
if strains[0] != 0.0:
|
|
1980
|
+
raise ValueError("plastic curve first equivalent plastic strain must be exactly zero")
|
|
1981
|
+
if any(right <= left for left, right in zip(strains, strains[1:], strict=False)):
|
|
1982
|
+
raise ValueError("plastic curve strain must increase strictly")
|
|
1983
|
+
if any(right < left for left, right in zip(stresses, stresses[1:], strict=False)):
|
|
1984
|
+
raise ValueError("plastic curve yield stress must be nondecreasing")
|
|
1985
|
+
return self
|
|
1986
|
+
|
|
1987
|
+
|
|
1988
|
+
class SectionInput(Base):
|
|
1989
|
+
id: str
|
|
1990
|
+
kind: Literal["truss", "beam", "frame", "plate", "shell", "solid"]
|
|
1991
|
+
material_id: str
|
|
1992
|
+
area: AreaQuantity | None = None
|
|
1993
|
+
principal_second_moment_y: SecondMomentQuantity | None = None
|
|
1994
|
+
principal_second_moment_z: SecondMomentQuantity | None = None
|
|
1995
|
+
torsion_constant_j: SecondMomentQuantity | None = None
|
|
1996
|
+
shear_area_y: AreaQuantity | None = None
|
|
1997
|
+
shear_area_z: AreaQuantity | None = None
|
|
1998
|
+
thickness: PositiveLengthQuantity | None = None
|
|
1999
|
+
orientation_id: str | None = None
|
|
2000
|
+
provenance: Provenance
|
|
2001
|
+
|
|
2002
|
+
@field_validator("id", "material_id")
|
|
2003
|
+
@classmethod
|
|
2004
|
+
def _text(cls, value: str) -> str:
|
|
2005
|
+
return _nonblank(value)
|
|
2006
|
+
|
|
2007
|
+
@model_validator(mode="after")
|
|
2008
|
+
def _shape(self) -> "SectionInput":
|
|
2009
|
+
if self.kind == "truss":
|
|
2010
|
+
if self.area is None:
|
|
2011
|
+
raise ValueError("truss section requires area")
|
|
2012
|
+
if any(
|
|
2013
|
+
value is not None
|
|
2014
|
+
for value in (
|
|
2015
|
+
self.principal_second_moment_y,
|
|
2016
|
+
self.principal_second_moment_z,
|
|
2017
|
+
self.torsion_constant_j,
|
|
2018
|
+
self.shear_area_y,
|
|
2019
|
+
self.shear_area_z,
|
|
2020
|
+
self.thickness,
|
|
2021
|
+
self.orientation_id,
|
|
2022
|
+
)
|
|
2023
|
+
):
|
|
2024
|
+
raise ValueError("truss section accepts only area")
|
|
2025
|
+
if self.kind in {"beam", "frame"} and any(
|
|
2026
|
+
value is None
|
|
2027
|
+
for value in (
|
|
2028
|
+
self.area,
|
|
2029
|
+
self.principal_second_moment_y,
|
|
2030
|
+
self.principal_second_moment_z,
|
|
2031
|
+
self.torsion_constant_j,
|
|
2032
|
+
self.orientation_id,
|
|
2033
|
+
)
|
|
2034
|
+
):
|
|
2035
|
+
raise ValueError("beam/frame section requires area, Iy, Iz, J, and orientation")
|
|
2036
|
+
if self.kind in {"plate", "shell"} and self.thickness is None:
|
|
2037
|
+
raise ValueError("plate/shell section requires thickness")
|
|
2038
|
+
if self.kind in {"plate", "shell"} and any(
|
|
2039
|
+
value is not None
|
|
2040
|
+
for value in (
|
|
2041
|
+
self.area,
|
|
2042
|
+
self.principal_second_moment_y,
|
|
2043
|
+
self.principal_second_moment_z,
|
|
2044
|
+
self.torsion_constant_j,
|
|
2045
|
+
self.shear_area_y,
|
|
2046
|
+
self.shear_area_z,
|
|
2047
|
+
)
|
|
2048
|
+
):
|
|
2049
|
+
raise ValueError("plate/shell section accepts thickness and optional orientation only")
|
|
2050
|
+
if self.kind == "solid" and any(
|
|
2051
|
+
value is not None
|
|
2052
|
+
for value in (
|
|
2053
|
+
self.area,
|
|
2054
|
+
self.principal_second_moment_y,
|
|
2055
|
+
self.principal_second_moment_z,
|
|
2056
|
+
self.torsion_constant_j,
|
|
2057
|
+
self.thickness,
|
|
2058
|
+
self.orientation_id,
|
|
2059
|
+
)
|
|
2060
|
+
):
|
|
2061
|
+
raise ValueError("solid section does not accept invented section dimensions")
|
|
2062
|
+
return self
|
|
2063
|
+
|
|
2064
|
+
|
|
2065
|
+
class ElementAssignment(Base):
|
|
2066
|
+
id: str
|
|
2067
|
+
region: RegionSelector
|
|
2068
|
+
section_id: str
|
|
2069
|
+
provenance: Provenance
|
|
2070
|
+
|
|
2071
|
+
@field_validator("id", "section_id")
|
|
2072
|
+
@classmethod
|
|
2073
|
+
def _text(cls, value: str) -> str:
|
|
2074
|
+
return _nonblank(value)
|
|
2075
|
+
|
|
2076
|
+
|
|
2077
|
+
class InertiaTensor(Base):
|
|
2078
|
+
xx: InertiaQuantity
|
|
2079
|
+
yy: InertiaQuantity
|
|
2080
|
+
zz: InertiaQuantity
|
|
2081
|
+
xy: InertiaQuantity
|
|
2082
|
+
xz: InertiaQuantity
|
|
2083
|
+
yz: InertiaQuantity
|
|
2084
|
+
frame: str
|
|
2085
|
+
|
|
2086
|
+
@field_validator("frame")
|
|
2087
|
+
@classmethod
|
|
2088
|
+
def _frame(cls, value: str) -> str:
|
|
2089
|
+
return _nonblank(value)
|
|
2090
|
+
|
|
2091
|
+
@model_validator(mode="after")
|
|
2092
|
+
def _physical_tensor(self) -> "InertiaTensor":
|
|
2093
|
+
values = [self.xx, self.yy, self.zz, self.xy, self.xz, self.yz]
|
|
2094
|
+
if any(value.frame not in {None, self.frame} for value in values):
|
|
2095
|
+
raise ValueError("inertia tensor components must use the tensor frame")
|
|
2096
|
+
xx, yy, zz, xy, xz, yz = (value.base() for value in values)
|
|
2097
|
+
tensor = np.array(((xx, xy, xz), (xy, yy, yz), (xz, yz, zz)), dtype=float)
|
|
2098
|
+
moments, basis = np.linalg.eigh(tensor)
|
|
2099
|
+
if not np.isfinite(moments).all() or moments[0] <= 0:
|
|
2100
|
+
raise ValueError("inertia tensor must have strictly positive principal moments")
|
|
2101
|
+
tolerance = max(float(moments[-1]), 1.0) * 1e-12
|
|
2102
|
+
if moments[-1] > moments[0] + moments[1] + tolerance:
|
|
2103
|
+
raise ValueError("inertia principal moments must satisfy the triangle inequality")
|
|
2104
|
+
if np.linalg.det(basis) < 0:
|
|
2105
|
+
basis[:, 0] *= -1
|
|
2106
|
+
if not np.allclose(basis.T @ basis, np.eye(3), atol=1e-9) or np.linalg.det(basis) <= 0:
|
|
2107
|
+
raise ValueError("inertia tensor must yield a right-handed orthonormal principal basis")
|
|
2108
|
+
return self
|
|
2109
|
+
|
|
2110
|
+
|
|
2111
|
+
class MassAssignment(Base):
|
|
2112
|
+
id: str
|
|
2113
|
+
region: RegionSelector
|
|
2114
|
+
mass: MassQuantity
|
|
2115
|
+
center_of_mass: VectorQuantity | None = None
|
|
2116
|
+
rotational_inertia: InertiaTensor | None = None
|
|
2117
|
+
provenance: Provenance
|
|
2118
|
+
|
|
2119
|
+
@field_validator("id")
|
|
2120
|
+
@classmethod
|
|
2121
|
+
def _id(cls, value: str) -> str:
|
|
2122
|
+
return _nonblank(value)
|
|
2123
|
+
|
|
2124
|
+
@model_validator(mode="after")
|
|
2125
|
+
def _physical_mass(self) -> "MassAssignment":
|
|
2126
|
+
if self.mass.base() <= 0 or not math.isfinite(self.mass.base()):
|
|
2127
|
+
raise ValueError("mass assignment requires positive finite mass")
|
|
2128
|
+
if self.center_of_mass is not None:
|
|
2129
|
+
self.center_of_mass.require_dimension("length", "center of mass")
|
|
2130
|
+
if self.rotational_inertia is not None:
|
|
2131
|
+
if self.center_of_mass is None:
|
|
2132
|
+
raise ValueError("rotational inertia requires an explicit center of mass")
|
|
2133
|
+
if self.center_of_mass.frame != self.rotational_inertia.frame:
|
|
2134
|
+
raise ValueError("center of mass and rotational inertia must use one frame")
|
|
2135
|
+
return self
|
|
2136
|
+
|
|
2137
|
+
|
|
2138
|
+
class ConnectionInput(Base):
|
|
2139
|
+
id: str
|
|
2140
|
+
kind: Literal["rigid", "pinned", "spring", "damper"]
|
|
2141
|
+
endpoint_a: RegionSelector
|
|
2142
|
+
endpoint_b: RegionSelector
|
|
2143
|
+
dofs: DofSelector
|
|
2144
|
+
translational_stiffness: LinearStiffnessQuantity | None = None
|
|
2145
|
+
rotational_stiffness: RotationalStiffnessQuantity | None = None
|
|
2146
|
+
damping_id: str | None = None
|
|
2147
|
+
provenance: Provenance
|
|
2148
|
+
|
|
2149
|
+
@field_validator("id")
|
|
2150
|
+
@classmethod
|
|
2151
|
+
def _id(cls, value: str) -> str:
|
|
2152
|
+
return _nonblank(value)
|
|
2153
|
+
|
|
2154
|
+
@model_validator(mode="after")
|
|
2155
|
+
def _operands(self) -> "ConnectionInput":
|
|
2156
|
+
if self.kind == "spring" and not (
|
|
2157
|
+
self.translational_stiffness or self.rotational_stiffness
|
|
2158
|
+
):
|
|
2159
|
+
raise ValueError("spring connection requires stiffness")
|
|
2160
|
+
if self.kind == "damper" and self.damping_id is None:
|
|
2161
|
+
raise ValueError("damper connection requires an explicit damping reference")
|
|
2162
|
+
if self.kind in {"rigid", "pinned"} and any(
|
|
2163
|
+
value is not None
|
|
2164
|
+
for value in (
|
|
2165
|
+
self.translational_stiffness,
|
|
2166
|
+
self.rotational_stiffness,
|
|
2167
|
+
self.damping_id,
|
|
2168
|
+
)
|
|
2169
|
+
):
|
|
2170
|
+
raise ValueError("rigid/pinned connections do not accept spring or damping operands")
|
|
2171
|
+
return self
|
|
2172
|
+
|
|
2173
|
+
|
|
2174
|
+
class SupportInput(Base):
|
|
2175
|
+
id: str
|
|
2176
|
+
region: RegionSelector
|
|
2177
|
+
dofs: DofSelector
|
|
2178
|
+
provenance: Provenance
|
|
2179
|
+
|
|
2180
|
+
@field_validator("id")
|
|
2181
|
+
@classmethod
|
|
2182
|
+
def _id(cls, value: str) -> str:
|
|
2183
|
+
return _nonblank(value)
|
|
2184
|
+
|
|
2185
|
+
|
|
2186
|
+
class AnalyticAmplitude(Base):
|
|
2187
|
+
kind: Literal["constant", "sine", "ramp"]
|
|
2188
|
+
scale: float = 1.0
|
|
2189
|
+
frequency: FrequencyQuantity | None = None
|
|
2190
|
+
phase: AngleQuantity | None = None
|
|
2191
|
+
duration: PositiveTimeQuantity | None = None
|
|
2192
|
+
|
|
2193
|
+
@model_validator(mode="after")
|
|
2194
|
+
def _shape(self) -> "AnalyticAmplitude":
|
|
2195
|
+
if not math.isfinite(self.scale):
|
|
2196
|
+
raise ValueError("amplitude scale must be finite")
|
|
2197
|
+
if self.kind == "sine" and self.frequency is None:
|
|
2198
|
+
raise ValueError("sine amplitude requires frequency")
|
|
2199
|
+
if self.kind == "ramp" and self.duration is None:
|
|
2200
|
+
raise ValueError("ramp amplitude requires duration")
|
|
2201
|
+
return self
|
|
2202
|
+
|
|
2203
|
+
|
|
2204
|
+
class SeriesAmplitude(Base):
|
|
2205
|
+
kind: Literal["input_series"] = "input_series"
|
|
2206
|
+
artifact: InputArtifact
|
|
2207
|
+
time_column: str
|
|
2208
|
+
value_column: str
|
|
2209
|
+
time_unit: str
|
|
2210
|
+
value_unit: str
|
|
2211
|
+
interpolation: Literal["linear"] = "linear"
|
|
2212
|
+
extrapolation: Literal["refuse"] = "refuse"
|
|
2213
|
+
|
|
2214
|
+
@field_validator("time_column", "value_column")
|
|
2215
|
+
@classmethod
|
|
2216
|
+
def _columns(cls, value: str) -> str:
|
|
2217
|
+
return _nonblank(value)
|
|
2218
|
+
|
|
2219
|
+
@model_validator(mode="after")
|
|
2220
|
+
def _series_roles(self) -> "SeriesAmplitude":
|
|
2221
|
+
if not units.known(self.time_unit) or units.dimension(self.time_unit) != "time":
|
|
2222
|
+
raise ValueError("input-series time unit must have time dimension")
|
|
2223
|
+
if not units.known(self.value_unit) or units.dimension(self.value_unit) != "dimensionless":
|
|
2224
|
+
raise ValueError("input-series amplitude values must be dimensionless")
|
|
2225
|
+
if self.artifact.format not in {"csv", "json"}:
|
|
2226
|
+
raise ValueError("input-series artifact must be csv or json")
|
|
2227
|
+
return self
|
|
2228
|
+
|
|
2229
|
+
|
|
2230
|
+
class LoadInput(Base):
|
|
2231
|
+
id: str
|
|
2232
|
+
kind: Literal["force", "moment", "gravity", "pressure", "prescribed_displacement"]
|
|
2233
|
+
region: RegionSelector
|
|
2234
|
+
vector: VectorQuantity | None = None
|
|
2235
|
+
scalar: Quantity | None = None
|
|
2236
|
+
application_frame: str
|
|
2237
|
+
amplitude: AnalyticAmplitude | SeriesAmplitude | None = None
|
|
2238
|
+
provenance: Provenance
|
|
2239
|
+
|
|
2240
|
+
@field_validator("id", "application_frame")
|
|
2241
|
+
@classmethod
|
|
2242
|
+
def _text(cls, value: str) -> str:
|
|
2243
|
+
return _nonblank(value)
|
|
2244
|
+
|
|
2245
|
+
@model_validator(mode="after")
|
|
2246
|
+
def _load_role(self) -> "LoadInput":
|
|
2247
|
+
vector_dimensions = {
|
|
2248
|
+
"force": "force",
|
|
2249
|
+
"moment": "torque",
|
|
2250
|
+
"gravity": "acceleration",
|
|
2251
|
+
"prescribed_displacement": "length",
|
|
2252
|
+
}
|
|
2253
|
+
scalar_dimensions = {"pressure": "pressure"}
|
|
2254
|
+
if self.kind in vector_dimensions:
|
|
2255
|
+
if self.vector is None or self.scalar is not None:
|
|
2256
|
+
raise ValueError(f"{self.kind} load requires only a vector")
|
|
2257
|
+
self.vector.require_dimension(vector_dimensions[self.kind], f"{self.kind} load")
|
|
2258
|
+
if self.vector.frame != self.application_frame:
|
|
2259
|
+
raise ValueError("load vector must use the application frame")
|
|
2260
|
+
else:
|
|
2261
|
+
if self.scalar is None or self.vector is not None:
|
|
2262
|
+
raise ValueError(f"{self.kind} load requires only a scalar")
|
|
2263
|
+
if self.scalar.dimension() != scalar_dimensions[self.kind]:
|
|
2264
|
+
raise ValueError(f"{self.kind} load has the wrong unit")
|
|
2265
|
+
if self.scalar.frame not in {None, self.application_frame}:
|
|
2266
|
+
raise ValueError("load scalar must use the application frame")
|
|
2267
|
+
return self
|
|
2268
|
+
|
|
2269
|
+
|
|
2270
|
+
class DampingInput(Base):
|
|
2271
|
+
id: str
|
|
2272
|
+
kind: Literal["rayleigh", "modal_ratio", "viscous_translational", "viscous_rotational"]
|
|
2273
|
+
rayleigh_alpha: InverseTimeQuantity | None = None
|
|
2274
|
+
rayleigh_beta: TimeQuantity | None = None
|
|
2275
|
+
modal_ratio: DimensionlessQuantity | None = None
|
|
2276
|
+
translational: LinearDampingQuantity | None = None
|
|
2277
|
+
rotational: RotationalDampingQuantity | None = None
|
|
2278
|
+
provenance: Provenance
|
|
2279
|
+
|
|
2280
|
+
@field_validator("id")
|
|
2281
|
+
@classmethod
|
|
2282
|
+
def _id(cls, value: str) -> str:
|
|
2283
|
+
return _nonblank(value)
|
|
2284
|
+
|
|
2285
|
+
@model_validator(mode="after")
|
|
2286
|
+
def _one_law(self) -> "DampingInput":
|
|
2287
|
+
populated = {
|
|
2288
|
+
"rayleigh_alpha": self.rayleigh_alpha,
|
|
2289
|
+
"rayleigh_beta": self.rayleigh_beta,
|
|
2290
|
+
"modal_ratio": self.modal_ratio,
|
|
2291
|
+
"translational": self.translational,
|
|
2292
|
+
"rotational": self.rotational,
|
|
2293
|
+
}
|
|
2294
|
+
required = {
|
|
2295
|
+
"rayleigh": {"rayleigh_alpha", "rayleigh_beta"},
|
|
2296
|
+
"modal_ratio": {"modal_ratio"},
|
|
2297
|
+
"viscous_translational": {"translational"},
|
|
2298
|
+
"viscous_rotational": {"rotational"},
|
|
2299
|
+
}[self.kind]
|
|
2300
|
+
actual = {name for name, value in populated.items() if value is not None}
|
|
2301
|
+
if actual != required:
|
|
2302
|
+
raise ValueError(f"{self.kind} damping requires exactly {', '.join(sorted(required))}")
|
|
2303
|
+
if self.rayleigh_beta is not None and (
|
|
2304
|
+
self.rayleigh_beta.base() < 0 or not math.isfinite(self.rayleigh_beta.base())
|
|
2305
|
+
):
|
|
2306
|
+
raise ValueError("Rayleigh beta must be nonnegative and finite")
|
|
2307
|
+
if self.modal_ratio is not None and not 0 <= self.modal_ratio.base() < 1:
|
|
2308
|
+
raise ValueError("modal damping ratio must be in [0, 1)")
|
|
2309
|
+
return self
|
|
2310
|
+
|
|
2311
|
+
|
|
2312
|
+
class ContactTabularPoint(Base):
|
|
2313
|
+
pressure: PressureQuantity
|
|
2314
|
+
overclosure: LengthQuantity
|
|
2315
|
+
|
|
2316
|
+
|
|
2317
|
+
class ContactNormalLaw(Base):
|
|
2318
|
+
kind: Literal["hard", "linear", "exponential", "tabular"]
|
|
2319
|
+
pressure_overclosure_slope: ContactStiffnessQuantity | None = None
|
|
2320
|
+
tensile_stress: PressureQuantity | None = None
|
|
2321
|
+
clearance_coefficient: DimensionlessQuantity | None = None
|
|
2322
|
+
clearance_distance: PositiveLengthQuantity | None = None
|
|
2323
|
+
zero_clearance_pressure: PositivePressureQuantity | None = None
|
|
2324
|
+
points: list[ContactTabularPoint] = []
|
|
2325
|
+
|
|
2326
|
+
@model_validator(mode="after")
|
|
2327
|
+
def _law_shape(self) -> "ContactNormalLaw":
|
|
2328
|
+
scalar_values = (
|
|
2329
|
+
self.pressure_overclosure_slope,
|
|
2330
|
+
self.tensile_stress,
|
|
2331
|
+
self.clearance_coefficient,
|
|
2332
|
+
self.clearance_distance,
|
|
2333
|
+
self.zero_clearance_pressure,
|
|
2334
|
+
)
|
|
2335
|
+
if self.kind == "hard" and (any(value is not None for value in scalar_values) or self.points):
|
|
2336
|
+
raise ValueError("hard contact has no numeric contract default")
|
|
2337
|
+
if self.kind == "linear" and self.pressure_overclosure_slope is None:
|
|
2338
|
+
raise ValueError("linear contact requires pressure-overclosure slope")
|
|
2339
|
+
if self.clearance_coefficient is not None and self.clearance_coefficient.base() < 0:
|
|
2340
|
+
raise ValueError("contact clearance coefficient must be nonnegative")
|
|
2341
|
+
if self.kind == "linear" and any(
|
|
2342
|
+
value is not None for value in (self.clearance_distance, self.zero_clearance_pressure)
|
|
2343
|
+
):
|
|
2344
|
+
raise ValueError("linear contact does not accept exponential operands")
|
|
2345
|
+
if self.kind == "exponential" and (
|
|
2346
|
+
self.clearance_distance is None or self.zero_clearance_pressure is None
|
|
2347
|
+
):
|
|
2348
|
+
raise ValueError("exponential contact requires clearance and zero-clearance pressure")
|
|
2349
|
+
if self.kind == "exponential" and any(
|
|
2350
|
+
value is not None
|
|
2351
|
+
for value in (
|
|
2352
|
+
self.pressure_overclosure_slope,
|
|
2353
|
+
self.tensile_stress,
|
|
2354
|
+
self.clearance_coefficient,
|
|
2355
|
+
)
|
|
2356
|
+
):
|
|
2357
|
+
raise ValueError("exponential contact accepts only clearance and pressure operands")
|
|
2358
|
+
if self.kind == "tabular":
|
|
2359
|
+
if not self.points:
|
|
2360
|
+
raise ValueError("tabular contact requires points")
|
|
2361
|
+
if any(value is not None for value in scalar_values):
|
|
2362
|
+
raise ValueError("tabular contact accepts only pressure-overclosure points")
|
|
2363
|
+
closures = [point.overclosure.base() for point in self.points]
|
|
2364
|
+
pressures = [point.pressure.base() for point in self.points]
|
|
2365
|
+
if any(right <= left for left, right in zip(closures, closures[1:], strict=False)):
|
|
2366
|
+
raise ValueError("tabular contact overclosure must increase strictly")
|
|
2367
|
+
if any(right < left for left, right in zip(pressures, pressures[1:], strict=False)):
|
|
2368
|
+
raise ValueError("tabular contact pressure must be nondecreasing")
|
|
2369
|
+
elif self.points:
|
|
2370
|
+
raise ValueError("contact points are only valid for a tabular law")
|
|
2371
|
+
return self
|
|
2372
|
+
|
|
2373
|
+
|
|
2374
|
+
class ContactTangentialLaw(Base):
|
|
2375
|
+
friction_coefficient: DimensionlessQuantity
|
|
2376
|
+
stick_slope: ContactStiffnessQuantity | None = None
|
|
2377
|
+
|
|
2378
|
+
@model_validator(mode="after")
|
|
2379
|
+
def _friction(self) -> "ContactTangentialLaw":
|
|
2380
|
+
if self.friction_coefficient.base() < 0:
|
|
2381
|
+
raise ValueError("friction coefficient must be nonnegative")
|
|
2382
|
+
return self
|
|
2383
|
+
|
|
2384
|
+
|
|
2385
|
+
class ContactDampingLaw(Base):
|
|
2386
|
+
coefficient: ContactDampingQuantity
|
|
2387
|
+
tangential_fraction: DimensionlessQuantity
|
|
2388
|
+
|
|
2389
|
+
@model_validator(mode="after")
|
|
2390
|
+
def _fraction(self) -> "ContactDampingLaw":
|
|
2391
|
+
if not 0 <= self.tangential_fraction.base() <= 1:
|
|
2392
|
+
raise ValueError("contact tangential damping fraction must be in [0, 1]")
|
|
2393
|
+
return self
|
|
2394
|
+
|
|
2395
|
+
|
|
2396
|
+
class ContactInput(Base):
|
|
2397
|
+
id: str
|
|
2398
|
+
master: RegionSelector
|
|
2399
|
+
slave: RegionSelector
|
|
2400
|
+
frame: str
|
|
2401
|
+
normal: ContactNormalLaw
|
|
2402
|
+
tangential: ContactTangentialLaw | None = None
|
|
2403
|
+
damping: ContactDampingLaw | None = None
|
|
2404
|
+
provenance: Provenance
|
|
2405
|
+
|
|
2406
|
+
@field_validator("id", "frame")
|
|
2407
|
+
@classmethod
|
|
2408
|
+
def _text(cls, value: str) -> str:
|
|
2409
|
+
return _nonblank(value)
|
|
2410
|
+
|
|
2411
|
+
@model_validator(mode="after")
|
|
2412
|
+
def _surfaces(self) -> "ContactInput":
|
|
2413
|
+
if self.master.kind != "surface_set" or self.slave.kind != "surface_set":
|
|
2414
|
+
raise ValueError("contact master and slave must be surface_set selectors")
|
|
2415
|
+
if self.master == self.slave:
|
|
2416
|
+
raise ValueError("contact master and slave must be distinct")
|
|
2417
|
+
return self
|
|
2418
|
+
|
|
2419
|
+
|
|
2420
|
+
class StructuralProcedure(Base):
|
|
2421
|
+
id: str
|
|
2422
|
+
kind: Literal[
|
|
2423
|
+
"linear_static",
|
|
2424
|
+
"modal",
|
|
2425
|
+
"harmonic",
|
|
2426
|
+
"transient",
|
|
2427
|
+
"nonlinear_static",
|
|
2428
|
+
"impact",
|
|
2429
|
+
]
|
|
2430
|
+
assignment_ids: list[str]
|
|
2431
|
+
support_ids: list[str] = []
|
|
2432
|
+
load_ids: list[str] = []
|
|
2433
|
+
mass_ids: list[str] = []
|
|
2434
|
+
contact_ids: list[str] = []
|
|
2435
|
+
damping_mode: Literal["declared", "undamped"] | None = None
|
|
2436
|
+
damping_ids: list[str] = []
|
|
2437
|
+
mode_count: Annotated[int, Field(strict=True, gt=0)] | None = None
|
|
2438
|
+
frequency: FrequencyQuantity | None = None
|
|
2439
|
+
duration: PositiveTimeQuantity | None = None
|
|
2440
|
+
time_step: PositiveTimeQuantity | None = None
|
|
2441
|
+
initial_velocity: VectorQuantity | None = None
|
|
2442
|
+
geometric_nonlinearity: bool | None = None
|
|
2443
|
+
plasticity: bool | None = None
|
|
2444
|
+
max_increments: Annotated[int, Field(strict=True, gt=0)] | None = None
|
|
2445
|
+
provenance: Provenance
|
|
2446
|
+
|
|
2447
|
+
@field_validator("id")
|
|
2448
|
+
@classmethod
|
|
2449
|
+
def _id(cls, value: str) -> str:
|
|
2450
|
+
return _nonblank(value)
|
|
2451
|
+
|
|
2452
|
+
@model_validator(mode="after")
|
|
2453
|
+
def _procedure_shape(self) -> "StructuralProcedure":
|
|
2454
|
+
if not self.assignment_ids:
|
|
2455
|
+
raise ValueError("structural procedure requires assignments")
|
|
2456
|
+
dynamic = self.kind in {"modal", "harmonic", "transient", "impact"}
|
|
2457
|
+
if dynamic and self.damping_mode is None:
|
|
2458
|
+
raise ValueError("dynamic procedure requires explicit damping_mode")
|
|
2459
|
+
if self.damping_mode == "declared" and not self.damping_ids:
|
|
2460
|
+
raise ValueError("declared damping mode requires damping references")
|
|
2461
|
+
if self.damping_mode == "undamped" and self.damping_ids:
|
|
2462
|
+
raise ValueError("undamped procedure must not reference damping")
|
|
2463
|
+
if not dynamic and (self.damping_mode is not None or self.damping_ids):
|
|
2464
|
+
raise ValueError("static procedures do not consume damping")
|
|
2465
|
+
if self.kind == "modal" and self.mode_count is None:
|
|
2466
|
+
raise ValueError("modal procedure requires mode_count")
|
|
2467
|
+
if self.kind == "harmonic" and (self.frequency is None or not self.load_ids):
|
|
2468
|
+
raise ValueError("harmonic procedure requires frequency and excitation loads")
|
|
2469
|
+
if self.frequency is not None and (
|
|
2470
|
+
self.frequency.base() <= 0 or not math.isfinite(self.frequency.base())
|
|
2471
|
+
):
|
|
2472
|
+
raise ValueError("procedure frequency must be positive and finite")
|
|
2473
|
+
if self.kind == "transient" and (
|
|
2474
|
+
self.duration is None
|
|
2475
|
+
or self.time_step is None
|
|
2476
|
+
or (not self.load_ids and self.initial_velocity is None)
|
|
2477
|
+
):
|
|
2478
|
+
raise ValueError("transient procedure requires duration, step, and excitation")
|
|
2479
|
+
if self.kind == "nonlinear_static" and (
|
|
2480
|
+
self.max_increments is None
|
|
2481
|
+
or self.geometric_nonlinearity is None
|
|
2482
|
+
or self.plasticity is None
|
|
2483
|
+
or not self.load_ids
|
|
2484
|
+
):
|
|
2485
|
+
raise ValueError("nonlinear static requires flags, increments, and loads")
|
|
2486
|
+
if self.kind == "impact":
|
|
2487
|
+
if (
|
|
2488
|
+
self.duration is None
|
|
2489
|
+
or self.time_step is None
|
|
2490
|
+
or self.initial_velocity is None
|
|
2491
|
+
or not self.contact_ids
|
|
2492
|
+
):
|
|
2493
|
+
raise ValueError("impact requires duration, step, initial velocity, and contact")
|
|
2494
|
+
self.initial_velocity.require_dimension("velocity", "impact initial velocity")
|
|
2495
|
+
if self.duration is not None and self.time_step is not None:
|
|
2496
|
+
if self.time_step.base() > self.duration.base():
|
|
2497
|
+
raise ValueError("procedure time step must not exceed duration")
|
|
2498
|
+
return self
|
|
2499
|
+
|
|
2500
|
+
|
|
2501
|
+
class ScalarAgreementLimit(Base):
|
|
2502
|
+
kind: Literal["scalar_reference"] = "scalar_reference"
|
|
2503
|
+
id: str
|
|
2504
|
+
metric: str
|
|
2505
|
+
comparator: Literal["absolute", "relative"]
|
|
2506
|
+
threshold: Quantity
|
|
2507
|
+
reference: Literal["analytic"] | ArtifactRef
|
|
2508
|
+
provenance: Provenance
|
|
2509
|
+
|
|
2510
|
+
|
|
2511
|
+
class RmsPeakAgreementLimit(Base):
|
|
2512
|
+
kind: Literal["rms_peak"] = "rms_peak"
|
|
2513
|
+
id: str
|
|
2514
|
+
metric: str
|
|
2515
|
+
rms_threshold: Quantity
|
|
2516
|
+
peak_threshold: Quantity
|
|
2517
|
+
window: PositiveTimeQuantity
|
|
2518
|
+
provenance: Provenance
|
|
2519
|
+
|
|
2520
|
+
@model_validator(mode="after")
|
|
2521
|
+
def _matching_roles(self) -> "RmsPeakAgreementLimit":
|
|
2522
|
+
if self.rms_threshold.dimension() != self.peak_threshold.dimension():
|
|
2523
|
+
raise ValueError("RMS and peak thresholds must have matching dimensions")
|
|
2524
|
+
return self
|
|
2525
|
+
|
|
2526
|
+
|
|
2527
|
+
class EnergyBalanceLimit(Base):
|
|
2528
|
+
kind: Literal["energy_balance"] = "energy_balance"
|
|
2529
|
+
id: str
|
|
2530
|
+
threshold: EnergyQuantity
|
|
2531
|
+
provenance: Provenance
|
|
2532
|
+
|
|
2533
|
+
|
|
2534
|
+
class RefinementLimit(Base):
|
|
2535
|
+
kind: Literal["refinement"] = "refinement"
|
|
2536
|
+
id: str
|
|
2537
|
+
coarse_procedure_id: str
|
|
2538
|
+
fine_procedure_id: str
|
|
2539
|
+
requirement: Literal["fine_nonworse"] = "fine_nonworse"
|
|
2540
|
+
provenance: Provenance
|
|
2541
|
+
|
|
2542
|
+
|
|
2543
|
+
class ContactBehaviorLimit(Base):
|
|
2544
|
+
kind: Literal["contact_behavior"] = "contact_behavior"
|
|
2545
|
+
id: str
|
|
2546
|
+
metric: Literal["separation", "slip", "penetration", "rebound_velocity"]
|
|
2547
|
+
threshold: Quantity
|
|
2548
|
+
provenance: Provenance
|
|
2549
|
+
|
|
2550
|
+
@model_validator(mode="after")
|
|
2551
|
+
def _metric_unit(self) -> "ContactBehaviorLimit":
|
|
2552
|
+
expected = "velocity" if self.metric == "rebound_velocity" else "length"
|
|
2553
|
+
if self.threshold.dimension() != expected:
|
|
2554
|
+
raise ValueError(f"{self.metric} threshold requires {expected}")
|
|
2555
|
+
return self
|
|
2556
|
+
|
|
2557
|
+
|
|
2558
|
+
StructuralAcceptanceLimit = Annotated[
|
|
2559
|
+
ScalarAgreementLimit
|
|
2560
|
+
| RmsPeakAgreementLimit
|
|
2561
|
+
| EnergyBalanceLimit
|
|
2562
|
+
| RefinementLimit
|
|
2563
|
+
| ContactBehaviorLimit,
|
|
2564
|
+
Field(discriminator="kind"),
|
|
2565
|
+
]
|
|
2566
|
+
|
|
2567
|
+
|
|
2568
|
+
class StructuralInputs(Base):
|
|
2569
|
+
version: Literal["1"] = "1"
|
|
2570
|
+
model: StructuralModelBinding
|
|
2571
|
+
frames: list[FrameInput] = []
|
|
2572
|
+
orientations: list[SectionOrientation] = []
|
|
2573
|
+
materials: list[MaterialInput]
|
|
2574
|
+
sections: list[SectionInput]
|
|
2575
|
+
assignments: list[ElementAssignment]
|
|
2576
|
+
masses: list[MassAssignment] = []
|
|
2577
|
+
connections: list[ConnectionInput] = []
|
|
2578
|
+
supports: list[SupportInput] = []
|
|
2579
|
+
loads: list[LoadInput] = []
|
|
2580
|
+
damping: list[DampingInput] = []
|
|
2581
|
+
contacts: list[ContactInput] = []
|
|
2582
|
+
procedures: list[StructuralProcedure]
|
|
2583
|
+
acceptance_limits: list[StructuralAcceptanceLimit] = []
|
|
2584
|
+
runtime: RuntimeSelection
|
|
2585
|
+
provenance: Provenance
|
|
2586
|
+
|
|
2587
|
+
@model_validator(mode="after")
|
|
2588
|
+
def _resolved_graph(self) -> "StructuralInputs":
|
|
2589
|
+
collections = {
|
|
2590
|
+
"frames": self.frames,
|
|
2591
|
+
"orientations": self.orientations,
|
|
2592
|
+
"materials": self.materials,
|
|
2593
|
+
"sections": self.sections,
|
|
2594
|
+
"assignments": self.assignments,
|
|
2595
|
+
"masses": self.masses,
|
|
2596
|
+
"connections": self.connections,
|
|
2597
|
+
"supports": self.supports,
|
|
2598
|
+
"loads": self.loads,
|
|
2599
|
+
"damping": self.damping,
|
|
2600
|
+
"contacts": self.contacts,
|
|
2601
|
+
"procedures": self.procedures,
|
|
2602
|
+
"acceptance_limits": self.acceptance_limits,
|
|
2603
|
+
}
|
|
2604
|
+
for label, rows in collections.items():
|
|
2605
|
+
_unique_row_ids(label, rows)
|
|
2606
|
+
|
|
2607
|
+
frame_ids = {self.model.source_frame, "world"}
|
|
2608
|
+
for frame in self.frames:
|
|
2609
|
+
if frame.id in frame_ids:
|
|
2610
|
+
raise ValueError(f"duplicate or reserved frame id {frame.id!r}")
|
|
2611
|
+
if frame.parent not in frame_ids and not frame.parent.startswith("model_link:"):
|
|
2612
|
+
raise ValueError(f"frame {frame.id!r} has unknown or forward parent {frame.parent!r}")
|
|
2613
|
+
frame_ids.add(frame.id)
|
|
2614
|
+
for orientation in self.orientations:
|
|
2615
|
+
if orientation.frame not in frame_ids:
|
|
2616
|
+
raise ValueError(f"orientation {orientation.id!r} has unknown frame")
|
|
2617
|
+
|
|
2618
|
+
materials = {row.id: row for row in self.materials}
|
|
2619
|
+
orientations = {row.id for row in self.orientations}
|
|
2620
|
+
sections = {row.id: row for row in self.sections}
|
|
2621
|
+
assignments = {row.id: row for row in self.assignments}
|
|
2622
|
+
masses = {row.id: row for row in self.masses}
|
|
2623
|
+
dampers = {row.id for row in self.damping}
|
|
2624
|
+
supports = {row.id for row in self.supports}
|
|
2625
|
+
loads = {row.id for row in self.loads}
|
|
2626
|
+
contacts = {row.id for row in self.contacts}
|
|
2627
|
+
for section in self.sections:
|
|
2628
|
+
if section.material_id not in materials:
|
|
2629
|
+
raise ValueError(f"section {section.id!r} references unknown material")
|
|
2630
|
+
if section.orientation_id is not None and section.orientation_id not in orientations:
|
|
2631
|
+
raise ValueError(f"section {section.id!r} references unknown orientation")
|
|
2632
|
+
for assignment in self.assignments:
|
|
2633
|
+
if assignment.section_id not in sections:
|
|
2634
|
+
raise ValueError(f"assignment {assignment.id!r} references unknown section")
|
|
2635
|
+
if (
|
|
2636
|
+
assignment.section_id in sections
|
|
2637
|
+
and sections[assignment.section_id].kind == "solid"
|
|
2638
|
+
and assignment.region.kind != "mesh_region"
|
|
2639
|
+
):
|
|
2640
|
+
raise ValueError("solid section assignments require a mesh_region selector")
|
|
2641
|
+
for connection in self.connections:
|
|
2642
|
+
if connection.damping_id is not None and connection.damping_id not in dampers:
|
|
2643
|
+
raise ValueError(f"connection {connection.id!r} references unknown damping")
|
|
2644
|
+
|
|
2645
|
+
for procedure in self.procedures:
|
|
2646
|
+
references = (
|
|
2647
|
+
("assignment", procedure.assignment_ids, assignments),
|
|
2648
|
+
("mass", procedure.mass_ids, masses),
|
|
2649
|
+
("support", procedure.support_ids, supports),
|
|
2650
|
+
("load", procedure.load_ids, loads),
|
|
2651
|
+
("damping", procedure.damping_ids, dampers),
|
|
2652
|
+
("contact", procedure.contact_ids, contacts),
|
|
2653
|
+
)
|
|
2654
|
+
for label, ids, inventory in references:
|
|
2655
|
+
missing = sorted(set(ids) - set(inventory))
|
|
2656
|
+
if missing:
|
|
2657
|
+
raise ValueError(
|
|
2658
|
+
f"procedure {procedure.id!r} references unknown {label}(s): "
|
|
2659
|
+
+ ", ".join(missing)
|
|
2660
|
+
)
|
|
2661
|
+
if len(procedure.assignment_ids) != len(set(procedure.assignment_ids)):
|
|
2662
|
+
raise ValueError(f"procedure {procedure.id!r} repeats an assignment")
|
|
2663
|
+
|
|
2664
|
+
if procedure.kind in {"modal", "harmonic", "transient", "impact"}:
|
|
2665
|
+
selected_masses = [masses[row_id] for row_id in procedure.mass_ids]
|
|
2666
|
+
for assignment_id in procedure.assignment_ids:
|
|
2667
|
+
assignment = assignments[assignment_id]
|
|
2668
|
+
section = sections[assignment.section_id]
|
|
2669
|
+
has_density = materials[section.material_id].density is not None
|
|
2670
|
+
explicit_here = [row for row in selected_masses if row.region == assignment.region]
|
|
2671
|
+
if has_density and explicit_here:
|
|
2672
|
+
raise ValueError(
|
|
2673
|
+
f"procedure {procedure.id!r} double-counts density and explicit mass"
|
|
2674
|
+
)
|
|
2675
|
+
if not has_density and not explicit_here:
|
|
2676
|
+
raise ValueError(
|
|
2677
|
+
f"procedure {procedure.id!r} lacks density or explicit mass for "
|
|
2678
|
+
f"assignment {assignment_id!r}"
|
|
2679
|
+
)
|
|
2680
|
+
if procedure.kind == "nonlinear_static" and procedure.plasticity:
|
|
2681
|
+
for assignment_id in procedure.assignment_ids:
|
|
2682
|
+
assignment = assignments[assignment_id]
|
|
2683
|
+
section = sections[assignment.section_id]
|
|
2684
|
+
if materials[section.material_id].kind != "isotropic_elastoplastic":
|
|
2685
|
+
raise ValueError(
|
|
2686
|
+
f"procedure {procedure.id!r} enables plasticity for a linear material"
|
|
2687
|
+
)
|
|
2688
|
+
for support_id in procedure.support_ids:
|
|
2689
|
+
support = next(row for row in self.supports if row.id == support_id)
|
|
2690
|
+
matching_kinds = {
|
|
2691
|
+
sections[assignments[assignment_id].section_id].kind
|
|
2692
|
+
for assignment_id in procedure.assignment_ids
|
|
2693
|
+
if assignments[assignment_id].region == support.region
|
|
2694
|
+
}
|
|
2695
|
+
if support.dofs.rotation and matching_kinds.intersection({"truss", "solid"}):
|
|
2696
|
+
raise ValueError(
|
|
2697
|
+
f"support {support.id!r} selects rotational DOFs unavailable to its formulation"
|
|
2698
|
+
)
|
|
2699
|
+
return self
|
|
2700
|
+
|
|
2701
|
+
|
|
2702
|
+
class SignalInterface(Base):
|
|
2703
|
+
id: str
|
|
2704
|
+
role: Literal["command", "state_feedback", "sensor", "actuator"]
|
|
2705
|
+
unit: str
|
|
2706
|
+
frame: str
|
|
2707
|
+
independently_measured: bool = False
|
|
2708
|
+
|
|
2709
|
+
@field_validator("id", "frame")
|
|
2710
|
+
@classmethod
|
|
2711
|
+
def _text(cls, value: str) -> str:
|
|
2712
|
+
return _nonblank(value)
|
|
2713
|
+
|
|
2714
|
+
@field_validator("unit")
|
|
2715
|
+
@classmethod
|
|
2716
|
+
def _unit(cls, value: str) -> str:
|
|
2717
|
+
if not units.known(value):
|
|
2718
|
+
raise ValueError(f"unknown unit {value!r}")
|
|
2719
|
+
return value
|
|
2720
|
+
|
|
2721
|
+
@model_validator(mode="after")
|
|
2722
|
+
def _feedback_source(self) -> "SignalInterface":
|
|
2723
|
+
if self.role == "state_feedback" and not self.independently_measured:
|
|
2724
|
+
raise ValueError("state feedback must be independently measured, not command echo")
|
|
2725
|
+
if self.role != "state_feedback" and self.independently_measured:
|
|
2726
|
+
raise ValueError("independently_measured is reserved for state feedback")
|
|
2727
|
+
return self
|
|
2728
|
+
|
|
2729
|
+
|
|
2730
|
+
class WorldInput(Base):
|
|
2731
|
+
id: str
|
|
2732
|
+
artifact: InputArtifact
|
|
2733
|
+
root_frame: str
|
|
2734
|
+
gravity: VectorQuantity
|
|
2735
|
+
provenance: Provenance
|
|
2736
|
+
|
|
2737
|
+
@field_validator("id", "root_frame")
|
|
2738
|
+
@classmethod
|
|
2739
|
+
def _text(cls, value: str) -> str:
|
|
2740
|
+
return _nonblank(value)
|
|
2741
|
+
|
|
2742
|
+
@model_validator(mode="after")
|
|
2743
|
+
def _gravity_role(self) -> "WorldInput":
|
|
2744
|
+
self.gravity.require_dimension("acceleration", "world gravity")
|
|
2745
|
+
if self.gravity.frame != self.root_frame:
|
|
2746
|
+
raise ValueError("gravity must be expressed in the world root frame")
|
|
2747
|
+
return self
|
|
2748
|
+
|
|
2749
|
+
|
|
2750
|
+
class PlantDampingInput(Base):
|
|
2751
|
+
id: str
|
|
2752
|
+
joint: str | None = None
|
|
2753
|
+
translational: LinearDampingQuantity | None = None
|
|
2754
|
+
rotational: RotationalDampingQuantity | None = None
|
|
2755
|
+
provenance: Provenance
|
|
2756
|
+
|
|
2757
|
+
@field_validator("id")
|
|
2758
|
+
@classmethod
|
|
2759
|
+
def _id(cls, value: str) -> str:
|
|
2760
|
+
return _nonblank(value)
|
|
2761
|
+
|
|
2762
|
+
@model_validator(mode="after")
|
|
2763
|
+
def _coefficient(self) -> "PlantDampingInput":
|
|
2764
|
+
if (self.translational is None) == (self.rotational is None):
|
|
2765
|
+
raise ValueError("plant damping requires exactly one translational/rotational value")
|
|
2766
|
+
return self
|
|
2767
|
+
|
|
2768
|
+
|
|
2769
|
+
class PlantInput(Base):
|
|
2770
|
+
id: str
|
|
2771
|
+
kind: Literal["px4_x500", "anymal"]
|
|
2772
|
+
artifact: InputArtifact
|
|
2773
|
+
source_sha256: str
|
|
2774
|
+
structure_hash: str
|
|
2775
|
+
base_frame: str
|
|
2776
|
+
has_collisions: Literal[True]
|
|
2777
|
+
has_verified_inertials: Literal[True]
|
|
2778
|
+
interfaces: list[SignalInterface]
|
|
2779
|
+
damping_mode: Literal["declared", "undamped"]
|
|
2780
|
+
damping_ids: list[str] = []
|
|
2781
|
+
provenance: Provenance
|
|
2782
|
+
|
|
2783
|
+
@field_validator("id", "base_frame")
|
|
2784
|
+
@classmethod
|
|
2785
|
+
def _text(cls, value: str) -> str:
|
|
2786
|
+
return _nonblank(value)
|
|
2787
|
+
|
|
2788
|
+
@field_validator("source_sha256", "structure_hash")
|
|
2789
|
+
@classmethod
|
|
2790
|
+
def _hashes(cls, value: str) -> str:
|
|
2791
|
+
if len(value) != 64 or any(char not in "0123456789abcdef" for char in value):
|
|
2792
|
+
raise ValueError("plant hashes must be 64 lowercase hexadecimal characters")
|
|
2793
|
+
return value
|
|
2794
|
+
|
|
2795
|
+
@model_validator(mode="after")
|
|
2796
|
+
def _capabilities(self) -> "PlantInput":
|
|
2797
|
+
if self.artifact.ref.sha256 != self.source_sha256:
|
|
2798
|
+
raise ValueError("plant artifact must match the bound plant source hash")
|
|
2799
|
+
_unique_row_ids("plant interfaces", self.interfaces)
|
|
2800
|
+
roles = {interface.role for interface in self.interfaces}
|
|
2801
|
+
if not {"command", "state_feedback", "sensor", "actuator"}.issubset(roles):
|
|
2802
|
+
raise ValueError(
|
|
2803
|
+
"plant requires command, independent state-feedback, sensor, and actuator interfaces"
|
|
2804
|
+
)
|
|
2805
|
+
if self.damping_mode == "declared" and not self.damping_ids:
|
|
2806
|
+
raise ValueError("declared plant damping requires references")
|
|
2807
|
+
if self.damping_mode == "undamped" and self.damping_ids:
|
|
2808
|
+
raise ValueError("undamped plant must not reference damping")
|
|
2809
|
+
return self
|
|
2810
|
+
|
|
2811
|
+
|
|
2812
|
+
class InitialStateInput(Base):
|
|
2813
|
+
id: str
|
|
2814
|
+
plant_id: str
|
|
2815
|
+
position: VectorQuantity
|
|
2816
|
+
orientation_xyzw: tuple[float, float, float, float]
|
|
2817
|
+
linear_velocity: VectorQuantity
|
|
2818
|
+
angular_velocity: VectorQuantity
|
|
2819
|
+
joint_positions: dict[str, AngleQuantity] = {}
|
|
2820
|
+
joint_velocities: dict[str, AngularRateQuantity] = {}
|
|
2821
|
+
provenance: Provenance
|
|
2822
|
+
|
|
2823
|
+
@field_validator("id", "plant_id")
|
|
2824
|
+
@classmethod
|
|
2825
|
+
def _text(cls, value: str) -> str:
|
|
2826
|
+
return _nonblank(value)
|
|
2827
|
+
|
|
2828
|
+
@model_validator(mode="after")
|
|
2829
|
+
def _state_roles(self) -> "InitialStateInput":
|
|
2830
|
+
self.position.require_dimension("length", "initial position")
|
|
2831
|
+
self.linear_velocity.require_dimension("velocity", "initial linear velocity")
|
|
2832
|
+
self.angular_velocity.require_dimension("angular_rate", "initial angular velocity")
|
|
2833
|
+
frames = {self.position.frame, self.linear_velocity.frame, self.angular_velocity.frame}
|
|
2834
|
+
if len(frames) != 1:
|
|
2835
|
+
raise ValueError("initial base pose and twist must use one world frame")
|
|
2836
|
+
if not all(math.isfinite(component) for component in self.orientation_xyzw):
|
|
2837
|
+
raise ValueError("initial orientation must be finite")
|
|
2838
|
+
norm = math.sqrt(sum(component * component for component in self.orientation_xyzw))
|
|
2839
|
+
if not math.isclose(norm, 1.0, rel_tol=0.0, abs_tol=1e-9):
|
|
2840
|
+
raise ValueError("initial orientation quaternion must be normalized")
|
|
2841
|
+
return self
|
|
2842
|
+
|
|
2843
|
+
|
|
2844
|
+
class ControllerInput(Base):
|
|
2845
|
+
id: str
|
|
2846
|
+
kind: Literal["px4_sitl", "ros2_control", "disabled"]
|
|
2847
|
+
config: InputArtifact | None = None
|
|
2848
|
+
admitted_identity: str | None = None
|
|
2849
|
+
update_period: PositiveTimeQuantity | None = None
|
|
2850
|
+
command_interfaces: list[str] = []
|
|
2851
|
+
state_interfaces: list[str] = []
|
|
2852
|
+
implicit_hold: bool
|
|
2853
|
+
provenance: Provenance
|
|
2854
|
+
|
|
2855
|
+
@field_validator("id")
|
|
2856
|
+
@classmethod
|
|
2857
|
+
def _id(cls, value: str) -> str:
|
|
2858
|
+
return _nonblank(value)
|
|
2859
|
+
|
|
2860
|
+
@field_validator("admitted_identity")
|
|
2861
|
+
@classmethod
|
|
2862
|
+
def _identity(cls, value: str | None) -> str | None:
|
|
2863
|
+
if value is not None and not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", value):
|
|
2864
|
+
raise ValueError("controller identity must be a safe package-manifest key")
|
|
2865
|
+
return value
|
|
2866
|
+
|
|
2867
|
+
@model_validator(mode="after")
|
|
2868
|
+
def _controller_shape(self) -> "ControllerInput":
|
|
2869
|
+
if self.kind == "disabled":
|
|
2870
|
+
if self.implicit_hold:
|
|
2871
|
+
raise ValueError("disabled controller requires implicit_hold=False")
|
|
2872
|
+
if any(
|
|
2873
|
+
value is not None for value in (self.config, self.admitted_identity, self.update_period)
|
|
2874
|
+
) or self.command_interfaces or self.state_interfaces:
|
|
2875
|
+
raise ValueError("disabled controller accepts no executable configuration or interfaces")
|
|
2876
|
+
elif (
|
|
2877
|
+
self.config is None
|
|
2878
|
+
or self.admitted_identity is None
|
|
2879
|
+
or self.update_period is None
|
|
2880
|
+
or not self.command_interfaces
|
|
2881
|
+
or not self.state_interfaces
|
|
2882
|
+
):
|
|
2883
|
+
raise ValueError("enabled controller requires config, admitted identity, period, and maps")
|
|
2884
|
+
return self
|
|
2885
|
+
|
|
2886
|
+
|
|
2887
|
+
class PhysicsInput(Base):
|
|
2888
|
+
id: str
|
|
2889
|
+
engine_profile: str
|
|
2890
|
+
max_step_size: PositiveTimeQuantity
|
|
2891
|
+
solver_iterations: Annotated[int, Field(strict=True, gt=0)]
|
|
2892
|
+
real_time_factor: float = Field(gt=0)
|
|
2893
|
+
collision_contact_enabled: bool
|
|
2894
|
+
random_seed: Annotated[int, Field(strict=True, ge=0)]
|
|
2895
|
+
simulated_time: PositiveTimeQuantity
|
|
2896
|
+
time_policy: Literal["fixed"] = "fixed"
|
|
2897
|
+
provenance: Provenance
|
|
2898
|
+
|
|
2899
|
+
@field_validator("id")
|
|
2900
|
+
@classmethod
|
|
2901
|
+
def _id(cls, value: str) -> str:
|
|
2902
|
+
return _nonblank(value)
|
|
2903
|
+
|
|
2904
|
+
@field_validator("engine_profile")
|
|
2905
|
+
@classmethod
|
|
2906
|
+
def _profile(cls, value: str) -> str:
|
|
2907
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", value):
|
|
2908
|
+
raise ValueError("engine_profile must be a safe manifest key")
|
|
2909
|
+
return value
|
|
2910
|
+
|
|
2911
|
+
@model_validator(mode="after")
|
|
2912
|
+
def _fixed_time_controls(self) -> "PhysicsInput":
|
|
2913
|
+
if self.max_step_size.base() > self.simulated_time.base():
|
|
2914
|
+
raise ValueError("physics max step must not exceed fixed simulated time")
|
|
2915
|
+
if not math.isfinite(self.real_time_factor):
|
|
2916
|
+
raise ValueError("physics real-time factor must be finite")
|
|
2917
|
+
return self
|
|
2918
|
+
|
|
2919
|
+
|
|
2920
|
+
class X500Scenario(Base):
|
|
2921
|
+
id: str
|
|
2922
|
+
kind: Literal["x500_takeoff_hover_land"] = "x500_takeoff_hover_land"
|
|
2923
|
+
plant_id: str
|
|
2924
|
+
controller_id: str
|
|
2925
|
+
negative_controller_id: str
|
|
2926
|
+
world_id: str
|
|
2927
|
+
physics_id: str
|
|
2928
|
+
runtime_id: str
|
|
2929
|
+
initial_state_id: str
|
|
2930
|
+
target_altitude: PositiveLengthQuantity
|
|
2931
|
+
settled_hover_duration: PositiveTimeQuantity
|
|
2932
|
+
deadline: PositiveTimeQuantity
|
|
2933
|
+
phase_durations: dict[Literal["takeoff", "settle", "hover", "land"], PositiveTimeQuantity]
|
|
2934
|
+
random_seed: Annotated[int, Field(strict=True, ge=0)]
|
|
2935
|
+
simulated_time: PositiveTimeQuantity
|
|
2936
|
+
require_nonzero_sensing: Literal[True]
|
|
2937
|
+
require_nonzero_actuation: Literal[True]
|
|
2938
|
+
require_actual_feedback: Literal[True]
|
|
2939
|
+
require_controller_disabled_negative: Literal[True]
|
|
2940
|
+
acceptance_limit_ids: list[str]
|
|
2941
|
+
provenance: Provenance
|
|
2942
|
+
|
|
2943
|
+
@model_validator(mode="after")
|
|
2944
|
+
def _fixed_x500_case(self) -> "X500Scenario":
|
|
2945
|
+
if not math.isclose(self.target_altitude.base(), 2.0, rel_tol=0.0, abs_tol=1e-12):
|
|
2946
|
+
raise ValueError("X500 target altitude must be exactly 2 m")
|
|
2947
|
+
if not math.isclose(
|
|
2948
|
+
self.settled_hover_duration.base(), 10.0, rel_tol=0.0, abs_tol=1e-12
|
|
2949
|
+
):
|
|
2950
|
+
raise ValueError("X500 settled hover must be exactly 10 s")
|
|
2951
|
+
if not math.isclose(self.deadline.base(), 90.0, rel_tol=0.0, abs_tol=1e-12):
|
|
2952
|
+
raise ValueError("X500 landing/disarm deadline must be exactly 90 s")
|
|
2953
|
+
if set(self.phase_durations) != {"takeoff", "settle", "hover", "land"}:
|
|
2954
|
+
raise ValueError("X500 phase allocation must declare takeoff/settle/hover/land")
|
|
2955
|
+
if not math.isclose(
|
|
2956
|
+
self.phase_durations["hover"].base(), 10.0, rel_tol=0.0, abs_tol=1e-12
|
|
2957
|
+
):
|
|
2958
|
+
raise ValueError("X500 hover phase allocation must be 10 s")
|
|
2959
|
+
if not math.isclose(
|
|
2960
|
+
sum(value.base() for value in self.phase_durations.values()),
|
|
2961
|
+
self.simulated_time.base(),
|
|
2962
|
+
rel_tol=0.0,
|
|
2963
|
+
abs_tol=1e-9,
|
|
2964
|
+
):
|
|
2965
|
+
raise ValueError("X500 phases must allocate all fixed simulated time")
|
|
2966
|
+
if self.simulated_time.base() > self.deadline.base():
|
|
2967
|
+
raise ValueError("X500 simulated time must not exceed its 90 s deadline")
|
|
2968
|
+
return self
|
|
2969
|
+
|
|
2970
|
+
|
|
2971
|
+
class AnymalScenario(Base):
|
|
2972
|
+
id: str
|
|
2973
|
+
kind: Literal["anymal_squat"] = "anymal_squat"
|
|
2974
|
+
plant_id: str
|
|
2975
|
+
controller_id: str
|
|
2976
|
+
negative_controller_id: str
|
|
2977
|
+
world_id: str
|
|
2978
|
+
physics_id: str
|
|
2979
|
+
runtime_id: str
|
|
2980
|
+
initial_state_id: str
|
|
2981
|
+
knee_cycle_amplitude: AngleQuantity
|
|
2982
|
+
knee_cycle_count: Literal[2]
|
|
2983
|
+
trajectory: Literal["smooth"]
|
|
2984
|
+
knee_targets: dict[str, AngleQuantity]
|
|
2985
|
+
compensating_hip_targets: dict[str, AngleQuantity]
|
|
2986
|
+
phase_durations: dict[
|
|
2987
|
+
Literal["stand", "cycle_1", "cycle_2", "recover"], PositiveTimeQuantity
|
|
2988
|
+
]
|
|
2989
|
+
max_abs_roll_pitch: AngleQuantity
|
|
2990
|
+
random_seed: Annotated[int, Field(strict=True, ge=0)]
|
|
2991
|
+
simulated_time: PositiveTimeQuantity
|
|
2992
|
+
require_nonzero_sensing: Literal[True]
|
|
2993
|
+
require_nonzero_actuation: Literal[True]
|
|
2994
|
+
require_actual_feedback: Literal[True]
|
|
2995
|
+
require_controller_disabled_negative: Literal[True]
|
|
2996
|
+
acceptance_limit_ids: list[str]
|
|
2997
|
+
provenance: Provenance
|
|
2998
|
+
|
|
2999
|
+
@model_validator(mode="after")
|
|
3000
|
+
def _fixed_anymal_case(self) -> "AnymalScenario":
|
|
3001
|
+
if not math.isclose(
|
|
3002
|
+
self.knee_cycle_amplitude.base(), 0.15, rel_tol=0.0, abs_tol=1e-12
|
|
3003
|
+
):
|
|
3004
|
+
raise ValueError("ANYmal knee cycle amplitude must be exactly 0.15 rad")
|
|
3005
|
+
if not self.knee_targets or not self.compensating_hip_targets:
|
|
3006
|
+
raise ValueError("ANYmal requires predeclared knee and compensating hip targets")
|
|
3007
|
+
if set(self.phase_durations) != {"stand", "cycle_1", "cycle_2", "recover"}:
|
|
3008
|
+
raise ValueError("ANYmal phase allocation must declare stand/two cycles/recover")
|
|
3009
|
+
if not math.isclose(
|
|
3010
|
+
sum(value.base() for value in self.phase_durations.values()),
|
|
3011
|
+
self.simulated_time.base(),
|
|
3012
|
+
rel_tol=0.0,
|
|
3013
|
+
abs_tol=1e-9,
|
|
3014
|
+
):
|
|
3015
|
+
raise ValueError("ANYmal phases must allocate all fixed simulated time")
|
|
3016
|
+
if self.max_abs_roll_pitch.base() <= 0:
|
|
3017
|
+
raise ValueError("ANYmal roll/pitch bound must be positive")
|
|
3018
|
+
return self
|
|
3019
|
+
|
|
3020
|
+
|
|
3021
|
+
SimulationScenario = Annotated[X500Scenario | AnymalScenario, Field(discriminator="kind")]
|
|
3022
|
+
|
|
3023
|
+
|
|
3024
|
+
class X500PositionLimit(Base):
|
|
3025
|
+
kind: Literal["x500_position"] = "x500_position"
|
|
3026
|
+
id: str
|
|
3027
|
+
metric: Literal["hover_altitude_rms", "hover_altitude_peak", "horizontal_displacement"]
|
|
3028
|
+
threshold: PositiveLengthQuantity
|
|
3029
|
+
provenance: Provenance
|
|
3030
|
+
|
|
3031
|
+
|
|
3032
|
+
class X500LandingLimit(Base):
|
|
3033
|
+
kind: Literal["x500_land_disarm"] = "x500_land_disarm"
|
|
3034
|
+
id: str
|
|
3035
|
+
deadline: PositiveTimeQuantity
|
|
3036
|
+
require_landed: Literal[True]
|
|
3037
|
+
require_disarmed: Literal[True]
|
|
3038
|
+
provenance: Provenance
|
|
3039
|
+
|
|
3040
|
+
|
|
3041
|
+
class AnymalTrackingLimit(Base):
|
|
3042
|
+
kind: Literal["anymal_tracking"] = "anymal_tracking"
|
|
3043
|
+
id: str
|
|
3044
|
+
rms_threshold: AngleQuantity
|
|
3045
|
+
peak_threshold: AngleQuantity
|
|
3046
|
+
provenance: Provenance
|
|
3047
|
+
|
|
3048
|
+
|
|
3049
|
+
class AnymalBodyContactLimit(Base):
|
|
3050
|
+
kind: Literal["anymal_body_contact"] = "anymal_body_contact"
|
|
3051
|
+
id: str
|
|
3052
|
+
allow_unintended_body_ground_contact: Literal[False]
|
|
3053
|
+
provenance: Provenance
|
|
3054
|
+
|
|
3055
|
+
|
|
3056
|
+
class AnymalAttitudeLimit(Base):
|
|
3057
|
+
kind: Literal["anymal_attitude"] = "anymal_attitude"
|
|
3058
|
+
id: str
|
|
3059
|
+
max_abs_roll_pitch: AngleQuantity
|
|
3060
|
+
provenance: Provenance
|
|
3061
|
+
|
|
3062
|
+
|
|
3063
|
+
SimulationAcceptanceLimit = Annotated[
|
|
3064
|
+
X500PositionLimit
|
|
3065
|
+
| X500LandingLimit
|
|
3066
|
+
| AnymalTrackingLimit
|
|
3067
|
+
| AnymalBodyContactLimit
|
|
3068
|
+
| AnymalAttitudeLimit,
|
|
3069
|
+
Field(discriminator="kind"),
|
|
3070
|
+
]
|
|
3071
|
+
|
|
3072
|
+
|
|
3073
|
+
class SimulationInputs(Base):
|
|
3074
|
+
version: Literal["1"] = "1"
|
|
3075
|
+
frames: list[FrameInput] = []
|
|
3076
|
+
worlds: list[WorldInput]
|
|
3077
|
+
plants: list[PlantInput]
|
|
3078
|
+
initial_states: list[InitialStateInput]
|
|
3079
|
+
controllers: list[ControllerInput]
|
|
3080
|
+
physics: list[PhysicsInput]
|
|
3081
|
+
damping: list[PlantDampingInput] = []
|
|
3082
|
+
runtimes: list[RuntimeSelection]
|
|
3083
|
+
scenarios: list[SimulationScenario]
|
|
3084
|
+
acceptance_limits: list[SimulationAcceptanceLimit]
|
|
3085
|
+
provenance: Provenance
|
|
3086
|
+
|
|
3087
|
+
@model_validator(mode="after")
|
|
3088
|
+
def _resolved_scenarios(self) -> "SimulationInputs":
|
|
3089
|
+
collections = {
|
|
3090
|
+
"frames": self.frames,
|
|
3091
|
+
"worlds": self.worlds,
|
|
3092
|
+
"plants": self.plants,
|
|
3093
|
+
"initial_states": self.initial_states,
|
|
3094
|
+
"controllers": self.controllers,
|
|
3095
|
+
"physics": self.physics,
|
|
3096
|
+
"damping": self.damping,
|
|
3097
|
+
"runtimes": self.runtimes,
|
|
3098
|
+
"scenarios": self.scenarios,
|
|
3099
|
+
"acceptance_limits": self.acceptance_limits,
|
|
3100
|
+
}
|
|
3101
|
+
for label, rows in collections.items():
|
|
3102
|
+
_unique_row_ids(label, rows)
|
|
3103
|
+
|
|
3104
|
+
frame_ids = {"world"}
|
|
3105
|
+
for frame in self.frames:
|
|
3106
|
+
if frame.id in frame_ids or frame.parent not in frame_ids:
|
|
3107
|
+
raise ValueError(f"simulation frame {frame.id!r} has duplicate/unknown parent")
|
|
3108
|
+
frame_ids.add(frame.id)
|
|
3109
|
+
for world in self.worlds:
|
|
3110
|
+
if world.root_frame not in frame_ids:
|
|
3111
|
+
raise ValueError(f"world {world.id!r} uses an unknown root frame")
|
|
3112
|
+
if world.artifact.format not in {"world", "sdf"}:
|
|
3113
|
+
raise ValueError("world artifact must use world or sdf format")
|
|
3114
|
+
for plant in self.plants:
|
|
3115
|
+
if plant.artifact.format not in {"urdf", "sdf"}:
|
|
3116
|
+
raise ValueError("plant artifact must use urdf or sdf format")
|
|
3117
|
+
for controller in self.controllers:
|
|
3118
|
+
if controller.config is not None and controller.config.format not in {
|
|
3119
|
+
"controller_config",
|
|
3120
|
+
"json",
|
|
3121
|
+
"yaml",
|
|
3122
|
+
}:
|
|
3123
|
+
raise ValueError("controller config has an unsupported data format")
|
|
3124
|
+
|
|
3125
|
+
worlds = {row.id: row for row in self.worlds}
|
|
3126
|
+
plants = {row.id: row for row in self.plants}
|
|
3127
|
+
states = {row.id: row for row in self.initial_states}
|
|
3128
|
+
controllers = {row.id: row for row in self.controllers}
|
|
3129
|
+
physics = {row.id: row for row in self.physics}
|
|
3130
|
+
damping = {row.id for row in self.damping}
|
|
3131
|
+
runtimes = {row.id for row in self.runtimes}
|
|
3132
|
+
limits = {row.id: row for row in self.acceptance_limits}
|
|
3133
|
+
for plant in self.plants:
|
|
3134
|
+
missing_damping = sorted(set(plant.damping_ids) - damping)
|
|
3135
|
+
if missing_damping:
|
|
3136
|
+
raise ValueError(
|
|
3137
|
+
f"plant {plant.id!r} references unknown damping: {', '.join(missing_damping)}"
|
|
3138
|
+
)
|
|
3139
|
+
for state in self.initial_states:
|
|
3140
|
+
if state.plant_id not in plants:
|
|
3141
|
+
raise ValueError(f"initial state {state.id!r} references unknown plant")
|
|
3142
|
+
|
|
3143
|
+
for scenario in self.scenarios:
|
|
3144
|
+
references = {
|
|
3145
|
+
"world": (scenario.world_id, worlds),
|
|
3146
|
+
"plant": (scenario.plant_id, plants),
|
|
3147
|
+
"controller": (scenario.controller_id, controllers),
|
|
3148
|
+
"negative controller": (scenario.negative_controller_id, controllers),
|
|
3149
|
+
"physics": (scenario.physics_id, physics),
|
|
3150
|
+
"runtime": (scenario.runtime_id, runtimes),
|
|
3151
|
+
"initial state": (scenario.initial_state_id, states),
|
|
3152
|
+
}
|
|
3153
|
+
for label, (row_id, inventory) in references.items():
|
|
3154
|
+
if row_id not in inventory:
|
|
3155
|
+
raise ValueError(f"scenario {scenario.id!r} references unknown {label} {row_id!r}")
|
|
3156
|
+
plant = plants[scenario.plant_id]
|
|
3157
|
+
controller = controllers[scenario.controller_id]
|
|
3158
|
+
negative = controllers[scenario.negative_controller_id]
|
|
3159
|
+
state = states[scenario.initial_state_id]
|
|
3160
|
+
physical = physics[scenario.physics_id]
|
|
3161
|
+
if state.plant_id != plant.id:
|
|
3162
|
+
raise ValueError("scenario initial state must belong to its plant")
|
|
3163
|
+
if state.position.frame != worlds[scenario.world_id].root_frame:
|
|
3164
|
+
raise ValueError("scenario initial state must use the selected world root frame")
|
|
3165
|
+
expected = (
|
|
3166
|
+
("px4_x500", "px4_sitl")
|
|
3167
|
+
if scenario.kind == "x500_takeoff_hover_land"
|
|
3168
|
+
else ("anymal", "ros2_control")
|
|
3169
|
+
)
|
|
3170
|
+
if (plant.kind, controller.kind) != expected:
|
|
3171
|
+
raise ValueError(f"scenario {scenario.id!r} has incompatible plant/controller")
|
|
3172
|
+
interfaces = {interface.id: interface for interface in plant.interfaces}
|
|
3173
|
+
if any(
|
|
3174
|
+
interface_id not in interfaces or interfaces[interface_id].role != "command"
|
|
3175
|
+
for interface_id in controller.command_interfaces
|
|
3176
|
+
):
|
|
3177
|
+
raise ValueError("controller command map must reference plant command interfaces")
|
|
3178
|
+
if any(
|
|
3179
|
+
interface_id not in interfaces
|
|
3180
|
+
or interfaces[interface_id].role != "state_feedback"
|
|
3181
|
+
or not interfaces[interface_id].independently_measured
|
|
3182
|
+
for interface_id in controller.state_interfaces
|
|
3183
|
+
):
|
|
3184
|
+
raise ValueError("controller state map must reference independent plant feedback")
|
|
3185
|
+
if negative.kind != "disabled" or negative.implicit_hold:
|
|
3186
|
+
raise ValueError("scenario negative requires a disabled controller without implicit hold")
|
|
3187
|
+
if not physical.collision_contact_enabled:
|
|
3188
|
+
raise ValueError("declared simulation scenarios require collision/contact enabled")
|
|
3189
|
+
if scenario.random_seed != physical.random_seed or not math.isclose(
|
|
3190
|
+
scenario.simulated_time.base(), physical.simulated_time.base(), abs_tol=1e-12
|
|
3191
|
+
):
|
|
3192
|
+
raise ValueError("scenario seed/time must match its fixed physics declaration")
|
|
3193
|
+
missing_limits = sorted(set(scenario.acceptance_limit_ids) - set(limits))
|
|
3194
|
+
if missing_limits:
|
|
3195
|
+
raise ValueError(
|
|
3196
|
+
f"scenario {scenario.id!r} references unknown limits: {', '.join(missing_limits)}"
|
|
3197
|
+
)
|
|
3198
|
+
selected = [limits[row_id] for row_id in scenario.acceptance_limit_ids]
|
|
3199
|
+
if scenario.kind == "x500_takeoff_hover_land":
|
|
3200
|
+
position = {
|
|
3201
|
+
row.metric: row.threshold.base()
|
|
3202
|
+
for row in selected
|
|
3203
|
+
if isinstance(row, X500PositionLimit)
|
|
3204
|
+
}
|
|
3205
|
+
expected_position = {
|
|
3206
|
+
"hover_altitude_rms": 0.2,
|
|
3207
|
+
"hover_altitude_peak": 0.5,
|
|
3208
|
+
"horizontal_displacement": 0.5,
|
|
3209
|
+
}
|
|
3210
|
+
landing = [row for row in selected if isinstance(row, X500LandingLimit)]
|
|
3211
|
+
if position != expected_position or len(landing) != 1 or not math.isclose(
|
|
3212
|
+
landing[0].deadline.base(), 90.0, abs_tol=1e-12
|
|
3213
|
+
):
|
|
3214
|
+
raise ValueError("X500 scenario requires the fixed position and land/disarm limits")
|
|
3215
|
+
else:
|
|
3216
|
+
tracking = [row for row in selected if isinstance(row, AnymalTrackingLimit)]
|
|
3217
|
+
contact = [row for row in selected if isinstance(row, AnymalBodyContactLimit)]
|
|
3218
|
+
attitude = [row for row in selected if isinstance(row, AnymalAttitudeLimit)]
|
|
3219
|
+
if (
|
|
3220
|
+
len(tracking) != 1
|
|
3221
|
+
or not math.isclose(tracking[0].rms_threshold.base(), 0.05, abs_tol=1e-12)
|
|
3222
|
+
or not math.isclose(tracking[0].peak_threshold.base(), 0.15, abs_tol=1e-12)
|
|
3223
|
+
or len(contact) != 1
|
|
3224
|
+
or len(attitude) != 1
|
|
3225
|
+
or not math.isclose(
|
|
3226
|
+
attitude[0].max_abs_roll_pitch.base(),
|
|
3227
|
+
scenario.max_abs_roll_pitch.base(),
|
|
3228
|
+
abs_tol=1e-12,
|
|
3229
|
+
)
|
|
3230
|
+
):
|
|
3231
|
+
raise ValueError("ANYmal scenario requires fixed tracking/contact/attitude limits")
|
|
3232
|
+
return self
|
|
3233
|
+
|
|
3234
|
+
|
|
3235
|
+
class Gearbox(Base):
|
|
3236
|
+
"""A CAD-lite reduction stage between two recorded joints.
|
|
3237
|
+
|
|
3238
|
+
Declarative data only, like `Budget`: `ratio` and the two joint references
|
|
3239
|
+
are consumed by nothing yet — this is the record, not a torque-budget
|
|
3240
|
+
integration. `input_joint`/`output_joint` existence and any position in
|
|
3241
|
+
the link tree are `edit.add_entry`'s and `edit.reparent`'s job, not
|
|
3242
|
+
this model's; that keeps the reference check in one place.
|
|
3243
|
+
"""
|
|
3244
|
+
|
|
3245
|
+
id: str
|
|
3246
|
+
ratio: float
|
|
3247
|
+
input_joint: str
|
|
3248
|
+
output_joint: str
|
|
3249
|
+
provenance: Provenance | None = None
|
|
3250
|
+
|
|
3251
|
+
@field_validator("ratio")
|
|
3252
|
+
@classmethod
|
|
3253
|
+
def _positive_ratio(cls, v: float) -> float:
|
|
3254
|
+
if v <= 0:
|
|
3255
|
+
raise ValueError("gearbox ratio must be > 0")
|
|
3256
|
+
return v
|
|
3257
|
+
|
|
3258
|
+
|
|
3259
|
+
class Contract(Base):
|
|
3260
|
+
schema_version: str = SCHEMA_VERSION
|
|
3261
|
+
robot: RobotInfo
|
|
3262
|
+
model: ModelRecord | None = None
|
|
3263
|
+
edges: list[DependencyEdge] = []
|
|
3264
|
+
assumptions: list[Assumption] = []
|
|
3265
|
+
families: list[DeviceFamily] = []
|
|
3266
|
+
device_models: list[DeviceModel] = []
|
|
3267
|
+
devices: list[DeviceInstance] = []
|
|
3268
|
+
topology: Topology | None = None
|
|
3269
|
+
budgets: list[Budget] = []
|
|
3270
|
+
gearboxes: list[Gearbox] = []
|
|
3271
|
+
fitted_band_allocations: list[FittedBandAllocation] = []
|
|
3272
|
+
actuator_cases: list[ActuatorCase] = []
|
|
3273
|
+
structural: StructuralInputs | None = None
|
|
3274
|
+
simulation: SimulationInputs | None = None
|
|
3275
|
+
|
|
3276
|
+
@model_validator(mode="after")
|
|
3277
|
+
def _unique_ids(self) -> "Contract":
|
|
3278
|
+
groups = {
|
|
3279
|
+
"devices": self.devices,
|
|
3280
|
+
"device_models": self.device_models,
|
|
3281
|
+
"families": self.families,
|
|
3282
|
+
"rails": self.topology.rails if self.topology is not None else [],
|
|
3283
|
+
"buses": self.topology.buses if self.topology is not None else [],
|
|
3284
|
+
"wires": self.topology.wires if self.topology is not None else [],
|
|
3285
|
+
"budgets": self.budgets,
|
|
3286
|
+
"gearboxes": self.gearboxes,
|
|
3287
|
+
"assumptions": self.assumptions,
|
|
3288
|
+
"actuator_cases": self.actuator_cases,
|
|
3289
|
+
}
|
|
3290
|
+
for name, rows in groups.items():
|
|
3291
|
+
ids = [row.id for row in rows]
|
|
3292
|
+
duplicates = sorted({row_id for row_id in ids if ids.count(row_id) > 1})
|
|
3293
|
+
if duplicates:
|
|
3294
|
+
raise ValueError(f"duplicate id(s) in {name}: {', '.join(duplicates)}")
|
|
3295
|
+
return self
|
|
3296
|
+
|
|
3297
|
+
@model_validator(mode="after")
|
|
3298
|
+
def _valid_fitted_band_allocations(self) -> "Contract":
|
|
3299
|
+
fitted_inventory: dict[str, FittedBandEvidence] = {}
|
|
3300
|
+
for device in self.devices:
|
|
3301
|
+
for envelope in device.envelopes:
|
|
3302
|
+
for path, evidence in envelope.fitted_bands.items():
|
|
3303
|
+
inventory_path = f"devices/{device.id}/envelopes/{envelope.context}/{path}"
|
|
3304
|
+
if inventory_path in fitted_inventory:
|
|
3305
|
+
raise ValueError(f"duplicate fitted-band inventory path {inventory_path!r}")
|
|
3306
|
+
fitted_inventory[inventory_path] = evidence
|
|
3307
|
+
|
|
3308
|
+
if (fitted_inventory or self.fitted_band_allocations) and self.schema_version not in {
|
|
3309
|
+
"0.3",
|
|
3310
|
+
"0.4",
|
|
3311
|
+
}:
|
|
3312
|
+
raise ValueError(
|
|
3313
|
+
"fitted_bands and fitted_band_allocations require schema_version '0.3' or '0.4'"
|
|
3314
|
+
)
|
|
3315
|
+
if not fitted_inventory and not self.fitted_band_allocations:
|
|
3316
|
+
return self
|
|
3317
|
+
if fitted_inventory and not self.fitted_band_allocations:
|
|
3318
|
+
raise ValueError("fitted_bands requires fitted_band_allocations")
|
|
3319
|
+
|
|
3320
|
+
allocated_inventory: dict[str, FittedBandAllocation] = {}
|
|
3321
|
+
allocated_channels: set[tuple[str, str]] = set()
|
|
3322
|
+
cluster_counts = Counter(row.context for row in self.fitted_band_allocations)
|
|
3323
|
+
for row in self.fitted_band_allocations:
|
|
3324
|
+
scoped_channels = {(row.context, channel) for channel in row.channels}
|
|
3325
|
+
overlap = allocated_channels.intersection(scoped_channels)
|
|
3326
|
+
if overlap:
|
|
3327
|
+
raise ValueError(
|
|
3328
|
+
"allocation channels appear in multiple clusters: "
|
|
3329
|
+
+ ", ".join(f"{context}:{channel}" for context, channel in sorted(overlap))
|
|
3330
|
+
)
|
|
3331
|
+
allocated_channels.update(scoped_channels)
|
|
3332
|
+
expected_denominator = cluster_counts[row.context] * len(row.channels)
|
|
3333
|
+
if row.denominator != expected_denominator:
|
|
3334
|
+
raise ValueError(
|
|
3335
|
+
f"allocation denominator {row.denominator} for context "
|
|
3336
|
+
f"{row.context!r} does not match {cluster_counts[row.context]} "
|
|
3337
|
+
f"cluster(s) × {len(row.channels)} channel(s) = "
|
|
3338
|
+
f"{expected_denominator}"
|
|
3339
|
+
)
|
|
3340
|
+
for path in row.fitted_bands:
|
|
3341
|
+
if path in allocated_inventory:
|
|
3342
|
+
raise ValueError(f"fitted-band path {path!r} appears in multiple allocations")
|
|
3343
|
+
allocated_inventory[path] = row
|
|
3344
|
+
|
|
3345
|
+
current_paths = set(fitted_inventory)
|
|
3346
|
+
allocated_paths = set(allocated_inventory)
|
|
3347
|
+
if current_paths != allocated_paths:
|
|
3348
|
+
missing = sorted(current_paths - allocated_paths)
|
|
3349
|
+
stale = sorted(allocated_paths - current_paths)
|
|
3350
|
+
details = []
|
|
3351
|
+
if missing:
|
|
3352
|
+
details.append("missing from ledger: " + ", ".join(missing))
|
|
3353
|
+
if stale:
|
|
3354
|
+
details.append("not in current inventory: " + ", ".join(stale))
|
|
3355
|
+
raise ValueError("fitted-band allocation inventory mismatch; " + "; ".join(details))
|
|
3356
|
+
|
|
3357
|
+
for path, evidence in fitted_inventory.items():
|
|
3358
|
+
row = allocated_inventory[path]
|
|
3359
|
+
expected_alpha = FITTED_BAND_ALPHA_GATE / row.denominator
|
|
3360
|
+
if not math.isclose(evidence.alpha, expected_alpha, rel_tol=1e-12, abs_tol=0.0):
|
|
3361
|
+
raise ValueError(
|
|
3362
|
+
f"fitted alpha {evidence.alpha} for {path!r} does not match "
|
|
3363
|
+
f"allocation {FITTED_BAND_ALPHA_GATE}/{row.denominator} = "
|
|
3364
|
+
f"{expected_alpha}"
|
|
3365
|
+
)
|
|
3366
|
+
return self
|
|
3367
|
+
|
|
3368
|
+
@model_validator(mode="after")
|
|
3369
|
+
def _valid_actuator_cases(self) -> "Contract":
|
|
3370
|
+
if self.actuator_cases and self.schema_version != "0.4":
|
|
3371
|
+
raise ValueError("actuator_cases require schema_version '0.4'")
|
|
3372
|
+
for case in self.actuator_cases:
|
|
3373
|
+
device = self.device(case.actuator_device)
|
|
3374
|
+
joint = (
|
|
3375
|
+
next((item for item in self.model.joints if item.name == case.joint), None)
|
|
3376
|
+
if self.model is not None
|
|
3377
|
+
else None
|
|
3378
|
+
)
|
|
3379
|
+
if device is None or device.drives_joint != case.joint:
|
|
3380
|
+
raise ValueError(
|
|
3381
|
+
f"actuator case {case.id!r} must bind its driving device and joint"
|
|
3382
|
+
)
|
|
3383
|
+
if device.power is None or device.power.rail != case.rail:
|
|
3384
|
+
raise ValueError(f"actuator case {case.id!r} must bind its device's declared rail")
|
|
3385
|
+
if joint is None or joint.type not in {"revolute", "continuous"}:
|
|
3386
|
+
raise ValueError(f"actuator case {case.id!r} requires a declared rotary joint")
|
|
3387
|
+
model = self.device_model(device.model)
|
|
3388
|
+
if model is None or model.actuator is None:
|
|
3389
|
+
raise ValueError(f"actuator case {case.id!r} requires an actuator device model")
|
|
3390
|
+
return self
|
|
3391
|
+
|
|
3392
|
+
@model_validator(mode="after")
|
|
3393
|
+
def _valid_structural_simulation_sections(self) -> "Contract":
|
|
3394
|
+
if (self.structural is not None or self.simulation is not None) and self.schema_version != "0.4":
|
|
3395
|
+
raise ValueError("structural and simulation sections require schema_version '0.4'")
|
|
3396
|
+
if self.structural is not None:
|
|
3397
|
+
if self.model is None:
|
|
3398
|
+
raise ValueError("structural section requires Contract.model")
|
|
3399
|
+
binding = self.structural.model
|
|
3400
|
+
if (
|
|
3401
|
+
binding.source_sha256 != self.model.source_sha256
|
|
3402
|
+
or binding.structure_hash != self.model.structure_hash
|
|
3403
|
+
):
|
|
3404
|
+
raise ValueError("structural model binding does not match Contract.model hashes")
|
|
3405
|
+
known_links = {link.name for link in self.model.links}
|
|
3406
|
+
for frame in self.structural.frames:
|
|
3407
|
+
if frame.parent.startswith("model_link:"):
|
|
3408
|
+
link = frame.parent.removeprefix("model_link:")
|
|
3409
|
+
if link not in known_links:
|
|
3410
|
+
raise ValueError(f"frame {frame.id!r} references unknown model link {link!r}")
|
|
3411
|
+
return self
|
|
3412
|
+
|
|
3413
|
+
# -- lookups ---------------------------------------------------------
|
|
3414
|
+
|
|
3415
|
+
def device(self, id: str) -> DeviceInstance | None:
|
|
3416
|
+
return next((d for d in self.devices if d.id == id), None)
|
|
3417
|
+
|
|
3418
|
+
def device_model(self, id: str) -> DeviceModel | None:
|
|
3419
|
+
return next((m for m in self.device_models if m.id == id), None)
|
|
3420
|
+
|
|
3421
|
+
def family(self, id: str) -> DeviceFamily | None:
|
|
3422
|
+
return next((f for f in self.families if f.id == id), None)
|
|
3423
|
+
|
|
3424
|
+
def rail(self, id: str) -> Rail | None:
|
|
3425
|
+
if self.topology is None:
|
|
3426
|
+
return None
|
|
3427
|
+
return next((r for r in self.topology.rails if r.id == id), None)
|
|
3428
|
+
|
|
3429
|
+
def wire(self, id: str) -> Wire | None:
|
|
3430
|
+
if self.topology is None:
|
|
3431
|
+
return None
|
|
3432
|
+
return next((w for w in self.topology.wires if w.id == id), None)
|
|
3433
|
+
|
|
3434
|
+
def resolve(self, path: str) -> Any:
|
|
3435
|
+
"""Resolve a '/'-separated contract path; KeyError if any segment misses.
|
|
3436
|
+
|
|
3437
|
+
List segments match by 'id' (or 'name' for links/hosts without ids).
|
|
3438
|
+
"""
|
|
3439
|
+
node: Any = self.model_dump(mode="python")
|
|
3440
|
+
for seg in path.split("/"):
|
|
3441
|
+
if isinstance(node, dict):
|
|
3442
|
+
if seg not in node:
|
|
3443
|
+
raise KeyError(f"{path!r}: no key {seg!r}")
|
|
3444
|
+
node = node[seg]
|
|
3445
|
+
elif isinstance(node, list):
|
|
3446
|
+
hits = [
|
|
3447
|
+
x
|
|
3448
|
+
for x in node
|
|
3449
|
+
if isinstance(x, dict) and (x.get("id") == seg or x.get("name") == seg)
|
|
3450
|
+
]
|
|
3451
|
+
if not hits:
|
|
3452
|
+
raise KeyError(f"{path!r}: no entry with id/name {seg!r}")
|
|
3453
|
+
node = hits[0]
|
|
3454
|
+
else:
|
|
3455
|
+
raise KeyError(f"{path!r}: {seg!r} reached a leaf")
|
|
3456
|
+
return node
|
|
3457
|
+
|
|
3458
|
+
|
|
3459
|
+
# ---------------------------------------------------------------- io
|
|
3460
|
+
|
|
3461
|
+
|
|
3462
|
+
def _yaml() -> YAML:
|
|
3463
|
+
y = YAML()
|
|
3464
|
+
y.default_flow_style = False
|
|
3465
|
+
y.sort_base_mapping_type_on_output = False # keep authored order
|
|
3466
|
+
# match edit.py's fallback style (4/2, `edit.py::_DEFAULT_SEQUENCE_STYLE`)
|
|
3467
|
+
# so a first `add`/`declare` after `init` sniffs the style it just wrote instead
|
|
3468
|
+
# of reflowing the whole file. Indent only affects the
|
|
3469
|
+
# emitter, so this is a no-op for `load_contract`'s use of this same loader.
|
|
3470
|
+
y.indent(mapping=2, sequence=4, offset=2)
|
|
3471
|
+
# Same reason edit.py never re-wraps: ruamel's default width (80) folds a
|
|
3472
|
+
# long scalar (a sha256, a source path) onto a continuation line, which
|
|
3473
|
+
# then reads as a changed line on the next unrelated add/declare once the
|
|
3474
|
+
# editor's un-folded width redumps it.
|
|
3475
|
+
y.width = 10_000
|
|
3476
|
+
return y
|
|
3477
|
+
|
|
3478
|
+
|
|
3479
|
+
_ANCHOR_ALIAS_FORBIDDEN = "YAML anchors/aliases are not permitted in a lockfile"
|
|
3480
|
+
|
|
3481
|
+
|
|
3482
|
+
def _node_has_anchor(node: Any) -> bool:
|
|
3483
|
+
"""True when ruamel attached a named anchor to this composed node."""
|
|
3484
|
+
anchor = getattr(node, "anchor", None)
|
|
3485
|
+
if anchor is None:
|
|
3486
|
+
return False
|
|
3487
|
+
return bool(getattr(anchor, "value", None))
|
|
3488
|
+
|
|
3489
|
+
|
|
3490
|
+
def _reject_yaml_anchors_aliases(node: Any) -> None:
|
|
3491
|
+
"""Refuse any loaded YAML graph that contains anchors or aliases.
|
|
3492
|
+
|
|
3493
|
+
ruamel keeps aliases as shared object references and does not expand them,
|
|
3494
|
+
so a billion-laughs document loads as a small physical graph. Walk that
|
|
3495
|
+
graph once: anchor metadata (``.anchor.value``) catches every named anchor
|
|
3496
|
+
regardless of Unicode; a second visit to the same container ``id`` is an
|
|
3497
|
+
alias. The visited-set bounds the walk to unique physical nodes.
|
|
3498
|
+
"""
|
|
3499
|
+
if not REJECT_YAML_ANCHORS_ALIASES:
|
|
3500
|
+
return
|
|
3501
|
+
visited: set[int] = set()
|
|
3502
|
+
|
|
3503
|
+
def walk(n: Any) -> None:
|
|
3504
|
+
if _node_has_anchor(n):
|
|
3505
|
+
raise ValueError(_ANCHOR_ALIAS_FORBIDDEN)
|
|
3506
|
+
if isinstance(n, dict | list):
|
|
3507
|
+
nid = id(n)
|
|
3508
|
+
if nid in visited:
|
|
3509
|
+
raise ValueError(_ANCHOR_ALIAS_FORBIDDEN)
|
|
3510
|
+
visited.add(nid)
|
|
3511
|
+
if isinstance(n, dict):
|
|
3512
|
+
for key, value in n.items():
|
|
3513
|
+
walk(key)
|
|
3514
|
+
walk(value)
|
|
3515
|
+
else:
|
|
3516
|
+
for item in n:
|
|
3517
|
+
walk(item)
|
|
3518
|
+
|
|
3519
|
+
walk(node)
|
|
3520
|
+
|
|
3521
|
+
|
|
3522
|
+
def _load_yaml_guarded(loader: YAML, stream: Any) -> Any:
|
|
3523
|
+
"""Load YAML, mapping ruamel's bare ``RecursionError`` to the tool's refusal shape.
|
|
3524
|
+
|
|
3525
|
+
Deeply nested YAML (e.g. thousands of ``[`` characters) blows the recursive
|
|
3526
|
+
descent parser's stack before any size/anchor guard runs, surfacing a raw
|
|
3527
|
+
traceback instead of a clean refusal. Every loader in this codebase
|
|
3528
|
+
(contract, plan, recording manifest) routes through here.
|
|
3529
|
+
"""
|
|
3530
|
+
try:
|
|
3531
|
+
return loader.load(stream)
|
|
3532
|
+
except RecursionError as error:
|
|
3533
|
+
raise ValueError("YAML nesting too deep to parse safely") from error
|
|
3534
|
+
|
|
3535
|
+
|
|
3536
|
+
def _guard_contract_raw(path: Path) -> None:
|
|
3537
|
+
"""Refuse oversized contracts cheaply, before YAML parse or validation.
|
|
3538
|
+
|
|
3539
|
+
Byte-size is checked from ``Path.stat`` (no read) so a giant scalar never
|
|
3540
|
+
reaches ruamel's scanner. Anchors/aliases are refused after load by
|
|
3541
|
+
``_reject_yaml_anchors_aliases`` — the parser decides membership.
|
|
3542
|
+
"""
|
|
3543
|
+
size = path.stat().st_size
|
|
3544
|
+
if size > MAX_CONTRACT_BYTES:
|
|
3545
|
+
raise ValueError(f"contract file exceeds size limit ({size} bytes > {MAX_CONTRACT_BYTES})")
|
|
3546
|
+
|
|
3547
|
+
|
|
3548
|
+
class UnsupportedSchemaVersion(ValueError):
|
|
3549
|
+
"""A contract declares a ``schema_version`` this lockwire cannot read.
|
|
3550
|
+
|
|
3551
|
+
Error path only — no schema *field* is added, moved or retyped by this
|
|
3552
|
+
class (the schema freeze binds the fields, not the refusal a bad one raises).
|
|
3553
|
+
It subclasses ``ValueError`` so every existing ``except ValueError`` caller
|
|
3554
|
+
keeps working; the point of the type is that a door can recognise this one
|
|
3555
|
+
failure and refuse it as a verdict (REFUSED, exit 2) instead of
|
|
3556
|
+
printing a plain "failed" line, or, for a library caller, an anonymous
|
|
3557
|
+
``ValueError`` indistinguishable from a parse error.
|
|
3558
|
+
"""
|
|
3559
|
+
|
|
3560
|
+
def __init__(self, found: object, supported: frozenset[str]) -> None:
|
|
3561
|
+
self.found = found
|
|
3562
|
+
self.supported = ", ".join(sorted(supported))
|
|
3563
|
+
super().__init__(
|
|
3564
|
+
f"contract declares schema_version {found!r}; this lockwire supports {self.supported}"
|
|
3565
|
+
)
|
|
3566
|
+
|
|
3567
|
+
|
|
3568
|
+
def load_contract(path: Path) -> Contract:
|
|
3569
|
+
_guard_contract_raw(path)
|
|
3570
|
+
return load_contract_bytes(path.read_bytes())
|
|
3571
|
+
|
|
3572
|
+
|
|
3573
|
+
def load_contract_bytes(raw: bytes) -> Contract:
|
|
3574
|
+
"""Validate one caller-held contract byte sequence without reopening its path."""
|
|
3575
|
+
|
|
3576
|
+
if len(raw) > MAX_CONTRACT_BYTES:
|
|
3577
|
+
raise ValueError(f"contract file exceeds size limit ({len(raw)} bytes > {MAX_CONTRACT_BYTES})")
|
|
3578
|
+
data = _load_yaml_guarded(_yaml(), StringIO(raw.decode("utf-8")))
|
|
3579
|
+
_reject_yaml_anchors_aliases(data)
|
|
3580
|
+
declared = data.get("schema_version", SCHEMA_VERSION) if isinstance(data, dict) else None
|
|
3581
|
+
if declared not in SUPPORTED_SCHEMA_VERSIONS:
|
|
3582
|
+
raise UnsupportedSchemaVersion(declared, SUPPORTED_SCHEMA_VERSIONS)
|
|
3583
|
+
return Contract.model_validate(data)
|
|
3584
|
+
|
|
3585
|
+
|
|
3586
|
+
def upgrade_contract_document(document: dict[str, Any]) -> dict[str, Any]:
|
|
3587
|
+
"""Return a proposed v0.4 document without mutating the caller's object."""
|
|
3588
|
+
|
|
3589
|
+
proposed = deepcopy(document)
|
|
3590
|
+
declared = proposed.get("schema_version", "0.2")
|
|
3591
|
+
if declared not in SUPPORTED_SCHEMA_VERSIONS:
|
|
3592
|
+
raise UnsupportedSchemaVersion(declared, SUPPORTED_SCHEMA_VERSIONS)
|
|
3593
|
+
if declared in {"0.2", "0.3"} and ("structural" in proposed or "simulation" in proposed):
|
|
3594
|
+
raise ValueError("legacy contract must not contain structural or simulation v1 sections")
|
|
3595
|
+
if declared in {"0.2", "0.3"} and proposed.get("actuator_cases"):
|
|
3596
|
+
raise ValueError("legacy contract must not contain actuator_cases")
|
|
3597
|
+
proposed["schema_version"] = "0.4"
|
|
3598
|
+
return proposed
|
|
3599
|
+
|
|
3600
|
+
|
|
3601
|
+
def upgrade_contract_text(text: str) -> str:
|
|
3602
|
+
"""Pure in-memory preview of the explicit v0.4 upgrade."""
|
|
3603
|
+
|
|
3604
|
+
data = _load_yaml_guarded(_yaml(), StringIO(text))
|
|
3605
|
+
_reject_yaml_anchors_aliases(data)
|
|
3606
|
+
if not isinstance(data, dict):
|
|
3607
|
+
raise ValueError("contract document must be a mapping")
|
|
3608
|
+
output = StringIO()
|
|
3609
|
+
_yaml().dump(upgrade_contract_document(data), output)
|
|
3610
|
+
return output.getvalue()
|
|
3611
|
+
|
|
3612
|
+
|
|
3613
|
+
def save_contract(contract: Contract, path: Path) -> None:
|
|
3614
|
+
data = contract.model_dump(mode="json", exclude_none=True)
|
|
3615
|
+
with open(path, "w") as f:
|
|
3616
|
+
_yaml().dump(data, f)
|