Skip to content

Geo / OSM

The geo subpackage is the bridge between the GIS world and the generator stack. It ingests building outlines from OpenStreetMap (via the Overpass API, ODbL) — and in the future from rasterised maps via image recognition — projects them into a local right-handed ENU frame in metres, and feeds them into TnNetworkGenerator through the OsmBuildingPlacement member of the PlacementSpec discriminated union.

Mathematical / physical context

A foundation electrode according to DIN 18014 is a closed conductor inside the building's Streifenfundament. Within the quasi-static regime (\(f \le 1\,\mathrm{kHz}\)), the relevant field problem is the same Laplace equation that drives the rest of groundfield (see Generators for the full PDE statement); the role of the geo layer is purely topological: which buildings exist, where are they, how are their foundation rings oriented?

Three mechanisms are sensitive to the geometry, not only to the count of houses:

  1. Galvanic dissipation per building. A foundation electrode's spreading admittance scales with the conductor length actually buried — for a closed perimeter ring that is the polygon's perimeter. The oriented minimum bounding rectangle (OMBR, see below) conserves the perimeter to within a few percent on the residential / commercial shapes we care about, so the OMBR-based reduction captures the dominant contribution to the cluster impedance.
  2. Inductive coupling to PEN and measurement leads. The orientation of the foundation ring matters for the mutual inductance to a parallel PEN trunk along the street. Below 1 kHz this is in the Carson / Sommerfeld regime (ADRs 0005 / 0006); the orientation enters the Neumann integral via the direction cosine on the wire elements.
  3. Spatial layout of the houses. Real Reihenhauszeilen do not sit on a Manhattan grid. Cluster impedance of the PEN backbone is not separable from layout — the Manhattan grid used as a reference case in ADR-0009 is the upper-bound symmetric idealisation; the OSM-driven layout is the realism case.

The OMBR projection is the Phase-A reduction defined in ADR-0011. It maps an arbitrary building outline to a rectangle by

\[ \mathrm{OMBR}(P) \;=\; \mathop{\mathrm{arg\,min}}_{R \in \mathcal{R}} \big\{ \mathrm{area}(R) : P \subseteq R,\; R \text{ is a rectangle} \big\}, \]

implemented via the rotating-calipers algorithm in shapely.geometry.Polygon.minimum_rotated_rectangle. For an L-shape this contains the polygon (so the perimeter approximation is an upper bound) and aligns with the dominant edge; for a clean rectangle it is the rectangle itself. Phase B (deferred) replaces the rectangle by a polygonal Strip chain along polygon.buffer(-inset).exterior — this is required only when non-rectangular foundation interiors become electrically relevant (typically Gewerbehallen with complex outlines).

Validity envelope

  • Frequency\(f \le 1\,\mathrm{kHz}\) (same as the rest of the generator stack).
  • Projection — azimuthal equidistant (+proj=aeqd) centred on a user-supplied origin. Residual distortion \(< 10^{-5}\) within ~5 km of the origin, well below soil-resistivity uncertainty and OSM polygon quantisation. Single tangent plane, no UTM-zone bookkeeping.
  • Originnever inferred from data. Two runs over the same area at different dates produce identical metric coordinates regardless of which features the underlying query returns. This is the reproducibility guarantee any Monte-Carlo study relies on.
  • Stochasticity — the only geometric stochastic axis preserved across the OMBR override is FoundationElectrodeSpec.presence_prob. Bernoulli per building, fully reproducible under a seeded RNG. All geometric distributions (size_m, size_xy_m, orientation_deg) are overridden by the polygon and therefore deterministic per footprint. The non-geometric fields that govern the foundation's electrical environment — in particular the concrete-shell parameters concrete_rho_ohm_m (moisture state) and concrete_thickness_m (typical 30–200 mm) from ADR-0012 — continue to accept :class:AnyDistribution, so an OSM-driven Monte-Carlo run still varies independently across the moisture classes per realisation.

Installation

The geo layer is gated behind an optional dependency group named geo (see pyproject.toml):

pip install groundfield[geo]
# or
poetry install --extras geo

The optional dependencies are :mod:requests (HTTP to the Overpass endpoint), :mod:pyproj (the projection), and :mod:shapely (the OMBR and polygon hygiene). The BuildingFootprint data class itself has zero optional dependencies and is importable on a core install — only the active functions (querying Overpass, building a Projector, computing the OMBR) need the extra. The :class:ImportError raised on first use carries the install hint verbatim.

