A Django-based digital twin platform for Dutch geospatial urban data β water supply, urban heat, energy, housing, and nature. PostgreSQL/PostGIS handles spatial storage, Mapbox GL JS renders the map, and a separate TiTiler (FastAPI) service serves raster (COG) tiles.
The project follows the DPSIR framework (Driver β Pressure β State β Impact β Response) to model causal relationships between urban systems β see core/DAG.dot.
- Domain: Urban digital twin β water supply, urban heat, housing, energy, nature
- Default CRS: EPSG:28992 (Amersfoort / RD New), set via
COORDINATE_SYSTEMin.env - Stack: Django 5 Β· PostgreSQL/PostGIS Β· GDAL/GeoPandas Β· Mapbox GL JS Β· TiTiler (FastAPI) Β· HTMX + Tailwind
- Python 3.12 or newer, on Windows, macOS or Linux
- GDAL/GEOS: on Windows they come with the
gdalwheel thatuv syncinstalls; on macOS and Linux install them from the system package manager (see below) - PostgreSQL 16 or 17 with the
postgisandpostgis_rasterextensions - A Mapbox access token
Optional, only needed for specific External Data Import sources:
SENTINEL_CLIENT_ID/SENTINEL_CLIENT_SECRETβ Copernicus Data Space (openEO), for Sentinel-2 importsKNMI_API_KEYβ KNMI Data Platform, for weather/WBGT importsOVERPASS_URLβ another Overpass API instance for OpenStreetMap imports (default: the public overpass-api.de)GEE_PROJECT_IDβ a Google Cloud project registered for Earth Engine, for GEE imports and the LST tool
git clone https://github.com/ivan-cardenas/CrossTwin.git
cd CrossTwin
# Recommended: uv (https://docs.astral.sh/uv/)
uv sync # creates .venv and installs pyproject.toml + uv.lockRun every command below through uv run (e.g. uv run python manage.py migrate): it uses the project's .venv without activating it, from any folder of the project, and first syncs it with uv.lock, so after a git pull that changed dependencies you never run against a stale environment. Activating .venv (.venv\Scripts\Activate, source .venv/bin/activate) and typing plain python still works, as long as you run uv sync after pulling.
Dependencies are declared in pyproject.toml and pinned in uv.lock. uv sync uses the Python version uv finds; pick another with uv sync --python 3.13. Add --no-dev to skip the Jupyter kernel (ipykernel).
π§° GDAL, and installing without uv
GDAL. GeoDjango needs the GDAL and GEOS C libraries:
-
Windows: nothing to install.
uv syncinstalls Christoph Gohlke's GDAL wheel for your Python version (3.12β3.15) and CPU (x64 or ARM64), which bundles the GDAL, GEOS and PROJ DLLs;DigitalTwin/settings.pyfinds them in the venv'sosgeofolder. PyPI has no Windows wheel for GDAL, sopyproject.tomlpoints uv straight at the GitHub release files ([tool.uv.sources]). 32-bit Python and Python 3.16+ have no wheel there and fall back to building GDAL from source. -
macOS:
brew install gdal -
Linux (Debian/Ubuntu):
sudo apt install gdal-bin libgdal-devOn macOS and Linux the
gdalPython package is not installed: Django loads the system library, and rasterio, pyogrio and pyproj bundle their own GDAL/PROJ in their wheels.
Without uv. Without uv, python -m venv .venv and pip install -r requirements.txt still work (activate .venv and drop the uv run prefix from the commands), with requirements.txt (uv export --format requirements-txt --no-hashes of uv.lock; its markers pick the GDAL wheel per Python version and CPU on Windows).
CrossTwin needs PostgreSQL (16 or 17) with the PostGIS extensions postgis and postgis_raster. Nothing in the code uses postgis_topology, postgis_sfcgal or pgRouting; tools/check_postgres.py only lists them as optional.
πͺ Windows
- Install PostgreSQL with the EDB installer from postgresql.org/download/windows, or with winget:
Note the password you set for the
winget install --id PostgreSQL.PostgreSQL.17 -e
postgressuperuser and keep the default port5432. - PostGIS is not on winget. At the end of the installer, launch Stack Builder (or later from the Start menu), pick your PostgreSQL server, and install Spatial Extensions β PostGIS 3.x Bundle. Alternatively, use the OSGeo installer.
- Make sure the service is running:
net start postgresql-x64-17(or Services βpostgresql-x64-17β Start). psqllives inC:\Program Files\PostgreSQL\17\bin; add it toPATHor use SQL Shell (psql) / pgAdmin from the Start menu.
π macOS
- Postgres.app (bundles PostGIS), or Homebrew:
brew install postgresql@17 postgis brew services start postgresql@17
π§ Linux (Debian/Ubuntu)
sudo apt install postgresql postgresql-17-postgis-3 # match your PostgreSQL major version
sudo systemctl enable --now postgresql
sudo -u postgres psql # opens psql as the superuserWindows: double-click setup_db.bat in the project folder. It does steps 2 and 3 for you (and optionally step 5), so you don't have to open psql or pgAdmin. It asks:
| Prompt | Default | Notes |
|---|---|---|
| PostgreSQL host / port | localhost / 5432 |
|
| Admin (superuser) name + password | postgres |
The password you set in the PostgreSQL installer; only used during setup, never saved |
| Database name | crosstwin |
Lowercase letters, digits, _ |
| Database user name + password | crosstwin_user |
Password typed twice (hidden); goes into .env |
| Privileges | 1 Full |
1 Full (owner, can migrate) Β· 2 Read/write Β· 3 Read-only |
| Allow creating databases | Yes for Full | Needed to run the test suite |
| Mapbox token | (blank) | Optional; add it to .env later if you don't have one yet |
It then creates the user and database, creates the postgis and postgis_raster extensions, grants the privileges, and writes .env from .env_template with a generated SECRET_KEY. With Full privileges and an existing .venv (run uv sync first), it ends by offering step 5: an empty database (migrate) or the example data (tools/example_db.py load, then migrate).
βοΈ More about setup_db.bat
- Where is psql? The script looks on
PATH, then underC:\Program Files\PostgreSQL\<version>\bin(newest version first), and asks for the path if neither works. - Privilege levels:
- 1 Full: the user owns the database and the
publicschema, so it can run migrations, imports andtools/example_db.py load. Use this for local development. - 2 Read/write: can read and change data but not the schema; migrations must be run by an admin.
- 3 Read-only:
SELECTonly, e.g. for a viewer or dashboard account.
- 1 Full: the user owns the database and the
- Running it again is safe: an existing user or database is reused (you're asked first), and an existing
.envis copied to.env.backup-<timestamp>before it is replaced. - Special characters in passwords (
& | ^ % ! ' " \ $) are fine: the.batfile only launches the PowerShell script embedded in the same file.
π§ Manual: create the database and .env by hand (macOS / Linux, or without the script)
Database. Run this in psql (or pgAdmin's Query Tool) as the postgres superuser β CREATE EXTENSION for PostGIS needs superuser rights. Pick your own user name and password; they go into .env in the next step.
CREATE DATABASE crosstwin;
CREATE USER your_username WITH PASSWORD 'your_password';
ALTER DATABASE crosstwin OWNER TO your_username;
GRANT ALL PRIVILEGES ON DATABASE crosstwin TO your_username;
\c crosstwin
GRANT ALL ON SCHEMA public TO your_username;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO your_username;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO your_username;
CREATE EXTENSION postgis;
CREATE EXTENSION postgis_raster;.env file. Copy the template to .env in the project root (next to manage.py) and fill in your values. .env is git-ignored, so your keys never get committed.
cp .env_template .env # Windows: copy .env_template .envThe template contains:
SECRET_KEY=your-secret-key-here
DEBUG=True
DATABASE_NAME=crosstwin
DATABASE_USER=your_db_user
DATABASE_PASSWORD=your_db_password
DATABASE_HOST=localhost
DATABASE_PORT=5432
MAPBOX_ACCESS_TOKEN=your_mapbox_token
TITILER_BASE_URL=http://localhost:8001
COORDINATE_SYSTEM=28992 # EPSG code for Dutch RD New
# Optional β External Data Import sources
GEE_PROJECT_ID=your-google-cloud-project-id
SENTINEL_CLIENT_ID=your-cdse-client-id
SENTINEL_CLIENT_SECRET=your-cdse-client-secret
KNMI_API_KEY=your-knmi-api-key
# OVERPASS_URL=https://overpass-api.de/api/interpreter # OpenStreetMap imports
# Optional β performance measurement (see docs/PERFORMANCE.md Β§0)
# QUERY_STATS=True # per-request query count + DB time (default: same as DEBUG)
# QUERY_STATS_SLOW_MS=500 # log requests slower than this as warnings
# SQL_LOG=False # print every SQL statement (needs DEBUG=True)π Where each .env value comes from (Mapbox token, API keys, β¦)
| Variable | Required | How to get it |
|---|---|---|
SECRET_KEY |
yes | Any long random string. Generate one: uv run python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" |
DEBUG |
yes | True for local development, False in production |
DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD |
yes | The database, user and password you created in step 2 |
DATABASE_HOST, DATABASE_PORT |
yes | localhost / 5432 for a local install (the port you chose in the PostgreSQL installer) |
MAPBOX_ACCESS_TOKEN |
yes | Sign up at mapbox.com, then Account β Tokens. The Default public token (pk.β¦) works; the free tier is enough for development |
TITILER_BASE_URL |
yes | http://localhost:8001 β the port you start uv run uvicorn tiler:app on |
COORDINATE_SYSTEM |
yes | 28992 (Amersfoort / RD New) for Dutch data. Set it before the first migrate: geometry columns are created in this CRS |
GEE_PROJECT_ID |
for Earth Engine imports and the LST tool | Create a Google Cloud project and register it for Earth Engine at code.earthengine.google.com/register (free for non-commercial/research use); use the project's ID. The first Earth Engine call opens a browser for Google sign-in and caches the token per machine (or run earthengine authenticate once) |
SENTINEL_CLIENT_ID, SENTINEL_CLIENT_SECRET |
for Sentinel-2 imports | Register at dataspace.copernicus.eu, open the Sentinel Hub dashboard β User settings β OAuth clients β Create. The secret is shown only once |
KNMI_API_KEY |
for KNMI weather/WBGT/radar imports | Register at the KNMI Developer Portal and request an Open Data API key (KNMI also publishes a time-limited anonymous key there for testing) |
OVERPASS_URL |
no | Only to use another Overpass instance for OpenStreetMap imports; leave out to use the public overpass-api.de |
QUERY_STATS, QUERY_STATS_SLOW_MS, SQL_LOG |
no | Performance measurement flags, see docs/PERFORMANCE.md Β§0 |
uv run python tools/check_postgres.pyIt connects with the DATABASE_* values from .env, checks that PostGIS is installed and enabled, and tells you what to fix if not (the common cases are listed under Running the Project below).
Option A: empty database. Create the tables, then import your own data through the importer (/importer/):
uv run python manage.py migrateOption B: example data. The repository ships an example database in example/crosstwin_example_data.zip. Load it into the empty database of step 2 (instead of Option A's migrate):
uv run python tools/example_db.py load # = example/crosstwin_example_data.zip
uv run python manage.py migrate # applies migrations newer than the package, if anyIt restores the tables and data, copies the raster COG files into cogs/ and points each raster row at them. The database must be empty and already have the postgis/postgis_raster extensions. If a load fails halfway, drop and re-create the database before trying again.
Then create a superuser (users are not part of the example data). The superuser can log in at /admin/ and is staff, so it can also edit layers on the map and run the LST/SVF tools.
uv run python manage.py createsuperuserCrossTwin runs as two servers, in separate terminals:
uv run python manage.py runserver 8000
uv run uvicorn tiler:app --port 8001 --reloadOr run start.bat on Windows, which starts both. Visit http://localhost:8000.
start.bat first runs uv run python tools/check_postgres.py (you can run it on any OS). It connects with the DATABASE_* settings of .env and checks for the postgis and postgis_raster extensions. If something is missing it says what and how to fix it, and start.bat stops:
- PostgreSQL not installed:
winget install --id PostgreSQL.PostgreSQL.17 -eon Windows (Homebrew / apt on macOS / Linux). - PostgreSQL installed but not running: the
net start postgresql-x64-<version>command for the service it finds. - PostGIS not installed: PostGIS is not on winget; install the PostGIS bundle with Stack Builder (included with the PostgreSQL installer) or the OSGeo installer.
- Extensions not created, wrong credentials, missing database: the SQL or
.envsetting to fix.
Click a topic to expand it.
π Project Structure
| π Folder | π Contains | Description |
|---|---|---|
DigitalTwin/ |
settings.py, urls.py, test_runner.py |
Django project config, env loading, caches, custom PostGIS test runner |
core/ |
utils.py, signals.py, cache.py, middleware.py, rasterOperations.py, DAG.dot, static/js/ |
Model registry, layer-cache versions, query-stats middleware, rasterβCOG export pipeline, DPSIR causal graph, shared frontend JS |
mainMap/ |
views.py, urls.py, charts.py, styles/ |
Interactive map view, layer catalog + GeoJSON API, map editing (code in core/editing.py, layers switched on in the Map settings panel, form rules in core/editable_layers.toml), Map settings panel (settings_views.py: editable layers, style overrides on top of styles/, styles/overrides.py), population dock charts; styles/ holds every map style: vector layer styles (layerStyles.py), soil map colours (soilStyles.py), land-cover legend (landCoverStyles.py), raster colormaps (rasterStyles.py) |
importer/ |
views.py, views_external.py, external_catalog.py, external_data.py, batching.py |
File-upload import + external catalog import (PDOK, CBS, BIS Nederland, OpenStreetMap, Sentinel-2, GEE, KNMI, RIVM, Klimaateffectatlas); bulk writes and deferred cascades |
docs/ |
PERFORMANCE.md, β¦ |
Performance review and implementation status |
administrative/ |
models.py, population.py, signals.py |
Province > City > District > Neighborhood hierarchy, population projection |
watersupply/ |
models.py, calculations.py, views.py, signals.py |
Water infrastructure, indicator dashboard (incl. SCS infiltration with rain-event and groundwater-season what-ifs) |
urban_heat/ |
models.py, calculations.py, views.py |
Thermal comfort rasters (UTCI, PET, MRT, LST, SVF), NBS |
housing/ |
models.py, calculations.py, views.py |
Supply/demand, mortgages, rentals, affordability |
builtup/ |
models.py, calculations.py, views.py |
Streets, facilities, buildings, properties, zoning (HILUCS land use), indicator dashboard |
physicalEnv/ |
models.py, soil.py, hilucs.py, signals.py |
Land cover, DEM/DSM, soil map + hydrologic soil groups, groundwater depth (GHG/GLG), SCS Curve Number method, HILUCS land-use codelist |
nature/, weather/, Energy/ |
models.py |
Water bodies, forests, green spaces (parks = type park), trees; weather; energy domain data |
Templates/ |
mainMap.html, indicators_base.html, <app>/partials/ |
Shared HTML shells + HTMX partials |
tiler.py |
β | Standalone FastAPI app serving COG tiles (run as its own process) |
π Registries in core/utils.py auto-discover every model in the domain apps above β no manual layer wiring needed.
ποΈ Architecture
build_model_registry() scans a hardcoded list of domain apps and builds four registries:
MODEL_REGISTRYβ every model from the allowed appsVECTOR_REGISTRYβ models with aGeometryField(noRasterField)RASTER_REGISTRYβ models with aRasterFieldWMS_REGISTRYβ models whose registry key contains"WMS"
These drive the generic layer API, the map's layer catalog, and the importer's field-mapping UI. Adding a domain app means adding it to allowed_apps in core/utils.py and INSTALLED_APPS in DigitalTwin/settings.py.
administrative/signals.pyβ cascades population/density Neighborhood β District β City β Province via_recompute_population(), usingupdate()(notsave()) to avoid signal recursion.physicalEnv/signals.pyβ when land cover changes, recomputes every neighborhood'surban_areain the city with one set-based SQLUPDATE, then cascades each parent once. A building drawn in the map editor (negative id) turns a 10 m buffer around it into urban fabric (physicalEnv/landcover.py::urbanize_around, DAG edgeBuildings -> LandCover): the buffer is cut out of the overlapping land-cover polygons and inserted as one new polygon. The cut pieces are kept (UrbanFabricCut), so moving or deleting that building gives the land back (release_ring), and the editor's backup journals all those rows so Restore brings the original land cover back. The housing dashboard reports the pipeline's land take (greenfield vs infill hectares,housing/calculations.py::calculate_pipeline_land_take).core/signals.pyβpost_saveon everyRASTER_REGISTRYmodel auto-exports it to a COG (see Raster Pipeline below);post_save/post_deleteon every vector model invalidates that layer's cached GeoJSON.- Bulk imports wrap their writes in
importer/batching.py::deferred_cascades(), so the cascades run once per affected parent at the end instead of once per imported row. watersupply/signals.pyβ cascades population βConsumptionCapitaβTotalWaterDemandβSupplySecurity, and re-derivesOPEX/CoverageWaterSupply/NRW indicators from their upstream inputs.
map_viewrendersTemplates/mainMap.htmlwith the Mapbox token;available_layersreturns the full layer catalog as JSON.model_geojsonserves any registered vector model in one SQL statement: coordinates at 6 decimals (~10 cm), FK names via joins, optional viewport filter (?bbox=) and zoom-based simplification (?zoom=, below zoom 14). Responses are cached per layer and invalidated when its rows change.- Large layers (β₯ 5 000 features, except administrative boundaries) load only the current viewport and reload after each pan/zoom.
- Styles live in
mainMap/styles/.layerStyles.py::LAYER_STYLESmaps a registry key (app_label.Model) to its catalog colour, Mapbox style layers and an optional legend; layers without an entry get a fallback colour. Land cover and the soil map build their style and legend per request from the classes actually imported (landCoverStyles.py,soilStyles.py); raster colormaps and value ranges are inrasterStyles.py. - Popups show every property with the label, unit and help text of its model field. Columns of a linked table can be added through
POPUP_RELATED_FIELDSinmainMap/views.py; they arrive as<fk>__<field>on the same SQL join (e.g. a soil polygon's soil name, hydrologic group and infiltration rates), and styles can match on them too. - Floating panels, legends and the toolbar are translucent and draggable by their header (double-click the header to reset). Legends are stacked in one container that moves out of the way of the side panel and population dock.
core/static/js/mainMap.jsholds page-level wiring (map init, right panel, year selector, guided tour);Draggable.js,Layers.js,Events.jshold dragging, layer loading and UI events. New map-page behavior belongs in these files, not in inline<script>blocks.
Each follows the same three-layer pattern:
calculations.pyβ pure query functions, each documenting the DPSIR/DAG edges it implements.views.pyβ_get_adminUnit_data()assembles calculations into a dict;_build_indicators()derives display-ready metrics with what-if overrides (consumption_override,interest_rate_override, β¦).- Templates β the standalone dashboard and the map's HTMX side-panel partial render the same slider/gauge markup and share the same JS file in
core/static/js/indicators/<domain>.js(wrapped in an IIFE, re-runs safely after everyhtmx:afterSwap).
MOCK_DATA dicts provide fallback values when a province has no DB data, so frontend work isn't blocked on a populated database.
π§© Domain Apps
| App | Covers |
|---|---|
administrative |
Province > City > District > Neighborhood; population projection |
physicalEnv |
Land cover (vector + raster), DEM, DSM, satellite imagery, surface materials |
watersupply |
Extraction, treatment, pipe networks, coverage, NRW, OPEX |
urban_heat |
UTCI, PET, MRT, LST, SVF, SUHII rasters; Nature-Based Solutions; per-neighborhood heat and shade (NeighborhoodHeat, Klimaateffectatlas) |
housing |
Supply/demand, mortgages, rentals, HPI, affordability stress; per-neighborhood social profile (NeighborhoodSocialProfile, Klimaateffectatlas) |
builtup |
Zoning, streets, facilities, buildings (incl. canopy, facade shade and cloudburst depth from the Klimaateffectatlas), properties; per-neighborhood foundation and flood risk (NeighborhoodClimateRisk) |
nature, weather, Energy |
Additional domain data |
importer |
File-upload and external-catalog import pipelines |
mainMap |
Interactive map view and layer catalog API |
core |
Model registry, generic layer API, raster export pipeline, signals |
π DPSIR Causal Network
The full causal graph lives in core/DAG.dot (Graphviz). Each domain's calculations.py documents which edges its functions implement:
# DAG edges: Central_Bank -> MortgageOf the 103 edges in the graph, only 31 are backed by real derivation logic (a save() or calculations.py function that reads the source field β not just a docstring claiming the edge). See TODO.md for the full per-app gap analysis and checklist before extending any dashboard's calculations.
π₯ Importer System
Two paths, both under /importer/:
- File upload β upload GeoJSON or a zipped Shapefile, map source fields to any registered model, preview, then import inside DB savepoints. Known gap: only geometry and upsert-key columns are imported today; other mapped attribute columns are ignored.
- External catalog β catalog-driven import:
| Source | Provides | Auth |
|---|---|---|
| PDOK | Admin boundaries, BAG buildings, roads, water, elevation, land cover, zoning plans (each zoning element with its HILUCS land use) | None |
| RIVM | Per-building energy labels β builtup.Building (import BAG buildings first) |
None |
| Klimaateffectatlas (WFS) | 21 of its 68 climate-effect layers: foundation and flood risk, heat and shade, social vulnerability per neighborhood; canopy, facade shade and cloudburst depth onto existing BAG buildings; water boards, drought-sensitive nature, climate buffers. Mapping, graph and the 47 layers not imported: docs/KEA_WFS_MAPPING.md |
None |
| CBS | National statistics via OData, incl. population forecasts | None |
| BIS Nederland (bodemdata.nl) | Soil map 1:50 000 (WFS) β SoilType/SoilArea with SCS soil group, drawn in the colours of the BRO Bodemkaart legend; groundwater depth GHG/GLG (WCS, 50 m) β GroundwaterDepth |
None |
| OpenStreetMap (Overpass API) | Parks β nature.GreenSpaces (type park), water bodies β nature.WaterBodies, amenities (nodes only) β builtup.Facility, trees β nature.Tree |
None (OVERPASS_URL optional) |
| Sentinel-2 | Land cover + NDVI/NDWI/moisture/true-color via openEO | SENTINEL_CLIENT_ID/SECRET, else UI prompt |
| KNMI Data Platform | Weather observations (e.g. WBGT), precipitation radar forecast (WMS) | Server-side KNMI_API_KEY |
| Google Earth Engine | Arbitrary GEE assets, exported as GeoTIFF | Service-account JSON pasted per import |
For districts/neighborhoods, the import map picks the area of interest from the parent city/district (bbox_from in the catalog).
Large imports are batched (importer/batching.py): spatial parents (the city a polygon lies in, β¦) are found with prepared geometries, and models without their own save() logic β land cover, streets, facilities, soil polygons, the nature layers and trees β are written 500 rows at a time with bulk_create / INSERT β¦ ON CONFLICT. Models with save() logic (buildings, the administrative hierarchy) are still saved row by row.
π°οΈ Raster Pipeline
Every RASTER_REGISTRY model auto-exports to a tile-servable COG on save:
- PostGIS raster β
ST_AsGDALRasterβ temp GeoTIFF - Reproject to EPSG:4326 via
rasterio - Convert to COG via
rio-cogeo - Store under
cogs/<app_label>/ - TiTiler serves tiles from that path
Raster models need a cog_path field to participate. Bulk-export with:
uv run python manage.py export_cogsπ API Routes
| Path | Purpose |
|---|---|
/ |
Main map view |
/api/layers/ |
Layer catalog JSON β ?app_labels=administrative,builtup restricts which apps are queried |
/api/layers/<app>/<model>/geojson/ |
GeoJSON for a vector model β optional ?bbox=minLng,minLat,maxLng,maxLat (WGS84) and ?zoom=z |
/api/layers/<app>/<model>/bounds/ |
Bounding box as WGS84 [[west, south], [east, north]] |
/api/raster/<app>/<model>/tiles/ |
TiTiler tile URL for a raster model |
/api/raster/<app>/<model>/info/ |
Raster metadata |
/importer/ |
File upload import |
/importer/external/ |
External catalog import |
/watersupply/ |
Water supply dashboard |
/urban_heat/ |
Urban heat views |
/housing/ |
Housing dashboard |
/weather/ |
Weather data views |
β Testing
Tests use PostGISTestRunner (DigitalTwin/test_runner.py), which creates the test DB, installs PostGIS extensions, and patches Django's prepare_database to skip the superuser requirement.
uv run python manage.py test --settings=DigitalTwin.settings_test --keepdb --noinput
uv run python manage.py test importer --settings=DigitalTwin.settings_test --keepdb --noinput # single appAlways pass --keepdb: without it Django drops the test database the runner prepared and recreates it without PostGIS, and migrations fail with type "geometry" does not exist.
π Key Conventions
- Spatial models always use
srid=settings.COORDINATE_SYSTEM, never a hardcoded SRID. - Computed fields are derived in
save()β check existing patterns (administrative/models.py,watersupply/signals.py) first. - FK
on_deleteon spatial/reference models usesmodels.DO_NOTHING. - Population cascading uses
update(), notsave(), to avoid signal recursion. - The GeoJSON API transforms to EPSG:4326 at query time β storage stays in RD New.
- Frontend JS lives in
core/static/js/β never inline<script>blocks in templates.
π Troubleshooting
| Symptom | Check |
|---|---|
setup_db.bat: psql.exe was not found |
PostgreSQL isn't installed, or is in a custom folder: enter the path to its bin folder when asked |
setup_db.bat: PostGIS is not installed on this server |
Install Spatial Extensions β PostGIS 3.x Bundle with Stack Builder (Start menu β PostgreSQL), then run the script again |
setup_db.bat: Could not connect |
PostgreSQL service running (net start postgresql-x64-17)? Correct admin password and port? |
Django: password authentication failed |
DATABASE_* in .env match the database user; re-run setup_db.bat and choose to set a new password |
| PROJ version mismatch on import | uv run python -c "import pyproj; print(pyproj.proj_version_str)" vs psql -c "SELECT PostGIS_PROJ_Version();" β prefer ST_AsGeoJSON over GDAL reprojection if they diverge |
TiTiler crashes at start: CRSError: The EPSG code is unknown. PROJ: proj_create_from_database: β¦PostgreSQL\18\share\contrib\postgis-3.6\proj\proj.db contains DATABASE.LAYOUT.VERSION.MINOR = 2 whereas a number >= 5 is expected |
The PostGIS bundle set a machine-wide PROJ_LIB/PROJ_DATA pointing to its older proj.db, which overrides the PROJ data bundled with rasterio. tiler.py now unsets both before importing rasterio (pull the latest). If it still happens in another process, check echo $env:PROJ_LIB; echo $env:PROJ_DATA and remove the variables (System Properties β Environment Variables), then open a new terminal |
| Import fails despite a valid file | File encoding (UTF-8), geometry validity (ST_IsValid), SRID, required field mappings |
| Mapbox map is blank | MAPBOX_ACCESS_TOKEN set/valid; browser console + network tab for failed requests |
| Imported layer missing from map | Model's app is in allowed_apps (core/utils.py) and INSTALLED_APPS; /api/layers/<app>/<model>/geojson/ returns valid GeoJSON |
| Map shows old data after a direct DB edit | Rows changed outside Django (SQL, psql) don't invalidate the GeoJSON cache β restart the server, or wait for the 2-minute expiry |
| Large layer shows only part of the city | Expected: layers with β₯ 5 000 features load by viewport; pan or zoom out to load more |
Tests fail with type "geometry" does not exist |
Rerun with --keepdb |
Could not find the GDAL library at startup |
macOS/Linux: install the system GDAL (brew install gdal, apt install gdal-bin libgdal-dev). Windows: uv sync did not install gdal (check .venv\Lib\site-packages\osgeo\gdal.dll); usually a 32-bit or unsupported Python version |
| Sentinel-2 (openEO) import fails / asks for credentials | SENTINEL_CLIENT_ID/SECRET set + server restarted; CDSE OAuth client still valid at shapps.dataspace.copernicus.eu/dashboard; otherwise paste a personal client ID/secret into the UI panel (used once, not saved) |