creature-lab 0.3.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.
- creature_lab/__init__.py +18 -0
- creature_lab/agents/__init__.py +5 -0
- creature_lab/agents/baseline.py +53 -0
- creature_lab/agents/llm.py +33 -0
- creature_lab/agents/loop.py +112 -0
- creature_lab/agents/prompts.py +63 -0
- creature_lab/agents/tools.py +101 -0
- creature_lab/autopsy.py +376 -0
- creature_lab/backends/__init__.py +1 -0
- creature_lab/backends/base.py +25 -0
- creature_lab/backends/mujoco_backend.py +253 -0
- creature_lab/backends/pybullet_backend.py +573 -0
- creature_lab/cli.py +3317 -0
- creature_lab/controllers/__init__.py +1 -0
- creature_lab/controllers/cpg.py +83 -0
- creature_lab/controllers/factory.py +208 -0
- creature_lab/controllers/pid.py +33 -0
- creature_lab/controllers/policy.py +91 -0
- creature_lab/controllers/pose_seq.py +38 -0
- creature_lab/controllers/posture.py +125 -0
- creature_lab/controllers/sinusoid.py +20 -0
- creature_lab/controllers/target_seek.py +128 -0
- creature_lab/diagnosis.py +435 -0
- creature_lab/diagnostics.py +261 -0
- creature_lab/editor/__init__.py +5 -0
- creature_lab/editor/controls.py +2495 -0
- creature_lab/editor/history.py +85 -0
- creature_lab/editor/jobs.py +176 -0
- creature_lab/editor/labels.py +91 -0
- creature_lab/editor/live.py +672 -0
- creature_lab/editor/playback.py +98 -0
- creature_lab/editor/presets.py +282 -0
- creature_lab/editor/robot_panel.py +288 -0
- creature_lab/editor/robot_stage.py +181 -0
- creature_lab/editor/session.py +1413 -0
- creature_lab/env.py +274 -0
- creature_lab/evolve.py +394 -0
- creature_lab/export/__init__.py +9 -0
- creature_lab/export/mjcf.py +199 -0
- creature_lab/export/urdf.py +152 -0
- creature_lab/export/urdf_import.py +142 -0
- creature_lab/exporting.py +360 -0
- creature_lab/failure_zoo.py +98 -0
- creature_lab/hashing.py +19 -0
- creature_lab/io_utils.py +37 -0
- creature_lab/library.py +311 -0
- creature_lab/qualification.py +299 -0
- creature_lab/reports.py +428 -0
- creature_lab/reports_html.py +549 -0
- creature_lab/rl/__init__.py +9 -0
- creature_lab/rl/gym_env.py +74 -0
- creature_lab/rl/train.py +148 -0
- creature_lab/robots/__init__.py +6 -0
- creature_lab/robots/actuator.py +115 -0
- creature_lab/robots/analysis.py +282 -0
- creature_lab/robots/api.py +30 -0
- creature_lab/robots/card.py +182 -0
- creature_lab/robots/catalog.py +153 -0
- creature_lab/robots/checkup.py +216 -0
- creature_lab/robots/cli.py +275 -0
- creature_lab/robots/compare.py +229 -0
- creature_lab/robots/data/UNITREE_RL_GYM_LICENSE.txt +23 -0
- creature_lab/robots/data/microduck.json +253 -0
- creature_lab/robots/data/unitree_g1.json +174 -0
- creature_lab/robots/data/unitree_g1_policy.npz +0 -0
- creature_lab/robots/fetch.py +115 -0
- creature_lab/robots/film.py +78 -0
- creature_lab/robots/g1.py +103 -0
- creature_lab/robots/microduck.py +153 -0
- creature_lab/robots/overlay.py +384 -0
- creature_lab/robots/policies.py +81 -0
- creature_lab/robots/render.py +236 -0
- creature_lab/robots/report.py +307 -0
- creature_lab/robots/rollout.py +185 -0
- creature_lab/robots/scene.py +53 -0
- creature_lab/robots/story.py +485 -0
- creature_lab/robots/types.py +120 -0
- creature_lab/robots/video.py +277 -0
- creature_lab/robots/visual.py +41 -0
- creature_lab/robustness.py +165 -0
- creature_lab/runs.py +242 -0
- creature_lab/scaffold/__init__.py +29 -0
- creature_lab/scaffold/humanoid.py +227 -0
- creature_lab/scaffold/legged.py +144 -0
- creature_lab/scaffold/mirror.py +90 -0
- creature_lab/scaffold/worm.py +80 -0
- creature_lab/schema/__init__.py +79 -0
- creature_lab/schema/agent.py +28 -0
- creature_lab/schema/base.py +33 -0
- creature_lab/schema/control.py +49 -0
- creature_lab/schema/controller.py +208 -0
- creature_lab/schema/creature.py +235 -0
- creature_lab/schema/summary.py +30 -0
- creature_lab/schema/task.py +137 -0
- creature_lab/schema/trace.py +110 -0
- creature_lab/scoring.py +56 -0
- creature_lab/terrain.py +172 -0
- creature_lab/validation.py +103 -0
- creature_lab/viewers/__init__.py +1 -0
- creature_lab/viewers/overlays.py +71 -0
- creature_lab/viewers/plotting.py +43 -0
- creature_lab/viewers/video_exporter.py +26 -0
- creature_lab/viewers/viser_viewer.py +489 -0
- creature_lab/web/__init__.py +6 -0
- creature_lab/web/builder.py +91 -0
- creature_lab/web/creatures.py +445 -0
- creature_lab/web/jobs.py +172 -0
- creature_lab/web/library.py +254 -0
- creature_lab/web/scene.py +112 -0
- creature_lab/web/server.py +394 -0
- creature_lab/web/static/assets/index-CVJ_iiu_.css +1 -0
- creature_lab/web/static/assets/index-D-2y8anQ.js +4233 -0
- creature_lab/web/static/clips/unitree_g1.webp +0 -0
- creature_lab/web/static/index.html +16 -0
- creature_lab/web/thumbs.py +120 -0
- creature_lab/web/whatif.py +149 -0
- creature_lab/zoo/damaged_quadruped/baselines/recover.json +14 -0
- creature_lab/zoo/damaged_quadruped/baselines/recover.mujoco.json +14 -0
- creature_lab/zoo/damaged_quadruped/creature.json +205 -0
- creature_lab/zoo/damaged_quadruped/tasks/recover.json +8 -0
- creature_lab/zoo/hexapod/baselines/crawl_forward.json +14 -0
- creature_lab/zoo/hexapod/baselines/crawl_forward.mujoco.json +14 -0
- creature_lab/zoo/hexapod/controller.json +42 -0
- creature_lab/zoo/hexapod/creature.json +295 -0
- creature_lab/zoo/hexapod/tasks/crawl_forward.json +7 -0
- creature_lab/zoo/humanoid_12dof/baselines/reach.json +14 -0
- creature_lab/zoo/humanoid_12dof/baselines/reach.mujoco.json +14 -0
- creature_lab/zoo/humanoid_12dof/baselines/walk.json +14 -0
- creature_lab/zoo/humanoid_12dof/baselines/walk.mujoco.json +14 -0
- creature_lab/zoo/humanoid_12dof/controller.json +18 -0
- creature_lab/zoo/humanoid_12dof/creature.json +628 -0
- creature_lab/zoo/humanoid_12dof/tasks/reach.json +8 -0
- creature_lab/zoo/humanoid_12dof/tasks/walk.json +7 -0
- creature_lab/zoo/humanoid_minimal/baselines/balance.json +14 -0
- creature_lab/zoo/humanoid_minimal/baselines/balance.mujoco.json +14 -0
- creature_lab/zoo/humanoid_minimal/baselines/push_recovery.json +14 -0
- creature_lab/zoo/humanoid_minimal/baselines/push_recovery.mujoco.json +14 -0
- creature_lab/zoo/humanoid_minimal/baselines/walk.json +14 -0
- creature_lab/zoo/humanoid_minimal/baselines/walk.mujoco.json +14 -0
- creature_lab/zoo/humanoid_minimal/creature.json +434 -0
- creature_lab/zoo/humanoid_minimal/tasks/balance.json +7 -0
- creature_lab/zoo/humanoid_minimal/tasks/push_recovery.json +8 -0
- creature_lab/zoo/humanoid_minimal/tasks/walk.json +7 -0
- creature_lab/zoo/quadruped/baselines/crawl_forward.json +14 -0
- creature_lab/zoo/quadruped/baselines/crawl_forward.mujoco.json +14 -0
- creature_lab/zoo/quadruped/baselines/gap_cross.json +14 -0
- creature_lab/zoo/quadruped/baselines/gap_cross.mujoco.json +14 -0
- creature_lab/zoo/quadruped/baselines/low_friction_crawl.mujoco.json +14 -0
- creature_lab/zoo/quadruped/baselines/push_recovery.mujoco.json +14 -0
- creature_lab/zoo/quadruped/baselines/reach_target.json +14 -0
- creature_lab/zoo/quadruped/baselines/reach_target.mujoco.json +14 -0
- creature_lab/zoo/quadruped/baselines/slope_climb.json +14 -0
- creature_lab/zoo/quadruped/baselines/slope_climb.mujoco.json +14 -0
- creature_lab/zoo/quadruped/baselines/stability_hold.mujoco.json +14 -0
- creature_lab/zoo/quadruped/baselines/step_over.json +14 -0
- creature_lab/zoo/quadruped/baselines/step_over.mujoco.json +14 -0
- creature_lab/zoo/quadruped/controller.json +30 -0
- creature_lab/zoo/quadruped/creature.json +205 -0
- creature_lab/zoo/quadruped/tasks/crawl_forward.json +7 -0
- creature_lab/zoo/quadruped/tasks/gap_cross.json +7 -0
- creature_lab/zoo/quadruped/tasks/low_friction_crawl.json +7 -0
- creature_lab/zoo/quadruped/tasks/push_recovery.json +8 -0
- creature_lab/zoo/quadruped/tasks/reach_target.json +8 -0
- creature_lab/zoo/quadruped/tasks/slope_climb.json +7 -0
- creature_lab/zoo/quadruped/tasks/stability_hold.json +7 -0
- creature_lab/zoo/quadruped/tasks/step_over.json +7 -0
- creature_lab/zoo/tripod/baselines/crawl_forward.json +14 -0
- creature_lab/zoo/tripod/baselines/crawl_forward.mujoco.json +14 -0
- creature_lab/zoo/tripod/controller.json +24 -0
- creature_lab/zoo/tripod/creature.json +145 -0
- creature_lab/zoo/tripod/tasks/crawl_forward.json +7 -0
- creature_lab/zoo/worm/baselines/crawl_forward.json +14 -0
- creature_lab/zoo/worm/baselines/crawl_forward.mujoco.json +14 -0
- creature_lab/zoo/worm/controller.json +36 -0
- creature_lab/zoo/worm/creature.json +247 -0
- creature_lab/zoo/worm/tasks/crawl_forward.json +7 -0
- creature_lab/zoo.py +171 -0
- creature_lab-0.3.0.dist-info/METADATA +370 -0
- creature_lab-0.3.0.dist-info/RECORD +182 -0
- creature_lab-0.3.0.dist-info/WHEEL +4 -0
- creature_lab-0.3.0.dist-info/entry_points.txt +2 -0
- creature_lab-0.3.0.dist-info/licenses/LICENSE +201 -0
creature_lab/autopsy.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Failure-first experiment autopsy, independent of any physics backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from creature_lab.diagnosis import diagnose
|
|
12
|
+
from creature_lab.diagnostics import summarize_episode
|
|
13
|
+
from creature_lab.qualification import (
|
|
14
|
+
BUILTIN_PROFILES,
|
|
15
|
+
QualificationProfile,
|
|
16
|
+
task_setup_check,
|
|
17
|
+
task_success,
|
|
18
|
+
)
|
|
19
|
+
from creature_lab.robustness import RobustnessResult, run_trials
|
|
20
|
+
from creature_lab.schema import CreatureSpec, EpisodeTrace, TaskSpec
|
|
21
|
+
|
|
22
|
+
SimulateFn = Callable[[CreatureSpec, TaskSpec], EpisodeTrace]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class AutopsyEvidence:
|
|
27
|
+
name: str
|
|
28
|
+
passed: bool | None
|
|
29
|
+
detail: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class TrialRecording:
|
|
34
|
+
creature: CreatureSpec
|
|
35
|
+
task: TaskSpec
|
|
36
|
+
trace: EpisodeTrace
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class AutopsyResult:
|
|
41
|
+
creature: str
|
|
42
|
+
task: str
|
|
43
|
+
profile: str
|
|
44
|
+
primary_cause: str
|
|
45
|
+
confidence: str
|
|
46
|
+
summary: str
|
|
47
|
+
evidence: list[AutopsyEvidence]
|
|
48
|
+
recommendations: list[str]
|
|
49
|
+
baseline_trace: EpisodeTrace = field(repr=False)
|
|
50
|
+
reference_trace: EpisodeTrace | None = field(repr=False)
|
|
51
|
+
other_backend_trace: EpisodeTrace | None = field(default=None, repr=False)
|
|
52
|
+
robustness: RobustnessResult | None = field(default=None, repr=False)
|
|
53
|
+
trial_recordings: list[TrialRecording] = field(default_factory=list, repr=False)
|
|
54
|
+
|
|
55
|
+
def to_json_dict(self) -> dict[str, object]:
|
|
56
|
+
robustness = None
|
|
57
|
+
if self.robustness is not None:
|
|
58
|
+
robustness = {
|
|
59
|
+
"trials": len(self.robustness.trials),
|
|
60
|
+
"mean_score": self.robustness.mean_score,
|
|
61
|
+
"std_score": self.robustness.std_score,
|
|
62
|
+
"fail_rate": self.robustness.fail_rate,
|
|
63
|
+
"trial_details": [asdict(trial) for trial in self.robustness.trials],
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
"creature": self.creature,
|
|
67
|
+
"task": self.task,
|
|
68
|
+
"profile": self.profile,
|
|
69
|
+
"primary_cause": self.primary_cause,
|
|
70
|
+
"confidence": self.confidence,
|
|
71
|
+
"confidence_method": "heuristic; not a calibrated probability",
|
|
72
|
+
"summary": self.summary,
|
|
73
|
+
"evidence": [evidence.__dict__ for evidence in self.evidence],
|
|
74
|
+
"recommendations": self.recommendations,
|
|
75
|
+
"baseline": {
|
|
76
|
+
"run_id": self.baseline_trace.run_id,
|
|
77
|
+
"backend": self.baseline_trace.backend,
|
|
78
|
+
"score": self.baseline_trace.score,
|
|
79
|
+
},
|
|
80
|
+
"reference": (
|
|
81
|
+
{
|
|
82
|
+
"run_id": self.reference_trace.run_id,
|
|
83
|
+
"backend": self.reference_trace.backend,
|
|
84
|
+
"score": self.reference_trace.score,
|
|
85
|
+
}
|
|
86
|
+
if self.reference_trace is not None
|
|
87
|
+
else None
|
|
88
|
+
),
|
|
89
|
+
"other_backend": (
|
|
90
|
+
{
|
|
91
|
+
"run_id": self.other_backend_trace.run_id,
|
|
92
|
+
"backend": self.other_backend_trace.backend,
|
|
93
|
+
"score": self.other_backend_trace.score,
|
|
94
|
+
}
|
|
95
|
+
if self.other_backend_trace is not None
|
|
96
|
+
else None
|
|
97
|
+
),
|
|
98
|
+
"robustness": robustness,
|
|
99
|
+
"evidence_packs": {
|
|
100
|
+
"reference": "evidence/reference" if self.reference_trace is not None else None,
|
|
101
|
+
"other_backend": (
|
|
102
|
+
"evidence/other_backend" if self.other_backend_trace is not None else None
|
|
103
|
+
),
|
|
104
|
+
"trials": [
|
|
105
|
+
{"path": f"evidence/trial_{index + 1:03d}", "run_id": item.trace.run_id}
|
|
106
|
+
for index, item in enumerate(self.trial_recordings)
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def infer_profile(task: TaskSpec) -> QualificationProfile:
|
|
113
|
+
if task.target is not None:
|
|
114
|
+
return BUILTIN_PROFILES["target-reach"]
|
|
115
|
+
if task.damage_event is not None or task.impulse_event is not None:
|
|
116
|
+
return BUILTIN_PROFILES["push-recovery"]
|
|
117
|
+
return BUILTIN_PROFILES["basic-locomotion"]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def distinct_curated_reference(creature: CreatureSpec, controller: str) -> bool:
|
|
121
|
+
"""Only claim a counterfactual when exact controller snapshots differ."""
|
|
122
|
+
from creature_lab.controllers.factory import curated_controller
|
|
123
|
+
from creature_lab.exporting import file_hash, resolve_controller_bundle
|
|
124
|
+
|
|
125
|
+
reference = curated_controller(creature)
|
|
126
|
+
selected = reference if controller == "curated" else controller
|
|
127
|
+
try:
|
|
128
|
+
a = resolve_controller_bundle(creature, selected)
|
|
129
|
+
b = resolve_controller_bundle(creature, reference)
|
|
130
|
+
if not a.exact or not b.exact:
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
def behavior(bundle):
|
|
134
|
+
data = bundle.spec.model_dump(mode="json", exclude_none=True)
|
|
135
|
+
for key in ("name", "creature_hash", "task_hash", "runtime_versions"):
|
|
136
|
+
data.pop(key, None)
|
|
137
|
+
if bundle.policy_source is not None:
|
|
138
|
+
data["policy_file"] = file_hash(bundle.policy_source)
|
|
139
|
+
return data
|
|
140
|
+
|
|
141
|
+
return behavior(a) != behavior(b)
|
|
142
|
+
except (OSError, ValueError):
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def run_autopsy(
|
|
147
|
+
creature: CreatureSpec,
|
|
148
|
+
task: TaskSpec,
|
|
149
|
+
*,
|
|
150
|
+
simulate: SimulateFn,
|
|
151
|
+
simulate_reference: SimulateFn | None,
|
|
152
|
+
profile: QualificationProfile | None = None,
|
|
153
|
+
simulate_other_backend: SimulateFn | None = None,
|
|
154
|
+
robustness_trials: int = 5,
|
|
155
|
+
) -> AutopsyResult:
|
|
156
|
+
"""Run baseline/counterfactual/perturbation/backend evidence and attribute failure."""
|
|
157
|
+
profile = profile or infer_profile(task)
|
|
158
|
+
setup = task_setup_check(profile, task)
|
|
159
|
+
if setup is not None and not setup.passed:
|
|
160
|
+
raise ValueError(f"Task/profile mismatch: {setup.detail}")
|
|
161
|
+
if profile.max_sim2sim_gap is not None and simulate_other_backend is None:
|
|
162
|
+
raise ValueError("This profile requires a second physics backend.")
|
|
163
|
+
baseline = simulate(creature, task)
|
|
164
|
+
baseline_summary = summarize_episode(baseline, task, creature)
|
|
165
|
+
baseline_ok = task_success(profile, baseline, baseline_summary)
|
|
166
|
+
|
|
167
|
+
reference = simulate_reference(creature, task) if simulate_reference is not None else None
|
|
168
|
+
reference_summary = summarize_episode(reference, task, creature) if reference else None
|
|
169
|
+
reference_ok = task_success(profile, reference, reference_summary) if reference else None
|
|
170
|
+
trial_recordings: list[TrialRecording] = []
|
|
171
|
+
|
|
172
|
+
def evaluate(c: CreatureSpec, t: TaskSpec) -> tuple[float, bool, bool]:
|
|
173
|
+
trace = simulate(c, t)
|
|
174
|
+
trial_recordings.append(TrialRecording(c, t, trace))
|
|
175
|
+
summary = summarize_episode(trace, t, c)
|
|
176
|
+
return trace.score, bool(summary.fell), task_success(profile, trace, summary)
|
|
177
|
+
|
|
178
|
+
robustness = run_trials(
|
|
179
|
+
creature,
|
|
180
|
+
task,
|
|
181
|
+
evaluate,
|
|
182
|
+
trials=robustness_trials,
|
|
183
|
+
mass_jitter=profile.robustness_mass_jitter,
|
|
184
|
+
friction_jitter=profile.robustness_friction_jitter,
|
|
185
|
+
)
|
|
186
|
+
robust_ok = robustness.fail_rate <= profile.max_fail_rate
|
|
187
|
+
|
|
188
|
+
other = simulate_other_backend(creature, task) if simulate_other_backend is not None else None
|
|
189
|
+
portability_ok = True
|
|
190
|
+
normalized_gap = None
|
|
191
|
+
if other is not None:
|
|
192
|
+
normalized_gap = abs(baseline.score - other.score) / max(
|
|
193
|
+
1.0, abs(baseline.score), abs(other.score)
|
|
194
|
+
)
|
|
195
|
+
threshold = profile.max_sim2sim_gap if profile.max_sim2sim_gap is not None else 0.2
|
|
196
|
+
portability_ok = normalized_gap <= threshold
|
|
197
|
+
|
|
198
|
+
diagnosis = diagnose(baseline, creature, task)
|
|
199
|
+
evidence = [
|
|
200
|
+
AutopsyEvidence(
|
|
201
|
+
"Selected controller task success",
|
|
202
|
+
baseline_ok,
|
|
203
|
+
f"score={baseline.score:.4f}, fell={bool(baseline_summary.fell)}",
|
|
204
|
+
),
|
|
205
|
+
AutopsyEvidence(
|
|
206
|
+
"Curated-controller counterfactual",
|
|
207
|
+
reference_ok,
|
|
208
|
+
(
|
|
209
|
+
f"score={reference.score:.4f}, fell={bool(reference_summary.fell)}"
|
|
210
|
+
if reference is not None and reference_summary is not None
|
|
211
|
+
else "Not run: selected and curated controllers cannot be distinguished."
|
|
212
|
+
),
|
|
213
|
+
),
|
|
214
|
+
AutopsyEvidence(
|
|
215
|
+
"Perturbation robustness",
|
|
216
|
+
robust_ok,
|
|
217
|
+
f"task failure rate={robustness.fail_rate:.0%} over {len(robustness.trials)} trials",
|
|
218
|
+
),
|
|
219
|
+
]
|
|
220
|
+
if other is not None:
|
|
221
|
+
evidence.append(
|
|
222
|
+
AutopsyEvidence(
|
|
223
|
+
"Backend agreement",
|
|
224
|
+
portability_ok,
|
|
225
|
+
f"normalized score gap={normalized_gap:.3f} "
|
|
226
|
+
f"({baseline.backend} vs {other.backend})",
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if not baseline_ok and reference_ok is None:
|
|
231
|
+
cause = "undetermined"
|
|
232
|
+
confidence = "low"
|
|
233
|
+
summary = "No distinct controller counterfactual was available; the cause is unknown."
|
|
234
|
+
recommendations = ["Select a different controller and rerun the analysis."]
|
|
235
|
+
elif not baseline_ok and reference_ok:
|
|
236
|
+
cause = "controller"
|
|
237
|
+
confidence = "high"
|
|
238
|
+
summary = "The same body and task succeed with the curated controller."
|
|
239
|
+
recommendations = [
|
|
240
|
+
"Compare joint targets and phase relationships against the curated controller.",
|
|
241
|
+
"Reduce gait amplitude/frequency or migrate the curated gait into controller.json.",
|
|
242
|
+
]
|
|
243
|
+
elif not baseline_ok and not reference_ok:
|
|
244
|
+
cause = "undetermined"
|
|
245
|
+
confidence = "low"
|
|
246
|
+
summary = (
|
|
247
|
+
"Both tested controllers failed. The evidence does not distinguish a controller "
|
|
248
|
+
"problem from a body or task problem."
|
|
249
|
+
)
|
|
250
|
+
recommendations = [
|
|
251
|
+
"Try another controller or change one body or task variable at a time.",
|
|
252
|
+
"Check task scale, target/event placement, support polygon, and center of mass.",
|
|
253
|
+
]
|
|
254
|
+
elif not robust_ok:
|
|
255
|
+
cause = "fragility"
|
|
256
|
+
confidence = "high"
|
|
257
|
+
summary = "The baseline works, but small mass/friction changes frequently break it."
|
|
258
|
+
recommendations = [
|
|
259
|
+
"Widen the stance, lower the center of mass, or slow the controller.",
|
|
260
|
+
"Tune against a distribution of perturbations instead of one nominal setup.",
|
|
261
|
+
]
|
|
262
|
+
elif not portability_ok:
|
|
263
|
+
cause = "backend_sensitivity"
|
|
264
|
+
confidence = "medium"
|
|
265
|
+
summary = "Nominal behavior depends strongly on one backend's contact dynamics."
|
|
266
|
+
recommendations = [
|
|
267
|
+
"Inspect trajectory divergence and reduce contact-model-sensitive gait tuning.",
|
|
268
|
+
"Treat the artifact as structurally portable, not behaviorally portable yet.",
|
|
269
|
+
]
|
|
270
|
+
else:
|
|
271
|
+
cause = "no_failure_detected"
|
|
272
|
+
confidence = "high"
|
|
273
|
+
summary = "The selected controller passed nominal, perturbation, and requested checks."
|
|
274
|
+
recommendations = [
|
|
275
|
+
"Increase perturbation strength or add a more demanding task-specific threshold."
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
if diagnosis.patterns:
|
|
279
|
+
recommendations.extend(diagnosis.suggestions[:2])
|
|
280
|
+
return AutopsyResult(
|
|
281
|
+
creature=creature.name,
|
|
282
|
+
task=task.name,
|
|
283
|
+
profile=profile.name,
|
|
284
|
+
primary_cause=cause,
|
|
285
|
+
confidence=confidence,
|
|
286
|
+
summary=summary,
|
|
287
|
+
evidence=evidence,
|
|
288
|
+
recommendations=list(dict.fromkeys(recommendations)),
|
|
289
|
+
baseline_trace=baseline,
|
|
290
|
+
reference_trace=reference,
|
|
291
|
+
other_backend_trace=other,
|
|
292
|
+
robustness=robustness,
|
|
293
|
+
trial_recordings=trial_recordings,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def write_autopsy_evidence(
|
|
298
|
+
result: AutopsyResult,
|
|
299
|
+
creature: CreatureSpec,
|
|
300
|
+
task: TaskSpec,
|
|
301
|
+
out: Path,
|
|
302
|
+
*,
|
|
303
|
+
overwrite: bool = False,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Save hash-verifiable packs for each recorded counterfactual and trial."""
|
|
306
|
+
import shutil
|
|
307
|
+
|
|
308
|
+
from creature_lab.exporting import export_design_pack, verify_design_pack
|
|
309
|
+
|
|
310
|
+
evidence_dir = out / "evidence"
|
|
311
|
+
if evidence_dir.exists():
|
|
312
|
+
if not overwrite:
|
|
313
|
+
raise FileExistsError(f"evidence directory already exists: {evidence_dir}")
|
|
314
|
+
if evidence_dir.is_symlink() or evidence_dir.resolve().parent != out.resolve():
|
|
315
|
+
raise ValueError("refusing to replace an evidence directory outside the report")
|
|
316
|
+
shutil.rmtree(evidence_dir)
|
|
317
|
+
recordings: list[tuple[str, CreatureSpec, TaskSpec, EpisodeTrace]] = []
|
|
318
|
+
if result.reference_trace is not None:
|
|
319
|
+
recordings.append(("reference", creature, task, result.reference_trace))
|
|
320
|
+
if result.other_backend_trace is not None:
|
|
321
|
+
recordings.append(("other_backend", creature, task, result.other_backend_trace))
|
|
322
|
+
for index, trial in enumerate(result.trial_recordings):
|
|
323
|
+
recordings.append((f"trial_{index + 1:03d}", trial.creature, trial.task, trial.trace))
|
|
324
|
+
for name, body, scenario, trace in recordings:
|
|
325
|
+
destination = evidence_dir / name
|
|
326
|
+
export_design_pack(body, scenario, trace, out_dir=destination)
|
|
327
|
+
verification = verify_design_pack(destination)
|
|
328
|
+
if not verification.valid:
|
|
329
|
+
raise ValueError(
|
|
330
|
+
f"{name} evidence verification failed: {'; '.join(verification.errors)}"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def autopsy_to_markdown(result: AutopsyResult) -> str:
|
|
335
|
+
lines = [
|
|
336
|
+
f"# Creature Lab Experiment Autopsy: {result.creature}",
|
|
337
|
+
"",
|
|
338
|
+
f"**Candidate explanation:** `{result.primary_cause}` (heuristic)",
|
|
339
|
+
"",
|
|
340
|
+
"This rule-based assessment is not a calibrated probability or proof of causation.",
|
|
341
|
+
"",
|
|
342
|
+
result.summary,
|
|
343
|
+
"",
|
|
344
|
+
"## Evidence",
|
|
345
|
+
"",
|
|
346
|
+
]
|
|
347
|
+
for item in result.evidence:
|
|
348
|
+
status = "SKIPPED" if item.passed is None else "PASS" if item.passed else "FAIL"
|
|
349
|
+
lines.append(f"- {status} — **{item.name}:** {item.detail}")
|
|
350
|
+
lines.extend(["", "## Recommended next experiments", ""])
|
|
351
|
+
lines.extend(f"- {item}" for item in result.recommendations)
|
|
352
|
+
lines.append("")
|
|
353
|
+
return "\n".join(lines)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def autopsy_to_html(result: AutopsyResult) -> str:
|
|
357
|
+
payload = result.to_json_dict()
|
|
358
|
+
evidence = "".join(
|
|
359
|
+
f"<li class='{'skipped' if item.passed is None else 'pass' if item.passed else 'fail'}'>"
|
|
360
|
+
"<strong>"
|
|
361
|
+
f"{html.escape(item.name)}</strong>: {html.escape(item.detail)}</li>"
|
|
362
|
+
for item in result.evidence
|
|
363
|
+
)
|
|
364
|
+
recommendations = "".join(f"<li>{html.escape(item)}</li>" for item in result.recommendations)
|
|
365
|
+
return f"""<!doctype html>
|
|
366
|
+
<html><head><meta charset="utf-8"><title>Creature Lab Autopsy</title>
|
|
367
|
+
<style>body{{font:16px system-ui;max-width:900px;margin:3rem auto;padding:0 1rem;color:#172033}}
|
|
368
|
+
.card{{border:1px solid #ccd3df;border-radius:12px;padding:1.2rem;margin:1rem 0}}
|
|
369
|
+
.pass{{color:#176b3a}}.fail{{color:#a52b2b}}code{{background:#eef1f6;padding:.15rem .35rem}}
|
|
370
|
+
</style></head><body><h1>Experiment Autopsy: {html.escape(result.creature)}</h1>
|
|
371
|
+
<div class="card"><h2>Candidate explanation: <code>{html.escape(result.primary_cause)}</code></h2>
|
|
372
|
+
<p>{html.escape(result.summary)}</p><p>This rule-based assessment is not a calibrated
|
|
373
|
+
probability or proof of causation.</p></div>
|
|
374
|
+
<h2>Evidence</h2><ul>{evidence}</ul><h2>Recommended next experiments</h2>
|
|
375
|
+
<ul>{recommendations}</ul><details><summary>Machine-readable result</summary>
|
|
376
|
+
<pre>{html.escape(json.dumps(payload, indent=2))}</pre></details></body></html>"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Simulator adapters. Only backend modules may import a physics engine."""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Backend-neutral simulator protocol.
|
|
2
|
+
|
|
3
|
+
Every physics engine adapter (PyBullet first, others later) implements this
|
|
4
|
+
protocol. Code outside `backends/` must only depend on this interface, never
|
|
5
|
+
on a specific engine.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
from creature_lab.schema import CreatureSpec, FrameState, TaskSpec
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class SimBackend(Protocol):
|
|
17
|
+
def build(self, creature: CreatureSpec, task: TaskSpec, *, seed: int | None = None) -> None: ...
|
|
18
|
+
def reset(self) -> None: ...
|
|
19
|
+
def step(self, dt: float) -> FrameState: ...
|
|
20
|
+
def observe(self) -> FrameState: ...
|
|
21
|
+
def apply_motor_targets(self, targets: dict[str, float]) -> None: ...
|
|
22
|
+
def apply_joint_control(self, targets: dict[str, float], mode: str = "position") -> None: ...
|
|
23
|
+
def damage_part(self, part_id: str) -> None: ...
|
|
24
|
+
def score_summary(self) -> dict[str, float]: ...
|
|
25
|
+
def close(self) -> None: ...
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""MuJoCo implementation of the SimBackend protocol.
|
|
2
|
+
|
|
3
|
+
Only this module imports ``mujoco``. The model is built at runtime from the MJCF
|
|
4
|
+
exporter (``export_mjcf``), so the creature spec stays the single source of truth.
|
|
5
|
+
Scoring reuses the backend-neutral ``scoring`` module, exactly like the PyBullet
|
|
6
|
+
backend, so the two engines score episodes the same way (even though their exact
|
|
7
|
+
dynamics differ — see docs/archive/MVP_PLAN.md on portability).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
|
|
14
|
+
import mujoco
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from creature_lab.export.mjcf import export_mjcf
|
|
18
|
+
from creature_lab.schema import CreatureSpec, PartPose, TaskSpec
|
|
19
|
+
from creature_lab.schema.creature import JointType
|
|
20
|
+
from creature_lab.schema.trace import ContactSpec, FrameState
|
|
21
|
+
from creature_lab.scoring import score_components
|
|
22
|
+
from creature_lab.terrain import is_flat, normalized_heightfield_data
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def backend_version() -> str:
|
|
26
|
+
"""Human-readable MuJoCo backend version."""
|
|
27
|
+
return f"mujoco {mujoco.__version__}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MuJoCoBackend:
|
|
31
|
+
"""Builds a creature as a MuJoCo model and steps it."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, gui: bool = False) -> None:
|
|
34
|
+
# gui is accepted for protocol parity; this backend renders via traces.
|
|
35
|
+
self._creature: CreatureSpec | None = None
|
|
36
|
+
self._task: TaskSpec | None = None
|
|
37
|
+
self._model: mujoco.MjModel | None = None
|
|
38
|
+
self._data: mujoco.MjData | None = None
|
|
39
|
+
self._root_body = ""
|
|
40
|
+
self._ground_geom = -1
|
|
41
|
+
self._geom_part: dict[int, str] = {}
|
|
42
|
+
self._damaged: set[str] = set()
|
|
43
|
+
self._t = 0.0
|
|
44
|
+
self._energy = 0.0
|
|
45
|
+
self._initial_root_x = 0.0
|
|
46
|
+
self._initial_target_distance = 0.0
|
|
47
|
+
self._last_score_components: dict[str, float] = {}
|
|
48
|
+
self._damage_fired = False
|
|
49
|
+
self._impulse_fired = False
|
|
50
|
+
self._seed: int | None = None
|
|
51
|
+
|
|
52
|
+
def build(self, creature: CreatureSpec, task: TaskSpec, *, seed: int | None = None) -> None:
|
|
53
|
+
self._creature = creature
|
|
54
|
+
self._task = task
|
|
55
|
+
self._seed = seed
|
|
56
|
+
xml = export_mjcf(
|
|
57
|
+
creature, friction=task.terrain.friction, timestep=task.timestep, terrain=task.terrain
|
|
58
|
+
)
|
|
59
|
+
self._model = mujoco.MjModel.from_xml_string(xml)
|
|
60
|
+
if not is_flat(task.terrain):
|
|
61
|
+
self._model.hfield_data[:] = normalized_heightfield_data(task.terrain)
|
|
62
|
+
self._data = mujoco.MjData(self._model)
|
|
63
|
+
|
|
64
|
+
child_ids = {joint.child for joint in creature.joints}
|
|
65
|
+
self._root_body = next(p.id for p in creature.parts if p.id not in child_ids)
|
|
66
|
+
self._ground_geom = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_GEOM, "ground")
|
|
67
|
+
# geom index -> part id (each part has exactly one geom, added in part order).
|
|
68
|
+
self._geom_part = {}
|
|
69
|
+
for part in creature.parts:
|
|
70
|
+
body_id = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_BODY, part.id)
|
|
71
|
+
for geom in range(
|
|
72
|
+
self._model.body_geomadr[body_id],
|
|
73
|
+
self._model.body_geomadr[body_id] + self._model.body_geomnum[body_id],
|
|
74
|
+
):
|
|
75
|
+
self._geom_part[geom] = part.id
|
|
76
|
+
|
|
77
|
+
self._t = 0.0
|
|
78
|
+
self._energy = 0.0
|
|
79
|
+
self._damaged.clear()
|
|
80
|
+
self._damage_fired = False
|
|
81
|
+
self._impulse_fired = False
|
|
82
|
+
mujoco.mj_forward(self._model, self._data)
|
|
83
|
+
self._initial_root_x = float(self._root_pos()[0])
|
|
84
|
+
self._initial_target_distance = (
|
|
85
|
+
math.dist(self._root_pos(), task.target.position) if task.target is not None else 0.0
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def reset(self) -> None:
|
|
89
|
+
if self._creature is None or self._task is None:
|
|
90
|
+
raise RuntimeError("build() must be called before reset()")
|
|
91
|
+
self.build(self._creature, self._task, seed=self._seed)
|
|
92
|
+
|
|
93
|
+
def step(self, dt: float) -> FrameState:
|
|
94
|
+
if self._model is None or self._data is None or self._task is None:
|
|
95
|
+
raise RuntimeError("build() must be called before step()")
|
|
96
|
+
self._t += dt
|
|
97
|
+
events: list[str] = []
|
|
98
|
+
|
|
99
|
+
damage = self._task.damage_event
|
|
100
|
+
if damage is not None and not self._damage_fired and self._t >= damage.time:
|
|
101
|
+
self.damage_part(damage.part_id)
|
|
102
|
+
self._damage_fired = True
|
|
103
|
+
events.append(f"damage:{damage.part_id}")
|
|
104
|
+
|
|
105
|
+
impulse = self._task.impulse_event
|
|
106
|
+
if impulse is not None and not self._impulse_fired and self._t >= impulse.time:
|
|
107
|
+
body_id = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_BODY, impulse.part_id)
|
|
108
|
+
self._data.xfrc_applied[body_id, :3] = impulse.force
|
|
109
|
+
self._impulse_fired = True
|
|
110
|
+
events.append(f"impulse:{impulse.part_id}")
|
|
111
|
+
|
|
112
|
+
# ``step(dt)`` is part of the backend contract. Keep MuJoCo's actual
|
|
113
|
+
# integrator step aligned with the requested duration instead of advancing
|
|
114
|
+
# physics by a stale model timestep while only bookkeeping uses ``dt``.
|
|
115
|
+
self._model.opt.timestep = dt
|
|
116
|
+
mujoco.mj_step(self._model, self._data)
|
|
117
|
+
if impulse is not None and self._impulse_fired:
|
|
118
|
+
body_id = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_BODY, impulse.part_id)
|
|
119
|
+
self._data.xfrc_applied[body_id, :3] = 0.0 # one-step push only
|
|
120
|
+
self._accumulate_energy(dt)
|
|
121
|
+
|
|
122
|
+
frame = self._read_frame()
|
|
123
|
+
if events:
|
|
124
|
+
frame = frame.model_copy(update={"events": events})
|
|
125
|
+
return frame
|
|
126
|
+
|
|
127
|
+
def apply_motor_targets(self, targets: dict[str, float]) -> None:
|
|
128
|
+
if self._model is None or self._data is None:
|
|
129
|
+
raise RuntimeError("build() must be called before apply_motor_targets()")
|
|
130
|
+
for joint_id, value in targets.items():
|
|
131
|
+
if joint_id in self._damaged:
|
|
132
|
+
continue
|
|
133
|
+
actuator_id = mujoco.mj_name2id(
|
|
134
|
+
self._model, mujoco.mjtObj.mjOBJ_ACTUATOR, f"act_{joint_id}"
|
|
135
|
+
)
|
|
136
|
+
if actuator_id >= 0:
|
|
137
|
+
self._data.ctrl[actuator_id] = value
|
|
138
|
+
|
|
139
|
+
def apply_joint_control(self, targets: dict[str, float], mode: str = "position") -> None:
|
|
140
|
+
"""Position control of motored joints (used by CreatureEnv).
|
|
141
|
+
|
|
142
|
+
The MJCF exports ``<position>`` servos, so only position mode is supported;
|
|
143
|
+
velocity/torque control would need a different actuator model.
|
|
144
|
+
"""
|
|
145
|
+
if mode != "position":
|
|
146
|
+
raise NotImplementedError(
|
|
147
|
+
f"MuJoCo backend supports position control only, not {mode!r}"
|
|
148
|
+
)
|
|
149
|
+
self.apply_motor_targets(targets)
|
|
150
|
+
|
|
151
|
+
def damage_part(self, part_id: str) -> None:
|
|
152
|
+
if self._creature is None:
|
|
153
|
+
raise RuntimeError("build() must be called before damage_part()")
|
|
154
|
+
# Disable the actuator on the joint that drives this part (best-effort parity
|
|
155
|
+
# with PyBullet: the limb goes limp).
|
|
156
|
+
for joint in self._creature.joints:
|
|
157
|
+
if joint.child == part_id:
|
|
158
|
+
self._damaged.add(joint.id)
|
|
159
|
+
actuator_id = mujoco.mj_name2id(
|
|
160
|
+
self._model, mujoco.mjtObj.mjOBJ_ACTUATOR, f"act_{joint.id}"
|
|
161
|
+
)
|
|
162
|
+
if actuator_id >= 0:
|
|
163
|
+
self._data.ctrl[actuator_id] = 0.0
|
|
164
|
+
|
|
165
|
+
def close(self) -> None:
|
|
166
|
+
self._model = None
|
|
167
|
+
self._data = None
|
|
168
|
+
|
|
169
|
+
def score_summary(self) -> dict[str, float]:
|
|
170
|
+
return dict(self._last_score_components)
|
|
171
|
+
|
|
172
|
+
def observe(self) -> FrameState:
|
|
173
|
+
return self._read_frame()
|
|
174
|
+
|
|
175
|
+
# -- internals --------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def _root_pos(self) -> tuple[float, float, float]:
|
|
178
|
+
body_id = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_BODY, self._root_body)
|
|
179
|
+
return tuple(float(v) for v in self._data.xpos[body_id])
|
|
180
|
+
|
|
181
|
+
def _accumulate_energy(self, dt: float) -> None:
|
|
182
|
+
for joint in self._creature.joints:
|
|
183
|
+
if joint.type != JointType.HINGE:
|
|
184
|
+
continue
|
|
185
|
+
jid = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_JOINT, joint.id)
|
|
186
|
+
if jid < 0:
|
|
187
|
+
continue
|
|
188
|
+
velocity = float(self._data.qvel[self._model.jnt_dofadr[jid]])
|
|
189
|
+
self._energy += velocity * velocity * dt
|
|
190
|
+
|
|
191
|
+
def _read_frame(self) -> FrameState:
|
|
192
|
+
parts: dict[str, PartPose] = {}
|
|
193
|
+
for part in self._creature.parts:
|
|
194
|
+
body_id = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_BODY, part.id)
|
|
195
|
+
position = tuple(float(v) for v in self._data.xpos[body_id])
|
|
196
|
+
orientation = tuple(float(v) for v in self._data.xquat[body_id]) # MuJoCo: w,x,y,z
|
|
197
|
+
parts[part.id] = PartPose(position=position, orientation=orientation)
|
|
198
|
+
|
|
199
|
+
joint_angles: dict[str, float] = {}
|
|
200
|
+
for joint in self._creature.joints:
|
|
201
|
+
if joint.type != JointType.HINGE:
|
|
202
|
+
continue
|
|
203
|
+
jid = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_JOINT, joint.id)
|
|
204
|
+
if jid >= 0:
|
|
205
|
+
joint_angles[joint.id] = float(self._data.qpos[self._model.jnt_qposadr[jid]])
|
|
206
|
+
|
|
207
|
+
root_position = self._root_pos()
|
|
208
|
+
forward_distance = root_position[0] - self._initial_root_x
|
|
209
|
+
target_progress = 0.0
|
|
210
|
+
if self._task.target is not None:
|
|
211
|
+
target_progress = self._initial_target_distance - math.dist(
|
|
212
|
+
root_position, self._task.target.position
|
|
213
|
+
)
|
|
214
|
+
root_body = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_BODY, self._root_body)
|
|
215
|
+
up_z = float(self._data.xmat[root_body].reshape(3, 3)[2, 2])
|
|
216
|
+
|
|
217
|
+
self._last_score_components = score_components(
|
|
218
|
+
self._task.reward,
|
|
219
|
+
forward_distance=forward_distance,
|
|
220
|
+
target_progress=target_progress,
|
|
221
|
+
energy=self._energy,
|
|
222
|
+
fallen=up_z < 0.5,
|
|
223
|
+
)
|
|
224
|
+
return FrameState(
|
|
225
|
+
t=self._t,
|
|
226
|
+
parts=parts,
|
|
227
|
+
joint_angles=joint_angles,
|
|
228
|
+
contacts=self._read_contacts(),
|
|
229
|
+
score=self._last_score_components["total"],
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def _read_contacts(self) -> list[ContactSpec]:
|
|
233
|
+
contacts: list[ContactSpec] = []
|
|
234
|
+
force = np.zeros(6)
|
|
235
|
+
for i in range(self._data.ncon):
|
|
236
|
+
contact = self._data.contact[i]
|
|
237
|
+
part_geom = None
|
|
238
|
+
if contact.geom1 == self._ground_geom:
|
|
239
|
+
part_geom = contact.geom2
|
|
240
|
+
elif contact.geom2 == self._ground_geom:
|
|
241
|
+
part_geom = contact.geom1
|
|
242
|
+
if part_geom is None or part_geom not in self._geom_part:
|
|
243
|
+
continue
|
|
244
|
+
mujoco.mj_contactForce(self._model, self._data, i, force)
|
|
245
|
+
contacts.append(
|
|
246
|
+
ContactSpec(
|
|
247
|
+
part_id=self._geom_part[part_geom],
|
|
248
|
+
position=tuple(float(v) for v in contact.pos),
|
|
249
|
+
normal=tuple(float(v) for v in contact.frame[:3]),
|
|
250
|
+
force=max(0.0, float(force[0])), # normal component
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
return contacts
|