Physics-based RF digital twin

A radio network you can ask what if.

telecogecoTwin predicts the signal strength and quality of a cell-site cluster from 3GPP propagation physics. It shows what changes if you tilt an antenna or put a cell to sleep.

Model

TR 38.901 UMa + §7.3

Urban-macro path loss, line of sight, shadow fading and the sector antenna pattern: ~1,100 lines of NumPy/SciPy.

Interface

FastAPI, 9 endpoints

Ingest, build, calibrate, evaluate, sweep, attach. Each response has a rung: the source of its parameters.

Ships as

Generic mode

Runs with no data on a fictional hex estate. Calibration to measurements, if run, happens locally and never enters the repository.

Contract

Deterministic, refusing

Same input: identical bytes. Missing tilt: refuses. Unknown cell: refuses. Relative comparisons only.

All whitepapersWhitepaper28 August 2026About 4 minutes to skim, 17 with the detail
01 · The model

What the twin calculates

At each sample point, the twin calculates signal strength and quality from first principles.

A cell is an antenna with a known position, height, azimuth, down-tilt and carrier. The serving cell sets the signal strength (RSRP) of a user. Interference from the other cells on the same carrier sets the signal quality (SINR).

ONE SAMPLE POINT

A cell site: a 30 m mast with a beam tilted tau degrees down. The beam reaches a sample point n at 1.5 m on the ground. A second mast, the neighbour, interferes on the same carrier. The quantities at the point are RSRP = P0 + G(theta, phi, tau) minus PL minus SF minus O2I, and serving = argmax over cells. SINR = S over the sum of interferers plus noise. The geometry shows d2D along the ground, d3D along the slant and the angle theta at the antenna.

  • serving cell and its boresight
  • neighbour cell, interferes on the same carrier
  • sample point n, where the twin computes
At every sample point the twin computes the received power from every cell, chooses the serving cell, and turns the rest into interference. Do that for 4,000 points and 18 cells (more with /v1/load-generic) and you have a distribution, the thing calibration matches and guardrails read.simulation, generic 3GPP defaults (uncalibrated)
Read the detail

Calibration fits the unknowns that the physics cannot know. Then an acceptance gate decides if a decision path can trust the fit.

The twin calculates RSRP and SINR at thousands of sample points. It uses first principles: distance, angle, frequency and the radiation pattern of the antenna. It gives the results as the KPIs that the decision system of an operator already uses:

  • cell-edge SINR
  • coverage rate
  • outage rate
  • where the traffic lands.

The physics cannot know a dozen global unknowns:

  • the real radiated power of each band
  • the fraction of users that are indoors
  • the thickness of the walls
  • the load of the neighbour cells.

Calibration fits these unknowns, so that the predicted RSRP and SINR distributions match the distributions in a measurement report.

Why not learn from the measurements only?

A model that learns only from measurements never sees a change of antenna tilt. Thus it is blind to tilt by construction. This engine models tilt through the antenna pattern, so it responds to tilt smoothly. The trade-off between footprint and quality is clear on both axes. An optimiser needs this control surface.

02 · The engine

How the twin stays fast

The twin calculates the geometry once, so each what-if is a few matrix operations.

Click a stage to see what it does and which code owns it.

PIPELINE · FIVE STAGES
Ingest. Read topology.csv + config.csv (and, in calibrated mode, the measurement report). Merge on cell_id; refuse on any null in the seven required columns. Reduce the report to ten report-weighted quantiles. app/main.py owns this stage (/v1/upload, /v1/load-local, /v1/load-generic), with calibrate.targets_from_mr_report.

Pipeline of five stages in a chain: 1 Ingest (topology.csv + config.csv, plus the measurement report), 2 Build estate (Estate.build(), geometry, path loss, shadow fading and gain cached), 3 Calibrate (calibrate.fit(), 12 params from 10 quantiles), 4 Infer (Estate.evaluate(), tilt or on-off to KPIs), 5 Filter (delta vs baseline plus gate, refuse or pass to a human). A dashed arc from Build estate to Infer marks that generic mode skips stage 3; a dashed arc from Filter back to Infer marks chaining another config. Stage 1, Ingest, is selected.

  • request so far; the chosen stage is filled
  • stage not yet reached; dashed: an optional path
