Sionna RT와 OAI 사전 세팅
도커 컴포넌트 빌드 파이프라인
Sionna의 공식 Research Kit 튜토리얼에는 CUDA Channel Eumlator를 OAI gNB의 플러그인으로 삽입하는 구조이다.
이 과정에서 Sionna 측에서 OAI를 patch한 도커 파일을 로드해서 사용해야 한다.
전체 빌드 파이프라인은 다음과 같다.
sionna-rk 저장소
│
├── ext/openairinterface5g/ ← OAI 소스 (2025.w34 브랜치)
│ + openairinterface5g.patch 적용
│ → GPU 가속 + plugin infrastructure 활성화
│
├── plugins/
│ ├── channel_emulation/cuda_emulator/ ← CUDA .so
│ ├── channel_emulation/zmq_loader/ ← ZMQ .so
│ ├── channel_emulation/file_loader/ ← File .so
│ ├── data_acquisition/ ← IQ 캡처 .so
│ ├── neural_demapper/ ← TensorRT .so
│ └── ric_xapps/ ← Python xApp
│
└── make sionna-rk
→ ran-base-cuda 이미지 빌드 (17GB)
→ ran-build-cuda 이미지 빌드 (22GB)
→ oai-gnb-cuda 이미지 빌드 (16GB) ← 모든 플러그인 내장
→ oai-nr-ue-cuda 이미지 빌드 (8GB)
→ oai-flexric 이미지 빌드 (1GB)
따라서 Sionna의 공식 Research Kit를 구매하면 다음과 같이 동작한다 (rfsim + Channel Eumlator)
start_system.sh rfsim 실행
│
▼
① CN5G (AMF/SMF/UPF) 컨테이너 시작
│
▼
② nearRT-RIC 컨테이너 시작
│
▼
③ oai-gnb-cuda 컨테이너 시작
USE_ADDITIONAL_OPTIONS에 따라 플러그인 동적 로드:
--loader.chn_emu.shlibpath /usr/local/lib → libchn_emu.so 로드
--loader.cir_zmq.shlibpath /usr/local/lib → libcir_zmq.so 로드
--cir-zmq-num-taps 48
│
│ gNB 내부 루프 (매 슬롯, 0.5ms)
│ ┌─────────────────────────────────────┐
│ │ IQ 샘플 생성 (rfsim) │
│ │ ↓ │
│ │ cir_zmq: ZMQ REP port 5556에서 │
│ │ CIR 업데이트 수신 │
│ │ ↓ │
│ │ cuda_emulator: GPU에서 │
│ │ y[n] = Σ h[ℓ]·x[n-ℓ] + σ·w[n] │
│ │ (컨볼루션 + 노이즈 추가) │
│ │ ↓ │
│ │ rfsim TCP → UE로 전송 │
│ └─────────────────────────────────────┘
│
▼
④ oai-nr-ue-cuda 컨테이너 시작
│
▼
⑤ sionna-rt-gui 실행 (호스트에서, MPS 활성화 상태)
--config spark_quectel.yaml
│
│ ┌──────────────────────────────────────┐
│ │ Sionna RT: 씬에서 ray tracing │
│ │ UE 위치 → CIR 계산 (GPU) │
│ │ ↓ │
│ │ ZMQ PUB port 5556: CIR 전송 │
│ │ → gNB cir_zmq가 수신 │
│ │ │
│ │ ZMQ SUB port 5555: MAC 통계 수신 │
│ │ ← FlexRIC xApp zmq_stats_server │
│ │ MCS/BLER/PRB → GUI에 표시 │
│ └──────────────────────────────────────┘
여기서의 핵심은 2개의 ZMQ 채널이다.
Sionna RT GUI ──[ZMQ port 5556, CIR]──→ gNB (채널 적용)
Sionna RT GUI ←─[ZMQ port 5555, KPI]── FlexRIC xApp
Sionna RT가 Scene 기반으로 CIR을 계산해서 ZMQ로 gNB에 실시간으로 스트리밍하고,
gNB는 그걸 IQ 샘플에 실시간으로 적용한다.
현재 Sionna RK의 공식 튜토리얼을 활용하기 위해서는 DGX Spark, USRP, Quectel COTS 등이 필요하다.
하지만 현재 환경은 NVIDIA RTX 5090 그래픽카드 기반으로 수행하며, USRP, COTS UE는 모두 시뮬레이터로 대체한다.
Sionna RT와 OAI E2E 동작 확인하기
OAI와 Iperf3 세팅
bash ~/sionna-oai-flexric-smo/scripts/start.sh
우선 모든 컨테이너를 실행하는 script를 실행한다.

위와 같이 모든 컨테이너를 띄워놓은 상태에서, 3개 UE에 UL/DL Traffic을 iperf3로 주입한다.

이 상태에서 GUI를 실행하는 script를 실행한다.

