ADR-0011: OSM building footprints and footprint-driven foundation electrodes¶
| Status | Accepted |
| Date | 2026-05-14 |
| Deciders | Project maintainers |
| Scope | groundfield.geo (new subpackage); generators.placement, |
generators.electrode_specs, generators.tn_network |
|
| (additive extensions); optional Poetry dependency group | |
geo (requests, shapely, pyproj) |
Context¶
ADR-0009 introduced the generator stack and explicitly deferred a
follow-up generator that will read from open building-map data
as the next fidelity step beyond ManhattanGridPlacement. The
existing placement.py module docstring repeats that statement.
This ADR delivers exactly that step for TnNetworkGenerator.
The motivating question is whether the synthetic Manhattan grid ever masks effects that a real street layout would expose. Three mechanisms are sensitive to the geometry, not only to the count of houses:
- Galvanic dissipation per building. A foundation electrode's spreading admittance scales with the perimeter of the foundation, not with a generic side length. Real residential footprints are L-shaped or rectangular with non-square aspect ratios; the bounding-rectangle approximation already captures the perimeter and the principal axes within \(\sim 10\,\%\), which is below typical soil-resistivity uncertainty.
- Inductive coupling to PEN and measurement leads. The orientation of a foundation ring matters for the mutual inductance to a parallel PEN trunk along the street, which is one of the low-frequency effects (Carson coupling below 1 kHz).
- Spatial distribution of houses. Bunched-up Reihenhäuser at one end of a Ortsnetz behave differently from a uniform grid even at the same total count — the cluster impedance of the PEN backbone is not separable from layout.
OpenStreetMap (OSM, ODbL) is the only open data source that
covers all three with metric accuracy and is queryable
programmatically through the Overpass API. Coverage in
Germany for building=* is essentially complete in built-up areas;
building:levels is sparser but populated for newer surveys.
The user also requested two implementation variants:
- Variant 1 (Overpass-driven): lat/lon + radius → polygons. Adopted as the primary path. Reproducible, scriptable, exact.
- Variant 2 (image recognition): rasterised maps via CV/ML.
Deferred. The principal obstacle is metric scale recovery, not
segmentation. When implemented, it slots in behind the same
BuildingFootprintdata class and is otherwise invisible to the generator.
Decision¶
Subpackage layout¶
A new subpackage groundfield.geo is added, intentionally next
to groundfield.geometry/, not inside it. geometry/ holds
electrode primitives (RodElectrode, StripElectrode, …); geo/
holds georeferencing and external-data ingest. Keeping the two
distinct prevents conflating "physical conductor shape" with
"GIS-derived input metadata".
src/groundfield/geo/
├── __init__.py
├── osm.py # Overpass query, cache, parser
├── projection.py # WGS84 → local ENU via pyproj
├── footprint.py # BuildingFootprint Pydantic model
└── placement.py # OsmBuildingPlacement(PlacementSpec)
osm.py, projection.py, and the placement.OsmBuildingPlacement
class import shapely, pyproj, and requests. These three
ship in a new optional Poetry dependency group:
[tool.poetry.group.geo]
optional = true
[tool.poetry.group.geo.dependencies]
requests = "^2.32"
shapely = "^2.0"
pyproj = "^3.6"
The top-level re-export shim in groundfield/__init__.py lazy-loads
geo.* and raises an instructive ImportError ("install with
poetry install --with geo") on first access — matching the
pattern already established by the optional groundinsight
dependency.
Data model¶
class BuildingFootprint(BaseModel):
"""A single OSM building, projected to local ENU metres."""
polygon_xy_m: list[tuple[float, float]] # closed ring, CCW
holes_xy_m: list[list[tuple[float, float]]] = []
levels: float | None = None # building:levels (may be NaN)
building_use: str | None = None # building=residential|...
osm_id: int | None = None
osm_tags: dict[str, str] = {}
Coordinates are metric in the local ENU frame defined by the user (see Projection). The model is Pydantic v2, JSON-roundtripable, and frozen — every cache hit deserialises to a bit-identical instance.
Projection¶
WGS84 → local ENU via pyproj.Proj with a user-supplied origin
(lat0_deg, lon0_deg). The origin is never inferred from data
(rejected during the Coordinate-System clarification) so two
runs against the same query at different dates produce identical
metric coordinates regardless of which buildings appear in the
result.
For radii up to a few kilometres at mid-latitudes the residual
projection distortion is \(< 10\,\text{ppm}\), two orders of
magnitude below soil-resistivity uncertainty and the OSM polygon
quantisation. This justifies a single tangent-plane projection
for the whole query — no UTM zone bookkeeping, no zone-edge
discontinuities. The exact projection string used is
+proj=aeqd +lat_0=… +lon_0=… +ellps=WGS84 (azimuthal
equidistant). The choice and its rationale are documented in
projection.py.
Overpass query and caching¶
osm.query_buildings(lat0, lon0, radius_m, *, cache_dir=…) builds
a deterministic Overpass-QL query of the form
[out:json][timeout:30];
(
way["building"](around:{r},{lat},{lon});
relation["building"](around:{r},{lat},{lon});
);
out body geom tags;
and POSTs it to the configured Overpass endpoint. The response is
written verbatim to {cache_dir}/{sha256_of_query}.json and is
read from disk on every subsequent call — Overpass is hit at most
once per (lat0, lon0, radius) tuple. The cache directory
defaults to ~/.cache/groundfield/osm/ (XDG-aware via
appdirs-style logic, implemented locally to avoid one more
dependency). Disk format is the raw Overpass JSON, not the
projected BuildingFootprints, so users can swap projections
without re-downloading.
A user-agent header groundfield/<__version__>
(https://github.com/…) is set per Overpass usage policy, and a
single retry with exponential backoff is performed on 429 /
504.
The cache and the network call are only in osm.py; the
generator layer never sees requests. This keeps the test
surface small (mock at the osm.query_buildings boundary).
Generator integration — OsmBuildingPlacement¶
A new placement variant is added to the existing
generators.placement.PlacementSpec discriminated union:
class OsmBuildingPlacement(BaseModel):
kind: Literal["osm"] = "osm"
footprints: list[BuildingFootprint] # already projected
selection: Literal["all", "first_n"] = "first_n"
def generate(self, n, rng) -> list[tuple[float, float]]: ...
def footprint_at(self, i: int) -> BuildingFootprint | None: ...
generate(n, rng) returns the polygon centroids in metric ENU,
exactly mirroring the interface of ManhattanGridPlacement and
ExplicitPlacement so no caller needs to special-case it.
footprint_at(i) is the additive hook the generator uses to
ask "is there a polygon I can use for building \(i\)?" — present
only on this variant, looked up via hasattr in the generator.
OsmBuildingPlacement does not call Overpass itself. It is
constructed from an explicit footprints list. The standard
workflow is:
footprints = geo.query_and_project(
lat0=…, lon0=…, radius_m=…,
cache_dir=…, projection_origin=(lat0, lon0),
)
placement = OsmBuildingPlacement(footprints=footprints)
Separating the network step from the placement keeps the generator config JSON-serialisable and bit-exactly reproducible without an internet round-trip.
Footprint-driven foundation electrodes — Phase A¶
FoundationElectrodeSpec.size_xy_m is currently Optional; the
existing semantics ("when set, overrides size_m") are preserved.
TnNetworkGenerator.build is extended at exactly one point:
before sampling a building's GroundingSystemSpec, if the
placement is an OsmBuildingPlacement and the per-building
spec contains a FoundationElectrodeSpec, the generator derives
three fields from the polygon and overrides the spec values
for this realisation:
size_xy_m— sides of the oriented minimum bounding rectangle (Shapely'sminimum_rotated_rectangle).offset_xy_m— polygon centroid minus the site (x, y); zero by construction in v1 (site = centroid).- footprint orientation — angle of the OMBR principal axis.
This is not currently a field on
FoundationElectrodeSpec; Phase A adds it asorientation_deg: float | None = None, defaulting toNone(= +x as today). When set, the underlyingGridMeshElectrodeis rotated about its centre by this angle. This is also useful outside the OSM path (e.g. hand-placed houses on a street that doesn't run E–W).
presence_prob is unchanged. This is the only stochastic axis
the user requested — Bernoulli per building, defaulting to
\(p_\text{install} = 1.0\). Per-realisation diversity comes from
the presence_prob draw, not from sampling geometric
distributions (the polygon fixes those).
Buildings whose polygon has fewer than four vertices, or whose
OMBR area is below a configurable threshold (default 16 m²,
matches a typical Gartenhaus), are skipped with a debug log
entry to keep TnNetworkGenerator robust to OSM mapper noise.
Footprint-driven foundation electrodes — Phase B (deferred)¶
Phase A approximates every building by its bounding rectangle.
Real L-shaped houses are covered correctly in their perimeter (by
construction OMBR ≥ polygon area; perimeter typically matches to
within 5 %) but the interior of an L-shape is not modelled.
Phase B introduces a new electrode primitive
PolygonalStripElectrode(loop_xy_m, depth_m, wire_radius_m) and
a sibling spec PolygonalFoundationElectrodeSpec(polygon, depth,
inset_m, segment_max_m) that materialises a closed Strip-chain
along polygon.buffer(-inset_m).exterior with each long segment
subdivided to segment_max_m. Phase B becomes essential once
non-rectangular Reihenhauszeilen or Gewerbehallen with complex
footprints are studied.
Phase B has no impact on the cache, projection, or placement layers and can ship as an additive change.
Validation programme¶
tests/test_geo_*.py:
- Overpass query construction — given
(lat0, lon0, radius_m)the QL string is byte-identical across runs, and the cache filename is the sha256 of that string. - Cache round-trip —
query_buildingsagainst a mocked Overpass response writes the cache file on first call and does not open a socket on the second call (verified with arequests-monkeypatch sentinel). - Projection — round-trip
(lat, lon) → ENU → (lat, lon)for ten points within a 5 km radius returns an absolute error below \(10^{-6}\) degrees (\(\approx 0.1\,\text{m}\)). - Polygon parsing — a synthetic Overpass payload with one
simple way, one way with an inner ring, and one multipolygon
relation parses to the expected
BuildingFootprintcount and ring orientation (CCW exteriors, CW holes). OsmBuildingPlacement.generate— for \(n\) ≤len(footprints), returns the first \(n\) polygon centroids in declared order. For \(n\) > available, raises with a clear message.- OMBR override — for an L-shaped polygon, the override
produces a
FoundationElectrodeSpecwithsize_xy_mmatching the OMBR sides to within \(10^{-9}\) m andorientation_degmatching the OMBR axis to within \(10^{-9}\) deg. - Reproducibility under
presence_prob— fixed RNG seed produces a bit-identical sequence of "house has / hasn't a foundation electrode" decisions across twoTnNetworkGenerator.buildcalls on the same config. - End-to-end smoke — a small (~20 houses) cached fixture
under
tests/data/osm_sample.jsonbuilds aWorldthat solves successfully withimage_2layerand produces a non-trivial PEN current distribution. Cluster impedance is compared to aManhattanGridPlacementreference at the same house count and soil — values are expected to differ (the point of the new path) but to remain within an order of magnitude. - Optional-dependency gate — importing
groundfield.geo.placementwithout thegeogroup installed raisesImportErrorwith the install hint and does not break any non-geotest.
Consequences¶
Positive¶
- Realistic layouts become a one-liner: pick a TN-Ortsnetz on the map, run the generator, the houses sit where they sit and their foundation rings face the way the real buildings face.
- The Manhattan grid stays as the bit-reproducible reference case; the OSM path is the realism case. Cross-validating the two is itself a meaningful result.
- The cache + explicit-origin projection make a Monte-Carlo run over OSM-driven worlds bit-exactly replayable without internet access after the first run.
- The data class
BuildingFootprintand the deferred Variant 2 (image recognition) share an interface — when CV-based polygon recovery becomes interesting, it slots in behind the sameOsmBuildingPlacement(footprints=…)constructor.
Negative¶
- Three new optional dependencies (
requests,shapely,pyproj). All ODbL/MIT-friendly and widely available; opt-in via the Poetry group keeps the core install lean. - OSM data quality varies.
building:levelsis sparse outside city centres; the generator must remain robust when the tag is missing (handled by defaulting toNone). - Overpass rate limits (typically 10 000 queries/day; one query per Ortsnetz is well within this). The on-disk cache makes the effective rate one query per (origin, radius) tuple per developer lifetime.
Neutral¶
- Existing
ManhattanGridPlacement/ExplicitPlacementusers are unaffected; the new placement is purely additive. FoundationElectrodeSpecgains one optional field (orientation_deg), defaulting toNone. JSON round-trips of configs written before this ADR continue to load unchanged.- ADR-0007 cross-layer-electrode preconditions cover the foundation depth range used here (0.5–1.5 m); no new numerical-kernel work is required.
References¶
- OpenStreetMap contributors (2026).
building=*tag wiki. https://wiki.openstreetmap.org/wiki/Key:building - OpenStreetMap Foundation. Open Database Licence (ODbL) v1.0. https://opendatacommons.org/licenses/odbl/1-0/
- Overpass API project documentation. https://wiki.openstreetmap.org/wiki/Overpass_API
- DIN 18014:2023-04. Fundamenterder — Planung, Ausführung
und Dokumentation. DIN, Berlin. Defines the geometric and
electrical requirements that the
FoundationElectrodeSpec(perimeter strip at 0.5–1 m depth, ring or mesh) reflects. - ADR-0009 — generator architecture; this ADR is the deferred OSM follow-up named there.
- ADR-0007 — cross-layer electrodes; ensures a foundation ring at 0.8 m straddling a thin top layer is solved correctly.