genesis-forge-runtime 1.0.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.
- genesis_forge_runtime/__init__.py +100 -0
- genesis_forge_runtime/action_schema.py +155 -0
- genesis_forge_runtime/actions.py +246 -0
- genesis_forge_runtime/archive.py +122 -0
- genesis_forge_runtime/bundle.py +220 -0
- genesis_forge_runtime/constants.py +28 -0
- genesis_forge_runtime/errors.py +32 -0
- genesis_forge_runtime/manifest.py +200 -0
- genesis_forge_runtime/observation_schema.py +131 -0
- genesis_forge_runtime/observations.py +187 -0
- genesis_forge_runtime/processors.py +234 -0
- genesis_forge_runtime/serialization.py +26 -0
- genesis_forge_runtime-1.0.0.dist-info/METADATA +66 -0
- genesis_forge_runtime-1.0.0.dist-info/RECORD +16 -0
- genesis_forge_runtime-1.0.0.dist-info/WHEEL +4 -0
- genesis_forge_runtime-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Simulation-free runtime for deploying Genesis Forge policies to real robots.
|
|
2
|
+
|
|
3
|
+
This package reproduces the observation-assembly and action-processing pipelines
|
|
4
|
+
from training, reading them out of a bundle exported by ``genesis_forge``. It
|
|
5
|
+
depends on **numpy only** -- importing anything here must never pull in torch or
|
|
6
|
+
the Genesis simulator, so it installs on a Raspberry Pi or Jetson.
|
|
7
|
+
|
|
8
|
+
Typical robot-side use::
|
|
9
|
+
|
|
10
|
+
from genesis_forge_runtime import load_bundle
|
|
11
|
+
|
|
12
|
+
bundle = load_bundle("./go2_walk")
|
|
13
|
+
print(bundle.describe()) # what to wire up
|
|
14
|
+
|
|
15
|
+
observation_assembler = bundle.create_observation_assembler()
|
|
16
|
+
action_processor = bundle.create_action_processor()
|
|
17
|
+
|
|
18
|
+
while True:
|
|
19
|
+
obs = observation_assembler.assemble({
|
|
20
|
+
"robot_ang_vel": gyro,
|
|
21
|
+
"actions": action_processor.last_raw_actions_by_manager["action_manager"],
|
|
22
|
+
})
|
|
23
|
+
targets = action_processor.process(policy(obs))
|
|
24
|
+
send_to_motors(targets.by_joint)
|
|
25
|
+
|
|
26
|
+
The modules behind it, roughly in dependency order:
|
|
27
|
+
|
|
28
|
+
* :mod:`~genesis_forge_runtime.constants` -- the manifest's vocabulary
|
|
29
|
+
* :mod:`~genesis_forge_runtime.errors` -- every exception raised here
|
|
30
|
+
* :mod:`~genesis_forge_runtime.serialization` -- JSON to numpy and back
|
|
31
|
+
* :mod:`~genesis_forge_runtime.observation_schema` /
|
|
32
|
+
:mod:`~genesis_forge_runtime.action_schema` -- the two halves of the contract
|
|
33
|
+
* :mod:`~genesis_forge_runtime.manifest` -- the contract as a whole
|
|
34
|
+
* :mod:`~genesis_forge_runtime.bundle` -- reading and writing a bundle directory
|
|
35
|
+
* :mod:`~genesis_forge_runtime.observations` /
|
|
36
|
+
:mod:`~genesis_forge_runtime.processors` / :mod:`~genesis_forge_runtime.actions`
|
|
37
|
+
-- the runtime itself
|
|
38
|
+
|
|
39
|
+
**Trust model:** a bundle is trusted input, equivalent to executable code --
|
|
40
|
+
loading one may import processor classes it names. Only load bundles you produced.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from importlib.metadata import version
|
|
44
|
+
|
|
45
|
+
from .action_schema import ActionManagerSpec, ActuatorSpec
|
|
46
|
+
from .actions import ActionProcessor, ProcessedActions
|
|
47
|
+
from .bundle import Bundle, load_bundle, load_manifest, save_bundle
|
|
48
|
+
from .constants import (
|
|
49
|
+
ARCHIVE_SUFFIX,
|
|
50
|
+
EXTRACT_MARKER,
|
|
51
|
+
GOLDEN_FILENAME,
|
|
52
|
+
MANIFEST_FILENAME,
|
|
53
|
+
MIN_SUPPORTED_SCHEMA_VERSION,
|
|
54
|
+
POLICY_DIRNAME,
|
|
55
|
+
SCHEMA_VERSION,
|
|
56
|
+
)
|
|
57
|
+
from .errors import (
|
|
58
|
+
ActionError,
|
|
59
|
+
BundleError,
|
|
60
|
+
MalformedBundleError,
|
|
61
|
+
ObservationError,
|
|
62
|
+
SchemaVersionError,
|
|
63
|
+
)
|
|
64
|
+
from .manifest import Manifest, Provenance
|
|
65
|
+
from .observation_schema import ObservationEntry, ObservationLayout
|
|
66
|
+
from .observations import ObservationAssembler
|
|
67
|
+
from .processors import ActionManagerProcessor, AffineProcessor
|
|
68
|
+
|
|
69
|
+
__version__ = version("genesis-forge-runtime")
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
"ARCHIVE_SUFFIX",
|
|
73
|
+
"ActionError",
|
|
74
|
+
"ActionManagerProcessor",
|
|
75
|
+
"ActionManagerSpec",
|
|
76
|
+
"ActionProcessor",
|
|
77
|
+
"ActuatorSpec",
|
|
78
|
+
"AffineProcessor",
|
|
79
|
+
"Bundle",
|
|
80
|
+
"BundleError",
|
|
81
|
+
"EXTRACT_MARKER",
|
|
82
|
+
"GOLDEN_FILENAME",
|
|
83
|
+
"MANIFEST_FILENAME",
|
|
84
|
+
"MIN_SUPPORTED_SCHEMA_VERSION",
|
|
85
|
+
"MalformedBundleError",
|
|
86
|
+
"Manifest",
|
|
87
|
+
"ObservationAssembler",
|
|
88
|
+
"ObservationEntry",
|
|
89
|
+
"ObservationError",
|
|
90
|
+
"ObservationLayout",
|
|
91
|
+
"POLICY_DIRNAME",
|
|
92
|
+
"ProcessedActions",
|
|
93
|
+
"Provenance",
|
|
94
|
+
"SCHEMA_VERSION",
|
|
95
|
+
"SchemaVersionError",
|
|
96
|
+
"__version__",
|
|
97
|
+
"load_bundle",
|
|
98
|
+
"load_manifest",
|
|
99
|
+
"save_bundle",
|
|
100
|
+
]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""How the policy's output maps onto real joints.
|
|
2
|
+
|
|
3
|
+
The half of the manifest that :mod:`genesis_forge_runtime.processors` consumes: which
|
|
4
|
+
slice of the policy vector belongs to each action manager, how to process it, and
|
|
5
|
+
the actuator settings the robot should match.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .errors import MalformedBundleError
|
|
14
|
+
from .serialization import require
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _read_action_index(value: Any, name: str) -> tuple[int, ...] | None:
|
|
18
|
+
"""Read the joint-to-action mapping as integers."""
|
|
19
|
+
if value is None:
|
|
20
|
+
return None
|
|
21
|
+
if not isinstance(value, (list, tuple)):
|
|
22
|
+
raise MalformedBundleError(
|
|
23
|
+
f"Action manager '{name}' has a 'joint_action_index' that is not a list."
|
|
24
|
+
)
|
|
25
|
+
try:
|
|
26
|
+
return tuple(int(index) for index in value)
|
|
27
|
+
except (TypeError, ValueError) as error:
|
|
28
|
+
raise MalformedBundleError(
|
|
29
|
+
f"Action manager '{name}' has a non-integer entry in 'joint_action_index'."
|
|
30
|
+
) from error
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ActionManagerSpec:
|
|
35
|
+
"""How one action manager's slice of the policy output is processed."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
deploy_type: str
|
|
39
|
+
joint_names: tuple[str, ...]
|
|
40
|
+
slice_start: int
|
|
41
|
+
slice_end: int
|
|
42
|
+
config: dict[str, Any]
|
|
43
|
+
processor_import_path: str | None = None
|
|
44
|
+
#: One action index per joint, positionally matched to :attr:`joint_names`.
|
|
45
|
+
#: None when every joint has its own action.
|
|
46
|
+
joint_action_index: tuple[int, ...] | None = None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def num_actions(self) -> int:
|
|
50
|
+
"""How many policy outputs this manager consumes."""
|
|
51
|
+
return self.slice_end - self.slice_start
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def num_joints(self) -> int:
|
|
55
|
+
"""How many joint targets it produces.
|
|
56
|
+
|
|
57
|
+
Larger than :attr:`num_actions` when joints share an action.
|
|
58
|
+
"""
|
|
59
|
+
return len(self.joint_names)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_dict(cls, data: dict[str, Any], *, where: str) -> ActionManagerSpec:
|
|
63
|
+
name = require(data, "name", where=where)
|
|
64
|
+
scope = f"{where}.{name}"
|
|
65
|
+
bounds = require(data, "slice", where=scope)
|
|
66
|
+
if not isinstance(bounds, list) or len(bounds) != 2:
|
|
67
|
+
raise MalformedBundleError(
|
|
68
|
+
f"Action manager '{name}' has a malformed 'slice': expected [start, end], "
|
|
69
|
+
f"got {bounds!r}."
|
|
70
|
+
)
|
|
71
|
+
joint_names = tuple(require(data, "joint_names", where=scope))
|
|
72
|
+
spec = cls(
|
|
73
|
+
name=name,
|
|
74
|
+
deploy_type=require(data, "deploy_type", where=scope),
|
|
75
|
+
joint_names=joint_names,
|
|
76
|
+
slice_start=int(bounds[0]),
|
|
77
|
+
slice_end=int(bounds[1]),
|
|
78
|
+
config=data.get("config", {}),
|
|
79
|
+
processor_import_path=data.get("processor_import_path"),
|
|
80
|
+
joint_action_index=_read_action_index(data.get("joint_action_index"), name),
|
|
81
|
+
)
|
|
82
|
+
grouped = spec.joint_action_index is not None
|
|
83
|
+
if grouped and len(spec.joint_action_index) != len(joint_names):
|
|
84
|
+
raise MalformedBundleError(
|
|
85
|
+
f"Action manager '{name}' maps "
|
|
86
|
+
f"{len(spec.joint_action_index)} joint(s) to actions but names "
|
|
87
|
+
f"{len(joint_names)}."
|
|
88
|
+
)
|
|
89
|
+
if grouped and any(
|
|
90
|
+
index < 0 or index >= spec.num_actions for index in spec.joint_action_index
|
|
91
|
+
):
|
|
92
|
+
raise MalformedBundleError(
|
|
93
|
+
f"Action manager '{name}' maps a joint to an action outside its "
|
|
94
|
+
f"slice of {spec.num_actions} action(s)."
|
|
95
|
+
)
|
|
96
|
+
if not grouped and spec.num_actions != len(joint_names):
|
|
97
|
+
raise MalformedBundleError(
|
|
98
|
+
f"Action manager '{name}' covers {spec.num_actions} actions but names "
|
|
99
|
+
f"{len(joint_names)} joints, and records no mapping between them; "
|
|
100
|
+
f"the bundle is inconsistent."
|
|
101
|
+
)
|
|
102
|
+
if grouped and len(joint_names) < spec.num_actions:
|
|
103
|
+
raise MalformedBundleError(
|
|
104
|
+
f"Action manager '{name}' groups {len(joint_names)} joints across "
|
|
105
|
+
f"{spec.num_actions} actions, so at least one action drives nothing; "
|
|
106
|
+
f"the bundle is inconsistent."
|
|
107
|
+
)
|
|
108
|
+
return spec
|
|
109
|
+
|
|
110
|
+
def to_dict(self) -> dict[str, Any]:
|
|
111
|
+
data: dict[str, Any] = {
|
|
112
|
+
"name": self.name,
|
|
113
|
+
"deploy_type": self.deploy_type,
|
|
114
|
+
"slice": [self.slice_start, self.slice_end],
|
|
115
|
+
"joint_names": list(self.joint_names),
|
|
116
|
+
"config": self.config,
|
|
117
|
+
}
|
|
118
|
+
if self.processor_import_path is not None:
|
|
119
|
+
data["processor_import_path"] = self.processor_import_path
|
|
120
|
+
if self.joint_action_index is not None:
|
|
121
|
+
data["joint_action_index"] = [
|
|
122
|
+
int(index) for index in self.joint_action_index
|
|
123
|
+
]
|
|
124
|
+
return data
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class ActuatorSpec:
|
|
129
|
+
"""Nominal actuator gains and defaults, recorded so the robot can match training."""
|
|
130
|
+
|
|
131
|
+
name: str
|
|
132
|
+
joint_names: tuple[str, ...]
|
|
133
|
+
values: dict[str, list[float]]
|
|
134
|
+
randomized: tuple[str, ...] = ()
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_dict(cls, data: dict[str, Any], *, where: str) -> ActuatorSpec:
|
|
138
|
+
name = require(data, "name", where=where)
|
|
139
|
+
scope = f"{where}.{name}"
|
|
140
|
+
return cls(
|
|
141
|
+
name=name,
|
|
142
|
+
joint_names=tuple(require(data, "joint_names", where=scope)),
|
|
143
|
+
values=data.get("values", {}),
|
|
144
|
+
randomized=tuple(data.get("randomized", ())),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def to_dict(self) -> dict[str, Any]:
|
|
148
|
+
data: dict[str, Any] = {
|
|
149
|
+
"name": self.name,
|
|
150
|
+
"joint_names": list(self.joint_names),
|
|
151
|
+
"values": self.values,
|
|
152
|
+
}
|
|
153
|
+
if self.randomized:
|
|
154
|
+
data["randomized"] = list(self.randomized)
|
|
155
|
+
return data
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Turning a full policy output vector into named joint targets.
|
|
2
|
+
|
|
3
|
+
Slices the vector across every action manager, hands each slice to its processor,
|
|
4
|
+
and remembers the result so the caller can feed it back into the next observation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from .action_schema import ActionManagerSpec
|
|
14
|
+
from .errors import ActionError
|
|
15
|
+
from .processors import ActionManagerProcessor, resolve_processor_class
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProcessedActions:
|
|
19
|
+
"""The result of one process, viewable per joint, per manager, or as a vector."""
|
|
20
|
+
|
|
21
|
+
__slots__ = ("_joint_names", "by_manager", "targets")
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
targets: np.ndarray,
|
|
26
|
+
by_manager: dict[str, np.ndarray],
|
|
27
|
+
joint_names: tuple[str, ...],
|
|
28
|
+
) -> None:
|
|
29
|
+
self.targets = targets
|
|
30
|
+
self.by_manager = by_manager
|
|
31
|
+
self._joint_names = joint_names
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def joint_names(self) -> tuple[str, ...]:
|
|
35
|
+
return self._joint_names
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def by_joint(self) -> dict[str, float]:
|
|
39
|
+
"""Joint name to target value -- what you hand to the motor controllers."""
|
|
40
|
+
return {
|
|
41
|
+
name: float(value)
|
|
42
|
+
for name, value in zip(self._joint_names, self.targets, strict=True)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
def __len__(self) -> int:
|
|
46
|
+
return int(self.targets.size)
|
|
47
|
+
|
|
48
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
49
|
+
return f"ProcessedActions({self.by_joint})"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ActionProcessor:
|
|
53
|
+
"""Processes a full policy output vector across every action manager.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
specs: The action manager specs from a loaded bundle's manifest.
|
|
57
|
+
check_finite: Raise when the policy emits NaN or infinity. On by default:
|
|
58
|
+
motors must never be handed those values.
|
|
59
|
+
dtype: Output dtype, float32 by default.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
specs: tuple[ActionManagerSpec, ...],
|
|
65
|
+
*,
|
|
66
|
+
check_finite: bool = True,
|
|
67
|
+
dtype: Any = np.float32,
|
|
68
|
+
) -> None:
|
|
69
|
+
self._specs = tuple(sorted(specs, key=lambda spec: spec.slice_start))
|
|
70
|
+
self._check_finite = check_finite
|
|
71
|
+
self._dtype = np.dtype(dtype)
|
|
72
|
+
|
|
73
|
+
self._processors: list[ActionManagerProcessor] = [
|
|
74
|
+
resolve_processor_class(spec)(spec, dtype=self._dtype)
|
|
75
|
+
for spec in self._specs
|
|
76
|
+
]
|
|
77
|
+
self._joint_names = tuple(
|
|
78
|
+
name for spec in self._specs for name in spec.joint_names
|
|
79
|
+
)
|
|
80
|
+
self.reset()
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
Properties
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def num_actions(self) -> int:
|
|
88
|
+
"""Width of the policy output vector this processor consumes."""
|
|
89
|
+
return self._specs[-1].slice_end if self._specs else 0
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def num_joints(self) -> int:
|
|
93
|
+
"""How many joint targets one call produces.
|
|
94
|
+
|
|
95
|
+
Larger than :attr:`num_actions` when joints share an action.
|
|
96
|
+
"""
|
|
97
|
+
return len(self._joint_names)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def joint_names(self) -> tuple[str, ...]:
|
|
101
|
+
"""Every joint this processor produces a target for, in output order."""
|
|
102
|
+
return self._joint_names
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def clip_range_by_joint(self) -> dict[str, tuple[float, float]]:
|
|
106
|
+
"""Per joint, the clip its processor applies to the target it produces.
|
|
107
|
+
|
|
108
|
+
The range a joint's targets can span, which is what a robot needs to map
|
|
109
|
+
them onto whatever its motors take. Joints whose targets are unbounded
|
|
110
|
+
are absent; see :meth:`ActionManagerProcessor.clip_range_by_joint`.
|
|
111
|
+
"""
|
|
112
|
+
clips: dict[str, tuple[float, float]] = {}
|
|
113
|
+
for processor in self._processors:
|
|
114
|
+
clips.update(processor.clip_range_by_joint)
|
|
115
|
+
return clips
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def processors(self) -> tuple[ActionManagerProcessor, ...]:
|
|
119
|
+
return tuple(self._processors)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def last_raw_actions(self) -> np.ndarray:
|
|
123
|
+
"""The policy's raw output from the previous process.
|
|
124
|
+
|
|
125
|
+
Zeros before the first process, matching how training starts an episode.
|
|
126
|
+
Pass this to :meth:`ObservationAssembler.assemble` for an observation entry
|
|
127
|
+
that echoes raw policy output.
|
|
128
|
+
"""
|
|
129
|
+
return self._last_raw_actions.copy()
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def last_target_actions(self) -> np.ndarray:
|
|
133
|
+
"""The processed joint targets from the previous process.
|
|
134
|
+
|
|
135
|
+
Zeros before the first process.
|
|
136
|
+
|
|
137
|
+
Note this is *not* what the built-in ``current_actions`` observation feeds
|
|
138
|
+
back -- that one is raw policy output at both of its call shapes, so use
|
|
139
|
+
:attr:`last_raw_actions` or :attr:`last_raw_actions_by_manager`. This
|
|
140
|
+
property is for an observation that genuinely echoes processed targets,
|
|
141
|
+
which you would have written yourself.
|
|
142
|
+
"""
|
|
143
|
+
return self._last_target_actions.copy()
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def last_target_actions_by_manager(self) -> dict[str, np.ndarray]:
|
|
147
|
+
"""Per-manager view of :attr:`last_target_actions`, for multi-manager robots.
|
|
148
|
+
|
|
149
|
+
As with :attr:`last_target_actions`, this is processed output -- not what
|
|
150
|
+
``current_actions`` feeds back.
|
|
151
|
+
"""
|
|
152
|
+
return {name: values.copy() for name, values in self._last_by_manager.items()}
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def last_raw_actions_by_manager(self) -> dict[str, np.ndarray]:
|
|
156
|
+
"""What the policy last emitted for each manager, before it was processed.
|
|
157
|
+
|
|
158
|
+
This is what ``current_actions(action_manager=...)`` feeds back during
|
|
159
|
+
training, and it is the whole policy vector only when a single manager is
|
|
160
|
+
registered.
|
|
161
|
+
"""
|
|
162
|
+
return {name: value.copy() for name, value in self._last_raw_by_manager.items()}
|
|
163
|
+
|
|
164
|
+
"""
|
|
165
|
+
Public methods
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def reset(self) -> None:
|
|
169
|
+
"""Clear processor state and remembered outputs."""
|
|
170
|
+
for processor in self._processors:
|
|
171
|
+
processor.reset()
|
|
172
|
+
self._last_raw_actions = np.zeros(self.num_actions, dtype=self._dtype)
|
|
173
|
+
self._last_target_actions = np.zeros(self.num_joints, dtype=self._dtype)
|
|
174
|
+
self._last_by_manager = {
|
|
175
|
+
spec.name: np.zeros(spec.num_joints, dtype=self._dtype)
|
|
176
|
+
for spec in self._specs
|
|
177
|
+
}
|
|
178
|
+
self._last_raw_by_manager = {
|
|
179
|
+
spec.name: np.zeros(spec.num_actions, dtype=self._dtype)
|
|
180
|
+
for spec in self._specs
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
def process(self, actions: Any) -> ProcessedActions:
|
|
184
|
+
"""Process one policy output vector into joint targets.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
actions: The policy's raw output, length :attr:`num_actions`. A
|
|
188
|
+
leading batch dimension of 1 is accepted and squeezed.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
A :class:`ProcessedActions` view of the result.
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
ActionError: Wrong length, or non-finite values when
|
|
195
|
+
``check_finite`` is on.
|
|
196
|
+
"""
|
|
197
|
+
values = np.asarray(actions, dtype=self._dtype).ravel()
|
|
198
|
+
if values.size != self.num_actions:
|
|
199
|
+
raise ActionError(
|
|
200
|
+
f"Expected {self.num_actions} action(s) from the policy, got "
|
|
201
|
+
f"{values.size}."
|
|
202
|
+
)
|
|
203
|
+
if self._check_finite and not np.all(np.isfinite(values)):
|
|
204
|
+
bad = np.flatnonzero(~np.isfinite(values))
|
|
205
|
+
raise ActionError(
|
|
206
|
+
f"Policy produced non-finite action(s) at index/indices "
|
|
207
|
+
f"{bad.tolist()}: {values[bad].tolist()}. Refusing to send these to "
|
|
208
|
+
f"the actuators. Pass check_finite=False to override for debugging."
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
by_manager: dict[str, np.ndarray] = {}
|
|
212
|
+
raw_by_manager: dict[str, np.ndarray] = {}
|
|
213
|
+
pieces: list[np.ndarray] = []
|
|
214
|
+
for spec, processor in zip(self._specs, self._processors, strict=True):
|
|
215
|
+
chunk = values[spec.slice_start : spec.slice_end]
|
|
216
|
+
raw_by_manager[spec.name] = chunk.copy()
|
|
217
|
+
processed = processor.process(chunk)
|
|
218
|
+
by_manager[spec.name] = processed
|
|
219
|
+
pieces.append(processed)
|
|
220
|
+
|
|
221
|
+
# copy() so `targets` does not alias `by_manager[name]`; concatenate
|
|
222
|
+
# already returns a fresh array.
|
|
223
|
+
targets = (
|
|
224
|
+
pieces[0].copy() if len(pieces) == 1 else np.concatenate(pieces)
|
|
225
|
+
).astype(self._dtype, copy=False)
|
|
226
|
+
|
|
227
|
+
self._last_raw_actions = values.copy()
|
|
228
|
+
self._last_target_actions = targets.copy()
|
|
229
|
+
self._last_raw_by_manager = raw_by_manager
|
|
230
|
+
self._last_by_manager = {
|
|
231
|
+
name: chunk.copy() for name, chunk in by_manager.items()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return ProcessedActions(targets, by_manager, self._joint_names)
|
|
235
|
+
|
|
236
|
+
def describe_outputs(self) -> str:
|
|
237
|
+
"""Human-readable listing of the joint targets this processor produces."""
|
|
238
|
+
lines = [f"Joint targets produced ({self.num_joints}):"]
|
|
239
|
+
for spec in self._specs:
|
|
240
|
+
joints = ", ".join(spec.joint_names)
|
|
241
|
+
lines.append(f" - [{spec.deploy_type}] {joints}")
|
|
242
|
+
return "\n".join(lines)
|
|
243
|
+
|
|
244
|
+
"""
|
|
245
|
+
Internal methods
|
|
246
|
+
"""
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Bundles stored as a single file.
|
|
2
|
+
|
|
3
|
+
A bundle is a directory. An *archive* is that same directory written as one zip,
|
|
4
|
+
which is what you copy to a robot -- one artifact instead of a folder, and a
|
|
5
|
+
transfer that either arrives whole or does not arrive.
|
|
6
|
+
|
|
7
|
+
Reading one extracts it beside the archive, into a dot-directory, and reuses that
|
|
8
|
+
on later loads. The extracted copy records a fingerprint of the archive it came
|
|
9
|
+
from, so replacing the archive re-extracts rather than leaving a robot quietly
|
|
10
|
+
running the bundle it had before.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import shutil
|
|
17
|
+
import zipfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .constants import EXTRACT_MARKER, MANIFEST_FILENAME
|
|
21
|
+
from .errors import MalformedBundleError
|
|
22
|
+
|
|
23
|
+
#: Every zip begins with this. Read by content rather than by extension, so a
|
|
24
|
+
#: bundle someone renamed still loads.
|
|
25
|
+
_ZIP_MAGIC = b"PK\x03\x04"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_archive(path: Path) -> bool:
|
|
29
|
+
"""True when this file is a zip, whatever it happens to be called."""
|
|
30
|
+
if not path.is_file():
|
|
31
|
+
return False
|
|
32
|
+
with path.open("rb") as handle:
|
|
33
|
+
return handle.read(4) == _ZIP_MAGIC
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fingerprint(archive: Path) -> str:
|
|
37
|
+
"""Identify an archive's contents without reading them.
|
|
38
|
+
|
|
39
|
+
A zip's central directory already holds a CRC and a size for every entry, so
|
|
40
|
+
this costs the same whether the bundle is a kilobyte or a gigabyte -- no
|
|
41
|
+
payload is read. It is enough to notice that an archive has been replaced,
|
|
42
|
+
which is what it is for; it is not a tamper check, and a bundle is trusted
|
|
43
|
+
input either way.
|
|
44
|
+
"""
|
|
45
|
+
with zipfile.ZipFile(archive) as zipped:
|
|
46
|
+
entries = sorted(
|
|
47
|
+
(item.filename, item.CRC, item.file_size) for item in zipped.infolist()
|
|
48
|
+
)
|
|
49
|
+
digest = hashlib.sha256()
|
|
50
|
+
for name, crc, size in entries:
|
|
51
|
+
digest.update(f"{name}\0{crc}\0{size}\0".encode())
|
|
52
|
+
return digest.hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def extract_dir_for(archive: Path) -> Path:
|
|
56
|
+
"""Where an archive's contents live once unpacked: ``.<name>`` beside it."""
|
|
57
|
+
return archive.parent / f".{archive.stem}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def ensure_extracted(archive: Path) -> Path:
|
|
61
|
+
"""Unpack an archive beside itself, reusing a previous extraction when valid.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The directory holding the bundle's files.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
MalformedBundleError: The archive holds no manifest, the directory it
|
|
68
|
+
would extract into belongs to something else, or there is nowhere
|
|
69
|
+
writable to put it.
|
|
70
|
+
"""
|
|
71
|
+
with zipfile.ZipFile(archive) as zipped:
|
|
72
|
+
if MANIFEST_FILENAME not in zipped.namelist():
|
|
73
|
+
raise MalformedBundleError(
|
|
74
|
+
f"'{archive.name}' is a zip archive but holds no "
|
|
75
|
+
f"{MANIFEST_FILENAME}, so it is not a deployment bundle."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
destination = extract_dir_for(archive)
|
|
79
|
+
marker = destination / EXTRACT_MARKER
|
|
80
|
+
expected = fingerprint(archive)
|
|
81
|
+
|
|
82
|
+
if destination.exists():
|
|
83
|
+
if not destination.is_dir():
|
|
84
|
+
raise MalformedBundleError(
|
|
85
|
+
f"'{destination}' already exists and is not a directory, so "
|
|
86
|
+
f"'{archive.name}' cannot be unpacked beside itself."
|
|
87
|
+
)
|
|
88
|
+
if not marker.is_file():
|
|
89
|
+
raise MalformedBundleError(
|
|
90
|
+
f"'{destination}' already exists but was not unpacked from an "
|
|
91
|
+
f"archive, so it will not be overwritten -- it may be someone's "
|
|
92
|
+
f"work. If you know it came from '{archive.name}', deleting it is "
|
|
93
|
+
f"safe and the next load will unpack it again."
|
|
94
|
+
)
|
|
95
|
+
if marker.read_text().strip() == expected:
|
|
96
|
+
return destination
|
|
97
|
+
|
|
98
|
+
# Unpack elsewhere and swap it in, so an interrupted unpack leaves either the
|
|
99
|
+
# previous extraction or nothing -- never a half-filled directory, which the
|
|
100
|
+
# next load would mistake for someone else's and refuse.
|
|
101
|
+
staging = destination.with_name(f"{destination.name}.unpacking")
|
|
102
|
+
try:
|
|
103
|
+
if staging.exists():
|
|
104
|
+
shutil.rmtree(staging)
|
|
105
|
+
staging.mkdir(parents=True)
|
|
106
|
+
with zipfile.ZipFile(archive) as zipped:
|
|
107
|
+
# extractall keeps every member inside the target directory, so a
|
|
108
|
+
# crafted path cannot escape it.
|
|
109
|
+
zipped.extractall(staging)
|
|
110
|
+
(staging / EXTRACT_MARKER).write_text(expected)
|
|
111
|
+
|
|
112
|
+
if destination.exists():
|
|
113
|
+
shutil.rmtree(destination)
|
|
114
|
+
staging.rename(destination)
|
|
115
|
+
except OSError as error:
|
|
116
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
117
|
+
raise MalformedBundleError(
|
|
118
|
+
f"Could not unpack '{archive.name}' into '{destination}' ({error}). "
|
|
119
|
+
f"Unpack it yourself somewhere writable and load that directory."
|
|
120
|
+
) from error
|
|
121
|
+
|
|
122
|
+
return destination
|