GUI의 소스코드는 다음과 같다: https://github.com/NVlabs/sionna-rt-gui
GitHub - NVlabs/sionna-rt-gui
Contribute to NVlabs/sionna-rt-gui development by creating an account on GitHub.
github.com
GUI의 실행 순서는 다음과 같다.
- venv 가상환경의 Python으로 run.py 실행
- kyunghee.yaml 설정을 로드 (경희대 캠퍼스 씬)
- Sionna RT GUI 윈도우 띄움 (Polyscope 기반 3D 렌더링, Rasterization 모드)
- ZMQ 소켓 연결 — 포트 5600(UE 위치 수신), 포트 5601(KPI 수신)
이 GUI는 현재 돌고 있는 컨테이너 중 다음과 ZMQ 기반으로 통신이 이루어진다.
- integration_loop.py → ZMQ:5600 → GUI (UE 실시간 위치 표시)
- xapp-zmq-stats → ZMQ:5601 → GUI (KPI 패널 업데이트)
관련 패키지 import
다음으로 Sionna RT의 환경값을 세팅해야 하는데, 가장 먼저 Sionna RT 관련된 패키지를 import한다.
import sys, os
sys.path.insert(0, os.path.abspath('..'))
import numpy as np
import matplotlib.pyplot as plt
import matplotlib
matplotlib.rcParams['font.family'] = 'DejaVu Sans'
matplotlib.rcParams['axes.unicode_minus'] = False
# Sionna RT
import sionna.rt as rt
print('Sionna RT:', rt.__version__)
>>>
Sionna RT: 2.0.1
이후 load_kyunghee_scene()에서 Kyunghee.xml 파일을 로드하고, 주파수/안테나 설정을 수행한다.
from sionna_rt import load_kyunghee_scene, add_gnb, add_ue
scene = load_kyunghee_scene()
print('씬 로드 완료')
print('씬 크기 (bounding box):')
# 씬에 포함된 오브젝트 목록
for name in list(scene.objects.keys())[:10]:
print(f' {name}')
print(f' ... 총 {len(scene.objects)} 개 오브젝트')
>>>
씬 로드 완료
씬 크기 (bounding box):
elm__101
elm__97
elm__95
elm__92
no-name-1
elm__99
no-name-2
elm__2
... 총 8 개 오브젝트
gNB와 UE 위치 설정
다음으로 gNB의 위치와 UE의 초기 위치를 설정해준다.
정확한 위치좌표는 xml 파일을 생성한 Blender에서 확인할 수 있다.

# ── gNB 위치 설정 (공학관 옥상, 높이 25m) ──
# np.float32 → float 변환 (mitsuba.Point3f 호환)
cx, cy = float(center[0]), float(center[1])
GNB_POS = [-150, -267, 48]
# ── UE 위치 (캠퍼스 내 3곳) ──
UE_POSITIONS = {
'ue1': [-155, -100, 1.5], # 동쪽
'ue2': [23, -174, 1.5], # 북서쪽
'ue3': [-47, -3, 1.5], # 남쪽
}
add_gnb(scene, position=GNB_POS, name='gnb')
for ue_name, pos in UE_POSITIONS.items():
add_ue(scene, position=pos, name=ue_name)
print(f'gNB 위치: {GNB_POS}')
for name, pos in UE_POSITIONS.items():
print(f'{name} 위치: {pos}')
>>>
gNB 위치: [-150, -267, 48]
ue1 위치: [-155, -100, 1.5]
ue2 위치: [23, -174, 1.5]
ue3 위치: [-47, -3, 1.5]
gNB의 경우 천장의 높이보다 2m 높도록 설정해주었다.
add_gnb 함수와 add_ue함수로 각각 rt.Transmitter, rt.Receiver를 Scene에 추가한다.
현재 시점까지는 Ray Tracing이 실행되는 것은 아니고, TX/RX 위치만 Scene 객체에 등록한 상태이다.
실제 CIR 계산은 이후 compute_cir(scene)을 호출할 때 이루어진다.
Ray Tracing을 돌려서 CIR 계산값 체크하기
이후, 현재 좌표를 기준으로 Ray Tracing을 1회 돌려서 CIR 값이 제대로 나오는지 체크한다.
이 부분은 gNB가 건물 내부에 있어서 UE까지 Ray가 도달하지 않는 상황처럼,
gNB와 UE의 위치좌표가 제대로 설정되지 않았는지 등을 체크한다.
from sionna_rt.cir_generator import compute_cir, NUM_TAPS, SAMPLE_RATE_HZ
import sionna.rt as rt
# Sionna RT 2.0: PathSolver()를 직접 사용
print('CIR 계산 중 (경로 수렴까지 수초 소요)...')
solver = rt.PathSolver()
paths = solver(
scene,
max_depth=5,
samples_per_src=int(1e6),
los=True,
diffraction=True,
)
# Sionna RT 2.0: cir()에 bandwidth 인자 없음
a, tau = paths.cir(
sampling_frequency=SAMPLE_RATE_HZ,
num_time_steps=1,
out_type='numpy',
)
a_np = np.array(a)
tau_np = np.array(tau)
print(f'a shape: {a_np.shape}')
print(f'tau shape: {tau_np.shape}')
>>>
a shape: (3, 1, 1, 1, 111, 1)
tau shape: (3, 1, 111)
solver = rt.PathSolver() 함수에서 Sionna RT의 Ray Tracing 엔진 instance를 만든다.
이후 paths = solver(scene, max_depth=5, samples_per_src=int(1e6), los=True, diffraction=True)에서 무거운 계산이 이뤄지는데, 각각의 의미는 다음과 같다.
- samples_per_src=1e6은 gNB(TX) 1개에서 100만 개의 ray를 방사한다.
- max_depth는 각 ray가 최대 5번 반사/회절할 때까지 추적한다.
- los=True는 직접파(Line-of-Sight) 경로를 포함한다는 뜻이다.
- Diffraction=True는 건물 모서리에서 회절 경로를 포함한다는 것이다.
이 과정에서 내부적으로 Mitsuba/Dr.Jit가 GPU 또는 LLVM 백엔드로 ray-scene intersection을 대규모 병렬처리한다.
즉 TX(gNB) → 씬 메쉬 충돌 → 반사/회절 → RX(UE 3개) 도달 여부를 판별해 유효 경로만 수집한다.
측정 결과인 a shape와 tau shape의 의미는 다음과 같다.
| 차원 | 의미 | 값 |
| 3 | UE(RX) 수 | ue1, ue2, ue3 |
| 1 | RX 안테나 수 | SISO |
| 1 | TX 수 | gNB 1개 |
| 1 | TX 안테나 수 | SISO |
| 111 | 발견된 유효 경로 수 | LOS + 반사 + 회절 경로 합산 |
| 1 | time step | 정적 씬 |
a는 각 경로의 복소 채널 계수(진폭+위상), tau는 각 경로의 전파 지연(초 단위)이다.
gNB에서 100만 개의 ray를 쏴서 경희대 씬 메쉬와의 물리적 충돌을 GPU로 병렬 계산하고,
UE 3개에 도달하는 111개의 유효 전파 경로(직접파+반사+회절)를 찾아낸다.
이 결과가 이후 OAI로 주입되는 CIR 계산의 입력이 된다.
# CIR 시각화
a_flat = a_np.flatten()
tau_flat = tau_np.flatten()
valid = np.isfinite(a_flat) & np.isfinite(tau_flat) & (np.abs(a_flat) > 1e-20)
a_v = a_flat[valid]
tau_v = tau_flat[valid]
print(f'유효 경로 수: {valid.sum()}')
if valid.sum() > 0:
print(f'최소 지연: {tau_v.min()*1e6:.3f} µs')
print(f'최대 지연: {tau_v.max()*1e6:.3f} µs')
abs_sq = np.abs(a_v)**2
mean_tau = np.sum(abs_sq * tau_v) / abs_sq.sum()
rms_ds = np.sqrt(np.sum(abs_sq * (tau_v - mean_tau)**2) / abs_sq.sum())
print(f'RMS 지연 스프레드: {rms_ds*1e6:.3f} µs')
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
ax1.stem(tau_v * 1e6, 20*np.log10(np.abs(a_v) + 1e-10), basefmt=' ')
ax1.set_xlabel('Delay [µs]')
ax1.set_ylabel('Path Gain [dB]')
ax1.set_title('Kyunghee Campus — Delay Power Profile')
ax1.grid(True)
delay_samples = (tau_v * SAMPLE_RATE_HZ).astype(int)
order = np.argsort(-np.abs(a_v))
ax2.stem(delay_samples[order][:NUM_TAPS],
20*np.log10(np.abs(a_v[order][:NUM_TAPS]) + 1e-10), basefmt=' ')
ax2.set_xlabel('Tap Index [samples]')
ax2.set_ylabel('Tap Gain [dB]')
ax2.set_title(f'CIR Taps (The top {NUM_TAPS})')
ax2.grid(True)
plt.tight_layout()
os.makedirs('../data', exist_ok=True)
plt.savefig('../data/cir_profile.png', dpi=150, bbox_inches='tight')
plt.show()
print('저장: ../data/cir_profile.png')

