The event of 5G and 6G requires high-fidelity radio channel modeling, however the ecosystem is very fragmented. Link-level simulators, network-level simulators, and AI training frameworks operate independently, often in several programming languages.
If you happen to are a researcher or an engineer attempting to simulate the behavior of the important thing components of the physical layer of 5G or 6G systems, this tutorial teaches you tips on how to extend your simulation chain and add high-fidelity channel realizations generated by the Aerial Omniverse Digital Twin (AODT).
Prerequisites:
- Hardware: An NVIDIA RTX GPU (Ada generation or newer beneficial for optimal performance).
- Software: Access to the AODT Release 1.4 container.
- Knowledge: Basic familiarity with Python and wireless network concepts, equivalent to radio units (RUs) and user equipment (UE).
AODT universal embedded service architecture
Figure 1 shows how AODT might be embedded into any simulation chain, whether in C++, Python, or MATLAB.


AODT is organized into two fundamental components:Â
- The AODT service acts because the centralized, high-power computation core. It manages and loads the huge 3D city models (e.g., from an Omniverse Nucleus server) and executes all of the complex electromagnetic (EM) physics calculations.
- The AODT client and language bindings provide a light-weight developer interface. The client handles all of the service calls, and uses GPU IPC to transfer data efficiently, enabling direct GPU-memory access to radio-channel outputs. To support a broad range of development environments, the AODT client provides universal language bindings, enabling direct use from C++, Python ( through
pybind11) and MATLAB (through user-implementedmex).
Workflow in motion: Computing channel impulse responses in 7 easy steps
So how do you really use it? All the workflow is designed to be straightforward and follows a precise sequence orchestrated by the client as shown in Figure 3.