Ingest, build estate, calibrate, infer, filter. Generic mode skips calibration; the filter can chain another configuration back into infer.simulation, generic 3GPP defaults (uncalibrated)

Estate.build() keeps everything that does not depend on the fitted parameters as (cells × points) NumPy matrices. A calibration search runs thousands of evaluations on them; a tilt what-if runs one.

CACHED MATRICES

Estate.build() computes five cached matrices one time: pl, sf_unit, theta and phi, gain0 (each C by N) and ext_* (6C by N). They feed evaluate() together with the 12 fitted or default parameters, which change every call. evaluate() forms the RSRP matrix, takes the argmax for serving, sums to SINR and reduces percentiles to KPIs. tilt_override recomputes one gain row; off_cells drops a cell from the argmax and the sum.

  • computed once at build
  • 12 fitted parameters
  • evaluate(), runs every call
C = cells, N = sample points. The teal blocks never change after build. Calibration searches over the copper block. Every API call runs the cobalt block.simulation, generic 3GPP defaults (uncalibrated)
Read the detail

Estate.build() calculates these quantities one time:

  • the projected coordinates
  • the distances and angles from each cell to each sample point
  • the line-of-sight state
  • the path loss
  • the draw of shadow fading
  • the antenna gain at the baseline tilt.

After the build, one full evaluation is a few matrix operations:

  1. Add the power of each band.
  2. Subtract the losses.
  3. Take the argmax along the cell axis. This gives the serving cell.
  4. Add the remaining cells. This gives the interference.

A tilt what-if calculates a single gain row again. Calibration and what-ifs stay interactive because the expensive part never repeats.

The forward model, in one line

RSRP[c,n] = P0[band(c)] + G(θ[c,n], φ[c,n], tilt_c) − PL[c,n] − z[c,n]·(σ_NLOS + σ_extra) − indoor[n]·(L_tw[band(c)] + 4·z_O2I[n])

serving[n] = argmax_c ( RSRP[c,n] + bias[band(c)] ) over cells that are on and allowed to serve

SINR[n] = S / ( load·Σ_{same carrier, c≠s} 10^(RSRP/10) + load·w_tier·Σ_tier 10^(RSRP/10) + 10^(N0/10) )

Twelve names in this expression are the fitted parameters: p0_low/mid/hi, sigma_extra, p_in, ltw_low/hi, load, agg, bias_mid/hi, w_tier. All other terms are geometry from the standard.

03 · The service

How to run the service

Endpoints move the service between three states.

SERVICE STATES · FASTAPI, ONE ESTATE, AT MOST ONE MODEL

State machine of the service. EMPTY has no estate, no model and rung null. POST /v1/upload or POST /v1/load-local moves it to ESTATE LOADED (fields cached, targets held, rung null, no model yet). POST /v1/train moves ESTATE LOADED to CALIBRATED MODEL (fitted params plus acceptance gate, rung: calibrated to supplied measurement data). The train call needs targets and takes minutes. EMPTY moves to GENERIC MODEL (public 3GPP defaults, gate null, rung: generic 3GPP defaults, uncalibrated) by POST /v1/load-generic or by starting with TWIN_GENERIC=1. Real inputs loaded over a generic model drop it and return to ESTATE LOADED. /v1/evaluate, /v1/tilt-sweep and /v1/attach need a model of either kind, else 409. GET /v1/cells and GET /health work from ESTATE LOADED on.

  • request path: upload, then train
  • calibrated model (dashed: only after a local fit)
  • generic model (the repository ships it)
  • no model yet (rung: null)
Only a model earns a rung: loaded inputs give none. /v1/train is unavailable on the generic model (no targets). Non-generic inputs loaded over a generic model drop the generic model. Thus default parameters can never sit on real geometry.the rung is the model's, shown on each node; the service itself has none
Read the detail