CIR을 시각화하면 다음과 같다.
우선, cell의 a(복소 계수), tau(지연) 배열을 1차원으로 펼친 뒤, nan/inf이거나 진폭이 거의 0인 경로를 걸러낸다.
그 다음으로 아래 line에서 평균 지연(각 경로의 전력으로 가중 평균한 지연값)과 RMS 지연 스프레드 (평균 지연 중심으로의 전력 가중 표준편차, 채널의 주파수 선택적 페이딩 강도를 나타내는 핵심 지표)를 계산한다.
- mean_tau = np.sum(abs_sq * tau_v) / abs_sq.sum()
- rms_ds = np.sqrt(np.sum(abs_sq * (tau_v - mean_tau)**2) / abs_sq.sum())
최종적으로 그래프가 2개 생기는데,
왼쪽이 Delay Power Profile로, 유효한 모든 경로를 X(지연, us), Y(경로 이득, dB)으로 표시한다.
이를 통해 직접파부터 다중 반사파까지 전체 분포가 보인다.
오른쪽은 CIR Taps인데, 지연을 sample index로 변환(τ × 샘플레이트)하고,
진폭 내림차순으로 정렬해 상위 32개(NUM_TAPS)만 표시된 결과이다.
이게 실제 OAI gNB에 주입될 CIR 탭의 모습이다.
그 다음으로, 해당 Ray Tracing 결과를 OAI gNB Telnet으로 던질 수 있는 chanmod 파라미터로 변환해야 한다.
from sionna_rt.cir_generator import _pack_cir
from sionna_rt.telnet_channel import cir_to_channel_params
cir_dict = _pack_cir(a_np, tau_np)
print('CIR 패킹 결과:')
print(f' norm: {cir_dict["norm"]:.4f}')
print(f' n_paths: {cir_dict["n_paths"]}')
print(f' taps (상위 5): {cir_dict["taps_ri"][:10].round(4)}')
print(f' delays (상위 5): {cir_dict["delays"][:5]}')
params = cir_to_channel_params(cir_dict, tx_power_dbm=30.0)
print(f'\n채널 파라미터:')
print(f' 경로 손실: {params.path_loss_db:.1f} dB')
print(f' noise_power: {params.noise_power_db:.1f} dB')
print(f' K-factor: {params.ricean_k_db:.1f} dB')
print(f' 지연 스프레드: {params.delay_spread_us:.3f} µs')
print(f' LOS 여부: {params.has_los}')
>>>
CIR 패킹 결과:
norm: 0.0000
n_paths: 32
taps (상위 5): [ 0. 0. 0. 0. 0. 0. -0. -0. -0. 0.]
delays (상위 5): [ 0 0 0 13 2]
채널 파라미터:
경로 손실: 87.0 dB
noise_power: -30.0 dB
K-factor: 2.4 dB
지연 스프레드: 0.499 µs
LOS 여부: True
이 _pack_cir(a_np, tau_np) 부분은 CIR을 OAI 주입 포맷으로 패킹하는 부분이다.
Ray Tracing 결과(a, tau)를 받아서 nan/inf/0 경로를 제거하고, 지연을 sample 인덱수로 변환(τ × 샘플레이트)한 뒤,
진폭 내림차순 정렬 후 상위 32개(NUM_TAPS)만 추출하여 OAI channel emulator가 읽는 형식인 [Re0, Im0, Re1, Im1, ...] float32 배열로 변환한다.
이후 전체 채널 에너지의 제곱근을 norm으로 저장하여 결과적으로 3개 UE × 325개 경로를 32탭짜리 이산 CIR 하나로 압축한다.
cir_to_channel_params(cir_dict) 부분에서 채널 파라미터를 추출하고,
_pack_cir 결과에서 OAI rfsim의 Telnet chanmod에 주입할 4가지 파라미터를 계산한다.
| 파라미터 | 계산 방법 |
| path_loss_db | −20 × log10(norm) |
| noise_power_db | −(TX_power − path_loss − 열잡음) → SNR의 역수, -30~+20dB로 클리핑 |
| ricean_k_db | 가장 강한 경로 전력 ÷ 나머지 경로 전력 합 → K-factor(dB) |
| has_los | K > 0dB면 LOS 판정 |
| delay_spread_us | 전력 가중 RMS 지연 스프레드 |
따라서 출력 결과를 해석하면
norm: 0.0000 → 채널 에너지가 매우 작음 (도심 회절/반사 손실)
path_loss: 87 dB → 레이트레이싱으로 계산된 경로 손실
K-factor: 2.4 dB → 0 초과 → has_los = True (NLOS, 직접파가 반사파보다 우세)
DS: 0.499 µs → 약 150m 상당의 초과 지연 (도심 캠퍼스 환경에서 합리적)
이후 TelnetChannelController.apply_channel_params(params)를 호출할 때 실제 채널 주입이 이루어진다.
OAI rfsim에 채널 파라미터 주입
이 부분부터는 모든 OAI 요소가 실행 중인 상태에서 동작한다.
from sionna_rt.telnet_channel import TelnetChannelController, UE1_IP, UE_PORTS
# UE1 Telnet 연결 테스트
ctrl = TelnetChannelController(UE1_IP, UE_PORTS['ue1'])
if ctrl.connect():
print('현재 chanmod 설정:')
print(ctrl.show_current())
# Sionna RT 계산 결과 적용
print(f'\nnoise_power_dB = {params.noise_power_db:.1f} 적용...')
result = ctrl.set_noise_power(0, params.noise_power_db)
print(f'결과: {result[:100]}')
ctrl.disconnect()
else:
print('연결 실패 (OAI 실행 확인 필요)')
print(' docker compose -f docker-compose-ue-rfsim.yaml up -d')
>>>
현재 chanmod 설정:
model 0 rfsimu_channel_enB0 type AWGN:
model owner: rfsimulator
nb_tx: 1 nb_rx: 1 taps: 1 bandwidth: 0.000000 sampling: 46080000.000000
channel length: 1 Max path delay: 0.000000 ricean fact.: 0.000000 angle of arrival: 0.000000 (randomized:No)
max Doppler: 0.000000 path loss: 20.000000 noise: -30.000000 rchannel offset: 0 forget factor; 0.000000
Initial phase: 0.000000 nb_path: 10
taps: 0 lin. ampli. : 1.000000 delay: 0.000000
----------------
model 1 rfsimu_channel_ue0 type AWGN:
model owner: not set
nb_tx: 1 nb_rx: 1 taps: 1 bandwidth: 0.000000 sampling: 46080000.000000
channel length: 1 Max path delay: 0.000000 ricean fact.: 0.000000 angle of arrival: 0.000000 (randomized:No)
max Doppler: 0.000000 path loss: 20.000000 noise: -2.000000 rchannel offset: 0 forget factor; 0.000000
Initial phase: 0.000000 nb_path: 10
taps: 0 lin. ampli. : 1.000000 delay: 0.000000
----------------
softmodem_5Gue>
noise_power_dB = -30.0 적용...
[Telnet] channelmod modify 0 noise_power_dB -30.00
결과: model owner: rfsimulator
nb_tx: 1 nb_rx: 1 taps: 1 bandwidth: 0.000000 sampling: 46080000.
우선 ctrl = TelnetChannelController(UE1_IP, UE_PORTS['ue1'])과 ctrl.connect() 부분에서 Telnet과 연결한다.
호스트에서 oai-nr-ue1 컨테이너(192.168.70.150:9095)으로 Telnet 접속하는데,
이 부분은 OAI UE 소프트모뎀이 띄워놓은 내장 CLI 서버에 연결되는 것으로 이루어진다.
다음으로 현재 chanmod의 설정 조회할 수 있는데, ctrl.show_current() # → "channelmod show current" 전송 부분에서
UE1의 rfsim 채널 모델 현재 상태를 읽어온다.
출력 결과를 보면:
- model 0 rfsimu_channel_enB0 — gNB→UE 방향 (DL) 채널
- model 1 rfsimu_channel_ue0 — UE→gNB 방향 (UL) 채널
- 둘 다 타입 AWGN, 탭 1개, 도플러 없음 → 기본 설정 그대로인 상태
- DL noise: -30.0dB, UL noise: -2.0dB
이후 Sionna RT 계산 결과를 실제 채널에 주입한다.
ctrl.set_noise_power(0, params.noise_power_db) # → "channelmod modify 0 noise_power_dB -30.00" 전송.
Telnet으로 문자열 명령어를 보내서 실행 중인 OAI UE 프로세스의 채널 파라미터를 런타임에 바꾼다.
이 부분은 도커의 재시작 없이 즉시 반영된다.
(여기서 -30.0dB는 Sionna RT가 레이트레이싱으로 계산한 경로 손실 73.2dB에서 도출된 SNR 기반 noise 값)
즉 해당 부분은 물리 전파 시뮬레이션 결과가 실제 5G 소프트웨어 무선 채널에 반영되는 지점이다.
Workflow를 정리하면 다음과 같다.
Sionna RT 레이트레이싱
→ CIR 계산 (경로 손실, K-factor 등)
→ Telnet CLI
→ 실행 중인 OAI UE rfsim 채널 파라미터 실시간 변경
FlexRIC KPI 수집
from sionna_rt.kpi_collector import KPICollector
import time
kpi_history = []
def collect_kpi(stats_list):
for s in stats_list:
kpi_history.append({
'time': time.time(),
'rnti': s.rnti,
'dl_mcs': s.dl_mcs,
'ul_mcs': s.ul_mcs,
'dl_bler': s.dl_bler,
'ul_bler': s.ul_bler,
'dl_prb': s.dl_prb_used,
})
collector = KPICollector()
collector.on_update(collect_kpi)
collector.start()
print('KPI 수집 10초간 대기...')
time.sleep(10)
print(f'수집된 KPI 샘플 수: {len(kpi_history)}')
if kpi_history:
import pandas as pd
df = pd.DataFrame(kpi_history)
print(df.tail(5))
else:
print('(FlexRIC xApp이 실행 중인지 확인)')
collector.stop()
>>>
[KPI] ZMQ 연결: tcp://localhost:5557
KPI 수집 10초간 대기...
수집된 KPI 샘플 수: 594
time rnti dl_mcs ul_mcs dl_bler ul_bler dl_prb
607 1.782746e+09 61366 0 0 5.605194e-45 5.605194e-45 0
608 1.782746e+09 20813 0 0 5.605194e-45 5.605194e-45 0
609 1.782746e+09 29350 0 0 5.605194e-45 5.605194e-45 0
610 1.782746e+09 61366 0 0 5.605194e-45 5.605194e-45 0
611 1.782746e+09 20813 0 0 5.605194e-45 5.605194e-45 0
① 콜백 함수 등록
이후 FlexRIC의 xApp으로 KPI를 실시간으로 확인하는 콜백 함수를 등록한다.
아래 함수를 통해 새 KPI 데이터가 수신될 때마다 호출될 함수를 등록되는데, 아직 수신은 시작되지 않은 상태이다.
def collect_kpi(stats_list):
for s in stats_list:
kpi_history.append({...})
collector.on_update(collect_kpi)
② ZMQ 구독 시작
그 다음으로 ZMQ 구독을 시작한다.
아래 명령어로 백그라운드 스레드를 하나 생성하고, tcp://localhost:5557에 ZMQ SUB 소켓으로 연결한다.
xapp-zmq-stats 컨테이너가 포트 5555에서 pub하는 것을 호스트 5557로 포워딩받는 상태이다.
스레드는 _recv_loop()를 돌면서 메시지가 오면 JSON 파싱 → collect_kpi 콜백 호출을 반복한다.
collector.start()
③ 10초 수신 후 결과 확인

