DataRegistry
DataRegistry is the pipeline's central data-lookup point:
- Streets — the topology builders get the street network with
streets(area). Streets are registered withregister_streets(see Streets). - Regionalised data — the
EnergySystemBuilderasks for the demand value, profile and technology shares of eachDemandTypewith aDataRegistryQuery(a key plus optional params) and the region's topology; the registry returns the result of the highest-priority dataset whosescopecovers that region.
This indirection means the rest of the pipeline does not need to know whether data comes from a GeoJSON, a Postgres database, a hard-coded constant, or a custom query function — only that some dataset has registered against the key.
Minimal usage
import pathlib
from geopipe import DataRegistry
from geopipe.data.data_registry import DataKeys
from geopipe.data.dataset import FileDataset
heating_shares = FileDataset(
keys=[DataKeys.HEATING_SHARES],
file_path=str(pathlib.Path(__file__).parent / "Census2022HeatingType.geojson"),
query_function=census_query
)
data_reg = DataRegistry(crs="EPSG:25832")
data_reg.register(heating_shares)
Project CRS
The registry owns the project CRS; it is the single CRS of the whole pipeline. Every registered dataset is reprojected to it once (on registration, or on first load for file datasets), and the topology builder reprojects streets and polygons to it. Query functions therefore always receive data in the project CRS. Use a projected CRS in metres — a warning is logged otherwise, since lengths and distances are measured in CRS units.
API
DataRegistry
| Method | Signature | Description |
|---|---|---|
__init__ |
DataRegistry(crs: str \| pyproj.CRS) |
Sets the project CRS (see above). |
crs |
property → pyproj.CRS |
The project CRS. |
query |
query(topology, query: DataRegistryQuery, unit=None) -> Any |
Answers the query for one region topology (see below). |
DataRegistryQuery
DataRegistryQuery(key, params={}) — key selects the datasets;
params are dataset-specific options passed unchanged to the query
function (e.g. {"name_mapping": {...}}).
Routing
The registry works on region topologies only; it derives no geometry
itself. query routes the region to the highest-priority dataset whose
scope covers all of the region's topology nodes. A dataset
covering only part of the study area thus answers for the regions
inside it, and the next dataset by priority for the rest. A region no
dataset covers raises a LookupError.
Units
With unit=, a numeric result is converted from the dataset's unit
(e.g. UnitEnum.KWH → UnitEnum.MWH); a dataset without a unit is then
rejected. The EnergySystemBuilder requests demand values in the energy
unit of the energy system.
| register | register(dataset: Dataset) -> None | Adds a dataset. Lookups walk registered datasets in descending priority (then registration order) and return the first match. Streets go through register_streets. |
| register_streets | register_streets(streets, id_column, ...) -> None | The only way to register streets (see Streets). |
| streets | streets(area: GeoDataFrame) -> GeoDataFrame | All streets of the highest-priority street dataset whose scope covers area. |
Streets
Streets are the base of the topology, so they have their own entry
point: register_streets is the only way to register them (register
refuses the key STREET_NETWORK).
data_reg.register_streets(streets, id_column="fid", divide_at_junctions=True, gap_distance=5)
| Argument | Default | Description |
|---|---|---|
streets |
— (required) | LineString / MultiLineString streets, as GeoDataFrame or Path. |
id_column |
— (required) | Unique, non-empty id per street. Street-keyed data (StreetValueDataset) refers to these ids. |
scope, priority |
None, 10 |
As for every dataset. |
divide_at_junctions, junction_tol |
False, 1e-6 |
See cleaning steps. |
gap_distance |
None |
See cleaning steps. |
drop_isolated_null_columns |
None |
See cleaning steps. |
The streets are validated and reprojected, and every street records its
source_street_id and source_share, which the cleaning steps split
along with it. Cleaning runs once, at registration.
streets(area) returns all streets of the highest-priority street
dataset whose scope covers the whole area — also those outside it,
as connections between regions may run over them. The topology builders
call it.
Cleaning steps
All steps are off by default, as they cost seconds per town (e.g. junction division ≈ 4 s for 4,660 streets). They run in this order:
divide_at_junctions— streets are only connected in the graph if they share a vertex. This cuts them where they meet without one: at T-junctions (one street ends on, or withinjunction_tolof, another's interior; the crossed street is split) and at mid-segment crossings (both streets are split). Each piece becomes its own row with the IDf"{k * 100}{old_id}"for the k-th piece (street42→10042,20042). Streets sharing an endpoint are left untouched; collinear overlaps are ignored.gap_distance— every dead-end (degree-1 node) is connected to the closest point on the nearest street within this distance, excluding its own street and the streets at its neighbouring junction. The connection point is inserted into the target street and a connector street is appended with the IDf"000{target_id}"and all other attributesNaN. Facing dead-ends are bridged once.drop_isolated_null_columns— streets outside the largest connected component of the network are dropped if their values in all of these columns are NULL.
Built-in keys (DataKeys)
| Key | Returns |
|---|---|
RESIDENTIAL_HEAT_DEMAND_PROFILE |
pd.Series of 8760 hourly values (normalised) |
RESIDENTIAL_ELECTRICITY_DEMAND_PROFILE |
same |
RESIDENTIAL_HEAT_DEMAND |
float total per region (in the dataset's unit) |
RESIDENTIAL_ELECTRICITY_DEMAND |
float total per region (in the dataset's unit) |
HEATING_SHARES |
dict[str, float] summing to 1, after name_mapping |
STREET_NETWORK |
Street lines — only via register_streets / streets |
LINEAR_HEAT_DENSITY |
GeoDataFrame with geom and heat_density_mwh_per_km |
A single Dataset can advertise multiple keys via the keys=[…]
list.
Datasets
Two built-ins are available out of the box: FileDataset (any file
that pandas/geopandas can read) and PostgresDataset (any Postgres
connection). Both call the optional query_function(dataset, topology,
query) -> Any if supplied, otherwise return the loaded data as-is.
FileDataset
| Parameter | Type | Default | Description |
|---|---|---|---|
keys |
list[str] |
— (required) | Data keys this dataset can answer (typically values from DataKeys). |
file_path |
str |
— (required) | Path to the source file (.geojson / .gpkg / .shp → geopandas, else pandas CSV). |
query_function |
Callable or None |
None |
Custom resolver with signature (dataset, topology, query) -> Any. If omitted, query() returns the loaded data. |
unit |
UnitEnum or None |
None |
Optional unit annotation for the returned data. |
load_data_kwargs |
dict or None |
None |
Extra kwargs forwarded to the underlying read_file / read_csv. |
priority |
int |
10 |
Higher = preferred when multiple datasets can answer the same key. |
scope |
GeoDataFrame or None |
None |
Polygon of the area the dataset is valid for; None = applies everywhere. A region is routed to the dataset only if the polygon covers all of its topology nodes. |
StreetValueDataset
Values per street (e.g. annual heat demand), keyed by the street ids of
the raw input streets (the topology builder's id_column). A region's
value is the sum over its edges of value × the edge's share of its
source street's length, so it does not depend on how regions are
defined.
heat_demand = StreetValueDataset.from_column(
streets, id_column="fid", value_column="waerme_mwh",
keys=[DataKeys.RESIDENTIAL_HEAT_DEMAND], unit=UnitEnum.MWH)
PostgresDataset
| Parameter | Type | Default | Description |
|---|---|---|---|
keys |
list[str] |
— (required) | Data keys this dataset can answer. |
db_connection |
PostgresConnection |
— (required) | Connection used by execute_spatial_query / the query function. |
query_function |
Callable or None |
— (required) | Resolver with signature (dataset, topology, query) -> Any. |
sql |
str or None |
None |
SQL executed by fetch(), with :wkt / :epsg placeholders for the region. |
unit |
UnitEnum or None |
None |
Optional unit annotation. |
priority |
int |
2 |
Lower default than FileDataset so files win on ties. |
scope |
GeoDataFrame or None |
None |
Polygon of the area the dataset is valid for; None = applies everywhere. |
Adding a Postgres data source
Wiring up a database source has three parts: credentials in .env, a
PostgresConnection, and a PostgresDataset that carries the SQL.
1. Credentials in .env
Credentials never live in code. Each database is one prefix, and
PostgresConnection.from_env(prefix) reads exactly five variables:
{PREFIX}_HOST, {PREFIX}_PORT, {PREFIX}_DATABASE, {PREFIX}_USER,
{PREFIX}_PASSWORD. All five are required; a missing one raises
RuntimeError: Missing env var ….
Copy .env.template to .env in the repository root and fill it in.
.env is git-ignored; .env.template is the committed, value-free
version — add a block there whenever you add a new database, so others
know which variables to set.
# --- DATABASE1 ---
DATABASE1_HOST=db.example.org
DATABASE1_PORT=5432
DATABASE1_DATABASE=DATABASE1
DATABASE1_USER=dbuser
DATABASE1_PASSWORD=****
Format rules — the file is parsed by python-dotenv, not by Python:
- One
KEY=valueper line, no trailing comma, no;. - Do not quote values unless the value itself contains spaces or
#. Quotes are stripped, soPORT="5432"works but adds nothing. - No spaces around
=(KEY = valueis tolerated, but stick to the plain form). - Lines starting with
#are comments; blank lines are ignored. - A value already present in the real environment wins over the
.enventry — useful for overriding a single variable on a server or in CI.
Malformed lines are skipped with a python-dotenv could not parse
statement starting at line N warning, and the variable then shows up
as missing — if you get that warning, look for a stray comma or quote
on the reported line.
2. Connection and dataset
from_env locates the nearest .env starting from the current working
directory, so scripts run from anywhere inside the repository pick it up.
The sql string is executed by fetch() through
execute_spatial_query, which binds two parameters for you: :wkt
(the geometry passed to fetch, as WKT) and :epsg (its EPSG code). Use them in a
ST_Intersects clause so the database does the spatial filtering:
from geopipe import DataRegistry
from geopipe.data import PostgresConnection, PostgresDataset
from geopipe.data.data_registry import DataKeys
db_conn = PostgresConnection.from_env("INFDBGAUSS")
heating_shares = PostgresDataset(
keys=[DataKeys.HEATING_SHARES],
db_connection=db_conn,
query_function=census_query, # see below
sql="""
SELECT
SUM(c."gas")::float AS "Gas",
SUM(c."heizoel")::float AS "Heizoel"
-- … remaining carriers …
FROM opendata.zensus_2022_100m_energietraeger_heizung AS c
WHERE ST_Intersects(
c.geom,
ST_Transform(ST_GeomFromText(:wkt, :epsg), ST_SRID(c.geom))
)
""",
)
data_reg = DataRegistry(crs="EPSG:25832")
data_reg.register(heating_shares)
The same db_conn can back any number of datasets — it is shared, and
the SQLAlchemy engine is created lazily on first query. is_available()
opens a connection and runs SELECT 1, returning False instead of
raising if the server is unreachable, so a database dataset that is
temporarily down simply loses to a lower-priority FileDataset.
The query_function receives the raw query result via
dataset.fetch(geometry, query) (e.g. topology.convex_hull) and post-processes it exactly as it would
for a file-backed dataset — the same function can therefore serve both a
FileDataset and a PostgresDataset variant of the same data.
Custom query function
When the default loader doesn't fit, pass a query_function.
It receives the dataset, the region's Topology and the
DataRegistryQuery. How the topology becomes a spatial filter is up to
the function — e.g. topology.convex_hull (a one-row GeoDataFrame) for
an area-based lookup:
def census_query(dataset, topology, query):
if query.key != DataKeys.HEATING_SHARES:
raise ValueError("only heating_shares supported")
gdf = dataset.fetch(topology.convex_hull, query)
# ... aggregation ...
name_mapping = query.params.get("name_mapping", {})
return mapped_shares
The function runs lazily, on every query() call — it can re-read the
file or hit the network if it wants, though for performance it is
typical to cache dataset.get_data().