The service is services/ocp-physics-digitalTwin/app/main.py. It holds one estate and at most one model in memory, and saves both to VAR_DIR. It refuses each request that its state cannot honour.

The public repository runs this path, with no files to mount.

  1. Create the environment and install the requirements.

    cd services/ocp-physics-digitalTwin
    python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
  2. Start the service in generic mode.

    TWIN_GENERIC=1 VAR_DIR=/tmp/physics-twin-generic PYTHONPATH=$(pwd) \
      .venv/bin/python -m uvicorn app.main:app --port 8093
  3. Read the health: the model and the rung.

    curl -s localhost:8093/health
    #  {"status":"ok","estate_loaded":true,"calibrated":false,"generic":true,
    #   "model":"generic","rung":"simulation, generic 3GPP defaults (uncalibrated)"}
  4. Read the parsed estate.

    curl -s localhost:8093/v1/cells | head -c 300
  5. Infer: switch a cell off and read the delta against the baseline.

    curl -s -X POST localhost:8093/v1/evaluate -H 'Content-Type: application/json' \
      -d '{"cell_configs":[{"cell_id":"g-s1-a-n78","on_off":false}]}'
  6. Infer: read the tilt control surface for one cell (point-sample mode by default).

    curl -s -X POST localhost:8093/v1/tilt-sweep -H 'Content-Type: application/json' \
      -d '{"cell_id":"g-s1-a-n3","delta_range_deg":6,"step_deg":2}'

Start the service without TWIN_GENERIC to load a different fictional estate on demand:

curl -s -X POST localhost:8093/v1/load-generic -H 'Content-Type: application/json' \
  -d '{"n_sites":7,"isd_m":500,"bands_mhz":[700,1800,3500],"h_tx_m":30,"tilt_deg":6,"beam_width_deg":65}'
# unknown keys: 422. Never overwrites persisted calibrated state unless "overwrite_calibrated": true.
04 · Step by step

What each stage does

Five stages turn the input files into a KPI table for a decision hub.

1

Ingest

The twin reads three inputs. The topology and the config are mandatory.

InputColumns or contentBecomes
topology.csvcell_id, cell_lat, cell_lon, cell_az_deg, cell_carrier_freq_mhz, hTx; optional beam_width_deg (65), technology (NR)the table of parameters for each cell, estate.cells
config.csvcell_id, cell_el_degthe baseline tilt of each cell (merged on cell_id)
measurement reportone row per device with average RSRP, average SINR, MR countreport-weighted p5/p25/p50/p75/p95 of RSRP and SINR: the calibration targets
Refuse to run on defaults. A null in a required column gives a 422. The twin never uses a default.
Read the detail

The seven required columns are cell_id, cell_lat, cell_lon, cell_az_deg, cell_carrier_freq_mhz, hTx, cell_el_deg. A made-up tilt gives a confident model of an antenna that does not exist.

The topology is static planning data. The config holds the one value that a what-if changes: the electrical tilt. The measurement report exists only in calibrated mode. The twin reduces it to ten numbers when it reads it.

Locale-tolerant parsing

Some measurement exports write counts ≥ 1,000 as comma-formatted text. Thus the weight column arrives as strings. A simple numeric conversion silently drops the heaviest devices, which carry a large share of the weight. It gives no error.

The reader removes the separators before the conversion. It counts the values that it cannot parse, and writes them to the log clearly. Silent loss of weight in the targets is the failure class that the refusal of defaults is there to stop.

2

Build the estate

Estate.build(topology, config, n_points=4000) evaluates the standard once per (cell, point) pair.

ItemValue
Framelocal flat East/North, in metres
Sample points4,000, uniform by area in annuli from 100 m to 1,250 m around each site
Wrap-around tiersix translated copies of the estate at 60° steps; it interferes but never serves
Random drawsseeded from blake2s(name)
Read the detail

The sample points are a traffic proxy. Users are not at tower bases or in far corners, so a uniform rectangle is a bad proxy.

The wrap-around tier is the surrounding city. It interferes but never serves, and we do not tune it.

