SEAM Studio
Guide 07 · SEAM Studio docs

Radar sensing: RCS targets, echo solves, and Doppler

SEAM Studio can treat any actor (car, pedestrian, UAV, custom object) as a radar sensing target and solve the echo paths TX → target → RX with Sionna RT's radar cross-section solver (sionna.rt.rcs.RCSSolver, new in sionna-rt 2.2). Every echo carries its own Doppler shift, so a moving target shows up as a colored velocity signature in the viewport. Without sionna-rt 2.2 the whole workflow still runs on the Mock backend (bistatic radar equation), so you can try it without a GPU.

The Sample Demo (v0.1.14 and later) is ready for sensing: it ships a drone target (uav_001, TR 38.901 uav-small-size, flying a 90 m L at 40 m and 10 m/s, then hovering) and a sensing receiver co-located with the rooftop TX (tx_001_rx, "TX 1 sensing RX"), so the API examples below run on sample_demo as they are. Since v0.1.15 it also has two more sensing sites on street masts (tx_002 with tx_002_rx, tx_003 with tx_003_rx) and a second stored config, sensing_fr1 (3.5 GHz, 20 MHz), with which the scenario of §6 detects and tracks the drone out of the box. A project created by an earlier version keeps its devices, actors and configs (an upgrade never rewrites a project). To get the current demo, create a fresh one next to it, reload the page and pick Sample Demo v2 in the project select (or use sample_demo_v2 in place of sample_demo in the API examples):

curl -X POST http://127.0.0.1:8000/api/projects -H "Content-Type: application/json" \
  -d '{"name": "Sample Demo v2", "template": "demo", "project_id": "sample_demo_v2"}'

In a source checkout you can instead delete projects/sample_demo.seam and restart: it is copied again with every addition. Or add a target and co-located RXs to your own project (§2, §3).

1. What RCS is

2. Binding an actor

Select an actor and open Inspector → Sensing target. The binding is stored on the actor as actor.sensing (see ../scene_format.md); null means "not a target".

Model is one of:

object_typeModel typesDefault for kindMean monostatic σ_M (dBsm)Scattering pointsTR 38.901 default size L×W×H (m)
uav-small-size1uav−12.8110.3 × 0.4 × 0.2
uav-large-size2—−5.8511.6 × 1.5 × 0.7
human1, 2human−1.3710.5 × 0.5 × 1.75
vehicle-single-sp2—11.2515.0 × 2.0 × 1.6
vehicle-multi-sp2car11.255 (left, back, right, front, roof)5.0 × 2.0 × 1.6
agv-single-sp2—−4.2510.5 × 1.0 × 0.5
agv-multi-sp2—−4.2550.5 × 1.0 × 0.5

Plain "vehicle" / "agv" are not valid: Sionna requires the single- or multi-scattering-point variant. A model type the object type does not define (e.g. model 2 on uav-small-size) is rejected with a 422 on save.

Other fields (both models unless noted):

FieldMeaning
size_mTarget cuboid [length, width, height] m. Blank = the actor's own box size (shape.size_m) — not the TR 38.901 default above. Mesh actors should set it.
velocity_m_sWorld-frame velocity of the target. Blank = the actor trajectory's tangent at t = 0; a static actor is at rest.
random_componentsTR 38.901 only: draw σ_S, XPR and initial phases per direction pair (off = deterministic RCS).
enabledUntick to keep the binding but skip the target.

The target cuboid is centered at the actor base plus half its height and takes the actor's [yaw, pitch, roll]. When the velocity comes from the trajectory (blank velocity_m_s on an actor with waypoints), the whole pose does too: the base sits at the trajectory's t = 0 position and the yaw faces the direction of travel, exactly as playback shows the first frame, so the scattering lobes and the Doppler describe the same motion. An explicit velocity_m_s keeps the authored pose. Other actors with a trajectory keep their authored pose in the solve but move at their t = 0 velocity, so legs that reflect off them carry their Doppler. Plain paths solves follow the same rule (§4).

3. Running a sensing solve

Place at least one TX and one RX. For monostatic sensing put an RX at the TX position; any other RX gives a bistatic geometry. Then choose Actions ▾ → Sensing solve in Results mode. The solve covers every actor whose binding is enabled and persists as a sensing result set (it appears in the run history like any other solve).

The same solve over the API:

curl -X POST http://127.0.0.1:8000/api/projects/sample_demo/simulate/sensing \
  -H "Content-Type: application/json" \
  -d '{"config": {"backend": "auto", "frequency_hz": 28e9}, "include_comm_paths": false}'
curl http://127.0.0.1:8000/api/projects/sample_demo/results/sensing   # latest stored result
Request fieldMeaning
config_id / configSolver configuration, exactly as for /simulate/paths.
tx_ids / rx_idsOverride the config's device selection.
target_actor_idsRestrict the solve to these actors (default: every enabled binding).
max_depthRCS depth. It counts the scattering event itself, so 1 = direct legs only and each leg can bounce at most max_depth − 1 times. Default max(1, config.max_depth).
samples_per_spRays launched per scattering point (default 1 000 000).
include_comm_pathsAlso run the normal paths solve and append its paths after the echoes (target_id null, ids path_…). In that solve each target stands in for its actor as an absorber cuboid (how Sionna's PathSolver treats sensing targets), so no comm path also reflects off the target's own mesh.

Backends. sionna runs RCSSolver (LoS, specular reflection and refraction legs). mock evaluates the bistatic radar equation over one scattering point at each target center (LoS legs, no occlusion), with each device's element gain toward the target and the per-leg polarization term of §8 (v0.1.14; earlier mock echoes were isotropic). auto picks sionna when sionna-rt ≥ 2.2 is installed and has a working Dr.Jit backend (a CUDA GPU, or LLVM on the CPU: see INSTALL), and otherwise falls back to the mock with a warning; an explicit "backend": "sionna" on an older sionna-rt answers 409 (sensing requires sionna-rt>=2.2). Requests with no bound actor, an unknown actor or device id, or no TX/RX answer 400.

Antennas. Sionna applies the first selected TX's (RX's) antenna to every TX (RX) and warns when they differ; the mock applies each device's own element pattern and orientation (and the polarization term of §8).

4. Reading Doppler

Each echo path carries doppler_hz, positive when the path is closing (target approaching). For a single scattering point Sionna computes

f_D = (v_tx · k̂₁ − v_rx · k̂₂ + v_t · (k̂₂ − k̂₁)) / λ

with k̂₁ the unit vector TX → target and k̂₂ target → RX; a target receding from a monostatic radar at v gives −2v/λ (−1868 Hz at 10 m/s, 28 GHz).

In the viewport, tick Show: Sensing in the Results panel: echo paths are colored by Doppler (red approaching, gray static, blue receding) with a legend, and every scattering point gets a marker. The sensing card lists each target (model, scattering points, σ, path count) and the strongest echoes (power, delay, Doppler).

Comm paths. On the Sionna backend, paths solves give actors a velocity too, so a communication path that reflects off a moving actor is Doppler-shifted even when the TX and RX are static. Each bounce off an actor moving at v adds v · (k̂_out − k̂_in) / λ, with k̂_in the unit vector into the bounce and k̂_out the one leaving it. For example, a car 20 m out, driving away at 10 m/s from a static TX/RX pair 8 m apart, gives about −228 Hz at 3.5 GHz on its face reflection and 0 Hz on the LoS and ground paths. Which velocity an actor gets:

A device attached to a moving actor (attached_device_ids) moves with it at the actor's velocity, in every solve above and in a sensing solve. Outside Simulate scenario, a device's own velocity_m_s takes precedence. A UE riding a car is therefore Doppler-shifted on every path, the LoS included.

A plain solve reports this Doppler in metadata.doppler_hz, a list aligned with paths. The list is present when the TX or RX has a velocity or any actor in the scene moves at t = 0, even if no path touches that actor (the paths then read 0 Hz); an all-static solve has no Doppler field, as before. The comm paths of a sensing result also carry it per path, in doppler_hz. The mock backend has no Doppler on paths solves, and alternate engines (subprocess worker) apply no velocities.

5. Export

6. Sensing over time: detection and multistatic fusion

A sensing solve is one snapshot. Simulate scenario with sensing on runs a sensing solve in every frame, at the frame's actor poses and velocities (and rider positions), turns each echo into a per-link detection, and fuses the detected links into a position/velocity estimate that is scored against the true actor track.

Running it. In Results mode open Scenario playback, tick Sensing (ISAC) under Include paths, set the threshold, CPI and integrated pulses (the form defaults to 4096 since v0.1.15), pick the Sensing receivers (default Auto, below), and run. The checkbox stays disabled until at least one actor has an enabled sensing binding. The form solves with the Simulation panel's config, which starts from the project's first stored config (default on the Sample Demo), so for the demo run below first pick the stored config Sensing demo (3.5 GHz) in that panel's Preset dropdown: the run then uses sensing_fr1 as is, and the result's simulation_config_id is sensing_fr1, as with the API call below. Over the API, add a sensing block to the scenario request:

curl -X POST http://127.0.0.1:8000/api/projects/sample_demo/simulate/scenario \
  -H "Content-Type: application/json" \
  -d '{"config_id": "sensing_fr1", "num_frames": 19, "dt_s": 0.5, "include_paths": false,
       "sensing": {"enabled": true, "threshold_db": 13, "cpi_s": 0.01,
                   "cpi_pulses": 4096, "measurement_noise": true,
                   "tracking": {"enabled": true}}}'

A Sample Demo created before v0.1.15 has one site and no sensing_fr1 (this request answers 404 and the Preset dropdown lists no stored configs): create a fresh demo as in the introduction, or, after adding the two sites yourself, replace "config_id": "sensing_fr1" with the inline "config": {"frequency_hz": 3.5e9, "bandwidth_hz": 2e7, "noise_figure_db": 7, "max_depth": 2}.

On the Sample Demo that covers the drone's 9 s flight. The demo has three TRPs, each a 30 dBm iso TX with a co-located sensing RX: tx_001 on the roof of building b01 at (−9, 7, 10.5) m, and tx_002 at (30, −30, 10) m and tx_003 at (35, 35, 10) m on street masts. Auto makes their three RXs the radar receivers, so every frame has 9 links (3 monostatic, 6 bistatic) over 6 distinct geometries. The example runs the demo's second stored config, sensing_fr1 (3.5 GHz, 20 MHz, NF 7 dB, max depth 2): at 28 GHz / 100 MHz a single element's drone echo stays under the 13 dB threshold even with 4096 pulses (+36 dB), because λ² costs 18 dB (FR1 vs mmWave below) and the 5× bandwidth adds 7 dB of noise. At 3.5 GHz every link of the flight is at 15–33 dB, and every link has a direct echo in every frame, with or without RF materials on the buildings and the tree. Measured (noise seed 0):

