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.
Files changed (45) hide show
  1. simforge_gym/__init__.py +58 -0
  2. simforge_gym/batch.py +138 -0
  3. simforge_gym/datasets.py +238 -0
  4. simforge_gym/env.py +229 -0
  5. simforge_gym/examples/__init__.py +1 -0
  6. simforge_gym/examples/ppo.py +368 -0
  7. simforge_gym/examples/rigs/alpamayo-fisheye.car.rig.json +74 -0
  8. simforge_gym/pipe.py +125 -0
  9. simforge_gym/py.typed +0 -0
  10. simforge_gym/rewards.py +42 -0
  11. simforge_gym/runtime.py +590 -0
  12. simforge_gym/scenarios.py +390 -0
  13. simforge_gym/server.py +208 -0
  14. simforge_gym/tasks/belmont-research-center.json +160 -0
  15. simforge_gym/tasks/easterbrook-discovery-school.json +160 -0
  16. simforge_gym/tasks/el-camino-road.json +178 -0
  17. simforge_gym/tasks/richmond-field-station.json +154 -0
  18. simforge_gym/tasks/templates/adult-midblock-crossing.template.json +91 -0
  19. simforge_gym/tasks/templates/backing-out-vehicle.template.json +81 -0
  20. simforge_gym/tasks/templates/cpnco-parked-row.template.json +344 -0
  21. simforge_gym/tasks/templates/cut-in-brake.template.json +81 -0
  22. simforge_gym/tasks/templates/cutout-reveals-stopped.template.json +92 -0
  23. simforge_gym/tasks/templates/cyclist-crossing-path.template.json +92 -0
  24. simforge_gym/tasks/templates/disabled-vehicle.template.json +112 -0
  25. simforge_gym/tasks/templates/lead-hard-brake.template.json +77 -0
  26. simforge_gym/tasks/templates/left-turn-crosswalk.template.json +106 -0
  27. simforge_gym/tasks/templates/light-corridor.template.json +170 -0
  28. simforge_gym/tasks/templates/ltap-opposing.template.json +345 -0
  29. simforge_gym/tasks/templates/oncoming-overtake.template.json +27 -0
  30. simforge_gym/tasks/templates/opposing-turn-encroachment.template.json +26 -0
  31. simforge_gym/tasks/templates/queue-tail.template.json +90 -0
  32. simforge_gym/tasks/templates/red-light-late-entry.template.json +25 -0
  33. simforge_gym/tasks/templates/right-turn-crosswalk.template.json +128 -0
  34. simforge_gym/tasks/templates/sideswipe.template.json +81 -0
  35. simforge_gym/tasks/templates/urban-mixed.template.json +1391 -0
  36. simforge_gym/tasks/templates/vehicle-pulls-out.template.json +78 -0
  37. simforge_gym/tasks/yale-street.json +184 -0
  38. simforge_gym/transport.py +159 -0
  39. simforge_gym/vector.py +49 -0
  40. simforge_gym/wire.py +95 -0
  41. simforge_gym/world.py +141 -0
  42. simforge_gym-0.2.0.dist-info/METADATA +117 -0
  43. simforge_gym-0.2.0.dist-info/RECORD +45 -0
  44. simforge_gym-0.2.0.dist-info/WHEEL +4 -0
  45. simforge_gym-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -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)
@@ -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``."""