Skip to content

Channel Simulator

The ChannelSimulator lets you run the full communication and multi-channel sensing pipeline with no radio attached. It replaces the USRP with shared-memory “air” and builds a scatterer-based multipath channel: the transmitted samples pass through a LoS path, optional static multipath paths, and moving scatterers with delay / Doppler / gain / array response. A fixed integer timing offset can be folded into each path delay; the communication/bistatic receive chain can also apply a UE sample-clock offset, then relative carrier frequency offset (CFO), and AWGN is added to every RX output. The timing offset is a fixed integer sample offset, while sample_rate_offset_ppm models sampling frequency offset (SFO) between the BS and UE sample clocks.

Each sensing RX channel is treated as one antenna. The array response can be generated from a parametric uniform linear array (ULA, array_spacing_lambda) or loaded as a custom array manifold matrix from steering_override_file. The communication channel is a realistic multipath channel: a LoS path plus scatterer returns, so the UE’s bistatic sensing can also detect range-Doppler targets.

ChannelSimulator shared-memory air flow

All three processes attach to POSIX shared-memory rings named by simulation.session. The engines select the backend with radio_backend: sim in their YAML. The radio::SimBackend implements the same OpenISAC radio HAL used by the UHD backend, so the BS/UE/sensing hot paths drive radio::ITxStream / radio::IRxStream interfaces regardless of whether the underlying transport is shared memory or a USRP. radio_backend: uhd (the default) keeps the hardware behaviour.

The shared-memory streams use one absolute sample timeline. The simulated device clock runs before the first sample is transmitted, timed TX metadata places the first burst sample on that clock, and timed RX commands seek to the requested absolute sample. The BS uplink RX timeline continues through silence before the UE starts transmitting instead of beginning at an unrelated ring-local zero. Consequently, UE downlink RX alignment and uplink TX window movement retain the same clock relationship as timed USRP streams. Changing sync_tracking.desired_peak_pos changes the local delay/TO_UE reference coordinate, but an eRTM-corrected physical delay peak remains at the same position.

Terminal window
cd build && cmake .. && make -j ChannelSimulator BS UE

Run the three processes on the same machine in three terminals:

Terminal window
cd build
cp ../config/BS_Sim.yaml BS.yaml
cp ../config/UE_Sim.yaml UE.yaml
# 1) start the "air" FIRST (it creates the shared memory and is the sample clock)
./ChannelSimulator # reads BS.yaml (+ its simulation: block)
# 2) start the transmitter / monostatic sensing
./BS
# 3) start the receiver / comm demod
./UE

Use the dedicated pair when validating bidirectional timing and absolute eRTM delay correction:

Terminal window
cd build
cp ../config/BS_Sim_eRTM.yaml BS.yaml
cp ../config/UE_Sim_eRTM.yaml UE.yaml
./ChannelSimulator # terminal 1
./BS # terminal 2
./UE # terminal 3
# Optional: inspect the uplink/downlink spectra and eRTM correlation result.
python3 ../scripts/plot_ertm_debug.py

This preset enables the duplex simulator, the maximum_likelihood timing metric, eRTM debug output, and ertm_absolute bistatic delay correction. It disables BS monostatic sensing RX, CFO, SRO, and UE predictive-delay tracking so the first run isolates eRTM timing behavior. Both files use the dedicated oisac_ertm_sim session and must be copied as a pair.

Start ChannelSimulator first. By default the hub paces output to wall-clock sample time and still uses shared-memory backpressure: if communication RX or sensing RX is enabled but the corresponding consumer process is not reading from shared memory, the related ring buffer will fill and the whole simulation will wait. Start every enabled receiver; if you only need one side, disable the unused output in the simulation: block:

  • Sensing only: set enable_comm_rx: false; the hub does not produce communication RX output, so run only ChannelSimulator + BS and skip UE.
  • Comm only: set enable_sensing_rx: false; the hub does not produce sensing RX output, but still run ChannelSimulator + BS + UE because the BS transmits and the UE receives the communication stream.

