telecogeco
Physics-based RF digital twin

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.

Model

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.

Interface

FastAPI, 9 endpoints

Ingest, build, calibrate, evaluate, sweep, attach. Every response carries a rung saying where its parameters came from.

Ships as

Generic mode

Runs with no data at all on a fictional hex estate. Calibration to real measurements happens locally and never enters the repository.

Contract

Deterministic, refusing

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

01 · What is happening

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.

h_BS = 30 mboresight, tilted τ° downsample point n (h_UT = 1.5 m)d2Dd3DθRSRP = P0 + G(θ,φ,τ) − PL − SF − O2Iserving = argmax over cellsSINR = S / (Σ interferers + noise)neighbour cell:interferes on same carrier
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 2,000+ points and 18–126 cells and you have a distribution — the thing calibration matches and guardrails read.
02 · How the twin works

Five stages, one cached matrix.

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

1 · Ingesttopology.csv + config.csv(+ measurement report)2 · Build estateEstate.build()geometry · PL · SF · gain cached3 · Calibratecalibrate.fit()12 params ← 10 quantiles4 · InferEstate.evaluate()tilt / on-off → KPIs5 · FilterΔ vs baseline + gaterefuse / pass to humangeneric mode skips 3: parameters are public 3GPP defaultschain another config
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. 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

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 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.

pl (C×N)sf_unit (C×N)theta, phi (C×N)gain0 (C×N)ext_* (6C×N)computed once — Estate.build()12 paramsfitted or defaultchanges every callevaluate()RSRP matrixargmax → servingsum → SINRpercentiles → KPIstilt_override → recompute one gain rowoff_cells → drop from argmax and Σ
C = cells, N = sample points. The teal blocks never change after build; the copper block is what calibration searches over; the blue block is what every API call actually runs.
03 · Triggering the flow in the actual app

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.

EMPTYno estate, no modelrung: nullESTATE LOADEDfields cached, targets heldrung: null (no model yet)CALIBRATED MODELfitted params + acceptance gaterung: calibrated to supplied measurement dataGENERIC MODELpublic 3GPP defaults, gate = nullrung: generic 3GPP defaults (uncalibrated)POST /v1/uploadPOST /v1/load-localPOST /v1/trainneeds targets · minutesPOST /v1/load-generic · or start with TWIN_GENERIC=1real inputs over generic → generic model dropped/v1/evaluate · /v1/tilt-sweep · /v1/attachneed a model of either kind (else 409)GET /v1/cells · GET /healthwork from here on
A rung is earned by a model: loading inputs gives none. /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.xlsx

The 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.ipynb
04 · Step by step

Ingest → build → calibrate → infer → filter.

1

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.

InputColumns / contentBecomes
topology.csvcell_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.csvcell_id, cell_el_degthe baseline tilt per 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. Any null in 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.
Locale-tolerant parsing. Measurement exports write counts ≥ 1,000 as comma-formatted text. The reader strips separators before coercing and logs anything still unparseable — silent weight loss in the targets is the failure class the guard above exists for.
2

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.

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
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 θ

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.

3

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 wordsRole
…Average RSRP SSB-All MR (dBm)how strong the signal was for this device, averaged over every report it senttarget metric 1
…Average SINR SSB-All MR (dB)how clean the signal was — signal against interference plus noise — same averagingtarget metric 2
…MR Count…how many measurement reports the device sent, i.e. how much it was on the networkweight

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.

measurement reportdeviceRSRPSINRMR countd1−96.43.11,240d2−108.9−4.7312d3−88.011.620,115… one row per device …→ weighted percentilestargets — ten numbersp5p25p50p75p95rsrp·····sinr·····this is all the fit ever seesthe same ten numbers, from the model side, come from evaluate(aggregate=True)→ grid points grouped into pseudo-devices of size agg, averaged in dB, clamped like the reportignored: identifiers, brand/model, RSRQ, CQI, throughput, failure counters
Illustrative rows. The fit matches distributions, never rows — which is also why the result is a calibrated model, not a coverage map.
Parsing is deliberate. Exports of this kind write counts ≥ 1,000 as comma-formatted text, so the weight column arrives as strings. A naive numeric coercion silently drops exactly the heaviest devices — a large share of the weight, with no error. The reader strips separators before coercing, counts what it could not parse, and logs it loudly.
Why a per-device average changes the physics. Each row is a device's average over its whole day, wherever it went — not a snapshot at one spot. Averaging squashes the spread: near-tower highs and cell-edge lows partly cancel. Raw point samples from the simulation would therefore have far too wide a distribution to compare against these targets. The twin groups its grid points into pseudo-devices of size 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.

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)
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
Out of sample. With 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.
4

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.

EndpointQuestionWhat it does
POST /v1/evaluateWhat 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-sweepWhat 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/attachWhat 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.
Two views, two questions. 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.
5

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:

Layer 1 · the twin refuses

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.

Layer 2 · the model qualifies

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.

Layer 3 · the hub guards

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.

Floors are coupled to a model version. A refit moves the baseline; an absolute floor tuned against the previous model then refuses every candidate, improvements included. Regression budgets survive a refit; absolute floors must be re-tuned with it. The evaluation is also tick-invariant — the same config gives the same KPIs at any time step — so per-tick scoping in a UI is cosmetic.
05 · Try the two ideas that matter

Tilt is a knob. A guardrail is a filter.

τ = 6.0°

The parabola is the vertical lobe; the shaded band is the depression-angle range of a 30 m mast's served area (≈ 3–10°). Move the tilt and watch which users sit near peak gain and which fall down the side — that is the whole mechanism behind a tilt what-if. Pattern from app/physics/antenna.py, computed here in JavaScript.

PASS — status: pending_approval (a person decides)

The same two-part check a decision hub applies before a proposal reaches a person: an absolute floor on where the network must stay, and a regression budget on how far it may move. Try a proposal that improves coverage but blows the SINR budget — it is refused, correctly.

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.