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). Unspecified types use BottomType.rms_height_m.
  • 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.
  • 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, density, d_min, d_max, alpha, seed); zone=None means whole scene.
  • 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.

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
point x, y, z, ts_db, n_sub?, jitter_m?, seed?
mesh x, y, mesh_path, scale, rotation_z_deg, ts_db, seed
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_*.

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.