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 numerous programming languages.
When you 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 learn 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 advisable for optimal performance).
- Software: Access to the AODT Release 1.4 container.
- Knowledge: Basic familiarity with Python and wireless network concepts, similar 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 foremost components:
- The AODT service acts because the centralized, high-power computation core. It manages and loads the large 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 truly use it? The complete 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 foremost phases:
- Configuration tells AODT what to simulate.
- Execution runs the simulation and gets data.
Follow the total example:
Phase 1: Configuration (constructing the YAML string)
The AODT service is configured using a single YAML string. While you possibly can 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 essential 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 personal.
# 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 possibly can 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 possibly can 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 can physically interact with and alter the radio signals.
# If we would like 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’ve got 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 the whole 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 possibly can query the service to get the parameters of the simulation you simply created. This confirms the whole lot 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 possibly can 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 probably 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 total 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, nonetheless, 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 a couple of lines of Python, you have got configured a posh, 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 are able to 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 long run of 6G.