Each random draw has its seed from blake2s(name). Thus the environment that a cell sees is a pure function of its id.

Path loss, line of sight and shadow fading (TR 38.901 UMa)

P_LOS = 18/d2D + e^(−d2D/63)·(1 − 18/d2D) (1 if d2D ≤ 18 m)

PL_LOS = 28 + 22·log10(d3D) + 20·log10(fc) d2D ≤ d_BP
= 28 + 40·log10(d3D) + 20·log10(fc) − 9·log10(d_BP² + (h_BS−h_UT)²)

PL_NLOS = max(PL_LOS, 13.54 + 39.08·log10(d3D) + 20·log10(fc) − 0.6·(h_UT−1.5))

SF ~ N(0, σ), σ = 4 dB LOS / 6 dB NLOS

Sector antenna pattern (TR 38.901 §7.3)

A_V(θ) = −min(12·((θ−τ)/θ3dB)², 30)
A_H(φ) = −min(12·(φ/φ3dB)², 30)
G = G_max − min(−(A_V + A_H), 30)
G_max = 10·log10(41000 / (θ3dB·φ3dB))

θ3dB = 6.5°, φ3dB = beam_width_deg
τ = tilt: slides the vertical lobe along θ
3

Calibrate (train)

The fit sets twelve bounded global parameters, so that ten predicted quantiles match ten targets.

The calibration target

The targets come from a measurement report: one row per device, exactly three columns read by name.

Column (header name)In plain wordsRole
rsrp_dbmthe signal strength of this device, averaged over all its reportstarget metric 1
sinr_dbthe signal quality (signal against interference plus noise), with the same averagingtarget metric 2
sample_countthe number of measurement reports from the device: how much it was on the networkweight

Rename your headers to these, or change MR_COLUMNS in app/physics/calibrate.py.

THE CALIBRATION TARGET

The measurement report has one row per device: device, RSRP, SINR, MR count. Weighted percentiles collapse the rows into the ten targets: p5, p25, p50, p75 and p95 of RSRP and of SINR.

  • per-device rows, read once
  • the ten targets
  • weighted percentiles, the measurement path
Illustrative rows. The fit matches distributions and never rows. For this reason the result is a calibrated model and cannot serve as a coverage map. The ten targets are all the fit ever sees. On the model side, evaluate(aggregate=True) gives the same ten numbers. It groups grid points into pseudo-devices of size agg, averages them in dB and clamps them like the report. The fit ignores every other column of an export.illustrative rows, not measured data

The acceptance gate

Gate checkRuleHard?
RSRP median|Δ p50| ≤ 2 dByes
SINR cell edge|Δ p5| ≤ 1.5 dByes
EIRP sanity25 ≤ P0 + 17 ≤ 45 dBm, every bandyes
RSRP tails, SINR p50/p95within 3 dB / 2 dBno; reported, never hidden
Read the detail

The search

The loss is a weighted squared error. The 5th percentiles get more weight, because the guardrails read that tail. The twin calculates the loss through an argmax and a percentile. Thus the loss is piecewise-constant in the parameters and has no gradients.

For this reason the search uses differential evolution (a population search inside the bounds). A bounded Nelder-Mead polish follows. With the targets fixed, calibration is this minimisation:

L(θ) = Σ_q w_R[q]·(Q_R^pred[q] − Q_R^target[q])² + 2·Σ_q w_S[q]·(Q_S^pred[q] − Q_S^target[q])²
q ∈ {5,25,50,75,95}, w_R = (2,1,1,1,1), w_S = (3,1,1,1,1)

differential_evolution(loss, BOUNDS, seed=7, maxiter=140, popsize=14) : minimize(Nelder-Mead, bounded)

The measurement report

A measurement report is a per-device export from the analytics tools of a network. Each row is one device and describes the whole day of that device on the network. The report is not a drive test, and it has no location.