The method is split into two fundamental phases:Â
- Configuration tells AODT what to simulate.Â
- Execution runs the simulation and gets data.Â
Follow the complete example:
Phase 1: Configuration (constructing the YAML string)
The AODT service is configured using a single YAML string. While you may write this by hand, we also provide a strong Python API to construct it programmatically, step-by-step.
Step 1. Initialize the simulation configuration
First, import the configuration objects and arrange the fundamental parameters: the scene to load, the simulation mode (e.g., SimMode.EM), the variety of slots to run, and a seed for repeatable, deterministic results.
from _config import (SimConfig, SimMode, DBTable, Panel)
# EM is the default mode.
config = SimConfig(scene, SimMode.EM)
# One batch is the default.
config.set_num_batches(1)
config.set_timeline(
slots_per_batch=15000,
realizations_per_slot=1
)
# Seeding is disabled by default.
config.set_seed(seed=1)
config.add_tables_to_db(DBTable.CIRS)
Step 2: Define antenna arrays
Next, define the antenna panels for each your base stations (RUs) and your UEs. You need to use standard models, like ThreeGPP38901, or define your individual.
# Declare the panel for the RU
ru_panel = Panel.create_panel(
antenna_elements=[AntennaElement.ThreeGPP38901],
frequency_mhz=3600,
vertical_spacing=0.5,
vertical_num=1,
horizontal_spacing=0.5,
horizontal_num=1,
dual_polarized=True,
roll_first=-45,
roll_second=45)
# Set as default for RUs
config.set_default_panel_ru(ru_panel)
# Declare the panel for the UE
ue_panel = Panel.create_panel(
antenna_elements=[AntennaElement.InfinitesimalDipole],
frequency_mhz=3600,
vertical_spacing=0.5,
vertical_num=1,
horizontal_spacing=0.5,
horizontal_num=1,
dual_polarized=True,
roll_first=-45,
roll_second=45)
# Set as default for UEs
config.set_default_panel_ue(ue_panel)
Step 3: Deploy network elements (RUs and manual UEs)
Place your network elements within the scene. We use georeferenced coordinates (latitude/longitude) to position them precisely. For UEs, you may define a series of waypoints to create a pre-determined path.
du = Nodes.create_du(
du_id=1,
frequency_mhz=3600,
scs_khz=30
)
ru = Nodes.create_ru(
ru_id=1,
frequency_mhz=3600,
radiated_power_dbm=43,
du_id=du.id,
)
ru.set_position(
Position.georef(
35.66356389841298,
139.74686323425487))
ru.set_height(2.5)
ru.set_mech_azimuth(0.0)
ru.set_mech_tilt(10.0)
ue = Nodes.ue(
ue_id=1,
radiated_power_dbm=26,
)
ue.add_waypoint(
Position.georef(
35.66376818087683,
139.7459968717682))
ue.add_waypoint(
Position.georef(
35.663622296081414,
139.74622811587614))
ue.add_waypoint(
Position.georef(
35.66362516562424,
139.74653110368598))
config.add_ue(ue)
config.add_du(du)
config.add_ru(ru)
Step 4: Deploy dynamic elements (procedural UEs and scatterers)
That is where the simulation becomes truly dynamic. As a substitute of placing every UE by hand, you may define a spawn_zone and have AODT procedurally generate UEs that move realistically inside that area. You may also enable urban_mobility so as to add dynamic scatterers (cars) that may physically interact with and alter the radio signals.
# If we wish to enable procedural UEs we'd like a spawn zone.
config.add_spawn_zone(
translate=[150.2060449, 99.5086621, 0],
scale=[1.5, 2.5, 1],
rotate_xyz=[0, 0, 71.0])
# Procedural UEs are zero by default.
config.set_num_procedural_ues(1)
# Indoor proc. UEs are 0% by default.
config.set_perc_indoor_procedural_ues(0.0)
# Urban mobility is disabled by default.
config.enable_urban_mobility(
vehicles=50,
enable_dynamic_scattering=True)
# Save to string
from omegaconf import OmegaConf
config_dict = config.to_dict()yaml_string = OmegaConf.to_yaml(config_dict)
Phase 2: Execution (client-server interaction)
Now that we have now our yaml_string configuration, we hook up with the AODT service and run the simulation.
Step 5: Connect
Import the dt_client library, create a client pointing to the service address, and call client.start(yaml_string). This single call sends your complete configuration to the service, which then loads the 3D scene, generates all of the objects, and prepares the simulation.
import dt_client
import numpy as np
import matplotlib.pyplot as plt
# Server address (currently only localhost is supported)
server_address = "localhost:50051"
# Create client
client = dt_client.DigitalTwinClient(server_address)
try:
client.start(yaml_string)
except RuntimeError as e:
print(f"X Failed to begin scenario: {e}")
return 1
Once began, you may query the service to get the parameters of the simulation you simply created. This confirms every part is prepared and tells you the way many slots, RUs, and UEs to expect.
try:
status = client.get_status()
num_batches = status['total_batches']
num_slots = status['slots_per_batch']
num_rus = status['num_rus']
num_ues = status['num_ues']
except RuntimeError as e:
print(f"X Did not get status: {e}")
return 1
Step 6: Get UE positions
for slot in range(num_slots):
try:
ue_positions = client.get_ue_positions(batch_index=0,
temporal_index=SlotIndex(slot))
except RuntimeError as e:
print(f"X Did not get UE pos: {e}")
Step 7: Retrieve Channel Impulse Responses
Now we loop through each simulation slot where you may ask for the present position of all UEs. That is crucial for verifying that the mobility models are working as expected and for correlating channel data with location.Â
Retrieving the core simulation data is essentially the most critical step. The Channel Impulse Response (CIR) describes how the signal propagates from each RU to every UE, including all multipath components (their delays, amplitudes, and phases).
Retrieving this much data for/from/at? every slot might be slow. To make it fast, the API uses a two-step, zero-copy process using IPC.
First, before the loop, you ask the client to allocate GPU memory for the CIR results. The service does this and returns IPC handles, that are tips to that GPU memory.
ru_indices = [0]
ue_indices_per_ru = [[0, 1]]
is_full_antenna_pair = False
try:
# Step 1: Allocate GPU memory for CIR
cir_alloc_result = client.allocate_cirs_memory(
ru_indices,
ue_indices_per_ru,
is_full_antenna_pair)
values_ipc_handles = cir_alloc_result['values_handles']
delays_ipc_handles = cir_alloc_result['delays_handles']
Now, inside your loop, you call client.get_cirs(…), passing in those memory handles. The AODT service runs the complete EM simulation for that slot and writes the outcomes directly into that shared GPU memory. No data is copied over the network, making it incredibly efficient. The client has just been notified that the brand new data is prepared.
# Step 2: Retrieve CIR
cirs = client.get_cirs(
values_ipc_handles,
delays_ipc_handles,
batch_index=0,
temporal_index=SlotIndex(0),
ru_indices=ru_indices,
ue_indices_per_ru=ue_indices_per_ru,
is_full_antenna_pair=is_full_antenna_pair)
values_shapes = cirs['values_shapes']
delays_shapes = cirs['delays_shapes']
Access the info in NumPy
The info (CIR values and delays) continues to be on the GPU. The client library provides easy utilities to get a GPU pointer without latency penalties. For convenience, nevertheless, the info may also be accessed from NumPy. This might be achieved as shown in the next code.
# Step 3: export to numpy
for i in range(len(ru_indices)):
values_gpu_ptr = client.access_values_gpu(
values_ipc_handles[i],
values_shapes[i])
delays_gpu_ptr = client.access_delays_gpu(
delays_ipc_handles[i],
delays_shapes[i])
values = client.gpu_to_numpy(
values_gpu_ptr,
values_shapes[i])
delays = client.gpu_to_numpy(
delays_gpu_ptr,
delays_shapes[i])
And that’s it! In only just a few lines of Python, you’ve gotten configured a fancy, dynamic, georeferenced simulation, run it on a strong distant server, and retrieved the high-fidelity, physics-based CIRs as a NumPy array. The info is now able to be visualized, analyzed, or fed directly into an AI training pipeline. For example, we will visualize the frequency responses of the manual UE declared above using the next plot function.
def cfr_from_cir(h, tau, freqs_hz):
phase_arg = -1j * 2.0 * np.pi * np.outer(tau, freqs_hz)
# Protected exponential and matrix multiplication
with np.errstate(all='ignore'):
# Sanitize inputs
h = np.where(np.isfinite(h), h, 0.0)
expm = np.exp(phase_arg)
expm = np.where(np.isfinite(expm), expm, 0.0)
result = h @ expm
result = np.where(np.isfinite(result), result, 0.0)
return result
def plot(values, delays):
# values shape:
# [n_ue, number of UEs
# n_symbol, number of OFDM symbols
# n_ue_h, number of horizontal sites in the UE panel
# n_ue_v, number of vertical sites in the UE panel
# n_ue_p, number of polarizations in the UE panel
# n_ru_h, number of horizontal sites in the RU panel
# n_ru_v, number of vertical sites in the RU panel
# n_ru_p, number of polarizations in the RU panel
# n_tap number of taps
# ]
AX_UE, AX_SYM, AX_UEH, AX_UEV, AX_UEP, AX_RUH, AX_RUV, AX_RUP,AX_TAPS = range(9)
# delays shape:
# [n_ue, number of UEs
# n_symbols, number of OFDM symbols
# n_ue_h, number of horizontal sites in the UE panel
# n_ue_v, number of vertical sites in the UE panel
# n_ru_h, number of horizontal sites in the RU panel
# n_ru_v, number of vertical sites in the RU panel
# n_tap number of taps
# ]
D_AX_UE, D_AX_SYM, D_AX_UEH, D_AX_UEV, D_AX_RUH, D_AX_RUV, D_AX_TAPS = range(7)
nbins = 4096
spacing_khz = 30.0
freqs_hz = (np.arange(nbins) - (nbins // 2)) *
spacing_khz * 1e3
# Setup Figure (2x2 grid)
fig, axes = plt.subplots(nrows=2, ncols=2, figsize=(8, 9),
sharex=True)
axes = axes.ravel()
cases = [(0,0), (0,1), (1,0), (1,1)]
titles = [
"UE$_1$: -45° co-pol",
"UE$_1$: -45° x-pol",
"UE$_1$: 45° x-pol",
"UE$_1$: 45° co-pol"
]
for ax, (j, k), title in zip(axes, cases, titles):
try:
# Construct index tuple: [i, 0, 0, 0, j, 0, 0,
# k, :]
idx_vals = [0] * values_full.ndim
idx_vals[AX_UE] = i_fixed
idx_vals[AX_UEP] = j # UE polarization
idx_vals[AX_RUP] = k # RU polarization
idx_vals[AX_TAPS] = slice(None) # All taps
h_i = values_full[tuple(idx_vals)]
h_i = np.squeeze(h_i)
# Construct index tuple: [i, 0, 0, 0, 0, 0, :]
idx_del = [0] * delays_full.ndim
idx_del[D_AX_UE] = i_fixed
idx_del[D_AX_TAPS] = slice(None)
tau_i = delays_full[tuple(idx_del)]
tau_i = np.squeeze(tau_i) * DELAY_SCALE
H = cfr_from_cir(h_i, tau_i, freqs_hz)
power_w = np.abs(H) ** 2
power_w = np.maximum(power_w, 1e-12)
power_dbm = 10.0 * np.log10(power_w) + 30.0
ax.plot(freqs_hz/1e6 + 3600, power_dbm,
linewidth=1.5)
ax.set_title(title)
ax.grid(True, alpha=0.3)
# Formatting
for ax in axes:
ax.set_ylabel("Power (dBm)")
axes[2].set_xlabel("Frequency (MHz)")
axes[3].set_xlabel("Frequency (MHz)")
plt.tight_layout()
plt.show()


Empowering the AI-native 6G era
The transition from 5G to 6G must tackle greater complexity in wireless signal processing, characterised by massive data volumes, extreme heterogeneity, and the core mandate for AI-native networks. Traditional, siloed simulation methods are simply insufficient for this challenge.
The NVIDIA Aerial Omniverse Digital Twin is built precisely for this latest era. By moving to a gRPC-based service architecture in release 1.4, AODT is democratizing access to physics-based radio simulation and providing the bottom truth needed for machine learning and algorithm exploration.
AODT 1.4 is offered on NVIDIA NGC. We invite researchers, developers, and operators to integrate this powerful latest service and collaborate with us in constructing the longer term of 6G.
