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.
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.
FastAPI, 9 endpoints
Ingest, build, calibrate, evaluate, sweep, attach. Each response has a rung: the source of its parameters.
Generic mode
Runs with no data on a fictional hex estate. Calibration to measurements, if run, happens locally and never enters the repository.
Deterministic, refusing
Same input: identical bytes. Missing tilt: refuses. Unknown cell: refuses. Relative comparisons only.
In one minute
- 01At each sample point, the twin calculates RSRP and SINR from TR 38.901 physics.
- 02The twin caches the geometry, so a tilt what-if recalculates one gain row.
- 03The FastAPI service refuses each request that its state cannot honour.
- 04Calibration fits 12 parameters to 10 report percentiles, and a gate decides if the fit is usable.
- 05The twin proposes nothing. Three layers filter its KPI table before a person sees it.
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).
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
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.
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.
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
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.
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
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:
- Add the power of each band.
- Subtract the losses.
- Take the
argmaxalong the cell axis. This gives the serving cell. - 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
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.
How to run the service
Endpoints move the service between three states.
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)
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.
Create the environment and install the requirements.
cd services/ocp-physics-digitalTwin python3 -m venv .venv && .venv/bin/pip install -r requirements.txtStart 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 8093Read 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)"}Read the parsed estate.
curl -s localhost:8093/v1/cells | head -c 300Infer: 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}]}'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.This is the product rung. The person who runs the twin supplies the inputs and mounts them read-only at /data (DATA_DIR). The fitted model goes to VAR_DIR. Nothing here goes into the repository.
Put the three files in the data directory, in the layout that the defaults expect. Other layouts also work if you send the paths in the request body.
/data/topology.csv # cell_id, cell_lat, cell_lon, cell_az_deg, cell_carrier_freq_mhz, hTx, beam_width_deg, technology /data/config.csv # cell_id, cell_el_deg /data/mr_report.xlsx # one row per device: rsrp_dbm, sinr_db, sample_countStart the service with Docker.
docker compose up -d --build # http://localhost:8093, ./data mounted read-onlyWithout Docker, use this command:
DATA_DIR=$(pwd)/data VAR_DIR=$(pwd)/var PYTHONPATH=$(pwd) \ .venv/bin/python -m uvicorn app.main:app --port 8093Ingest and build. The service reads the three files, calculates the estate and derives the targets.
curl -X POST localhost:8093/v1/load-local -H 'Content-Type: application/json' -d '{}' # {"loaded":{"cells":…,"nr_serving_cells":…,"grid_points":4000,"targets_loaded":true}, # "targets":{"rsrp":[p5,p25,p50,p75,p95],"sinr":[…]}, "rung":null}Calibrate with a 70/30 device holdout. The fit takes minutes. It returns the parameters, the quantiles and the gate.
curl -s -X POST localhost:8093/v1/train -H 'Content-Type: application/json' \ -d '{"holdout_frac":0.3,"seed":42}' # {"params":{…12…},"loss":…,"quantiles":{…},"gate":{"checks":{…},"hard_pass":true,…}, # "holdout":{"gate":{…"rung":"simulation, VALIDATED against held-out supplied data"}}, # "fit_seconds":…, "rung":"simulation, calibrated to supplied measurement data"}Infer: propose a tilt and read the device-mean view. This view is comparable to the report.
curl -X POST localhost:8093/v1/evaluate -H 'Content-Type: application/json' \ -d '{"cell_configs":[{"cell_id":"<id>","cell_el_deg":6.0}]}'Read the same proposal in the point-sample view. An optimiser must read this view.
curl -X POST localhost:8093/v1/evaluate -H 'Content-Type: application/json' \ -d '{"cell_configs":[{"cell_id":"<id>","cell_el_deg":6.0}],"aggregate":false}'Use the integration seam: send per-UE coordinates and get the per-UE radio values.
curl -s -X POST localhost:8093/v1/attach -H 'Content-Type: application/json' \ -d '{"points":[{"lon":…,"lat":…}],"cell_configs":[],"cells_are_exhaustive":false}'
To ingest by multipart upload instead of a mounted directory, send the three files:
curl -s -X POST localhost:8093/v1/upload \
-F topology=@topology.csv -F config=@config.csv -F mr_report=@mr_report.xlsxThe notebook runs the same stages as literal cells, without the HTTP layer, and writes out the maths before each step. services/ocp-physics-digitalTwin/notebooks/twin_walkthrough.ipynb makes an example estate and builds it. It runs the calibration search, and chains tilt and on/off configs through Estate.evaluate().
From
services/ocp-physics-digitalTwin/, install the notebook requirements..venv/bin/pip install -r requirements.txt -r notebooks/requirements-notebook.txtOpen the notebook.
.venv/bin/jupyter notebook notebooks/twin_walkthrough.ipynb
What each stage does
Five stages turn the input files into a KPI table for a decision hub.
Ingest
The twin reads three inputs. The topology and the config are mandatory.
| Input | Columns or content | Becomes |
|---|---|---|
topology.csv | cell_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.csv | cell_id, cell_el_deg | the baseline tilt of each cell (merged on cell_id) |
| measurement report | one row per device with average RSRP, average SINR, MR count | report-weighted p5/p25/p50/p75/p95 of RSRP and SINR: the calibration targets |
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.
Build the estate
Estate.build(topology, config, n_points=4000) evaluates the standard once per (cell, point) pair.
| Item | Value |
|---|---|
| Frame | local flat East/North, in metres |
| Sample points | 4,000, uniform by area in annuli from 100 m to 1,250 m around each site |
| Wrap-around tier | six translated copies of the estate at 60° steps; it interferes but never serves |
| Random draws | seeded 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)
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_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 θ
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 words | Role |
|---|---|---|
rsrp_dbm | the signal strength of this device, averaged over all its reports | target metric 1 |
sinr_db | the signal quality (signal against interference plus noise), with the same averaging | target metric 2 |
sample_count | the number of measurement reports from the device: how much it was on the network | weight |
Rename your headers to these, or change MR_COLUMNS in app/physics/calibrate.py.
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
The acceptance gate
| Gate check | Rule | Hard? |
|---|---|---|
| RSRP median | |Δ p50| ≤ 2 dB | yes |
| SINR cell edge | |Δ p5| ≤ 1.5 dB | yes |
| EIRP sanity | 25 ≤ P0 + 17 ≤ 45 dBm, every band | yes |
| RSRP tails, SINR p50/p95 | within 3 dB / 2 dB | no; 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:
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.
Infer
One function, Estate.evaluate(params, tilt_override, off_cells, aggregate), answers three questions.
| Endpoint | Question | What it does |
|---|---|---|
POST /v1/evaluate | What 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-sweep | What 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/attach | What 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. |
| View | Setting | What it gives | Use |
|---|---|---|---|
| device_mean | aggregate=true | the measurement report again: pseudo-device averaging and the ±(15/23) dB reporting clamp | the gate is judged on this view |
| point_sample | aggregate=false | the unclamped physical field | an 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.
Filter
The twin proposes nothing and actuates nothing. A decision hub filters its KPI table in three layers.
Structural
No estate or no model: 409. Unknown cell_id or null geometry: 422.
Rung + gate
A calibrated model enters a decision path only if its gate hard_pass is true.
Floors + budgets
An absolute floor and a regression budget on delta_vs_baseline must both hold.
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.
What a tilt and a guardrail do
Tilt is a knob; a guardrail is a filter. Try both.
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
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
What ships and what does not
The repository ships the generic rung; the calibrated rung needs a local fit.
| Item | In the repository | Only after a local fit |
|---|---|---|
| Rung | simulation, generic 3GPP defaults (uncalibrated) | simulation, calibrated to supplied measurement data |
| Data | no input data: a fictional hex estate of 18 cells | topology.csv, config.csv and the measurement report that you supply, mounted read-only at /data |
| Model | public 3GPP defaults from app/generic.py | the fitted parameters and the gate, in VAR_DIR |
| Code | services/ocp-physics-digitalTwin/ and notebooks/twin_walkthrough.ipynb, Apache-2.0 | the 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.