10초 동안 602개 샘플 수집 → 약 60 msg/초
출력 결과는 다음과 같다.
rnti: 53589, 47902, 28334 → 현재 접속 중인 UE 3개의 무선 식별자
dl_mcs: 0 → Downlink MCS 0 (가장 낮은 변조 방식, QPSK)
ul_mcs: 0 → Uplink MCS 0
dl_bler: 5.6e-45 → 사실상 0 (정상 수신)
dl_prb: 14~106 → PRB 사용량 (트래픽에 따른 변동)

각 column의 의미는 다음과 같다.
- rnti: UE 무선 식별자 (3개가 번갈아 나옴 = UE 3대)
- dl_mcs / ul_mcs: 변조·코딩 레벨 27~28 → 최상위권. 채널 상태가 좋아 고효율 전송 중
- dl_bler / ul_bler: 5.6e-45 → 사실상 0. 전송 오류 없음
- dl_prb: 이번 0.5초 구간에서 사용된 최대 순간 PRB 수
- rnti 28334가 106 = 106 PRB 전부 사용 = DL 풀 대역폭 점유 (iperf3에서 UE3에 20Mbps+ 주입한 것과 일치)
- rnti 47902는 14 = 가벼운 트래픽 (UE2 Metaverse, 100~140K)
이 결과를 보면 iperf3로 주입한 트래픽 차이가 PRB 사용량에 그대로 반영되고 있다.
특히 UE2인 0xD155(53589)의 경우
트래픽 많은 UE는 PRB를 많이, 적은 UE는 적게 할당받는 게 정상적으로 보인다.
xApp으로 모니터링하는 요소들
현재 NVIDIA에서 기본 제공하는 xapp-zmq-stats이 수집하고 있는 구조체는 openairinterface5g/openair2/E2AP/flexric/src/sm/mac_sm/ie/mac_data_ie.h에서 확인할 수 있다.
여기에 FlexRIC MAC service model이 gNB에서 뽑아주는 UE별 KPI의 실제 정의가 들어있다.
해당 부분에는 throughput(dl_aggr_tbs), SNR(pusch_snr/pucch_snr), CQI(wb_cqi), PHR(phr), BSR(bsr), HARQ, 재전송 PRB 등의 KPI 지표가 정의되어 있다.



