xlab-api 0.1.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.
- xlab_api/__init__.py +66 -0
- xlab_api/data.py +90 -0
- xlab_api/py.typed +1 -0
- xlab_api/spec/__init__.py +118 -0
- xlab_api/spec/_names.py +15 -0
- xlab_api/spec/articulation.py +34 -0
- xlab_api/spec/capability.py +198 -0
- xlab_api/spec/entity.py +156 -0
- xlab_api/spec/freeze.py +92 -0
- xlab_api/spec/lifecycle.py +428 -0
- xlab_api/spec/mdp.py +384 -0
- xlab_api/spec/motion_reference.py +323 -0
- xlab_api/spec/portability.py +106 -0
- xlab_api/spec/relations.py +29 -0
- xlab_api/spec/rigid_object.py +103 -0
- xlab_api/spec/robot.py +300 -0
- xlab_api/spec/sensor.py +481 -0
- xlab_api/spec/task.py +524 -0
- xlab_api/spec/validation.py +364 -0
- xlab_api/spec/volume.py +150 -0
- xlab_api-0.1.0.dist-info/METADATA +21 -0
- xlab_api-0.1.0.dist-info/RECORD +24 -0
- xlab_api-0.1.0.dist-info/WHEEL +5 -0
- xlab_api-0.1.0.dist-info/top_level.txt +1 -0
xlab_api/spec/mdp.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""The MDP of a task, declared once for every engine.
|
|
2
|
+
|
|
3
|
+
A term is stated in one of two ways. The choice follows the implementation's
|
|
4
|
+
actual portability; event terms in particular may use either form:
|
|
5
|
+
|
|
6
|
+
**Portable families carry a function.** Observations, rewards, terminations and commands are
|
|
7
|
+
written as ``ObsTermSpec(mdp.base_ang_vel, ...)`` -- a direct reference to a function in
|
|
8
|
+
:mod:`xlab.tasks.<task>.mdp` that runs unmodified under either engine's native manager. This is not a
|
|
9
|
+
convenience. It is the reason migration is cheap: an Isaac Lab task's term definitions transfer
|
|
10
|
+
almost verbatim, with the import redirected and the class renamed, because the function itself did
|
|
11
|
+
not have to change.
|
|
12
|
+
|
|
13
|
+
**Native terms carry a semantic name.** Actions and events that change native
|
|
14
|
+
physics state are stated as ``EventTermSpec(kind="randomize_friction")`` and
|
|
15
|
+
resolved through each engine's term registry. Isaac Lab randomises friction
|
|
16
|
+
per shape across a bucket pool, while MJLab changes MuJoCo geom fields; choosing
|
|
17
|
+
one native function as the shared implementation would silently change the
|
|
18
|
+
other engine's behavior. Named intent keeps those translations explicit.
|
|
19
|
+
|
|
20
|
+
An event that only uses the shared environment surface should instead carry its
|
|
21
|
+
task-owned function, just like an observation or reward. Adding such an event
|
|
22
|
+
does not require an engine registration. A new ``kind=`` is reserved for an
|
|
23
|
+
operation that genuinely needs a new native adapter or capability.
|
|
24
|
+
|
|
25
|
+
The split is visible in :class:`TermSpec`: exactly one of ``func`` and ``kind`` is set, and which
|
|
26
|
+
one is set determines whether the compiler needs a per-engine mapping at all.
|
|
27
|
+
|
|
28
|
+
Engine-specific values reach a term through ``engine_params``, keyed by engine name. That is data,
|
|
29
|
+
not a branch -- ``spec/`` may not contain ``if engine == ...`` anywhere, since a conditional here
|
|
30
|
+
would mean the declaration is no longer engine-agnostic and every future engine would have to edit
|
|
31
|
+
task files to be supported.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import math
|
|
37
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
38
|
+
from dataclasses import dataclass, field, replace
|
|
39
|
+
from typing import Any, Literal
|
|
40
|
+
|
|
41
|
+
from .capability import Requirement
|
|
42
|
+
from .entity import EntityRef
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"ActionTermSpec",
|
|
46
|
+
"CommandTermSpec",
|
|
47
|
+
"CurriculumTermSpec",
|
|
48
|
+
"DoneTermSpec",
|
|
49
|
+
"EventTermSpec",
|
|
50
|
+
"MdpSpec",
|
|
51
|
+
"NoiseSpec",
|
|
52
|
+
"ObsGroupSpec",
|
|
53
|
+
"ObsTermSpec",
|
|
54
|
+
"RewardTermSpec",
|
|
55
|
+
"TermSpec",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def walk_parameter_values(values: Iterable[Any]) -> Iterable[Any]:
|
|
60
|
+
"""Yield parameter values recursively through mappings and sequences."""
|
|
61
|
+
for value in values:
|
|
62
|
+
yield value
|
|
63
|
+
if isinstance(value, Mapping):
|
|
64
|
+
yield from walk_parameter_values(value.values())
|
|
65
|
+
elif isinstance(value, (tuple, list, set, frozenset)):
|
|
66
|
+
yield from walk_parameter_values(value)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class NoiseSpec:
|
|
71
|
+
"""Observation noise, in the two shapes both engines' noise models already share.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
kind: ``"uniform"`` over ``[lo, hi]``, or ``"gaussian"`` with mean ``lo`` and standard
|
|
75
|
+
deviation ``hi``. The reuse of the field names for the gaussian case is deliberate --
|
|
76
|
+
they are the two numbers each distribution takes, and naming them per-distribution
|
|
77
|
+
would need a class per distribution for no gain.
|
|
78
|
+
lo: Lower bound, or the mean.
|
|
79
|
+
hi: Upper bound, or the standard deviation.
|
|
80
|
+
operation: Whether the sample is added to the observation or scales it.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
kind: Literal["uniform", "gaussian"]
|
|
84
|
+
lo: float
|
|
85
|
+
hi: float
|
|
86
|
+
operation: Literal["add", "scale", "abs"] = "add"
|
|
87
|
+
|
|
88
|
+
def __post_init__(self) -> None:
|
|
89
|
+
if self.kind == "uniform" and self.lo > self.hi:
|
|
90
|
+
raise ValueError(f"Uniform noise has lo={self.lo} above hi={self.hi}.")
|
|
91
|
+
if self.kind == "gaussian" and self.hi < 0.0:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"Gaussian noise has a negative standard deviation: {self.hi}."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class TermSpec:
|
|
99
|
+
"""Base of every MDP term.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
func: The task-owned portable implementation, run directly by either engine's native
|
|
103
|
+
manager.
|
|
104
|
+
kind: The semantic name for a native term, looked up in the running engine's registry.
|
|
105
|
+
Exactly one of ``func`` and ``kind`` is set.
|
|
106
|
+
params: Arguments passed to the term, identical on every engine.
|
|
107
|
+
target: The entity subset the term acts on, if any.
|
|
108
|
+
level: What the compiler does when this engine cannot provide the term.
|
|
109
|
+
engine_params: Per-engine parameter overrides, merged over ``params`` at compile time by
|
|
110
|
+
:meth:`resolved_params`. Data rather than a branch, so that adding an engine never
|
|
111
|
+
edits a task file.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
func: Callable[..., Any] | None = None
|
|
115
|
+
kind: str | None = None
|
|
116
|
+
params: Mapping[str, Any] = field(default_factory=dict)
|
|
117
|
+
target: EntityRef | None = None
|
|
118
|
+
level: Requirement = Requirement.OPTIONAL
|
|
119
|
+
engine_params: Mapping[str, Mapping[str, Any]] = field(default_factory=dict)
|
|
120
|
+
|
|
121
|
+
def __post_init__(self) -> None:
|
|
122
|
+
if (self.func is None) == (self.kind is None):
|
|
123
|
+
stated = "both" if self.func is not None else "neither"
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"{type(self).__name__} must set exactly one of func / kind; {stated} was given. "
|
|
126
|
+
"Portable terms carry the task function; native terms carry a semantic name."
|
|
127
|
+
)
|
|
128
|
+
object.__setattr__(self, "params", dict(self.params))
|
|
129
|
+
object.__setattr__(
|
|
130
|
+
self, "engine_params", {k: dict(v) for k, v in self.engine_params.items()}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def is_portable(self) -> bool:
|
|
135
|
+
"""Whether this term runs the same implementation on every engine."""
|
|
136
|
+
return self.func is not None
|
|
137
|
+
|
|
138
|
+
def resolved_params(self, engine: str) -> dict[str, Any]:
|
|
139
|
+
"""``params`` with this engine's overrides applied. The only place the two are combined.
|
|
140
|
+
|
|
141
|
+
Nested mappings merge rather than replace, so a shared ``velocity_ranges`` dict can keep
|
|
142
|
+
the nine common sub-terrains and put only the tenth name in ``engine_params``. A shallow
|
|
143
|
+
update would drop the shared keys on that engine and the command would raise at init.
|
|
144
|
+
"""
|
|
145
|
+
merged = dict(self.params)
|
|
146
|
+
for key, value in self.engine_params.get(engine, {}).items():
|
|
147
|
+
existing = merged.get(key)
|
|
148
|
+
if isinstance(existing, Mapping) and isinstance(value, Mapping):
|
|
149
|
+
nested = dict(existing)
|
|
150
|
+
nested.update(value)
|
|
151
|
+
merged[key] = nested
|
|
152
|
+
else:
|
|
153
|
+
merged[key] = value
|
|
154
|
+
return merged
|
|
155
|
+
|
|
156
|
+
def engines_named(self) -> frozenset[str]:
|
|
157
|
+
"""Engines this term mentions by name, so the compiler can reject typos in engine keys."""
|
|
158
|
+
return frozenset(self.engine_params)
|
|
159
|
+
|
|
160
|
+
def replace(self, **changes: Any) -> TermSpec:
|
|
161
|
+
"""Return the same term with only the named configuration fields changed."""
|
|
162
|
+
return replace(self, **changes)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True)
|
|
166
|
+
class ObsTermSpec(TermSpec):
|
|
167
|
+
"""One observation. REQUIRED by default: dropping it changes the policy's input width."""
|
|
168
|
+
|
|
169
|
+
noise: NoiseSpec | None = None
|
|
170
|
+
scale: float | None = None
|
|
171
|
+
clip: tuple[float, float] | None = None
|
|
172
|
+
history_length: int = 0
|
|
173
|
+
level: Requirement = Requirement.REQUIRED
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True)
|
|
177
|
+
class RewardTermSpec(TermSpec):
|
|
178
|
+
"""One reward. OPTIONAL by default, and therefore the term family most worth watching.
|
|
179
|
+
|
|
180
|
+
A skipped reward leaves a run that trains, converges and looks healthy while optimising a
|
|
181
|
+
different objective than the one written down. That is why every skip is recorded and printed
|
|
182
|
+
rather than warned about, and why a task that depends on a particular reward should raise its
|
|
183
|
+
level rather than trusting the report to be read.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
weight: float = 0.0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class DoneTermSpec(TermSpec):
|
|
191
|
+
"""One termination. REQUIRED by default: dropping it changes the episode structure.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
time_out: Whether this termination is a time limit rather than a failure. Both engines use
|
|
195
|
+
the distinction for bootstrapping the value function at the episode boundary, and
|
|
196
|
+
getting it wrong biases the value estimate rather than raising anything.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
time_out: bool = False
|
|
200
|
+
level: Requirement = Requirement.REQUIRED
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(frozen=True)
|
|
204
|
+
class EventTermSpec(TermSpec):
|
|
205
|
+
"""One event or domain randomisation.
|
|
206
|
+
|
|
207
|
+
Use ``func=`` when the event runs on the shared environment interface. Use
|
|
208
|
+
``kind=`` only when each engine must lower the operation onto different
|
|
209
|
+
native physics or sensor state.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
mode: When it fires. ``"startup"`` once at construction, ``"reset"`` per episode,
|
|
213
|
+
``"interval"`` on a timer.
|
|
214
|
+
interval_range_s: Seconds between firings, for ``"interval"`` mode.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
mode: Literal["startup", "reset", "interval"] = "reset"
|
|
218
|
+
interval_range_s: tuple[float, float] | None = None
|
|
219
|
+
|
|
220
|
+
def __post_init__(self) -> None:
|
|
221
|
+
super().__post_init__()
|
|
222
|
+
if self.mode not in {"startup", "reset", "interval"}:
|
|
223
|
+
raise ValueError(
|
|
224
|
+
f"Unknown event mode {self.mode!r}; expected startup, reset, or interval."
|
|
225
|
+
)
|
|
226
|
+
if (self.mode == "interval") != (self.interval_range_s is not None):
|
|
227
|
+
raise ValueError(
|
|
228
|
+
f"Event in {self.mode!r} mode with interval_range_s={self.interval_range_s}: an "
|
|
229
|
+
"interval range is required in interval mode and meaningless otherwise."
|
|
230
|
+
)
|
|
231
|
+
if self.interval_range_s is not None:
|
|
232
|
+
lo, hi = self.interval_range_s
|
|
233
|
+
if not math.isfinite(lo) or not math.isfinite(hi) or lo <= 0.0 or hi < lo:
|
|
234
|
+
raise ValueError(
|
|
235
|
+
f"Event interval_range_s must be finite with 0 < min <= max, got {self.interval_range_s}."
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True)
|
|
240
|
+
class ActionTermSpec(TermSpec):
|
|
241
|
+
"""One action term. REQUIRED by default: without it the policy cannot act.
|
|
242
|
+
|
|
243
|
+
Always per-engine. The two engines drive joints through different objects -- Isaac Lab's
|
|
244
|
+
articulation actuators against mjlab's MuJoCo actuators -- so this family is stated with
|
|
245
|
+
``kind`` and resolved per engine even though the resulting behaviour is meant to match.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
level: Requirement = Requirement.REQUIRED
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass(frozen=True)
|
|
252
|
+
class CommandTermSpec(TermSpec):
|
|
253
|
+
"""One command generator. REQUIRED by default: observations read its output.
|
|
254
|
+
|
|
255
|
+
A task-owned ``func`` is a class or factory called as ``func(env=env,
|
|
256
|
+
params=params)``. It exposes ``command``, ``metrics``,
|
|
257
|
+
``_update_metrics()``, ``_resample_command(env_ids)`` and
|
|
258
|
+
``_update_command()``; each engine's portable command wrapper supplies the
|
|
259
|
+
native manager base and timer. A new task command therefore does not need
|
|
260
|
+
an engine registration.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
level: Requirement = Requirement.REQUIRED
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@dataclass(frozen=True)
|
|
267
|
+
class CurriculumTermSpec(TermSpec):
|
|
268
|
+
"""One curriculum term."""
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclass(frozen=True)
|
|
272
|
+
class ObsGroupSpec:
|
|
273
|
+
"""One observation group, such as the policy's input or the critic's.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
terms: The group's terms. **Insertion order is the concatenation order**, and therefore
|
|
277
|
+
part of the task's contract with any checkpoint trained from it -- reordering this
|
|
278
|
+
mapping silently permutes the policy's input vector.
|
|
279
|
+
enable_corruption: Whether the terms' noise is applied. Off for critic groups by
|
|
280
|
+
convention on both engines.
|
|
281
|
+
concatenate_terms: Whether the group is flattened into one tensor.
|
|
282
|
+
history_length: Group-level override. ``None`` (the default) is unset — each
|
|
283
|
+
term keeps its own history. An integer, including ``0``, overrides every
|
|
284
|
+
term. Both engines' managers test ``is not None``, so ``0`` cannot mean
|
|
285
|
+
"unspecified": that value is a real instruction to drop history.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
terms: Mapping[str, ObsTermSpec]
|
|
289
|
+
enable_corruption: bool = True
|
|
290
|
+
concatenate_terms: bool = True
|
|
291
|
+
history_length: int | None = None
|
|
292
|
+
|
|
293
|
+
def __post_init__(self) -> None:
|
|
294
|
+
if not self.terms:
|
|
295
|
+
raise ValueError(
|
|
296
|
+
"An observation group with no terms produces an empty input tensor."
|
|
297
|
+
)
|
|
298
|
+
if self.history_length is not None and self.history_length < 0:
|
|
299
|
+
raise ValueError(
|
|
300
|
+
f"Observation group history_length={self.history_length} is negative."
|
|
301
|
+
)
|
|
302
|
+
object.__setattr__(self, "terms", dict(self.terms))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@dataclass(frozen=True)
|
|
306
|
+
class MdpSpec:
|
|
307
|
+
"""Every term of a task's MDP, grouped by family.
|
|
308
|
+
|
|
309
|
+
Rewards are grouped rather than flat because both this project's ``MultiRewardManager`` and the
|
|
310
|
+
agent configs that consume it treat reward groups as separate signals. A task with one group
|
|
311
|
+
writes ``rewards={"rewards": {...}}``.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
observations: Mapping[str, ObsGroupSpec] = field(default_factory=dict)
|
|
315
|
+
actions: Mapping[str, ActionTermSpec] = field(default_factory=dict)
|
|
316
|
+
rewards: Mapping[str, Mapping[str, RewardTermSpec]] = field(default_factory=dict)
|
|
317
|
+
terminations: Mapping[str, DoneTermSpec] = field(default_factory=dict)
|
|
318
|
+
events: Mapping[str, EventTermSpec] = field(default_factory=dict)
|
|
319
|
+
commands: Mapping[str, CommandTermSpec] = field(default_factory=dict)
|
|
320
|
+
curriculum: Mapping[str, CurriculumTermSpec] = field(default_factory=dict)
|
|
321
|
+
|
|
322
|
+
def __post_init__(self) -> None:
|
|
323
|
+
for name in (
|
|
324
|
+
"observations",
|
|
325
|
+
"actions",
|
|
326
|
+
"terminations",
|
|
327
|
+
"events",
|
|
328
|
+
"commands",
|
|
329
|
+
"curriculum",
|
|
330
|
+
):
|
|
331
|
+
object.__setattr__(self, name, dict(getattr(self, name)))
|
|
332
|
+
object.__setattr__(
|
|
333
|
+
self,
|
|
334
|
+
"rewards",
|
|
335
|
+
{group: dict(terms) for group, terms in self.rewards.items()},
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def terms(self) -> dict[str, TermSpec]:
|
|
339
|
+
"""Every term keyed by ``family/name``, the key used throughout a compilation's report.
|
|
340
|
+
|
|
341
|
+
Reward keys carry their group -- ``reward/rewards/track_lin_vel_xy_exp`` -- so that two
|
|
342
|
+
groups may use the same term name without colliding in the report.
|
|
343
|
+
"""
|
|
344
|
+
out: dict[str, TermSpec] = {}
|
|
345
|
+
for group, group_spec in self.observations.items():
|
|
346
|
+
for name, term in group_spec.terms.items():
|
|
347
|
+
out[f"observation/{group}/{name}"] = term
|
|
348
|
+
for group, group_terms in self.rewards.items():
|
|
349
|
+
for name, term in group_terms.items():
|
|
350
|
+
out[f"reward/{group}/{name}"] = term
|
|
351
|
+
for family, mapping in (
|
|
352
|
+
("action", self.actions),
|
|
353
|
+
("termination", self.terminations),
|
|
354
|
+
("event", self.events),
|
|
355
|
+
("command", self.commands),
|
|
356
|
+
("curriculum", self.curriculum),
|
|
357
|
+
):
|
|
358
|
+
for name, term in mapping.items():
|
|
359
|
+
out[f"{family}/{name}"] = term
|
|
360
|
+
return out
|
|
361
|
+
|
|
362
|
+
def entity_refs(self) -> tuple[EntityRef, ...]:
|
|
363
|
+
"""Every entity reference the MDP uses, including those passed through ``params``.
|
|
364
|
+
|
|
365
|
+
The backend needs all of them to check its selector support up front, and they hide in two
|
|
366
|
+
places: the ``target`` field, and parameters like an ``asset_cfg`` that a portable term
|
|
367
|
+
takes as an argument.
|
|
368
|
+
"""
|
|
369
|
+
found: list[EntityRef] = []
|
|
370
|
+
for term in self.terms().values():
|
|
371
|
+
if term.target is not None:
|
|
372
|
+
found.append(term.target)
|
|
373
|
+
found.extend(
|
|
374
|
+
v
|
|
375
|
+
for v in walk_parameter_values(term.params.values())
|
|
376
|
+
if isinstance(v, EntityRef)
|
|
377
|
+
)
|
|
378
|
+
for overrides in term.engine_params.values():
|
|
379
|
+
found.extend(
|
|
380
|
+
v
|
|
381
|
+
for v in walk_parameter_values(overrides.values())
|
|
382
|
+
if isinstance(v, EntityRef)
|
|
383
|
+
)
|
|
384
|
+
return tuple(found)
|