Scene framework
The YAML scene schema: seafloor, objects, motion, sonar, and outputs.
YAML-backed, declarative scene definitions that drive end-to-end
simulator + beamformer runs. The goal is reproducibility: every run
is described by a versioned SceneConfig dataclass tree that can be
saved, loaded, inherited from, and replayed bit-for-bit.
Why
Imperative scene-building scripts drift. The same scene ends up
spelled slightly differently in each runner, seeds get regenerated
rather than recorded, and comparing two runs a month apart means
diffing 200-line Python files. A SceneConfig solves this by:
- Giving every parameter a single canonical location and default.
- Making seeds explicit and stored: never derived, never auto-generated post-hoc.
- Letting scripts be 20 lines of thin wrapper over
load(name)+run(cfg). - Supporting
inherits:so scene variants share a base.
Layout
simulator/scenes/
__init__.py # public API: load, save, instantiate, run
schema.py # SceneConfig dataclasses + schema_version
io.py # YAML load/save + inheritance resolver
run.py # instantiate() and run() (sim + beamform)
library/ # curated YAML scenes (bare-name lookup)
9obj_split_zonemap.yaml
...
Top-Level SceneConfig
@dataclass
class SceneConfig:
schema_version: int = 1
name: str = ''
description: str = ''
extent: SceneExtent # x_min/x_max/y_min/y_max/cell_size
bottom: BottomConfig # zones, ripples, rocks, custom types
objects: list[dict] # raw dicts keyed by 'kind'
motion: MotionConfig # straight_line | waypoint_spline | circle | spline_track (+ perturbations)
sonar: SonarCfg # fc, fs, bandwidth, array geometry
simulation: SimulationConfig # method, density, max_range, seed
beamform: BeamformConfig # pixel_spacing, window, TVG, ...
output: OutputConfig # prefix, directory, save_port_image
schema_version=1 is enforced by from_dict: loading a YAML with a
different version raises ValueError.
motion.kind: spline_track
A centripetal Catmull-Rom curve through track.control_points, each
{x, y, alt, speed}, with attitude derived kinematically from the path
through track.dynamics. Built by simulator/track.py; edited live in
the GUI by the Trajectory panel (docs/imgui-gui.md). Example, from
simulator/scenes/library/spline_track_demo_sas.yaml:
motion:
kind: spline_track
speed: 1.583
ping_rate: 2.6383
altitude: 10.0
n_pings: 105
track:
control_points:
- {x: 0.0, y: 6.0, alt: 10.0}
- {x: 20.0, y: 10.0, alt: 10.0}
- {x: 40.0, y: 2.0, alt: 11.0}
- {x: 60.0, y: 6.0, alt: 12.0}
dynamics:
tau_yaw: 2.0
tau_pitch: 3.0
tau_roll: 1.5
max_yaw_rate: 0.35
max_pitch: 0.35
max_roll: 0.35
bank_gain: 0.2
crab: 0.0
aoa: 0.0
A control point's speed: null (the default) inherits motion.speed;
setting it overrides the speed law for that point only.
If the parent scene already has a profiles: block (profiles is the
source of truth there; the top-level motion: field is only an alias
into profiles[active_profile] and is overwritten by it on load),
override motion under profiles.<active_profile>.motion instead of a
bare top-level motion: key, or the override is silently dropped.
BottomConfig
The interesting one: wraps everything the SeafloorBuilder needs:
default_type: fallback BottomType name forScene(bottom_type=...), overridden per-cell byzone_grid.disable_auto_rocks: if True (default), zeros everyBottomType.default_rock_densitybefore building, so rocks only appear where you put them.zone_grid: 2D list of bottom-type names, row-major. Rows = along-track (X), cols = cross-track (Y).rms_heights:{type_name: rms_m}override map for FFT macro-roughness (m). A type named here is synthesised in the legacyrmsmode: the realisation is rescaled to exactly this rms height, bit for bit as before 2026-09-22. Unspecified built-in types use the absolute spectrumW(k) = spectral_strength k^-γ(no rescale), so their rms height grows with the scene size and their slope with a finer cell (default sand: ~3.5 deg range slope at a 5 cm cell, ~12 cm rms height over 100 m). Author note: leave a type out ofrms_heightsfor physical, cell-consistent slopes; list it only to pin an exact height (older scenes that list types keep their look). Unspecified custom types use their ownspectrum_mode(below).custom_types: list ofCustomBottomTyperegistrations applied to globalBOTTOM_TYPESbefore zone map setup. Lets a scene define, e.g.,rocky_sand= sand BSS with rock β, without editing core bottom tables. An optionalripple:block (RipplePattern) paints a Tang ripple field wherever the type is painted; itsforcing:sub-block (hs_m,tp_s,depth_m,d50_mm,wave_dir_deg) derives wavelength / height / direction from the sea state andage_daysfades a field left unforced (seesimulator.md, Ripples from wave forcing).spectrum_mode(defaultrms) picks the macro-roughness synthesis:rmsnormalises torms_height_mand ignoresspectral_strength(older YAML carries arbitrary strengths that never did anything, so this stays the default);absolutesynthesisesW(k) = spectral_strength k^-spectral_exponent, k in rad/m, like the built-ins (seesimulator.md, Roughness spectrum).bathymetry: list ofBathymetryOperator, the scene-scale relief (metres over tens of metres) that everything else settles on; see Large-scale bathymetry below.bathymetry_clip_m(default 5.0) bounds the composed field.ripples: list ofZoneRipple(Tang skewed) overlays,zone=(row,col), withripple_wavelength,rms_height,direction_deg,skewness_b,seed.rocks: list ofProceduralRockGroup(zone/zones/bbox,density,d_min,d_max,alpha,seed); all placement None means whole scene. Apreset(glacial_till,dropstones,talus,rockfall,cobble_bed,slab_field,reef_rubble,basalt_columns) or any boulder knob (lithology,shape,cluster_scale_m,cluster_density,source_edge,band_width_m,size_grows_with_distance,burial_min,burial_max,heading_deg,heading_spread_deg,scour,scour_d_min,ts_offset_db,specular_fraction) turns the group into a boulder field; seeboulder_fields.md.outcrops: list ofOutcropField(2026-09-18): bedrock outcrops (kind: outcrop) or ledges / terraces (kind: ledge). Each is an ellipselength x widthatstrike_degwith an organic outline, a plateau dipping bydip_deg, edges atedge_slope_deg(a ledge's back atback_slope_deg), roughnesstop_rms_mon top and a talus apron ofapron_fractionx height at the foot. Heights are log-uniform inheight_min..height_max.countordensity;bboxplaces the centres and a feature keeps its organic edge past it (feather_m > 0confines it softly instead).material(defaultrock, ornone) is painted under the plateau after the seafloor bake, so the top scatters like rock. Rocks and boulders arerocks; this is the bedrock they sit on.patches: list ofMaterialPatchField(2026-09-18): organic patches of a bottomtype(shell_hash,gravel,mud, any custom type) painted after the bake with no relief.countordensity,diameter_min..maxwith power lawdiameter_alpha,eccentricity_max,outline_roughness,seed. Shell hash and gravel lag in real surveys are small and scattered; keep them so.seed: RNG seed for zone-map FFT roughness (also the texture graph's base seed fallback).texture_graph/texture_graph_path: optional Blender-style procedural node graph (inline dict, or a*.texgraph.jsonsidecar resolved relative to the YAML; the sidecar wins on load, mirroringzone_grid_path). Applied after the zone-map/ripple overlay and before legacy pockmarks/trawl/rocks; drives height, material, reflectivity gain, masks, and placements. Author it in ApertureLab's Texture Editor tab or by hand: seedocs/texture-graph.md. Demo:simulator/scenes/library/texgraph_smoke.yaml.
Large-scale bathymetry (bottom.bathymetry)
The zone map, the ripple overlays and the fractal background shape the bed
at the metre scale and below. bottom.bathymetry shapes it at the scene
scale: the regional slope and the bedform field a survey line actually
crosses, up to the bathymetry_clip_m bound (default 5 m). It is the
first relief stage in instantiate(), applied before the zone warp, so
ripples, pockmarks, trawl scars, outcrops, rocks and objects all settle on
it rather than on a flat datum.
Each entry is a BathymetryOperator with a kind; the operators are
summed, clipped to +/- bathymetry_clip_m and made zero-mean, so they
change the shape of the bottom without moving its depth (the altitude rule
and the slant-range geometry are untouched). See
simulator.md, Large-scale bathymetry, for the operator
table and the parameter meanings.
bottom:
default_type: sand
bathymetry_clip_m: 4.0 # composed relief stays within +/- 4 m
bathymetry:
- kind: rolling # Goff-Jordan 1/k^beta relief
rms_m: 1.5
correlation_length_m: 80.0
spectral_exponent: 2.5
seed: 901
- kind: tilt # regional slope, zero at the scene centre
across_deg: 1.5 # bed deepens toward the far range
along_deg: 0.0
- kind: sand_waves # a sand-wave FIELD, not a uniform blanket
wavelength_m: 25.0
amplitude_m: 0.9
direction_deg: 20.0 # crest normal, degrees from +x (along-track)
asymmetry: 0.5 # steep lee flank, gentle stoss flank
envelope_fraction: 0.45 # covers ~45 % of the scene, organic outline
seed: 902
kind values other than rolling, tilt and sand_waves raise
ValueError at instantiate time instead of being silently ignored. The
realised range of the composed field is reported on the [sim-prep]
bathymetry line and stored on the heightfield as bathymetry_range_m.
Objects
Stored as raw dicts keyed by kind so new primitives can be
added without schema changes. run.py::_place_object dispatches:
| kind | keys |
|---|---|
box |
x, y, x_extent, y_extent, height, ts_db, seed |
cylinder |
x, y, length, diameter, orientation, ts_db, seed or preset (below) in place of length, diameter; heading_deg? (any heading, overrides orientation) and proud? (with heading_deg: rests on the bed, a full diameter high, instead of the axis on the datum) |
point |
x, y, z, ts_db, n_sub?, jitter_m?, seed? |
mesh |
x, y, mesh_path, scale, rotation_z_deg, ts_db, seed, z_offset?, feather_m?, terrain? |
rockan |
x, y, heading_deg, ts_db, seed |
manta |
x, y, ts_db, seed |
Angles in YAML are degrees (heading_deg, rotation_z_deg); the
dispatcher converts to radians before calling scene.add_*.
Published object sizes
simulator/object_sizes.py records the published dimensions of the
mine-like bodies the simulator places, with a public source per entry.
The wedge and truncated_cone kinds read theirs from it, and a
cylinder can name a row with preset: instead of length and
diameter (explicit values win over the preset):
- {kind: cylinder, x: 30.0, y: 20.0, preset: quickstrike_mk62,
orientation: along_track, ts_db: -13.0, seed: 44}
| kind | body | size |
|---|---|---|
truncated_cone |
Manta MN103 | 0.98 m base diameter, 0.44 m high; top diameter 0.30 m is an estimate from photographs |
wedge |
Rockan GMI-100 | 1.02 m long, 0.80 m wide at the tail, 0.385 m high; nose width 0.50 m is an estimate |
preset |
body | length m | diameter m |
|---|---|---|---|
mk52 |
US Mk 52 bottom mine | 1.78 | 0.48 |
quickstrike_mk62 |
US Mk 62 Quickstrike, Mk 82 bomb body | 2.26 | 0.27 |
mk55 |
US Mk 55 bottom mine | 2.28 | 0.59 |
quickstrike_mk63 |
US Mk 63 Quickstrike, Mk 83 bomb body | 2.87 | 0.36 |
mk56 |
US Mk 56 moored mine | 2.90 | 0.56 |
murena |
Rheinmetall MURENA | 3.15 | 0.54 |
quickstrike_mk65 |
US Mk 65 Quickstrike (0.74 m is the span across the fins) | 3.25 | 0.74 |
quickstrike_mk64 |
US Mk 64 Quickstrike, Mk 84 bomb body | 3.84 | 0.46 |
A cylinder is a smooth horizontal cylinder, so fins, tail fairings
and lugs are not modelled. The Stonefish is 0.533 m in diameter but
modular in length, so it has no preset. Library scenes that predate the
table use 3.0 x 0.5 m, which sits inside this spread.
terrain: true: a mesh that IS the seafloor
A normal kind: mesh object does two things: it stamps its top surface
into the bed, and it draws surface scatterers at
surface_area * scatterer_density. That is right for a wreck sitting on
the seafloor. It is wrong for a mesh that is the seafloor: a
bathymetry tile, a terrain export from Blender: because the bed already
generates scatterers across that footprint, at the elevation the stamp
just wrote. The area then carries two scatterer populations, and at
seafloor scale that dominates the build: the 80 m Blender terrain disc in
blend_training_scene3_hisas.yaml asks for 171 M points on its own at
20000/m^2, more than the whole bed.
terrain: true keeps the elevation stamp, the protect mask and the FW
facet record, and skips only the surface draw:
- kind: mesh
x: 60.0
y: 60.0
mesh_path: models/blend_seafloor_plane/blend_seafloor_plane.obj
terrain: true
feather_m: 3.0 # ramp the tile's rim into the surrounding bed
ts_db: -20.0
seed: 301
Use it for any mesh whose job is to define the bed's shape. Leave it off for objects lying on the bed, whose surface return is the point.
Sizing note: simulator/scenes/mem_preflight.py predicts the build peak
from the bed and from every non-terrain mesh object, so a scene that
cannot fit is refused before it starts rather than OOM-killed part way
through placement.
Inheritance
A YAML may start with inherits: <name-or-path>. The parent is
resolved via the same bare-name / path rules as load, deep-merged
into the child, and then parsed. The merge rules
(schema.deep_merge):
- Nested dicts recurse: child sub-keys overlay parent.
- Scalars and lists replace: no list concatenation. If you want to "add one object" to a parent's list, you must re-specify the full list in the child.
# child.yaml
inherits: 9obj_split_zonemap
simulation:
scatterer_density: 4000.0 # only this field changes
output:
prefix: 9obj_split_4000pm2
YAML 1.1 Numeric-Coercion Note
PyYAML's safe_load uses YAML 1.1 resolution, which is surprisingly
strict about scientific notation:
300000.0: parses as float OK.3.0e+5: parses as float OK (explicit sign on exponent).300.0e3: parses as a string! (No sign on exponent.)
schema._coerce_numerics rescues this by converting string values
to float/int per the target dataclass's type hints on load. Prefer
300000.0 or the explicit-sign form in YAML to avoid relying on
coercion.
Public API
from simulator.scenes import load, save, instantiate, run
cfg = load('9obj_split_zonemap') # bare name -> library/
cfg = load('/path/to/scene.yaml') # explicit path
save(cfg, '/path/to/out.yaml')
built = instantiate(cfg)
# -> {'scene': Scene, 'motion': PlatformMotion, 'sonar': SonarConfig,
# 'h5_path': str, 'cfg': SceneConfig}
result = run(cfg, force_sim=False, verbose=True)
# -> {'tdbp': SlcImage, 'h5_path': str, 'port_png': str}
Backend tag in file names. At the start of run, the output prefix is
passed through simulator.scenes.naming.method_tagged_prefix: for
simulation.method: fw any _<N>pm2 density tag is dropped (the FW
seafloor is rasterised, so density does not apply) and _fw is appended,
e.g. scene_2000pm2 -> scene_fw. PS prefixes are left as they are. A PS
run and an FW run of the same YAML therefore never overwrite each other and
the file name says which simulator produced the image.
run caches the simulated HDF5: if <output.directory>/<output.prefix>.h5
exists and force_sim=False, it skips straight to beamforming.
Iterate on beamformer params without re-paying simulation cost.
Example: Library Scene
simulator/scenes/library/9obj_split_zonemap.yaml is the canonical
9-target MUSCLE-geometry demo. Excerpt:
schema_version: 1
name: 9obj_split_zonemap
extent: {x_min: -5.0, x_max: 66.45, y_min: 0.0, y_max: 70.0, cell_size: 0.05}
bottom:
default_type: sand
disable_auto_rocks: true
seed: 42
zone_grid:
- [sand]
- [rocky_sand]
rms_heights: {sand: 0.0001, rocky_sand: 0.06}
custom_types:
- {name: rocky_sand, mean_reflectivity: 0.3, roughness_std: 0.002,
bss_db_at_20deg: -28.0, spectral_exponent: 2.0,
spectral_strength: 6.0e-4, rms_height_m: 0.06}
ripples:
- {zone: [0, 0], ripple_wavelength: 0.75, rms_height: 0.037,
direction_deg: 35.0, skewness_b: 18.0, seed: 142}
rocks:
- {zone: [0, 0], density: 1.0, d_min: 0.2, d_max: 1.0, alpha: 2.3, seed: 542}
- {zone: [1, 0], density: 2.0, d_min: 0.2, d_max: 1.0, alpha: 2.3, seed: 543}
objects:
- {kind: box, x: 20.0, y: 20.0, x_extent: 2.0, y_extent: 1.0,
height: 0.5, ts_db: -15.0, seed: 43}
- {kind: cylinder, x: 30.0, y: 20.0, length: 3.0, diameter: 0.5,
orientation: along_track, ts_db: -13.0, seed: 44}
- {kind: point, x: 40.0, y: 20.0, z: 0.0, ts_db: -25.0}
- {kind: rockan, x: 30.0, y: 32.5, heading_deg: 55.0, ts_db: -15.0, seed: 45}
- {kind: manta, x: 20.0, y: 45.0, ts_db: -15.0, seed: 46}
# ... meshes for boat, torpedo, container, boat_mine
Every ripple, rock group, and object has an explicit seed. The
scene reproduces bit-for-bit across runs and machines.
Thin Wrapper Pattern
Runner scripts should be trivial over load + run. For most scenes
you don't need a script at all: use the generic CLI:
conda run -n py312 python -m simulator.scenes.run simulator/scenes/library/9obj_split_zonemap.yaml
conda run -n py312 python -m simulator.scenes.run simulator/scenes/library/9obj_split_zonemap.yaml --force-sim # rebuild HDF5
If a scene genuinely needs a custom wrapper, keep it to this shape
(example from the retired run_scene_9obj_split.py; git history):
import os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from simulator.scenes import load, run
def main():
cfg = load('9obj_split_zonemap')
# CLI-style overrides via env vars (schema-driven scripts stay thin).
if 'SIM_DENSITY' in os.environ:
density = float(os.environ['SIM_DENSITY'])
cfg.simulation.scatterer_density = density
cfg.output.prefix = f'9obj_split_zonemap_{int(density)}pm2'
force_sim = bool(os.environ.get('FORCE_SIM'))
result = run(cfg, force_sim=force_sim, verbose=True)
print(f"Done. port={result.get('port_png', '<not saved>')}")
if __name__ == '__main__':
main()
When a runner grows more than ~30 lines, that's a signal to push the
complexity into a new library YAML (or a parent YAML plus inherits:
variants) rather than into Python.
Reproducibility Rule
Seeds are always explicit. Every ProceduralRockGroup,
ZoneRipple, object dict, and each of bottom.seed,
simulation.seed, simulation.fw_seed carries its own integer. A
scene file is the ground truth; never store derived random values
(e.g. a concrete rock list that was generated from a seed), and
never call time.time() or random.SystemRandom() in the runner.
Two run(cfg) invocations on the same YAML must produce the same
HDF5, the same scatterer count, and the same image.
Batch generators
Deterministic scene batches live beside the library: generate_manta_grid.py
(one grid scene), generate_linear_feature_pairs.py (change-detection pairs)
and sample_images/ (1000 HISAS swaths for ML; spec
docs/superpowers/specs/2026-09-17-sample-images-1000-design.md). They all
write plain scene dicts validated through schema.from_dict +
run.validate_objects, keep every YAML on disk for bulk edits, and render
heightmaps (run.save_heightmap_png) before any simulation.