This guide walks through adding a new datasource end-to-end: from creating the mapper class and table models, through writing the YAML config, to verifying data flows through every ETL stage.
The example used throughout is the DWD Weather Stations mapper — fetches weather station locations from Bright Sky, builds a point geometry in enrichment, and maps each road segment to its nearest station.
The pipeline discovers mapper classes by convention from class_name in YAML:
class_name in YAML |
File | Class |
|---|---|---|
weatherStation |
data_mappers/weatherStationMapper.py |
WeatherStationMapper |
airQualityData |
data_mappers/airQualityDataMapper.py |
AirQualityDataMapper |
tree |
data_mappers/treeMapper.py |
TreeMapper |
Rule: append Mapper to the filename, convert class_name to PascalCase and append Mapper for the class.
Both staging and enrichment table classes live in the same mapper file as the mapper class. The framework auto-creates and migrates them on startup via SQLAlchemy. Reference them from YAML using table_class:.
The staging table holds the raw records loaded from the source. It maps directly to what your read_file_content() / source_filter() returns — one column per dict key.
# data_mappers/weatherStationMapper.py
from geoalchemy2 import Geometry
from sqlalchemy import Column, Integer, String, Float, DateTime, UniqueConstraint, Index
from database_tables.staging_table import StagingTable
class DwdStationsStagingTable(StagingTable):
__tablename__ = "dwd_station_locations_staging"
uid = Column(Integer, primary_key=True, autoincrement=True)
dwd_station_id = Column(Integer, unique=True, nullable=False)
station_name = Column(String)
observation_type = Column(String)
lat = Column(Float)
lon = Column(Float)
height = Column(Float)
wmo_station_id = Column(String)
first_record = Column(DateTime(timezone=True))
last_record = Column(DateTime(timezone=True))
Rules for staging tables:
StagingTable (provides __abstract__ = True and the SQLAlchemy base)uid = Column(Integer, primary_key=True, autoincrement=True))UniqueConstraint on the natural key — the framework uses it for upsertsEnrichment holds cleaned, derived, and geometry-enriched versions of staging records.
class DwdWeatherStationEnrichmentTable(EnrichmentTable):
__tablename__ = "dwd_station_locations_enrichment"
uid = Column(Integer, primary_key=True, autoincrement=True)
dwd_station_id = Column(Integer, unique=True, nullable=False)
station_name = Column(String)
point = Column(Geometry(geometry_type="POINT", srid=4326), index=True)
Rules for enrichment tables:
EnrichmentTableColumn(Geometry(...), index=True) or Index(None, "col", postgresql_using="gist")Only needed when using strategy.type: custom (or mapper_sql) — the framework creates the mapping table automatically for built-in strategies.
from database_tables.mapping_table import MappingTable
class DwdMappingTable(MappingTable):
__tablename__ = "dwd_station_locations_mapping"
uid = Column(Integer, primary_key=True, autoincrement=True)
dwd_station_id = Column(Integer, nullable=False)
distance = Column(Float, nullable=False)
bearing_degree = Column(Float, nullable=True)
MappingTable base provides: way_id = Column(Integer, ForeignKey(ways_base.id), unique=True).
Subclass DataSourceABCImpl and override only what is dataset-specific. The base class handles everything else: download, file reading, bulk insert, staging/enrichment sync, mapping, scheduling, and metadata.
For CSV, JSON (flat list), GeoPackage, Shapefile, Parquet — the built-in reader handles the file. Your mapper file just needs to exist and import the base class:
from main_core.data_source_abc_impl import DataSourceABCImpl
class MyDataMapper(DataSourceABCImpl):
pass
source_filter — reshaping a nested JSON responsesource_filter receives the raw parsed payload (the whole dict or list from the file) and must return a flat list[dict]. This is the most commonly overridden method for JSON APIs.
# Real example from weatherStationMapper.py
class WeatherStationMapper(DataSourceABCImpl):
def source_filter(self, data: list[dict]) -> list[dict]:
# Bright Sky wraps stations in data[0]["sources"]
stations = data[0]["sources"]
filtered = [
row for row in stations
if row.get("observation_type") == "forecast"
and int(row.get("last_record", "0")[:4]) >= 2024
]
self.logger.info(f"Filtered {len(stations)} → {len(filtered)} stations")
return filtered
# Real example from weatherMapper.py — multi-fetch where each file has a sources list
class WeatherMapper(DataSourceABCImpl):
def source_filter(self, data: list) -> list[dict]:
result = []
for content in data:
sources = content.get("sources", [])
if not sources:
continue
dwd_station_id = int(sources[0].get("dwd_station_id"))
for weather in content.get("weather", []):
enriched = dict(weather)
enriched["dwd_station_id"] = dwd_station_id
result.append(enriched)
return result
read_file_content — custom/binary formatsOverride this when the format isn’t handled automatically (gz, zip, xml, pbf) or when you need WKB geometry encoding.
# Real example from airQualityDataMapper.py — gzip-compressed GeoJSON
import gzip
import orjson
class AirQualityDataMapper(DataSourceABCImpl):
def read_file_content(self, path: str) -> list[dict]:
with gzip.open(path, "rb") as f:
payload = orjson.loads(f.read())
rows = []
skipped = 0
for feature in payload.get("features", []):
try:
props = feature["properties"]
x, y = feature["geometry"]["coordinates"]
rows.append({
"grid_id": props["id"],
"forecast_time": props["date_time_forecast_iso8601"],
"no2": props.get("no2"),
"pm10": props.get("pm10"),
"x_utm": x,
"y_utm": y,
"geom_25833": f"SRID=25833;POINT({x} {y})",
})
except Exception:
skipped += 1
if skipped:
self.logger.warning(f"Skipped {skipped} malformed features in {path}")
return rows
Geometry encoding: pass geometry as EWKT strings ("SRID=25833;POINT(x y)") — PostGIS parses them on insert via geoalchemy2.
Each file in data_source_configs/ is a single flat dict — no datasources: list wrapper. The framework auto-discovers every *.yaml in the folder on startup; no registration in config.yaml needed.
# data_source_configs/weather_station_bright_sky.yaml
name: weather_station_bright_sky
description: "DWD weather station catalog from Bright Sky"
enable: true
class_name: weatherStation # → weatherStationMapper.py → WeatherStationMapper
data_type: static
debug:
endpoint: weather-station # /debug/mappers/weather-station/...
source:
fetch: http
mode: single
url: "https://api.brightsky.dev/sources"
destination: "tmp/brightsky/stations/weatherStation.json"
response_type: json
check_metadata:
enable: true
keys: ["last_modified"] # skip re-download when server file unchanged
header:
Accept: "application/json"
job:
trigger:
type:
name: interval
config:
hours: 10
storage:
persistent: true
staging:
table_name: dwd_station_locations_staging
table_class: DwdStationsStagingTable
enrichment:
table_name: dwd_station_locations_enrichment
table_class: DwdWeatherStationEnrichmentTable
mapping:
enable: true
table_name: dwd_station_locations_mapping
strategy:
type: knn
base_geometry_column: geometry
enrichment_geometry_column: point
distance_sql: "ST_Distance({base_geometry}::geography, {enrichment_geometry}::geography)"
order_by_sql: "ST_Distance({base_geometry}::geography, {enrichment_geometry}::geography)"
base_table:
column_name: dwd_station_id
column_type: String
table_schema defaults to the DB_SCHEMA environment variable (exp_null in MDP); set it explicitly if you need a different schema.
The framework reads each downloaded file through a filter pipeline. Override only what is dataset-specific.
response_type |
Library | What you get |
|---|---|---|
json |
orjson | Raw dict or list — use source_filter() to reshape |
csv / tsv |
pandas | list[dict] |
gpkg / shp / geojson |
geopandas | list[dict] (geometry dropped by default) |
parquet |
pandas | list[dict] |
xlsx / xls |
pandas | First sheet as list[dict] |
For spatial formats, add a reader: block to reproject without Python:
source:
response_type: gpkg
reader:
engine: pyogrio # "pyogrio" (default) or "fiona"
target_crs: 25833 # auto-reprojects if source CRS differs
For gz, zip, xml, pbf — implement read_file_content().
| Method | Signature | Default | When to override |
|---|---|---|---|
read_file_content(path) |
(str) → list[dict] |
Auto-detect by response_type |
Unsupported format, WKB geometry needed, multi-file merge |
source_filter(data) |
(list\|dict) → list[dict] |
Pass through | Flatten nested JSON, filter rows, add computed fields |
pre_filter_processing(data) |
(list) → None |
No-op | Build in-memory spatial index (KDTree) before filtering |
post_filter_processing(data) |
(list) → None |
No-op | Post-filter validation, write debug output |
before_filter_pipeline(data, path) |
(list, str) → None |
No-op | Per-file setup (open side-channel connection) |
after_filter_pipeline(data, path) |
(list, str) → None |
No-op | Per-file metrics or progress counter |
should_load_transformed_data(data, path) |
(list, str) → bool |
bool(data) |
Return False to skip DB insert for this file |
before_process_file(path) |
(str) → None |
No-op | Per-file setup before transform() is called |
after_process_file(path, data) |
(str, list) → None |
No-op | Per-file cleanup after load() |
on_process_file_error(path, error) |
(str, Exception) → None |
Log error | Custom error handling or quarantine |
After source_filter, the framework bulk-inserts records into a raw-staging clone of your table. These hooks fire around that insert.
for each file:
before_load(data)
pre_database_processing()
→ db.bulk_insert(raw_staging_table, records)
after_load(data)
after all files:
post_database_processing()
| Method | Called when | Purpose |
|---|---|---|
before_load(data) |
Before bulk insert | Final validation, add metadata columns |
pre_database_processing() |
Before bulk insert | Prepare DB structures, clear in-memory caches |
after_load(data) |
After bulk insert | Record stats, reset per-file state |
post_database_processing() |
After all files are processed | Flush in-memory results accumulated across files |
load(data) |
Default bulk insert | Override entirely to skip DB (keep data in memory) |
# Available inside any hook as self.db
self.db.bulk_insert(
table_name="my_staging",
schema="exp_null",
data=records_list, # list[dict]
upsert=True # INSERT ... ON CONFLICT DO UPDATE
)
self.db.call_sql("UPDATE exp_null.my_staging SET ...")
self.db.get_table_count("my_staging", "exp_null") # → int
class MyMapper(DataSourceABCImpl):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._metrics = []
def after_load(self, data):
# Compute per-file metrics while data is still in memory
self._metrics.extend([{"way_id": r["id"], "elev": r["elevation"]} for r in data])
def post_database_processing(self):
# Flush once all files are done
if self._metrics:
self.db.bulk_insert("elevation_metrics", "exp_null", self._metrics, upsert=True)
self._metrics.clear()
After all files are processed, raw-staging is synced to the permanent staging table. Then staging_db_query() fires.
| Method | Signature | Purpose |
|---|---|---|
staging_db_query() |
() → str \| None |
SQL to run after raw→staging sync. Return None to skip. |
sync_raw_to_staging() |
() → dict |
Override for custom aggregation or deduplication on sync |
staging_db_querydef staging_db_query(self) -> str | None:
stg = self.data_source_config.storage.staging
# stg.table_schema → "exp_null"
# stg.table_name → "dwd_station_locations_staging"
return f"""
UPDATE {stg.table_schema}.{stg.table_name}
SET geom_4326 = ST_SetSRID(ST_MakePoint(lon, lat), 4326)
WHERE lon IS NOT NULL
AND lat IS NOT NULL
AND geom_4326 IS NULL
"""
Return None (the default) to skip this stage entirely.
After staging→enrichment sync, enrichment_db_query() fires.
| Method | Signature | Purpose |
|---|---|---|
enrichment_db_query() |
() → str \| None |
SQL to run after staging→enrichment sync. Return None to skip. |
sync_staging_to_enrichment() |
override | Custom aggregation on sync (e.g. hourly rollup, grid binning) |
enrichment_db_query# Real example from weatherStationMapper.py
def enrichment_db_query(self) -> str | None:
staging = self.data_source_config.storage.staging
enrichment = self.data_source_config.storage.enrichment
return f"""
UPDATE {enrichment.table_schema}.{enrichment.table_name} e
SET point = ST_SetSRID(ST_MakePoint(s.lon, s.lat), 4326)
FROM {staging.table_schema}.{staging.table_name} s
WHERE e.dwd_station_id = s.dwd_station_id
AND e.point IS NULL
"""
# Real example from airQualityDataMapper.py — CRS transform
def enrichment_db_query(self) -> str | None:
enrichment = self.data_source_config.storage.enrichment
return f"""
UPDATE {enrichment.table_schema}.{enrichment.table_name}
SET geom_4326 = ST_Transform(geom_25833, 4326)
WHERE geom_25833 IS NOT NULL
AND geom_4326 IS NULL
"""
enrichment_operators (config-driven, no Python needed)For common transformations, declare operators in YAML instead of writing SQL:
# From tree.yaml — derives typed columns from a raw JSONB attributes column
enrichment_operators:
operators:
- { type: derive, target_col: species_de, expression: "attributes->>'art_dtsch'" }
- { type: derive, target_col: height_m, expression: "NULLIF(attributes->>'baumhoehe','')::numeric" }
- type: derive
target_col: size_class
expression: >
CASE
WHEN height_m >= 20 THEN 'large'
WHEN height_m >= 10 THEN 'medium'
WHEN height_m > 0 THEN 'small'
ELSE 'unknown'
END
Supported operator types: derive, make_point, reproject, snap_to_grid, normalize, aggregate, spatial_aggregate, raster_aggregate.
Mapping links enrichment rows to ways_base road segments. The framework either auto-generates the PostGIS SQL (built-in strategies) or calls your mapping_db_query() (custom strategy).
| Data type | Density | Strategy | Key config |
|---|---|---|---|
| Points (stations, sensors) | Sparse | knn |
base_geometry_column, enrichment_geometry_column |
| Points (trees, stops) | Dense | aggregate_within_distance |
max_distance, aggregation_type |
| Regular grid cells | Medium | sql_template or custom |
sql or mapping_db_query() |
| Polygons / lines | — | intersection |
geometry columns |
| K nearest per road segment | — | nearest_k |
k |
| Shared non-spatial key | — | attribute_join |
base_column, mapping_column |
| Complex / LATERAL / CTE | — | custom |
implement mapping_db_query() |
# knn — nearest road per feature (weather stations)
mapping:
enable: true
table_name: dwd_station_locations_mapping
strategy:
type: knn
base_geometry_column: geometry
enrichment_geometry_column: point
distance_sql: "ST_Distance({base_geometry}::geography, {enrichment_geometry}::geography)"
order_by_sql: "ST_Distance({base_geometry}::geography, {enrichment_geometry}::geography)"
base_table:
column_name: dwd_station_id
column_type: String
# aggregate_within_distance — aggregate all trees within 50 m (tree mapper)
mapping:
enable: true
table_name: tree_mapping
strategy:
type: aggregate_within_distance
max_distance: 50
aggregation_type: jsonb_build_object
aggregation_expression: |
COALESCE(
jsonb_agg(
jsonb_build_object(
'tree_id', {enrichment_alias}.id,
'distance_m', ST_Distance({enrichment_alias}.geometry_25833, {base_geometry})
)
ORDER BY ST_Distance({enrichment_alias}.geometry_25833, {base_geometry})
) FILTER (WHERE {enrichment_alias}.id IS NOT NULL),
'[]'::jsonb
)
aggregation_alias: trees
base_table:
column_name: tree_factor
column_type: Integer
mapping_db_query()Set strategy.type: custom (or mapper_sql) in YAML and implement mapping_db_query() in your mapper:
mapping:
enable: true
table_name: my_mapping
strategy:
type: custom
def mapping_db_query(self) -> str | None:
enr = self.data_source_config.storage.enrichment
m = self.data_source_config.mapping
# m.table_schema, m.table_name → where to insert
# enr.table_schema, enr.table_name → source of enrichment data
return f"""
INSERT INTO {m.table_schema}.{m.table_name}
(way_id, dwd_station_id, distance)
SELECT DISTINCT ON (e.dwd_station_id)
w.id AS way_id,
e.dwd_station_id,
ST_Distance(w.geometry_25833, e.point::geometry) AS distance
FROM {enr.table_schema}.{enr.table_name} e
CROSS JOIN LATERAL (
SELECT id, geometry_25833
FROM {m.table_schema}.ways_base
ORDER BY geometry_25833 <-> e.point::geometry
LIMIT 1
) w
ON CONFLICT (way_id) DO UPDATE SET
dwd_station_id = EXCLUDED.dwd_station_id,
distance = EXCLUDED.distance
"""
# Storage tables
self.data_source_config.storage.staging.table_name # "dwd_station_locations_staging"
self.data_source_config.storage.staging.table_schema # "exp_null"
self.data_source_config.storage.enrichment.table_name
self.data_source_config.storage.enrichment.table_schema
# Mapping table
self.data_source_config.mapping.table_name
self.data_source_config.mapping.table_schema
# Datasource identity
self.data_source_name # "weather_station_bright_sky"
| Method | Signature | When it fires | Use |
|---|---|---|---|
run_end_cleanup(succeeded, error) |
(bool, Exception\|None) |
Always, even on failure | Temp file cleanup, memory release |
check_before_update() |
() → bool |
Before extraction starts | Return False to abort the run |
after_datasource_success() |
() |
After a successful run | Notify external system |
on_run_error(error) |
(Exception) |
On unhandled run exception | Custom error reporting |
def run_end_cleanup(self, succeeded: bool, error=None):
if hasattr(self, "_temp_index"):
self._temp_index = None # release memory
if error:
self.logger.error(f"Run failed: {error}")
# Run only this datasource
python3 run.py --only weather_station_bright_sky
# Check row counts at each stage
psql -U postgres -d mydb -c "SELECT COUNT(*) FROM exp_null.dwd_station_locations_staging;"
psql -U postgres -d mydb -c "SELECT COUNT(*) FROM exp_null.dwd_station_locations_enrichment;"
psql -U postgres -d mydb -c "SELECT COUNT(*) FROM exp_null.dwd_station_locations_mapping;"
# Debug API (pipeline must be running)
curl http://localhost:8000/debug/datasources
curl http://localhost:8000/debug/mappers/weather-station/staging
curl http://localhost:8000/debug/mappers/weather-station/enrichment
curl http://localhost:8000/debug/mappers/weather-station/mapping
Hot-reload: edit any file in data_source_configs/ → save → pipeline reloads in ~2 s. No restart needed.
| Symptom | Likely cause | Fix |
|---|---|---|
| Staging table empty | source_filter returns empty list, or read_file_content fails |
Check source_filter() logic; inspect raw file at destination path |
| Geometry column is NULL | enrichment_db_query() not running or wrong column names |
Verify method returns a non-None SQL string; check self.data_source_config.storage.* attribute values |
| Too few mapping rows | Wrong geometry column or CRS mismatch | Check base_geometry_column / enrichment_geometry_column in mapping config; verify SRID consistency |
| Mapping skipped | mapping.enable: false or table count already matches |
Set enable: true and check if incremental mapping is short-circuiting |
| Job skipped (no download) | Remote file hasn’t changed, check_metadata cached it |
Disable check_metadata.enable temporarily to force re-download |
table_class not found |
Class name in YAML doesn’t match class in Python file | table_class: DwdStationsStagingTable must exactly match the Python class name |
data_mappers/<name>Mapper.py created, class name matches class_name + Mapper suffixdata_source_configs/<name>.yaml written as a flat dict (no datasources: wrapper)storage.staging.table_class and storage.enrichment.table_class match your Python class names