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
|