metadata.sensing.targets.uav_001mocksionna (sionna-rt 2.2.0, CUDA)
detection_rate0.947 (18 / 19 frames)0.947 (18 / 19 frames)
link_detection_rate0.4390.439
frames_ge3_links1515
ok_frames (fusion)1010
median_position_error_m (fusion)0.43 m0.79 m
median_gdop1.621.62
tracked_frames / lost_frames19 / 019 / 0
median_track_position_error_m (EKF)0.36 m0.38 m
p90_track_position_error_m2.89 m2.98 m
median_track_velocity_error_m_s0.66 m/s0.66 m/s

Every missed link is mti_rejected: at 3.5 GHz the 100 Hz notch blinds a monostatic link below 4.28 m/s of radial speed, a large share of the drone's 10 m/s. At t = 9 s the drone hovers and all 9 links are rejected (the one frame without a detection). From t = 1.5 to 3 s only links between TX 2 and TX 3 survive (at t = 5 s, between TX 1 and TX 3): two sites, which the fusion reports diverged (see Fusion). At t = 4–4.5 s only TX 1's monostatic link does. The EKF carries the track through those frames (frames_track_without_fusion = 9). It starts at t = 0 from a 5-link fusion 7.6 m off (GDOP 2.8: the drone starts outside the triangle of the three sites), is 1.3–2.5 m off from t = 1.5 to 3 s and 0.4 m from t = 3.5 s, restarts at the corner (t = 5.5 s, init), and stays within 0.1–0.4 m on the second leg.

The other examples in this guide keep "config_id": "default" (28 GHz). With 4096 pulses its best drone link peaks at 7.7 dB, so nothing is detected, but it does detect the drone when it integrates the whole 10 ms CPI, B · cpi_s = 10⁶ samples (+60 dB): the request above with "config_id": "default" and "cpi_pulses": 1000000 detects it in 18 of 19 frames on Sionna, with 18 ok fusions, a 0.11 m median fusion error and a 0.08 m median track error (the 100 MHz range cell is 1.5 m, not 15 m). 10⁶ is the most a 10 ms CPI can integrate at that bandwidth.

