Documentation

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 for Scene(bottom_type=...), overridden per-cell by zone_grid.
  • disable_auto_rocks: if True (default), zeros every BottomType.default_rock_density before 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 legacy rms mode: the realisation is rescaled to exactly this rms height, bit for bit as before 2026-09-22. Unspecified built-in types use the absolute spectrum W(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 of rms_heights for 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 own spectrum_mode (below).
  • custom_types: list of CustomBottomType registrations applied to global BOTTOM_TYPES before zone map setup. Lets a scene define, e.g., rocky_sand = sand BSS with rock β, without editing core bottom tables. An optional ripple: block (RipplePattern) paints a Tang ripple field wherever the type is painted; its forcing: sub-block (hs_m, tp_s, depth_m, d50_mm, wave_dir_deg) derives wavelength / height / direction from the sea state and age_days fades a field left unforced (see simulator.md, Ripples from wave forcing). spectrum_mode (default rms) picks the macro-roughness synthesis: rms normalises to rms_height_m and ignores spectral_strength (older YAML carries arbitrary strengths that never did anything, so this stays the default); absolute synthesises W(k) = spectral_strength k^-spectral_exponent, k in rad/m, like the built-ins (see simulator.md, Roughness spectrum).
  • bathymetry: list of BathymetryOperator, 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 of ZoneRipple (Tang skewed) overlays, zone=(row,col), with ripple_wavelength, rms_height, direction_deg, skewness_b, seed.
  • rocks: list of ProceduralRockGroup (zone/zones/bbox, density, d_min, d_max, alpha, seed); all placement None means whole scene. A preset (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; see boulder_fields.md.
  • outcrops: list of OutcropField (2026-09-18): bedrock outcrops (kind: outcrop) or ledges / terraces (kind: ledge). Each is an ellipse length x width at strike_deg with an organic outline, a plateau dipping by dip_deg, edges at edge_slope_deg (a ledge's back at back_slope_deg), roughness top_rms_m on top and a talus apron of apron_fraction x height at the foot. Heights are log-uniform in height_min..height_max. count or density; bbox places the centres and a feature keeps its organic edge past it (feather_m > 0 confines it softly instead). material (default rock, or none) is painted under the plateau after the seafloor bake, so the top scatters like rock. Rocks and boulders are rocks; this is the bedrock they sit on.
  • patches: list of MaterialPatchField (2026-09-18): organic patches of a bottom type (shell_hash, gravel, mud, any custom type) painted after the bake with no relief. count or density, diameter_min..max with power law diameter_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.json sidecar resolved relative to the YAML; the sidecar wins on load, mirroring zone_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: see docs/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.