Subpackage layout

src/groundfield/geo/
├── footprint.py    # BuildingFootprint Pydantic model
├── projection.py   # Projector (WGS84 -> local ENU)
├── osm.py          # Overpass-API client + on-disk cache
└── placement.py    # OsmBuildingPlacement (PlacementSpec member)

API reference

BuildingFootprint

Bases: BaseModel

A single building's footprint, projected to the local ENU frame.

Attributes:

Name Type Description
polygon_xy_m Ring

Vertices of the exterior ring in metres, CCW. May be passed open or closed; on validation the ring is normalised to CCW and stored open (no duplicate final vertex).

holes_xy_m list[Ring]

Interior rings (holes) in metres, CW. Each hole is normalised on validation. Empty list means no holes (the typical residential case).

levels Optional[float]

building:levels tag if present (often missing in OSM). None denotes a missing value; downstream consumers default to a heuristic such as levels_default=2 for residential.

building_use Optional[str]

Value of the building=* tag (e.g. "residential", "apartments", "industrial", "yes"). Kept as the raw OSM string so the consumer can map it to a :class:generators.building.BuildingTypeSpec at will.

osm_id Optional[int]

Original OSM way / relation id. Useful for tracing back to the source feature for debugging.

osm_tags dict[str, str]

Untouched OSM key/value tag dictionary. Lets consumers extract additional information (e.g. addr:housenumber, building:material) without going back to the source.

Notes

The instance is immutable (frozen=True) so that a :class:generators.placement.OsmBuildingPlacement can rely on the polygon not changing while the generator iterates over sites.

area_m2

area_m2() -> float

Signed-area-based polygon area in m² (holes subtracted).

Returns:

Type Description
float

area(exterior) - sum(area(holes)), always non-negative because the rings have been normalised to CCW / CW on validation.

axis_aligned_bounding_rectangle

axis_aligned_bounding_rectangle() -> (
    tuple[tuple[float, float], tuple[float, float]]
)

Axis-aligned bounding rectangle of the exterior ring.

Returns:

Type Description
tuple

((cx, cy), (dx, dy)) — centre of the bounding box and its side lengths in metres. cx, cy are the means of the min/max coordinates; dx, dy are the absolute extents.

Notes

Pure-Python implementation; no :mod:shapely dependency. Used as a fall-back when the optional geo extra is not installed; for a fully rotated foundation electrode use :meth:oriented_bounding_rectangle instead.

centroid_xy_m

centroid_xy_m() -> Point2D

Geometric centroid of the exterior ring in metres.

Notes

Holes are ignored — for the residential / commercial building shapes we care about, the moment shift caused by a hole is at most a few centimetres and irrelevant to the soil-spreading calculation. Implements

.. math::

C_x = \frac{1}{6A}\sum_{i=0}^{n-1} (x_i + x_{i+1})(x_i y_{i+1} - x_{i+1} y_i),

analogously for :math:C_y.

oriented_bounding_rectangle

oriented_bounding_rectangle() -> (
    tuple[tuple[float, float], tuple[float, float], float]
)

Oriented minimum bounding rectangle (OMBR) of the exterior ring.

Returns:

Type Description
tuple

((cx, cy), (dx, dy), orientation_deg) — centre, side lengths (dx along the local long axis, dy perpendicular), and rotation of the long axis with respect to the world +x direction in degrees. orientation_deg lies in [-90, 90].

Notes

Uses Shapely's Polygon.minimum_rotated_rectangle, which implements the rotating-calipers algorithm. Requires the optional geo extra; raises :class:ImportError with the install hint otherwise.

Physical interpretation

The OMBR is the canonical projection of an arbitrary building outline onto a Streifenfundament (DIN 18014): the foundation ring follows the OMBR's perimeter, so its side lengths fix the conductor length that contributes to the galvanic spreading admittance, and the orientation fixes the angle relative to the PEN trunk for the inductive-coupling calculation. For a typical residential L-shape the OMBR captures the dominant edge alignment within a few degrees; full polygonal Strip chains are deferred to Phase B (ADR-0011).

Projector

