Documentation

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:

  1. 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;
  2. 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);
  3. 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.