A radio network you can ask what if.
telecogecoTwin predicts how signal strength and quality are distributed across a cluster of cell sites — from 3GPP propagation physics, not from guesswork — and then tells you what changes if you tilt an antenna or put a cell to sleep. This page explains what happens inside, and how to drive it end to end through the service.
TR 38.901 UMa + §7.3
Standard urban-macro path loss, line-of-sight statistics, shadow fading and the sector antenna pattern — ~1,100 lines of NumPy/SciPy.
FastAPI, 9 endpoints
Ingest, build, calibrate, evaluate, sweep, attach. Every response carries a rung saying where its parameters came from.
Generic mode
Runs with no data at all on a fictional hex estate. Calibration to real measurements happens locally and never enters the repository.
Deterministic, refusing
Same input → identical bytes. Missing tilt → refuses. Unknown cell → refuses. Relative comparisons only.
Physics places, measurements calibrate.
A cellular estate is a set of cells: each an antenna at a known position and height, pointed along an azimuth, tilted down by some degrees, transmitting on some carrier. Users spread across the area experience a signal strength (RSRP) from whichever cell serves them and a signal quality (SINR) that depends on how loudly every other cell on the same carrier is interfering.
The twin computes those two quantities at thousands of sample points from first principles — distance, angle, frequency, the antenna's radiation pattern — and summarises them as the KPIs an operator's decision system already speaks: cell-edge SINR, coverage rate, outage rate, and where the traffic lands.
What the physics cannot know is a dozen global unknowns: how much power each band really radiates, what fraction of users are indoors, how thick the walls are, how loaded the neighbours are. Calibration fits those unknowns so the model's predicted RSRP/SINR distributions match the distributions in a real measurement report — and an acceptance gate decides whether the fit is good enough to be trusted in a decision path.
Why not just learn from the measurements directly? Because a model trained only on measurements has never seen an antenna tilt change and is blind to it by construction. This engine models tilt through the antenna pattern, so it responds to it — smoothly, with a footprint-versus-quality trade-off readable on both axes. That is the control surface an optimiser needs.
Five stages, one cached matrix.
Click a stage to see what it does and which part of the 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. Owned by app/main.py (/v1/upload, /v1/load-local, /v1/load-generic) and calibrate.targets_from_mr_report.The one idea that makes it fast
Everything that does not depend on the fitted parameters — projected coordinates, distances and angles from every cell to every sample point, line-of-sight state, path loss, the shadow-fading draw, the antenna gain at the baseline tilt — is computed once in Estate.build() and kept as (cells × points) NumPy matrices.
After that, a full evaluation is a few matrix operations: add the per-band power, subtract the losses, take an argmax down the cell axis for serving, sum the rest for interference. A calibration search runs thousands of those; a tilt what-if runs one, recomputing a single gain row. Both 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 that expression are the fitted parameters: p0_low/mid/hi, sigma_extra, p_in, ltw_low/hi, load, agg, bias_mid/hi, w_tier. Everything else is geometry from the standard.
The service has three states. Endpoints move it between them.
The twin is a FastAPI service (submodules/physics-twin/app/main.py). It holds one estate and at most one model in memory, persists both to VAR_DIR, and refuses any request its state cannot honour.
/v1/train is unavailable on the generic model (no targets); loading non-generic inputs over a generic model drops the generic model so default parameters can never sit on real geometry.The path the public repository runs on. A plain clone, no files to mount.
# from submodules/physics-twin/
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
TWIN_GENERIC=1 VAR_DIR=/tmp/physics-twin-generic PYTHONPATH=$(pwd) \
.venv/bin/python -m uvicorn app.main:app --port 8093
# 1 · health: which model, which rung
curl -s localhost:8093/health
# {"status":"ok","estate_loaded":true,"calibrated":false,"generic":true,
# "model":"generic","rung":"simulation, generic 3GPP defaults (uncalibrated)"}
# 2 · the parsed estate
curl -s localhost:8093/v1/cells | head -c 300
# 3 · infer: switch a cell off, read Δ vs 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}]}'
# 4 · infer: 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}'# or load a different fictional estate on demand (service started WITHOUT TWIN_GENERIC)
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.The product rung. Inputs are supplied by whoever runs the twin and mounted read-only at /data (DATA_DIR); the fitted model lands in VAR_DIR. Nothing here is ever committed.
# data directory layout the defaults expect (any layout works — pass paths in the body)
/data/derived/topology.csv # cell_id, cell_lat, cell_lon, cell_az_deg, cell_carrier_freq_mhz, hTx, beam_width_deg, technology
/data/derived/config.csv # cell_id, cell_el_deg
/data/mr-report.xlsx # one row per device: Average RSRP/SINR SSB-All MR, MR Count
docker compose up -d --build # or: DATA_DIR=... VAR_DIR=$(pwd)/var PYTHONPATH=$(pwd) uvicorn app.main:app --port 8093
# 1 · ingest + build: reads the three files, precomputes the estate, derives the targets
curl -s -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}
# 2 · calibrate with a 70/30 device holdout — minutes; returns params, quantiles, 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"}
# 3 · infer: propose a tilt, device-mean view (report-comparable)
curl -s -X POST localhost:8093/v1/evaluate -H 'Content-Type: application/json' \
-d '{"cell_configs":[{"cell_id":"<id>","cell_el_deg":8.0}]}'
# 3b · the same, point-sample view (what an optimiser must read)
curl -s -X POST localhost:8093/v1/evaluate -H 'Content-Type: application/json' \
-d '{"cell_configs":[{"cell_id":"<id>","cell_el_deg":8.0}],"aggregate":false}'
# 4 · integration seam: per-UE coordinates in, per-UE radio out
curl -s -X POST localhost:8093/v1/attach -H 'Content-Type: application/json' \
-d '{"points":[{"lon":…,"lat":…}],"cell_configs":[],"cells_are_exhaustive":false}'# alternative ingest: multipart upload instead of a mounted directory
curl -s -X POST localhost:8093/v1/upload \
-F topology=@topology.csv -F config=@config.csv -F mr_report=@mr-report.xlsxThe same stages without the HTTP layer, as literal cells — with the maths written out before each step. submodules/physics-twin/notebooks/telecogecoTwin_walkthrough.ipynb generates an example estate, builds it, runs the calibration search, and chains tilt and on/off configs through Estate.evaluate().
# from submodules/physics-twin/
.venv/bin/pip install -r requirements.txt -r notebooks/requirements-notebook.txt
.venv/bin/jupyter lab notebooks/telecogecoTwin_walkthrough.ipynbIngest → build → calibrate → infer → filter.
Ingest
Three inputs, two of them mandatory. The topology is static planning data; the config carries the one thing a what-if changes, electrical tilt; the measurement report exists only in calibrated mode and is reduced to ten numbers the moment it is read.
| Input | Columns / 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 per-cell parameter table estate.cells |
config.csv | cell_id, cell_el_deg | the baseline tilt per 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 |
cell_id, cell_lat, cell_lon, cell_az_deg, cell_carrier_freq_mhz, hTx, cell_el_deg is a 422, not a default. A fabricated tilt would produce a confident model of an antenna that does not exist.Build the estate
Estate.build(topology, config, n_points=4000). Coordinates are projected to a local flat East/North frame; sample points are drawn uniformly by area in annuli 100 m → 1,250 m around each site (a traffic proxy, not a uniform rectangle — tower bases and far corners are where users are not); and for every (cell, point) pair the standard is evaluated once.
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
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 θ
Six translated copies of the estate at 60° steps form a wrap-around tier that interferes but never serves — the surrounding city, not ours to tune. Every random draw is seeded from blake2s(name), so the environment a cell sees is a pure function of its id.
Calibrate (train)
Fit twelve bounded global parameters so the model's ten predicted quantiles match the ten targets. The loss is a weighted squared error with declared extra weight on the 5th percentiles, because that is the tail the guardrails read. It is evaluated through an argmax and percentile, so it is piecewise-constant in the parameters: no gradients, hence differential evolution (population search inside the bounds) followed by a bounded Nelder–Mead polish.
What the fit is matching: the calibration target
The measurement report is a per-subscriber export from the operator's analytics tooling: one row per device, describing that device's whole day on the network. It is not a drive test and carries no location. Of its many columns the twin reads exactly three, found by substring so the vendor's exact header wording does not matter:
| Column (matched by substring) | In plain words | Role |
|---|---|---|
…Average RSRP SSB-All MR (dBm) | how strong the signal was for this device, averaged over every report it sent | target metric 1 |
…Average SINR SSB-All MR (dB) | how clean the signal was — signal against interference plus noise — same averaging | target metric 2 |
…MR Count… | how many measurement reports the device sent, i.e. how much it was on the network | weight |
The twin never learns from individual rows. It collapses the whole file into ten numbers — the 5th, 25th, 50th, 75th and 95th percentiles of RSRP, and the same five for SINR — and those ten numbers are the targets. Two rules govern the collapse:
- Weighted by report count. A device that sent twenty thousand reports counts twenty thousand times more than one that sent three. The percentiles describe what the network looked like across the traffic, not across the list of handsets.
- Sentinels dropped. Values at or below −150 are placeholders for "no measurement" and are excluded before the percentiles are taken.
The targets are the answer key for the fit: which values of the twelve unknowns make the simulated estate produce these same ten percentiles? The acceptance gate re-reads the same ten numbers afterwards.
agg and averages them the same way before taking percentiles — the measurement operator, aggregate=True. Without it the model is predicting the wrong quantity, however good the physics.The search
With the targets fixed, calibration is the minimisation below.
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)
| 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 |
holdout_frac, devices are split by seed; the fit sees only the training marginals and the gate is recomputed against the unseen devices' marginals. Matching the distribution you fit to is necessary, not sufficient — ten constraints cannot pin down twelve parameters, and the EIRP window exists because a fit once matched every quantile with a physically impossible power.Infer
Three questions, one function. Estate.evaluate(params, tilt_override, off_cells, aggregate) runs the forward model and summarises it; the endpoints differ only in what they override and how they scope the answer.
| Endpoint | Question | What it does |
|---|---|---|
POST /v1/evaluate | What are the KPIs for this configuration, and how do they differ from now? | Evaluates baseline and proposal under 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 cell serves, because the estate-wide p5 lives in other cells' areas and understates the response. |
POST /v1/attach | What is the radio at these coordinates? | Fresh, unclamped per-point RSRP/SINR/serving for caller-supplied lon/lat — the integration seam a platform's UE frame posts each tick. |
aggregate=true (device_mean) reproduces the measurement report — pseudo-device averaging and the ±(15/23) dB reporting clamp — and is what the gate is judged on. aggregate=false (point_sample) is the unclamped physical field. Device averaging blunts a single cell's tilt response several-fold, so an optimiser must read point_sample; /v1/tilt-sweep defaults to it.Filter
The twin proposes nothing and actuates nothing. Its job ends at a KPI table; the filter is what a decision hub does with that table before a human sees a recommendation. Three layers, in order:
Structural
No estate → 409. No model → 409. Unknown cell_id → 422, the whole request, never silently ignored. Null geometry → 422. Nothing downstream ever runs on a partial answer.
Rung + gate
Every response carries its rung. A generic-rung number supports relative comparison only. A calibrated model is wired into a decision path only if its gate hard_pass is true — softs are shown, not enforced.
Floors + budgets
Two-part guardrails on delta_vs_baseline: an absolute floor (e.g. coverage_rate ≥ 0.99) and a regression budget (e.g. Δ sinr_p5 ≥ −0.5 dB). Both must hold; a pass goes to pending_approval for a person.
Tilt is a knob. A guardrail is a filter.
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: submodules/physics-twin/ · service README for the full endpoint contract · notebooks/telecogecoTwin_walkthrough.ipynb for the same stages as runnable cells.