The twin never learns from individual rows. It reduces the whole file to ten numbers: the 5th, 25th, 50th, 75th and 95th percentiles of RSRP, and the same five for SINR. These ten numbers are the targets. Two rules apply:

  • Weight by report count. A device that sent twenty thousand reports counts twenty thousand times more than a device that sent three. Thus the percentiles describe the network across the traffic, and not across the list of handsets.
  • Drop the sentinels. Values at or below −150 are placeholders for "no measurement". The twin removes them before it calculates the percentiles.

The targets are the answer key for the fit: which values of the twelve unknowns make the simulated estate give the same ten percentiles? After the fit, the acceptance gate reads the same ten numbers again.

Why a per-device average changes the physics

Each row is the average of one device over its whole day, at all the places it went. It is not a snapshot at one spot. The average makes the spread smaller, because the high values near the tower and the low values at the cell edge partly cancel.

Thus the raw point samples of the simulation have a distribution that is much too wide to compare with these targets. The twin groups its grid points into pseudo-devices of size agg. It averages them the same way before it calculates the percentiles. This step is the measurement operator, aggregate=True. Without it, the model predicts the wrong quantity, however good the physics is.

Out of sample

With holdout_frac, the twin splits the devices by seed. The fit sees only the training marginals. Then the twin calculates the gate again against the marginals of the unseen devices.

A match to the fitted distribution is necessary but not sufficient. Ten constraints cannot fix twelve parameters. The EIRP window exists because quantile matching alone can accept a power that is physically impossible.

4

Infer

One function, Estate.evaluate(params, tilt_override, off_cells, aggregate), answers three questions.

EndpointQuestionWhat it does
POST /v1/evaluateWhat are the KPIs for this configuration, and how do they differ from now?Evaluates the baseline and the proposal with the same params and draws. Returns guardrail_kpis, baseline_kpis, delta_vs_baseline, serving_share. Unknown cell: 422.
POST /v1/tilt-sweepWhat does tilt do to this cell?Evaluates ±N° in steps. Adds cell-scoped KPIs over the points that the cell serves. The estate-wide p5 is in the areas of other cells, so it understates the response.
POST /v1/attachWhat is the radio at these coordinates?Gives fresh, unclamped RSRP, SINR and serving cell for each lon/lat point from the caller. This is the integration seam: the UE frame of a platform posts to it each tick.
ViewSettingWhat it givesUse
device_meanaggregate=truethe measurement report again: pseudo-device averaging and the ±(15/23) dB reporting clampthe gate is judged on this view
point_sampleaggregate=falsethe unclamped physical fieldan optimiser must read this view; /v1/tilt-sweep uses it by default
Read the detail

The endpoints run the forward model and summarise the result. They differ only in what they override and how they scope the answer.

Device averaging makes the tilt response of a single cell several times weaker. For this reason an optimiser must read point_sample.

5

Filter

The twin proposes nothing and actuates nothing. A decision hub filters its KPI table in three layers.

Layer 1 · the twin refuses

Structural

No estate or no model: 409. Unknown cell_id or null geometry: 422.

Layer 2 · the model qualifies

Rung + gate

A calibrated model enters a decision path only if its gate hard_pass is true.

Layer 3 · the hub guards

Floors + budgets

An absolute floor and a regression budget on delta_vs_baseline must both hold.

Caution. Absolute floors are coupled to a model version. Tune them again after each refit.
Read the detail

The filter is what a decision hub does with the KPI table before a person sees a recommendation.

Layer 1: the twin refuses

No estate: 409. No model: 409. Unknown cell_id: 422. Null geometry: 422. The twin refuses the whole request for an unknown cell_id. It never ignores the cell silently. Nothing downstream ever runs on a partial answer.

Layer 2: the model qualifies

Each response has its rung. A generic-rung number supports relative comparison only. The gate shows the soft checks but does not enforce them.

Layer 3: the hub guards

The guardrails on delta_vs_baseline have two parts:

  • an absolute floor, for example coverage_rate ≥ 0.99
  • a regression budget, for example Δ sinr_p5 ≥ −0.5 dB.

Both must hold. A pass goes to pending_approval for a person.

Floors and refits