이제 xApp의 컨테이너에 볼륨 마운트되는 zmq_stats_server.py를 수정한다.

UEStats에 추가로 모니터링할 SNR/CQI/PHR/BSR/누적 TBS/HARQ/재전송 PRB 필드를 추가한다.
이는 _parse에서 새 필드를 파싱하게 된다.
추가로 여기서 기록되는 throughput은 누적값을 계산하는 형태이다.
따라서 이 부분에 dl_aggr_tbs 누적값을 RNTI별로 기억했다가 차분 ÷ 경과시간 → dl_tput_mbps/ul_tput_mbps을 수행하는 로직을 추가해두었다.

이후 Cell에 다음 KPI값들을 추가해서 관측 가능한 모든 KPI의 데이터를 수집할 수 있다.
from sionna_rt.kpi_collector import KPICollector
import time
kpi_history = []
def collect_kpi(stats_list):
for s in stats_list:
kpi_history.append({
'time': round(s.timestamp, 2),
'rnti': s.rnti,
# 실시간 처리량 (Mbps)
'dl_tput': round(s.dl_tput_mbps, 2),
'ul_tput': round(s.ul_tput_mbps, 2),
# 변조/코딩
'dl_mcs': s.dl_mcs,
'ul_mcs': s.ul_mcs,
# PRB 사용량
'dl_prb': s.dl_prb_used,
'ul_prb': s.ul_prb_used,
# 채널 품질
'pusch_snr': s.pusch_snr,
'pucch_snr': s.pucch_snr,
'wb_cqi': s.wb_cqi,
'phr': s.phr,
'bsr': s.bsr,
# 오류율
'dl_bler': s.dl_bler,
'ul_bler': s.ul_bler,
# HARQ
'dl_harq': s.dl_num_harq,
'ul_harq': s.ul_num_harq,
# 재전송 PRB (누적)
'dl_retx_prb': s.dl_aggr_retx_prb,
'ul_retx_prb': s.ul_aggr_retx_prb,
# 누적 전송량 (bits)
'dl_aggr_tbs': s.dl_aggr_tbs,
'ul_aggr_tbs': s.ul_aggr_tbs,
})
collector = KPICollector()
collector.on_update(collect_kpi)
collector.start()
print('KPI 수집 10초간 대기...')
time.sleep(10)
# DataFrame 만들기 전에 먼저 중단 (스레드가 계속 append하면 길이 불일치 에러)
collector.stop()
snapshot = list(kpi_history) # 스레드 안전 복사
print(f'수집된 KPI 샘플 수: {len(snapshot)}')
if snapshot:
import pandas as pd
pd.set_option('display.width', None)
pd.set_option('display.max_columns', None) # 모든 컬럼 표시
pd.set_option('display.max_rows', None) # 모든 행 표시
df = pd.DataFrame(snapshot)
display(df) # 전체 데이터프레임
else:
print('(FlexRIC xApp이 실행 중인지 확인)')

