urml-chrono-runtime 0.4.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.
- urml_chrono_runtime/__init__.py +27 -0
- urml_chrono_runtime/_version.py +3 -0
- urml_chrono_runtime/adapter.py +352 -0
- urml_chrono_runtime/config.py +116 -0
- urml_chrono_runtime/py.typed +0 -0
- urml_chrono_runtime/terramechanics.py +223 -0
- urml_chrono_runtime-0.4.0.dist-info/METADATA +110 -0
- urml_chrono_runtime-0.4.0.dist-info/RECORD +9 -0
- urml_chrono_runtime-0.4.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""urml_chrono_runtime — high-fidelity validation runtime for URML.
|
|
2
|
+
|
|
3
|
+
ChronoAdapter (+ ChronoConfig, load_chrono_config)
|
|
4
|
+
Project Chrono multibody / multi-physics via PyChrono. **No ROS 2
|
|
5
|
+
dependency.** Chrono is the high-fidelity *validation* target: a
|
|
6
|
+
URML program is checked statically against the capability manifest
|
|
7
|
+
and safety envelope first, then the same validated intent is driven
|
|
8
|
+
through a short Chrono simulation segment and the dynamics (system
|
|
9
|
+
time, contacts, the commanded driver inputs) come back as evidence.
|
|
10
|
+
``pychrono`` is imported lazily (it ships via conda-forge), so this
|
|
11
|
+
module loads on every host without it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from urml_chrono_runtime._version import __version__
|
|
17
|
+
from urml_chrono_runtime.adapter import ChronoAdapter, ChronoConfig, load_chrono_config
|
|
18
|
+
from urml_chrono_runtime.terramechanics import TerramechanicsParams, TerramechanicsScene
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ChronoAdapter",
|
|
22
|
+
"ChronoConfig",
|
|
23
|
+
"TerramechanicsParams",
|
|
24
|
+
"TerramechanicsScene",
|
|
25
|
+
"__version__",
|
|
26
|
+
"load_chrono_config",
|
|
27
|
+
]
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""ChronoAdapter — Project Chrono multibody physics, via ``pychrono``.
|
|
2
|
+
|
|
3
|
+
Project Chrono is a high-fidelity open multibody and multi-physics
|
|
4
|
+
engine (Chrono::Vehicle ground dynamics, terramechanics for deformable
|
|
5
|
+
terrain, Chrono::Sensor) from the University of Wisconsin-Madison and
|
|
6
|
+
the University of Parma. This adapter mirrors :class:`MujocoAdapter`:
|
|
7
|
+
lazy ``pychrono``, **no ROS 2 dependency**, a lazily-built cached
|
|
8
|
+
``ChSystem``, failures returned not raised.
|
|
9
|
+
|
|
10
|
+
Chrono's distinctive role for URML is **high-fidelity pre-deployment
|
|
11
|
+
validation** (RFC-0328): URML checks a program statically against the
|
|
12
|
+
declared capability and safety envelope first, then drives the same
|
|
13
|
+
validated intent through a short Chrono simulation segment so the
|
|
14
|
+
dynamics come back as evidence the claim holds. URML composes *above*
|
|
15
|
+
Chrono; it does not embed it.
|
|
16
|
+
|
|
17
|
+
## v0.1 method coverage
|
|
18
|
+
|
|
19
|
+
Supported (mapped onto a ``ChSystem`` advanced by driver inputs):
|
|
20
|
+
|
|
21
|
+
- ``move_to`` / ``hover`` → apply the configured driver segment and
|
|
22
|
+
advance the engine ``steps_per_command`` steps (location → driver
|
|
23
|
+
inputs via :class:`ChronoConfig`); ``hover`` is a zero-input hold
|
|
24
|
+
(step in place).
|
|
25
|
+
- ``wait`` → step the engine for the dwell.
|
|
26
|
+
- ``measure`` → return the accumulated dynamics evidence (system time,
|
|
27
|
+
contact count, the last commanded driver inputs).
|
|
28
|
+
- ``wait_for`` → step-then-check (a sim has no external event bus; the
|
|
29
|
+
step advances state and the predicate is evaluated once).
|
|
30
|
+
- ``report`` → structured record to a local sink (no cloud — manifesto).
|
|
31
|
+
- ``scan`` → documented **stub success**, mirroring PX4Adapter.run_scan.
|
|
32
|
+
|
|
33
|
+
The primitive → driver-input altitude follows Project Chrono lead Dan
|
|
34
|
+
Negrut's feedback on issue #746.
|
|
35
|
+
|
|
36
|
+
Not supported by a bare vehicle / terramechanics model (returned, not
|
|
37
|
+
raised): ``grasp`` / ``release``, ``dock``, ``detect``, ``capture``,
|
|
38
|
+
``speak``, ``listen``. Perception primitives want a Chrono::Sensor
|
|
39
|
+
companion (camera / lidar / GPS / IMU); manipulation wants an
|
|
40
|
+
articulated-model companion. The drone trio ``take_off`` / ``land`` /
|
|
41
|
+
``return_to_home`` are ``not_applicable_sim``.
|
|
42
|
+
|
|
43
|
+
The committed unit suite exercises every path against a fake
|
|
44
|
+
``pychrono`` injected into ``sys.modules`` (the technique px4-runtime /
|
|
45
|
+
mujoco-runtime use); a Chrono::Vehicle terramechanics scene driven by a
|
|
46
|
+
real engine is the documented calibration step in
|
|
47
|
+
``chrono-integration.yml``.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
from typing import Any, Literal
|
|
53
|
+
|
|
54
|
+
from urml_ros2_runtime.substrate.base import (
|
|
55
|
+
CaptureResult,
|
|
56
|
+
DetectionResult,
|
|
57
|
+
ListenResult,
|
|
58
|
+
ManipulationResult,
|
|
59
|
+
MeasurementResult,
|
|
60
|
+
NavigationResult,
|
|
61
|
+
ProgramCallResult,
|
|
62
|
+
ScanResult,
|
|
63
|
+
SubstrateResult,
|
|
64
|
+
WaitResult,
|
|
65
|
+
unsupported_program_call,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
from urml_chrono_runtime._version import __version__
|
|
69
|
+
from urml_chrono_runtime.config import ChronoConfig, DriverSegment, load_chrono_config
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
"ChronoAdapter",
|
|
73
|
+
"ChronoConfig",
|
|
74
|
+
"DriverSegment",
|
|
75
|
+
"__version__",
|
|
76
|
+
"load_chrono_config",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
_NOT_SUPPORTED = (
|
|
80
|
+
"not_supported_in_base_sim: a bare Chrono::Vehicle / terramechanics model has no {capability}. "
|
|
81
|
+
"Pair it with a Chrono::Sensor or articulated-model companion; the URML "
|
|
82
|
+
"program, manifest, and validator are unchanged."
|
|
83
|
+
)
|
|
84
|
+
_NOT_APPLICABLE = "not_applicable_sim: {capability} has no meaning for a ground-vehicle physics model."
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _require_pychrono() -> Any:
|
|
88
|
+
"""Lazy-import pychrono with a clear, actionable error when missing."""
|
|
89
|
+
try:
|
|
90
|
+
import pychrono # type: ignore[import-not-found,unused-ignore]
|
|
91
|
+
except ImportError as exc:
|
|
92
|
+
raise RuntimeError(
|
|
93
|
+
"pychrono is not installed. ChronoAdapter requires PyChrono.\n"
|
|
94
|
+
" PyChrono ships via conda-forge, not as a PyPI wheel:\n"
|
|
95
|
+
" conda install -c conda-forge pychrono\n"
|
|
96
|
+
" (the [chrono] extra documents the boundary; pychrono is imported lazily)."
|
|
97
|
+
) from exc
|
|
98
|
+
return pychrono
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ChronoAdapter:
|
|
102
|
+
"""Project Chrono adapter implementing the URML ROSAdapter Protocol."""
|
|
103
|
+
|
|
104
|
+
BRAND = "chrono"
|
|
105
|
+
|
|
106
|
+
def __init__(self, config: ChronoConfig | None = None) -> None:
|
|
107
|
+
self._chrono = _require_pychrono()
|
|
108
|
+
self._config = config or ChronoConfig()
|
|
109
|
+
self._system: Any = None
|
|
110
|
+
self._scene: Any = None # set when config.scene == "terramechanics"
|
|
111
|
+
self._reports: list[dict[str, Any]] = []
|
|
112
|
+
self._last_evidence: dict[str, Any] | None = None
|
|
113
|
+
self._closed = False
|
|
114
|
+
|
|
115
|
+
def _sim(self) -> Any:
|
|
116
|
+
"""Lazily build and cache the ``ChSystem`` (bare or terramechanics scene)."""
|
|
117
|
+
if self._system is not None:
|
|
118
|
+
return self._system
|
|
119
|
+
if self._config.scene == "terramechanics":
|
|
120
|
+
from urml_chrono_runtime.terramechanics import (
|
|
121
|
+
TerramechanicsParams,
|
|
122
|
+
TerramechanicsScene,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
self._scene = TerramechanicsScene(
|
|
126
|
+
self._chrono, self._config.terramechanics or TerramechanicsParams()
|
|
127
|
+
)
|
|
128
|
+
self._system = self._scene.system
|
|
129
|
+
else:
|
|
130
|
+
ctor = self._chrono.ChSystemSMC if self._config.system_type == "SMC" else self._chrono.ChSystemNSC
|
|
131
|
+
self._system = ctor()
|
|
132
|
+
return self._system
|
|
133
|
+
|
|
134
|
+
def _advance(self, steps: int, driver: list[float] | None = None) -> None:
|
|
135
|
+
system = self._sim()
|
|
136
|
+
if self._scene is not None:
|
|
137
|
+
self._scene.advance(self._config.step_size, steps, driver)
|
|
138
|
+
return
|
|
139
|
+
for _ in range(max(1, steps)):
|
|
140
|
+
system.DoStepDynamics(self._config.step_size)
|
|
141
|
+
|
|
142
|
+
def _evidence(self, driver: list[float], steps: int) -> dict[str, Any]:
|
|
143
|
+
"""Read the current ``ChSystem`` dynamics as a validation-evidence dict."""
|
|
144
|
+
system = self._sim()
|
|
145
|
+
ev: dict[str, Any] = {
|
|
146
|
+
"sim_time": float(system.GetChTime()),
|
|
147
|
+
"n_contacts": int(system.GetNcontacts()),
|
|
148
|
+
"steps": int(steps),
|
|
149
|
+
"driver": [float(v) for v in driver],
|
|
150
|
+
"terrain_class": self._config.terrain_fidelity, # RFC-0381 hint, mirrored from the manifest
|
|
151
|
+
"backend": "pychrono",
|
|
152
|
+
}
|
|
153
|
+
if self._scene is not None:
|
|
154
|
+
# Richer terramechanics evidence (sinkage, contact force, tip margin).
|
|
155
|
+
ev.update(self._scene.evidence())
|
|
156
|
+
self._last_evidence = ev
|
|
157
|
+
return ev
|
|
158
|
+
|
|
159
|
+
def close(self) -> None:
|
|
160
|
+
self._closed = True
|
|
161
|
+
|
|
162
|
+
def __enter__(self) -> ChronoAdapter:
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def __exit__(self, *_: object) -> None:
|
|
166
|
+
self.close()
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
# Supported
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def send_navigation_goal(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
location: str | None = None,
|
|
176
|
+
pose: dict[str, float] | None = None,
|
|
177
|
+
frame: str | None = None,
|
|
178
|
+
carrying: dict[str, Any] | None = None,
|
|
179
|
+
speed: float | None = None,
|
|
180
|
+
) -> NavigationResult:
|
|
181
|
+
if location is None and pose is None:
|
|
182
|
+
return NavigationResult(success=False, reason="send_navigation_goal called without location or pose")
|
|
183
|
+
if location is not None:
|
|
184
|
+
seg = self._config.resolve_location(location)
|
|
185
|
+
if seg is None:
|
|
186
|
+
return NavigationResult(
|
|
187
|
+
success=False,
|
|
188
|
+
reason=f"location_not_configured: {location!r} is declared in the "
|
|
189
|
+
"manifest but not mapped to a driver segment in chrono_adapter.yaml.",
|
|
190
|
+
)
|
|
191
|
+
driver = list(seg.driver)
|
|
192
|
+
steps = seg.steps or self._config.steps_per_command
|
|
193
|
+
else:
|
|
194
|
+
driver = [float(v) for v in (pose or {}).values()]
|
|
195
|
+
steps = self._config.steps_per_command
|
|
196
|
+
self._advance(steps, driver)
|
|
197
|
+
ev = self._evidence(driver, steps)
|
|
198
|
+
# final_pose is a flat dict[str, float]: the commanded driver vector as
|
|
199
|
+
# indexed scalars, plus the headline dynamics evidence the run produced.
|
|
200
|
+
final: dict[str, float] = {f"driver{i}": float(v) for i, v in enumerate(driver)}
|
|
201
|
+
final["sim_time"] = float(ev["sim_time"])
|
|
202
|
+
final["n_contacts"] = float(ev["n_contacts"])
|
|
203
|
+
return NavigationResult(success=True, final_pose=final, frame=frame or "vehicle")
|
|
204
|
+
|
|
205
|
+
def wait_passively(self, *, duration_seconds: float) -> SubstrateResult:
|
|
206
|
+
self._advance(self._config.steps_per_command)
|
|
207
|
+
return SubstrateResult(success=True)
|
|
208
|
+
|
|
209
|
+
def take_measurement(self, *, what: str, target: str | None, sensor: str | None) -> MeasurementResult:
|
|
210
|
+
system = self._sim()
|
|
211
|
+
ev = self._last_evidence or {
|
|
212
|
+
"sim_time": float(system.GetChTime()),
|
|
213
|
+
"n_contacts": int(system.GetNcontacts()),
|
|
214
|
+
"steps": 0,
|
|
215
|
+
"driver": [],
|
|
216
|
+
"backend": "pychrono",
|
|
217
|
+
}
|
|
218
|
+
payload: dict[str, Any] = {
|
|
219
|
+
"value": ev["n_contacts"],
|
|
220
|
+
"unit": "contacts",
|
|
221
|
+
"timestamp": ev["sim_time"],
|
|
222
|
+
"what": what,
|
|
223
|
+
**ev,
|
|
224
|
+
}
|
|
225
|
+
return MeasurementResult(success=True, payload=payload)
|
|
226
|
+
|
|
227
|
+
def wait_for_condition(
|
|
228
|
+
self,
|
|
229
|
+
*,
|
|
230
|
+
kind: Literal["event", "signal", "input", "sensor_threshold"],
|
|
231
|
+
name: str | None,
|
|
232
|
+
input_mode: str | None,
|
|
233
|
+
threshold: dict[str, Any] | None,
|
|
234
|
+
timeout_seconds: float | None,
|
|
235
|
+
) -> WaitResult:
|
|
236
|
+
self._advance(self._config.steps_per_command)
|
|
237
|
+
return WaitResult(success=True, timed_out=False, payload=None)
|
|
238
|
+
|
|
239
|
+
def emit_report(
|
|
240
|
+
self,
|
|
241
|
+
*,
|
|
242
|
+
to: str,
|
|
243
|
+
facts: dict[str, Any],
|
|
244
|
+
attachments: list[str] | None,
|
|
245
|
+
status: Literal["success", "partial", "failure"],
|
|
246
|
+
severity: Literal["info", "notice", "warning", "error"],
|
|
247
|
+
) -> SubstrateResult:
|
|
248
|
+
self._reports.append({"to": to, "status": status, "severity": severity, "facts": facts})
|
|
249
|
+
return SubstrateResult(success=True)
|
|
250
|
+
|
|
251
|
+
def run_scan(
|
|
252
|
+
self,
|
|
253
|
+
*,
|
|
254
|
+
area: dict[str, Any],
|
|
255
|
+
pattern: Literal["serpentine", "spiral", "grid", "adaptive"],
|
|
256
|
+
overlap: float,
|
|
257
|
+
altitude: float | None,
|
|
258
|
+
media: Literal["photo", "video", "sensor_only"],
|
|
259
|
+
sensor: str | None,
|
|
260
|
+
) -> ScanResult:
|
|
261
|
+
return ScanResult(
|
|
262
|
+
success=True,
|
|
263
|
+
payload={"samples": [], "coverage": 0.0, "anomalies": [], "_note": "v0.1 ChronoAdapter scan: stub."},
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# ------------------------------------------------------------------
|
|
267
|
+
# Not supported by a bare vehicle / terramechanics model
|
|
268
|
+
# ------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def send_manipulation_goal(
|
|
271
|
+
self,
|
|
272
|
+
*,
|
|
273
|
+
action: Literal["grasp", "release"],
|
|
274
|
+
target: dict[str, Any] | None = None,
|
|
275
|
+
force_n: float | None = None,
|
|
276
|
+
approach: Literal["top", "side", "front", "auto"] = "auto",
|
|
277
|
+
release_mode: Literal["drop", "place", "hand_to_user"] | None = None,
|
|
278
|
+
release_at: dict[str, Any] | str | None = None,
|
|
279
|
+
) -> ManipulationResult:
|
|
280
|
+
return ManipulationResult(success=False, reason=_NOT_SUPPORTED.format(capability="manipulator controller"))
|
|
281
|
+
|
|
282
|
+
def send_docking_goal(self, *, station: str, service: str, until: str | None = None) -> NavigationResult:
|
|
283
|
+
return NavigationResult(success=False, reason=_NOT_SUPPORTED.format(capability="docking station"))
|
|
284
|
+
|
|
285
|
+
def query_detection(
|
|
286
|
+
self,
|
|
287
|
+
*,
|
|
288
|
+
object_class: str,
|
|
289
|
+
attributes: dict[str, Any] | None = None,
|
|
290
|
+
where_near: str | None = None,
|
|
291
|
+
where_within: float | None = None,
|
|
292
|
+
) -> DetectionResult:
|
|
293
|
+
return DetectionResult(success=False, reason=_NOT_SUPPORTED.format(capability="Chrono::Sensor perception"))
|
|
294
|
+
|
|
295
|
+
def capture_media(
|
|
296
|
+
self,
|
|
297
|
+
*,
|
|
298
|
+
media: Literal["photo", "video"],
|
|
299
|
+
target: str | None,
|
|
300
|
+
duration_seconds: float | None,
|
|
301
|
+
attributes: dict[str, Any] | None,
|
|
302
|
+
) -> CaptureResult:
|
|
303
|
+
return CaptureResult(success=False, reason=_NOT_SUPPORTED.format(capability="Chrono::Sensor camera"))
|
|
304
|
+
|
|
305
|
+
def emit_speech(
|
|
306
|
+
self,
|
|
307
|
+
*,
|
|
308
|
+
utterance: str,
|
|
309
|
+
locale: str | None,
|
|
310
|
+
style: Literal["notice", "warning", "conversational"],
|
|
311
|
+
interrupt: bool,
|
|
312
|
+
) -> SubstrateResult:
|
|
313
|
+
return SubstrateResult(success=False, reason=_NOT_SUPPORTED.format(capability="speaker"))
|
|
314
|
+
|
|
315
|
+
def acquire_speech(
|
|
316
|
+
self,
|
|
317
|
+
*,
|
|
318
|
+
prompt: str | None,
|
|
319
|
+
locale: str | None,
|
|
320
|
+
timeout_seconds: float | None,
|
|
321
|
+
expected: Literal["free_form", "confirmation", "choice"],
|
|
322
|
+
choices: list[str] | None,
|
|
323
|
+
) -> ListenResult:
|
|
324
|
+
return ListenResult(success=False, reason=_NOT_SUPPORTED.format(capability="microphone"))
|
|
325
|
+
|
|
326
|
+
def send_takeoff_goal(self, *, altitude: float, climb_rate: float | None = None) -> NavigationResult:
|
|
327
|
+
return NavigationResult(success=False, reason=_NOT_APPLICABLE.format(capability="take_off"))
|
|
328
|
+
|
|
329
|
+
def send_land_goal(
|
|
330
|
+
self,
|
|
331
|
+
*,
|
|
332
|
+
at: str | None = None,
|
|
333
|
+
precision: Literal["standard", "precise"] = "standard",
|
|
334
|
+
) -> NavigationResult:
|
|
335
|
+
return NavigationResult(success=False, reason=_NOT_APPLICABLE.format(capability="land"))
|
|
336
|
+
|
|
337
|
+
def send_return_to_home_goal(
|
|
338
|
+
self,
|
|
339
|
+
*,
|
|
340
|
+
speed: float | None = None,
|
|
341
|
+
altitude: float | None = None,
|
|
342
|
+
) -> NavigationResult:
|
|
343
|
+
return NavigationResult(success=False, reason=_NOT_APPLICABLE.format(capability="return_to_home"))
|
|
344
|
+
|
|
345
|
+
def call_named_program(
|
|
346
|
+
self,
|
|
347
|
+
*,
|
|
348
|
+
name: str,
|
|
349
|
+
args: dict[str, Any] | None = None,
|
|
350
|
+
) -> ProgramCallResult:
|
|
351
|
+
"""``call_program``: this substrate exposes no named programs (RFC-0015)."""
|
|
352
|
+
return unsupported_program_call("chrono_sim")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Deployment-side configuration for ``ChronoAdapter``.
|
|
2
|
+
|
|
3
|
+
A Chrono validation run needs to know:
|
|
4
|
+
|
|
5
|
+
- Which contact method the system uses (``system_type``: ``NSC``
|
|
6
|
+
non-smooth complementarity, or ``SMC`` smooth penalty).
|
|
7
|
+
- The integration step size and how many steps to advance per command.
|
|
8
|
+
- A mapping from manifest-declared location names to a **driver
|
|
9
|
+
segment**: the driver inputs (throttle / steering / braking, model
|
|
10
|
+
ordering) that drive the vehicle toward that named pose for one
|
|
11
|
+
command, mirroring the ROS 2 runtime's ``location_to_pose`` and the
|
|
12
|
+
MuJoCo runtime's ``location_to_target``.
|
|
13
|
+
|
|
14
|
+
This altitude follows Project Chrono lead Dan Negrut's feedback on
|
|
15
|
+
issue #746: align at Chrono's **motion primitives and driver inputs**,
|
|
16
|
+
each manifest entry mapping to a concrete ``ChSystem`` configuration.
|
|
17
|
+
These facts are model- and deployment-specific, so they do NOT belong
|
|
18
|
+
in the URML program, manifest, or envelope. They live in a
|
|
19
|
+
``chrono_adapter.yaml`` alongside the URML artifacts.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Literal
|
|
26
|
+
|
|
27
|
+
import yaml
|
|
28
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
29
|
+
|
|
30
|
+
from urml_chrono_runtime.terramechanics import TerramechanicsParams
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DriverSegment(BaseModel):
|
|
34
|
+
"""A named driver segment: the driver-input vector for one command.
|
|
35
|
+
|
|
36
|
+
The adapter applies ``driver`` (model-specific ordering, e.g.
|
|
37
|
+
``[throttle, steering, braking]``) and advances the Chrono system;
|
|
38
|
+
``driver`` is model-specific, which is exactly why it is deployment
|
|
39
|
+
config and not a URML-level concept.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(extra="forbid")
|
|
43
|
+
|
|
44
|
+
driver: list[float] = Field(
|
|
45
|
+
default_factory=list,
|
|
46
|
+
description="Driver-input vector applied for this command (model ordering).",
|
|
47
|
+
)
|
|
48
|
+
steps: int | None = Field(
|
|
49
|
+
default=None,
|
|
50
|
+
ge=1,
|
|
51
|
+
description="Physics steps for this segment; overrides steps_per_command when set.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ChronoConfig(BaseModel):
|
|
56
|
+
"""Chrono system + driver config."""
|
|
57
|
+
|
|
58
|
+
model_config = ConfigDict(extra="forbid")
|
|
59
|
+
|
|
60
|
+
system_type: Literal["NSC", "SMC"] = Field(
|
|
61
|
+
default="NSC",
|
|
62
|
+
description="Contact method: NSC (complementarity) or SMC (penalty).",
|
|
63
|
+
)
|
|
64
|
+
step_size: float = Field(
|
|
65
|
+
default=1e-3,
|
|
66
|
+
gt=0.0,
|
|
67
|
+
description="Integration step size in seconds passed to DoStepDynamics.",
|
|
68
|
+
)
|
|
69
|
+
steps_per_command: int = Field(
|
|
70
|
+
default=200,
|
|
71
|
+
ge=1,
|
|
72
|
+
description="Physics steps advanced per navigation/wait command.",
|
|
73
|
+
)
|
|
74
|
+
location_to_segment: dict[str, DriverSegment] = Field(
|
|
75
|
+
default_factory=dict,
|
|
76
|
+
description="Map manifest-declared location names to a driver segment.",
|
|
77
|
+
)
|
|
78
|
+
terrain_fidelity: Literal["rigid", "deformable", "granular", "unmodeled"] | None = Field(
|
|
79
|
+
default=None,
|
|
80
|
+
description=(
|
|
81
|
+
"RFC-0381 terrain class this deployment runs over, mirrored from the "
|
|
82
|
+
"manifest's validation.terrain_fidelity. Recorded in the evidence; the "
|
|
83
|
+
"terramechanics scene reads it as the cue for deformable terrain."
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
scene: Literal["bare", "terramechanics"] = Field(
|
|
87
|
+
default="bare",
|
|
88
|
+
description=(
|
|
89
|
+
"Which Chrono scene the adapter builds. 'bare' is a plain ChSystem "
|
|
90
|
+
"advanced by driver inputs (v0.1). 'terramechanics' builds a rig on "
|
|
91
|
+
"SCM deformable terrain and returns richer evidence (sinkage, contact "
|
|
92
|
+
"force, tip margin); pair it with terrain_fidelity: deformable."
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
terramechanics: TerramechanicsParams | None = Field(
|
|
96
|
+
default=None,
|
|
97
|
+
description="Soil + rig parameters for the terramechanics scene; defaults used when omitted.",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def resolve_location(self, name: str) -> DriverSegment | None:
|
|
101
|
+
"""Return the driver segment for a named location, or None if unmapped.
|
|
102
|
+
|
|
103
|
+
Unmapped names produce a ``NavigationResult(success=False)`` with a
|
|
104
|
+
``location_not_configured`` reason; they do not raise.
|
|
105
|
+
"""
|
|
106
|
+
return self.location_to_segment.get(name)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_chrono_config(path: str | Path) -> ChronoConfig:
|
|
110
|
+
"""Parse a ``chrono_adapter.yaml`` file into a ``ChronoConfig``."""
|
|
111
|
+
p = Path(path)
|
|
112
|
+
with p.open(encoding="utf-8") as fh:
|
|
113
|
+
data = yaml.safe_load(fh) or {}
|
|
114
|
+
if not isinstance(data, dict):
|
|
115
|
+
raise ValueError(f"chrono-config file {p} did not contain a YAML mapping at the top level.")
|
|
116
|
+
return ChronoConfig.model_validate(data)
|
|
File without changes
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""TerramechanicsScene — a rig on Chrono SCM deformable terrain.
|
|
2
|
+
|
|
3
|
+
The bundled Chrono::Vehicle terramechanics scene the runtime's status note
|
|
4
|
+
flagged as a follow-up, and the richer evidence Project Chrono lead Dan Negrut
|
|
5
|
+
pointed at on `projectchrono/chrono` issue #746: contact forces, sinkage, and a
|
|
6
|
+
tip-stability margin, exercised over a *deformable* terrain model rather than a
|
|
7
|
+
bare rigid `ChSystem`.
|
|
8
|
+
|
|
9
|
+
The role is unchanged from the rest of the runtime (RFC-0328): URML validates a
|
|
10
|
+
program statically against the capability manifest and the safety envelope
|
|
11
|
+
*first*, then drives the same validated intent through this scene so the
|
|
12
|
+
high-fidelity dynamics come back as evidence. The scene is selected by the
|
|
13
|
+
manifest's `validation.terrain_fidelity: deformable` hint (RFC-0381), mirrored
|
|
14
|
+
into `ChronoConfig.terrain_fidelity`; URML never models the soil itself, it only
|
|
15
|
+
declares the fidelity it expects and reads back what Chrono computed.
|
|
16
|
+
|
|
17
|
+
## What is verified where
|
|
18
|
+
|
|
19
|
+
The URML-side wiring — selecting the deformable scene from the config, driving
|
|
20
|
+
the rig with the resolved driver segment, and shaping sinkage / contact force /
|
|
21
|
+
tip margin into the validation-evidence payload — is exercised hermetically by
|
|
22
|
+
the committed unit suite against a fake `pychrono` (the technique the rest of the
|
|
23
|
+
runtime uses). The live SCM construction against a real PyChrono build is the
|
|
24
|
+
documented **calibration step** (the px4 / mujoco / opcua convention): the exact
|
|
25
|
+
SCM getter surface is version-sensitive, so each physics read is wrapped and
|
|
26
|
+
degrades to an honest ``unavailable`` note rather than crashing, and the pinned
|
|
27
|
+
scene is verified in ``chrono-integration.yml``'s ``chrono-sitl-e2e`` job.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import math
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TerramechanicsParams(BaseModel):
|
|
39
|
+
"""Soil + rig parameters for the SCM deformable-terrain scene.
|
|
40
|
+
|
|
41
|
+
Bekker-Wong pressure-sinkage plus Janosi-Hanamoto shear, the standard SCM
|
|
42
|
+
(Soil Contact Model) parameter set. Defaults approximate a soft sandy loam.
|
|
43
|
+
These are model- and deployment-specific, so they live in adapter config
|
|
44
|
+
(``chrono_adapter.yaml``), never in the URML program, manifest, or envelope.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
model_config = ConfigDict(extra="forbid")
|
|
48
|
+
|
|
49
|
+
terrain_length_m: float = Field(default=4.0, gt=0.0)
|
|
50
|
+
terrain_width_m: float = Field(default=2.0, gt=0.0)
|
|
51
|
+
mesh_resolution_m: float = Field(default=0.04, gt=0.0)
|
|
52
|
+
rig_mass_kg: float = Field(default=250.0, gt=0.0)
|
|
53
|
+
tip_threshold_deg: float = Field(
|
|
54
|
+
default=35.0,
|
|
55
|
+
gt=0.0,
|
|
56
|
+
description="Roll/pitch angle past which the rig is treated as unstable (tip margin = threshold - |tilt|).",
|
|
57
|
+
)
|
|
58
|
+
drawbar_gain_n: float = Field(
|
|
59
|
+
default=1500.0,
|
|
60
|
+
ge=0.0,
|
|
61
|
+
description="Forward force per unit of driver[0] (throttle) applied to the rig, N.",
|
|
62
|
+
)
|
|
63
|
+
# Bekker-Wong / Janosi soil parameters (SCM).
|
|
64
|
+
bekker_kphi: float = Field(default=0.2e6, description="Bekker frictional modulus k_phi, Pa/m^n.")
|
|
65
|
+
bekker_kc: float = Field(default=0.0, description="Bekker cohesive modulus k_c, Pa/m^(n-1).")
|
|
66
|
+
bekker_n: float = Field(default=1.1, gt=0.0, description="Bekker sinkage exponent n.")
|
|
67
|
+
mohr_cohesion_pa: float = Field(default=0.0, ge=0.0, description="Mohr-Coulomb cohesion, Pa.")
|
|
68
|
+
mohr_friction_deg: float = Field(default=30.0, description="Mohr-Coulomb internal friction angle, deg.")
|
|
69
|
+
janosi_shear_m: float = Field(default=0.01, gt=0.0, description="Janosi-Hanamoto shear deformation modulus, m.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _unavailable(reason: str) -> dict[str, Any]:
|
|
73
|
+
return {"value": None, "note": f"unavailable: {reason}"}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TerramechanicsScene:
|
|
77
|
+
"""A rigid rig resting on an SCM deformable-terrain patch, via ``pychrono``.
|
|
78
|
+
|
|
79
|
+
Holds a cached ``ChSystemSMC`` (SCM contact requires the smooth/penalty
|
|
80
|
+
method), an SCM terrain patch, and a rigid rig body. ``advance`` applies the
|
|
81
|
+
driver and steps both; ``evidence`` reads sinkage, the peak contact force,
|
|
82
|
+
and the tip-stability margin. The scene is built lazily by the adapter only
|
|
83
|
+
when ``ChronoConfig.scene == "terramechanics"``.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
TERRAIN_MODEL = "scm_deformable"
|
|
87
|
+
|
|
88
|
+
def __init__(self, chrono: Any, params: TerramechanicsParams) -> None:
|
|
89
|
+
self._ch = chrono
|
|
90
|
+
self._p = params
|
|
91
|
+
self._veh = self._import_vehicle()
|
|
92
|
+
self.system: Any = chrono.ChSystemSMC()
|
|
93
|
+
self._terrain: Any = None
|
|
94
|
+
self._rig: Any = None
|
|
95
|
+
self._rig_z0: float = 0.0
|
|
96
|
+
self._notes: list[str] = []
|
|
97
|
+
self._build()
|
|
98
|
+
|
|
99
|
+
def _import_vehicle(self) -> Any:
|
|
100
|
+
"""Resolve the ``pychrono.vehicle`` module (attribute first, then import)."""
|
|
101
|
+
veh = getattr(self._ch, "vehicle", None)
|
|
102
|
+
if veh is not None:
|
|
103
|
+
return veh
|
|
104
|
+
try:
|
|
105
|
+
import pychrono.vehicle as veh # type: ignore[import-not-found,unused-ignore]
|
|
106
|
+
except ImportError as exc: # pragma: no cover - calibration-only path
|
|
107
|
+
raise RuntimeError(
|
|
108
|
+
"pychrono.vehicle is required for the terramechanics scene.\n"
|
|
109
|
+
" It ships with PyChrono from conda-forge: conda install -c conda-forge pychrono"
|
|
110
|
+
) from exc
|
|
111
|
+
return veh
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
# Construction (calibration-gated against a real PyChrono build)
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def _build(self) -> None:
|
|
118
|
+
p = self._p
|
|
119
|
+
terrain = self._veh.SCMTerrain(self.system)
|
|
120
|
+
terrain.SetSoilParameters(
|
|
121
|
+
p.bekker_kphi,
|
|
122
|
+
p.bekker_kc,
|
|
123
|
+
p.bekker_n,
|
|
124
|
+
p.mohr_cohesion_pa,
|
|
125
|
+
p.mohr_friction_deg,
|
|
126
|
+
p.janosi_shear_m,
|
|
127
|
+
)
|
|
128
|
+
terrain.Initialize(p.terrain_length_m, p.terrain_width_m, p.mesh_resolution_m)
|
|
129
|
+
self._terrain = terrain
|
|
130
|
+
|
|
131
|
+
rig = self._ch.ChBody()
|
|
132
|
+
rig.SetMass(p.rig_mass_kg)
|
|
133
|
+
self.system.AddBody(rig)
|
|
134
|
+
self._rig = rig
|
|
135
|
+
self._rig_z0 = self._body_z(rig)
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# Stepping
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def advance(self, step_size: float, steps: int, driver: list[float] | None = None) -> None:
|
|
142
|
+
if driver:
|
|
143
|
+
self._apply_driver(driver)
|
|
144
|
+
for _ in range(max(1, steps)):
|
|
145
|
+
self._advance_terrain(step_size)
|
|
146
|
+
self.system.DoStepDynamics(step_size)
|
|
147
|
+
|
|
148
|
+
def _apply_driver(self, driver: list[float]) -> None:
|
|
149
|
+
"""Apply driver[0] (throttle) as a forward drawbar force on the rig."""
|
|
150
|
+
force = float(driver[0]) * self._p.drawbar_gain_n
|
|
151
|
+
try:
|
|
152
|
+
self._rig.EmptyAccumulators()
|
|
153
|
+
self._rig.AccumulateForce(
|
|
154
|
+
self._ch.ChVector3d(force, 0.0, 0.0), self._rig.GetPos(), False
|
|
155
|
+
)
|
|
156
|
+
except Exception as exc: # pragma: no cover - calibration-only path
|
|
157
|
+
self._note(f"driver_force {type(exc).__name__}")
|
|
158
|
+
|
|
159
|
+
def _advance_terrain(self, step_size: float) -> None:
|
|
160
|
+
try:
|
|
161
|
+
self._terrain.Advance(step_size)
|
|
162
|
+
except Exception as exc: # pragma: no cover - calibration-only path
|
|
163
|
+
self._note(f"terrain_advance {type(exc).__name__}")
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
# Evidence
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def evidence(self) -> dict[str, Any]:
|
|
170
|
+
"""Richer terramechanics evidence for the validation payload."""
|
|
171
|
+
ev: dict[str, Any] = {
|
|
172
|
+
"terrain_model": self.TERRAIN_MODEL,
|
|
173
|
+
"sinkage_m": self._sinkage_m(),
|
|
174
|
+
"max_contact_force_n": self._max_contact_force_n(),
|
|
175
|
+
"tip_margin_deg": self._tip_margin_deg(),
|
|
176
|
+
}
|
|
177
|
+
if self._notes:
|
|
178
|
+
ev["terramechanics_notes"] = list(self._notes)
|
|
179
|
+
return ev
|
|
180
|
+
|
|
181
|
+
def _sinkage_m(self) -> float | None:
|
|
182
|
+
try:
|
|
183
|
+
return max(0.0, self._rig_z0 - self._body_z(self._rig))
|
|
184
|
+
except Exception as exc: # pragma: no cover - calibration-only path
|
|
185
|
+
self._note(f"sinkage {type(exc).__name__}")
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
def _max_contact_force_n(self) -> float | None:
|
|
189
|
+
try:
|
|
190
|
+
f = self._terrain.GetContactForceBody(self._rig)
|
|
191
|
+
return float(f.Length())
|
|
192
|
+
except Exception as exc: # pragma: no cover - calibration-only path
|
|
193
|
+
self._note(f"contact_force {type(exc).__name__}")
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def _tip_margin_deg(self) -> float | None:
|
|
197
|
+
try:
|
|
198
|
+
roll, pitch = self._rig_roll_pitch_deg()
|
|
199
|
+
return self._p.tip_threshold_deg - max(abs(roll), abs(pitch))
|
|
200
|
+
except Exception as exc: # pragma: no cover - calibration-only path
|
|
201
|
+
self._note(f"tip_margin {type(exc).__name__}")
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
# Small physics reads
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def _body_z(self, body: Any) -> float:
|
|
209
|
+
return float(body.GetPos().z)
|
|
210
|
+
|
|
211
|
+
def _rig_roll_pitch_deg(self) -> tuple[float, float]:
|
|
212
|
+
"""Roll and pitch of the rig from its orientation quaternion, in degrees."""
|
|
213
|
+
q = self._rig.GetRot()
|
|
214
|
+
w, x, y, z = float(q.e0), float(q.e1), float(q.e2), float(q.e3)
|
|
215
|
+
# Standard quaternion -> roll (x) / pitch (y), ZYX convention.
|
|
216
|
+
roll = math.atan2(2.0 * (w * x + y * z), 1.0 - 2.0 * (x * x + y * y))
|
|
217
|
+
sin_pitch = max(-1.0, min(1.0, 2.0 * (w * y - z * x)))
|
|
218
|
+
pitch = math.asin(sin_pitch)
|
|
219
|
+
return math.degrees(roll), math.degrees(pitch)
|
|
220
|
+
|
|
221
|
+
def _note(self, msg: str) -> None:
|
|
222
|
+
if msg not in self._notes:
|
|
223
|
+
self._notes.append(msg)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: urml-chrono-runtime
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: High-fidelity validation runtime for URML — Project Chrono multibody physics, zero ROS.
|
|
5
|
+
Project-URL: Homepage, https://github.com/URML-MARS/URML
|
|
6
|
+
Project-URL: Repository, https://github.com/URML-MARS/URML
|
|
7
|
+
Project-URL: Issues, https://github.com/URML-MARS/URML/issues
|
|
8
|
+
Author: URML Maintainers
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: chrono,pychrono,robotics,runtime,simulation,terramechanics,urml
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: pydantic<3,>=2.6
|
|
21
|
+
Requires-Dist: pyyaml<7,>=6.0
|
|
22
|
+
Requires-Dist: urml-ros2-runtime>=0.4.0
|
|
23
|
+
Requires-Dist: urml-validator>=0.4.0
|
|
24
|
+
Provides-Extra: chrono
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
<p align="center">
|
|
33
|
+
<a href="https://urml.dev"><img src="https://urml.dev/favicon.svg" alt="URML" width="72" height="72"></a>
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
<p align="center">
|
|
37
|
+
A small, opinionated, human-readable language for describing robot intent.
|
|
38
|
+
</p>
|
|
39
|
+
|
|
40
|
+
<p align="center">
|
|
41
|
+
<a href="https://urml.dev"><b>urml.dev</b></a>
|
|
42
|
+
</p>
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
# urml-chrono-runtime
|
|
47
|
+
|
|
48
|
+
**High-fidelity validation reference runtime for URML** — `ChronoAdapter` for **Project Chrono** multibody / multi-physics, via `pychrono`.
|
|
49
|
+
|
|
50
|
+
Project Chrono is an open high-fidelity multibody engine (Chrono::Vehicle ground dynamics, terramechanics for deformable terrain, Chrono::Sensor) from the University of Wisconsin-Madison and the University of Parma. This adapter mirrors `MujocoAdapter` / `PX4Adapter`: lazy `pychrono`, **no ROS 2 dependency**, a lazily-built cached `ChSystem`, failures returned not raised. Built against the frozen substrate Protocol per [RFC-0014](../../docs/rfcs/0014-substrate-conformance.md).
|
|
51
|
+
|
|
52
|
+
Chrono's distinctive role for URML is **high-fidelity pre-deployment validation** ([RFC-0328](../../docs/rfcs/0328-project-chrono-outreach.md)): URML checks a program statically against the declared capability manifest and active safety envelope first, then drives the same validated intent through a short Chrono simulation segment so the dynamics come back as evidence. URML composes **above** Chrono; it never embeds it. The differentiator is the static capability-and-envelope check *before* the expensive multibody solver ever spins, so the high-fidelity run only ever exercises admissible programs.
|
|
53
|
+
|
|
54
|
+
The primitive → driver-input altitude follows Project Chrono lead Dan Negrut's feedback on [issue #746](https://github.com/projectchrono/chrono/issues/746): align each manifest entry to a concrete `ChSystem` configuration at the level of Chrono's motion primitives and driver inputs.
|
|
55
|
+
|
|
56
|
+
## Method coverage
|
|
57
|
+
|
|
58
|
+
| URML primitive | v0.1 |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `move_to` / `hover` | apply the configured driver segment, advance the `ChSystem` `steps_per_command` steps, return the dynamics evidence (sim time, contacts, commanded driver inputs) |
|
|
61
|
+
| `wait` | step the engine |
|
|
62
|
+
| `measure` | return the accumulated dynamics evidence (`value`=contacts, `sim_time`, `driver`, `backend`) |
|
|
63
|
+
| `wait_for` | step-then-check (a sim has no external event bus) |
|
|
64
|
+
| `report` | structured record to a local sink (no cloud) |
|
|
65
|
+
| `scan` | documented **stub success** (mirrors `PX4Adapter.run_scan`) |
|
|
66
|
+
|
|
67
|
+
`grasp`/`release`, `dock`, `detect`, `capture`, `speak`, `listen` return `not_supported_in_base_sim` (a Chrono::Sensor or articulated-model companion supplies them under the unchanged program, manifest, and validator). The drone trio returns `not_applicable_sim`.
|
|
68
|
+
|
|
69
|
+
## Install / use
|
|
70
|
+
|
|
71
|
+
PyChrono ships via **conda-forge**, not as a PyPI wheel. The `[chrono]` extra is intentionally empty (it documents the boundary); `pychrono` is imported lazily, so this module loads on every host without it.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
conda install -c conda-forge pychrono # the engine
|
|
75
|
+
pip install -e reference/chrono-runtime[chrono] # the adapter
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from urml_chrono_runtime import ChronoAdapter, ChronoConfig
|
|
80
|
+
from urml_chrono_runtime.adapter import DriverSegment
|
|
81
|
+
cfg = ChronoConfig(steps_per_command=200,
|
|
82
|
+
location_to_segment={"ridge_waypoint": DriverSegment(driver=[0.8, 0.1, 0.0])})
|
|
83
|
+
with ChronoAdapter(cfg) as sim:
|
|
84
|
+
r = sim.send_navigation_goal(location="ridge_waypoint")
|
|
85
|
+
assert r.success and r.final_pose["sim_time"] > 0.0
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Without `pychrono` installed, `ChronoAdapter()` raises a clear error pointing at conda-forge.
|
|
89
|
+
|
|
90
|
+
## Status
|
|
91
|
+
|
|
92
|
+
**v0.1 (this release):**
|
|
93
|
+
- `ChronoAdapter` + `ChronoConfig` (PyChrono, no ROS). `chrono_vehicle_cell` manifest + `conformance/fixtures/home/21_chrono_vehicle_terrain_positive.yaml` verified through the runner (hermetic against `MockROSAdapter`; adapter-agnostic against `ChronoAdapter`).
|
|
94
|
+
- **Terramechanics scene** (`scene: terramechanics`): a rig on Chrono SCM deformable terrain, selected by the manifest's `validation.terrain_fidelity: deformable` hint (RFC-0381). The validation-evidence payload gains **sinkage, peak contact force, and a tip-stability margin** alongside the base dynamics — the richer evidence Project Chrono lead Dan Negrut pointed at on [#746](https://github.com/projectchrono/chrono/issues/746). See [`terramechanics.py`](src/urml_chrono_runtime/terramechanics.py) and [`chrono_adapter.terramechanics.yaml`](chrono_adapter.terramechanics.yaml).
|
|
95
|
+
- Hermetic unit tests: navigation (configured + unmapped), the NSC/SMC system-type switch, measure (validation-evidence payload), scan-stub, lifecycle, the not-supported / not-applicable-sim sentinels, the missing-`pychrono` (conda-pointing) error, the conformance hook, and the **terramechanics scene** (sinkage / contact-force / tip-margin evidence, driver-force application, bare-path regression) — no pychrono install required (a fake `pychrono`, with a `vehicle`/SCM surface, is injected into `sys.modules`).
|
|
96
|
+
- Gated `.github/workflows/chrono-integration.yml`: `chrono-smoke` (real pychrono from conda-forge + the hermetic suite + the live smoke) and `chrono-sitl-e2e` against a real Chrono::Vehicle scene (first run is a calibration run by design — the established px4 / mujoco / opcua convention). The live SCM scene is that calibration target: its physics reads are version-sensitive, so each degrades to an honest `unavailable` note rather than crashing.
|
|
97
|
+
- [`SPEC-GAPS.md`](SPEC-GAPS.md): the two manifest gaps the mapping surfaced (terrain-fidelity + simulator-target-class hints) shipped as RFC-0381; the terramechanics scene now reads the terrain-fidelity hint.
|
|
98
|
+
|
|
99
|
+
**Follow-ups (not yet):** a true sentence→motion validation video; a Chrono::Sensor companion for `detect` / `capture`; a full Chrono::Vehicle (HMMWV-class) model under the rig, pinned in CI.
|
|
100
|
+
|
|
101
|
+
## Core Commitment
|
|
102
|
+
|
|
103
|
+
This runtime is Apache 2.0. It is outside the [Core Commitment](../../CORE_COMMITMENT.md) boundary (only ROS 2 + PX4 reference runtimes are named there) but carries the same no-vendor-coupling, no-cloud, no-enterprise-edition posture. PyChrono / Project Chrono is BSD-3-Clause (confirmed by the maintainers on #746); a validated-intent mapping carries no license entanglement.
|
|
104
|
+
|
|
105
|
+
## Related documents
|
|
106
|
+
|
|
107
|
+
- [`/reference/mujoco-runtime/`](../mujoco-runtime/) — the zero-ROS simulator sibling whose structure this mirrors.
|
|
108
|
+
- [`/docs/rfcs/0328-project-chrono-outreach.md`](../../docs/rfcs/0328-project-chrono-outreach.md) — the engagement and mapping this implements.
|
|
109
|
+
- [`/docs/rfcs/0014-substrate-conformance.md`](../../docs/rfcs/0014-substrate-conformance.md) — the conformance contract this is built against.
|
|
110
|
+
- [`/conformance/`](../../conformance/) — the test suite that decides conformance.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
urml_chrono_runtime/__init__.py,sha256=gNgCyvZ8gL86nbEQChXnWVJWPtrv_d0KzZyUCTqQb8E,1091
|
|
2
|
+
urml_chrono_runtime/_version.py,sha256=E5rRAxDRNIsA89ZzGERJeca0a74MUnpI6Avs1JFLuv0,66
|
|
3
|
+
urml_chrono_runtime/adapter.py,sha256=mZwxTF0K2qACFMFP6KBJSvw87v9HnsJLDfHIKLcBRIc,13779
|
|
4
|
+
urml_chrono_runtime/config.py,sha256=QeHwWEGOHxmTQq_LgRYJXiLcnO4Eg4aKi1vt9yICGN0,4510
|
|
5
|
+
urml_chrono_runtime/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
urml_chrono_runtime/terramechanics.py,sha256=3_d_W87XGJm-y8zdsSq5FMj8-LHc_ZYUKf_tCtR9ExM,9783
|
|
7
|
+
urml_chrono_runtime-0.4.0.dist-info/METADATA,sha256=TZAXCzbj3xXjsP3TJ2Ho6xfLjcJ4ELicOC_BKP3boSo,8029
|
|
8
|
+
urml_chrono_runtime-0.4.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
9
|
+
urml_chrono_runtime-0.4.0.dist-info/RECORD,,
|