robovast-sim-roqsim 2.1.0__tar.gz

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.
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.1
2
+ Name: robovast-sim-roqsim
3
+ Version: 2.1.0
4
+ Summary: The roqsim simulator backend for RoboVAST
5
+ License: Apache-2.0
6
+ Author: Frederik Pasch
7
+ Author-email: fred-labs@mailbox.org
8
+ Requires-Python: >=3.10,<3.14
9
+ Classifier: License :: OSI Approved :: Apache Software License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Dist: pydantic (>=2.0,<3.0)
15
+ Description-Content-Type: text/markdown
16
+
17
+ # robovast_sim_roqsim
18
+
19
+ The [roqsim](https://github.com/cps-test-lab/roqsim) (MuJoCo) simulator backend for RoboVAST.
20
+
21
+ Registers one `robovast.simulators` entry point, so a campaign selects the simulator by name:
22
+
23
+ ```yaml
24
+ execution:
25
+ mode: ros2
26
+ containers:
27
+ simulation:
28
+ backend: roqsim
29
+ config: worlds/depot.yaml
30
+ ```
31
+
32
+ Everything else — the image, the command, the GL/record environment, which files the world is
33
+ made of — comes from the backend rather than from the `.vast`.
34
+
35
+ ## Installation
36
+
37
+ It ships as a RoboVAST extra:
38
+
39
+ ```bash
40
+ pip install 'robovast[roqsim]'
41
+ ```
42
+
43
+ `pip install robovast` deliberately gets you nothing from here: RoboVAST names no simulator, so a
44
+ backend is always something you add. The default service/controller image installs this extra,
45
+ which is what lets `backend: roqsim` resolve on a cluster without the campaign shipping anything.
46
+
47
+ ### From source
48
+
49
+ ```bash
50
+ pip install -e .
51
+ ```
52
+
53
+ ## Scope
54
+
55
+ This package must import **without roqsim installed** — it runs in the long-lived RoboVAST
56
+ service process, which has no reason to carry a MuJoCo runtime. It declares strings and container
57
+ specs; anything that genuinely needs the simulator (such as enumerating the files a world is built
58
+ from) runs inside roqsim's own image.
59
+
@@ -0,0 +1,42 @@
1
+ # robovast_sim_roqsim
2
+
3
+ The [roqsim](https://github.com/cps-test-lab/roqsim) (MuJoCo) simulator backend for RoboVAST.
4
+
5
+ Registers one `robovast.simulators` entry point, so a campaign selects the simulator by name:
6
+
7
+ ```yaml
8
+ execution:
9
+ mode: ros2
10
+ containers:
11
+ simulation:
12
+ backend: roqsim
13
+ config: worlds/depot.yaml
14
+ ```
15
+
16
+ Everything else — the image, the command, the GL/record environment, which files the world is
17
+ made of — comes from the backend rather than from the `.vast`.
18
+
19
+ ## Installation
20
+
21
+ It ships as a RoboVAST extra:
22
+
23
+ ```bash
24
+ pip install 'robovast[roqsim]'
25
+ ```
26
+
27
+ `pip install robovast` deliberately gets you nothing from here: RoboVAST names no simulator, so a
28
+ backend is always something you add. The default service/controller image installs this extra,
29
+ which is what lets `backend: roqsim` resolve on a cluster without the campaign shipping anything.
30
+
31
+ ### From source
32
+
33
+ ```bash
34
+ pip install -e .
35
+ ```
36
+
37
+ ## Scope
38
+
39
+ This package must import **without roqsim installed** — it runs in the long-lived RoboVAST
40
+ service process, which has no reason to carry a MuJoCo runtime. It declares strings and container
41
+ specs; anything that genuinely needs the simulator (such as enumerating the files a world is built
42
+ from) runs inside roqsim's own image.
@@ -0,0 +1,31 @@
1
+ [tool.poetry]
2
+ name = "robovast-sim-roqsim"
3
+ version = "2.1.0"
4
+ description = "The roqsim simulator backend for RoboVAST"
5
+ authors = ["Frederik Pasch <fred-labs@mailbox.org>"]
6
+ license = "Apache-2.0"
7
+ readme = "README.md"
8
+ packages = [{include = "robovast_sim_roqsim"}]
9
+
10
+ [tool.poetry.dependencies]
11
+ python = ">=3.10,<3.14"
12
+ # Deliberately NOT depending on roqsim. This is imported in the long-lived RoboVAST
13
+ # service process, which has no reason to carry a MuJoCo runtime; everything here is
14
+ # strings and container specs, and the one operation that genuinely needs roqsim runs
15
+ # inside roqsim's own image.
16
+ #
17
+ # Deliberately NOT depending on robovast either, for two reasons. It is installed into
18
+ # an environment that already has it, and pinning would invert which of the two is the
19
+ # host. And robovast declares this package as a path dependency, so an edge back would
20
+ # be a cycle -- robovast-nav gets away with one only because its extra is never resolved
21
+ # from inside robovast itself.
22
+ pydantic = "^2.0"
23
+
24
+ # The entry point is named for the *product*, not for the `roqsim` Python packages it
25
+ # wraps, so a .vast reads `backend: roqsim`.
26
+ [tool.poetry.plugins."robovast.simulators"]
27
+ roqsim = "robovast_sim_roqsim.backend:RoqsimBackend"
28
+
29
+ [build-system]
30
+ requires = ["poetry-core"]
31
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,34 @@
1
+ # Copyright (C) 2026 Frederik Pasch
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """The roqsim simulator backend for RoboVAST.
5
+
6
+ **Its own distribution, deliberately.** RoboVAST must not *require* a simulator: the
7
+ framework is what makes any simulator runnable, and a hard dependency on one would
8
+ invert that. So this ships as the ``roqsim`` extra rather than as part of
9
+ ``robovast`` -- ``pip install robovast`` names no simulator, ``pip install
10
+ robovast[roqsim]`` adds this backend's entry point, and a third-party backend arrives
11
+ the same way through its own package.
12
+
13
+ It is in the default service/controller image because it is cheap enough to be: strings
14
+ and container specs, whose only dependency (``pydantic``) RoboVAST already has.
15
+
16
+ What it removes from a campaign: the GL/apt block, the ``mujoco`` pin, the hand-ordered
17
+ ``roqsim`` wheel list, ``MUJOCO_GL`` selection, ``ENABLE_X11``, the record/capture
18
+ variables, and the choice of how to start the simulator at all. A ``.vast`` says which
19
+ simulator and which config, and nothing else::
20
+
21
+ execution:
22
+ mode: ros2
23
+ containers:
24
+ simulation: {backend: roqsim, config: worlds/depot.yaml}
25
+
26
+ **This module must import without roqsim installed.** It runs in the RoboVAST service
27
+ process, which has no reason to carry MuJoCo -- so everything here is strings and
28
+ container specs, and the one operation that genuinely needs roqsim (working out which
29
+ files a world is made of) runs *in roqsim's own image*.
30
+ """
31
+
32
+ from robovast_sim_roqsim.backend import RoqsimBackend
33
+
34
+ __all__ = ["RoqsimBackend"]
@@ -0,0 +1,445 @@
1
+ # Copyright (C) 2026 Frederik Pasch
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """What roqsim tells RoboVAST about itself."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import shlex
10
+ from typing import Optional
11
+
12
+ from pydantic import BaseModel, ConfigDict, field_validator
13
+
14
+ from robovast.common.execution import MEMBER_ROQSIM, family_image_ref
15
+ from robovast.common.simulators import (CONFIG_MOUNT, SCENARIO_CONTAINER, SHAPE_ROS, SHAPE_STEPPED,
16
+ SIM_OVERRIDES_MOUNT, SIM_QUERY_OVERRIDES_MOUNT,
17
+ SIMULATION_CONTAINER, ContainerQuery, SimulatorBackend,
18
+ shape_for, simulator_image)
19
+ from robovast.common.variation.container_runner import ContainerSpec
20
+
21
+ #: The ``SimulationInterface`` scenario-execution steps.
22
+ ADAPTER = "roqsim.scenario_adapter:MujocoSim"
23
+
24
+ #: Every top level a world document has, and therefore every root a ``sim:`` destination may
25
+ #: address.
26
+ _WORLD_ROOTS = ("sim", "components")
27
+
28
+ #: The MuJoCo state recording each run writes, relative to its output directory. Named once
29
+ #: and read twice — :meth:`RoqsimBackend.env` asks for it, :meth:`run_state_file` tells the
30
+ #: service where to find it — so the request and the lookup cannot drift apart.
31
+ _RECORD_FILE = "run.npz"
32
+
33
+
34
+ def _is_package_ref(config: str) -> bool:
35
+ """``roqsim_scenes:depot`` names a packaged world; anything else is a file."""
36
+ return ":" in config and not config.startswith((".", "/"))
37
+
38
+
39
+ def _config_in_container(config: str) -> str:
40
+ """Where the simulator will find the config once the job is running.
41
+
42
+ A path in the ``.vast`` is relative to the ``.vast``, which is a directory that does
43
+ not exist in the container: RoboVAST mounts the campaign's ``run_files`` under
44
+ ``/config``. Passing the authored path through unchanged makes the simulator look
45
+ beside its own working directory and fail with "world config does not exist" -- after
46
+ the image pull and the pod schedule, so the cost is a whole cell.
47
+
48
+ A package ref is left alone: it travels inside the image and has no path at all.
49
+ """
50
+ if _is_package_ref(config):
51
+ return config
52
+ if config.startswith("/"):
53
+ return config
54
+ return f"{CONFIG_MOUNT}/{config.lstrip('./')}"
55
+
56
+
57
+ class RoqsimConfig(BaseModel):
58
+ """roqsim's own keys in the ``simulation`` container block."""
59
+
60
+ model_config = ConfigDict(extra="forbid")
61
+
62
+ #: The roqsim config: a world YAML path beside the ``.vast``, or a package ref
63
+ #: (``roqsim_scenes:depot``). Called ``config`` rather than ``world`` because the file
64
+ #: is roqsim's whole configuration -- physics, plugins, robot, sensors, and its
65
+ #: ``extends`` chain -- and "world" understates what a campaign is selecting.
66
+ config: str
67
+ #: Parts of the world to change before it is compiled, as a nested mapping mirroring
68
+ #: the world YAML with components addressed by name -- exactly :func:`roqsim.apply_overrides`'
69
+ #: input, and exactly what ``roqsim sim --set`` builds from a dotlist.
70
+ #:
71
+ #: This is what makes a world *variable* without one YAML per cell: a campaign sweeping
72
+ #: a floorplan dimension or a prop's mass writes ``sim: components.floorplan.size`` and
73
+ #: lands here. It travels as a file rather than as ``--set`` flags because the values are
74
+ #: structured, and because a file is something the results keep and a human can replay.
75
+ #:
76
+ #: Override semantics, not ``extends``: a child world's ``components`` are *appended* after
77
+ #: the parent's, so an inherited plugin can only be changed by disabling and re-adding
78
+ #: it. ``apply_overrides`` resolves a plugin by name and deep-merges, which is what a
79
+ #: campaign varying one value of one plugin actually means.
80
+ overrides: Optional[dict] = None
81
+ #: The ``SimulationInterface`` scenario-execution steps, as ``module:Class``.
82
+ #: Defaults to roqsim's generic adapter, which is what an ordinary campaign wants.
83
+ #:
84
+ #: A campaign overrides it to name its own factors. scenario-execution forwards a
85
+ #: scenario parameter only when the concrete ``reset()`` declares it *by name* --
86
+ #: ``_build_reset_kwargs`` skips ``**kwargs`` outright -- so a sweep over
87
+ #: experiment-specific parameters needs a ``reset()`` that names them. Putting those
88
+ #: names in the generic adapter would mean roqsim learning one experiment's
89
+ #: factors, so the subclass belongs to the experiment, next to the plugins it drives.
90
+ #: Stepped shape only: with the ROS shape there is no in-process interface at all.
91
+ adapter: Optional[str] = None
92
+
93
+ @field_validator("overrides")
94
+ @classmethod
95
+ def _addresses_the_world(cls, value):
96
+ """Refuse an override rooted at something a world document does not have.
97
+
98
+ A campaign's ``sim:`` destination is a path into the world, and one that starts anywhere
99
+ else names nothing: the world builds unchanged and every cell of the sweep runs the same
100
+ one, which reads as a factor with no effect rather than as a mistake. Checked here because
101
+ this is where a campaign is composed -- the alternative is the simulator refusing it once
102
+ the image is pulled and the pod is scheduled, per cell.
103
+ """
104
+ for root in sorted(value or {}):
105
+ if root not in _WORLD_ROOTS:
106
+ raise ValueError(
107
+ f"'{root}' is no part of a roqsim world. A sim destination addresses a "
108
+ f"settings key as 'sim.<key>' or a component's key as "
109
+ f"'components.<name>.<key>'; a world declares no parameters for one to name."
110
+ )
111
+ return value
112
+
113
+
114
+ class RoqsimBackend(SimulatorBackend):
115
+ """roqsim, in both shapes.
116
+
117
+ Stepped (``mode: base``) and ROS (``mode: ros2``) differ in exactly two ways: where
118
+ the simulator runs, and how it is told which config to load. Everything else --
119
+ headless, the GL backend, the run capture -- is the same because it has one correct
120
+ value for any campaign.
121
+ """
122
+
123
+ CONFIG_CLASS = RoqsimConfig
124
+ SUPPORTED_SHAPES = (SHAPE_STEPPED, SHAPE_ROS)
125
+ #: A bare ``sim:`` path is a path into the world. ``sim: config`` still selects the
126
+ #: world file, because a bare backend key wins; a world key colliding with one is
127
+ #: reached by spelling this root out.
128
+ DOTTED_ROOT = "overrides"
129
+
130
+ #: Where roqsim's worlds and models come from. Naming these belongs HERE and nowhere else:
131
+ #: this distribution exists to be the one place that knows roqsim, so core stays free of
132
+ #: simulator names while a campaign's results still record which asset provider supplied
133
+ #: them. It matters because some providers are private -- a campaign using one is
134
+ #: reproducible only by someone who can obtain that code, and a published dataset has to
135
+ #: name it and its commit rather than depending on something nobody can identify.
136
+ ASSET_ENTRY_POINT_GROUPS = ("roqsim.models", "roqsim.worlds", "roqsim.plugins")
137
+
138
+ def containers(self, cfg, execution: dict) -> dict:
139
+ # Both shapes name the SAME family member, symbolically: only that image carries
140
+ # roqsim *and* the RoboVAST contract (the org.robovast.compat-version label,
141
+ # scenario-execution, the /out mount). Not roqsim's own published image: that one
142
+ # has the simulator but not the contract, so the runner rejects it -- and nothing
143
+ # publishes that tag anyway.
144
+ #
145
+ # Symbolic, not resolved here: which project and tag it comes from is a property
146
+ # of the campaign, and this runs before one exists.
147
+ image = family_image_ref(MEMBER_ROQSIM)
148
+ if shape_for(execution.get("mode", "auto")) == SHAPE_ROS:
149
+ # Its own container, running roqsim's ordinary CLI. Not a RoboVAST-specific
150
+ # entry point: the same command debugs the world by hand, so there is no
151
+ # second way the simulator can be started.
152
+ #
153
+ # No transport flags: which topics a world speaks, under which namespace, and
154
+ # whether it serves a control plane are the WORLD's to declare. A campaign
155
+ # runner configuring a simulator's middleware would be reaching a layer down,
156
+ # and headless/pacing are the only two the deployment owns.
157
+ command = ["roqsim", "sim", _config_in_container(cfg.config),
158
+ "--headless", "--pacing", "realtime"]
159
+ if cfg.overrides:
160
+ # The file spelling of --set. Not the flags themselves: a campaign's
161
+ # overrides are a nested tree, and flattening one onto argv loses it to
162
+ # quoting, keeps it out of the results, and leaves nobody able to replay
163
+ # the cell. RoboVAST mounts the document; this only names where.
164
+ command += ["--override", SIM_OVERRIDES_MOUNT]
165
+ return {SIMULATION_CONTAINER: {"image": image, "command": command}}
166
+ # Stepped: scenario-execution calls step(), so the simulator is in its process
167
+ # and the two roles are one container.
168
+ return {SCENARIO_CONTAINER: {"image": image}}
169
+
170
+ def simulation_ref(self, cfg, execution: dict) -> Optional[str]:
171
+ return cfg.adapter or ADAPTER
172
+
173
+ def env(self, cfg, execution: dict) -> dict:
174
+ """What has one correct value for any campaign, so nobody should have to write it.
175
+
176
+ Not a "session" block in the world YAML and not keys in the ``.vast``: a campaign
177
+ is always headless, always wants the capture its 3D run view replays, and always
178
+ wants a GL backend that works on the node it landed on. There is nothing to
179
+ decide, so there is nothing to declare.
180
+ """
181
+ env = {
182
+ # An in-container Xvfb would shadow a bind-mounted host X socket, and a
183
+ # campaign has no window either way.
184
+ "ENABLE_X11": "false",
185
+ # The run's ground truth, and the capture the scene3d panel replays. Both
186
+ # are written on a clean stop only; a run killed by a timeout leaves neither.
187
+ "ROQSIM_RECORD": _RECORD_FILE,
188
+ "ROQSIM_CAPTURE_EXPORT_DIR": "capture",
189
+ # The pose series, streamed per sample beside the recording as
190
+ # `run.sim_poses.csv` -> the `sim_poses` table. Unlike the two above it
191
+ # survives a kill, because every row is flushed as it is taken.
192
+ #
193
+ # Always on, for two reasons a campaign never has to weigh. It is the only
194
+ # pose data a STEPPED run produces at all: with no ROS there is no rosbag, so
195
+ # nothing derives a `poses` table afterwards. And where there IS a rosbag it
196
+ # is the honest one — world-frame poses on exact sim time, with velocities
197
+ # read from the solver instead of differenced over rosbag arrival times,
198
+ # which are quantized by the /clock grid and jittered by delivery.
199
+ "ROQSIM_SIM_POSES": "1",
200
+ # Timestamp roqsim's own log lines, so they can be placed on the run's clock like
201
+ # every other producer's. roqsim defaults to `INFO roqsim.engine: msg` because that is
202
+ # what belongs in a terminal, where `roqsim sim` is one command a person is
203
+ # watching -- and roqsim is published standalone, so a campaign is no reason
204
+ # to make that worse. Here the reader is the merged run log rather than a
205
+ # person, and a line with no timestamp cannot be ordered against anything.
206
+ #
207
+ # Measured on a three-container run before this: five roqsim lines (the drawn seed,
208
+ # the recording summary) carried no time and folded into the entrypoint line
209
+ # above them instead of standing as their own events.
210
+ "ROQSIM_LOG_FORMAT": "stamped",
211
+ }
212
+ # MUJOCO_GL is deliberately absent. Which backend works is a property of the
213
+ # machine the simulator lands on, and this code runs on the *service host* -- a
214
+ # different machine whenever a campaign is dispatched. roqsim picks it at
215
+ # import instead (roqsim.gl.select_offscreen_gl), which is what finally
216
+ # retires the 22-line shell script three packages had each copied.
217
+ if shape_for(execution.get("mode", "auto")) == SHAPE_STEPPED:
218
+ # In-process: no command line to put the config on, so the adapter reads it
219
+ # from here. The scenario stays simulator-agnostic either way -- it never
220
+ # learns that this simulator has a thing called a world.
221
+ #
222
+ # Through `_config_in_container`, like the ROS shape. Passed raw, a stepped
223
+ # campaign whose world is a relative path has the simulator look beside its own
224
+ # working directory instead of at the mount -- the exact failure that function
225
+ # exists to prevent, and it must not be avoided on one path only.
226
+ env["ROQSIM_WORLD"] = _config_in_container(cfg.config)
227
+ if cfg.overrides:
228
+ # The same document the ROS shape passes with --override, reached the way
229
+ # everything else is in this shape: there is no command line here.
230
+ env["ROQSIM_WORLD_OVERRIDES"] = SIM_OVERRIDES_MOUNT
231
+ return env
232
+
233
+ def sim_document(self, cfg, execution: dict):
234
+ """The overrides, which is the half of the config that is a *document*.
235
+
236
+ The world itself stays on argv -- it is one token, and ``roqsim sim <world>`` is how a
237
+ person runs this simulator. What cannot go there is the override tree, so that is
238
+ what gets a file. Written per job by RoboVAST, mounted at
239
+ :data:`~robovast.common.simulators.SIM_OVERRIDES_MOUNT`, and read by ``--override``.
240
+ """
241
+ del execution
242
+ return cfg.overrides or None
243
+
244
+ def produces_run_capture(self, cfg, execution: dict) -> bool:
245
+ return True
246
+
247
+ def default_panels(self, cfg, execution: dict) -> list:
248
+ """The 3D scene, always -- for the same reason :meth:`env` supplies the capture.
249
+
250
+ Two artifacts drive the panel and both resolve themselves: the *scene* (geometry) is
251
+ compiled by the service on first open, inside the simulator's own pinned image, and
252
+ cached by world identity; the *run capture* (motion) records the world reference and
253
+ its overrides and addresses that geometry by name, so a world that later gains an arm
254
+ or a walker replays without anyone editing a ``.vast``. The capture path defaults to
255
+ ``capture/capture.json``.
256
+
257
+ Since a roqsim campaign always records that capture (:meth:`produces_run_capture`),
258
+ every such campaign can replay its runs in 3D -- so the panel is contributed rather
259
+ than declared, and a campaign that wants it elsewhere on screen still says so itself.
260
+ """
261
+ return [{"scene3d": {}}]
262
+
263
+ def input_files(self, cfg, execution: dict, vast_dir: str):
264
+ """Everything the world is made of -- asked of the image that can answer it.
265
+
266
+ A world is not one file. It is the YAML, whatever it ``extends``, the MJCF that chain
267
+ settles on, the meshes and textures that MJCF names, and whatever its plugins point at
268
+ -- a floorplan's mesh and the wall colliders beside it, a trajectory CSV -- all
269
+ referenced by paths relative to each other. Returning just ``cfg.config`` staged the
270
+ YAML and nothing else, so such a world failed in the container on a file that never
271
+ travelled, after the image pull and the pod schedule.
272
+
273
+ Which files those are is roqsim's rule, and roqsim is the only place all of it exists:
274
+ a plugin's own config is what no YAML walk can see, so each plugin is asked
275
+ (``roqsim.plugin.Plugin.sources``). Deciding *here* whether a world is more than one
276
+ file would mean restating that rule from outside, in a copy free to disagree with it
277
+ -- and wrong in the direction that stages too little, which nothing notices until a
278
+ run opens the file that did not come.
279
+
280
+ Enumerating it needs roqsim, which this module must not import (it is loaded in the
281
+ long-lived service process). So it returns the question instead: ``roqsim scenes
282
+ inputs`` run in roqsim's own image, which is also the image that will run the
283
+ campaign, so the answer describes exactly what that run will open.
284
+
285
+ Asked of every world the campaign owns rather than only of the ones a test here
286
+ believes are more than one file, because the container this costs is one the caller
287
+ already has: ``validate_project`` and ``preview_configurations`` compose inside the
288
+ lane's aux-runner context and hold it, sharing one warm container across an authoring
289
+ loop.
290
+
291
+ A package ref (``roqsim_scenes:depot``) still needs nothing, and says so without a
292
+ container: the files arrive installed.
293
+ """
294
+ # Nothing here opens the campaign's files: what they say is roqsim's to read.
295
+ del vast_dir
296
+ if _is_package_ref(cfg.config):
297
+ return []
298
+ return ContainerQuery(
299
+ # The campaign's own image, for the same reason describe_query uses it: what a
300
+ # world is made of is resolved by what is installed.
301
+ ContainerSpec(image=simulator_image(execution, self.containers(cfg, execution))),
302
+ # Through ``_config_in_container``, like every other command this backend sends:
303
+ # the container is given the campaign's files at ``/config``, and the authored
304
+ # path is relative to the ``.vast``. Passed raw, roqsim looked for the world
305
+ # beside its own working directory and said it did not exist.
306
+ ["roqsim", "scenes", "inputs", _config_in_container(cfg.config)])
307
+
308
+ def describe_query(self, cfg, execution: dict, *, entities: bool = False,
309
+ targets: str = ""):
310
+ """``roqsim scenes describe``, in the image this campaign runs.
311
+
312
+ What makes the ``sim`` channel checkable: a campaign writes
313
+ ``components.floorplan.floor.friction`` and nothing here can tell whether that plugin is in
314
+ the world without resolving its ``extends`` chain, which needs the simulator. Asked of
315
+ the image that will run the campaign, so the answer describes the world that will load.
316
+ """
317
+ command = ["roqsim", "scenes", "describe", _config_in_container(cfg.config)]
318
+ if entities:
319
+ # Costs a model build, so it is asked for only when a campaign names entities.
320
+ command.append("--entities")
321
+ if targets:
322
+ # Same cost, same reason it is opt-in: naming what a run may override means
323
+ # compiling the model. The glob is the caller's, and it is what keeps the answer
324
+ # small -- a mobile-manipulator world has hundreds of geoms.
325
+ command += ["--overridable", targets]
326
+ # Described WITH this configuration's overrides, the same file spelling the run uses
327
+ # (:meth:`sim_document`). Which entities a world compiles depends on its plugins' config,
328
+ # so a campaign whose obstacles come from its own overrides compiles them only with those
329
+ # applied: asked without them, the answer is about a different world, and the entity check
330
+ # read that as a working campaign naming entities that do not exist.
331
+ document = self.sim_document(cfg, execution)
332
+ if document:
333
+ command += ["--override", SIM_QUERY_OVERRIDES_MOUNT]
334
+ return ContainerQuery(
335
+ ContainerSpec(image=simulator_image(execution, self.containers(cfg, execution))),
336
+ command,
337
+ {SIM_QUERY_OVERRIDES_MOUNT: document} if document else None)
338
+
339
+
340
+ def scene_export(self, cfg, execution: dict, *, world: str, max_tex_dim: int,
341
+ overrides: dict, overrides_file: Optional[str] = None) -> str:
342
+ """``roqsim-export-web``, roqsim's own exporter, run in roqsim's own image.
343
+
344
+ *world* is passed through as the capture recorded it -- a package ref, or the
345
+ ``/config/...`` path a campaign file had in the job, which RoboVAST reproduces for
346
+ the build so the world resolves what it references.
347
+
348
+ Overrides go in through ``--override``, THE SAME FILE SPELLING THE RUN USES, and for
349
+ the same reason (see :meth:`containers`): a campaign's overrides are a nested tree,
350
+ and argv cannot carry one. Flattening them onto ``--set`` worked until a campaign
351
+ varied something structured -- a list of obstacle instances -- which reached the
352
+ exporter as ``KeyError: '"pos"'``, because a list of mappings is not a dotlist
353
+ value. It fails only when the run view is opened, so it reads as "this campaign has
354
+ no 3D geometry" rather than as a quoting bug.
355
+
356
+ ``--set`` is still what a person types by hand; it is simply not how a campaign's
357
+ recorded overrides travel.
358
+ """
359
+ override_arg = f" --override {overrides_file}" if overrides_file else ""
360
+ return (f"roqsim-export-web --world {world}{override_arg} --out {{out}} "
361
+ f"--max-tex-dim {int(max_tex_dim)} --manifest {{out}}/.generated.json")
362
+
363
+ def run_state_file(self, cfg, execution: dict) -> str:
364
+ """The recording :meth:`env` asks every run to write.
365
+
366
+ Both sides read :data:`_RECORD_FILE`, so the name that is *requested* and the name that
367
+ is later *looked for* cannot drift apart.
368
+ """
369
+ del cfg, execution
370
+ return _RECORD_FILE
371
+
372
+ def health_command(self, cfg, execution: dict, *, run_dir: str) -> str:
373
+ """``roqsim health``, judging the records this run is streaming as it goes.
374
+
375
+ Cheap by construction, which is what lets the service poll it: the three checks read the
376
+ tail of the clock and pose records, and the ``state`` block in the same reply is the last
377
+ sample of those same reads. Nothing here folds a whole file.
378
+
379
+ Deliberately no ``--robot``: ``roqsim health`` resolves which bodies are robots from the
380
+ run's own entity roster, so the motion check runs without this backend naming anything
381
+ per world -- and a run with no roster says so in the reply's ``skipped``, which is the
382
+ honest answer rather than a silent pass. ``--robot`` remains an override there; passing
383
+ one from here would be RoboVAST guessing at a world it does not read.
384
+ """
385
+ del cfg, execution
386
+ return f"roqsim health --json {shlex.quote(run_dir)}"
387
+
388
+ def simulation_screenshot(self, cfg, execution: dict, *, state: str,
389
+ at=None, view=None, focus=None, camera=None,
390
+ size: str = "960x720") -> str:
391
+ """``roqsim render``, replaying the run's own recording from a chosen viewpoint.
392
+
393
+ No world argument: ``roqsim render``'s target is optional with ``--state``, because a
394
+ recording *names the world it was made from*. That is what makes this work for a
395
+ campaign whose world is a package ref and one whose world is a campaign file alike --
396
+ the recording answers, rather than RoboVAST having to reconstruct the reference.
397
+
398
+ RoboVAST's four view keys map one-to-one onto roqsim's ``sim.view`` names, which is why
399
+ those four were the ones chosen. So the whole roqsim-specific surface here is which
400
+ binary and how it spells its flags; a second simulator implements the same hook and
401
+ the same tool starts answering for it.
402
+
403
+ Every value is quoted: the return is a *string* put through ``shlex.split``, and
404
+ ``lookat=1,2,0`` has to survive as one word -- the same trap :func:`_set_arg`'s
405
+ docstring records for vector-valued overrides.
406
+ """
407
+ parts = ["roqsim", "render", "--state", state,
408
+ "--out", "{out}/frame.png", "--size", shlex.quote(str(size))]
409
+ if at is not None:
410
+ parts += ["--at", repr(float(at))]
411
+ if camera:
412
+ # Owns its own pose, so roqsim refuses it together with --view/--focus; the caller
413
+ # is refused earlier, with a message about the camera rather than about argv.
414
+ parts += ["--camera", shlex.quote(str(camera))]
415
+ else:
416
+ if view:
417
+ parts += ["--view"] + [shlex.quote(f"{k}={v}") for k, v in sorted(view.items())]
418
+ if focus:
419
+ parts += ["--focus"] + [shlex.quote(str(f)) for f in focus]
420
+ return " ".join(parts)
421
+
422
+
423
+ def _set_arg(key: str, value) -> str:
424
+ """One ``--set key=value`` argument that survives ``shlex.split`` as a single word.
425
+
426
+ QUOTED, and serialized without spaces, because the command is a STRING the generator
427
+ runs through ``shlex.split``. A vector-valued override -- ``components.parcel.pos: [11.8,
428
+ 4.55, 0.762]``, i.e. exactly what a campaign that sweeps a position records -- renders as
429
+ ``[11.8, 4.55, 0.762]`` and was torn into three argv words at those spaces, so
430
+ ``roqsim-export-web`` got ``--set components.parcel.pos=[11.8,`` plus two stray arguments and
431
+ exited 2. The failure surfaces only when somebody opens the run view, and only for a
432
+ world whose overrides contain a list, so it reads as "this campaign has no 3D geometry"
433
+ rather than as a quoting bug.
434
+ """
435
+ return shlex.quote(f"{key}={json.dumps(value, separators=(',', ':'))}")
436
+
437
+
438
+ def _flatten(value, prefix=""):
439
+ """Nested override dict -> ``[(dotted.key, leaf), ...]``, the dotlist ``--set`` accepts."""
440
+ for name, item in sorted((value or {}).items()):
441
+ path = f"{prefix}{name}"
442
+ if isinstance(item, dict):
443
+ yield from _flatten(item, f"{path}.")
444
+ else:
445
+ yield path, item