A refit moves the baseline. Then an absolute floor that was tuned against the previous model refuses every candidate, improvements included. Regression budgets survive a refit, but you must tune absolute floors again with each refit.

The evaluation is also tick-invariant: the same config gives the same KPIs at any time step. Thus per-tick scoping in a UI is cosmetic.

05 · Try it

What a tilt and a guardrail do

Tilt is a knob; a guardrail is a filter. Try both.

TILT WHAT-IF · §7.3 VERTICAL PATTERN
6.0°
tilt τ
simulation, generic 3GPP defaults
19.6dBi
user at θ = 5°
simulation, generic 3GPP defaults
-0.3dB
from peak gain
derived from the two above

A beam view and a chart. The beam view draws the mast top, the horizon, the served area between 3 and 10 degrees below it, the user at 5 degrees, and the half-power beam pointing 6.0 degrees down, with a dashed outline at the 6 degree reference. The chart plots antenna gain in dBi against depression angle from minus 10 to 40 degrees. The live curve peaks at the chosen tilt of 6.0 degrees; a dashed grey curve marks the 6 degree reference; a shaded band marks the served area. A user at 5 degrees sees 19.6 dBi, -0.3 dB from the peak.

  • pattern and beam at the chosen tilt
  • reference 6° tilt
  • served area, θ 3 to 10°, and the user at 5°
  • served-area bin whose gain moved on the last change
The parabola is the vertical lobe. The shaded band is the depression-angle range of a 30 m mast's served area, about 3 to 10 degrees. Move the tilt: the beam turns, and you see which users sit near peak gain and which fall down the side. That is the whole mechanism behind a tilt what-if. This page computes the pattern of app/physics/antenna.py in JavaScript.simulation, generic 3GPP defaults (uncalibrated)
GUARDRAIL FILTER · delta_vs_baseline

proposal: delta_vs_baseline from an /v1/evaluate call

thresholds: the hub's floor and budgets

-0.30dB
Δ sinr_p5
illustrative input
0.995
coverage_rate
illustrative input
0.004
Δ outage_rate
illustrative input
coverage_rate 0.995 (scale 0.95 to 1.00)90%
absolute floor 0.99080%
PASS: status: pending_approval (a person decides)

Guardrail calculator. A proposal's delta_vs_baseline from an /v1/evaluate call: delta sinr_p5 -0.3 dB, coverage_rate 0.995, delta outage_rate 0.004. Thresholds: sinr budget at least -0.5 dB, coverage floor at least 0.99, outage budget at most 0.01. Verdict: pass, status pending_approval, a person decides.

  • pass
  • pending_approval: a person decides
  • refused
  • the absolute floor
A decision hub runs this two-part check before a proposal reaches a person. An absolute floor says where the network must stay. A regression budget says how far it may move. Try a proposal that improves coverage but blows the SINR budget: the filter refuses it, correctly.illustrative inputs; the thresholds are examples, not a recommendation
06 · Scope

What ships and what does not

The repository ships the generic rung; the calibrated rung needs a local fit.

ItemIn the repositoryOnly after a local fit
Rungsimulation, generic 3GPP defaults (uncalibrated)simulation, calibrated to supplied measurement data
Datano input data: a fictional hex estate of 18 cellstopology.csv, config.csv and the measurement report that you supply, mounted read-only at /data
Modelpublic 3GPP defaults from app/generic.pythe fitted parameters and the gate, in VAR_DIR
Codeservices/ocp-physics-digitalTwin/ and notebooks/twin_walkthrough.ipynb, Apache-2.0the same service: POST /v1/train runs the fit

telecogecoTwin · Apache-2.0 · every number produced by the shipped repository carries the rung simulation, generic 3GPP defaults (uncalibrated). The calibrated rung simulation, calibrated to supplied measurement data exists only after a local fit against locally supplied data, which never enters this repository. Never quote either rung as "measured".

Source: services/ocp-physics-digitalTwin/ · service README for the full endpoint contract · notebooks/twin_walkthrough.ipynb for the same stages as runnable cells.