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.
@@ -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,3 @@
1
+ """Package version. Bumped per release."""
2
+
3
+ __version__ = "0.4.0"
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any