Sar radar
ApertureLab is dual-modality. Setting kind='rf' on the array config
(simulator/sonar.py::SonarConfig) switches the point-scatter simulator and
the time-domain back-projection beamformer from acoustics to radar: the
propagation speed becomes c, Thorp absorption is gated off, stop-and-hop is
the default, TVG is replaced by a radar range compensation, and the design
rules swap their sonar branch for an rf one. Everything else, the
heightfield, the scatterer cloud, the echo kernel, TDBP, the DRC, is shared
with SAS. This page holds only the SAR-specific physics and the two
reference systems; the shared machinery is in
simulator.md and
beamformer-pipeline.md, and the geometry
conventions (along-track up, range right, port-image layout) are unchanged
from coordinate-systems.md.
Out of scope, deliberately: quad-pol channels (the hardware is quad-pol, the
simulation is single-pol HH), canopy volume scattering, soil-moisture
physics, FMCW-specific effects (range-Doppler coupling, deramp residual
video phase, sweep nonlinearity), and interferometry. The frequency-domain
backends stay SAS-only: the FW engine raises on kind='rf'
(simulator/fw_engine.py) and so does the omega-k beamformer
(beamformer/sas/beamformer_wk.py), which tells the caller to use TDBP. FW
also ignores the angular-exponent grid described below.
Reference systems
Purdue DroneSAR, S-band
simulator/sonar.py::dronesar_s_preset(). A university crop-monitoring
radar: NSF IoT4Ag and USDA digital forestry, flown over Indiana fields for
soil moisture and biomass. Sources: the Purdue Radio Navigation Laboratory
page, which carries the spec table and the photographs
(https://engineering.purdue.edu/RNL/?p=337); the 2025 Purdue Symposium of
Digital Agriculture poster, Li, Azimi, Crawford and Garrison, DOI
10.5703/1288284318196; and the parent system paper, Jordan et al.,
"Development of the miloSAR Testbed for the One Kilogramme radioCamera SAR
for Small Drones", IEEE RadarConf 2019, DOI 10.1109/RADAR.2019.8835721.
| Parameter | Value | Basis |
|---|---|---|
| Centre frequency | 2.4375 GHz (lambda = 12.31 cm) | Purdue table |
| RF bandwidth | 175 MHz | Purdue table |
| Slant-range resolution | 0.857 m unweighted, 1.23 m Hann | derived, c/2B and 1.44c/2B |
| PRF | 1250 Hz | Purdue table |
| Max range | 900 m | Purdue table |
| Waveform | FMCW, modified sawtooth ramp | Purdue table |
| Polarisations | HH, HV, VH, VV | Purdue table |
| Antennas | two cylindrical horns, one TX one RX, about 0.2 m aperture | photograph |
| Platform | large multirotor, side-looking horns under the airframe | photograph |
| Radiated power | 10 mW class on the miloSAR parent | RadarConf 2019 |
Preset fields: fs 220 MHz (baseband, 1.25 x BW), pulse_length 20 us,
sos 3.0e8, n_channels 1, TX and RX elements 0.20 m azimuth by 0.15 m
elevation (31.2 deg and 41.6 deg one-way 3 dB beams), depression_deg 34.0,
sonar_side starboard, polarizations ['HH']. The horn dimensions are
estimates from the photograph and are the knobs most likely to be retuned.
Reference geometry (appcore/sar_presets.py):
| Knob | Value | Why |
|---|---|---|
| altitude | 120 m | FAA Part 107 ceiling (400 ft) |
| ground range | 80 to 330 m | incidence 33.7 to 70.0 deg; slant 144.22 to 351.14 m, inside the 900 m max |
ping_rate |
125 Hz | 10x presum of the 1250 Hz PRF |
| speed | 5 m/s | typical multirotor survey speed |
n_pings |
10 000 | 400 m of traverse at 5 m/s and 125 Hz |
along_track |
410 m | the 400 m traverse plus a 10 m margin for the wizard's own validation rule |
cell_size |
0.25 m | the range cell is 0.86 m |
scatterer_density |
20 per m^2 | about 8 per resolution cell, the SAS speckle convention |
stop_and_hop |
True | as every rf scene |
| buffer | 20 m |
Sandia MiniSAR, Ku-band
simulator/sonar.py::minisar_ku_preset(). Sandia's miniaturised SAR (27 to
60 lb, configuration dependent), the descendant of Lynx, kept as the second
reference system. Until 2026-09-19 this preset was labelled Ka-band with
600 MHz of bandwidth and cited a report that does not exist; the values
below come from the Sandia MiniSAR fact sheet (SAND2008-3363P), the Lynx
paper (Tsunoda et al., SAND99-0562C), Doerry's SAND2006-5855 and
SAND2006-2632, Sandia LabNews of 2005-06-10 and Avionics International of
2006-09. minisar_ka_preset remains importable as an alias for one release.
| Parameter | Value | Source |
|---|---|---|
| Band | Ku, 15.2 to 18.2 GHz available | Lynx paper |
| Centre frequency | 16.7 GHz (lambda 1.80 cm) | fact sheet (Avionics says 16.8) |
| Bandwidth | 2.0 GHz for the 4-inch mode; the hardware spans 3 GHz | SAND2006-5855: 0.1 m needs about 2 GHz after sidelobe filtering |
| Slant resolution | 0.075 m unweighted, 0.108 m with the Hann matched filter | derived |
| Resolution modes | 0.1 to 10 m adjustable; Lynx 0.1 m spotlight, 0.3 m stripmap | fact sheet, Lynx paper |
| Sample rate | 2.5 GHz baseband | 1.25 x bandwidth |
| Pulse length | 10 us, an equivalent pulsed LFM for the stretch-processed waveform | modelling choice |
| Antenna | wideband patch array in a roughly 12-inch antenna-gimbal cube; modelled as 0.25 m azimuth by 0.15 m elevation (3.7 by 6.1 deg beams), an estimate between the Lynx dish (3.2 by 7 deg) and the DroneSAR horn | fact sheet, Avionics, Lynx paper |
| Stripmap azimuth resolution | D/2 = 0.125 m before windowing, the 4-inch class | derived |
| Transmit power | 60 W (Lynx: 320 W TWTA) | fact sheet, Lynx paper |
| Channels | one spatial channel, HH | modelling choice |
| Mount depression | 30 deg, the grazing angle of the published 4-inch images | SAND2006-2632 fig. 1 (3.2 km, 31 deg); OSTI image portfolio (6.86 km, 30.35 deg) |
| Maximum range | 5 to 23 km, mode dependent; 4 in at 6 to 10 km, 1 ft at 15 km, 3.3 ft at 23 km | fact sheet, LabNews, Avionics |
| Platforms | Twin Otter testbed, Sky Spirit Class 3 UAV (2006); Predator and I-GNAT for Lynx | fact sheet, Lynx paper |
Operating envelope for scene authors: altitude 1.5 to 3.5 km and slant range 3 to 10 km for the fine-resolution modes (the published 4-inch images sit at 3.2 km slant and 31 deg grazing, and at 6.9 km and 30 deg), grazing 25 to 45 deg typical, depression 5 to 60 deg as the hardware limit, 5 to 23 km maximum range depending on mode. MiniSAR's headline images are spotlight; this simulator images it as stripmap, where azimuth resolution is set by the antenna.
Reference geometry (appcore/sar_presets.py): 3000 m AGL at 75 m/s,
30 deg grazing at the scene centre (6 km slant), a 200 m ground swath at
5096 to 5296 m (5913.61 to 6086.81 m slant), 700 Hz (0.107 m advance
against the 0.141 m single-channel sampling limit of the 3.7 deg beam),
5600 pulses for a 200 m image plus the 388 m far-range beam footprint,
5 cm cells and pixels, 500 scatterers per m^2 (about 7 per 0.108 by
0.125 m resolution cell). The record is about 29 k samples per pulse.
Registry
Both systems live in appcore/sar_presets.py as SAR_SYSTEM_PRESETS, an
ordered dict of SarSystemPreset (combo label, config factory, geometry
dict), mirroring appcore/sas_presets.py. DEFAULT_SAR_SYSTEM is the first
entry, Purdue DroneSAR. apply_sar_preset(sonar_cfg, system) copies the
array fields onto a scene-schema SonarCfg and default_sar_geometry(system)
returns a copy of the geometry table. The New Scene wizard's SAR path shows
the same system combo the SAS path shows, with these two entries, and
appcore.scene_defaults.default_scene(kind='rf', sar_system=...) takes the
system by name (the SAS twin is sas_system).
YAML scenes carry the modality: sonar.kind and sonar.depression_deg are
fields on the scene schema and reach the built SonarConfig. Before this
was fixed, every SAR scene loaded from YAML silently simulated as sonar.
FMCW modelled as pulsed LFM
DroneSAR transmits an FMCW sweep; the simulator generates pulsed chirps and
matched-filters them. For image formation the point-spread function of an
FMCW sweep and of a pulsed chirp of the same bandwidth are the same, so the
preset is an equivalent pulsed LFM at the sweep bandwidth. The 20 us
pulse_length is a record-length cost choice, not a physical one; the real
sweep is 800 us. FMCW-specific effects are out of scope, as listed above.
Azimuth presum
Real drone SAR processors presum azimuth before focusing. The preset's
docstring carries the true 1250 Hz PRF; the reference geometry carries
125 Hz, a 10x presum. Scenes may set any ping_rate and the rf design rule
R7 grades the result. Running at the full 1250 Hz is valid and costs 10x the
pulses.
The single-channel along-track sampling limit is lambda / (4 sin(beta/2)) with beta the one-way TX beamwidth: 0.1231 / (4 x 0.269) = 11.4 cm. At 125 Hz and 5 m/s the advance is 4 cm, a 2.9x margin.
Antenna depression
SonarConfig.depression_deg: float = 0.0 is the antenna boresight
depression below the body horizontal, positive down, on the sonar side. A
low-altitude wide-beam radar points its horns down at the swath; a
horizontal broadside beam would put the ground on the pattern's skirt.
The beam pattern is evaluated on body-frame direction cosines in
simulator/gpu_raytrace.py (the CuPy path _beam_pattern_2d_gpu and the
fused CUDA kernels). Before the sinc lookup the look vector is rotated about
body X:
u_z' = cos(dep) * u_z - side_sign * sin(dep) * u_y
with side_sign = +1 for a starboard mount and -1 for port. The rotation is
applied to TX and RX alike, one mount. At depression_deg == 0 the branch is
skipped entirely rather than evaluated with cos = 1 and sin = 0, so the SAS
path stays bit-identical.
The HDF5 writer records the angle as the attribute DepressionDeg on
TransmitterInformation/Transmitter1, beside the modality, so beamformers
and viewers can read it back. Nothing downstream consumes it yet.
That last point is a trap worth stating: TDBP's optional elevation FOV cull
(beamformer/sas/beamformer.py, enabled by a non-zero el_sin_half_beam)
tests the body-frame u_z of the pixel direction without the mount
rotation, so it measures the angle from the body horizontal rather than from
the depressed boresight. On a 34 deg mount it would cull the illuminated
swath. Anyone enabling the elevation cull on an rf file must make it consume
DepressionDeg first.
Material angular law
BottomType (simulator/bottom.py) carries two fields for the angular
behaviour of a distributed scatterer:
| Field | Default | Meaning |
|---|---|---|
angular_exponent |
2.0 | backscattered power varies as sin^n(grazing); Lambert is n = 2 |
ref_grazing_deg |
20.0 | the grazing angle at which bss_db_at_20deg is quoted |
bss_to_rayleigh_scale generalises from sin^2 at 20 deg to sin^n at
ref_grazing_deg. bss_db_at_20deg keeps its name for backward
compatibility; read it as "the calibration level", quoted at
ref_grazing_deg. The seafloor table is unchanged numerically: every entry
is Lambert at 20 deg grazing. Radar land cover is quoted the other way
round, at 40 deg incidence, which is 50 deg grazing, so an rf material sets
ref_grazing_deg: 50.0.
The exponent travels as a per-cell grid, not a per-scatterer array.
Heightfield.angular_exponent_grid() bakes an (n_x, n_y) float32 grid from
the zone bottom types (or the single bottom type) and then applies footprint
overrides registered through Heightfield.set_angular_exponent(xi_lo, xi_hi,
yi_lo, yi_hi, value). The raytracer reads it at the same (xi, yi) it
already uses for the surface normal and multiplies by cos_angle ** (n/2)
in place of cos_angle. Scatterers belonging to objects, primitives, meshes
and cables inherit the exponent of the cell under them, the same
approximation they already make with that cell's normal.
Two paths keep SAS bit-identical. angular_exponent_grid() returns None
when every cell is 2.0, which is every seafloor scene; the caller then
passes no grid, the kernel flag is 0 and the historical expression runs. The
kill switch PS_ANGULAR_EXPONENT=0 forces None unconditionally.
The ladder scene registers its material through bottom.custom_types:
custom_types:
# S-band bare soil, sigma0 -15 dB at 40 deg incidence (50 deg
# grazing), power ~ sin^3(grazing): Ulaby, Moore & Fung vol. III.
- name: bare_soil
mean_reflectivity: 0.3
roughness_std: 0.002
bss_db_at_20deg: -15.0
ref_grazing_deg: 50.0
angular_exponent: 3.0
spectral_exponent: 3.0
spectral_strength: 1.0e-4
rms_height_m: 0.0
default_rock_density: 0.0
Corner reflectors override their own footprint to exponent 0, see below.
Radar range compensation
Radar has no TVG. In its place the beamformer scales the matched-filtered data by a power of range, at the same point in the pipeline where TVG would divide:
amplitude x (R / R_ref) ** p
R comes from the sample time and the recorded range_offset_m, and
R_ref is the median slant range of the recorded samples, not the image
mid-swath: on the ladder the record spans 144 to 414 m, so R_ref is about
279 m (beamformer/sas/pipeline.py::range_compensation_gain). Its only
effect is a constant DRC offset, since dividing by any fixed R_ref scales
every pixel alike; the shape of the range trend comes from p alone. At
p = 0 the gain is exactly ones.
BeamformSettings.range_compensation_exponent: float | None = None means
auto, resolved from the HDF5's modality by
_resolve_range_compensation_from_kind: 0.55 for rf, 0.0 for sonar. An
explicit value always wins, using the same user-locked resolver pattern as
TVG. The scene schema mirrors the knob as
beamform.range_compensation_exponent and run.py::_beamform_kwargs
forwards it. Because the exponent depends on the imaging geometry (next
subsection), each reference system carries its own measured value in
appcore/sar_presets.py, and default_scene writes it into the scene as an
explicit setting; the auto value is the DroneSAR one.
| System | Exponent | Measured |
|---|---|---|
| Purdue DroneSAR S-band | 0.55 | 2026-09-18, DroneSAR ladder |
| Sandia MiniSAR Ku-band | see the MiniSAR ladder note below | 2026-09-19, MiniSAR ladder |
The textbook exponent is 2.0, which restores the R^4 two-way spreading loss in power. It over-compensates here because the coherent sum already supplies most of that: the aperture TDBP integrates grows with range, and in this implementation it grows faster than the geometry alone would give, for the reason in the next subsection. Measured on the corner-reflector ladder on 2026-09-18 (five 0.5 m trihedrals at 100 to 300 m ground range, each divided by the horn's analytic two-way elevation gain at its own depression angle):
| Exponent | Result |
|---|---|
| p = 2.0 | compensated levels still rise as P ~ R^2.90, a 9.4 dB spread across the swath |
| p = 0.55 | the range trend flattens to -0.006 dB per dB of R, leaving a 1.19 dB residual |
A 1.19 dB residual about the power law survives at any exponent, so the ladder gate is set at 1.5 dB rather than the 1.0 dB originally designed. It is systematic rather than noise: two reflectors at the same range agree to 0.13 dB, and the residual is a smooth inverted-U in elevation offset from boresight (-0.57 dB at +16.2 deg, +0.62 at +4.7, +0.25 at -3.0, +0.07 at -8.4, -0.37 at -12.2).
Why 0.55 and not 2.0: TDBP's beam gate lives in the ground plane
Both the exponent and the residual come from the TDBP kernel, not from the
propagation physics. beamformer/sas/beamformer.py gates each ping against
the beam, and weights it with the synthetic-aperture (Hann) window, using
atan2f(dy, dx): the azimuth of the pixel from the phase centre in the
horizontal plane, with the z offset dropped (the early cull near line 190,
the receive-time gate near line 316, the window lookup near line 372). The
along-track span that survives that gate at a pixel is therefore
2 y tan(beta/2), set by the ground range y, while the antenna actually
illuminates about 2 R sin(beta/2), set by the slant range R. The
coherent sum accumulates over the gated span, so the image gains a factor
(y / R)^2 in power that varies across the swath.
For SAS this is invisible: at 10 % altitude, y / R is 0.995 at every range. For DroneSAR, flying 120 m over a 100 to 300 m swath, y / R runs from 0.64 at the near edge to 0.93 at the far edge, an aperture deficit that shrinks with range and so adds a rising trend on top of R^-4 spreading.
A CPU model of the ladder (coherent sum over the same pings, the same
two-way sinc pattern, the same window) reproduces the measurement when it
gates the way the kernel does, and gives the textbook answer when it gates
on the true cone angle asin(dx/R):
| Beam gate in the model | p | Level spread | Fitted slope (P ~ R^slope) |
|---|---|---|---|
ground plane, atan2(dx, y) (the kernel today) |
2.00 | 9.20 dB | 2.88 |
ground plane, atan2(dx, y) (the kernel today) |
0.55 | 0.75 dB | -0.02 |
true cone angle, asin(dx/R) |
1.00 | 0.03 dB | 0.00 |
| measured on the ladder (kernel as shipped) | 0.55 | 1.18 dB | -0.006 |
The model's ground-plane rows match the measurement in every respect that matters: the R^2.9 trend at p = 2, a flat ladder at p = 0.55, and a 0.76 dB inverted-U residual with the measured sign pattern (down at the near and far ends, up in between). With a cone-angle gate the same model needs p = 1.0 and leaves 0.03 dB. The earlier suspect, a missing two-way cos^2 obliquity term, is withdrawn: it was also impossible on logic, because the ladder test divides each peak by the same analytic sinc model the kernel evaluates, so a term absent from both cancels and cannot produce a discrepancy.
0.55 is an empirical calibration of this TDBP gate geometry, not a physical constant. It is a function of y / R across the swath, so a different altitude-to-range ratio needs re-measuring, and so does any change to the aperture normalisation or the beam gating.
The same gate has a second consequence: it truncates the azimuth aperture at near range. At 100 m ground range the kernel integrates only 64 % of the along-track span the horn illuminates, so rf azimuth resolution is geometry-dependent and coarsest at the near edge of the swath.
The follow-up, out of this slice, is a cone-angle beam gate for kind='rf'
(or a beam_gate setting) that keeps the sonar path bit-identical. After
it, the auto exponent for rf returns to the physical 1.0 and the ladder gate
can go back to the 1.0 dB the design asked for.
Design rules for rf
simulator/design_rules.py gives kind == 'rf' its own branch. Two rules
are re-expressed for radar:
| Rule | rf form | Severity |
|---|---|---|
| R6 | incidence angle at the near and far edge of the image swath must stay inside 20 to 75 deg | warn |
| R7 | advance per pulse must not exceed lambda / (4 sin(beta/2)), beta the one-way TX beamwidth | error above the limit, warn within 20 % of it |
| R24 | with shadows on, the line-of-sight walk (swath width over cell size) must not exceed 1 500 cells per scatterer per pulse | warn |
R6 replaces the sonar altitude band; a spline track is graded on the worst-case altitude at each end of the swath (the highest control point for the near edge, the lowest for the far edge). R7 replaces the phase-centre lattice rule, since an rf array is single-channel; a spline track is graded on its peak control-point speed. Scenes usually presum the hardware PRF down to a rate that still satisfies R7.
R2 and R18 apply unchanged. The sonar-only rules R8, R17 and R19 do not run on rf scenes.
Corner reflectors
kind: trihedral is a scene-YAML object kind taking x, y, edge_m, an
optional z (default 0, on the local bed) and an optional seed. At build
time Scene.add_trihedral resolves it to a point target whose ts_db is the
peak RCS of a square trihedral,
sigma = 4 pi a^4 / (3 lambda^2)
(simulator/rcs.py::trihedral_rcs_dbsm; Knott, Shaeffer and Tuley, Radar
Cross Section, 2nd ed., ch. 6). The wavelength comes from
scene.wavelength_m, which the scene runner stashes from the sonar config
before placing objects, so a reflector stays correct if the preset changes.
A 0.5 m trihedral at S-band is 17.3 m^2, 12.4 dBsm, about 34 dB above one
bare-soil resolution cell.
Two properties make it a calibration target rather than clutter:
- Deterministic. One sub-scatterer, zero jitter,
fixed_amplitude. The point default of ten random-phase sub-scatterers sums to a Rayleigh-distributed peak, several dB of seed luck per reflector, which would swamp a 1 dB ladder. - Isotropic. The placer overrides the exponent grid to 0 on the 3 x 3 cells around the reflector, so no sin(grazing) factor is applied. A trihedral's RCS is flat over its acceptance cone, and the Lambert weighting a point target would otherwise inherit from the ground cell puts several dB of grazing-angle slope across the ladder on its own.
The desktop GUI does not know this kind yet. There is no palette entry for
trihedral, so one cannot be added from the GUI at all; author it in YAML
or through Studio, which documents the kind in studio/schema_docs.py. A
trihedral loaded from YAML does survive a round trip, but it renders through
the viewport's unknown-kind fallback, the small white marker of
gui_imgui/viewport/footprints.py, and it is edited through the generic
properties panel with no kind-specific fields. A known follow-up.
The validation ladders
There is one ladder scene per reference system, and one test,
simulator/tests/test_sar_cr_ladder.py, parametrised over both; it reads
the platform, antenna, reflector positions and beamformer settings from the
scene YAML, so editing a scene moves its gate. Select one with -k dronesar
or -k minisar.
simulator/scenes/library/dronesar_cr_ladder.yaml: flat bare_soil, no
receiver noise, six 0.5 m trihedrals, five on the strip centreline at ground
ranges 100, 150, 200, 250 and 300 m and a sixth 5 m along-track from the
200 m one.
simulator/scenes/library/minisar_cr_ladder.yaml: the MiniSAR reference
geometry (3 km AGL, 30 deg grazing, 200 m swath at 5096 to 5296 m), six
0.2 m trihedrals (13 dBsm at 1.8 cm), five across the swath at 40 m spacing
and a sixth 1 m along-track from the middle one, Ku-band bare soil.
The test simulates and focuses each scene once and checks three things:
- the five centreline peaks agree within 1.5 dB after range compensation and after dividing out the horn's analytic two-way elevation pattern at each reflector's depression angle;
- every peak lands within 0.5 m of truth, two cells on the 0.25 m image grid (positions came out exactly on truth at the 2026-09-18 measurement);
- the range PSF -3 dB width matches the Hann matched-filter width. The image grid is ground range, so the slant resolution 1.44c/2B projects by dR/dy = R/y at the reflector: 1.439 m expected at the 200 m reflector against 1.373 m measured, 4.6 percent below the expected value and well inside the test's 15 percent tolerance.
Both tests carry the slow marker. They need a GPU and take about
15 minutes (10 000 pulses, 2.2 M scatterers). The MiniSAR PSF test stays as
the cheap focus check.
Cost at fine resolution
Point-scatterer simulation cost scales with scatterers in the beam per pulse times pulses, and TDBP cost with image pixels times pulses in the aperture. Fine resolution at long range multiplies both: the MiniSAR reference geometry has 64 M scatterers of which 36 M sit in the 388 m beam footprint on every pulse, against 2.2 M and 1.3 M for the DroneSAR ladder. Measured on 2026-09-19 (GPU shared with another job, so the numbers are upper bounds):
| Scene | Scatterers | In beam per pulse | Pulses | Per pulse | Simulation |
|---|---|---|---|---|---|
| DroneSAR ladder, shadows on, 25 cm cells | 2.2 M | 1.3 M | 10 000 | 0.09 s | 15 min |
| MiniSAR ladder, shadows off, 5 cm cells | 64 M | 36 M | 5 600 | 0.2 to 1 s | 20 to 60 min |
| MiniSAR ladder, shadows on, 5 cm cells | 64 M | 36 M | 5 600 | 25 s | 39 h, never finished |
The shadows-on row is the line-of-sight test, not the raytrace: the LOS kernel walks the heightfield from the swath's near edge to each scatterer, up to 4 000 cells per scatterer at 5 cm cells over a 200 m swath, on every pulse. Design rule R24 warns when that walk exceeds 1 500 cells; flat scenes run with shadows off, and the MiniSAR blank scene ships that way. Scenes with relief or objects at this cell size need a coarser LOS grid (a mip level for the walk, independent of the scatterer cell), which is a follow-up.
Running the ladder by hand
conda run -n py312 python -m simulator.scenes.run simulator/scenes/library/dronesar_cr_ladder.yaml
The scene writes its HDF5 and images to the dated output folder
(output.directory in the YAML, /mnt/d/simulator_output; use the
YYYY-MM-DD subfolder convention for anything you keep). The image follows
the house orientation: along-track up, range right, row 0 at the bottom.
One rf-specific note on record length. run.derive_sim_range picks the
margin beyond the imaged swath by modality: the long-standing sonar
convention of two pulse-distances (2 tau c) is nonsense on a radar chirp,
where 20 us at 3e8 m/s is 12 km of margin and about 22 M samples per ping,
so kind='rf' gets a fixed 20 m instead. compute_n_samples already adds
the pulse length on top of the two-way span. Sonar HDF5 shapes are
untouched.