Projector(
    lat0_deg: float,
    lon0_deg: float,
    *,
    ellps: str = "WGS84"
)

WGS84 (longitude, latitude) <-> local ENU (x_m, y_m) projection.

Parameters:

Name Type Description Default
lat0_deg float

Latitude and longitude of the projection origin in decimal degrees, WGS84. The origin is the point that maps to (x_m, y_m) = (0, 0). The user picks it explicitly — it is never inferred from the data (see ADR-0011) so two runs over the same area at different dates produce identical metric coordinates regardless of which features appear in the underlying query.

required
lon0_deg float

Latitude and longitude of the projection origin in decimal degrees, WGS84. The origin is the point that maps to (x_m, y_m) = (0, 0). The user picks it explicitly — it is never inferred from the data (see ADR-0011) so two runs over the same area at different dates produce identical metric coordinates regardless of which features appear in the underlying query.

required
ellps str

Ellipsoid name accepted by :mod:pyproj. Defaults to "WGS84"; passed straight through.

'WGS84'
Notes

The projection is azimuthal equidistant (+proj=aeqd) centred on (lat0, lon0) with the WGS84 ellipsoid. For distances :math:r \lesssim 5 \, \mathrm{km} around the origin the residual distortion is below :math:10^{-5} (a few centimetres on a kilometre baseline), which is two orders of magnitude below typical OSM polygon quantisation. Conformality is not preserved exactly — for our use case (placing electrodes and measuring distances between them) equal distance to the origin is the relevant invariant, not angle preservation.

The projection is not thread-safe: each :class:Projector holds two :class:pyproj.Transformer instances internally; instantiate one per worker if you parallelise.

origin

origin() -> tuple[float, float]

Return the origin as (lat_deg, lon_deg).

ring_to_xy_m

ring_to_xy_m(
    lat_lon: list[tuple[float, float]]
) -> list[tuple[float, float]]

Vectorised forward projection of a list of (lat, lon).

Parameters:

Name Type Description Default
lat_lon list[tuple[float, float]]

List of WGS84 (lat_deg, lon_deg) tuples.

required

Returns:

Type Description
list[tuple[float, float]]

Same length, each tuple in local ENU metres.

to_lat_lon

to_lat_lon(x_m: float, y_m: float) -> tuple[float, float]

Inverse of :meth:to_xy_m: local ENU -> WGS84.

Parameters:

Name Type Description Default
x_m float

Local ENU coordinates in metres.

required
y_m float

Local ENU coordinates in metres.

required

Returns:

Type Description
tuple[float, float]

(lat_deg, lon_deg) in decimal degrees.

to_xy_m

to_xy_m(
    lat_deg: float, lon_deg: float
) -> tuple[float, float]

Project a single point WGS84 -> local ENU.

Parameters:

Name Type Description Default
lat_deg float

WGS84 coordinates of the point in decimal degrees.

required
lon_deg float

WGS84 coordinates of the point in decimal degrees.

required

Returns:

Type Description
tuple[float, float]

(x_m, y_m) in the local ENU frame. x points east, y points north, z (not returned) points up.

build_query

build_query(
    lat0_deg: float,
    lon0_deg: float,
    radius_m: float,
    *,
    timeout_s: int = DEFAULT_TIMEOUT_S
) -> str

Construct the Overpass-QL query string for a buildings-in-radius lookup.

Parameters:

Name Type Description Default
lat0_deg float

Centre of the lookup in WGS84 decimal degrees.

required
lon0_deg float

Centre of the lookup in WGS84 decimal degrees.

required
radius_m float

Search radius in metres. Must be positive.

required
timeout_s int

Query timeout passed to Overpass via [timeout:…].

DEFAULT_TIMEOUT_S

Returns:

Type Description
str

A deterministic Overpass-QL query. The string is byte-identical across runs given the same inputs, so its SHA-256 hash is a stable cache key.

query_buildings

query_buildings(
    lat0_deg: float,
    lon0_deg: float,
    radius_m: float,
    *,
    cache_dir: Optional[Path] = None,
    endpoint: str = DEFAULT_ENDPOINT,
    timeout_s: int = DEFAULT_TIMEOUT_S,
    force_refresh: bool = False,
    max_retries: int = 1,
    _post: Any = None
) -> dict[str, Any]

