simforge-gym 0.2.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.
- simforge_gym/__init__.py +58 -0
- simforge_gym/batch.py +138 -0
- simforge_gym/datasets.py +238 -0
- simforge_gym/env.py +229 -0
- simforge_gym/examples/__init__.py +1 -0
- simforge_gym/examples/ppo.py +368 -0
- simforge_gym/examples/rigs/alpamayo-fisheye.car.rig.json +74 -0
- simforge_gym/pipe.py +125 -0
- simforge_gym/py.typed +0 -0
- simforge_gym/rewards.py +42 -0
- simforge_gym/runtime.py +590 -0
- simforge_gym/scenarios.py +390 -0
- simforge_gym/server.py +208 -0
- simforge_gym/tasks/belmont-research-center.json +160 -0
- simforge_gym/tasks/easterbrook-discovery-school.json +160 -0
- simforge_gym/tasks/el-camino-road.json +178 -0
- simforge_gym/tasks/richmond-field-station.json +154 -0
- simforge_gym/tasks/templates/adult-midblock-crossing.template.json +91 -0
- simforge_gym/tasks/templates/backing-out-vehicle.template.json +81 -0
- simforge_gym/tasks/templates/cpnco-parked-row.template.json +344 -0
- simforge_gym/tasks/templates/cut-in-brake.template.json +81 -0
- simforge_gym/tasks/templates/cutout-reveals-stopped.template.json +92 -0
- simforge_gym/tasks/templates/cyclist-crossing-path.template.json +92 -0
- simforge_gym/tasks/templates/disabled-vehicle.template.json +112 -0
- simforge_gym/tasks/templates/lead-hard-brake.template.json +77 -0
- simforge_gym/tasks/templates/left-turn-crosswalk.template.json +106 -0
- simforge_gym/tasks/templates/light-corridor.template.json +170 -0
- simforge_gym/tasks/templates/ltap-opposing.template.json +345 -0
- simforge_gym/tasks/templates/oncoming-overtake.template.json +27 -0
- simforge_gym/tasks/templates/opposing-turn-encroachment.template.json +26 -0
- simforge_gym/tasks/templates/queue-tail.template.json +90 -0
- simforge_gym/tasks/templates/red-light-late-entry.template.json +25 -0
- simforge_gym/tasks/templates/right-turn-crosswalk.template.json +128 -0
- simforge_gym/tasks/templates/sideswipe.template.json +81 -0
- simforge_gym/tasks/templates/urban-mixed.template.json +1391 -0
- simforge_gym/tasks/templates/vehicle-pulls-out.template.json +78 -0
- simforge_gym/tasks/yale-street.json +184 -0
- simforge_gym/transport.py +159 -0
- simforge_gym/vector.py +49 -0
- simforge_gym/wire.py +95 -0
- simforge_gym/world.py +141 -0
- simforge_gym-0.2.0.dist-info/METADATA +117 -0
- simforge_gym-0.2.0.dist-info/RECORD +45 -0
- simforge_gym-0.2.0.dist-info/WHEEL +4 -0
- simforge_gym-0.2.0.dist-info/licenses/LICENSE +202 -0
simforge_gym/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Gymnasium environments and the runtime-protocol client for the SimForge runtime."""
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
|
|
5
|
+
from .env import SimForgeEnv, action_space_for, sensor_space, state_space
|
|
6
|
+
from .rewards import EventPenalty, WeightedTerms, native
|
|
7
|
+
from .batch import SimForgeBatchVectorEnv
|
|
8
|
+
from .runtime import BatchResults, BatchRow, CommandResult, RuntimeBatch, RuntimeClient, RuntimeProtocolError
|
|
9
|
+
from .datasets import DatasetError, dataset_scenarios, pull_dataset, read_dataset
|
|
10
|
+
from .scenarios import MapPin, RuntimeSessions, Scenario, ScenarioServers, ScenarioSet, ScenarioSetEnv, bundled_sets
|
|
11
|
+
from .server import EnvServer, template_workspace
|
|
12
|
+
from .world import Subscription, TruthDelivery, WorldCheckpoint, WorldSession
|
|
13
|
+
from .transport import EnvTransport, RuntimeEnvTransport, SocketTransport
|
|
14
|
+
from .vector import make_vector_env
|
|
15
|
+
from .wire import PROTOCOL, EnvServeError, ProtocolError
|
|
16
|
+
|
|
17
|
+
__version__ = importlib.metadata.version("simforge-gym")
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"PROTOCOL",
|
|
21
|
+
"BatchResults",
|
|
22
|
+
"BatchRow",
|
|
23
|
+
"CommandResult",
|
|
24
|
+
"RuntimeBatch",
|
|
25
|
+
"SimForgeBatchVectorEnv",
|
|
26
|
+
"Subscription",
|
|
27
|
+
"TruthDelivery",
|
|
28
|
+
"WorldCheckpoint",
|
|
29
|
+
"WorldSession",
|
|
30
|
+
"EnvServeError",
|
|
31
|
+
"EnvServer",
|
|
32
|
+
"EnvTransport",
|
|
33
|
+
"DatasetError",
|
|
34
|
+
"EventPenalty",
|
|
35
|
+
"MapPin",
|
|
36
|
+
"ProtocolError",
|
|
37
|
+
"RuntimeClient",
|
|
38
|
+
"RuntimeEnvTransport",
|
|
39
|
+
"RuntimeProtocolError",
|
|
40
|
+
"RuntimeSessions",
|
|
41
|
+
"Scenario",
|
|
42
|
+
"ScenarioServers",
|
|
43
|
+
"ScenarioSet",
|
|
44
|
+
"ScenarioSetEnv",
|
|
45
|
+
"SimForgeEnv",
|
|
46
|
+
"SocketTransport",
|
|
47
|
+
"WeightedTerms",
|
|
48
|
+
"action_space_for",
|
|
49
|
+
"bundled_sets",
|
|
50
|
+
"dataset_scenarios",
|
|
51
|
+
"make_vector_env",
|
|
52
|
+
"native",
|
|
53
|
+
"pull_dataset",
|
|
54
|
+
"read_dataset",
|
|
55
|
+
"sensor_space",
|
|
56
|
+
"state_space",
|
|
57
|
+
"template_workspace",
|
|
58
|
+
]
|
simforge_gym/batch.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Gymnasium VectorEnv over one runtime ``env.batch`` (state observations, in-runtime CPU parallelism)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Sequence
|
|
6
|
+
|
|
7
|
+
import gymnasium as gym
|
|
8
|
+
import numpy as np
|
|
9
|
+
from gymnasium.vector import AutoresetMode
|
|
10
|
+
from gymnasium.vector.utils import batch_space
|
|
11
|
+
|
|
12
|
+
from .env import ActionMode, action_space_for, encode_row, state_space, unpack_state
|
|
13
|
+
from .rewards import RewardFn, native
|
|
14
|
+
from .runtime import BatchResults, RuntimeBatch, RuntimeClient
|
|
15
|
+
from .scenarios import SEED_SPACE
|
|
16
|
+
from .wire import PROTOCOL, EnvServeError, ProtocolError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SimForgeBatchVectorEnv(gym.vector.VectorEnv[Any, Any, Any]):
|
|
20
|
+
"""``num_envs`` episodes of one workspace; an ended env is reset (masked) on the next step."""
|
|
21
|
+
|
|
22
|
+
metadata: dict[str, Any] = {"autoreset_mode": AutoresetMode.NEXT_STEP}
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
runtime: RuntimeClient,
|
|
27
|
+
argv: Sequence[str],
|
|
28
|
+
num_envs: int,
|
|
29
|
+
*,
|
|
30
|
+
shards: int | None = None,
|
|
31
|
+
cwd: str | None = None,
|
|
32
|
+
action_mode: ActionMode = "setpoint",
|
|
33
|
+
reward_fn: RewardFn = native,
|
|
34
|
+
info_channel: bool = True,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.runtime = runtime
|
|
37
|
+
self.batch: RuntimeBatch | None = runtime.batch_create(argv, num_envs, shards=shards, cwd=cwd)
|
|
38
|
+
try:
|
|
39
|
+
self.hello = self.batch.hello
|
|
40
|
+
if self.hello.get("protocol") != PROTOCOL:
|
|
41
|
+
raise ProtocolError(f"env.batch hello speaks {self.hello.get('protocol')!r}, not {PROTOCOL}")
|
|
42
|
+
self.num_envs = num_envs
|
|
43
|
+
self.action_mode = action_mode
|
|
44
|
+
self.reward_fn = reward_fn
|
|
45
|
+
self.info_channel = info_channel
|
|
46
|
+
self._slot = {name: index for index, name in enumerate(self.hello["actionFields"])}
|
|
47
|
+
self._width = int(self.hello["actionWidth"])
|
|
48
|
+
self._state = dict(state_space(self.hello).spaces)
|
|
49
|
+
self.single_observation_space = gym.spaces.Dict(self._state)
|
|
50
|
+
self.single_action_space = action_space_for(action_mode)
|
|
51
|
+
self.observation_space = batch_space(self.single_observation_space, num_envs)
|
|
52
|
+
self.action_space = batch_space(self.single_action_space, num_envs)
|
|
53
|
+
except BaseException:
|
|
54
|
+
self.close()
|
|
55
|
+
raise
|
|
56
|
+
self._ended = np.zeros(num_envs, dtype=bool)
|
|
57
|
+
self._rng = np.random.default_rng()
|
|
58
|
+
|
|
59
|
+
def _rows(self, op: str, results: BatchResults) -> None:
|
|
60
|
+
if results.failed:
|
|
61
|
+
codes = {k: results.rows[k].header.get("code") for k in results.failed} # type: ignore[union-attr]
|
|
62
|
+
error = EnvServeError(op, "batch_rows_failed", f"envs {list(results.failed)} failed: {codes}", codes)
|
|
63
|
+
error.results = results # type: ignore[attr-defined]
|
|
64
|
+
raise error
|
|
65
|
+
|
|
66
|
+
def _seeds(self, mask: np.ndarray) -> list[int | None]:
|
|
67
|
+
return [int(self._rng.integers(SEED_SPACE)) if m else None for m in mask]
|
|
68
|
+
|
|
69
|
+
def reset(self, *, seed: int | list[int] | None = None, options: dict[str, Any] | None = None) -> tuple[dict[str, np.ndarray], dict[str, Any]]:
|
|
70
|
+
"""Reset every env; ``seed`` reseeds the episode-seed stream (a list gives each env's seed)."""
|
|
71
|
+
if self.batch is None:
|
|
72
|
+
raise RuntimeError("the batch is closed")
|
|
73
|
+
if options:
|
|
74
|
+
raise ValueError(f"SimForgeBatchVectorEnv.reset takes no options, got {sorted(options)}")
|
|
75
|
+
if isinstance(seed, list):
|
|
76
|
+
if len(seed) != self.num_envs:
|
|
77
|
+
raise ValueError(f"{len(seed)} seeds for {self.num_envs} envs")
|
|
78
|
+
seeds: list[int | None] = [int(s) for s in seed]
|
|
79
|
+
self._rng = np.random.default_rng(seeds)
|
|
80
|
+
else:
|
|
81
|
+
if seed is not None:
|
|
82
|
+
self._rng = np.random.default_rng(seed)
|
|
83
|
+
seeds = self._seeds(np.ones(self.num_envs, dtype=bool))
|
|
84
|
+
results = self.runtime.batch_reset(self.batch, seeds)
|
|
85
|
+
self._rows("reset", results)
|
|
86
|
+
self._ended[:] = False
|
|
87
|
+
obs, infos = self._collect(results.rows)
|
|
88
|
+
infos["episode_seed"] = np.array(seeds)
|
|
89
|
+
return obs, infos
|
|
90
|
+
|
|
91
|
+
def step(self, actions: Any) -> tuple[dict[str, np.ndarray], np.ndarray, np.ndarray, np.ndarray, dict[str, Any]]:
|
|
92
|
+
if self.batch is None:
|
|
93
|
+
raise RuntimeError("the batch is closed")
|
|
94
|
+
actions = np.asarray(actions, dtype=np.float64)
|
|
95
|
+
stepping = ~self._ended
|
|
96
|
+
rows = [list(encode_row(self.action_mode, self._slot, actions[k], np.empty(self._width))) if stepping[k] else None for k in range(self.num_envs)]
|
|
97
|
+
merged: list[Any] = [None] * self.num_envs
|
|
98
|
+
if stepping.any():
|
|
99
|
+
stepped = self.runtime.batch_step(self.batch, rows, mask=list(stepping))
|
|
100
|
+
self._rows("step", stepped)
|
|
101
|
+
merged = list(stepped.rows)
|
|
102
|
+
seeds: list[int | None] = [None] * self.num_envs
|
|
103
|
+
if self._ended.any():
|
|
104
|
+
seeds = self._seeds(self._ended)
|
|
105
|
+
restarted = self.runtime.batch_reset(self.batch, seeds, mask=list(self._ended))
|
|
106
|
+
self._rows("reset", restarted)
|
|
107
|
+
merged = [restarted.rows[k] if self._ended[k] else merged[k] for k in range(self.num_envs)]
|
|
108
|
+
obs, infos = self._collect(merged)
|
|
109
|
+
rewards = np.zeros(self.num_envs)
|
|
110
|
+
terminated = np.zeros(self.num_envs, dtype=bool)
|
|
111
|
+
truncated = np.zeros(self.num_envs, dtype=bool)
|
|
112
|
+
for k in np.flatnonzero(stepping):
|
|
113
|
+
header = merged[k].header
|
|
114
|
+
info_k = {key: value[k] for key, value in infos.items() if key != "episode_seed"}
|
|
115
|
+
rewards[k] = float(self.reward_fn(float(header["reward"]), info_k))
|
|
116
|
+
terminated[k], truncated[k] = bool(header["terminated"]), bool(header["truncated"])
|
|
117
|
+
infos["episode_seed"] = np.array(seeds, dtype=object)
|
|
118
|
+
infos["native_reward"] = np.array([float(merged[k].header["reward"]) if stepping[k] else 0.0 for k in range(self.num_envs)])
|
|
119
|
+
self._ended = terminated | truncated
|
|
120
|
+
return obs, rewards, terminated, truncated, infos
|
|
121
|
+
|
|
122
|
+
def _collect(self, rows: Sequence[Any]) -> tuple[dict[str, np.ndarray], dict[str, Any]]:
|
|
123
|
+
observations, infos = [], []
|
|
124
|
+
for row in rows:
|
|
125
|
+
if row is None:
|
|
126
|
+
raise ProtocolError("a batch row is missing")
|
|
127
|
+
state, info = unpack_state(row.header, row.tail, self._state, self.info_channel)
|
|
128
|
+
observations.append(state)
|
|
129
|
+
infos.append(info)
|
|
130
|
+
obs = {name: np.stack([o[name] for o in observations]) for name in self._state}
|
|
131
|
+
keys = set().union(*(i.keys() for i in infos))
|
|
132
|
+
info = {key: np.array([i.get(key) for i in infos], dtype=object) for key in keys}
|
|
133
|
+
return obs, info
|
|
134
|
+
|
|
135
|
+
def close_extras(self, **kwargs: Any) -> None:
|
|
136
|
+
batch, self.batch = self.batch, None
|
|
137
|
+
if batch is not None and self.runtime.alive:
|
|
138
|
+
self.runtime.batch_close(batch)
|
simforge_gym/datasets.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""SimForge datasets as scenario-set tasks.
|
|
2
|
+
|
|
3
|
+
Two folder formats, both with ``manifest.json``, ``scenarios.jsonl`` (one row per scenario, with
|
|
4
|
+
its split, map and files) and ``SHA256SUMS``:
|
|
5
|
+
|
|
6
|
+
- ``simforge.dataset-files/v1``, a hosted dataset pulled as files (``simforge datasets pull``,
|
|
7
|
+
which checks every file): each scenario's document and the record of the simulation that
|
|
8
|
+
produced its trace become a workspace whose episode is exactly the input that simulation ran.
|
|
9
|
+
- ``simforge.public-dataset/v1``, the public SimForge dataset: each scenario is a
|
|
10
|
+
``document.json`` and the recordings it pins, laid out as a workspace the runtime compiles.
|
|
11
|
+
|
|
12
|
+
Either way an episode runs on the installed map whose OpenDRIVE digest the scenario names.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import gzip
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from .server import find_binary
|
|
28
|
+
|
|
29
|
+
FILES_SCHEMA = "simforge.dataset-files/v1"
|
|
30
|
+
PUBLIC_SCHEMA = "simforge.public-dataset/v1"
|
|
31
|
+
DATASET_ID = re.compile(r"^usds_[0-9A-Za-z_]+$")
|
|
32
|
+
MARKER = ".simforge-gym-package"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DatasetError(ValueError):
|
|
36
|
+
"""A named refusal: ``code`` is stable, ``reason`` says what and where."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, code: str, reason: str) -> None:
|
|
39
|
+
super().__init__(f"{code}: {reason}")
|
|
40
|
+
self.code = code
|
|
41
|
+
self.reason = reason
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cache_root() -> Path:
|
|
45
|
+
"""``$XDG_CACHE_HOME/simforge-gym``, else ``~/.cache/simforge-gym``."""
|
|
46
|
+
base = os.environ.get("XDG_CACHE_HOME")
|
|
47
|
+
return (Path(base) if base and os.path.isabs(base) else Path.home() / ".cache") / "simforge-gym"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sha256(path: Path) -> str:
|
|
51
|
+
digest = hashlib.sha256()
|
|
52
|
+
with path.open("rb") as f:
|
|
53
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
54
|
+
digest.update(chunk)
|
|
55
|
+
return digest.hexdigest()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _relative(folder: Path, path: str) -> Path:
|
|
59
|
+
"""A path a row or SHA256SUMS names: relative, ``/``-separated, plain names."""
|
|
60
|
+
parts = path.split("/")
|
|
61
|
+
if path.startswith("/") or any(part in ("", ".", "..") or "\\" in part for part in parts):
|
|
62
|
+
raise DatasetError("dataset_path_invalid", f"{path!r} is not a relative path")
|
|
63
|
+
return folder.joinpath(*parts)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def pull_dataset(dataset_id: str, out: str | os.PathLike[str], *, binary: str | os.PathLike[str] | None = None) -> Path:
|
|
67
|
+
"""Download hosted dataset ``dataset_id``'s scenarios as files with ``simforge datasets pull``
|
|
68
|
+
(signed in; it resumes and checks every file)."""
|
|
69
|
+
if not DATASET_ID.match(dataset_id):
|
|
70
|
+
raise DatasetError("dataset_id_invalid", f"{dataset_id!r} is not a dataset id (usds_...)")
|
|
71
|
+
folder = Path(out)
|
|
72
|
+
completed = subprocess.run(
|
|
73
|
+
[find_binary(binary), "datasets", "pull", dataset_id, "--include", "scenarios", "--out", os.fspath(folder), "--json"],
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
)
|
|
77
|
+
if completed.returncode != 0:
|
|
78
|
+
lines = completed.stderr.strip().splitlines()
|
|
79
|
+
try:
|
|
80
|
+
error: Any = json.loads(lines[-1]) if lines else {}
|
|
81
|
+
except json.JSONDecodeError:
|
|
82
|
+
error = {}
|
|
83
|
+
if isinstance(error, dict) and "code" in error:
|
|
84
|
+
raise DatasetError(str(error["code"]), str(error.get("reason", "")))
|
|
85
|
+
raise DatasetError("dataset_pull_failed", f"simforge datasets pull exited {completed.returncode}: {completed.stderr.strip()}")
|
|
86
|
+
return folder
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _listed(folder: Path, manifest: dict[str, Any]) -> dict[str, str]:
|
|
90
|
+
"""SHA256SUMS by path, after checking it is the file the manifest names."""
|
|
91
|
+
sums = folder / "SHA256SUMS"
|
|
92
|
+
if not sums.is_file() or _sha256(sums) != manifest["files"]["SHA256SUMS"]["sha256"]:
|
|
93
|
+
raise DatasetError("dataset_digest_mismatch", f"{sums} is missing or not the SHA256SUMS the manifest names")
|
|
94
|
+
listed = {}
|
|
95
|
+
for line in sums.read_text().splitlines():
|
|
96
|
+
if line:
|
|
97
|
+
digest, _, path = line.partition(" ")
|
|
98
|
+
listed[path] = digest
|
|
99
|
+
return listed
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def read_dataset(root: str | os.PathLike[str]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
103
|
+
"""The manifest and rows of a dataset folder; ``scenarios.jsonl`` must be the file the manifest names."""
|
|
104
|
+
folder = Path(root)
|
|
105
|
+
manifest_path, rows_path = folder / "manifest.json", folder / "scenarios.jsonl"
|
|
106
|
+
for path in (manifest_path, rows_path):
|
|
107
|
+
if not path.is_file():
|
|
108
|
+
raise DatasetError("dataset_file_missing", f"{path} is missing: {folder} is not a pulled dataset folder")
|
|
109
|
+
manifest = json.loads(manifest_path.read_bytes())
|
|
110
|
+
schema = manifest.get("schema") if isinstance(manifest, dict) else None
|
|
111
|
+
if schema not in (FILES_SCHEMA, PUBLIC_SCHEMA):
|
|
112
|
+
raise DatasetError("dataset_schema_unsupported", f"{manifest_path} is neither {FILES_SCHEMA} nor {PUBLIC_SCHEMA}")
|
|
113
|
+
listed = _listed(folder, manifest)
|
|
114
|
+
if "scenarios.jsonl" not in listed:
|
|
115
|
+
raise DatasetError("dataset_file_missing", "SHA256SUMS does not list scenarios.jsonl")
|
|
116
|
+
want = listed["scenarios.jsonl"]
|
|
117
|
+
if schema == FILES_SCHEMA and manifest["files"]["scenarios.jsonl"]["sha256"] != want:
|
|
118
|
+
raise DatasetError("dataset_digest_mismatch", "the manifest and SHA256SUMS name different scenarios.jsonl files")
|
|
119
|
+
if _sha256(rows_path) != want:
|
|
120
|
+
raise DatasetError("dataset_digest_mismatch", f"{rows_path} is not the scenarios.jsonl the manifest names (sha256 {want})")
|
|
121
|
+
rows = [json.loads(line) for line in rows_path.read_text().splitlines() if line.strip()]
|
|
122
|
+
if len(rows) != manifest["counts"]["scenarios"]:
|
|
123
|
+
raise DatasetError("dataset_rows_count", f"{len(rows)} rows, the manifest counts {manifest['counts']['scenarios']}")
|
|
124
|
+
return manifest, rows
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _checked(folder: Path, path: str, sha256: str | None, listed: dict[str, str]) -> Path:
|
|
128
|
+
"""A file a row names, present with the digest the row and SHA256SUMS record."""
|
|
129
|
+
if sha256 is None or listed.get(path) != sha256:
|
|
130
|
+
raise DatasetError("dataset_row_unlisted", f"{path} is not listed in SHA256SUMS with the digest its row names")
|
|
131
|
+
source = _relative(folder, path)
|
|
132
|
+
if not source.is_file():
|
|
133
|
+
raise DatasetError("dataset_file_missing", f"{source} is missing")
|
|
134
|
+
if _sha256(source) != sha256:
|
|
135
|
+
raise DatasetError("dataset_digest_mismatch", f"{source} is not the file its row names (sha256 {sha256})")
|
|
136
|
+
return source
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _laid_out(dest: Path, key: str) -> Path | None:
|
|
140
|
+
done = dest / MARKER
|
|
141
|
+
return dest if done.is_file() and done.read_text() == key else None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _install(partial: Path, dest: Path, key: str, manifest: dict[str, Any]) -> Path:
|
|
145
|
+
(partial / "manifest.json").write_text(json.dumps(manifest, sort_keys=True))
|
|
146
|
+
(partial / MARKER).write_text(key)
|
|
147
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
148
|
+
partial.rename(dest)
|
|
149
|
+
return dest
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _files_workspace(folder: Path, row: dict[str, Any], listed: dict[str, str], dest: Path) -> Path:
|
|
153
|
+
"""A pulled scenario as a package workspace whose episode is the input its simulation ran, once per scenario."""
|
|
154
|
+
key = row["id"]
|
|
155
|
+
if _laid_out(dest, key):
|
|
156
|
+
return dest
|
|
157
|
+
map_id, xodr_sha256 = row["map"]["sourceMapId"], row["map"]["xodrSha256"]
|
|
158
|
+
if map_id is None or xodr_sha256 is None:
|
|
159
|
+
raise DatasetError("dataset_map_unnamed", f"scenario {key} names no installable map (sourceMapId {map_id!r}, xodrSha256 {xodr_sha256!r})")
|
|
160
|
+
members = {m["role"]: m for m in row["members"]}
|
|
161
|
+
for role in ("document", "resolution"):
|
|
162
|
+
if role not in members:
|
|
163
|
+
raise DatasetError("dataset_row_unlisted", f"scenario {key} lists no {role}")
|
|
164
|
+
document = _checked(folder, members["document"]["path"], members["document"]["sha256"], listed)
|
|
165
|
+
resolution = _checked(folder, members["resolution"]["path"], members["resolution"]["sha256"], listed)
|
|
166
|
+
record = json.loads(gzip.decompress(resolution.read_bytes()))
|
|
167
|
+
if "resolvedInput" not in record:
|
|
168
|
+
raise DatasetError("dataset_input_missing", f"scenario {key}'s simulation record ({resolution.name}) holds no resolved input")
|
|
169
|
+
expected = row["simulation"]["resolvedInputDigest"]
|
|
170
|
+
if record.get("resolvedInputDigest") != expected:
|
|
171
|
+
raise DatasetError("dataset_input_mismatch", f"scenario {key}'s simulation record is of input {record.get('resolvedInputDigest')}, not the row's {expected}")
|
|
172
|
+
partial = dest.with_name(dest.name + ".partial")
|
|
173
|
+
shutil.rmtree(partial, ignore_errors=True)
|
|
174
|
+
(partial / "simulation").mkdir(parents=True)
|
|
175
|
+
shutil.copyfile(document, partial / "document.json")
|
|
176
|
+
shutil.copyfile(resolution, partial / "simulation" / "resolution.json.gz")
|
|
177
|
+
manifest = {"schema": "simforge.scenario-package/v1", "map": {"xodrSha256": xodr_sha256, "sourceMapId": map_id},
|
|
178
|
+
"timelines": [], "recordings": []}
|
|
179
|
+
return _install(partial, dest, key, manifest)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _public_workspace(folder: Path, row: dict[str, Any], listed: dict[str, str], dest: Path) -> Path:
|
|
183
|
+
"""A public scenario's document and recordings as a package workspace on its map, once per scenario."""
|
|
184
|
+
key = row["id"]
|
|
185
|
+
if _laid_out(dest, key):
|
|
186
|
+
return dest
|
|
187
|
+
partial = dest.with_name(dest.name + ".partial")
|
|
188
|
+
shutil.rmtree(partial, ignore_errors=True)
|
|
189
|
+
recordings = []
|
|
190
|
+
for entry in row["files"]:
|
|
191
|
+
if entry["role"] not in ("document", "recording"):
|
|
192
|
+
continue
|
|
193
|
+
source = _checked(folder, entry["path"], entry["sha256"], listed)
|
|
194
|
+
name = "document.json" if entry["role"] == "document" else f"recordings/{source.name}"
|
|
195
|
+
(partial / name).parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
shutil.copyfile(source, partial / name)
|
|
197
|
+
if entry["role"] == "recording":
|
|
198
|
+
recordings.append({"sha256": source.name.split(".", 1)[0], "path": name})
|
|
199
|
+
if not (partial / "document.json").is_file():
|
|
200
|
+
raise DatasetError("dataset_row_unlisted", f"scenario {key} lists no document")
|
|
201
|
+
manifest = {"schema": "simforge.scenario-package/v1", "map": {"xodrSha256": row["map"]["xodrSha256"], "sourceMapId": row["map"]["slug"]},
|
|
202
|
+
"timelines": [], "recordings": recordings}
|
|
203
|
+
return _install(partial, dest, key, manifest)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def dataset_scenarios(
|
|
207
|
+
source: str | os.PathLike[str],
|
|
208
|
+
*,
|
|
209
|
+
split: str | None = "train",
|
|
210
|
+
map_name: str | None = None,
|
|
211
|
+
cache_dir: str | os.PathLike[str] | None = None,
|
|
212
|
+
binary: str | os.PathLike[str] | None = None,
|
|
213
|
+
) -> list[Any]:
|
|
214
|
+
"""One workspace :class:`~simforge_gym.scenarios.Scenario` per scenario of the dataset's ``split`` (all with ``None``).
|
|
215
|
+
|
|
216
|
+
``source`` is a dataset folder, or a hosted dataset id (``usds_...``) pulled into the cache
|
|
217
|
+
first. ``map_name`` keeps the scenarios on that installed map id. Workspaces are laid out
|
|
218
|
+
under ``cache_dir`` (default :func:`cache_root`), keyed by the dataset's rows and the scenario.
|
|
219
|
+
"""
|
|
220
|
+
from .scenarios import Scenario
|
|
221
|
+
|
|
222
|
+
cache = Path(cache_dir) if cache_dir is not None else cache_root()
|
|
223
|
+
folder = Path(source)
|
|
224
|
+
if not folder.exists():
|
|
225
|
+
if not DATASET_ID.match(os.fspath(source)):
|
|
226
|
+
raise DatasetError("dataset_file_missing", f"{source} is neither a dataset folder nor a dataset id (usds_...)")
|
|
227
|
+
folder = pull_dataset(os.fspath(source), cache / "datasets" / os.fspath(source), binary=binary)
|
|
228
|
+
manifest, rows = read_dataset(folder)
|
|
229
|
+
listed = _listed(folder, manifest)
|
|
230
|
+
public = manifest["schema"] == PUBLIC_SCHEMA
|
|
231
|
+
map_of = (lambda r: r["map"]["slug"]) if public else (lambda r: r["map"]["sourceMapId"])
|
|
232
|
+
chosen = [r for r in rows if (split is None or r["split"] == split) and (map_name is None or map_of(r) == map_name)]
|
|
233
|
+
if not chosen:
|
|
234
|
+
splits = manifest["counts"]["bySplit"] if public else manifest["splits"]
|
|
235
|
+
raise DatasetError("dataset_split_empty", f"no scenarios of split {split!r} on map {map_name!r}; the dataset's splits: {splits}")
|
|
236
|
+
root = cache / "workspaces" / listed["scenarios.jsonl"][:16]
|
|
237
|
+
layout = _public_workspace if public else _files_workspace
|
|
238
|
+
return [Scenario(row["id"], workspace=os.fspath(layout(folder, row, listed, root / row["id"]))) for row in sorted(chosen, key=lambda r: r["id"])]
|
simforge_gym/env.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Gymnasium environment over one SimForge episode session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any, Literal, Mapping
|
|
7
|
+
|
|
8
|
+
import gymnasium as gym
|
|
9
|
+
import numpy as np
|
|
10
|
+
from gymnasium import spaces
|
|
11
|
+
|
|
12
|
+
from . import wire
|
|
13
|
+
from .rewards import RewardFn, native
|
|
14
|
+
from .transport import EnvTransport
|
|
15
|
+
|
|
16
|
+
ActionMode = Literal["setpoint", "control"]
|
|
17
|
+
ObservationMode = Literal["state", "sensors", "state+sensors"]
|
|
18
|
+
CAMERA_PASSES = {"rgb": 4, "id": 4, "semantic": 4}
|
|
19
|
+
#: The runtime's render warning for a step at which a camera's host actor is not in the world (the ego left the map).
|
|
20
|
+
SENSOR_HOST_ABSENT = "sensor_host_absent"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def action_space_for(mode: ActionMode) -> spaces.Box:
|
|
24
|
+
if mode == "setpoint":
|
|
25
|
+
return spaces.Box(np.array([0.0, -np.inf]), np.array([np.inf, np.inf]), (2,), np.float64)
|
|
26
|
+
if mode == "control":
|
|
27
|
+
return spaces.Box(np.array([0.0, 0.0, -1.0]), np.array([1.0, 1.0, 1.0]), (3,), np.float64)
|
|
28
|
+
raise ValueError(f"unknown action_mode {mode!r}; expected 'setpoint' or 'control'")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def state_space(hello: Mapping[str, Any]) -> spaces.Dict:
|
|
32
|
+
"""Privileged engine state: ego ``state_vector``, ``objects`` rows, optional ``bev``."""
|
|
33
|
+
max_objects, features = int(hello["maxObjects"]), int(hello["objectFeatures"])
|
|
34
|
+
members: dict[str, spaces.Space[Any]] = {}
|
|
35
|
+
if hello.get("stateVectorEnabled", True):
|
|
36
|
+
members["state_vector"] = spaces.Box(-np.inf, np.inf, (int(hello["stateVectorSize"]),), np.float64)
|
|
37
|
+
low = np.tile(np.array([0.0, -math.pi, -np.inf, 0.0, 0.0], dtype=np.float32), (max_objects, 1))
|
|
38
|
+
high = np.tile(np.array([np.inf, math.pi, np.inf, 1.0, 1.0], dtype=np.float32), (max_objects, 1))
|
|
39
|
+
members["objects"] = spaces.Box(low[:, :features], high[:, :features], (max_objects, features), np.float32)
|
|
40
|
+
if hello.get("bevShape"):
|
|
41
|
+
members["bev"] = spaces.Box(-np.inf, np.inf, tuple(hello["bevShape"]), np.float32)
|
|
42
|
+
return spaces.Dict(members)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def sensor_space(hello: Mapping[str, Any]) -> spaces.Dict:
|
|
46
|
+
"""Rendered camera passes, keyed ``"<sourceId>/<pass>"``; non-camera payloads stay in ``info``."""
|
|
47
|
+
members: dict[str, spaces.Space[Any]] = {}
|
|
48
|
+
for source in hello.get("sensors", []):
|
|
49
|
+
if source.get("modality") != "rgb":
|
|
50
|
+
continue
|
|
51
|
+
h, w = int(source["height"]), int(source["width"])
|
|
52
|
+
for name in source.get("passes", []):
|
|
53
|
+
if name == "depth":
|
|
54
|
+
members[f"{source['sourceId']}/depth"] = spaces.Box(-np.inf, np.inf, (h, w), np.float32)
|
|
55
|
+
elif name in CAMERA_PASSES:
|
|
56
|
+
members[f"{source['sourceId']}/{name}"] = spaces.Box(0, 255, (h, w, CAMERA_PASSES[name]), np.uint8)
|
|
57
|
+
if not members:
|
|
58
|
+
raise ValueError("observation mode needs rendered cameras: serve with --rig (not --no-sensors)")
|
|
59
|
+
return spaces.Dict(members)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SimForgeEnv(gym.Env[dict[str, Any], np.ndarray]):
|
|
63
|
+
"""Reset/step one SimForge episode; ``terminated``/``truncated`` are the runtime's.
|
|
64
|
+
|
|
65
|
+
A step at which a camera's host actor is not in the world (the ego drove off the map) has no rendered
|
|
66
|
+
frame: the camera observation is the last rendered one, ``info["render_warnings"]`` carries the runtime's
|
|
67
|
+
``sensor_host_absent`` warning, and the step is ``truncated``.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
metadata: dict[str, Any] = {"render_modes": []}
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
transport: EnvTransport,
|
|
75
|
+
*,
|
|
76
|
+
action_mode: ActionMode = "setpoint",
|
|
77
|
+
observation: ObservationMode = "state",
|
|
78
|
+
reward_fn: RewardFn = native,
|
|
79
|
+
info_channel: bool = True,
|
|
80
|
+
seed: int | str | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
super().__init__()
|
|
83
|
+
self.transport = transport
|
|
84
|
+
try:
|
|
85
|
+
self._configure(action_mode, observation)
|
|
86
|
+
except BaseException:
|
|
87
|
+
self.close()
|
|
88
|
+
raise
|
|
89
|
+
self.reward_fn = reward_fn
|
|
90
|
+
self.info_channel = info_channel
|
|
91
|
+
self._default_seed = seed
|
|
92
|
+
self._last_cameras: dict[str, np.ndarray] | None = None
|
|
93
|
+
|
|
94
|
+
def _configure(self, action_mode: ActionMode, observation: ObservationMode) -> None:
|
|
95
|
+
self.hello = self.transport.hello()
|
|
96
|
+
self.action_mode: ActionMode = action_mode
|
|
97
|
+
self.observation_mode: ObservationMode = observation
|
|
98
|
+
self._slot = {name: index for index, name in enumerate(self.hello["actionFields"])}
|
|
99
|
+
self._row = np.empty(int(self.hello["actionWidth"]), dtype=np.float64)
|
|
100
|
+
self.ego: str = self.hello["ego"]
|
|
101
|
+
self.decision_hz = int(self.hello["decisionHz"])
|
|
102
|
+
self.action_space = action_space_for(action_mode)
|
|
103
|
+
if observation not in ("state", "sensors", "state+sensors"):
|
|
104
|
+
raise ValueError(f"unknown observation mode {observation!r}")
|
|
105
|
+
self._state_space = dict(state_space(self.hello).spaces) if observation != "sensors" else {}
|
|
106
|
+
self._camera_space = dict(sensor_space(self.hello).spaces) if observation != "state" else {}
|
|
107
|
+
if observation == "state":
|
|
108
|
+
self.observation_space = spaces.Dict(self._state_space)
|
|
109
|
+
elif observation == "sensors":
|
|
110
|
+
self.observation_space = spaces.Dict(self._camera_space)
|
|
111
|
+
else:
|
|
112
|
+
self.observation_space = spaces.Dict({**self._state_space, "sensors": spaces.Dict(self._camera_space)})
|
|
113
|
+
|
|
114
|
+
def encode_action(self, action: Any) -> np.ndarray:
|
|
115
|
+
"""``action`` in the mode's Box as the native row (NaN = unset)."""
|
|
116
|
+
return encode_row(self.action_mode, self._slot, action, self._row)
|
|
117
|
+
|
|
118
|
+
def _unpack(self, header: Mapping[str, Any], tail: bytes, *, restart: bool = False) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
119
|
+
if restart:
|
|
120
|
+
self._last_cameras = None
|
|
121
|
+
if self.observation_mode == "sensors":
|
|
122
|
+
# A sensor policy sees no engine state or labels, whatever profile the runtime serves.
|
|
123
|
+
state, info = {}, {"t_s": header["info"]["t_s"]}
|
|
124
|
+
else:
|
|
125
|
+
state, info = unpack_state(header, tail, self._state_space, self.info_channel)
|
|
126
|
+
frames: dict[str, dict[str, np.ndarray]] = {}
|
|
127
|
+
for source, passes in header.get("sensors", {}).items():
|
|
128
|
+
frames[source] = {name: wire.array(tail, ref) for name, ref in passes.items()}
|
|
129
|
+
info["sensors"] = frames
|
|
130
|
+
if "renderMs" in header:
|
|
131
|
+
info["render_ms"] = header["renderMs"]
|
|
132
|
+
warnings = list(header.get("renderWarnings") or ())
|
|
133
|
+
if warnings:
|
|
134
|
+
info["render_warnings"] = warnings
|
|
135
|
+
if self.observation_mode == "state":
|
|
136
|
+
return state, info
|
|
137
|
+
if host_absent(header):
|
|
138
|
+
# No frame exists at this instant; the episode ends here (see `step`) on the last rendered one.
|
|
139
|
+
if self._last_cameras is None:
|
|
140
|
+
raise wire.ProtocolError(f"the runtime rendered no camera: {_absent_message(header)}, and no earlier frame exists")
|
|
141
|
+
cameras = self._last_cameras
|
|
142
|
+
else:
|
|
143
|
+
cameras = {}
|
|
144
|
+
for key, space in self._camera_space.items():
|
|
145
|
+
source, name = key.rsplit("/", 1)
|
|
146
|
+
cameras[key] = _declared(key, frames.get(source, {}).get(name), space)
|
|
147
|
+
self._last_cameras = cameras
|
|
148
|
+
if self.observation_mode == "sensors":
|
|
149
|
+
return cameras, info
|
|
150
|
+
return {**state, "sensors": cameras}, info
|
|
151
|
+
|
|
152
|
+
def reset(self, *, seed: int | None = None, options: Mapping[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
153
|
+
"""``options["episode_seed"]`` overrides the runtime seed; ``options["scenario"]`` switches input."""
|
|
154
|
+
super().reset(seed=seed)
|
|
155
|
+
options = options or {}
|
|
156
|
+
scenario = options.get("scenario")
|
|
157
|
+
if scenario is not None and not isinstance(scenario, Mapping):
|
|
158
|
+
raise TypeError("reset options['scenario'] must be a compiled scenario input mapping")
|
|
159
|
+
episode_seed = options.get("episode_seed", seed if seed is not None else self._default_seed)
|
|
160
|
+
return self._unpack(*self.transport.reset(episode_seed, scenario=scenario), restart=True)
|
|
161
|
+
|
|
162
|
+
def step(self, action: Any) -> tuple[dict[str, Any], float, bool, bool, dict[str, Any]]:
|
|
163
|
+
"""Apply ``action`` for one decision; ``None`` keeps the authored control."""
|
|
164
|
+
header, tail = self.transport.step(None if action is None else self.encode_action(action))
|
|
165
|
+
observation, info = self._unpack(header, tail)
|
|
166
|
+
native_reward = float(header["reward"])
|
|
167
|
+
info["native_reward"] = native_reward
|
|
168
|
+
reward = float(self.reward_fn(native_reward, info))
|
|
169
|
+
terminated = bool(header["terminated"])
|
|
170
|
+
# A camera policy has nothing to act on once its host left the world: the episode ends there.
|
|
171
|
+
truncated = bool(header["truncated"]) or (not terminated and self.observation_mode != "state" and host_absent(header))
|
|
172
|
+
return observation, reward, terminated, truncated, info
|
|
173
|
+
|
|
174
|
+
def observe(self) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
175
|
+
return self._unpack(*self.transport.observe())
|
|
176
|
+
|
|
177
|
+
def checkpoint(self) -> bytes:
|
|
178
|
+
return self.transport.checkpoint()
|
|
179
|
+
|
|
180
|
+
def restore(self, checkpoint: bytes) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
181
|
+
return self._unpack(*self.transport.restore(checkpoint), restart=True)
|
|
182
|
+
|
|
183
|
+
def close(self) -> None:
|
|
184
|
+
transport = self.__dict__.pop("transport", None)
|
|
185
|
+
if transport is not None:
|
|
186
|
+
transport.close()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def host_absent(header: Mapping[str, Any]) -> bool:
|
|
190
|
+
"""The runtime rendered no camera at this step: a camera's host actor is not in the world."""
|
|
191
|
+
return any(warning.get("code") == SENSOR_HOST_ABSENT for warning in header.get("renderWarnings") or ())
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _absent_message(header: Mapping[str, Any]) -> str:
|
|
195
|
+
return "; ".join(str(w.get("message") or w.get("actorIds")) for w in header.get("renderWarnings") or () if w.get("code") == SENSOR_HOST_ABSENT)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _declared(name: str, value: np.ndarray | None, space: spaces.Space[Any]) -> np.ndarray:
|
|
199
|
+
"""``value`` if it has the declared shape and dtype; a runtime that breaks the space is a protocol error."""
|
|
200
|
+
if value is None:
|
|
201
|
+
raise wire.ProtocolError(f"the runtime sent no {name} observation")
|
|
202
|
+
if value.shape != space.shape or value.dtype != space.dtype:
|
|
203
|
+
raise wire.ProtocolError(f"{name} is {value.dtype}{list(value.shape)}, but the space declares {space.dtype}{list(space.shape or ())}")
|
|
204
|
+
return value
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def unpack_state(header: Mapping[str, Any], tail: bytes, state: Mapping[str, spaces.Space[Any]], info_channel: bool) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
208
|
+
"""The declared state arrays and the engine info of one native header."""
|
|
209
|
+
observation = header["observation"]
|
|
210
|
+
values = {name: _declared(name, wire.array(tail, observation[name]) if name in observation else None, space) for name, space in state.items()}
|
|
211
|
+
server_info = dict(header["info"])
|
|
212
|
+
info: dict[str, Any] = {k: server_info[k] for k in ("t_s", "ego", "object_ids", "reward_terms")}
|
|
213
|
+
if info_channel:
|
|
214
|
+
info.update({k: v for k, v in server_info.items() if k not in info})
|
|
215
|
+
return values, info
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def encode_row(mode: ActionMode, slots: Mapping[str, int], action: Any, row: np.ndarray) -> np.ndarray:
|
|
219
|
+
"""Write ``action`` into the native ``row`` by field slot; every other field is NaN (unset)."""
|
|
220
|
+
values = np.asarray(action, dtype=np.float64).reshape(-1)
|
|
221
|
+
names = ("target_speed_mps", "target_acceleration_mps2") if mode == "setpoint" else ("throttle", "brake", "steer")
|
|
222
|
+
if values.shape != (len(names),):
|
|
223
|
+
raise ValueError(f"{mode} action must have shape ({len(names)},), got {values.shape}")
|
|
224
|
+
if not np.all(np.isfinite(values)):
|
|
225
|
+
raise ValueError(f"action contains non-finite values: {values}")
|
|
226
|
+
row.fill(np.nan)
|
|
227
|
+
for name, value in zip(names, values):
|
|
228
|
+
row[slots[name]] = value
|
|
229
|
+
return row
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Runnable examples: ``python -m simforge_gym.examples.ppo``."""
|