Search for “digital twin” and you will find vendor marketing. Cesium ion, Bentley iTwin, NVIDIA Omniverse, and a dozen platform plays all promise a living replica of your asset, city, or network for a licence fee that starts in the tens of thousands and climbs with every seat.
Almost all of the underlying capability is available as open source, and has been for years. This article builds a complete geospatial digital twin from open components only — acquisition, processing, delivery, and live data binding — with the actual commands, the actual formats, and a monthly hosting cost you can check against your own invoice.
It also says clearly where the open stack still falls short, because it does.
# What a Digital Twin Actually Is
A digital twin has three layers. If any one is missing, you have something else with a better name already.
Geometry is the measured shape of the thing: a mesh, a point cloud, a set of extruded footprints, a terrain surface. This is the layer everybody builds, because it is the layer that demos well.
Semantics is what each piece of geometry is. Not “a grey box at these coordinates” but “pump station 14, commissioned 2011, asset class 3, maintained by the north crew”. Without this layer you cannot query the twin, only look at it.
Live state is telemetry bound to the semantic objects: current flow rate, current occupancy, last inspection, open work orders. This is what makes it a twin rather than a survey.
Most projects deliver geometry, gesture at semantics, and never reach live state. The interesting engineering is in the last two layers, so this article spends proportionally more time there than the fly-through videos would suggest.
# Acquiring the Geometry
There is no single right sensor. There are four practical acquisition routes with sharply different cost, accuracy, and effort profiles.
# Uncrewed Aerial Vehicle Photogrammetry
For a site up to a few square kilometres, an Uncrewed Aerial Vehicle (UAV) survey processed with photogrammetry is the cheapest route to high-resolution geometry. OpenDroneMap (ODM) is the open source pipeline; WebODM is its browser interface and task manager.
The flight matters more than the software. Plan for 75% frontal and 65% side overlap, fly a double grid if you need building façades, and put down Ground Control Points if you need absolute accuracy better than the aircraft’s own positioning — five well-distributed markers, surveyed, will typically bring a site from metre-level to centimetre-level absolute accuracy.
# Process a survey with ODM in Docker, high point-cloud quality,
# outputting a georeferenced orthophoto, surface model, and point cloud
docker run --rm -v /data/site14:/datasets/code opendronemap/odm \
--project-path /datasets \
--dsm --dtm \
--pc-quality high \
--orthophoto-resolution 2 \
--gcp /datasets/code/gcp_list.txt \
--feature-quality high
The outputs are the useful part:
| Output | File | Use in the twin |
|---|---|---|
| Orthophoto | odm_orthophoto.tif |
basemap layer, visual inspection |
| Digital Surface Model | dsm.tif |
volumes, drainage, clash checks |
| Digital Terrain Model | dtm.tif |
ground surface with structures removed |
| Point cloud | odm_georeferenced_model.laz |
source for classification and meshing |
| Textured mesh | odm_textured_model_geo.obj |
source for 3D Tiles conversion |
Realistic numbers for a 40-hectare industrial site: 500 images at 2 cm Ground Sample Distance (GSD), roughly six hours of processing on a 32-core machine with 128 GB of memory. Photogrammetry is memory-hungry and parallelises well, which makes it a natural candidate for High Performance Computing (HPC), using the batch approach described in Spatial Processing on HPC.
# Mobile and Terrestrial LiDAR
Where you need accuracy under structures, in tunnels, or through vegetation, Light Detection and Ranging (LiDAR) beats photogrammetry outright. PDAL is the processing tool, and its pipelines are declarative JSON rather than imperative scripts, which makes them reviewable and reusable.
{
"pipeline": [
"raw_scan.laz",
{ "type": "filters.reprojection", "out_srs": "EPSG:7856" },
{ "type": "filters.outlier", "method": "statistical",
"mean_k": 12, "multiplier": 2.2 },
{ "type": "filters.smrf", "scalar": 1.2, "slope": 0.2,
"threshold": 0.45, "window": 16 },
{ "type": "filters.hag_nn" },
{ "type": "filters.range", "limits": "Classification![7:7]" },
{ "type": "writers.copc", "filename": "site14_classified.copc.laz" }
]
}
Read top to bottom: reproject to the local projected system, drop statistical outliers, classify ground with the Simple Morphological Filter, compute Height Above Ground (HAG) for every point, discard noise, and write a cloud-optimised archive. Registering multiple scan positions into one cloud uses filters.icp, which implements Iterative Closest Point alignment.
Point cloud handling at national scale is a large enough topic that it has its own article.
# Aerial and Satellite Imagery
For regional or city-scale twins, the imagery already exists. A SpatioTemporal Asset Catalog (STAC) search returns exactly the scenes you need without downloading a byte more.
from pystac_client import Client
catalog = Client.open("https://earth-search.aws.element84.com/v1")
search = catalog.search(
collections=["sentinel-2-l2a"],
bbox=[152.95, -27.55, 153.10, -27.42], # Brisbane inner
datetime="2024-06-01/2024-08-31",
query={"eo:cloud_cover": {"lt": 8}},
)
items = sorted(search.items(), key=lambda i: i.properties["eo:cloud_cover"])
print(f"{len(items)} usable scenes; clearest at {items[0].properties['eo:cloud_cover']}%")
Convert whatever you settle on to a Cloud-Optimised GeoTIFF (COG) so it can be served by range request rather than copied:
gdalwarp -t_srs EPSG:3857 -r cubic \
-of COG -co COMPRESS=DEFLATE -co BLOCKSIZE=512 -co OVERVIEWS=IGNORE_EXISTING \
input.tif site14_imagery_cog.tif
Thirty-centimetre commercial imagery is worth paying for when you are identifying individual assets — roof condition, vehicle counts, small plant. It is not worth paying for when you are establishing context around geometry you have already captured at 2 cm.
# Radiance Fields and Gaussian Splatting
Neural Radiance Field (NeRF) methods and 3D Gaussian Splatting produce startlingly good visual reconstructions from ordinary photographs. nerfstudio and gsplat are the accessible open implementations, both consuming a COLMAP pose graph derived from Structure from Motion (SfM).
The honest verdict for asset work: excellent perceptual fidelity, unreliable metric fidelity, and no semantics whatsoever. A splat will convince a steering committee. It will not tell you whether the pipe clears the beam. Treat these methods as visualisation for stakeholders, not as the survey of record — and expect that to change over the next few years as metric constraints are added to the training objective.
# Bringing In Models That Already Exist
Much of a twin’s semantic content is already sitting in a design file. IfcOpenShell extracts both geometry and property sets from Industry Foundation Classes (IFC) models:
import ifcopenshell
model = ifcopenshell.open("plant14.ifc")
for pump in model.by_type("IfcPump"):
psets = ifcopenshell.util.element.get_psets(pump)
print(pump.GlobalId, pump.Name, psets.get("Pset_PumpTypeCommon", {}))
At city scale, osm2pgsql plus OpenStreetMap building heights gives a free semantic base layer in hours, and cjio handles CityJSON where a municipality already publishes a formal city model. These are not as detailed as a survey, but they are the difference between a twin that can answer questions and one that cannot.
# Processing Into Delivery-Ready Formats
This is the step most projects botch. Capture produces working formats; the web needs streaming formats. The conversion is not optional and not automatic.
| Source | Delivery format | Tool |
|---|---|---|
| Point cloud | COPC | pdal translate ... --writers.copc |
| Point cloud (alternative) | Entwine Point Tile (EPT) | entwine build or untwine |
| Textured mesh | 3D Tiles 1.1 | py3dtiles, 3d-tiles-tools |
| Mesh compression | Draco-compressed glTF | gltf-transform optimize |
| Terrain | quantized-mesh | cesium-terrain-builder |
| Imagery | COG | gdalwarp -of COG |
| Vectors | PMTiles | tippecanoe |
| Whole-planet basemap | PMTiles | planetiler |
Two conversions carry most of the weight. Meshes become 3D Tiles:
# Textured OBJ from photogrammetry to a streaming 3D Tiles tileset
py3dtiles convert odm_textured_model_geo.obj \
--out ./tilesets/site14 --srs_in 7856 --srs_out 4978
# Then compress the glTF payloads — typically 60-80% smaller
gltf-transform optimize ./tilesets/site14/content.glb \
./tilesets/site14/content.glb --compress draco --texture-compress webp
And vectors become tiles, with the Graphics Language Transmission Format (glTF) handling 3D and the Mapbox Vector Tile (MVT) specification handling 2D:
tippecanoe -o assets.pmtiles -Z10 -z18 \
--drop-densest-as-needed --extend-zooms-if-still-dropping \
--layer=assets assets.geojson
# Tile and Data Delivery
You have four delivery patterns and should probably use three of them at once.
Dynamic raster tiling with TiTiler. TiTiler is a FastAPI application wrapping rio-tiler. Point it at a COG and it serves tiles, with band maths evaluated per request — no pre-rendering, no second copy of the data:
GET /cog/tiles/WebMercatorQuad/16/60293/37122@2x.png
?url=s3://twin-site14/imagery_cog.tif
&expression=(b4-b3)/(b4%2Bb3)
&rescale=-0.2,0.8
&colormap_name=rdylgn
That single request computes a vegetation index from the red and near-infrared bands and returns a styled tile. Changing the formula is a URL change, not a reprocessing job.
Vector tiles from PostGIS with Martin. When the data changes faster than you can re-tile, serve MVT directly from the database. Martin will publish any table automatically, but the useful pattern is a parameterised function:
CREATE OR REPLACE FUNCTION assets_by_status(z integer, x integer, y integer, query_params json)
RETURNS bytea AS $$
WITH bounds AS (SELECT ST_TileEnvelope(z, x, y) AS geom),
mvtgeom AS (
SELECT ST_AsMVTGeom(a.geom, bounds.geom) AS geom,
a.asset_id, a.asset_class, a.status, a.last_inspected
FROM assets a, bounds
WHERE ST_Intersects(a.geom, bounds.geom)
AND (query_params->>'status' IS NULL OR a.status = query_params->>'status')
)
SELECT ST_AsMVT(mvtgeom, 'assets') FROM mvtgeom;
$$ LANGUAGE sql STABLE PARALLEL SAFE;
pg_tileserv and tegola fill the same role; Martin is the fastest of the three in our experience and the simplest to configure.
Static PMTiles on object storage. For anything that changes weekly or slower, a single PMTiles archive on Cloudflare R2 removes the server entirely. R2 charges nothing for egress to Cloudflare’s edge, which turns tile bandwidth from a variable cost into a rounding error. This is covered in more depth in Ditching the Basemap.
Static 3D Tiles. A tileset.json and its content files on any web server is a complete 3D delivery stack. There is no 3D tile server to run. What you do need is correct headers — Access-Control-Allow-Origin for Cross-Origin Resource Sharing (CORS), and Content-Encoding: gzip where you have pre-compressed payloads. Getting these wrong produces a silently empty viewer, which is the single most common support question in every 3D web mapping community.
On the client, CesiumJS remains the only mature option for globe-scale terrain plus 3D Tiles. If your twin is site-scale and you want 2D and 3D in one canvas, MapLibre with deck.gl’s Tile3DLayer and PointCloudLayer (both built on loaders.gl) is lighter and composes better with existing 2D layers. For point-cloud review specifically, Potree and copc.js are purpose-built and better at it than either.
Esri’s Indexed 3D Scene Layer (I3S) format is the other standard in this space. It is well specified and poorly supported outside the Esri ecosystem; choose 3D Tiles unless an existing Esri deployment forces your hand.
# Binding Live Data to the Twin
Geometry plus semantics is a good survey. Adding live state is what earns the name.
# The Standards-Based Sensor Layer
The Open Geospatial Consortium (OGC) SensorThings API models exactly this problem, and FROST-Server is a solid open implementation. Its data model maps cleanly onto a twin: a Thing is an asset, a Datastream is one measured property of it, and Observations are the readings.
POST /FROST-Server/v1.1/Things
Content-Type: application/json
{
"name": "Pump Station 14",
"description": "Primary transfer pump, north network",
"properties": { "asset_id": "PS-014", "tileset_feature_id": 8823 },
"Locations": [{
"name": "PS-014 location",
"encodingType": "application/geo+json",
"location": { "type": "Point", "coordinates": [153.021, -27.470] }
}],
"Datastreams": [{
"name": "Flow rate",
"unitOfMeasurement": { "name": "litres per second", "symbol": "L/s" },
"observationType": "http://www.opengis.net/def/observationType/OGC-OM/2.0/OM_Measurement",
"ObservedProperty": { "name": "flow", "definition": "http://qudt.org/vocab/quantitykind/VolumeFlowRate" },
"Sensor": { "name": "Siemens MAG 5100W", "encodingType": "application/pdf", "metadata": "..." }
}]
}
The tileset_feature_id property is the join key. It is what lets a click on a pump in the 3D view resolve to that pump’s live readings, and getting this linkage designed early saves a painful retrofit later.
# The Streaming Path
Field devices rarely speak the SensorThings API. The practical chain is Message Queuing Telemetry Transport (MQTT) at the edge, a collector in the middle, and a time-series store behind it:
sensors ──MQTT──▶ Mosquitto/EMQX ──▶ Telegraf ──▶ TimescaleDB
│
└──▶ Redpanda ──▶ Apache Flink ──▶ alerts, geofences
-- TimescaleDB: a hypertable plus a rollup the dashboard can actually query
CREATE TABLE observations (
ts timestamptz NOT NULL,
asset_id text NOT NULL,
property text NOT NULL,
value double precision
);
SELECT create_hypertable('observations', 'ts');
CREATE MATERIALIZED VIEW observations_5m
WITH (timescaledb.continuous) AS
SELECT time_bucket('5 minutes', ts) AS bucket,
asset_id, property,
avg(value) AS mean, max(value) AS peak, count(*) AS n
FROM observations
GROUP BY bucket, asset_id, property;
For anything stateful across the stream — geofence entry and exit, dwell time, rate-of-change anomalies — Flink is the right tool, and that pattern is developed properly in Real-Time Spatial Streaming.
# Getting It to the Browser
The last hop is the one people over-engineer. For a handful of concurrent viewers, PostgreSQL’s pg_notify into a FastAPI endpoint using Server-Sent Events (SSE) is about thirty lines and needs no extra infrastructure:
from fastapi import FastAPI
from sse_starlette.sse import EventSourceResponse
import asyncpg, json
app = FastAPI()
@app.get("/stream/assets")
async def stream_assets():
conn = await asyncpg.connect(dsn=DSN)
queue = asyncio.Queue()
await conn.add_listener("asset_update", lambda *a: queue.put_nowait(a[-1]))
async def events():
while True:
payload = await queue.get()
yield {"event": "asset_update", "data": payload}
return EventSourceResponse(events())
Above a few hundred concurrent clients, put Centrifugo in front of it rather than scaling that endpoint. On the CesiumJS side, update entity properties rather than rebuilding primitives — replacing a tileset on every message will drop frames and is the usual cause of “the twin is laggy” complaints.
Node-RED deserves a mention as the honest glue layer. Every real deployment has two or three protocol adapters that exist because one vendor’s controller does something idiosyncratic, and Node-RED is a better home for those than your main codebase. Grafana’s Geomap panel, similarly, gives you an operations view for free while you build the bespoke one.
# Reference Architecture and What It Costs
A complete site-scale twin, self-hosted:
| Component | Choice | Monthly |
|---|---|---|
| Database | PostgreSQL + PostGIS + TimescaleDB, 8 vCPU / 32 GB VPS | $48 |
| Tile and API services | TiTiler + Martin + FastAPI, same VPS | $0 |
| Broker | Mosquitto, same VPS | $0 |
| Object storage | Cloudflare R2, 400 GB (3D Tiles, COG, PMTiles) | $6 |
| Egress | R2 to Cloudflare edge | $0 |
| Basemap | Protomaps PMTiles archive on R2 | $2 |
| Total | ~$56 |
That figure holds to roughly a thousand daily users and a few hundred sensors. The components that split out first, in order: the database (move TimescaleDB off the shared VPS once observation volume passes a few million rows per day), then Flink if you add stateful stream processing, then TiTiler behind a CDN if imagery traffic grows.
Compare against a commercial platform licence for the same scope and the gap is three to four orders of magnitude. The trade is real, though, and it is engineering time — roughly two to six weeks to stand this up competently the first time, plus ongoing operational ownership.
# What Still Does Not Work Well
Four things, stated plainly, because the marketing material will not.
Semantic linkage between IFC and 3D Tiles is fragile. There is no clean, standard way to carry an IFC property set through a mesh conversion into a 3D Tiles feature. Everyone maintains a side table keyed on a feature identifier, which breaks whenever geometry is reprocessed.
Versioning a twin over time is unsolved. Every serious asset owner wants to ask “what did this look like in March, and what changed?” No open format handles temporal versioning of 3D geometry well. Current practice is to keep dated tilesets and accept the storage cost.
Simulation coupling is bespoke. Connecting a twin to a hydraulic, thermal, or traffic model means writing an adapter. There is no open standard doing useful work here, and the commercial platforms are not much better despite claiming otherwise.
Radiance-field methods are not yet survey-grade. Covered above, and worth repeating because the visual quality is persuasive enough to override engineering judgement if you let it.
None of these are reasons to buy a platform — the commercial products share most of the same gaps behind a nicer interface. They are reasons to scope a first twin narrowly, prove the three layers work end to end on one site, and expand from something that functions.
Related reading: LiDAR and Point Clouds at Scale: PDAL, COPC, and Web Delivery · Real-Time Spatial Streaming: Kafka, Flink, and Live Geofencing · The Future of Geointelligence: AI, Foundation Models, and Spatial Analytics