Fetch (or load from cache) the raw Overpass response.

Parameters:

Name Type Description Default
lat0_deg float

Centre of the buildings-in-radius lookup, WGS84 degrees.

required
lon0_deg float

Centre of the buildings-in-radius lookup, WGS84 degrees.

required
radius_m float

Search radius in metres.

required
cache_dir Optional[Path]

Directory in which the response is cached. Defaults to :func:default_cache_dir. Created on first write.

None
endpoint str

Overpass endpoint URL. Defaults to the canonical mirror.

DEFAULT_ENDPOINT
timeout_s int

Per-query timeout passed to Overpass.

DEFAULT_TIMEOUT_S
force_refresh bool

If True, ignore any cached payload and re-query the endpoint, overwriting the cache file. Useful when the user knows the upstream data changed.

False
max_retries int

Number of retry attempts on 429 / 504 responses. Defaults to 1 (one retry, matching the historic behaviour). Raise to two or three for flaky cellular / VPN links; set to 0 to disable retries entirely. Must be >= 0.

1
_post Any

Internal hook for tests: a callable with the same signature as :func:_post_overpass. Production calls use the default.

None

Returns:

Type Description
dict[str, Any]

The decoded Overpass JSON payload. The shape is {"version": …, "generator": …, "elements": [...]}; only elements is required downstream.

Notes

The cache filename is <sha256(query)>.json; ergo two distinct queries that happen to round-trip to the same string share a cache file, and two semantically identical queries with different float formatting do not — see :func:build_query for the fixed formatting convention that guarantees stability.

parse_overpass_payload

parse_overpass_payload(
    payload: dict[str, Any],
    projector: Projector,
    *,
    min_area_m2: float = 0.0
) -> list[BuildingFootprint]

Convert an Overpass JSON payload into projected :class:BuildingFootprint instances.

Parameters:

Name Type Description Default
payload dict[str, Any]

Overpass JSON as returned by :func:query_buildings.

required
projector Projector

Projection from WGS84 to the local ENU frame. The same projector should be reused across all consumers of one payload to keep the metric coordinates consistent.

required
min_area_m2 float

Skip features whose footprint area is strictly below this threshold. 0.0 (the default) keeps every parseable polygon; the placement layer may apply its own (higher) threshold via :class:OsmBuildingPlacement.min_area_m2.

0.0

Returns:

Type Description
list[BuildingFootprint]

Order matches the Overpass element order, which Overpass keeps deterministic for the same query.

Notes

Multipolygon relations (type=multipolygon) are handled by walking the relation's members and treating each outer way as an independent footprint with the inner ways of the same relation attached as holes. Relations without an outer member are skipped.

query_and_project

query_and_project(
    lat0_deg: float,
    lon0_deg: float,
    radius_m: float,
    *,
    projector: Optional[Projector] = None,
    cache_dir: Optional[Path] = None,
    endpoint: str = DEFAULT_ENDPOINT,
    timeout_s: int = DEFAULT_TIMEOUT_S,
    min_area_m2: float = 0.0,
    force_refresh: bool = False,
    max_retries: int = 1
) -> tuple[list[BuildingFootprint], Projector]

Run :func:query_buildings and :func:parse_overpass_payload end-to-end.

Parameters:

Name Type Description Default
lat0_deg float

Buildings-in-radius parameters.

required
lon0_deg float

Buildings-in-radius parameters.

required
radius_m float

Buildings-in-radius parameters.

required
projector Optional[Projector]

If None, a new :class:Projector centred at (lat0_deg, lon0_deg) is created. Pass an existing projector to keep the metric frame consistent across multiple calls (useful when a study spans several overlapping radii).

None
cache_dir Optional[Path]

Forwarded to :func:query_buildings.

None
endpoint Optional[Path]

Forwarded to :func:query_buildings.

None
timeout_s Optional[Path]

Forwarded to :func:query_buildings.

None
force_refresh Optional[Path]

Forwarded to :func:query_buildings.

None
min_area_m2 float

Skip features below this footprint area. Defaults to 0.0 (keep everything; the placement layer applies its own threshold).

0.0
max_retries int

Number of retry attempts on 429 / 504 responses. Forwarded to :func:query_buildings. Defaults to 1.

1

Returns:

Type Description
tuple[list[BuildingFootprint], Projector]