TroubleShooting
추가로, 기본적인 세팅에서 gNB는 MAC 통계를 1ms마다 콜백으로 던지지만,
전체 리포트 (MCS/BLER/SNR/TBS 등)은 500번째 콜백마다, 즉 약 0.5초마다 한 번 갱신된다.
ZMQ 발행 루프는 50ms마다 메시지를 내보내는데, 0.5초 안에는 같은 Report가 100번 중복 발행되는 상황이었다.
- 즉, 실제 KPI가 바뀌는 주기 = 0.5초
- ZMQ 메시지는 50ms마다 오지만 10개 중 9개는 중복
- 그래서 throughput 계산도 0.5초 해상도인 상황이었다 (dl_aggr_tbs가 0.5초마다 갱신되므로)
그래서 간단한 방법으로 ZMQ 발행 주기를 Report 주기에 맞춰서(--interval 0.5) 중복을 제거하였다.
추후 xApp의 Report 해상도를 변경하려면 이 2부분 모두를 고려해야 한다.
통합 루프 실행 — UE 이동 시나리오
from sionna_rt.integration_loop import (
SionnaOAIIntegration, IntegrationConfig, LoopMode
)
trajectories = {
'ue1': [ # [-155, -100] 근처 남북 이동
[-155, -100, 1.5],
[-155, -60, 1.5],
[-155, -20, 1.5],
],
'ue2': [ # [23, -174] 근처 동서 이동
[ 23, -174, 1.5],
[-20, -174, 1.5],
[-60, -174, 1.5],
],
'ue3': [ # [-47, -3] 근처 gNB 방향으로 이동
[-47, -3, 1.5],
[-80, -80, 1.5],
[-110, -160, 1.5],
],
}
cfg = IntegrationConfig(
mode = LoopMode.TRAJECTORY,
update_interval = 2.0,
gnb_position = GNB_POS,
ue_positions = UE_POSITIONS,
ue_trajectories = trajectories,
steps_per_segment= 10,
enable_kpi = True,
save_cirs = False,
)
loop = SionnaOAIIntegration(cfg)
loop.setup()
>>>
[13:46:47] RNTI:28334 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
[13:46:47] RNTI:53589 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
[13:46:47] RNTI:47902 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
[13:46:47] RNTI:28334 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
[13:46:47] RNTI:53589 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
[13:46:47] RNTI:47902 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
[13:46:48] RNTI:28334 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
[13:46:48] RNTI:53589 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
[13:46:48] RNTI:47902 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
[13:46:48] RNTI:28334 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
[13:46:48] RNTI:53589 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
[13:46:48] RNTI:47902 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
[13:46:49] RNTI:28334 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
[13:46:49] RNTI:53589 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
[13:46:49] RNTI:47902 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
[13:46:49] RNTI:28334 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
[13:46:49] RNTI:53589 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
[13:46:49] RNTI:47902 DL MCS: 0 UL MCS: 0 DL BLER:0.000 UL BLER:0.000 ~SNR:-5.0dB
...
① 궤적 정의: 3개 UE 각각에 대해 씬 좌표 기준 3개 웨이포인트를 설정한다.
steps_per_segment=7이므로 각 웨이포인트 구간을 10단계로 선형 보간된다.
이 과정에서 UE당 3구간 × 10단계 = 10스텝의 이동 경로가 생긴다.
② IntegrationConfig 생성
이후 루프 동작 방식 전체를 하나의 설정 객체로 묶는다.
| 설정 | 값 | 의미 |
| mode | TRAJECTORY | UE가 궤적을 따라 이동하며 CIR 갱신 |
| update_interval | 2.0s | 2초마다 CIR 재계산 |
| steps_per_segment | 10 | way point간 보간 단계 |
| enable_kpi | True | FlexRIC KPI 수집 활성화 |
| save_cirs | False | CIR 파일 저장 안함 |
③ loop.setup() — 여기서 실제 초기화가 일어난다
출력 로그를 보면 다음 과정이 순서대로 이루어진다.
- Scene 로드 — load_kyunghee_scene() 재호출, gNB/UE 3개를 씬에 배치
- ZMQ Bridge 연결 — localhost:5600/5601 (GUI 실시간 UE 위치 + KPI 전송용)
- gNB Telnet 연결 — 192.168.70.140:9090 (거리 제어용, setdistance 전용)
- ZMQ CIR 채널 연결 — tcp://192.168.70.140:5555로 gNB의 cir_zmq 플러그인에 접속. 여기서 gNB로부터 num_taps=48, symbols/slot=14 설정을 읽어온다.
- KPI 수집 시작 — tcp://localhost:5557 ZMQ SUB 구독 스레드 시작
setup()이 완료되어도 아직 루프는 돌지 않는 상황이다.
이 시점은 모든 연결이 열려있고 Scene이 준비된 대기 상태입니다.
이후 loop.run() 또는 loop.start()를 호출해야 2초 주기로 UE 위치 이동 → CIR 계산 → gNB ZMQ 주입이 반복된다.
# ── GUI에 gNB / UE 초기 위치 전송 ──
# GUI가 setup() 이후에 시작된 경우 gui_client가 None일 수 있으므로 재연결
from sionna_rt_gui.zmq_bridge import ZMQBridgeClient
if loop.gui_client is None:
loop.gui_client = ZMQBridgeClient()
loop.gui_client.connect()
# gNB 위치 전송 (GUI에 Transmitter로 표시)
loop.gui_client.send_gnb_position('gnb', GNB_POS)
print(f'gNB 전송: {GNB_POS}')
# UE 초기 위치 전송
for name, pos in loop._ue_positions.items():
loop.gui_client.send_ue_position(name, pos)
print(f'{name} 전송: {pos}')
이제 윗 Cell에서 만든 loop 객체를 사용해서 gNB/UE 위치를 GUI 화면에 그리도록 전송한다.
그럼 초기 gNB와 UE 위치가 GUI 화면에 표시된다.

