PHY and link simulation
Use this path for link-level work, waveform algorithms, and experiments on individual physical-layer blocks. No MAC owner is required.
One granular PUSCH chain
This checked example calls the OAI ULSCH transmitter, gNB PUSCH receiver, and ULSCH decoder directly:
"""Run one granular PUSCH transmit/receive/decode chain."""
from oai_python.bindings.nr.phy import pusch
from oai_python.bindings.nr.phy.ulsch import decoding, transmit
def main() -> None:
payload = bytes((index * 29 + 3) & 0xFF for index in range(384))
config = pusch.PuschConfig()
with (
transmit.UlschTransmitter() as transmitter,
pusch.GnbPuschReceiver(timeout=10.0) as receiver,
):
waveform = transmitter.transmit(payload, config)
reception = receiver.receive(config, waveform.frequency_grid)
decoded = decoding.nr_ulsch_decoding(
reception.llrs,
transport_block_bits=len(payload) * 8,
base_graph=config.ldpc_base_graph,
modulation_order=config.modulation_order,
rb_size=config.rb_size,
nr_of_symbols=config.symbol_count,
dmrs_symbol_mask=config.dmrs_symbol_mask,
start_symbol_index=config.start_symbol,
dmrs_config_type=config.dmrs_config_type,
num_dmrs_cdm_groups_no_data=config.dmrs_cdm_groups_without_data,
unavailable_resources=reception.unavailable_resource_elements,
layers=1,
maximum_iterations=8,
redundancy_version=config.redundancy_version,
tbslbrm=config.tbslbrm,
mcs_index=config.mcs_index,
rnti=config.rnti,
frame=config.frame,
slot=config.slot,
)
assert waveform.matched_bits == reception.llr_count == 6_276
assert decoded[1] is True and bytes(decoded[0]) == payload
print(f"decoded PUSCH SDU: {len(payload)} bytes from {reception.llr_count} LLRs")
if __name__ == "__main__":
main()
Expected output:
decoded PUSCH SDU: 384 bytes from 6276 LLRs
A complete persistent link
NrPhyLink composes the reviewed SISO downlink and uplink chains. It accepts
payloads, advances slots, and returns copied results while retaining OAI state.
"""Run MAC-independent downlink and uplink through the complete PHY."""
from oai_python.bindings.nr.phy.link import NrPhyLink
def main() -> None:
downlink = bytes(range(64))
uplink = bytes((index * 29 + 3) & 0xFF for index in range(384))
with NrPhyLink(history_limit=2) as simulation:
downlink_slot = simulation.step(downlink_payload=downlink)
uplink_slot = simulation.step(uplink_payload=uplink)
simulation.run_slots(20)
assert len(downlink_slot.transmissions) == len(uplink_slot.transmissions) == 1
(received_downlink,) = downlink_slot.transmissions
(received_uplink,) = uplink_slot.transmissions
assert received_downlink.crc_ok
assert received_uplink.crc_ok
assert received_downlink.decoded_payload == downlink
assert received_uplink.decoded_payload == uplink
print(f"downlink PHY SDU: {received_downlink.decoded_payload!r}")
print(f"uplink PHY SDU: {len(received_uplink.decoded_payload)} bytes")
if __name__ == "__main__":
main()
Expected output:
downlink PHY SDU: b'\x00\x01\x02...'
uplink PHY SDU: 384 bytes
Insert any external channel
The wheel does not implement propagation models. It exposes a channel-agnostic hook over copied time-domain IQ samples:
tx = phy.transmit(...)
rx = my_channel(tx, context=context)
result = phy.receive(rx, ...)
At the owner level, pass downlink_channel= or uplink_channel= to
NrPhyLink or NrPhyStack. A channel callable receives a contiguous int16
IQ array plus ChannelContext with the direction, frame, slot, frequency,
sample rate, waveform kind, and antenna metadata already known by the binding.
Simple NumPy functions can ignore the context; mobility or ray-tracing models
can use it. See the Sionna RT example.
Replace a block
Call the transmitter and receiver as separate granular operations, run your Python function between them, and preserve the documented dtype, shape, contiguity, and ownership contract. The supported API lists the stable entry points; canonical stubs carry the exact signatures.