lockwire 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lockwire/__init__.py +9 -0
- lockwire/__main__.py +9 -0
- lockwire/advice.py +119 -0
- lockwire/api.py +1216 -0
- lockwire/api_payload.py +985 -0
- lockwire/bench.py +182 -0
- lockwire/blame.py +100 -0
- lockwire/catalog.py +100 -0
- lockwire/catalog_seed.yaml +545 -0
- lockwire/checks.py +3483 -0
- lockwire/cli.py +1799 -0
- lockwire/cli_line.py +73 -0
- lockwire/constraints.py +313 -0
- lockwire/copy.py +302 -0
- lockwire/describe.py +682 -0
- lockwire/edit.py +2098 -0
- lockwire/emit_ts.py +150 -0
- lockwire/export_mhs.py +115 -0
- lockwire/factdiff.py +254 -0
- lockwire/forces.py +334 -0
- lockwire/gitref.py +188 -0
- lockwire/hw.py +388 -0
- lockwire/mcp_server.py +276 -0
- lockwire/mechanism.py +379 -0
- lockwire/mesh_dae.py +555 -0
- lockwire/mesh_simple.py +198 -0
- lockwire/page.py +1485 -0
- lockwire/plan.py +849 -0
- lockwire/plan_payload.py +151 -0
- lockwire/plugins.py +70 -0
- lockwire/report.py +848 -0
- lockwire/report_html.py +707 -0
- lockwire/report_json.py +168 -0
- lockwire/review_draft.py +315 -0
- lockwire/scan.py +732 -0
- lockwire/scene.py +535 -0
- lockwire/scene_import.py +309 -0
- lockwire/scene_urdf.py +750 -0
- lockwire/scene_view.py +376 -0
- lockwire/schema.py +3616 -0
- lockwire/sdf.py +240 -0
- lockwire/serve.py +1256 -0
- lockwire/start_operation.py +92 -0
- lockwire/statics.py +77 -0
- lockwire/units.py +115 -0
- lockwire/urdf.py +348 -0
- lockwire/vcs.py +402 -0
- lockwire/whatif.py +161 -0
- lockwire-0.2.0.dist-info/METADATA +192 -0
- lockwire-0.2.0.dist-info/RECORD +52 -0
- lockwire-0.2.0.dist-info/WHEEL +4 -0
- lockwire-0.2.0.dist-info/entry_points.txt +3 -0
lockwire/describe.py
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
"""Per-device reference file, generated from the lockfile.
|
|
2
|
+
|
|
3
|
+
`lockwire describe` renders one plain-Markdown page per device: what it
|
|
4
|
+
measures, what it adjusts, what limits are enforced, and where each number
|
|
5
|
+
comes from. Every line is read from a schema field that already exists in the
|
|
6
|
+
contract — nothing here is inferred, predicted, or guessed. A field the
|
|
7
|
+
contract does not declare renders as "nothing declared", never a blank or a
|
|
8
|
+
made-up value.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from lockwire.checks import compute_system_cg, unknown_draw_message
|
|
14
|
+
from lockwire.copy import human_name
|
|
15
|
+
from lockwire.forces import force_document, render_markdown
|
|
16
|
+
from lockwire.report import format_magnitude
|
|
17
|
+
from lockwire.schema import (
|
|
18
|
+
Band,
|
|
19
|
+
Contract,
|
|
20
|
+
DeviceInstance,
|
|
21
|
+
DeviceModel,
|
|
22
|
+
Joint,
|
|
23
|
+
JointLimits,
|
|
24
|
+
Provenance,
|
|
25
|
+
Rail,
|
|
26
|
+
Tolerance,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_JOINT_UNITS = {
|
|
30
|
+
"prismatic": ("m", "N", "m/s"),
|
|
31
|
+
}
|
|
32
|
+
_DEFAULT_JOINT_UNITS = ("rad", "N*m", "rad/s")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _joint_units(joint_type: str) -> tuple[str, str, str]:
|
|
36
|
+
"""(position unit, effort unit, velocity unit) for a joint's kinematic type."""
|
|
37
|
+
return _JOINT_UNITS.get(joint_type, _DEFAULT_JOINT_UNITS)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _provenance_suffix(provenance: Provenance | None) -> str:
|
|
41
|
+
if provenance is None:
|
|
42
|
+
return ""
|
|
43
|
+
parts = [f"source: {provenance.source}", f"locked_at: {provenance.locked_at}"]
|
|
44
|
+
if provenance.witness:
|
|
45
|
+
parts.insert(0, f"witness: {provenance.witness}")
|
|
46
|
+
if provenance.bag_sha256 is not None:
|
|
47
|
+
parts.append(f"bag_sha256: {provenance.bag_sha256}")
|
|
48
|
+
return " (" + ", ".join(parts) + ")"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _provenance_block(label: str, provenance: Provenance | None) -> list[str]:
|
|
52
|
+
if provenance is None:
|
|
53
|
+
return []
|
|
54
|
+
lines = [
|
|
55
|
+
f"- {label}:",
|
|
56
|
+
f" - witness: {provenance.witness}",
|
|
57
|
+
f" - source: {provenance.source}",
|
|
58
|
+
]
|
|
59
|
+
lines.append(f" - locked_at: {provenance.locked_at}")
|
|
60
|
+
if provenance.bag_sha256 is not None:
|
|
61
|
+
lines.append(f" - bag_sha256: {provenance.bag_sha256}")
|
|
62
|
+
if provenance.compared_from is not None:
|
|
63
|
+
lines.append(f" - compared_from: {provenance.compared_from}")
|
|
64
|
+
if provenance.note:
|
|
65
|
+
lines.append(f" - note: {provenance.note}")
|
|
66
|
+
return lines
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _role(model: DeviceModel | None, instance: DeviceInstance) -> str:
|
|
70
|
+
adjusts = (
|
|
71
|
+
model is not None and model.actuator is not None
|
|
72
|
+
) or instance.drives_joint is not None
|
|
73
|
+
measures = instance.driver is not None and bool(instance.driver.signals)
|
|
74
|
+
if adjusts and measures:
|
|
75
|
+
return "measures and adjusts"
|
|
76
|
+
if adjusts:
|
|
77
|
+
return "adjusts"
|
|
78
|
+
if measures:
|
|
79
|
+
return "measures"
|
|
80
|
+
return "component"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _find_joint(contract: Contract, name: str) -> Joint | None:
|
|
84
|
+
if contract.model is None:
|
|
85
|
+
return None
|
|
86
|
+
return next((j for j in contract.model.joints if j.name == name), None)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _joint_limit_lines(joint: Joint) -> list[str]:
|
|
90
|
+
limits: JointLimits | None = joint.limits
|
|
91
|
+
if limits is None:
|
|
92
|
+
return [f"- joint {joint.name} ({joint.type}): no limits declared"]
|
|
93
|
+
pos_unit, effort_unit, vel_unit = _joint_units(joint.type)
|
|
94
|
+
lines = [f"- joint {joint.name} ({joint.type}):"]
|
|
95
|
+
if limits.lower is not None or limits.upper is not None:
|
|
96
|
+
lo = "?" if limits.lower is None else f"{limits.lower:g} {pos_unit}"
|
|
97
|
+
hi = "?" if limits.upper is None else f"{limits.upper:g} {pos_unit}"
|
|
98
|
+
lines.append(f" - range: [{lo}, {hi}]")
|
|
99
|
+
for name in ("lower", "upper"):
|
|
100
|
+
value = getattr(limits, f"{name}_enforced_by")
|
|
101
|
+
if value is not None:
|
|
102
|
+
lines.append(f" - {name} {_enforcement_detail(value)}")
|
|
103
|
+
if limits.effort is not None:
|
|
104
|
+
if limits.effort_limit is not None:
|
|
105
|
+
shown = f"{limits.effort:g} {effort_unit}"
|
|
106
|
+
else:
|
|
107
|
+
shown = "unset (0)" if limits.effort == 0 else f"invalid (negative) {limits.effort:g}"
|
|
108
|
+
lines.append(f" - effort limit: {shown}")
|
|
109
|
+
if limits.effort_enforced_by is not None:
|
|
110
|
+
lines.append(f" - effort {_enforcement_detail(limits.effort_enforced_by)}")
|
|
111
|
+
if limits.velocity is not None:
|
|
112
|
+
lines.append(f" - velocity limit: {limits.velocity:g} {vel_unit}")
|
|
113
|
+
if limits.velocity_enforced_by is not None:
|
|
114
|
+
lines.append(f" - velocity {_enforcement_detail(limits.velocity_enforced_by)}")
|
|
115
|
+
return lines
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _enforcement_detail(value: str) -> str:
|
|
119
|
+
return (
|
|
120
|
+
f"declared enforcement provenance: {value} "
|
|
121
|
+
"(metadata only; does not prove physical protection)"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _measures_section(instance: DeviceInstance) -> list[str]:
|
|
126
|
+
driver = instance.driver
|
|
127
|
+
if driver is None or not driver.signals:
|
|
128
|
+
return ["Measures: nothing declared."]
|
|
129
|
+
lines = ["| signal | field | unit |", "| --- | --- | --- |"]
|
|
130
|
+
for name, source in sorted(driver.signals.items()):
|
|
131
|
+
lines.append(f"| {name} | {source.field} | {source.unit} |")
|
|
132
|
+
if driver.topic is not None:
|
|
133
|
+
lines.append(f"- topic: {driver.topic}")
|
|
134
|
+
if driver.msg_type is not None:
|
|
135
|
+
lines.append(f"- msg_type: {driver.msg_type}")
|
|
136
|
+
if driver.nominal_rate_hz is not None:
|
|
137
|
+
lines.append(f"- nominal_rate_hz: {driver.nominal_rate_hz:g}")
|
|
138
|
+
if driver.payload_bytes is not None:
|
|
139
|
+
lines.append(f"- payload_bytes: {driver.payload_bytes}")
|
|
140
|
+
return lines
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _adjusts_section(
|
|
144
|
+
contract: Contract, model: DeviceModel | None, instance: DeviceInstance
|
|
145
|
+
) -> list[str]:
|
|
146
|
+
lines: list[str] = []
|
|
147
|
+
actuator = model.actuator if model is not None else None
|
|
148
|
+
if actuator is not None:
|
|
149
|
+
if actuator.gear_ratio is not None:
|
|
150
|
+
lines.append(f"- gear_ratio: {actuator.gear_ratio:g}")
|
|
151
|
+
if actuator.torque_constant is not None:
|
|
152
|
+
lines.append(f"- torque_constant: {actuator.torque_constant:g} N*m/A")
|
|
153
|
+
if actuator.torque_continuous is not None:
|
|
154
|
+
lines.append(f"- torque_continuous: {actuator.torque_continuous:g} N*m")
|
|
155
|
+
if actuator.torque_peak is not None:
|
|
156
|
+
lines.append(f"- torque_peak: {actuator.torque_peak:g} N*m")
|
|
157
|
+
if actuator.encoder_cpr is not None:
|
|
158
|
+
lines.append(f"- encoder_cpr: {actuator.encoder_cpr}")
|
|
159
|
+
if instance.drives_joint is not None:
|
|
160
|
+
joint = _find_joint(contract, instance.drives_joint)
|
|
161
|
+
if joint is None:
|
|
162
|
+
lines.append(f"- drives_joint: {instance.drives_joint} (not found in model record)")
|
|
163
|
+
else:
|
|
164
|
+
lines.extend(_joint_limit_lines(joint))
|
|
165
|
+
if not lines:
|
|
166
|
+
return ["Adjusts: nothing declared."]
|
|
167
|
+
return lines
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _limits_section(
|
|
171
|
+
contract: Contract, model: DeviceModel | None, instance: DeviceInstance
|
|
172
|
+
) -> list[str]:
|
|
173
|
+
lines: list[str] = []
|
|
174
|
+
model_prov_suffix = _provenance_suffix(model.provenance) if model is not None else ""
|
|
175
|
+
|
|
176
|
+
def band_details(band: Band) -> str:
|
|
177
|
+
return f", declared enforcement: {band.enforced_by}" if band.enforced_by else ""
|
|
178
|
+
|
|
179
|
+
if model is not None and model.hard_limits:
|
|
180
|
+
for channel, band in sorted(model.hard_limits.items()):
|
|
181
|
+
# ``str(Band)`` already carries the declared `why`.
|
|
182
|
+
rate = f", max rate {band.max_rate:g} {band.unit}/s" if band.max_rate else ""
|
|
183
|
+
lines.append(
|
|
184
|
+
f"- declared datasheet/model rating {channel}: "
|
|
185
|
+
f"{band}{rate}{band_details(band)}{model_prov_suffix}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if model is not None and model.input_voltage_range is not None:
|
|
189
|
+
rng = model.input_voltage_range
|
|
190
|
+
lines.append(
|
|
191
|
+
"- input_voltage_range: "
|
|
192
|
+
f"[{format_magnitude(rng.min.value, rng.min.unit)}, "
|
|
193
|
+
f"{format_magnitude(rng.max.value, rng.max.unit)}]{model_prov_suffix}"
|
|
194
|
+
)
|
|
195
|
+
for name in ("min", "max"):
|
|
196
|
+
value = getattr(rng, f"{name}_enforced_by")
|
|
197
|
+
if value is not None:
|
|
198
|
+
lines.append(f" - input_voltage_range {name} {_enforcement_detail(value)}")
|
|
199
|
+
|
|
200
|
+
if model is not None and model.thermal_limit is not None:
|
|
201
|
+
tl = model.thermal_limit
|
|
202
|
+
lines.append(
|
|
203
|
+
f"- thermal_limit ({tl.kind}): {format_magnitude(tl.temperature.value, tl.temperature.unit)}"
|
|
204
|
+
f"{_provenance_suffix(tl.provenance)}"
|
|
205
|
+
)
|
|
206
|
+
if tl.temperature_enforced_by is not None:
|
|
207
|
+
lines.append(
|
|
208
|
+
f" - thermal_limit temperature {_enforcement_detail(tl.temperature_enforced_by)}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if instance.power is not None:
|
|
212
|
+
power = instance.power
|
|
213
|
+
if power.draw is not None:
|
|
214
|
+
lines.append(
|
|
215
|
+
f"- power draw: {format_magnitude(power.draw.value, power.draw.unit)} on {power.rail}"
|
|
216
|
+
)
|
|
217
|
+
else:
|
|
218
|
+
lines.append(f"- power draw: {unknown_draw_message(instance.id, power.rail)}")
|
|
219
|
+
for mode in power.modes:
|
|
220
|
+
lines.append(
|
|
221
|
+
f" - mode {mode.name}: {format_magnitude(mode.draw.value, mode.draw.unit)}"
|
|
222
|
+
)
|
|
223
|
+
if power.choices is not None:
|
|
224
|
+
lines.append(f" - allowed modes: {', '.join(power.choices)}")
|
|
225
|
+
rail = contract.rail(power.rail)
|
|
226
|
+
if rail is not None:
|
|
227
|
+
voltage = (
|
|
228
|
+
f"at {format_magnitude(rail.voltage.value, rail.voltage.unit)}"
|
|
229
|
+
if rail.voltage is not None
|
|
230
|
+
else "at an undeclared voltage"
|
|
231
|
+
)
|
|
232
|
+
lines.append(
|
|
233
|
+
f" - rail {rail.id} capacity: {format_magnitude(rail.capacity.value, rail.capacity.unit)}"
|
|
234
|
+
f" {voltage}{_provenance_suffix(rail.provenance)}"
|
|
235
|
+
)
|
|
236
|
+
if rail.capacity_enforced_by is not None:
|
|
237
|
+
lines.append(
|
|
238
|
+
f" - rail {rail.id} capacity {_enforcement_detail(rail.capacity_enforced_by)}"
|
|
239
|
+
)
|
|
240
|
+
if rail.voltage_range is not None:
|
|
241
|
+
lines.append(
|
|
242
|
+
f" - rail {rail.id} voltage range: "
|
|
243
|
+
f"[{format_magnitude(rail.voltage_range.min.value, rail.voltage_range.min.unit)}, "
|
|
244
|
+
f"{format_magnitude(rail.voltage_range.max.value, rail.voltage_range.max.unit)}]"
|
|
245
|
+
)
|
|
246
|
+
if rail.current_limit is not None:
|
|
247
|
+
lines.append(
|
|
248
|
+
f" - rail {rail.id} current limit: "
|
|
249
|
+
f"{format_magnitude(rail.current_limit.value, rail.current_limit.unit)}"
|
|
250
|
+
)
|
|
251
|
+
if rail.current_limit_enforced_by is not None:
|
|
252
|
+
lines.append(
|
|
253
|
+
f" - rail {rail.id} current_limit "
|
|
254
|
+
f"{_enforcement_detail(rail.current_limit_enforced_by)}"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if instance.link_budget is not None:
|
|
258
|
+
lb = instance.link_budget
|
|
259
|
+
lines.append(
|
|
260
|
+
f"- link_budget required_range: {format_magnitude(lb.required_range.value, lb.required_range.unit)}"
|
|
261
|
+
)
|
|
262
|
+
lines.append(
|
|
263
|
+
f"- link_budget fade_margin_required: "
|
|
264
|
+
f"{format_magnitude(lb.fade_margin_required.value, lb.fade_margin_required.unit)}"
|
|
265
|
+
)
|
|
266
|
+
if lb.fade_margin_required_enforced_by is not None:
|
|
267
|
+
lines.append(
|
|
268
|
+
f" - link_budget fade_margin_required "
|
|
269
|
+
f"{_enforcement_detail(lb.fade_margin_required_enforced_by)}"
|
|
270
|
+
)
|
|
271
|
+
lines.append(
|
|
272
|
+
f"- link_budget installation_loss: "
|
|
273
|
+
f"{format_magnitude(lb.installation_loss.value, lb.installation_loss.unit)}"
|
|
274
|
+
)
|
|
275
|
+
if lb.carrier_frequency is not None:
|
|
276
|
+
lines.append(
|
|
277
|
+
f"- link_budget carrier_frequency: "
|
|
278
|
+
f"{format_magnitude(lb.carrier_frequency.value, lb.carrier_frequency.unit)}"
|
|
279
|
+
)
|
|
280
|
+
if lb.tx_antenna is not None:
|
|
281
|
+
lines.append(
|
|
282
|
+
f"- link_budget tx_antenna gain: {format_magnitude(lb.tx_antenna.gain.value, lb.tx_antenna.gain.unit)}"
|
|
283
|
+
f"{_provenance_suffix(lb.tx_antenna.provenance)}"
|
|
284
|
+
)
|
|
285
|
+
if lb.rx_antenna is not None:
|
|
286
|
+
lines.append(
|
|
287
|
+
f"- link_budget rx_antenna gain: {format_magnitude(lb.rx_antenna.gain.value, lb.rx_antenna.gain.unit)}"
|
|
288
|
+
f"{_provenance_suffix(lb.rx_antenna.provenance)}"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if instance.thermal is not None:
|
|
292
|
+
th = instance.thermal
|
|
293
|
+
if th.resistance is not None:
|
|
294
|
+
lines.append(
|
|
295
|
+
f"- thermal resistance: {format_magnitude(th.resistance.value, th.resistance.unit)}"
|
|
296
|
+
f"{_provenance_suffix(th.provenance)}"
|
|
297
|
+
)
|
|
298
|
+
if th.ambient is not None:
|
|
299
|
+
lines.append(
|
|
300
|
+
f"- thermal ambient: {format_magnitude(th.ambient.value, th.ambient.unit)}"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
for envelope in instance.envelopes:
|
|
304
|
+
env_prov_suffix = _provenance_suffix(envelope.provenance)
|
|
305
|
+
if envelope.rate_hz is not None:
|
|
306
|
+
lines.append(
|
|
307
|
+
f"- observed envelope [{envelope.context}] rate_hz: "
|
|
308
|
+
f"{envelope.rate_hz}{band_details(envelope.rate_hz)}{env_prov_suffix}"
|
|
309
|
+
)
|
|
310
|
+
for name, signal in sorted(envelope.signals.items()):
|
|
311
|
+
lines.append(
|
|
312
|
+
f"- observed envelope [{envelope.context}] signal {name}: "
|
|
313
|
+
f"{signal.band}{band_details(signal.band)}{env_prov_suffix}"
|
|
314
|
+
)
|
|
315
|
+
for name, band in sorted(envelope.conditions.items()):
|
|
316
|
+
lines.append(
|
|
317
|
+
f"- observed envelope [{envelope.context}] condition {name}: "
|
|
318
|
+
f"{band}{band_details(band)}{env_prov_suffix}"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
if not lines:
|
|
322
|
+
return ["Limits enforced: nothing declared."]
|
|
323
|
+
return lines
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def describe_device(contract: Contract, device: DeviceInstance) -> str:
|
|
327
|
+
"""Render one device's Markdown reference section."""
|
|
328
|
+
model = contract.device_model(device.model)
|
|
329
|
+
family = contract.family(model.family) if model is not None else None
|
|
330
|
+
|
|
331
|
+
lines = [f"# {device.id}", ""]
|
|
332
|
+
lines.append(f"- model: {device.model}")
|
|
333
|
+
if family is not None:
|
|
334
|
+
lines.append(f"- family: {family.id} — {family.name}")
|
|
335
|
+
lines.append(f"- owner: {device.owner}")
|
|
336
|
+
if device.identity is not None:
|
|
337
|
+
lines.append(f"- identity strength: {device.identity.effective_strength()}")
|
|
338
|
+
lines.append(f"- bus: {device.identity.bus_ref}")
|
|
339
|
+
lines.append("")
|
|
340
|
+
|
|
341
|
+
lines.append("## Role")
|
|
342
|
+
lines.append("")
|
|
343
|
+
lines.append(_role(model, device))
|
|
344
|
+
lines.append("")
|
|
345
|
+
|
|
346
|
+
lines.append("## Measures")
|
|
347
|
+
lines.append("")
|
|
348
|
+
lines.extend(_measures_section(device))
|
|
349
|
+
lines.append("")
|
|
350
|
+
|
|
351
|
+
lines.append("## Adjusts")
|
|
352
|
+
lines.append("")
|
|
353
|
+
lines.extend(_adjusts_section(contract, model, device))
|
|
354
|
+
lines.append("")
|
|
355
|
+
|
|
356
|
+
lines.append("## Limits enforced")
|
|
357
|
+
lines.append("")
|
|
358
|
+
lines.extend(_limits_section(contract, model, device))
|
|
359
|
+
lines.append("")
|
|
360
|
+
|
|
361
|
+
lines.append("## Provenance")
|
|
362
|
+
lines.append("")
|
|
363
|
+
prov_lines = _provenance_block("instance", device.provenance) + _provenance_block(
|
|
364
|
+
"model", model.provenance if model is not None else None
|
|
365
|
+
)
|
|
366
|
+
lines.extend(prov_lines if prov_lines else ["Provenance: nothing declared."])
|
|
367
|
+
lines.append("")
|
|
368
|
+
|
|
369
|
+
return "\n".join(lines)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _tolerance_str(tolerance: Tolerance) -> str:
|
|
373
|
+
"""Render whichever of the two exactly-one-required fields is set.
|
|
374
|
+
|
|
375
|
+
Never guess the other one and never default it: a rel_pct
|
|
376
|
+
edge shows its percent, an abs edge shows its magnitude, and nothing here
|
|
377
|
+
falls back to 0 or any other stand-in number for the field that is
|
|
378
|
+
genuinely not this edge's kind of tolerance.
|
|
379
|
+
"""
|
|
380
|
+
if tolerance.rel_pct is not None:
|
|
381
|
+
return f"{tolerance.rel_pct:g}%"
|
|
382
|
+
if tolerance.abs is not None:
|
|
383
|
+
return format_magnitude(tolerance.abs.value, tolerance.abs.unit)
|
|
384
|
+
return "not declared" # unreachable: Tolerance's own validator requires exactly one
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _edges_table(contract: Contract) -> list[str]:
|
|
388
|
+
if not contract.edges:
|
|
389
|
+
return []
|
|
390
|
+
lines = [
|
|
391
|
+
"## Edges",
|
|
392
|
+
"",
|
|
393
|
+
"| quantity | tolerance | files | owner |",
|
|
394
|
+
"| --- | --- | --- | --- |",
|
|
395
|
+
]
|
|
396
|
+
for edge in contract.edges:
|
|
397
|
+
lines.append(
|
|
398
|
+
f"| {edge.quantity} | {_tolerance_str(edge.tolerance)} | "
|
|
399
|
+
f"{', '.join(edge.code)} | {edge.owner} |"
|
|
400
|
+
)
|
|
401
|
+
lines.append("")
|
|
402
|
+
lines.append(
|
|
403
|
+
"_tolerance is scored against a supplied current reading "
|
|
404
|
+
"(`check_edge_tolerance`); the `check` command supplies none today, "
|
|
405
|
+
"so it renders `not_scored` until a caller passes one._"
|
|
406
|
+
)
|
|
407
|
+
lines.append("")
|
|
408
|
+
return lines
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
_JOINT_WORDS = {"revolute": "Rotates", "continuous": "Rotates", "prismatic": "Slides", "fixed": "Fixed"}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _joints_table(contract: Contract, human: bool = False) -> list[str]:
|
|
415
|
+
if contract.model is None or not contract.model.joints:
|
|
416
|
+
return []
|
|
417
|
+
lines = ["## Joints", "", "| name | type | parent | child |", "| --- | --- | --- | --- |"]
|
|
418
|
+
for joint in contract.model.joints:
|
|
419
|
+
if human:
|
|
420
|
+
kind = _JOINT_WORDS.get(str(joint.type), str(joint.type).capitalize())
|
|
421
|
+
cells = (human_name(joint.name), kind, human_name(joint.parent), human_name(joint.child))
|
|
422
|
+
else:
|
|
423
|
+
cells = (joint.name, joint.type, joint.parent, joint.child)
|
|
424
|
+
lines.append("| " + " | ".join(cells) + " |")
|
|
425
|
+
lines.append("")
|
|
426
|
+
return lines
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _rail_voltage_label(rail: Rail) -> str:
|
|
430
|
+
"""an undeclared voltage is painted "unknown" — never blank, never invented."""
|
|
431
|
+
if rail.voltage is None:
|
|
432
|
+
return "unknown"
|
|
433
|
+
return format_magnitude(rail.voltage.value, rail.voltage.unit)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _rail_loads(contract: Contract, rail: Rail, depth: int, human: bool = False) -> list[str]:
|
|
437
|
+
indent = " " * depth
|
|
438
|
+
lines = []
|
|
439
|
+
for device in contract.devices:
|
|
440
|
+
power = device.power
|
|
441
|
+
if power is None or power.rail != rail.id:
|
|
442
|
+
continue
|
|
443
|
+
draw = (
|
|
444
|
+
format_magnitude(power.draw.value, power.draw.unit)
|
|
445
|
+
if power.draw is not None
|
|
446
|
+
else unknown_draw_message(device.id, power.rail)
|
|
447
|
+
)
|
|
448
|
+
lines.append(
|
|
449
|
+
f"{indent}- {human_name(device.id)} · {draw}" if human else f"{indent}- load {device.id}: {draw}"
|
|
450
|
+
)
|
|
451
|
+
return lines
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _rail_subtree(
|
|
455
|
+
contract: Contract, rail: Rail, depth: int, seen: set[str], human: bool = False
|
|
456
|
+
) -> list[str]:
|
|
457
|
+
"""One rail plus its converter edge (if any), its loads, and its children.
|
|
458
|
+
|
|
459
|
+
The converter is its own node, never the rail standing in for
|
|
460
|
+
it. A converter is drawn only where one actually exists — ``upstream ==
|
|
461
|
+
"battery"`` or a parent rail id AND ``efficiency != 1.0``; the fixture
|
|
462
|
+
convention (x500) is that ``efficiency == 1.0`` is a pass-through, not a
|
|
463
|
+
converter, and ``upstream is None`` means no conversion edge was sourced at
|
|
464
|
+
all (``power_rollup`` treats it identically: no efficiency applied).
|
|
465
|
+
"""
|
|
466
|
+
lines: list[str] = []
|
|
467
|
+
if rail.upstream is not None and rail.efficiency != 1.0:
|
|
468
|
+
indent = " " * depth
|
|
469
|
+
lines.append(f"{indent}- converter" if human else f"{indent}- converter (efficiency: {rail.efficiency:g})")
|
|
470
|
+
depth += 1
|
|
471
|
+
indent = " " * depth
|
|
472
|
+
capacity = format_magnitude(rail.capacity.value, rail.capacity.unit)
|
|
473
|
+
if human:
|
|
474
|
+
lines.append(f"{indent}- {human_name(rail.id)} rail ({_rail_voltage_label(rail)}, up to {capacity})")
|
|
475
|
+
else:
|
|
476
|
+
lines.append(f"{indent}- rail {rail.id} ({_rail_voltage_label(rail)}, capacity {capacity})")
|
|
477
|
+
lines.extend(_rail_loads(contract, rail, depth + 1, human))
|
|
478
|
+
for child in contract.topology.rails if contract.topology is not None else []:
|
|
479
|
+
if child.upstream == rail.id and child.id not in seen:
|
|
480
|
+
lines.extend(_rail_subtree(contract, child, depth + 1, seen | {child.id}, human))
|
|
481
|
+
return lines
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def render_power_tree(contract: Contract, human: bool = False) -> list[str]:
|
|
485
|
+
"""The rail roll-up (``Rail.upstream``/``efficiency``, ``checks.power_rollup``)
|
|
486
|
+
rendered as a power tree: source -> converter -> rail -> loads. ZERO NEW
|
|
487
|
+
PHYSICS — this paints what the contract already declares and computes,
|
|
488
|
+
nothing inferred or guessed."""
|
|
489
|
+
topology = contract.topology
|
|
490
|
+
if topology is None or not topology.rails:
|
|
491
|
+
return []
|
|
492
|
+
lines = ["battery"]
|
|
493
|
+
for rail in topology.rails:
|
|
494
|
+
if rail.upstream in (None, "battery"):
|
|
495
|
+
lines.extend(_rail_subtree(contract, rail, 1, {rail.id}, human))
|
|
496
|
+
if not human and any(rail.upstream is not None for rail in topology.rails):
|
|
497
|
+
lines.append(
|
|
498
|
+
"(converters are drawn where the contract declares an upstream and an efficiency below 1; "
|
|
499
|
+
"a converter declared any other way is not shown)"
|
|
500
|
+
)
|
|
501
|
+
return lines
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def describe_system(contract: Contract, human: bool = False) -> str:
|
|
505
|
+
"""Render the system-level Markdown reference: topology, budgets, joints."""
|
|
506
|
+
robot = contract.robot
|
|
507
|
+
lines = [f"# {robot.name}", ""]
|
|
508
|
+
lines.append(f"- schema_version: {contract.schema_version}")
|
|
509
|
+
if robot.id is not None:
|
|
510
|
+
lines.append(f"- id: {robot.id}")
|
|
511
|
+
lines.append("")
|
|
512
|
+
|
|
513
|
+
topology = contract.topology
|
|
514
|
+
if topology is not None and topology.rails:
|
|
515
|
+
lines.append("## Rails")
|
|
516
|
+
lines.append("")
|
|
517
|
+
if human:
|
|
518
|
+
lines.append("| rail | voltage | voltage range | capacity | current limit | fed from | charged by |")
|
|
519
|
+
lines.append("| --- | --- | --- | --- | --- | --- | --- |")
|
|
520
|
+
else:
|
|
521
|
+
lines.append(
|
|
522
|
+
"| id | voltage | voltage_range | capacity | current_limit | upstream | efficiency | charged_by |"
|
|
523
|
+
)
|
|
524
|
+
lines.append("| --- | --- | --- | --- | --- | --- | --- | --- |")
|
|
525
|
+
for rail in topology.rails:
|
|
526
|
+
voltage = (
|
|
527
|
+
format_magnitude(rail.voltage.value, rail.voltage.unit)
|
|
528
|
+
if rail.voltage is not None
|
|
529
|
+
else "unknown"
|
|
530
|
+
)
|
|
531
|
+
voltage_range = (
|
|
532
|
+
f"[{format_magnitude(rail.voltage_range.min.value, rail.voltage_range.min.unit)}, "
|
|
533
|
+
f"{format_magnitude(rail.voltage_range.max.value, rail.voltage_range.max.unit)}]"
|
|
534
|
+
if rail.voltage_range is not None
|
|
535
|
+
else "-"
|
|
536
|
+
)
|
|
537
|
+
current_limit = (
|
|
538
|
+
format_magnitude(rail.current_limit.value, rail.current_limit.unit)
|
|
539
|
+
if rail.current_limit is not None
|
|
540
|
+
else "-"
|
|
541
|
+
)
|
|
542
|
+
capacity = format_magnitude(rail.capacity.value, rail.capacity.unit)
|
|
543
|
+
if rail.capacity_enforced_by is not None:
|
|
544
|
+
capacity += f" ({_enforcement_detail(rail.capacity_enforced_by)})"
|
|
545
|
+
if rail.current_limit_enforced_by is not None:
|
|
546
|
+
current_limit += f" ({_enforcement_detail(rail.current_limit_enforced_by)})"
|
|
547
|
+
if human:
|
|
548
|
+
lines.append(
|
|
549
|
+
f"| {human_name(rail.id)} | {voltage} | {voltage_range} | {capacity} | {current_limit} | "
|
|
550
|
+
f"{human_name(rail.upstream) if rail.upstream else '-'} | "
|
|
551
|
+
f"{human_name(rail.charged_by) if rail.charged_by else '-'} |"
|
|
552
|
+
)
|
|
553
|
+
continue
|
|
554
|
+
lines.append(
|
|
555
|
+
f"| {rail.id} | {voltage} | {voltage_range} | "
|
|
556
|
+
f"{capacity} | {current_limit} | "
|
|
557
|
+
f"{rail.upstream or '-'} | {rail.efficiency:g} | {rail.charged_by or '-'} |"
|
|
558
|
+
)
|
|
559
|
+
lines.append("")
|
|
560
|
+
|
|
561
|
+
tree = render_power_tree(contract, human)
|
|
562
|
+
if tree:
|
|
563
|
+
lines.append("## Power tree")
|
|
564
|
+
lines.append("")
|
|
565
|
+
lines.extend(tree)
|
|
566
|
+
lines.append("")
|
|
567
|
+
|
|
568
|
+
if topology is not None and topology.buses:
|
|
569
|
+
lines.append("## Buses")
|
|
570
|
+
lines.append("")
|
|
571
|
+
lines.append("| id | kind | bitrate |")
|
|
572
|
+
lines.append("| --- | --- | --- |")
|
|
573
|
+
for bus in topology.buses:
|
|
574
|
+
bitrate = "-" if bus.bitrate is None else f"{bus.bitrate:g}"
|
|
575
|
+
lines.append(f"| {bus.id} | {bus.kind} | {bitrate} |")
|
|
576
|
+
lines.append("")
|
|
577
|
+
|
|
578
|
+
if topology is not None and topology.battery is not None:
|
|
579
|
+
battery = topology.battery
|
|
580
|
+
lines.append("## Battery")
|
|
581
|
+
lines.append("")
|
|
582
|
+
lines.append(
|
|
583
|
+
f"- capacity: {format_magnitude(battery.capacity.value, battery.capacity.unit)}"
|
|
584
|
+
)
|
|
585
|
+
if battery.voltage is not None:
|
|
586
|
+
lines.append(
|
|
587
|
+
f"- voltage: {format_magnitude(battery.voltage.value, battery.voltage.unit)}"
|
|
588
|
+
)
|
|
589
|
+
lines.append("")
|
|
590
|
+
|
|
591
|
+
if topology is not None and topology.wires:
|
|
592
|
+
lines.append("## Wires")
|
|
593
|
+
lines.append("")
|
|
594
|
+
lines.append("| id | awg | installation | rail |")
|
|
595
|
+
lines.append("| --- | --- | --- | --- |")
|
|
596
|
+
for wire in topology.wires:
|
|
597
|
+
lines.append(f"| {wire.id} | {wire.awg} | {wire.installation} | {wire.rail} |")
|
|
598
|
+
lines.append("")
|
|
599
|
+
|
|
600
|
+
lines.extend(_edges_table(contract))
|
|
601
|
+
|
|
602
|
+
if contract.budgets:
|
|
603
|
+
lines.append("## Budgets")
|
|
604
|
+
lines.append("")
|
|
605
|
+
lines.append("| id | kind | limit |")
|
|
606
|
+
lines.append("| --- | --- | --- |")
|
|
607
|
+
for budget in contract.budgets:
|
|
608
|
+
limit = format_magnitude(budget.limit.value, budget.limit.unit)
|
|
609
|
+
if budget.limit_enforced_by is not None:
|
|
610
|
+
limit += f" ({_enforcement_detail(budget.limit_enforced_by)})"
|
|
611
|
+
lines.append(f"| {budget.id} | {budget.kind} | {limit} |")
|
|
612
|
+
lines.append("")
|
|
613
|
+
|
|
614
|
+
lines.extend(_joints_table(contract, human))
|
|
615
|
+
|
|
616
|
+
if contract.actuator_cases:
|
|
617
|
+
lines.append("## Actuator cases")
|
|
618
|
+
lines.append("")
|
|
619
|
+
lines.append(
|
|
620
|
+
"| id | actuator device | joint | rail | DC delivery | feedback loss max | model input manifest | clock mappings |"
|
|
621
|
+
)
|
|
622
|
+
lines.append("| --- | --- | --- | --- | --- | --- | --- | --- |")
|
|
623
|
+
for case in contract.actuator_cases:
|
|
624
|
+
mappings = ", ".join(
|
|
625
|
+
f"{ref.path} ({ref.sha256})" for ref in case.clock_mapping_artifacts
|
|
626
|
+
)
|
|
627
|
+
if case.dc_delivery is None:
|
|
628
|
+
dc_delivery = "not declared"
|
|
629
|
+
else:
|
|
630
|
+
target = case.dc_delivery
|
|
631
|
+
artifacts = ", ".join(
|
|
632
|
+
f"{ref.path} ({ref.sha256})" for ref in target.provenance_artifacts
|
|
633
|
+
)
|
|
634
|
+
dc_delivery = (
|
|
635
|
+
f"{target.applicability}; condition {target.condition_id}; "
|
|
636
|
+
f"{target.source_node} -> {target.receiver_node} -> {target.return_node}; "
|
|
637
|
+
f"source >= {target.source_voltage_min}; peak <= {target.peak_current_max}; "
|
|
638
|
+
f"Rmax source/out/return/contact {target.source_resistance_max}/"
|
|
639
|
+
f"{target.outbound_resistance_max}/{target.return_resistance_max}/"
|
|
640
|
+
f"{target.contact_resistance_max}; {artifacts}"
|
|
641
|
+
)
|
|
642
|
+
lines.append(
|
|
643
|
+
f"| {case.id} | {case.actuator_device} | {case.joint} | {case.rail} | "
|
|
644
|
+
f"{dc_delivery} | "
|
|
645
|
+
f"{case.feedback_loss_max if case.feedback_loss_max is not None else 'not declared'} | "
|
|
646
|
+
f"{case.model_input_manifest.path} ({case.model_input_manifest.sha256}) | "
|
|
647
|
+
f"{mappings} |"
|
|
648
|
+
)
|
|
649
|
+
lines.append("")
|
|
650
|
+
|
|
651
|
+
# the same force numbers the check document carries, rendered from that
|
|
652
|
+
# same payload so the two faces cannot print different figures — or a figure
|
|
653
|
+
# without its kind.
|
|
654
|
+
lines.extend(render_markdown(force_document(contract, compute_system_cg(contract))))
|
|
655
|
+
|
|
656
|
+
total = len(contract.devices)
|
|
657
|
+
measures = adjusts = component = 0
|
|
658
|
+
for device in contract.devices:
|
|
659
|
+
model = contract.device_model(device.model)
|
|
660
|
+
role = _role(model, device)
|
|
661
|
+
if role == "measures and adjusts":
|
|
662
|
+
measures += 1
|
|
663
|
+
adjusts += 1
|
|
664
|
+
elif role == "measures":
|
|
665
|
+
measures += 1
|
|
666
|
+
elif role == "adjusts":
|
|
667
|
+
adjusts += 1
|
|
668
|
+
else:
|
|
669
|
+
component += 1
|
|
670
|
+
lines.append(
|
|
671
|
+
f"{total} device(s): {measures} measure, {adjusts} adjust, {component} component-only."
|
|
672
|
+
)
|
|
673
|
+
lines.append("")
|
|
674
|
+
|
|
675
|
+
return "\n".join(lines)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def describe_all(contract: Contract, human: bool = False) -> dict[str, str]:
|
|
679
|
+
"""Filename -> Markdown for every device (ids are unique, see Contract._unique_ids), plus SYSTEM.md."""
|
|
680
|
+
result = {f"{device.id}.md": describe_device(contract, device) for device in contract.devices}
|
|
681
|
+
result["SYSTEM.md"] = describe_system(contract, human)
|
|
682
|
+
return result
|