There are two ways to disable sensing RX: set enable_sensing_rx: false, or set sensing_rx_channel_count: 0.

To view monostatic sensing results, run python3 scripts/plot_sensing_fast.py. As in hardware mode, RD streaming starts when the viewer connects.

Configure the channel in the simulation: block in BS_Sim.yaml:

radio_backend: sim
simulation:
session: oisac_sim # must match across all three processes
enable_comm_rx: true # produce the comm RX path (false = sensing-only, no UE)
enable_sensing_rx: true # produce the sensing RX paths (false = comm-only)
enable_uplink: false # route UE ul.tx to BS rx.ul; true in BS_Sim_Duplex.yaml
pacing_enabled: true # pace shared-memory output to wall-clock sample time
noise_power_dbfs: -50 # AWGN per RX channel; <= -200 disables
cfo_hz: 0.0 # initial BS->UE CFO; UE->BS is opposite/scaled and follows TX retune
sample_rate_offset_ppm: 0.0 # UE sample clock offset relative to the BS clock
timing_offset_samples: 0 # fixed integer sample offset, folded into path delays
array_spacing_m: 0.04283 # physical ULA spacing (m); d/lambda scales with center_freq
# (42.83 mm = lambda/2 @ 3.5 GHz, matches the sensing viewers).
# Set <= 0 to fall back to the legacy array_spacing_lambda.
array_spacing_lambda: 0.5 # legacy fixed spacing (wavelengths); used only if array_spacing_m <= 0
ring_capacity_samples: 262144 # about 2 frames; small keeps TX close to the hub
steering_override_file: "" # array manifold: [num_targets x num_channels] complex<float>, row-major; empty = ULA
comm_multipath_taps: # communication LoS path + static multipath paths
- { delay_samples: 0, gain_db: 0, phase_deg: 0 }
targets: # monostatic sensing scatterers (with array response)
- { range_m: 30, velocity_mps: 5, gain_db: -6, angle_deg: 20 }
- { range_m: 75, velocity_mps: -3, gain_db: -12, angle_deg: -10 }
# bistatic_targets: # optional independent scene for the bistatic (comm) channel
# - { range_m: 45, velocity_mps: 8, gain_db: -8, angle_deg: 0 }

The number of sensing antennas equals the number of sensing_rx_channels entries.

sample_rate_offset_ppm models one endpoint-level sampling clock error: the UE sample rate is BS_rate * (1 + ppm * 1e-6). BS TX, BS uplink RX, and all BS sensing RX channels stay on the same BS sample clock; UE downlink RX and UE uplink TX stay on the same UE sample clock. The simulator therefore resamples the BS->UE communication path by that ratio and the UE->BS uplink path by its reciprocal, while monostatic BS sensing channels are not resampled.

targets describes the scatterer scene seen by monostatic sensing and generates the corresponding array response for each sensing antenna. The bistatic (communication) channel can be configured separately with bistatic_targets; if it is omitted, the simulator reuses targets, so the same scatterer scene drives both the monostatic and bistatic chains. Because the communication RX is single-antenna, angle_deg is not used in the bistatic channel calculation. To override the array manifold, provide num_targets x num_channels little-endian complex<float> values in row-major order via steering_override_file; each row corresponds to one target. Once a custom array-manifold matrix is configured, targets[].angle_deg no longer participates in the monostatic array-response calculation. Compute the angle-dependent amplitude and phase for every antenna element in advance and store those values in the matrix.

When simulation.enable_uplink is enabled, the UE->BS simulated uplink uses the same communication-channel scene as the BS->UE downlink. In that scene, the static multipath components configured by comm_multipath_taps and the selected bistatic-scatterer components are both superimposed on the direct path; the bistatic scatterers come from bistatic_targets when configured, or fall back to targets otherwise.