이후 integration_loop.py의 run_once()를 돌려서 UE가 제대로 배치되었는지 확인한다.
def run_once(self):
cirs = self._compute_all_ue_cirs() # 현재 위치에서 CIR 1회 계산
self.channel.send_average_cir(cirs) # gNB에 1회 주입
return cirs
이 run_once는 UE 초기 위치 그대로 CIR 1번 계산 → gNB에 1번 주입 → 결과 반환하는 함수이다.
즉, UE는 안 움직이고 수 초만에 즉시 계산이 끝난다.
이 함수는 결과(경로손실/noise/LOS)를 출력해서 정상 동작 확인하는 용도이다.
# 단일 스텝 실행 (테스트)
print('단일 스텝 실행...')
cirs = loop.run_once()
print('\n결과 요약:')
for ue_name, cir in cirs.items():
from sionna_rt.telnet_channel import cir_to_channel_params
p = cir_to_channel_params(cir)
print(f' {ue_name}: 경로손실={p.path_loss_db:.1f}dB, '
f'noise={p.noise_power_db:.1f}dB, '
f'LOS={p.has_los}')
>>>
단일 스텝 실행...
[ZMQ-CIR] 대표 UE: ue2 (norm=0.0000)
[ZMQ-CIR] ue2: norm=0.0000, σ_scaling=0.010, σ_max=0.020, paths=32
결과 요약:
ue1: 경로손실=97.6dB, noise=-30.0dB, LOS=False
ue2: 경로손실=88.2dB, noise=-30.0dB, LOS=True
ue3: 경로손실=94.8dB, noise=-30.0dB, LOS=True
이제 실제로 30초간의 연속 루프를 돌려서 UE의 이동에 따른 변화를 관찰한다.
# 연속 루프 실행 (백그라운드, 30초)
import time
print('백그라운드 루프 30초 실행...')
loop.start()
time.sleep(30)
loop.stop()
print('루프 종료')

