lockwire 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. lockwire/__init__.py +9 -0
  2. lockwire/__main__.py +9 -0
  3. lockwire/advice.py +119 -0
  4. lockwire/api.py +1216 -0
  5. lockwire/api_payload.py +985 -0
  6. lockwire/bench.py +182 -0
  7. lockwire/blame.py +100 -0
  8. lockwire/catalog.py +100 -0
  9. lockwire/catalog_seed.yaml +545 -0
  10. lockwire/checks.py +3483 -0
  11. lockwire/cli.py +1799 -0
  12. lockwire/cli_line.py +73 -0
  13. lockwire/constraints.py +313 -0
  14. lockwire/copy.py +302 -0
  15. lockwire/describe.py +682 -0
  16. lockwire/edit.py +2098 -0
  17. lockwire/emit_ts.py +150 -0
  18. lockwire/export_mhs.py +115 -0
  19. lockwire/factdiff.py +254 -0
  20. lockwire/forces.py +334 -0
  21. lockwire/gitref.py +188 -0
  22. lockwire/hw.py +388 -0
  23. lockwire/mcp_server.py +276 -0
  24. lockwire/mechanism.py +379 -0
  25. lockwire/mesh_dae.py +555 -0
  26. lockwire/mesh_simple.py +198 -0
  27. lockwire/page.py +1485 -0
  28. lockwire/plan.py +849 -0
  29. lockwire/plan_payload.py +151 -0
  30. lockwire/plugins.py +70 -0
  31. lockwire/report.py +848 -0
  32. lockwire/report_html.py +707 -0
  33. lockwire/report_json.py +168 -0
  34. lockwire/review_draft.py +315 -0
  35. lockwire/scan.py +732 -0
  36. lockwire/scene.py +535 -0
  37. lockwire/scene_import.py +309 -0
  38. lockwire/scene_urdf.py +750 -0
  39. lockwire/scene_view.py +376 -0
  40. lockwire/schema.py +3616 -0
  41. lockwire/sdf.py +240 -0
  42. lockwire/serve.py +1256 -0
  43. lockwire/start_operation.py +92 -0
  44. lockwire/statics.py +77 -0
  45. lockwire/units.py +115 -0
  46. lockwire/urdf.py +348 -0
  47. lockwire/vcs.py +402 -0
  48. lockwire/whatif.py +161 -0
  49. lockwire-0.2.0.dist-info/METADATA +192 -0
  50. lockwire-0.2.0.dist-info/RECORD +52 -0
  51. lockwire-0.2.0.dist-info/WHEEL +4 -0
  52. lockwire-0.2.0.dist-info/entry_points.txt +3 -0
lockwire/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)