The model is applied to transmitted samples x[n]x[n] in this order.

  1. For each scatterer, compute integer propagation delay, Doppler, and complex gain; the monostatic sensing path also applies the array manifold vector.
  2. The communication/bistatic path first adds the LoS path and static multipath paths, then adds the scatterer-return components.
  3. The BS->UE communication path is resampled by 1 + sample_rate_offset_ppm * 1e-6; when uplink simulation is enabled, the UE->BS path is resampled by the reciprocal ratio.
  4. The communication/bistatic path applies relative CFO to the whole received signal: BS->UE uses cfo_hz, while UE->BS uses its negative; the monostatic sensing path does not apply CFO.
  5. AWGN is added to every RX output.

Target round-trip delay, sample delay, and Doppler are:

τi=2Ric,i=round(τifs)+n0,fD,i=2viλ,λ=cfc\tau_i = \frac{2 R_i}{c}, \qquad \ell_i = \mathrm{round}(\tau_i f_s) + n_0, \qquad f_{D,i} = \frac{2 v_i}{\lambda}, \qquad \lambda = \frac{c}{f_c}

Here RiR_i is range_m, viv_i is velocity_mps, fsf_s is the BS sample_rate, fcf_c is center_freq, and n0n_0 is timing_offset_samples. The n0n_0 term is a fixed integer sample offset; SFO is modeled separately by the communication-path resampler.

The ULA array manifold is:

ai,k(θi)=exp ⁣(j2πdλksinθi)a_{i,k}(\theta_i) = \exp\!\left(j 2\pi \frac{d}{\lambda} k \sin\theta_i\right)

where kk is the antenna index. The normalized element spacing is:

dλ=dmfccd_\lambda = \frac{d_m f_c}{c}

where dmd_m is array_spacing_m and dλd_\lambda is the normalized element spacing in wavelengths. It is derived from the physical spacing and carrier frequency, so the recovered angle remains correct at any center_freq; the viewers invert the phase slope using the same physical spacing. With array_spacing_m <= 0, the frequency-independent legacy parameter array_spacing_lambda is used instead. If steering_override_file is set, the simulator reads ai,ka_{i,k} directly from the array-manifold matrix instead of generating it from angle_deg. The custom matrix must therefore already encode the target angle’s amplitude and phase response at every antenna element.

For monostatic sensing RX antenna kk:

ymono,k[n]=igiai,k(θi)x[ni]ej2πfD,in/fs+wk[n]y_{\mathrm{mono},k}[n] = \sum_i g_i\, a_{i,k}(\theta_i)\, x[n-\ell_i]\, e^{j 2\pi f_{D,i} n / f_s} + w_k[n]

The monostatic sensing channel shares the simulator clock with the transmitter and does not apply relative CFO or UE sample-clock offset. In the equation above, wk[n]w_k[n] is AWGN.

The communication/bistatic RX is single-antenna. The LoS/static multipath paths first form the communication multipath component:

uLoS[n]=phpx[np],p=dp+n0u_{\mathrm{LoS}}[n] = \sum_p h_p\, x[n-\ell_p], \qquad \ell_p = d_p + n_0

Here dpd_p comes from comm_multipath_taps[].delay_samples, and hph_p is determined by gain_db and phase_deg. Scatterer-return components are added to the same communication channel:

u[n]=uLoS[n]+igix[ni]ej2πfD,in/fsu[n] = u_{\mathrm{LoS}}[n] + \sum_i g_i\, x[n-\ell_i]\, e^{j 2\pi f_{D,i} n / f_s}

With SFO disabled, the communication/bistatic chain then applies relative CFO and AWGN. The same BS/UE oscillator mismatch has the opposite sign on the reciprocal link:

yDL[n]=uDL[n]ej2πfCFOn/fs,UE+wDL[n]y_{\mathrm{DL}}[n] = u_{\mathrm{DL}}[n]\, e^{j 2\pi f_{\mathrm{CFO}} n / f_{s,\mathrm{UE}}} + w_{\mathrm{DL}}[n] yUL[n]=uUL[n]ej2πfCFOn/fs,BS+wUL[n]y_{\mathrm{UL}}[n] = u_{\mathrm{UL}}[n]\, e^{-j 2\pi f_{\mathrm{CFO}} n / f_{s,\mathrm{BS}}} + w_{\mathrm{UL}}[n]

When UE downlink tracking produces an RX frequency correction, the simulation backend now retunes the uplink TX just like the real-USRP path. Let α=fUL/fDL\alpha=f_{\mathrm{UL}}/f_{\mathrm{DL}} (α=1\alpha=1 in TDD). The two residual CFOs are

fDL,res=fCFO+fRX,corr,fUL,res=αfCFOfTX,corr.f_{\mathrm{DL,res}} = f_{\mathrm{CFO}} + f_{\mathrm{RX,corr}}, \qquad f_{\mathrm{UL,res}} = -\alpha f_{\mathrm{CFO}} - f_{\mathrm{TX,corr}}.

In TDD, fTX,corr=fRX,corrf_{\mathrm{TX,corr}}=f_{\mathrm{RX,corr}}; in FDD, the uplink TX correction is scaled by the carrier ratio α\alpha. The shared control block carries the logical TX target-carrier correction rather than UHD’s API-specific TX dsp_freq sign.

With sample_rate_offset_ppm enabled, the simulator first forms u[n]u[n] on the source clock and passes it through a 32-tap, 1024-phase Kaiser-windowed sinc polyphase resampler. Positive ppm means the UE clock is faster than the BS clock, so BS->UE communication produces samples at fs,UE=fs,BS(1+ppm106)f_{s,\mathrm{UE}} = f_{s,\mathrm{BS}}(1+\mathrm{ppm}\cdot10^{-6}). The downlink CFO phasor step uses that UE-side sample rate. After the reciprocal resampler returns UE->BS uplink output to the BS clock, the simulator applies the current residual CFO at the BS-side sample rate without resetting the continuous phase when the frequency step changes.

By default, targets drives both the monostatic sensing antennas (with array manifold) and the bistatic communication channel (single-antenna, no array manifold), modelling one coherent scene. Set bistatic_targets to give the bistatic/communication channel its own independent scatterers. comm_multipath_taps configures the delay, gain, and initial phase of the LoS path and static multipath paths. The UE->BS uplink reuses this same communication-channel model so the simulated TDD uplink is reciprocal with the downlink channel shape.

  • Each process stops cleanly on Ctrl-C (SIGINT), independently of the others; the hub unlinks its shared memory on exit.
  • The hub is paced by default: each processed BS-clock chunk is released after chunk_samples / sample_rate wall-clock time. Set simulation.pacing_enabled: false for fastest-possible batch simulation.
  • Keep ring_capacity_samples small, around a couple of frames, so the transmitter cannot race far ahead of the hub and overflow the monostatic TX/RX pairing queue.
  • sample_rate_offset_ppm models one endpoint-level UE/BS SFO. timing_offset_samples remains only a fixed integer sample offset and does not drift over time.
  • Timed simulated streams share an absolute sample clock. The clock remains at zero before the first TX, so a UE started before the BS still selects the same initial timed start. After the first TX establishes the origin, time advances only with processed air samples and is independent of whether the simulation keeps up with wall time. An RX request before the actual TX origin is aligned to that origin.
  • With sync_tracking.predictive_delay: true, the UE checks whether the shared-oscillator ppm implied by cfo_hz / center_freq matches sample_rate_offset_ppm. The simulator permits CFO and SRO to be configured independently, so it warns on a mismatch; disable predictive delay when the mismatch is intentional to avoid interpreting CFO as sample-clock drift.
  • The simulator does not model fractional delay; all propagation delays and configured timing offsets are quantized to integer samples.