The result is an ordinary scenario result. Each frame gains sensing: {echoes, links, estimates} and metadata.sensing holds the run summary. With sensing absent or enabled: false the result is the same as before (each frame's sensing is null). Comm and sensing share one backend: auto resolves as for any scenario, and a backend without sensing (sionna-rt < 2.2) answers 409 instead of falling back to the mock. No TX or no RX answers 400, and so does no bound actor, an unknown target_actor_ids entry or a sensing_rx_ids entry that is not a selected RX. The run holds the per-project solve lock and reports progress per frame, and Cancel works.

A scenario with sensing always stores every frame's echoes (whatever include_paths says): about 45 KB per frame on sionna with 16 links (≈ 2.6 MB for 61 frames, ≈ 45 MB per 1 000 frames), which the browser also loads. (Results are stored as compact JSON since v0.1.14; the pretty-printed files of earlier versions were about twice that size.)

sensing fieldDefaultMeaning
enabledfalseRun the per-frame sensing solve.
threshold_db13Detection threshold on the post-integration SNR (dB, −30…60).
cpi_s0.01Coherent processing interval (s). Doppler resolution = 1 / CPI.
cpi_pulses1 (the form: 4096)Coherently integrated pulses or OFDM resource elements. Gain = 10·log10(N).
mti_min_doppler_hznullMTI notch: |f_D − f_nodes| below it is rejected (f_nodes = 0 with static TX/RX; see MTI below). null = 1 / CPI (one Doppler cell); 0 disables MTI.
include_comm_pathsfalseAlso solve each frame's comm paths with the targets as absorbers and append them to echoes (target_id null). One extra solve per frame.
target_actor_idsnullRestrict to these actors (default: every enabled binding).
sensing_rx_idsnullBistatic radar receivers. null = auto: the selected RX within 1 m of a selected TX (as §7/§8), else every selected RX (v0.1.13). Comm links still cover every RX. Unknown id: 400.
samples_per_sp / max_depth1 000 000 / nullPassed to each frame's sensing solve, as in §3.
measurement_noisefalseAdd Gaussian noise to the range and Doppler fed to the fusion (below).
noise_seed0Seed of that noise. Same seed = same numbers.
trackingnullEKF tracking over the frames (see Tracking (EKF) below): {"enabled": true} plus optional process_accel_sigma_m_s2 (2), gate_chi2 (16), coast_max_frames (10), max_position_std_m (25), use_as_prior (true). null or enabled: false = no tracking.
pfanullFalse-alarm probability of a per-link pd (§9). null = the link reports carry no Pd.
detectorSwerling 1, no Monte CarloModel of that pd, and monte_carlo_trials for a Monte Carlo pd_mc per link with an echo (§9; needs pfa). Detection itself stays SNR ≥ threshold_db.

Sensing receivers. Only the sensing receivers form radar links; every selected RX keeps its comm link in the same frames. In the form, Auto (the default) sends nothing; picking devices from the list sends sensing_rx_ids. Auto is the §7/§8 rule: the selected RXs within 1 m of a selected TX (monostatic/TRP receivers) when there is at least one, else every selected RX. The resolved list and the rule (explicit, colocated or all_rx) are stored in metadata.sensing.sensing_rx_ids and sensing_rx_rule. v0.1.14: a project with co-located RXs now uses only those as radar receivers by default; before, every selected RX (UEs included) was a bistatic radar receiver. A project without co-located RXs runs exactly as in v0.1.13.

Detection. For each TX → sensing RX link and target, the strongest direct echo (TX → scattering point → RX, no other bounce) is reported. If there is none, the strongest echo of any kind is reported and flagged multipath. Then

SNR = P_echo − (−174 + 10·log10(B) + NF) + 10·log10(cpi_pulses) [dB]

with B = config.bandwidth_hz and NF = config.noise_figure_db. cpi_pulses counts the samples integrated coherently over the CPI: radar pulses, or OFDM resource elements (subcarriers × symbols). The full CPI caps it at B · cpi_s (20 MHz × 10 ms = 2·10⁵, +53 dB). A link is detected when SNR ≥ threshold_db and |f_D − f_nodes| ≥ mti_min_doppler_hz (below).

MTI. The notch defaults to one Doppler cell, 1 / CPI (100 Hz at 10 ms). That rejects everything slower than the monostatic blind speed λ · f_min / 2: 4.28 m/s at 3.5 GHz and 0.54 m/s at 28 GHz (10 ms CPI). With static TX and RX, static scene returns sit at 0 Hz, so MTI is what separates the drone from the buildings. A TX or RX riding a moving actor shifts the static scene too, so the notch is centred on that shift: f_nodes = (v_tx · k̂_dep − v_rx · k̂_arr) / λ, the Doppler a static scatterer on the echo's own path would have (k̂_dep = TX → first bounce, k̂_arr = last bounce → RX; 0 for static nodes). A hovering drone seen from a moving radar is therefore rejected, and one flying alongside it is detected. The reported doppler_hz stays the raw f_D. Comm paths and clutter are never target echoes, so they never count as a detection either way. Set mti_min_doppler_hz: 0 to switch MTI off.

Fusion. Per target and frame, the detected direct echoes are sorted by SNR, and links between the same two sites, in either order (a reciprocal pair, or a repeated link), are kept once, the strongest. A site is a cluster of TX/RX positions chained within 1 m, the co-location rule §7 and §8 use too, so a sensing RX up to 1 m from its TX is the same focus (v0.1.12 merged foci within 0.5 m). With at least 3 geometrically distinct links, the position p solves the bistatic range sums

|p − TX_i| + |p − RX_i| = R_i, R_i = c · τ_i

by Gauss–Newton. Each R_i is an ellipsoid with foci TX_i and RX_i (a sphere for a monostatic link). Rooftop TRPs are nearly coplanar, so the ellipsoids also meet at a mirror image across that plane. The solver therefore starts from the previous frame's estimate (advanced by its velocity) and from points above and below the TRPs; without a previous estimate it also starts above each link's midpoint. A tie goes to the candidate nearest the previous estimate. Without one it goes to the candidate nearest the ground (z = 0) for a car or human target, and to the highest one otherwise (a drone is assumed above the TRPs). With measurement_noise on, candidates whose cost is within Δχ² ≤ 9 of the best (½·Σr² within 4.5σ̄², σ̄² = the mean of σ² = ((c/B)/√(2·SNR))² over the links) also count as tied: with noisy ranges the mirror often fits slightly better by chance. With exactly 3 links every root fits exactly, so the cost cannot choose: without a previous estimate, two or more distinct roots not underground (z ≥ −2 m) make the estimate diverged (ambiguous). The velocity then follows from a linear least-squares fit of the measured Doppler with the convention of §4 (positive = closing):

λ · f_D,i + v_rx · k̂₂ − v_tx · k̂₁ = v_t · (k̂₂ − k̂₁)

Three links that touch only two nodes (TRP A monostatic, B monostatic and A→B) are degenerate. All three ellipsoids are symmetric about the A–B axis, so the solutions form a circle, and the estimate reports diverged.

Reading the output.

Velocity truth. velocity_true (and the Doppler the solver uses) is the trajectory tangent × speed. At an exact waypoint time it is the outgoing leg's velocity, at the end of a once trajectory it is 0 (the actor stops), and at a pingpong turnaround it is the reversed leg. Before v0.1.14 those frames reported the average of the two legs (7.07 m/s at a 90° turn at 10 m/s) or half the speed, so Doppler and velocity_true at waypoint frames changed in v0.1.14.

Without measurement_noise the measured values are the solver's exact delay and Doppler, so an ok estimate is exact to rounding (useful as a sanity check). The one exception is an exact mirror tie: when all nodes of the fused links lie in one plane (always so for three nodes), the mirror across that plane fits exactly too, and the tie rule above picks the side. With measurement_noise on, the range and Doppler get noise of σ = cell / sqrt(2 · SNR), with cell = c/B for range and 1/CPI for Doppler. The noise is seeded per frame, link and target. Detection decisions always use the exact values.

FR1 vs mmWave. The echo SNR is

SNR = P_t G_t G_r λ² σ N_int / ((4π)³ R_t² R_r² · kTB · NF)

so at equal antennas, power and bandwidth the λ² term costs 28 GHz 20·log10(8) = 18.06 dB against 3.5 GHz. Monostatic, P_t = 43 dBm, isotropic antennas, uav-small-size (σ = −12.81 dBsm), B = 20 MHz, NF 7 dB, cpi_pulses 4096 (+36.12 dB):

fλ (m)P_r at 200 mSNR at 200 mRange at 13 dBMTI blind speed (10 ms)
3.5 GHz0.0857−116.17 dBm13.94 dB (detected)211 m4.28 m/s
28 GHz0.0107−134.23 dBm−4.12 dB (missed)74.6 m0.54 m/s

The monostatic range therefore shrinks by 10^(−18.06/40) = ×0.354 at 28 GHz. Antenna arrays buy that back. The per-frame detection here still counts single elements (see §11 Limits), but §7 synthesizes real codebook beams and §8's steered mode adds the ideal array gain. The mmWave notch, on the other hand, is 8× narrower in speed.

Tracking (EKF)

Fusion needs three distinct links in one frame. With "tracking": {"enabled": true} in the sensing block (or Tracking (EKF) in the form), a constant-velocity extended Kalman filter per target carries the estimate from frame to frame. A frame with only one or two detected links still refines it, and a frame with none still predicts.

Each estimates[] row then carries:

FieldMeaning
track_statusnone (no track yet), init (started or restarted from this frame's fusion), tracking (≥ 1 scalar accepted), coasting (none accepted), lost (dropped; until the next start)
track_position, track_velocityPosterior state (null for none and lost)
track_position_error_m, track_velocity_error_m_sAgainst position_true and velocity_true
track_position_std_msqrt(trace(P_pos)): the 3-D RMS error the filter expects, comparable to track_position_error_m
track_updates, track_gatedScalars accepted and gated in this frame (0 and 0 on init; still counted on the frame the track is lost, null after it)

With tracking off all of them are null. Per target, metadata.sensing adds tracked_frames (init, tracking or coasting), coasting_frames, lost_frames, lost_by_std_frames (drops by the max_position_std_m cap; a frame whose own fusion restarts the track at once shows init, not lost), the median and p90 track position error, the median track velocity error, frames_improved_over_fusion (fusion ok and the track closer to the truth) and frames_track_without_fusion (a track in a frame whose fusion is not ok). The run adds median_track_position_error_m and tracking_model.

Waveform numerology (#23)

Without a waveform, cpi_pulses is a free number and the integration gain is 10log10(cpi_pulses) whatever cpi_s is (v0.1.15, still the default). sensing.waveform replaces it with an OFDM numerology: the pilot resource elements (REs) of one CPI set the gain, and the occupied bandwidth sets the noise floor and the resolutions. The same object is accepted by the ISAC request (waveform), the coverage request and /analysis/pd-curve (waveform plus cpi_s, default 0.01 s) and channel analysis (waveform: the RSRP/RSRQ grid then has n_active_sc subcarriers at scs_hz, e.g. 51 PRB / 612 SC in 20 MHz at 30 kHz instead of int(B / (12 scs)) = 55 RB).

FieldDefaultMeaning
scs_hz30e3Subcarrier spacing Δf [Hz]
n_fftrequiredFFT size (≥ n_active_sc)
n_active_sc612Active subcarriers; B_occ = n_active_sc · Δf
cpnormalnormal or extended
symbol_duration_snullAverage symbol duration incl. CP; null = NR slot timing (Δf = 15 · 2^μ kHz: (1 ms / 2^μ) / 14, or / 12 with the extended CP), else (1/Δf)(1 + 144/2048 or 512/2048)
symbols_per_cpinullnull = floor(cpi_s / T_sym)
pilot.patterncombcomb or block (block needs subcarrier_step 1)
pilot.subcarrier_step, pilot.symbol_step1, 1Pilots on every n-th active subcarrier of every m-th symbol, from index 0

The pilot lattice and everything derived from it:

K = ceil(n_active_sc / subcarrier_step)      N = ceil(symbols / symbol_step)
cpi_pulses = K N        integration gain = 10 log10(K N)
B_occ = n_active_sc Δf  noise floor = -174 dBm/Hz + 10 log10(B_occ) + NF
range-sum cell c / B_occ (monostatic range c / 2 B_occ)
delay bin 1 / (K · subcarrier_step Δf)     Doppler cell 1 / (N · symbol_step T_sym)
pilot axes  f_k = (k · subcarrier_step - n_active_sc // 2) Δf,   t_j = j · symbol_step T_sym

Amplitude and noise convention (shared by every Track B feature): a path's channel coefficient is dimensionless, a = 10^(path_gain_db / 20) e^(j phase_rad) (phase_rad already holds −2π f_c τ); every active subcarrier carries Es = P_tx / n_active_sc per RE (CP excluded) and the noise per RE is N0 = −174 dBm/Hz + 10log10(Δf) + NF. A path's per-RE SNR |a|² Es / N0 is then P_echo − N_floor(B_occ), and snr_db = P_echo − N_floor(B_occ) + 10log10(K N) is the matched-filter SNR of the K N pilot REs.

Preset, cpi_sT_symSymbolsK × NGainB_occc / B_occDoppler cellFloor (NF 7 dB)
NR 30 kHz 51 PRB, 10 ms0.5 ms / 14280612 × 280 = 171,36052.34 dB18.36 MHz16.33 m100 Hz−94.36 dBm
NR 30 kHz 51 PRB, 50 ms1400612 × 1400 = 856,80059.33 dB20 Hz
Grid S, 10 ms1/14400 s144256 × 16 = 409636.12 dB20.0 MHz14.99 m100 Hz−93.99 dBm
Grid S, 50 ms720256 × 80 = 20,48043.11 dB20 Hz

NR 30 kHz 51 PRB is {"scs_hz": 30000, "n_fft": 1024, "n_active_sc": 612}; Grid S (the 4096-pulse default of the v0.1.15 demos, read as a pilot grid) is {"scs_hz": 15625, "n_fft": 2048, "n_active_sc": 1280, "symbol_duration_s": 0.0000694444444444, "pilot": {"subcarrier_step": 5, "symbol_step": 9}} (pass 1/14400 exactly from code). At the same pilot density a 50 ms CPI adds 10log10(5) = 6.99 dB over 10 ms; Grid S at 10 ms reproduces the v0.1.15 numbers (4096 pulses on the 20 MHz floor).

With a waveform, metadata.sensing (and the ISAC, coverage and pd-curve metadata) gains cpi_pulses (= K N), b_occ_hz, n_re_per_cpi, symbols_per_cpi, symbol_duration_s, pilot_fraction, grid_k, grid_n, n0_dbm_per_re and the full numerology dict, and noise_floor_dbm, integration_gain_db, the resolutions and velocity_resolution_m_s follow it. bandwidth_hz, cpi_s and the MTI keys keep their v0.1.15 meaning. Two warnings keep old requests honest: cpi_pulses set together with a cpi_s other than 0.01 s and no waveform ("integration gain does not follow cpi_s without sensing.waveform"), and a cpi_pulses that differs from the waveform's K N ("cpi_pulses 4096 overridden by the waveform numerology: 171360 pilot REs per CPI (612 x 280)"); the stored request keeps the number you sent. A cpi_s shorter than one symbol, symbols_per_cpi that do not fit the CPI, or more than 10⁸ pilot REs per CPI answer 400 before any solve on every route that takes a waveform: scenario, ISAC, sensing coverage and /analysis/pd-curve. The coverage request warns "cpi_s is unused without waveform" when it gets a cpi_s and no waveform. Channel analysis has no cpi_s: its metadata.numerology covers symbols_per_cpi symbols when that is set, else 10 ms (at least one symbol), and only the subcarrier count and spacing enter RSRP/RSRQ, so any waveform the other routes accept is accepted there.

"sensing": {"enabled": true, "cpi_s": 0.05, "threshold_db": 13,
            "waveform": {"scs_hz": 30000, "n_fft": 1024, "n_active_sc": 612}}

Background paths H0/H1 (#21)

include_comm_paths appends the targets-as-absorbers comm paths to echoes: the background with the target present (H1). A false-alarm calibration needs the background with the target absent (H0), solved in the same frame, and the RCS solver traces no diffuse clutter. sensing.background solves both with PathSolver over the TX → sensing-RX links of every frame:

FieldDefaultMeaning
modeoffoff, h1_absorber, h0_removed or both
diffuse_reflectiontrueDiffuse clutter (the materials' scattering coefficients)
diffraction, edge_diffractionfalse
max_depth, num_samplesnullnull = the config's
deterministictruePathSolver(deterministic=True): two runs give the same paths
max_paths_per_link256Keep the strongest n paths per link and solve; null keeps every path. dropped_paths counts the rest, and one run warning sums them
cachestatic_nodesstatic_nodes: solve once while the key repeats; per_frame: every frame
storeh0Hypotheses kept in the result: h0 (H1 feeds the CFAR map in memory only), both, or none (maps only)

LoS, specular reflection and refraction follow the config. Each frame stores background_h0 / background_h1 (lists of RayPath: power_dbm, path_gain_db, phase_rad, delay_ns, doppler_hz always a float, interactions with type, prim_id and rf_material_id, target_id null) and background_meta (mode, mechanisms, max_depth, num_samples, seed, deterministic, and per hypothesis solved, cache_hit, source_frame, cache_key, solve_s, num_paths, dropped_paths, total_power_db = 10log10 Σ|a|² per "tx>rx" of the kept paths, stored and, after a failed solve, error). The paths come in a canonical order (link, then delay, interactions and vertices; renumbered path_0001…): the GPU solver returns the same set in a run-dependent order.

store decides which solved hypotheses the result keeps. With the default h0, H1 is solved whenever the mode needs it and feeds the CFAR map, but background_h1 stays out of the frame (background_meta.h1.stored: false); both keeps H1 too (needed to export background_h1 or total channels, §10) and none keeps neither. The map is the same in all three. max_paths_per_link keeps the strongest paths of each link (by power): the sionna backend drops the rest before it turns solver paths into rows, and the run ends with one warning "background: max_paths_per_link 256 kept the strongest 256 paths per link: … of … solved background paths dropped over the run". The dropped paths are the weak diffuse tail, so total_power_db (of the kept paths) changes little; set null to keep every path.

The static_nodes key hashes the RF projection fingerprint, the solver options, every TX and sensing RX position and velocity, every non-target actor's pose and velocity, and for H1 the target cuboids (H0 only needs which actors are removed). A frame with the key of an earlier frame stores no paths: background_meta.h0.source_frame names the frame that holds them (services.sensing_background.resolve_background(frames, i, "h0") follows it, and returns null for a hypothesis that was not stored). With static nodes and a static scene H0 is solved once per run; a moving TX/RX or car re-solves every frame. A run keeps the last 4 solved keys per hypothesis in memory. A failed solve (the sionna backend degrades to no paths and a warning) is never cached: that frame's meta carries error, its map falls back to H0 (or to no background), and the next frame solves again. metadata.sensing.background sums the run up (store, max_paths_per_link, solves_h0, cache_hits_h0, failed_solves_h0, solve_s_total, paths_stored, paths_dropped, …) and metadata.sensing.sensing_tx_ids lists the TXs.

With a background the run may have no target at all (no bound actor): links and estimates are empty and the frames carry the background only (a background-only H0 run for calibration). Without one, a scene with no target still answers 400 "no sensing targets". The other frame fields are unchanged by the background (echoes, links, estimates, nodes).

Diffuse clutter is many paths: the test scene of a 300 m baseline over a ground slab and one building gives 16,776 paths per link at 400,000 samples (about 10 MB of JSON per solved hypothesis), which is why the default keeps 256 per link. The mock backend's background is its LoS, ground and wall bounce per link (H1 drops the paths whose legs cross a target cuboid), with the Doppler of the TX/RX motion.

A moving target changes the H1 key, so H1 is solved again every frame even when every node is static. On the isac_drone_fr1 demo (16 links, config max_depth 2, 200,000 samples) one background solve gives 64,356 paths (about 4,000 per link). Measured over 8 frames (NR 612 × 280, mode both, CFAR): with every path kept and H1 stored, 11.1 s per frame (H1 3.9 s per solve) and a 403 MB result; with the defaults (256 per link, H1 not stored), 1.2 s per frame (H1 0.28 s per solve, 4,025 paths) and a 4.6 MB result, with the same CFAR maps whatever store is. Before the cap moved ahead of the row conversion, a capped H1 solve still took about 2.6 s. h0_removed with static nodes solves once per run (0.6 s per frame with CFAR on the same demo).

"sensing": {"enabled": true, "waveform": {"scs_hz": 30000, "n_fft": 1024, "n_active_sc": 612},
            "background": {"mode": "both", "store": "both", "max_paths_per_link": 2000}}

7. ISAC trade-off: beams and slot sharing

One TRP panel cannot point at its UEs and at a drone at the same time. The ISAC trade-off answers three questions per TX: which codebook beam serves its UEs best (the comm beam), which one sees the targets best (the sensing beam), and what sharing slots between the two costs in rate and buys in detection probability.

Running it. In Results mode open the ISAC trade-off panel, pick the TXs (none ticked = all), the array size, the sweep, CPI pulses, P_fa, the slot ratios and the sharing mode, and run. Over the API:

curl -X POST http://127.0.0.1:8000/api/projects/sample_demo/simulate/isac \
  -H "Content-Type: application/json" \
  -d '{"config_id": "default", "tx_rows": 4, "tx_cols": 4,
       "sweep_start_deg": -60, "sweep_stop_deg": 60, "sweep_step_deg": 5,
       "cpi_pulses": 4096, "pfa": 1e-6, "sharing_mode": "dual_function"}'
curl http://127.0.0.1:8000/api/projects/sample_demo/results/isac   # latest stored result

The result persists as an isac result set (run history, prune, label). Like /simulate/sensing, auto uses sionna when sionna-rt ≥ 2.2 is installed and otherwise the mock with a warning, and an explicit sionna without the RCS solver answers 409. An unknown device or actor, no UE, a TX without a sensing receiver, or use_device_orientation: false answers 400 before anything is solved.

On the Sample Demo with "config_id": "sensing_fr1" (Sionna, drone at its t = 0 position), the default azimuth-only 4×4 codebook serves rx_001 from TX 2 (comm beam 55°) and sees the drone with TX 1 (sensing beam −35°, 31.9 dB, P_d 0.99) and TX 3 (−40°, 23.4 dB, P_d 0.94), but not with TX 2: from its 10 m mast the drone is 28° up, in the vertical null of an elevation-0 beam of a 4-row panel (−1.0 dB). Adding the elevation sweep "elevation_start_deg": -10, "elevation_stop_deg": 60, "elevation_step_deg": 5 gives every TRP a detecting sensing beam (51.5, 44.9 and 39.4 dB at 40°, 30° and 20° elevation), and TX 1 then serves the UE (−20° azimuth, −10° elevation).

Request fieldDefaultMeaning
tx_idsnullTXs to evaluate (default: every TX).
ue_rx_ids / sensing_rx_idsnullRole split; see Roles below.
target_actor_idsnullTargets (default: every enabled sensing binding).
ue_associationservingserving: each UE belongs to the TX with the strongest best-beam RSS. all: every TX serves every UE (single-cell view per TX).
tx_rows × tx_cols4 × 4TX panel. Element spacing comes from the device antenna.
rx_rows × rx_colsnullSensing-RX panel (default: the TX size). UEs are always single elements.
use_device_orientationtruePanels keep their orientation_deg. false is rejected: look_at cannot aim one panel at both its UEs and its targets.
sweep_start_deg / sweep_stop_deg / sweep_step_deg−60 / 60 / 5Azimuth codebook in the panel's local frame, exactly as /simulate/beamforming's sweep (at most 361 beams, and at most 4096 beams × nonzero slot ratios per TX).
cpi_pulses4096Coherently integrated pulses / OFDM resource elements at ρ = 1.
cpi_s0.01Metadata only (resolutions).
threshold_db13Per-beam detected flag.
pfa1e-6False-alarm probability of the Pd model.
pd_target0.9Operating point of the Pareto summary.
slot_ratios0, .05, .1, .2, .3, .5, .7, 1Fractions ρ of the slots (pulses) spent sensing; sorted and deduped.
sharing_modedual_functiontime_sharing or dual_function (below).
samples_per_sp / max_depth1 000 000 / nullPassed to the echo solve, as in §3.
include_pathsfalseStore the solve's paths in the result: echoes first, then the comm paths.
elevation_start_deg / elevation_stop_deg / elevation_step_degnullElevation sweep of a 2-D codebook in the panel's local frame (all three or none; at most 361 elevations and 4096 beams in all). null = the azimuth-only codebook. See Elevation codebook.
interferencefalseInter-TX interference in the UE SINR (needs ue_association: serving). See Interference.
detectorSwerling 1, no Monte CarloP_d model, Monte Carlo trials, empirical threshold and seed (§9).

Roles. With ue_rx_ids and sensing_rx_ids both unset, an RX within 1 m of a selected TX is that TX's sensing receiver (monostatic). Every RX that is not within 1 m of any TX of the scene is a UE, so evaluating a subset of TRPs never turns the other TRPs' radar receivers into served users. Each TX takes the nearest RX of the sensing pool, so several TXs may share one (bistatic) when you pass sensing_rx_ids. ue_rx_ids overrides the UE rule. An RX cannot be both a UE and a sensing receiver.

Model. One t = 0 solve pair: the echo solve (TX → target → sensing RX, §3) and the comm solve (TX → UE with the targets as absorbers, as include_comm_paths). Both run with every antenna forced to a single element, so each stored path is referenced to the panel center. The beams are then synthesized from the paths' world-frame departure/arrival angles in Sionna's synthetic-array convention. Element n of a panel with orientation o responds to a wave along world direction k̂ with

a_n = exp(+j 2π p_n · R(o)ᵀ k̂),

where p_n is the PlanarArray element position in wavelengths. Beam w scores w^H a on both ends: the conjugate-weight matched filter of /simulate/beamforming. On sionna the synthesized TX codebook reproduces /simulate/beamforming (codebook_sweep, use_device_orientation: true) beam for beam: the regression tests see the same best beam, and every beam within 20 dB of the peak agrees within 0.1 dB (measured: under 0.01 dB). Per TX t, codebook beam k and UE u:

Elevation codebook. With the three elevation_* fields set, the codebook is every azimuth of the sweep at every elevation, elevation outer: beam k = i_el · n_az + i_az. Beam (φ, θ) steers toward the panel-local direction [cos θ cos φ, cos θ sin φ, sin θ]:

w_n(φ, θ) = exp(+j 2π (y_n cos θ sin φ + z_n sin θ)) / sqrt(N).

On the planar grid this is kron(w_z(θ), w_y(φ, θ)). It has unit norm and the full 10·log10(N) toward its own direction, and at θ = 0 it is the azimuth beam above. The sensing RX uses the same grid, and its best beam is taken over all of it. A drone at local elevation 32.6° on a 4×4 panel shows what this buys: the best azimuth-only beam gives −9.84 dB per end, the −10…40° / 5° grid 11.98 dB, so about 22 dB comes back per end. On sionna, the elevation beams of a 4×1 vertical ULA (TX side and RX side) match the same steering vectors applied to Sionna's own synthetic-array channel. In the regression tests the best beam agrees and sits on the LoS elevation, and every beam within 20 dB of the peak agrees within 0.1 dB (measured: under 0.005 dB). Without the fields the azimuth-only code runs unchanged.

Interference. With interference: true (only with ue_association: serving), a UE served by TX t hears every other selected TX that has a comm path to it. All TXs share ρ and the slot timing (synchronized slots). In comm slots each other TX radiates its comm beam c (none if it serves nobody); in sensing slots it radiates its sensing beam s (none without an echo). With RSS_t2,u(k) = P_t2 + 20·log10|h_k,u| of TX t2, in mW:

I_comm(u) = Σ_t2 10^(RSS_t2,u(c_t2)/10), I_sens(u) = Σ_t2 10^(RSS_t2,u(s_t2)/10)

SINR_k,u = RSS_t,u(k) − 10·log10(10^(N0/10) + I_comm(u)).

Each TX's comm beam depends on the others' comm beams, so the choice is iterated. Round 0 is the interference-free choice. Each later round lets every TX in turn pick the beam with the best interference-aware sum rate given the others' current beams. It stops when a round changes nothing, or after 10 rounds with a warning (the last round's beams are reported). Sensing beams do not depend on interference, and echo SNRs stay noise-limited. With one selected TX nothing changes: SINR = SNR exactly.

Pd. A Swerling-1 target with a square-law detector on the integrated sample has P_fa = e^(−T) and P_d = e^(−T / (1 + SNR)), so

P_d = P_fa^(1 / (1 + SNR)).

At P_fa = 10⁻⁶, P_d = 0.9 needs 21.1 dB, and the 13 dB threshold gives P_d = 0.517. No echo gives P_d = P_fa. Swerling 1 is the default detector.model; §9 has Swerling 0 and 3 and the Monte Carlo. With detector.monte_carlo_trials > 0 every beam with an echo and every Pareto point with ρ > 0 also get pd_mc. A run may spend at most 2·10⁸ trials, counted as TXs × beams × (1 + nonzero slot ratios) × trials (that is 250 000 trials for the default request with 4 TXs). A larger request answers 400 before anything is solved.

Slot sharing. The comm beam k_c = argmax R(k). A fraction ρ of the slots (or pulses) senses with beam k, so the echo integrates ρ · cpi_pulses.

ρ = 0 is one comm-only point (beam_idx null, P_d = P_fa). With dual_function the ρ = 1 rows equal the beams themselves (one beam does both). With time_sharing they carry no rate. With interference each slot sees the other TXs' beams of that slot, with S the interference-free signal power:

Under these synchronized slots the rate can rise with ρ: when the other TXs' sensing beams reach a UE more weakly than their comm beams (I_sens < I_comm), a dual_function point beats the comm-only rate, and its loss against comm-only comes out negative. That is the model, not a bug.

Reading the output. Each txs[] entry holds:

The top level adds ue_serving_tx, the noise_floor_dbm and, in metadata, the detection constants of §6 plus snr_for_pd_target_db and pd_at_threshold (both under detector.model). A TX that serves no UE gets a sensing-only trade-off (all rates 0) and says so in its warnings. Keys appear only with their option: codebook_shape ([n_el, n_az]) with an elevation sweep; interference, ue_interferers (per UE, the other TXs that reach it), interference_rounds, interference_converged and interference_model with interference; detector (model, threshold and the measured P_fa of the Monte Carlo) with a non-default detector. A request with every new field at its default stores the same metadata.request and request_hash as v0.1.12.

8. Sensing coverage map

Where would a drone at 60 m be seen, and by how many links? The coverage map puts a virtual point target in every cell of a horizontal grid and evaluates every TX × sensing-RX link (monostatic and bistatic). No RCS solve runs, so a whole map takes about a second.

Running it. In Results mode open the Sensing coverage panel, set the height, cell size, RCS (blank = TR 38.901 uav-small-size, −12.81 dBsm), threshold, pulses, P_fa and the array gain, and run. The metric selector switches the map between the four layers below. Over the API:

curl -X POST http://127.0.0.1:8000/api/projects/sample_demo/simulate/sensing-coverage \
  -H "Content-Type: application/json" \
  -d '{"config_id": "default", "height_m": 60, "cell_size_m": 10,
       "threshold_db": 13, "cpi_pulses": 4096, "array_gain": "none"}'
curl http://127.0.0.1:8000/api/projects/sample_demo/results/sensing-coverage

The result persists as a sensing_coverage result set. Sensing receivers follow the ISAC rule (an RX within 1 m of a selected TX, or sensing_rx_ids). A TX without one answers 400.

On the Sample Demo with "config_id": "sensing_fr1", height_m 40 and cell_size_m 5 (Sionna), the map has 441 cells and 9 links over 6 geometries: 100 % of the cells are LOS, detected and fusion-feasible, with a median best SNR of 29.3 dB. The map has no MTI, so the scenario of §6 still loses links to the Doppler notch along the actual flight.

Request fieldDefaultMeaning
tx_ids / sensing_rx_idsnullDevices (default: every TX and its co-located RX).
rcs_dbsm / object_typenullPoint-target σ: a value, or a TR 38.901 type's mean σ_M (not both). Both unset = uav-small-size.
height_m60Height of the grid plane (target center), within ±100 000 m.
cell_size_m10Cell size, at most 10 000 m. More than 40 000 cells coarsens it, with a warning.
center_xy / size_xynullExplicit extent (both or neither; center within ±10⁷ m, sides up to 10⁶ m). Default: the scene bounds (visual mesh, devices, actors) padded by min(15 m, max(3 m, 15 % of the larger side)), as for the sionna radio map.
threshold_db / cpi_pulses / pfa13 / 4096 / 1e-6Detection, as in §6 and §7.
array_gainnonesteered adds 10·log10(N_tx) + 10·log10(N_rx) of the tx_rows×tx_cols / rx_rows×rx_cols panels: every link beams at every cell, an upper bound.
min_links_for_fusion3Distinct geometries a cell needs to count as fusion-feasible.
detectorSwerling 1, no Monte CarloModel of pd_best (§9). monte_carlo_trials > 0 adds a Monte Carlo spot check of 5 cells.

Model. Per link and cell, with R_t = |cell − TX| and R_r = |cell − RX|:

SNR = P_t + G + G_e,t + G_e,r + L_pol + 10·log10(λ² σ / ((4π)³ R_t² R_r²)) − A(R_t + R_r) − N0 + 10·log10(cpi_pulses)

when both legs are line-of-sight, and no echo otherwise. G is the array gain above and A the atmospheric absorption (0 unless enabled in the config). G_e,t and G_e,r are the TX's and the RX's element gain toward the cell, from each device's antenna.pattern in its own frame (orientation_deg): 0 dB for iso, 8 dBi down to −22 dBi (a 30 dB floor) for tr38901. metadata.element_patterns lists them. L_pol is the polarization term below (v0.1.14).

Polarization. Each element radiates one linear field (the first port, as in Sionna's echo): along its local θ̂ for V and VH, along φ̂ for H, and θ̂ turned by −45° for cross. Pitch or roll tilts that field in the world. Sionna's RCS scattering is the identity in the world θ̂/φ̂ bases of the incident direction k_i (TX → target) and the scattered direction k_s (target → RX), so with p_t the TX element's world field toward the target and p_r the RX element's field toward the target:

F = (p_t · θ̂(k_i)) (p_r · θ̂(k_s)) + (p_t · φ̂(k_i)) (p_r · φ̂(k_s)), L_pol = 20·log10|F| (≤ 0 dB)

where θ̂(k) and φ̂(k) are the world spherical unit vectors of a direction. What follows from it:

Measured on the L2 regression site (TRP at (0, 0, 10) m, 3.5 GHz, a 10 dBsm constant target 30 m up, iso elements, V polarization; monostatic = RX at the TX, bistatic = RX at (0, 40, 10) m with the same orientation), the Sionna echo minus the v0.1.13 map over five LOS cells (four on the bistatic link):

LinkPitchEcho loss (dB)
monostatic0°0.000
monostatic−15°−0.35 … −1.51
monostatic+15°−0.42 … −1.52
monostatic−45°−3.99 … −29.03
bistatic−15°−0.12 … −1.88
bistatic−45°−1.20 … −18.69
monostatic, cross−15°−5.32 … −6.61

So a rooftop TRP tilted −13…−16° loses about 0.4–1.5 dB per monostatic link, which v0.1.13 maps and mock echoes did not show: they were optimistic by that much, and the Sionna echo was right. With L_pol the map matches RCSSolver echoes within 0.01 dB (measured ≤ 3·10⁻⁵ dB) for V, H, VH and cross elements at pitch 0, ±15° and −45°, monostatic and bistatic. When any link's loss is nonzero, metadata.polarization_model names the model.

Mock and LOS. The mock echo applies the same element gains and L_pol (v0.1.14), so a mock sensing solve with a target at a cell center still returns the map's value, for iso and tr38901 elements in any orientation. A constant target with xpr_db set gets no L_pol in the mock (its depolarization is not modeled). On sionna the LOS test is a Mitsuba shadow-ray test against the cached static scene with every actor mesh taken out (the map is about the site, not about where the real drone happens to be). In the regression tests a constant-RCS echo solved by RCSSolver at a LOS cell matches the map within 0.01 dB, with iso and with tr38901 elements (measured: about 10⁻⁵ dB), and cells in a building's shadow have no direct echo. The mock has no geometry occlusion: every leg counts as LOS, and the result says so in warnings and metadata.los_model.

Layers (values, row-major [ny][nx] like a radio map):

summary gives the cell, link and geometry counts, the percentage of cells with ≥ 1 LOS link, ≥ 1 detection and fusion feasibility, and the median best SNR over the cells with an echo. links[] gives each link's baseline and its LOS and detected fractions. With detector.monte_carlo_trials > 0, summary.mc_spot_check holds 5 distinct cells at the 0/25/50/75/100 % quantiles of best_snr_db over the cells with an echo (nearest rank; a value shared by several cells goes to the lowest cell index not yet taken; fewer cells when fewer have an echo). Each gives cell [ix, iy], snr_db, the analytic pd, pd_mc and its 95 % interval (ci_low, ci_high). metadata.detector appears with a non-default detector.

9. Detector models and Pd/Pfa Monte Carlo

Every P_d SEAM reports models a square-law detector on the coherently integrated sample, at the post-integration SNR (the link snr_db of §6, the beam SNRs of §7, best_snr_db of §8). The integrated noise sample is normalized to CN(0, 1), so noise alone gives y = |z|² ~ Exp(1), and the threshold T = −ln P_fa gives exactly P_fa. S = 10^(SNR/10) is the mean |A|² of the target amplitude A. A stays constant over the CPI and fluctuates from scan to scan as detector.model says:

modelTargetP_dSNR for P_d 0.9 / 0.5 at P_fa 10⁻⁶
swerling0SteadyQ₁(√(2S), √(2T)) (Marcum Q)13.18 / 11.24 dB
swerling1 (default)Rayleigh, |A|² ~ Exp(S)P_fa^(1/(1 + S))21.14 / 12.77 dB
swerling3One dominant scatterer, |A|² ~ Gamma(2, S/2)e^(−T/(1 + S/2)) · (1 + T·(S/2)/(1 + S/2)²)17.30 / 11.95 dB

A fluctuating target needs more SNR for a high P_d: 8 dB more for Swerling 1 than for a steady target at P_d 0.9.

Derivation. For a fixed |A|² = s, P(y > T) = P(X ≤ K) with K ~ Pois(s) and X ~ Pois(T) independent (the Poisson mixture of the noncentral chi-square). Swerling 0 sums that series in log space over the window S ± (40√S + 40); it matches scipy's ncx2.sf to about 10⁻¹³ from P_fa 0.1 down to 10⁻³⁰⁰. Mixing s over Gamma(2, θ) makes K negative binomial, P(K = k) = (k + 1) p² qᵏ with p = 1/(1 + θ) and q = θ/(1 + θ). Then Σ_{k≥x} (k + 1) p² qᵏ = qˣ((x + 1)p + q), and averaging over X gives e^(−Tp)(1 + Tpq): Swerling 3 with θ = S/2. Gamma(1, S) in the same steps gives e^(−T/(1 + S)), Swerling 1. Swerling 1 is evaluated with the v0.1.12 expression, so the default numbers do not move.

Where the model applies. ISAC (§7): every beam's and point's pd, plus snr_for_pd_target_db and pd_at_threshold. Coverage (§8): pd_best. Scenario sensing (§6): with sensing.pfa set, the link reports' pd. Detection there stays the snr_db ≥ threshold_db test.

Monte Carlo. detector.monte_carlo_trials > 0 adds a Monte Carlo estimate pd_mc next to the analytic pd. Each trial draws the target amplitude for the model (Swerling 0: √S·e^(jφ); Swerling 1: CN(0, S); Swerling 3: √Gamma(2, S/2)·e^(jφ); φ uniform) and the integrated sample z = A + CN(0, 1), and detects when |z|² exceeds the threshold. That is the law of the N = cpi_pulses pulses A/√N + n_i summed coherently and divided by √N (N iid CN(0, 1) summed and divided by √N is CN(0, 1)), so each trial costs one sample whatever N is and the caps below bound the run time. Drawing every pulse gives the same numbers at N times the cost; the regression tests check that the two agree.

Pd curve. POST /analysis/pd-curve plots P_d against SNR without a solve and stores nothing. In the UI: Results ▸ Detector (Pd curve).

curl -X POST http://127.0.0.1:8000/api/projects/sample_demo/analysis/pd-curve \
  -H "Content-Type: application/json" \
  -d '{"pfa": 1e-6, "cpi_pulses": 4096, "monte_carlo_trials": 200000}'
FieldDefaultMeaning
pfa1e-6False-alarm probability.
snr_min_db / snr_max_db / step_db−5 / 30 / 0.5SNR axis (71 points; at most 1001).
modelsall threeDeduplicated, order kept.
monte_carlo_trials0Trials per point (0 = analytic only).
empirical_thresholdfalseThreshold from a noise-only run (needs trials ≥ 20 / P_fa).
seed0Base of the Monte Carlo streams.
cpi_pulses1Pulses per trial. The SNR axis is post-integration and the Monte Carlo draws the integrated sample, so no number depends on it.
pd_target0.9Gives snr_for_pd_target_db per model.

The response holds snr_db and threshold (−ln P_fa). Per model it gives pd and snr_for_pd_target_db, and with trials > 0 also pd_mc with pd_mc_ci_low / pd_mc_ci_high, mc_max_abs_deviation and mc_within_ci_fraction. The top level then adds empirical_threshold, pfa_measured and pfa_measured_ci, and metadata.mc_method says which draw ran. An unknown project answers 404 and an invalid request, including one over the trial budget, 422.

Signal-level detection: delay-Doppler map and CFAR (#24)

The §6 detection is a threshold on the true SNR of the strongest echo. It never misses a strong echo and never raises a false alarm. With sensing.detection.mode: "cfar" each frame instead builds the received pilot channel of every TX → sensing-RX link, turns it into a delay-Doppler map and runs a 2-D CFAR detector on it. A detection is now a cell that crosses a threshold. Thermal noise, clutter and target sidelobes can cross it too, and those false alarms are counted. CFAR mode needs sensing.waveform (Waveform numerology (#23) in §6), because the map is built on its pilot grid.

curl -X POST http://127.0.0.1:8000/api/projects/sample_demo/simulate/scenario \
  -H "Content-Type: application/json" \
  -d '{"num_frames": 20, "dt_s": 0.5, "sensing": {"enabled": true,
       "waveform": {"scs_hz": 15625, "n_fft": 2048, "n_active_sc": 1280,
                    "symbol_duration_s": 6.944444444444444e-05,
                    "pilot": {"subcarrier_step": 5, "symbol_step": 9}},
       "background": {"mode": "both"},
       "detection": {"mode": "cfar",
                     "map": {"window": "hann", "clutter_removal": "mean_subtract"},
                     "cfar": {"type": "ca", "guard": [1, 1], "train": [8, 4],
                              "pfa_cell": 1e-4}}}}'

Signal model. Per frame i and link (tx, rx), on the K × N pilot lattice (subcarrier f_k, pilot symbol time t_n):

Y[n, k] = Σ_p a_p e^(−j2π f_k τ_p) e^(+j2π f_D,p t_n) + W[n, k]

The sum runs over the frame's echoes of every target on the link (direct and multipath) and the background paths: H1 when the run solved it, else H0, else none (§6, Background paths H0/H1). a_p = 10^(path_gain_db/20) · e^(j·phase_rad) is the dimensionless path gain. phase_rad already holds the carrier term −2π f_c τ, so f_k is the baseband subcarrier frequency and no carrier term is added again. W is circular Gaussian with variance

σ_H² = N0 / Es = 10^((N0,RE − Es,RE)/10), N0,RE = −174 + 10 log10(scs) + NF, Es,RE = P_tx − 10 log10(n_active_sc)

per RE, the noise of a least-squares pilot estimate. σ_H² equals N_floor(B_occ) / P_tx, so a path's per-RE SNR |a|²/σ_H² is P_echo − N_floor(B_occ). The noise of (frame i, tx, rx) is drawn from SeedSequence(entropy=[noise_seed, 0x5EA024], spawn_key=(0, i, id32(tx), id32(rx))) (PCG64; real parts first, then imaginary parts; id32 = the first 4 bytes of sha256, little-endian). Two runs with the same noise_seed therefore share the noise on equal (frame, tx, rx) keys, and the channel export (§10) redraws it bit for bit. H0 calibration maps (below) draw from a stream of their own, 0x5EA031, so a calibration never reuses the noise a run is tested on.

Map. clutter_removal: "mean_subtract" first removes the slow-time mean of each subcarrier, which cancels every static path exactly. Then the window (periodic Hann w[i] = 0.5 − 0.5 cos(2πi/L), or rect) is applied on both axes, followed by a K' = z·K point IFFT over subcarriers (delay) and an N' = z·N point FFT over symbols (Doppler), z = zero_pad. The Doppler axis is shifted so 0 Hz is row N'/2:

Z[v, u] = Σ_n Σ_k w_t[n] w_f[k] Y[n, k] e^(+j2π k u/K') e^(−j2π n v/N') P[v, u] = |Z|² / (σ_H² Σw_f² Σw_t²)

A noise cell has mean 1, so P is an SNR. A path at (τ, f_D) peaks at u = τ K' Δf_p and v = f_D N' T_p (Δf_p = pilot subcarrier spacing, T_p = pilot period), at range sum c·u/(K'Δf_p) and Doppler v/(N'T_p). On a bin centre the peak is 10 log10 P = SNR_RE + 10 log10(K N) + L_t + L_f, with window loss L = 10 log10((Σw)²/(L Σw²)): 0 for rect and −1.76 dB for Hann, per axis. Off a bin centre the straddle loss can reach about 3.9 dB per axis without padding, and zero_pad 4–8 recovers most of it. zero_doppler_notch_bins: k excludes the coarse Doppler bins −k…k (rows |v| < (k + 0.5)·z): they are neither cells under test nor training cells. mean_subtract leaves 0 Hz without noise, so without a notch it implies k = 0, and every row whose noise variance it lowers by more than 1 % does not train either: 1 − |W_t(v)|² / (N Σw_t²) < 0.99, W_t the N'-point DFT of the slow-time window. That is the 0 Hz row for rect at z = 1, the rows −1…1 for Hann (±1 keep 5/6 of their noise). metadata.sensing.detection.map lists notch_bins, notched_rows and non_training_rows (signed v).

CFAR. Each cell under test has training cells inside a box of ±(guard + train) cells around it, minus the guard box. guard and train are [delay, Doppler] half-widths, and the box wraps around both axes. The defaults are guard [1, 1] and train [8, 4], N_t = 17·9 − 3·3 = 200. A cell is detected when its statistic s exceeds the threshold (strictly) and it is not notched:

typeStatistic sAnalytic threshold α at pfa_cell
caP / mean(training)α = N_t (P_fa^(−1/N_t) − 1), so P_fa = (1 + α/N_t)^(−N_t)
osP / x₍k₎, the k-th smallest training cell (os_rank, default ⌈0.75 N_t⌉)α solves Π_{i<k} (N_t − i)/(N_t − i + α) = P_fa

A cell under test whose training box holds only n_v < N_t cells of training rows (next to the notch) uses their mean (CA) or their k_v-th smallest, k_v = ⌈k·n_v/N_t⌉ (OS), and its statistic is mapped onto the N_t scale so one threshold keeps P_fa per cell everywhere: CA s' = N_t((1 + s/n_v)^(n_v/N_t) − 1), exact for every threshold; OS s' = s·α(N_t, k)/α(n_v, k_v), exact at pfa_cell. (When the emptied 0 Hz row still trained, rect with mean_subtract false-alarmed at about 1.9 × pfa_cell in the rows |v| = 1…5.)

These P_fa values assume iid exponential cells (rect window, no padding). Hann and zero padding correlate neighbouring cells, so the achieved rate is higher than pfa_cell: 1.9·10⁻³ against 10⁻³ at N_t = 40 with a periodic Hann map. The report below shows the rate actually achieved. threshold: "h0_calibrated" replaces α with an empirical one, computed on the H0 maps of a stored result (h0_result_id): a sensing_background_ensemble (§12; every realization × noise frame × link) or a scenario result with stored H0 background frames (every frame; frames sharing one cached H0 list are synthesized once). These maps use this run's waveform, map, CFAR and σ_H², and their noise is drawn from SeedSequence(entropy=[seed, 0x5EA031], spawn_key=(r, m, id32(tx), id32(rx))) with the ensemble's seed and realization r, or the scenario source's noise_seed and r = 0: never the 0x5EA024 stream of in-run noise, so the achieved P_FA below stays a held-out check even when the seeds are equal. With n statistics of non-notched cells and m = ⌊n·p⌋, the threshold is sorted(s)[n − m] (0-based). m must be at least 20, and at most 5·10⁸ cells are evaluated. With system_pfa the statistic is the per-frame maximum over every link and every cell, and its quantile at system_pfa sets the threshold; the H0 source must then cover every TX → sensing-RX link of this run (400 otherwise), because a maximum over fewer links sets the threshold too low (about #links / #calibrated links × system_pfa). For a cell threshold, links without H0 maps use the threshold calibrated on the others with a warning (source.uncalibrated_links).

Output. SensingFrame.detections lists every link's detections in link order (tx-major), strongest statistic first. Each entry holds tx_id, rx_id, cell_tau (u), cell_nu (signed v), delay_ns, range_sum_m, doppler_hz (parabolic interpolation of 10 log10 P across the neighbouring cells per axis, clipped to ±0.5 cell), statistic, threshold, power_db (10 log10 P) and snr_est_db (10 log10(P/noise − 1) with the unbiased noise mean: CA the training mean, OS x₍k₎ / Σ_{i=N_t−k+1}^{N_t} 1/i). Detections carry no truth. Association is still an oracle. A link report is detected when a detection lies within G = oracle_gate_cells coarse cells (× zero_pad) of the map cell nearest its echo's true (range sum, Doppler), circularly: 2G + 1 cells per axis, and oracle_gate_cells: 0 is that cell alone. The strongest such detection supplies measured_range_m, measured_doppler_hz, range_bin, doppler_bin and detection_index (its index in detections), and fusion and the EKF use those values. With no detection in the gate, the reason is cfar_miss and the measured fields are null. snr_db, bistatic_range_m, doppler_hz and path_id keep the oracle values, and the MTI notch of §6 does not apply. measurement_noise is ignored, with a warning, because the map already adds the noise. At most max_detections_per_link (256) are stored per link, strongest first, and a gated detection is always kept.

Achieved P_FA. metadata.sensing.detection holds the map, CFAR and threshold settings (with α, or the calibration source with n, m and the quantile rule), the noise seed, N0 per RE and σ_H² per TX, and achieved:

Each comes with a 95 % Clopper–Pearson interval. A false alarm is any detection outside every gate of its link: thermal noise, clutter residue, multipath ghosts and target sidelobes beyond the gate all count. link_frame and system count one Bernoulli trial per map and per frame, so theirs are exact binomial intervals. Cells are not independent: Hann and zero padding correlate neighbours and false alarms cluster, so the cell interval is the Clopper–Pearson interval of the effective sample k/φ of n/φ, where φ = max(1, χ²/(M − 1)) is the Pearson dispersion of the M per-map false-alarm counts (cell.dispersion; measured on noise: 1.3 for Hann, 3 for Hann with zero_pad 2, about 1 for rect without padding). The Pd Monte Carlo of §9 keeps its Wilson intervals.

A strong static path masks a weak echo near 0 Hz. In free space with a 300 m bistatic baseline, 43 dBm and a 0.052 m² target (G1 geometry), the direct path is 68 dB above the echo. With Grid S at 10 ms, Hann and no clutter removal, the echo (12 dB integrated SNR at 22 Hz) was a cfar_miss in every frame, and the direct path's sidelobes raised the achieved cell P_FA to 1.3·10⁻³ against a 10⁻⁴ design (95 % interval 0.7–2.2·10⁻³, dispersion 3.9). With a 50 ms CPI, a rect window and clutter_removal: "mean_subtract" (notch 0 implied), the bistatic and the monostatic echo were detected in every frame, at an achieved cell P_FA of 6.9·10⁻⁵ (95 % interval 3.1–13.3·10⁻⁵). On the isac_drone_fr1 demo (NR 612 × 280, H0 background), mean subtraction raised the gated detections from 36 to 127 of 128 link-frames. The scenario form's "Clutter removal" select defaults to mean subtraction when a background is on.

FieldDefaultMeaning
detection.mode"threshold""cfar" turns on the detector (needs waveform).
map.window, map.zero_pad"hann", 1Window on both axes; FFT length factor (1–8).
map.clutter_removal"none""mean_subtract" removes the slow-time mean per subcarrier (implies notch 0; emptied rows do not train).
map.zero_doppler_notch_binsnullCoarse Doppler bins −k…k neither tested nor training.
cfar.type, cfar.guard, cfar.train"ca", [1, 1], [8, 4]Detector and [delay, Doppler] half-widths.
cfar.os_rank, cfar.pfa_cell⌈0.75 N_t⌉, 1e-4OS rank; design P_fa per cell.
threshold, h0_result_id, system_pfa"analytic", null, nullCalibrated threshold source and per-frame target.
oracle_gate_cells, max_detections_per_link1, 256Association gate around the nearest cell; stored detections per link.

These answer 400 before any solve: a CFAR window wider than the map (raise zero_pad or shrink train), a map of more than 2²⁴ = 16,777,216 cells (z²·K·N), a Hann window on an axis with one pilot, a notch that covers every Doppler row, and a tested row left without a training cell. So does an h0_result_id that is unknown, of another kind, without stored H0 frames, without a link of this run, missing a link of this run with system_pfa, or with fewer than 20 expected exceedances. CFAR without waveform answers 422.

10. Sensing dataset export

POST /export/sensing-dataset flattens the per-frame sensing of stored scenario results (§6) into tables for learning or offline analysis, in one zip under export/sensing_dataset/. It runs no solve. In the UI: Toolbar ▸ Actions ▸ Sensing dataset (.npz).

curl -X POST http://127.0.0.1:8000/api/projects/sample_demo/export/sensing-dataset \
  -H "Content-Type: application/json" \
  -d '{"formats": ["npz", "csv"], "include_echo_paths": true,
       "split": {"train": 0.8, "val": 0.1, "test": 0.1, "seed": 0}}'
FieldDefaultMeaning
result_idsnullScenario results to export, in this order. null = every stored scenario result that has sensing frames.
skip_without_sensingfalseWith result_ids: skip a listed result without sensing frames (named in warnings) instead of answering 400. The UI sends it for a partial selection.
formats["npz"]Any of npz, csv, parquet (parquet needs pyarrow: pip install "seam-studio[parquet]", the parquet extra).
include_echo_pathsfalseAlso write the echoes table.
splitnullFrame-level split {train, val, test, seed} (fractions sum to 1). null = every row is all.

The response gives zip_name and download_url (the project assets route), the zip's files, the row counts (num_rows, rows_per_result, rows_per_split, num_echo_rows), detected_fraction, size_bytes and warnings. The zip holds:

Every column is always there, so the schema is stable. A missing value is NaN in a float column and "" in a string column. npz has one 1-D array per column (strings as <U, so np.load(..., allow_pickle=False) works). csv has a header, floats in shortest round-trip form, NaN as an empty field and true/false. parquet keeps NaN as NaN.

links columns

ColumndtypeUnitRoleSource
result_id, splitstrmeta
frame_indexint64metaindex in frames
time_sfloat64smetaframe.time_s
tx_id, rx_id, target_idstrmetalink report
tx_pos_x/y/z, rx_pos_x/y/zfloat64mfeatureframe.sensing.nodes
tx_vel_x/y/z, rx_vel_x/y/zfloat64m/sfeatureframe.sensing.nodes
bistatic_range_m, doppler_hz, echo_power_dbm, snr_db, measured_range_m, measured_doppler_hzfloat64m, Hz, dBm, dBfeaturelink report
range_bin, doppler_binfloat64 (NaN = none)cellfeaturelink report
num_echoesint64featurelink report
multipathboolfeaturelink report
aod_az_deg, aod_el_deg, aoa_az_deg, aoa_el_degfloat64degfeaturethe reported echo (path_id in frame.sensing.echoes)
pd, pd_mcfloat64featurelink report (NaN unless the run set pfa)
detectedboollabel
reasonstrlabeldetected / no_echo / below_threshold / mti_rejected / cfar_miss (CFAR runs, see §9)
position_true_x/y/z, velocity_true_x/y/zfloat64m, m/slabelthe target's estimates[] row
est_statusstrestimateok / insufficient_links / diverged
n_links_detected, n_links_usedint64estimate
position_est_x/y/z, velocity_est_x/y/z, position_error_m, velocity_error_m_s, gdopfloat64m, m/sestimate
track_statusstrtrack"" when tracking was off
track_position_x/y/z, track_velocity_x/y/z, track_position_error_m, track_velocity_error_m_s, track_position_std_m, track_updates, track_gatedfloat64m, m/strackNaN when absent

The estimate and track columns hold one value per target and frame, repeated on each of its link rows. The velocity_true_* labels follow §6: at an exact waypoint time they hold the outgoing leg's velocity, and 0 at the end of a once trajectory. Results from before v0.1.14 hold the two-leg average (or half the speed) at those frames, so a dataset that mixes old and new results mixes both conventions there.

echoes columns: result_id, split, frame_index, time_s, path_id, tx_id, rx_id, target_id ("" = a comm path from include_comm_paths), path_type, delay_ns, bistatic_range_m (c·τ), power_dbm, path_gain_db, phase_rad, doppler_hz, aod_az_deg, aod_el_deg, aoa_az_deg, aoa_el_deg, num_interactions (int64), direct (bool: interactions == [sensing]) and reported (bool: the echo its link report was built from).

Split. The unit is the frame: the (result, frame) pairs, in export order, are permuted with numpy.random.default_rng(seed). The first n_train are train, the next n_val val and the rest test. The counts are train·F, val·F and test·F rounded by largest remainder (a tie goes to the smaller fraction, then train, val, test), so they add up to F, each is within one frame of its exact share, and a fraction of 0 never gets a frame. Every row of one frame, links and echoes alike, lands in the same split, and the same seed gives the same split.

Older results. Results from before v0.1.13 did not store nodes. Their TX/RX positions come from the frame's device_states, else the current scene, and their velocities from the current scene's trajectories (the way the run computed them, except at exact waypoint times, where the v0.1.14 rule of §6 applies). The export then warns "predates v0.1.13" (adding "scene changed since the run" when the scene hash differs) and the manifest says "kinematics_source": "current_scene".

Errors. Unknown project, or a result_ids entry that is unknown or whose file is missing or unreadable (also: not a valid scenario result): 404. With result_ids null such a file is skipped and named in warnings. A listed result without sensing frames (unless skip_without_sensing), no such result left at all, parquet without pyarrow, or more than 500 000 rows (links + echoes): 400, and nothing is written. An unknown format: 422.

Memory. Rows are built in memory: about 1.9 KB per row as Python lists plus about 0.6 KB per row of arrays and the encoded files, so an export at the 500 000-row cap peaks around 1.3 GB of RAM; export fewer results on a small machine.

Channel tensors (include_channel, #22)

Two opt-in flags add what a signal-level detector needs to the export. The export synthesizes the tensors from the stored path rows, so it still runs no solve.

curl -X POST http://127.0.0.1:8000/api/projects/sample_demo/export/sensing-dataset \
  -H "Content-Type: application/json" \
  -d '{"include_echo_paths": true, "include_background_paths": true,
       "include_channel": {"subcarriers": "pilot",
                           "components": ["echo", "background_h0", "total"],
                           "noise": {"enabled": true}}}'
FieldDefaultMeaning
include_background_pathsfalseAlso write background.<fmt>: one row per stored H0 / H1 background path (§6, Background paths H0/H1).
include_channelnullAlso write channel.npz (options below).
include_channel.subcarriers"pilot"pilot: the K × N pilot lattice. active: every active subcarrier of every symbol of the CPI.
include_channel.waveformnullGrid of the tensors. null: each run's sensing.waveform, else Grid S (15.625 kHz × 1280 SC, comb-5 pilots on every 9th 1/14400 s symbol: K 256 × N 16 per 10 ms).
include_channel.components["echo"]Any of echo, background_h0, background_h1, total (deduplicated).
include_channel.noise.enabledfalseAlso write the thermal noise realization noise.
include_channel.noise.n0_dbm_per_renullN0 per RE [dBm]. null: −174 + 10 log10(scs) + NF of the run's config.
include_channel.noise.seednullnull: each run's sensing.noise_seed.
include_channel.pilot_lsfalseAlso write H_ls = H_total + noise, the LS pilot estimate (needs noise).
include_channel.max_elements50,000,000Cap on every complex64 element of channel.npz together (at most 2·10⁸).

With either flag on, echoes rows also get a_re and a_im (Re and Im of the dimensionless gain 10^(path_gain_db/20)·e^(j·phase_rad), carrier phase included), tx_power_dbm, and per interaction (;-joined; "" for LoS or a missing value) interaction_types, rf_material_ids and prim_ids (the target actor id on a sensing interaction). The manifest then says schema_version: 2, and the response counts num_background_rows, num_channel_rows and channel_elements. Without either flag every table and manifest key is the v0.1.15 one (apart from created_at and seam_version).

background.<fmt> has the columns result_id, split, frame_index, time_s, hypothesis (h0 / h1), path_id, tx_id, rx_id, path_type, delay_ns, bistatic_range_m, power_dbm, path_gain_db, phase_rad, doppler_hz, the departure and arrival angles, num_interactions and the six channel columns above. It has rows only for frames that solved and stored a hypothesis. A cache-hit frame reuses the paths of an earlier frame, and the manifest's background_index maps each frame to [frame_index, h0 source frame, h1 source frame] (null = not solved, not stored, or failed). With the default sensing.background.store: "h0" a run has no H1 rows. The table has its own cap of 500 000 rows.

channel.npz (stored uncompressed inside the zip) holds one row per result × frame with sensing × TX → sensing-RX link (TX-major, the run's sensing_tx_ids × sensing_rx_ids):

ArrayShape, dtypeMeaning
row, frame_index[R] int64Row number, frame index.
result_id, tx_id, rx_id[R] str
time_s, sigma_h2, es_dbm_per_re[R] float64Frame time, σ_H² = N0/Es of the row's TX, Es per RE [dBm].
f_hz, t_s[K], [N] float64Baseband subcarrier frequencies, symbol times.
H_echo, H_background_h0, H_background_h1, H_total[R, N, K] complex64Requested components, noiseless.
noise, H_ls[R, N, K] complex64With noise.enabled / pilot_ls.

H[n, k] = Σ_p a_p e^(−j2π f_k τ_p) e^(+j2π f_D,p t_n)

f_k is the baseband frequency of subcarrier k. Because phase_rad already holds −2π f_c τ, a lone LoS path has phase −2π(f_c + f_k)τ, with the carrier applied once. No delay normalization is applied, and the Doppler sign is positive for a closing path. echo sums the link's target echoes (not the comm paths of include_comm_paths). total = echo + H1 when the frame has it, else + H0, the same rule the CFAR map uses. A missing background is zeros, with a warning. A run that solved H1 without storing it (the default sensing.background.store: "h0") cannot rebuild background_h1, total or pilot_ls (whose total would differ from the run's map): those answer 400; run with store: "both", or export echo / background_h0. The same holds for H0 with store: "none". The noise of a row is drawn with the key of the CFAR section above, at the row's σ_H². With the run's own waveform on the pilot grid, the exported noise is therefore the noise the run's map used, bit for bit. The manifest's channel block holds the grid (waveform, numerology), the components, the noise seeds and N0 per result, the noise key, the amplitude convention and the synthesis formula. The export warns when two exported results share a noise seed.

import numpy as np
ch = np.load("channel.npz", allow_pickle=False)
H = ch["H_total"][0].astype(np.complex128)      # [symbol, subcarrier]
Y = H + ch["noise"][0]                           # the LS pilot estimate
snr_re = np.abs(H) ** 2 / ch["sigma_h2"][0]      # per-RE SNR of the noiseless channel

All results of one export must resolve to the same grid (same frequencies and symbol times), else 400: export them separately or set include_channel.waveform. More than max_elements elements (rows × N × K × arrays) answers 400 before anything is written. A TX whose power is unknown (no stored path gain and no longer in the scene) is left out with a warning. The tensors are built in memory, at 8 bytes per element (400 MB at the default cap).

11. Limits

12. H0 calibration ensemble (#30)

POST /simulate/sensing-background-ensemble builds the target-free (H0) frames that a CFAR threshold is calibrated on and that digital-twin mismatch studies compare against. It re-solves the H0 background of one stored scenario frame (§6, Background paths H0/H1: the target actors removed, PathSolver with diffuse reflection) at that frame's node poses, once per realization. Realization 0 is the nominal twin. Every later realization draws a perturbation from seed: a scale on each radio material's ε_r and σ, one position offset per node site, and a carrier-frequency offset (CFO) per bistatic sensing receiver. The result stores each realization's paths and the seed. Noise frames are never stored; the seed redraws them bit for bit.

curl -X POST http://127.0.0.1:8000/api/projects/sample_demo/simulate/sensing-background-ensemble \
  -H "Content-Type: application/json" \
  -d '{"source": {"result_id": "sionna_scenario_003", "frame_index": 0},
       "n_realizations": 20, "noise_frames_per_realization": 500, "seed": 1,
       "perturb": {"material": {"eps_r_pct": 10, "sigma_pct": 30},
                   "node_pos_sigma_m": 0.1, "cfo_hz": 5}}'
FieldDefaultMeaning
source.result_id, source.frame_indexrequiredThe scenario result and frame whose node kinematics, actor poses and targets the ensemble uses. The frame must have sensing.
n_realizationsrequired (1–200)Realization 0 is nominal.
noise_frames_per_realizationrequired (1–100,000)H0 noise frames m = 0 … n−1 per realization, drawn on demand.
seed0Seeds the perturbation draws and the noise.
perturb.material.eps_r_pct, perturb.material.sigma_pct0, 0Standard deviation [%] of each radio material's scale: ε_r × (1 + d) and σ × (1 + d′), d, d′ ~ N(0, (pct/100)²). The solver clips ε_r at 1 and σ at 0.
perturb.material.scope"material"One draw per solver radio material. "prim" answers 400: material groups are merged meshes.
perturb.node_pos_sigma_m0Per node site, an N(0, σ² I₃) offset [m]. A TX and a sensing RX within 1 m of each other are one site and move together.
perturb.cfo_hz0Per bistatic sensing RX (more than 1 m from every TX), N(0, cfo²) [Hz], applied at synthesis as a Doppler offset of every path (positive = closing). A co-located receiver shares its TX's oscillator and gets none.
backgroundnullBackground solver options (§6). null: the source run's sensing.background with mode h0_removed, else the defaults. When set, mode must be "h0_removed" (422 otherwise).

What is solved. The links are every TX × sensing RX of the source run, TX-major (metadata.sensing.sensing_tx_ids, else the frame's nodes that are not sensing receivers). The nodes sit at the source frame's positions and velocities (frame.sensing.nodes) plus their offsets, the actors at the frame's actor_states, and the targets are resolved at that pose and removed. The ensemble uses the source run's backend and its simulation config (config_snapshot), and solves the realizations one after another. A realization with no material or position draw (nominal, or CFO only) has realization 0's solve inputs: it reuses those paths (background_h0: null, source_realization: 0) and is not solved again.

Draws. Realization r ≥ 1 draws from numpy.random.default_rng(SeedSequence(entropy=[seed, 0x5EA030], spawn_key=(r,))), in this order:

  1. per radio material (sorted by name): normal(0, eps_r_pct/100), then normal(0, sigma_pct/100);
  2. per node site (TX first, then the sensing receivers, in site order): normal(0, node_pos_sigma_m, 3);
  3. per bistatic sensing RX (sorted ids): normal(0, cfo_hz).

Every draw is taken even at σ = 0, so switching one perturbation on does not change the values of another. Zero draws are left out of material_draws, node_offsets_m and cfo_hz, and label is perturbed when anything is left.

H0 frames. Noise frame m of realization r on link (tx, rx) is

Y[n, k] = H_r[n, k] + w[n, k], H_r[n, k] = Σ_p |a_p| e^{jφ_p} e^{−j2π f_k τ_p} e^{+j2π (f_D,p + cfo_rx) t_n}

on the consumer's pilot grid (§6, Waveform numerology; f_k baseband, t_n the pilot symbol times, φ_p the stored phase_rad, which already holds the carrier term). w is complex Gaussian with E|w|² = σ_H² (N0 per RE over Es per RE), drawn from SeedSequence(entropy=[seed, 0x5EA031], spawn_key=(r, m, id32(tx), id32(rx))), where id32 is the first 4 bytes (little endian) of the id's SHA-256. 0x5EA031 is the H0 calibration stream: a scenario run draws its in-run noise from 0x5EA024, so an ensemble seed equal to a run's noise_seed never reproduces that run's noise. The noise level is the consumer's: the detector uses its own waveform, noise figure and TX power. Frames, realizations and links draw from independent streams (adjacent frames correlate below |ρ| = 0.01 over 10⁵ REs).

Calibrating a threshold. Set sensing.detection.threshold: "h0_calibrated" and h0_result_id to the ensemble's result_id (§9, Signal-level detection). The detector builds the map of every realization × noise frame × link with the run's numerology, map and CFAR options and takes sorted(values)[n − floor(n·p)] of the cell statistic, or of the per-frame maximum over every link with system_pfa. With n = 10,000 calibration frames at P_FA 10⁻², a held-out 10,000 frames show 74–129 false alarms in 95 % of calibrations (beta-binomial: the calibration's own spread widens the binomial 81–120). 10⁻³ needs n ≥ 20,000; 10⁵ is better.

Result. SensingBackgroundEnsembleResultSet holds source, seed, noise_frames_per_realization, background (the options used), links, the nominal nodes, and per realization index, label, material_draws (name → eps_r_scale, sigma_scale), node_offsets_m, cfo_hz, background_h0 (RayPath list; null when reused), source_realization, solve_s, num_paths, dropped_paths, total_power_db ("tx>rx" → 10 log10 Σ|a|²) and warnings. metadata carries the request and its request_hash, material_names, sites, bistatic_rx_ids, solves, total_solve_s, draw_key, noise_key and the provenance hashes. Read it back with GET /results/sensing-background-ensemble?result_id=… (the latest without result_id); &summary=true returns every realization without its background_h0 paths, which is what the UI shows. Labels and pruning work as for every result kind. Each stored path takes about 0.7–1 KB of JSON, and the background options default to 256 paths per link.

Errors. Unknown project or source result, or a source file that is not a valid scenario result: 404. A frame index out of range or a frame without sensing, a device or target actor of the run missing from the scene, scope: "prim", more than 2,000,000 paths stored over the solved realizations (lower background.max_paths_per_link), or a realization whose background solve failed (it would store a noise-only H0): 400. The source run's backend unavailable or without sensing: 409. Out-of-range values, unknown keys or a background mode other than h0_removed: 422.

Limits.