Skip to content

UE/gNB, multiple UEs, and schedulers

Choose between a one-UE real-PHY stack and a faster MAC/FAPI simulation with up to 16 UEs. Both live in one Python application.

One UE through real PHY

"""Attach one UE and exchange SDUs through the complete PHY stack mode."""

from oai_python.bindings.nr.phy.stack import NrPhyStack


def main() -> None:
    with NrPhyStack(history_limit=64) as simulation:
        simulation.start()
        attached = simulation.run_until_connected(140)
        assert attached.terminal_state == "connected"

        simulation.enqueue_downlink_sdu(0, b"hello from the gNB")
        simulation.enqueue_uplink_sdu(0, b"hello from the UE")
        for _ in range(500):
            simulation.step()
            result = simulation.result()
            if result.downlink_sdus and result.uplink_sdus:
                break
        else:
            raise AssertionError("full-PHY stack traffic did not complete")

    print(f"connected RNTI: {result.connected_rntis[0]:#06x}")
    print(f"downlink SDU: {result.downlink_sdus[-1]!r}")
    print(f"uplink SDU: {result.uplink_sdus[-1]!r}")


if __name__ == "__main__":
    main()

Successful output reports the connected RNTI and both decoded SDUs. MAC grants drive the supported waveform paths; only receiver results drive feedback.

Multiple UEs through MAC/FAPI

"""Run four UEs through the abstract MAC/FAPI link mode."""

from oai_python.bindings.nr.mac.link import UeGnbLink


def main() -> None:
    with UeGnbLink(ue_count=4, history_limit=64) as simulation:
        simulation.start()
        attached = simulation.run_until_connected(1_000)
        assert attached.terminal_state == "connected"

        for ue_id in range(4):
            simulation.enqueue_downlink_sdu(ue_id, f"downlink-{ue_id}".encode())
            simulation.enqueue_uplink_sdu(ue_id, f"uplink-{ue_id}".encode())

        for _ in range(1_000):
            simulation.step()
            result = simulation.result()
            if len(result.downlink_sdus) == len(result.uplink_sdus) == 4:
                break
        else:
            raise AssertionError("MAC/FAPI traffic did not complete")

    for delivery in result.deliveries:
        print(
            f"UE {delivery.ue_id} RNTI {delivery.rnti:#06x} "
            f"{delivery.direction} SDU: {delivery.payload!r}"
        )


if __name__ == "__main__":
    main()

Successful output contains one attributed uplink and downlink delivery for each UE. This mode abstracts PHY outcomes as copied FAPI messages, so use it for scheduler and protocol experiments rather than waveform or BLER claims.

Prototype a scheduler

The ordinary pinned-OAI scheduler remains the default. Passing a Python policy opts into a PREPARE → decision → COMMIT seam: OAI retains protocol state, validation, HARQ bookkeeping, and grant construction while Python chooses from the copied observation.

"""Run the high-level UE/gNB MAC workflow with an opt-in Python scheduler."""

from __future__ import annotations

from oai_python.bindings.nr.mac.gnb.scheduler_sdk import (
    RoundRobinPolicy,
    SchedulerDecision,
    SchedulerObservation,
)
from oai_python.bindings.nr.mac.link import UeGnbLink


class MyScheduler:
    """Small policy scaffold: replace the delegate with your own algorithm."""

    def __init__(self) -> None:
        self._baseline = RoundRobinPolicy()
        self.slots_seen = 0

    def decide(self, observation: SchedulerObservation) -> SchedulerDecision:
        self.slots_seen += 1
        return self._baseline.decide(observation)


def main() -> None:
    policy = MyScheduler()
    with UeGnbLink(ue_count=4, scheduler=policy) as link:
        link.start()
        attached = link.run_until_connected(1_000)
        assert attached.terminal_state == "connected"

        for ue_id in range(4):
            link.enqueue_downlink_sdu(ue_id, f"downlink-{ue_id}".encode())
            link.enqueue_uplink_sdu(ue_id, f"uplink-{ue_id}".encode())

        for _ in range(1_000):
            link.step()
            result = link.result()
            if len(result.downlink_sdus) == len(result.uplink_sdus) == 4:
                break
        assert len(result.downlink_sdus) == len(result.uplink_sdus) == 4
        assert policy.slots_seen == result.slots

    print(f"scheduled {len(result.connected_ues)} UEs for {policy.slots_seen} slots")
    for delivery in result.deliveries:
        print(f"UE {delivery.ue_id} {delivery.direction} SDU: {delivery.payload!r}")


if __name__ == "__main__":
    main()

Expected output starts with scheduled 4 UEs and then lists attributed uplink/downlink deliveries. The same policy interface can be passed to NrPhyStack, connecting the decision to supported real-PHY execution.

Several independent cells

"""Run independent MAC/FAPI and full-PHY cells in one Python process."""

from oai_python.bindings.nr.multicell import CellConfiguration, IndependentMultiCell


def main() -> None:
    cells = (
        CellConfiguration(101, mode="mac-fapi", ue_count=2, history_limit=64),
        CellConfiguration(202, mode="full-phy", history_limit=64),
    )
    with IndependentMultiCell(cells) as network:
        network.start()
        attached = network.run_until_connected(512)
        assert all(cell.result.terminal_state == "connected" for cell in attached)

        for cell in cells:
            for ue_id in range(cell.ue_count):
                network.enqueue_downlink_sdu(cell.cell_id, ue_id, b"downlink")
                network.enqueue_uplink_sdu(cell.cell_id, ue_id, b"uplink")

        for _ in range(512):
            network.step()
            current = network.result()
            if all(
                len(cell.result.deliveries) == 2 * network.configuration(cell.cell_id).ue_count
                for cell in current
            ):
                break
        else:
            raise AssertionError("independent-cell traffic did not complete")

    for cell in current:
        for delivery in cell.result.deliveries:
            print(
                f"cell {cell.cell_id}, UE {delivery.ue_id}, "
                f"{delivery.direction} SDU: {delivery.payload!r}"
            )


if __name__ == "__main__":
    main()

Each result carries a cell ID, UE ID, RNTI, direction, and payload. Cells are isolated and stepped sequentially; this release does not model shared RF, inter-cell interference, handover, or coordinated scheduling.