UE의 위치가 변화할 때마다 Ray Tracing을 수행함을 확인할 수 있다.
로직 분석 및 공식 Sionna-rk의 한계점
1. 무선 환경의 단순성
현재 cir_zmq 플러그인은 norms/taps/tap_indicies 배열 하나를 받아서 gNB의 IQ 샘플 전체에 적용한다.
즉, RNTI/UE 필드가 프로토콜에 아예 없다(config_req, cir, nrx만 존재).
이는 rfsim 채널 에뮬레이터가 gNB RF 단에서 모든 UE에 동일한 채널을 컨볼루션하는 구조라,
ZMQ로는 UE 개별 채널 주입이 불가능하다.
즉, CIR 주입이 send_average_cir로 대표 UE 1개의 CIR만 gNB에 넣는다.
이는 cir_zmq 플러그인이 단일 전역 채널만 지원하기 때문이다.
이 과정에서 gNB는 하나의 합성 IQ 파형을 내보낸다.
3개 UE의 PDSCH가 같은 OFDM 심볼 안에 시간/주파수로 다중화돼 있는 단일 DL 신호이다.
각 UE는 자기 소켓으로 이 샘플 스트림을 읽어간다.
cir_zmq 플러그인은 우리가 주입한 CIR 1개(taps 배열 1개)를 이 샘플 스트림에 CUDA로 컨볼루션한다.
RNTI 구분이 없으니, 모든 UE 링크가 똑같은 채널 탭을 거친다.
(원래 OAI rfsim에서는 UE마다 channel_mode를 따로 가질 수 있다. 다만 sionna-rk는 이걸 쓰지 않고 전역 CIR 방식(chn_eu/cir_zmq)으로 대체했고, 공식 이미지는 per-UE telnet chanmod를 비활성화한 것.
gNB에 CIR이 하나만 들어간다는 의미는 무선 채널(페이딩/지연/경로손실)이 3 UE 모두 동일하다는 뜻이다.
예를 들어 UE3가 gNB에 가깝고 UE2가 멀어도, 실제 라디오에서는 그 공간적 차이가 반영이 되지 않는다.
즉, Sionna가 UE별로 다른 경로손실(78 vs 88dB)을 계산해도, gNB엔 대표 1개만 주입되니 라디오는 그 하나로 통일되는 것이다.
이로 인해 UE별 채널 차이(near/far, LOS/NLOS)를 구분할수 없고,
이로 인해 UE별 공간 분리 / 빔 포밍도 불가능하다.
즉, 모든 UE에 CIR이 동일하게 적용된다면 다음과 같은 일이 일어난다.
① ue3 LOS, ue1 NLOS인 상황에서 기대되는 동작은 ue3 MCS↑, ue1 MCS↓. 하지만 실제로는 둘 다 같은 MCS.
② ue2가 건물뒤로 이동할 경우, ue2만 BLER↑. 하지만 전체 BLER↑의 결과 초래.
③ ue1이 gNB에 접근 시 ue1의 SNR↑. 하지만 실제로는 변화가 없을 수 있음.
④ 핸드오버관점에서, ue3이 gNB-2에 가까워지면 handover가 이뤄져야 하지만 채널 차이가 없어서 트리거되지 않을 수 있음.
이를 우리가 확인한 UE별 KPI 차이와 구분해야 하는데, MCS/PRB/throughput이 UE별로 달랐던 건 트래픽量 차이(iperf3로 다르게 주입) + MAC 스케줄링 때문이지, 채널이 달라서가 아니다.
채널은 다 같고, 부하만 달랐던 것! (BLER 차이는 동일 채널 + 부하 조합에서 나온 것)
따라서 지금 핵심 시스템은 "채널은 모두 동일, 트래픽만 UE별로 다름" 상태이다.
UE별 위치 기반 채널 차이를 라디오에 반영하려면 → cir_zmq에 RNTI/소켓 인덱스 추가 + CUDA 에뮬레이터가 소켓별 taps 적용 + 프로토콜 확장 + gNB 재빌드를 수행해야 한다.
즉, 이를 해결하기 위해서는 C 플러그인 + CUDA 에뮬레이터를 RNTI별로 고쳐서 gNB 이미지를 재빌드해야 한다.
2. 핸드오버 관점
핸드오버의 경우 현재 구조에서는 부분적으로 가능하기는 하다.
OAI는 Xn 인터페이스와 핸드오버를 지원한다.
구조는 다음과 같다.
CN5G (AMF/SMF/UPF)
├── gNB-1 (rfsim) ─→ UE1, UE2
└── gNB-2 (rfsim) ─→ UE3
Xn Interface ←→ gNB-1
각 gNB는 별도 docker-compose-gnb.yaml으로 올리고, 각각 다른 gNB_ID, physCellId를 가져야 한다.
핸드오버 트리거는 UE의 측정 보고(A3 이벤트 등)로 AMF가 중재한다.
만약 Sionna와 연동 시 각 gNB마다 별도 cir_zmq 포트(5555, 5556 등)를 열고,
Sionna가 UE 위치 기반으로 어느 gNB와 더 가까운지 계산해서 해당 gNB에 CIR을 주입하는 방식으로 채널을 제어할 수 있다.
하지만 핸드오버 결정 자체(RRC Measurement Report → A3 이벤트 → handover command)는 여전히 OAI AMF가 RSRP/RSRQ 기반으로 한다.
Sionna는 채널(CIR)을 통해 간접적으로 BLER/MCS를 바꿔 RSRQ를 조종할 수 있다.
결국 핸드오버만 확인하려면 gNB 2개 이상(멀티셀) 구성 + 각 셀 별도 rfsim + Xn/N2 — 완전히 다른 토폴로지로 구성해야 한다.
현재 OAI gNB 파라미터오 Sionna간 연동되는 파라미터는 다음과 같다.
| Sionna RT | OAI gNB | 인터페이스 |
| CIR (taps,norm, delay) | 채널 에뮬레이터 IQ Convolution | ZMQ REQ/REP (포트 5555) |
| 경로 수, 지연 스프레드 | → BLER/MCS 변화 (간접) | ZMQ → rfsim → MAC |
| sigma_scaling | noise 레벨 | ZMQ cir 메시지 |
OAI gNB 설정(주파수, BWP, numerology 등)은 gnb.sa.band78.106prb.rfsim.yaml에 고정돼 있고,
Sionna는 이걸 CARRIER_FREQ_HZ, BANDWIDTH_HZ 등으로 맞춰서 계산한다.
자동 동기화는 없고 수동으로 일치시키는 구조인 것이다.
만약 FlexRIC xApp으로 E2 Interface를 통해 gNB MAC으로 제어 명령을 내릴 수도 있다.
FlexRIC xApp
├── MAC SM (현재): KPI 읽기만 (report)
├── RC SM: RRC 파라미터 제어 (측정 보고 임계값 등)
└── MAC Control: 스케줄러 파라미터 제어 (PRB 할당, MCS 강제 등)
현재는 MAC SM을 읽기(report)만 하는데, 쓰기(control)를 추가하면 다음과 같은 작업이 가능해진다.
- MAC에 특정 UE의 MCS 상한 강제 → 처리량 제어
- 핸드오버 트리거 임계값 조정
- RRC 측정 보고 주기 변경
따라서 현재 Closed Loop 구조는 다음과 같다: (CIR → RAN → KPI → 다시 CIR)
① Sionna RT (레이트레이싱)
↓ CIR (경로손실, 지연, taps)
② ZMQ REQ → gNB cir_zmq 플러그인
↓ IQ 샘플 컨볼루션 (CUDA)
③ rfsim ↔ UE (TCP)
↓ 실제 5G 전송 (PDSCH/PUSCH)
④ OAI MAC 스케줄러
↓ MCS 적응, HARQ, PRB 할당
⑤ FlexRIC MAC SM (E2 인터페이스)
↓ KPI (MCS/BLER/PRB/TBS) ZMQ PUB
⑥ KPICollector (Python, 포트 5557)
↓ dl_tput, pusch_snr, dl_bler
⑦ SionnaOAIIntegration._on_kpi_update()
↓ (현재: print만 함)
⑧ 다시 ① (UE 위치 업데이트 → 레이트레이싱)
이때 ⑦에서 KPI를 CIR 계산에 피드백하지 않는다.
BLER이 높으면 UE를 다른 위치로 이동시키거나, sigma_scaling을 조정하는 adaptive 로직이 _step_adaptive()에 뼈대만 있고 구현이 비어있다.
'RAN CN 테스트베드 구축 > Sionna RT-OAI (AI-RAN)' 카테고리의 다른 글
| Sionna-RK 패치를 통한 UE별 채널 기반 디지털트윈 AI-RAN 환경 구성 (0) | 2026.07.02 |
|---|---|
| Sionna-RK에서 UE별 위치 기반 CIR을 반영하기 위한 gNB 패치 과정 (0) | 2026.07.02 |
| WSL2 환경에서 Sionna RT 연결을 위한 OAI RF 네트워크 스택 설치하기 (0) | 2026.06.24 |
| Sionna Research Kit로 구현하는 디지털 트윈 기반 5G E2E Emulation (0) | 2026.06.16 |
| NVIDIA Sionna Research Kit: AI-Native RAN 구현을 위한 시뮬레이션과 프로토타이핑 (0) | 2026.06.16 |