Projected footprints and the projector used. The projector is returned so the caller can re-use it for further coordinate transforms (e.g. plotting a substation marker at a known lat/lon).

OverpassError

OverpassError(
    message: str,
    *,
    status_code: Optional[int] = None,
    body: str = ""
)

Bases: RuntimeError

Raised on a non-success Overpass response.

Attributes:

Name Type Description
status_code

HTTP status code if available, else None.

body

First 1 kB of the response body for diagnostic purposes.

OsmBuildingPlacement

Bases: BaseModel

Placement driven by a list of pre-projected building footprints.

The class implements the same generate(n, rng) -> list[(x, y)] interface as :class:generators.placement.ManhattanGridPlacement and :class:generators.placement.ExplicitPlacement, so any consumer that accepts a :class:PlacementSpec will accept this placement too (once it has been added to the discriminated union; see Task 3 in ADR-0011's implementation plan).

Attributes:

Name Type Description
footprints list[BuildingFootprint]

Pre-projected building polygons in the local ENU frame. Order is preserved; the k-th footprint corresponds to the k-th building site.

min_area_m2 float

Skip footprints whose area is strictly below this threshold on :meth:generate. The default of 16 m² is roughly a garden shed; everything smaller is almost certainly OSM noise rather than a residential structure with a foundation electrode.

selection Literal['first_n', 'all']

How to choose footprints when the generator requests fewer than are available:

  • "first_n" (default) — take the first n in declared order. Fully deterministic.
  • "all" — return all footprints regardless of n. Useful when the user wants every visible building to appear in the world; n becomes a soft hint.
Notes

The class is intentionally a plain Pydantic BaseModel with a kind discriminator field; it does not inherit from a common PlacementSpec ABC because the existing union uses structural typing via the kind literal. Adding this class to generators.placement.PlacementSpec is a one-line union update tracked as a follow-up task.

n_footprints property

n_footprints: int

Number of footprints surviving the min_area_m2 filter.

Identical to len(placement) and provided as an explicit attribute so consumers can pre-flight an i against :meth:footprint_at without invoking len() on the model instance.

__len__

__len__() -> int

Number of footprints after applying min_area_m2.

footprint_at

footprint_at(
    i: int, *, strict: bool = False
) -> Optional[BuildingFootprint]

Return the footprint associated with site i.

Parameters:

Name Type Description Default
i int

Site index, matching the order returned by :meth:generate.

required
strict bool

If False (default), a out-of-range i returns None so callers can fall back to the spec-defined geometry without special-case logic — the historic v0.6.0 contract that :meth:TnNetworkGenerator.build relies on.

If True, an out-of-range i raises a self-describing :class:IndexError that names the placement, the requested index, the size of the filtered footprint list (after min_area_m2) and the raw list length. Generator pipelines that want a hard failure instead of a silent fall-back can opt in with strict=True and get an actionable traceback at the call site.

False

Returns:

Type Description
BuildingFootprint or None

The footprint at index i (after applying the min_area_m2 filter), or None when i is out of range and strict is False.

Raises:

Type Description
IndexError

If strict=True and i is outside [0, n_footprints).

generate

generate(
    n: int, rng: "np.random.Generator"
) -> list[tuple[float, float]]

Return n site positions as polygon centroids.

Parameters:

Name Type Description Default
n int

Number of sites the generator wants to populate.

required
rng 'np.random.Generator'

Unused. Accepted for interface parity with the other :class:PlacementSpec variants, all of which take an RNG so the generator can call them uniformly.

required

Returns:

Type Description
list[tuple[float, float]]

Polygon centroids in metres. Length is min(n, len(filtered)) when selection == "first_n" and equal to len(filtered) when selection == "all" (n is then ignored, with a soft contract).

Raises:

Type Description
ValueError

If n exceeds the available footprints and selection == "first_n". The generator can decide whether to widen its search radius or lower its n-target.

Worked example

A full end-to-end demo lives in the OSM-pipeline example in the docs. It synthesises six rotated rectangles (so the notebook needs no internet), feeds them into TnNetworkGenerator, compares the resulting cluster impedance against a Manhattan-grid reference with the same building count, and finishes with an optional live Overpass query (cached locally, gracefully skipped when the geo extra is absent or no network is available).