Skip to content

Generators

The generators subpackage is the factory layer that converts high-level parameter sets into a fully populated :class:groundfield.World. It is the bridge between parameter studies (typical, future Monte-Carlo runs) and the physics layer (soil, electrodes, conductors, solver).

Mathematical / physical context

For typical the relevant physics is

\[ \nabla \cdot \big( \sigma(z) \, \nabla \Phi \big) \;=\; 0 \quad \text{in } \mathbb{R}^3 \setminus \text{electrodes}, \]

with two-layer soil \(\sigma(z)\) and Dirichlet conditions on the metallic surfaces. Below 1 kHz the inductive correction is added on top via Carson (ADR-0005) or Sommerfeld (ADR-0006). The role of a generator is purely topological: it produces a concrete electrode-and-conductor layout the solver can integrate over. No physics happens in this subpackage.

The typical parameter axes that map directly onto :class:TnNetworkConfig fields are summarised below. TnNetworkConfig has been refactored onto a composable spec layer (ADR-0009 v2); building mixes are now declared via building_types (a catalog of :class:BuildingTypeSpec) and building_counts (per-type quantities), and every grounding system is built from a list of :class:ElectrodeSpec entries:

Axis default grid Config field
Single-family houses \(n_\text{EFH}\) 5, 10, 30, 80, 200 building_counts["residential"]
Small commercial buildings 0, 1, 5, 10 building_counts["small_industry"]
Medium commercial buildings 0, 1, 2, 5 building_counts["medium_industry"]
Cable cabinets placement spec kvs.electrodes + placement
Substation grounding ring / rod-circle / strip / foundation substation.electrodes
Upper-layer resistivity \(\rho_1\) 30, 100, 200, 500, 1000 Ω·m soil.rho_1
Lower-layer resistivity \(\rho_2\) 30, 100, 200 Ω·m soil.rho_2
Layer thickness \(h_1\) 5, 10, 30 m soil.h_1

Module rename

The module was originally called groundfield.generators.tn_ortsnetz and exposed the classes TnOrtsnetzGenerator / TnOrtsnetzConfig. The English-only naming convention renamed the module to groundfield.generators.tn_network with :class:TnNetworkGenerator / :class:TnNetworkConfig. The old module path still works as a deprecated re-export and emits a DeprecationWarning. Prefer importing from the top-level groundfield namespace.

Every numeric field accepts either a fixed value or a :class:Distribution (continuous: Uniform, Normal, LogNormal, Weibull; discrete: Discrete, Categorical, plus the trivial Constant). cfg.sample(rng) resolves every distribution reproducibly given a fixed seed.

Validity envelope

  • Frequency: \(f \le 1\,\mathrm{kHz}\) (quasi-static).
  • Soil: linear, isotropic, layered. Saturation / ionisation are not modelled.
  • Topology of :class:TnNetworkGenerator: radial-with-trunk via cable cabinets — every house connects to its nearest cable cabinet, every cable cabinet connects to the substation. Real street layouts are now supported via :class:~groundfield.geo.placement.OsmBuildingPlacement (see ADR-0011 and the geo API).
  • Per-building-type grounding is now type-driven via :class:BuildingTypeSpec and supports multi-electrode systems (foundation + extra rod, ring + grid + strips, …) with per-electrode presence_prob for stochastic fleets.

Foundation electrodes: orientation and concrete shell

Two extensions to :class:FoundationElectrodeSpec since 0.6.0 round out the foundation modelling envelope:

  • orientation_deg: float | None = None rotates the foundation rectangle around its centre. None and 0.0 both keep the historic axis-aligned :class:GridMeshElectrode fast path; any other value synthesises the foundation from rotated :class:StripElectrodes and bonds them internally. Set automatically from the OMBR of an OSM polygon when the configured placement is :class:~groundfield.geo.placement.OsmBuildingPlacement.
  • concrete_rho_ohm_m: float | AnyDistribution | None = None and concrete_thickness_m activate the concrete-encasement model (ADR-0012). None keeps the wire-in-soil baseline; setting concrete_rho_ohm_m switches to the cylindrical Sunde-shell model with the chosen radial thickness. The concrete_model: Literal["lumped", "distributed"] discriminator selects between a lumped series resistance on the PEN service drop (V1, default — zero solver-side change, exact for the cluster impedance when current distributes uniformly) and a per-segment diagonal augmentation in the image / image_2layer backends (V2 — correct for non-uniform current distributions and for the surface potential right at the building wall). Stochastic moisture is supported via concrete_rho_ohm_m=Discrete(values=[50, 150, 500, 2000], weights=[0.25, 0.40, 0.25, 0.10]).

The other electrode kinds (Rod, Ring, Strip, Mesh, GridMesh) do not carry the concrete fields by design — they correspond to electrodes that sit in trenches or driven holes, never in a concrete strip foundation.

Architecture

GeneratorConfig (Pydantic v2)
└── per-generator subclass (TnNetworkConfig, MvStrandConfig, ...)
    ├── numeric fields:   T | AnyDistribution
    ├── categorical:      str | Categorical
    └── nested configs (composable):
        ├── SoilSpec        (Homogeneous | TwoLayer | MultiLayer)
        ├── PlacementSpec   (Manhattan | Explicit)
        ├── GroundingSystemSpec (list[ElectrodeSpec])
        │   └── ElectrodeSpec (Rod | Ring | Strip | Foundation)
        └── BuildingTypeSpec (name + grounding + plot_size)

WorldGenerator[Generic[C]]   (ABC)
└── per-generator subclass (TnNetworkGenerator, ...)
    └── .build(cfg)  -> World
        strict mode:  guarded by ._assert_resolved(cfg)
        lazy mode:    resolves Distribution fields on demand
                      (TnNetworkGenerator chooses lazy mode)

WorldGenerator.sample_world(rng) -> tuple[World, ResolvedConfig]

Reproducibility: persist the resolved config alongside the result. With the same seed and the same library version the World is bit-exactly reproducible.

Lazy vs. up-front resolution

:class:TnNetworkGenerator deliberately supports two patterns:

  • gen.build(cfg) — lazy. Top-level numeric distributions get one draw per call. Per-instance distributions like a :class:Categorical house_electrode.kind get a fresh draw per house, producing a real mix.
  • gen.sample_world(rng) — up-front. cfg.sample collapses every distribution to a single value, including the Categorical; all houses then share the resolved kind. Use this when you need a persistable resolved config for one Monte-Carlo realisation.

Distribution catalogue

Class Backend Typical use
Constant trivial placeholder
Uniform(low, high) numpy bounded continuous parameters
Normal(mean, std, truncate_low?, truncate_high?) rejection sampling engineering tolerances
LogNormal(mu, sigma) (or LogNormal.from_moments) numpy resistivities, sizes
Weibull(shape, scale) numpy wear / lifetime
Discrete(values, weights?) numpy choice n_efh ∈ {5,10,30,80,200}
Categorical(values, weights?) numpy choice electrode-kind mix per house

All seven distributions are JSON-serialisable through a discriminator field kind. Persisting a stochastic config preserves every distribution exactly; replaying with the same seed yields the same world.

See ADR-0009 for the full design rationale, validation programme, and roadmap for follow-up generators.

Example

Two equivalent ways to drive :class:TnNetworkGenerator. Both build a fully wired :class:World ready for world.solve(engine).

import numpy as np
import groundfield as gf

# 1. Deterministic build — every parameter fixed.
cfg = gf.TnNetworkConfig(
    soil=gf.TwoLayerSoilSpec(rho_1=100.0, rho_2=500.0, h_1=2.0),
    building_counts={"residential": 10},  # 10 single-family houses
    source_magnitude_A=1.0,
)
gen = gf.TnNetworkGenerator()
world = gen.build(cfg)

# 2. Stochastic build — Categorical electrode kind per house,
#    Uniform soil resistivity, reproducible under a fixed seed.
cfg_stoch = gf.TnNetworkConfig(
    soil=gf.TwoLayerSoilSpec(
        rho_1=gf.Uniform(low=80.0, high=300.0),
        rho_2=500.0,
        h_1=2.0,
    ),
    building_counts={"residential": 30},
    source_magnitude_A=1.0,
)

rng = np.random.default_rng(seed=42)

# Lazy resolution — Categorical fields draw once per house,
# producing a real electrode-kind mix across the fleet.
world_lazy = gen.build(cfg_stoch, rng=rng)

# Strict / up-front resolution — every distribution collapses to a
# single value before build, so all houses share the same kind.
# Returns the resolved config too, which can be persisted alongside
# the result for bit-exact replay.
world_strict, resolved_cfg = gen.sample_world(rng=np.random.default_rng(seed=42))

Persist the resolved config (cfg.model_dump_json()) or the seed alongside the simulation output to guarantee reproducibility.

PEN backbone topology

The PEN backbone of a :class:TnNetworkGenerator run is selected via the new :class:TnNetworkConfig.pen_topology field (discriminated union, default :class:StarKvsTopology):

  • :class:StarKvsTopology (default) — legacy v0.6 behaviour: every KVS is wired directly to the substation, every building taps to its nearest KVS by Manhattan distance.
  • :class:RadialTrunkTopology — the substation feeds n_feeders axis-aligned LV feeders (default 4, evenly spaced) each with a finite slot budget at the substation; once exhausted, additional KVS are inserted along the trunk axis. Buildings are assigned to the angularly-closest feeder; buildings beyond max_feeder_length_m are dropped with a :class:UserWarning. See the radial trunk example in the docs for a bus-topology demonstration.

OrtsnetzLayout — imperative builder

Where :class:TnNetworkGenerator produces stochastic reference worlds from population-level parameters, :class:OrtsnetzLayout composes a single deterministic network piece by piece — in the order an engineer would draw it on a plan. It is the recommended entry point when the network is known from a real OSM extract plus a hand-placed substation, and the statistical sweep approach is too rigid.

Workflow

import groundfield as gf
from groundfield.generators import OrtsnetzLayout

# 1. OSM ingest + anchor placement, all in one call.
layout = OrtsnetzLayout.from_osm(
    center_lat_deg=51.9136, center_lon_deg=10.8432,  # Mulmke
    radius_m=200.0,
    substation_xy=(0.0, -12.0),     # ENU metres relative to centre
    kvs_xys=[(40.0, -12.0), (80.0, -12.0)],
    obstacle_clearance_m=1.0,
    min_area_m2=20.0,
)

# 2. PEN cables in strict Manhattan geometry. Endpoints can be
#    anchor names, free ENU metres, or WGS84 lat/lon -- pick
#    whichever style matches your map source.
layout.add_pen_cable(start='substation', end='kvs_0',
                     min_segment_length_m=8.0)
layout.add_pen_cable(start='kvs_0', end='kvs_1',
                     min_segment_length_m=8.0)
layout.add_pen_cable(start='substation',
                     end_xy=(0.0, 60.0),
                     min_segment_length_m=8.0)

# 3. Tap every house to its closest cable.
layout.connect_buildings()

# 4. Simulated fall-of-potential measurement.
layout.add_auxiliary_electrode(distance_m=200.0, direction_deg=0.0)
layout.add_voltage_probe(inline_fraction=0.5)

# 5. Reproducible foundation-electrode penetration.
mask = layout.foundation_mask(0.30)   # always returns the same houses

# 6. Materialise + solve.
world = layout.to_world(foundation_mask=mask,
                        source_magnitude_A=1.0)
engine = gf.create_engine(backend='image',
                          segment_length=0.5, frequencies=[50.0])
result = engine.solve(world)

# 7. Read the simulated meter reading + Kirchhoff plausibility.
Z = layout.measured_grounding_impedance(result)
balance = layout.verify_current_balance(result)
assert balance['ok']

# 8. Render: layout view + surface-potential plot.
ax = layout.plot(foundation_mask=mask)
fig = layout.plot_surface_potential(result, world)   # TwoSlopeNorm

Routing semantics

PEN cables are routed by :func:groundfield.generators.manhattan_routing.route_manhattan, a 4-connected A on a Manhattan grid of cell size min_segment_length_m that avoids every building's bounding rectangle (inflated by obstacle_clearance_m). When an anchor lands inside or right next to a foundation polygon — typical in real OSM extracts — the escape_radius_m parameter defines a disk around the anchor inside which obstacle blocking is not* enforced. Default: one grid cell; pass a larger value when the anchor is wedged into a dense city block.

Foundation-electrode penetration mask

:meth:OrtsnetzLayout.foundation_mask(penetration, salt=0) is the project-wide answer to "which houses have a Fundamenterder at this penetration rate \(p\)?". The decision is derived from a stable MD5 hash of each footprint's osm_id (with the footprint index as a fall-back for synthetic footprints), so the mask is:

  • Reproducible. Calling with the same (p, salt) always returns the bit-identical mask, across runs, processes and Python versions.
  • Nested in \(p\). Every house with a foundation at \(p_1\) also has one at any \(p_2 > p_1\) — increasing the penetration only grows the equipped subset.

salt lets the user draw several independent Monte-Carlo realisations at the same nominal penetration.

Measurement-loop semantics

When an :class:AuxiliaryElectrodePlacement is configured, :meth:OrtsnetzLayout.to_world adds a second :class:CurrentSource at the auxiliary anchor with phase_deg=180. The engine's net injected charge is then zero and the potential field develops the expected dipole pattern: positive trumpet at the substation, negative trumpet at the Hilfserder. Source.return_to on the substation-side source is preserved (it documents the planner's intent and will be honoured natively once the solver grows return-current support); the extra source is the v0.7 workaround for the image backend ignoring return_to.

The voltage probe is not materialised as an electrode in the world. :meth:measured_grounding_impedance samples the field at the probe location via :meth:FieldResult.potential — exactly what an ideal voltmeter would measure, with no perturbation of the field by a finite-impedance probe rod.

API reference

generators

World generators for reference networks.

This subpackage hosts the factory layer that turns high-level parameter sets — for example "N single-family houses on a two-layer soil" — into a fully populated :class:groundfield.World that the solver can consume.

The framework is organised in five composable spec layers:

  • :mod:~groundfield.generators.distributions — Constant, Uniform, Normal, LogNormal, Weibull, Discrete, Categorical. Each numerical / categorical field of any spec class accepts a fixed value or a :class:Distribution.
  • :mod:~groundfield.generators.electrode_specs — single-electrode specs (rod / ring / strip / foundation) with presence_prob and offset_xy_m.
  • :mod:~groundfield.generators.grounding — :class:GroundingSystemSpec composes a list of :data:ElectrodeSpec into one cluster.
  • :mod:~groundfield.generators.placement — Manhattan-grid and explicit-coordinate placement strategies.
  • :mod:~groundfield.generators.soil_specs — homogeneous / two-layer / multi-layer soil specs that resolve to the :mod:groundfield.soil.models types.
  • :mod:~groundfield.generators.building — :class:BuildingTypeSpec bundles a name with a grounding system and an optional plot size.

The first concrete generator, :class:~groundfield.generators.tn_network.TnNetworkGenerator, composes all of the above into a TN low-voltage network.

See docs/adr/0009-world-generators.md for the design rationale and the validation programme.

ElectrodeSpec module-attribute

ElectrodeSpec = Annotated[
    Union[
        RodElectrodeSpec,
        RingElectrodeSpec,
        StripElectrodeSpec,
        FoundationElectrodeSpec,
    ],
    Field(discriminator="kind"),
]

JSON-serialisable union of electrode specs.

Use this alias as the field type in any :class:GeneratorConfig that holds a list of electrodes (e.g. electrodes: list[ElectrodeSpec]). Pydantic dispatches on kind.

PenTopology module-attribute

PenTopology = Annotated[
    Union[StarKvsTopology, RadialTrunkTopology],
    Field(discriminator="kind"),
]

JSON-serialisable union of PEN topologies.

Add new topology classes here when a future Ortsnetz layout (meshed-LV, ring, mixed) is implemented; the :class:~groundfield.generators.tn_network.TnNetworkGenerator dispatches on :attr:kind.

PlacementSpec module-attribute

PlacementSpec = Annotated[
    Union[
        ManhattanGridPlacement,
        ExplicitPlacement,
        OsmBuildingPlacement,
    ],
    Field(discriminator="kind"),
]

JSON-serialisable union of placement strategies.

SoilSpec module-attribute

SoilSpec = Annotated[
    Union[
        HomogeneousSoilSpec,
        TwoLayerSoilSpec,
        MultiLayerSoilSpec,
    ],
    Field(discriminator="kind"),
]

JSON-serialisable union of soil specs (homogeneous / two-layer / multi-layer).

AuxiliaryElectrodePlacement

Bases: BaseModel

Auxiliary current electrode (Hilfserder) for a fall-of- potential measurement of the substation grounding.

The auxiliary electrode closes the test-current loop. It is materialised in :meth:OrtsnetzLayout.to_world via :meth:GroundingSystemSpec.build_at, giving the user full control over its geometry (single rod, ring, mesh, foundation, or any combination). The default is a bundle of three 0.5 m rods in a 0.5 m equilateral triangle, parallel-bonded — matching real-world Hilfserder practice and yielding a spreading resistance roughly a third of a single rod.

During :meth:OrtsnetzLayout.to_world the layout adds a second :class:CurrentSource with phase_deg=180 at the anchor electrode so the engine's potential field develops the expected dipole pattern (positive trumpet at the substation, negative trumpet at the Hilfserder).

BuildingConnection

Bases: BaseModel

Stub from one house's foundation centre to its serving cable.

Attributes:

Name Type Description
house_idx int

Index into :attr:OrtsnetzLayout.footprints.

cable_name str

Name of the :class:PenCable the stub taps into.

stub_waypoints list[tuple[float, float]]

Polyline from the house centroid to the tap point on the cable. [centroid, ..., tap_point]. The interior waypoints (if any) are the Manhattan corners.

tap_point_xy tuple[float, float]

The actual tap point on the cable polyline.

BuildingTypeSpec

Bases: GeneratorConfig

Definition of one building type.

Attributes:

Name Type Description
name str

Identifier used as the lookup key in :attr:groundfield.generators.tn_network.TnNetworkConfig.building_counts and as a prefix for electrode names.

grounding GroundingSystemSpec

:class:GroundingSystemSpec describing the type's earthing installation. Every electrode in the system is optional via presence_prob and may carry distributions for its geometric parameters.

plot_size_m Optional[tuple[float, float]]

Optional typical plot size \((dx, dy)\) in metres. v1 of the TN network generator does not consume this — it is exposed so future placement strategies (street-grid, OSM-driven) can pack buildings with type-aware footprints.

description Optional[str]

Free-form note describing the type (use case, source of the geometry, …). Stored verbatim, not validated.

Categorical

Bases: Distribution

Categorical distribution over a finite string set.

Used for non-numeric parameters such as the electrode kind per house (\(\in \{\text{foundation}, \text{rod}, \text{mesh}\}\)). weights biases the choice; uniform if omitted.

Constant

Bases: Distribution

Degenerate distribution returning value deterministically.

Useful as a placeholder or in code paths that always expect a :class:Distribution instance.

Discrete

Bases: Distribution

Discrete numerical distribution over a finite value set.

Mirrors typical parameter axes such as \(n_\text{EFH} \in \{5, 10, 30, 80, 200\}\). weights may be supplied to bias the choice; if omitted the values are sampled uniformly.

Distribution

Bases: BaseModel

Abstract base for probability distributions.

Subclasses must:

  1. set a literal kind field (used as the JSON discriminator),
  2. implement :meth:sample returning a single draw.

The base class is intentionally a regular Pydantic model rather than an :class:abc.ABC so it composes cleanly with discriminated unions in :class:pydantic.BaseModel field annotations.

ExplicitPlacement

Bases: BaseModel

Explicit caller-supplied list of \((x, y)\) positions.

The list must be at least as long as the requested count; extras are ignored. Order is preserved (so the k-th building from the count list ends up at the k-th position).

FoundationElectrodeSpec

Bases: _ElectrodeSpecBase

Rectangular foundation electrode (Fundamenterder).

A rectangle of side length size_m (or size_xy_m for an asymmetric footprint) at depth depth_m. The internal structure is selected by :attr:style:

  • "ring" — only the rectangle's perimeter is buried (a closed wire loop). No internal cross-bracing. n_x and n_y are ignored. Use this for the Ringerder-style foundation electrode in residential buildings where only the strip foundation is electrically connected.
  • "mesh" (default) — perimeter plus horizontal and vertical cross-braces, dividing the foundation into n_x × n_y cells. The Maschenerder-style foundation electrode found in larger buildings, transformer stations and industrial sites. With n_x = n_y = 2 (the default) the rectangle gets exactly one internal horizontal plus one internal vertical wire.

Both styles materialise as a :class:groundfield.geometry.electrodes.GridMeshElectrode; the "ring" style is just the special case n_x = n_y = 1 of that primitive (one mesh = perimeter only).

For an asymmetric footprint set size_xy_m instead of size_m (when both are given, size_xy_m wins).

GeneratorConfig

Bases: BaseModel

Base class for generator configurations.

Subclasses declare their parameters as Pydantic fields. Numerical fields are typed as T | <distribution alias> so the user can pass a fixed value or a :class:Distribution. Categorical fields use str | Categorical.

Two introspection helpers are provided:

  • :meth:has_distributions returns True if any field still carries a :class:Distribution instance — useful as a guard before passing the config to :meth:WorldGenerator.build.
  • :meth:sample returns a copy of the config with every :class:Distribution field resolved to a concrete value via its .sample(rng) method. Nested :class:GeneratorConfig sub-fields are recursed into; lists are processed element-wise.

has_distributions

has_distributions() -> bool

Return whether any field still carries a :class:Distribution.

Recursive: a nested :class:GeneratorConfig whose own field carries a distribution counts as "has distributions".

Source code in src/groundfield/generators/base.py
def has_distributions(self) -> bool:
    """Return whether any field still carries a :class:`Distribution`.

    Recursive: a nested :class:`GeneratorConfig` whose own field
    carries a distribution counts as "has distributions".
    """
    for value in self.__dict__.values():
        if _has_dist(value):
            return True
    return False

sample

sample(
    rng: Optional[Union[int, np.random.Generator]] = None
) -> "GeneratorConfig"

Return a copy with every :class:Distribution field resolved.

Each distribution is replaced by exactly one .sample(rng) draw. Non-distribution fields are passed through unchanged. Nested :class:GeneratorConfig instances are recursed into.

Parameters:

Name Type Description Default
rng Optional[Union[int, Generator]]

Either an integer seed, a :class:numpy.random.Generator, or None (fresh entropy).

None

Returns:

Type Description
GeneratorConfig

A resolved config of the same concrete type, in which has_distributions() is guaranteed to return False for any field that the base traversal can reach.

Source code in src/groundfield/generators/base.py
def sample(self: "GeneratorConfig", rng: Optional[Union[int, np.random.Generator]] = None) -> "GeneratorConfig":
    """Return a copy with every :class:`Distribution` field resolved.

    Each distribution is replaced by exactly one ``.sample(rng)``
    draw. Non-distribution fields are passed through unchanged.
    Nested :class:`GeneratorConfig` instances are recursed into.

    Parameters
    ----------
    rng
        Either an integer seed, a :class:`numpy.random.Generator`,
        or ``None`` (fresh entropy).

    Returns
    -------
    GeneratorConfig
        A *resolved* config of the same concrete type, in which
        ``has_distributions()`` is guaranteed to return ``False``
        for any field that the base traversal can reach.
    """
    rng_obj = _coerce_rng(rng)
    update: dict[str, Any] = {}
    for name, value in self.__dict__.items():
        new_value = _sample_value(value, rng_obj)
        if new_value is not value:  # only set if changed
            update[name] = new_value
    if not update:
        return self
    return self.model_copy(update=update)

GroundingSystemSpec

Bases: GeneratorConfig

A complete grounding installation.

Attributes:

Name Type Description
electrodes list[ElectrodeSpec]

Ordered list of electrode specs. Order matters only for the choice of anchor (first present electrode). All present electrodes are bonded into one cluster.

bond_conductor_type str

Conductor type used to bond the electrodes together. Default "bare_copper" matches the typical above-ground bonding strap inside a substation pit; switch to "pen" if you want the bond to be modelled like an insulated PEN cable.

build_at

build_at(
    world: World,
    site_xy: tuple[float, float],
    name_prefix: str,
    rng: np.random.Generator,
) -> Optional[str]

Materialise the grounding system inside world.

Parameters:

Name Type Description Default
world World

The :class:World to populate.

required
site_xy tuple[float, float]

Centre position (x, y) of this site (substation centre, KVS centre, building centre, …) in metres.

required
name_prefix str

Prefix used for every created electrode name. The actual name is f"{name_prefix}_{kind}_{i}". Should be unique within the world.

required
rng Generator

Random generator used to sample presence_prob and any geometric distributions.

required

Returns:

Type Description
str | None

The name of the anchor electrode (the first present one). None if no electrode ended up being placed (every Bernoulli failed) — the caller may want to skip this site or raise.

Source code in src/groundfield/generators/grounding.py
def build_at(
    self,
    world: World,
    site_xy: tuple[float, float],
    name_prefix: str,
    rng: np.random.Generator,
) -> Optional[str]:
    """Materialise the grounding system inside ``world``.

    Parameters
    ----------
    world
        The :class:`World` to populate.
    site_xy
        Centre position ``(x, y)`` of this site (substation
        centre, KVS centre, building centre, …) in metres.
    name_prefix
        Prefix used for every created electrode name. The actual
        name is ``f"{name_prefix}_{kind}_{i}"``. Should be unique
        within the world.
    rng
        Random generator used to sample ``presence_prob`` and any
        geometric distributions.

    Returns
    -------
    str | None
        The name of the *anchor* electrode (the first present
        one). ``None`` if no electrode ended up being placed
        (every Bernoulli failed) — the caller may want to skip
        this site or raise.
    """
    cx, cy = site_xy
    created: list[str] = []
    for i, spec in enumerate(self.electrodes):
        # Bernoulli on presence
        p = _to_float(spec.presence_prob, rng)
        if p < 1.0 and rng.random() >= p:
            continue
        ex = cx + spec.offset_xy_m[0]
        ey = cy + spec.offset_xy_m[1]
        name = f"{name_prefix}_{spec.kind}_{i}"
        self._build_one(world, spec, ex, ey, name, rng)
        created.append(name)

    if not created:
        return None

    # Bond every non-anchor electrode to the anchor with a
    # bare-copper conductor. All bonds are short — the segment
    # length is set by the engine, not by us.
    anchor = created[0]
    for k, name in enumerate(created[1:], start=1):
        create_conductor(
            world,
            name=f"{name_prefix}_bond_{k}",
            start=anchor,
            end=name,
            conductor_type=self.bond_conductor_type,
        )
    return anchor

HomogeneousSoilSpec

Bases: BaseModel

Homogeneous soil with a single resistivity \(\rho\).

Use as a sanity check (every layered backend collapses to this when \(K = 0\)) or for very simple studies.

KvsConfig

Bases: GeneratorConfig

Cable cabinet (KVS) block — placement, count, grounding.

KvsPlacement

Bases: BaseModel

One user-placed cable cabinet.

LogNormal

Bases: Distribution

Log-normal distribution.

The default parameterisation matches NumPy's: mu and sigma are the mean and standard deviation of the underlying normal \(Y \sim \mathcal{N}(\mu, \sigma^2)\), and the log-normal sample is \(X = e^{Y}\).

Use :meth:from_moments to construct an instance from the physical mean and standard deviation of \(X\) instead.

from_moments classmethod

from_moments(mean: float, std: float) -> 'LogNormal'

Build a :class:LogNormal from the physical mean and std.

Solves \(\mu = \ln(\text{mean}^2/\sqrt{\text{std}^2 + \text{mean}^2})\) and \(\sigma^2 = \ln(1 + \text{std}^2/\text{mean}^2)\).

Parameters:

Name Type Description Default
mean float

Target physical mean \(\mathbb{E}[X] > 0\).

required
std float

Target physical standard deviation \(\sqrt{\mathrm{Var}(X)} > 0\).

required
Source code in src/groundfield/generators/distributions.py
@classmethod
def from_moments(cls, mean: float, std: float) -> "LogNormal":
    """Build a :class:`LogNormal` from the physical mean and std.

    Solves $\\mu = \\ln(\\text{mean}^2/\\sqrt{\\text{std}^2 + \\text{mean}^2})$
    and $\\sigma^2 = \\ln(1 + \\text{std}^2/\\text{mean}^2)$.

    Parameters
    ----------
    mean
        Target physical mean $\\mathbb{E}[X] > 0$.
    std
        Target physical standard deviation $\\sqrt{\\mathrm{Var}(X)} > 0$.
    """
    if mean <= 0.0:
        raise ValueError(f"LogNormal.from_moments: mean must be > 0 (got {mean}).")
    if std <= 0.0:
        raise ValueError(f"LogNormal.from_moments: std must be > 0 (got {std}).")
    var = std * std
    sigma_sq = math.log(1.0 + var / (mean * mean))
    mu = math.log(mean) - 0.5 * sigma_sq
    return cls(mu=mu, sigma=math.sqrt(sigma_sq))

ManhattanGridPlacement

Bases: BaseModel

Regular Manhattan-grid placement.

Houses are placed on a grid centred on centre_xy with n_per_row columns and as many rows as needed to fit n sites (the last row may be partially filled).

Spacings are configurable as fixed values or distributions; they are sampled once per :meth:generate call (one draw used for the whole grid). Per-site jitter_m adds an additional uniform offset in the box \([-\text{jitter}/2, +\text{jitter}/2]^2\) to each grid point — sample once per site.

Notes

The grid is centred so the geometric centroid of a fully filled grid coincides with centre_xy. For a partial last row the centroid drifts slightly; if you need exact symmetry, request a count that is a multiple of n_per_row.

MeasurementInjectionConfig

Bases: GeneratorConfig

Auxiliary current electrode + optional metallic feed lead.

The auxiliary electrode (Hilfserder) is the remote electrode through which the test current returns to the source. Default: a single driven rod at \((200, 0)\) m. For studies that model the measurement against a neighbouring substation, point grounding at :func:neighbour_substation_grounding.

feed_lead is the metallic Stromeinspeiseleitung between the substation cluster and the aux electrode. None (default) means galvanic-only: the source's return_to is set to the aux electrode but no metallic wire closes the loop — the return current flows entirely through the soil. Setting feed_lead to a :class:MeasurementLeadConfig (often via :func:overhead_lead or :func:buried_lead) adds the physical wire and enables the inductive coupling.

MeasurementLeadConfig

Bases: GeneratorConfig

One physical measurement lead (current feed or voltage probe).

A measurement lead is a finite-impedance :class:Conductor that connects two anchor electrodes (typically the substation cluster and the auxiliary or probe cluster). Two routing variants are common in typical studies and selectable via :attr:depth_m:

  • depth_m = 0.0 — overhead at the surface. The classical default case for the Stromeinspeiseleitung of a fall-of-potential measurement.
  • depth_m > 0.0 (e.g. 0.6 m) — buried cable. Used when the study models a permanently installed measurement infrastructure or a measurement that uses an existing buried PEN cable as the feed.

The default settings (conductor_type="bare_copper", coupling_to_soil="isolated", inductance_model="neumann") match a bare overhead measurement wire that does not leak current to the soil along its length but does generate a magnetic field that couples to every parallel conductor — exactly the inductive-coupling problem.

MeasurementProbeConfig

Bases: GeneratorConfig

Voltage probe + optional metallic measurement lead.

The voltage probe (Spannungssonde) is the second remote electrode; the post-processing evaluates the potential at its location. Default: a short (0.5 m) driven rod at \((60, 0)\) m — the classical 62 % point in fall-of-potential measurements.

lead is the metallic measurement wire from the probe back to the measurement device near the substation. As with :class:MeasurementInjectionConfig.feed_lead, None is galvanic-only; setting it enables inductive coupling.

MeasurementSetupConfig

Bases: GeneratorConfig

Earth-resistance measurement setup.

When TnNetworkConfig.measurement is set, the generator:

  1. builds the auxiliary electrode at :attr:injection.position_xy via injection.grounding.build_at(...);
  2. builds the voltage probe at :attr:probe.position_xy via probe.grounding.build_at(...);
  3. if :attr:injection.feed_lead is set, adds a :class:Conductor from the substation anchor to the aux anchor with the configured depth, conductor type, and inductance model;
  4. if :attr:probe.lead is set, adds a similar conductor from the substation anchor to the probe anchor;
  5. attaches the source to the substation anchor with return_to pointing at the aux anchor — the test current physically returns through the auxiliary electrode (and, if the metallic feed lead is present, mostly through it).

The default factory leaves both leads as None (galvanic only). To enable inductive coupling, set, e.g.,

measurement = MeasurementSetupConfig( ... injection=MeasurementInjectionConfig( ... position_xy=(200.0, 0.0), ... feed_lead=overhead_lead(), ... ), ... probe=MeasurementProbeConfig( ... position_xy=(60.0, 0.0), ... lead=overhead_lead(), ... ), ... )

MultiLayerSoilSpec

Bases: BaseModel

Multi-layer horizontally stratified soil.

The list layers is consumed top-down: the first entry is the surface layer, the last entry must have thickness_m=None (semi-infinite). Each layer's resistivity (and finite-layer thickness) may carry a :class:Distribution.

Normal

Bases: Distribution

Normal distribution \(\mathcal{N}(\mu, \sigma^2)\) with optional truncation.

Truncation is implemented via rejection sampling. A :class:RuntimeError is raised if more than max_attempts rejections occur in a row, which protects against pathological bound configurations (e.g. truncate_low more than ten standard deviations away from the mean).

ObstacleBox

Bases: BaseModel

Axis-aligned bounding box used as a routing obstacle.

Attributes:

Name Type Description
x_min, y_min, x_max, y_max

Lower-left and upper-right corner in metres.

name Optional[str]

Optional label (typically the OSM building id or a human- readable house identifier). Pure metadata; the router does not consume it.

inflated

inflated(padding_m: float) -> 'ObstacleBox'

Return a copy enlarged by padding_m on every side.

Source code in src/groundfield/generators/manhattan_routing.py
def inflated(self, padding_m: float) -> "ObstacleBox":
    """Return a copy enlarged by ``padding_m`` on every side."""
    if padding_m <= 0.0:
        return self
    return ObstacleBox(
        x_min=self.x_min - padding_m,
        y_min=self.y_min - padding_m,
        x_max=self.x_max + padding_m,
        y_max=self.y_max + padding_m,
        name=self.name,
    )

rectangle_overlaps

rectangle_overlaps(
    cx: float, cy: float, half: float
) -> bool

Does the square [cx-half, cx+half]^2 overlap the box?

Source code in src/groundfield/generators/manhattan_routing.py
def rectangle_overlaps(
    self, cx: float, cy: float, half: float,
) -> bool:
    """Does the square ``[cx-half, cx+half]^2`` overlap the box?"""
    return (
        cx + half > self.x_min
        and cx - half < self.x_max
        and cy + half > self.y_min
        and cy - half < self.y_max
    )

OrtsnetzLayout

Bases: BaseModel

Imperative TN-Ortsnetz layout builder.

The builder is constructed empty (no buildings, no substation, no cables) and filled up by calling its methods in the order a planner would naturally take. Every state-changing method returns the new object (or its key) so the calls can be chained or stored:

layout = OrtsnetzLayout.from_footprints(footprints, ... substation_xy=(0, 0)) kvs_1 = layout.add_kvs((120, 0)) cable = layout.add_pen_cable(start="substation", ... end_xy=(250, 0)) layout.connect_buildings() ax = layout.plot()

Attributes:

Name Type Description
footprints list[BuildingFootprint]

Building footprints (already projected into the local ENU frame). Their axis-aligned bounding rectangles serve both as house positions and as PEN-routing obstacles.

substation_xy tuple[float, float]

Substation centre in metres.

substation_name str

Substation anchor id used everywhere else (default "substation").

kvs_placements list[KvsPlacement]

Optional pre-populated KVS list. Most callers leave this empty and use :meth:add_kvs.

pen_cables list[PenCable]

Optional pre-populated PEN cables. Most callers leave this empty and use :meth:add_pen_cable / :meth:add_pen_trunk.

connections list[BuildingConnection]

Optional pre-populated building connections. Most callers leave this empty and use :meth:connect_buildings.

obstacle_clearance_m float

Inflation applied to every footprint AABB before routing. Defaults to 1 m so cables keep a small visible distance from the foundation walls.

projector property

projector: Optional[Projector]

Lazy :class:Projector for the layout's ENU frame.

Returns None when :attr:frame_origin_lat_lon is not set (the layout is in an anonymous local frame). The projector is reconstructed on every access; pyproj caches the underlying CRS objects so this stays cheap.

add_auxiliary_electrode

add_auxiliary_electrode(
    *,
    distance_m: Optional[float] = None,
    direction_deg: float = 0.0,
    position_xy: Optional[tuple[float, float]] = None,
    position_lat_lon: Optional[tuple[float, float]] = None,
    name: str = "aux",
    grounding: Optional[GroundingSystemSpec] = None,
    length_m: Optional[float] = None,
    depth_m: float = 0.0
) -> str

Place the auxiliary current electrode (Hilfserder).

Exactly one of the three position kinds must be set:

  • distance_m (with optional direction_deg) places the electrode at :math:\mathbf{r}_\text{aux} = \mathbf{r}_\text{sub} + d\,(\cos\theta,\,\sin\theta), with :math:\theta measured CCW from the local +x axis. direction_deg = 0 corresponds to east, 90 to north. This is the convenient form for a controlled sensitivity sweep over distance, mimicking the fall-of-potential test geometry.
  • position_xy sets the electrode at an explicit ENU coordinate.
  • position_lat_lon projects through the layout's :attr:frame_origin_lat_lon (raises if no frame origin is set).

Parameters:

Name Type Description Default
distance_m Optional[float]

Manhattan-radius from the substation in metres. Must be positive.

None
direction_deg float

Direction in degrees CCW from +x (east). Default 0.

0.0
position_xy Optional[tuple[float, float]]

See above; mutually exclusive with distance_m.

None
position_lat_lon Optional[tuple[float, float]]

See above; mutually exclusive with distance_m.

None
name str

Anchor id of the auxiliary electrode (defaults to "aux"). Must be unique across the layout. The materialised electrodes inside :meth:to_world are named f"{name}_{kind}_{i}" -- e.g. for the default triangle: aux_rod_0 / aux_rod_1 / aux_rod_2.

'aux'
grounding Optional[GroundingSystemSpec]

:class:GroundingSystemSpec to use for the Hilfserder. None (default) selects the project default (3 × 0.5 m rods in a 0.5 m equilateral triangle, parallel-bonded), which matches typical field-deployment practice.

None
length_m Optional[float]

Convenience override: when grounding is None and length_m is given, the Hilfserder collapses to a single rod of that length (legacy v0.7 behaviour). Ignored when grounding is set explicitly.

None
depth_m float

Depth of the rod head when length_m is used to build a single-rod spec. Default 0.

0.0

Returns:

Type Description
str

The auxiliary anchor's name, identical to name.

Raises:

Type Description
ValueError

If no positioning kind is given, if more than one is given, or if name clashes with the substation or an existing KVS.

RuntimeError

From :meth:lat_lon_to_xy when position_lat_lon is set but the layout has no frame origin.

Notes

The placement replaces any previously configured auxiliary electrode (there is only one per layout in v1). Call this method again with different parameters to sweep the auxiliary-electrode distance without rebuilding the entire layout.

Source code in src/groundfield/generators/ortsnetz_builder.py
def add_auxiliary_electrode(
    self,
    *,
    distance_m: Optional[float] = None,
    direction_deg: float = 0.0,
    position_xy: Optional[tuple[float, float]] = None,
    position_lat_lon: Optional[tuple[float, float]] = None,
    name: str = "aux",
    grounding: Optional[GroundingSystemSpec] = None,
    length_m: Optional[float] = None,
    depth_m: float = 0.0,
) -> str:
    r"""Place the auxiliary current electrode (Hilfserder).

    Exactly one of the three position kinds must be set:

    * ``distance_m`` (with optional ``direction_deg``) places
      the electrode at
      :math:`\mathbf{r}_\text{aux} = \mathbf{r}_\text{sub} +
      d\,(\cos\theta,\,\sin\theta)`,
      with :math:`\theta` measured CCW from the local +x axis.
      ``direction_deg = 0`` corresponds to east, ``90`` to
      north. This is the convenient form for a controlled
      sensitivity sweep over distance, mimicking the
      fall-of-potential test geometry.
    * ``position_xy`` sets the electrode at an explicit
      ENU coordinate.
    * ``position_lat_lon`` projects through the layout's
      :attr:`frame_origin_lat_lon` (raises if no frame origin
      is set).

    Parameters
    ----------
    distance_m
        Manhattan-radius from the substation in metres. Must
        be positive.
    direction_deg
        Direction in degrees CCW from +x (east). Default 0.
    position_xy, position_lat_lon
        See above; mutually exclusive with ``distance_m``.
    name
        Anchor id of the auxiliary electrode (defaults to
        ``"aux"``). Must be unique across the layout. The
        materialised electrodes inside :meth:`to_world` are
        named ``f"{name}_{kind}_{i}"`` -- e.g. for the default
        triangle: ``aux_rod_0`` / ``aux_rod_1`` / ``aux_rod_2``.
    grounding
        :class:`GroundingSystemSpec` to use for the
        Hilfserder. ``None`` (default) selects the project
        default (3 × 0.5 m rods in a 0.5 m equilateral
        triangle, parallel-bonded), which matches typical
        field-deployment practice.
    length_m
        Convenience override: when ``grounding`` is ``None`` and
        ``length_m`` is given, the Hilfserder collapses to a
        *single* rod of that length (legacy v0.7 behaviour).
        Ignored when ``grounding`` is set explicitly.
    depth_m
        Depth of the rod head when ``length_m`` is used to
        build a single-rod spec. Default 0.

    Returns
    -------
    str
        The auxiliary anchor's name, identical to ``name``.

    Raises
    ------
    ValueError
        If no positioning kind is given, if more than one is
        given, or if ``name`` clashes with the substation or
        an existing KVS.
    RuntimeError
        From :meth:`lat_lon_to_xy` when
        ``position_lat_lon`` is set but the layout has no
        frame origin.

    Notes
    -----
    The placement *replaces* any previously configured
    auxiliary electrode (there is only one per layout in v1).
    Call this method again with different parameters to
    sweep the auxiliary-electrode distance without rebuilding
    the entire layout.
    """
    provided = sum(
        arg is not None
        for arg in (distance_m, position_xy, position_lat_lon)
    )
    if provided != 1:
        raise ValueError(
            "OrtsnetzLayout.add_auxiliary_electrode: exactly one "
            "of ``distance_m``, ``position_xy`` or "
            "``position_lat_lon`` must be set."
        )

    if distance_m is not None:
        if distance_m <= 0.0:
            raise ValueError(
                "OrtsnetzLayout.add_auxiliary_electrode: "
                f"distance_m must be > 0, got {distance_m}."
            )
        theta = math.radians(direction_deg)
        sx, sy = self.substation_xy
        xy = (sx + distance_m * math.cos(theta),
              sy + distance_m * math.sin(theta))
    elif position_lat_lon is not None:
        xy = self.lat_lon_to_xy(*position_lat_lon)
    else:
        assert position_xy is not None
        xy = tuple(position_xy)

    if name == self.substation_name:
        raise ValueError(
            f"OrtsnetzLayout.add_auxiliary_electrode: name "
            f"{name!r} clashes with the substation name."
        )
    if any(k.name == name for k in self.kvs_placements):
        raise ValueError(
            f"OrtsnetzLayout.add_auxiliary_electrode: name "
            f"{name!r} clashes with an existing KVS."
        )

    # Resolve the grounding spec. Priority:
    #   1. Explicit ``grounding`` argument wins.
    #   2. ``length_m`` collapses to a single-rod spec (legacy).
    #   3. Otherwise use the project default (3 x 0.5 m triangle).
    if grounding is not None:
        grounding_spec = grounding
    elif length_m is not None:
        grounding_spec = GroundingSystemSpec(
            electrodes=[
                RodElectrodeSpec(
                    length_m=float(length_m), depth_m=depth_m,
                ),
            ],
        )
    else:
        grounding_spec = _default_aux_grounding()

    self.auxiliary_electrode = AuxiliaryElectrodePlacement(
        name=name,
        position_xy=xy,
        grounding=grounding_spec,
    )
    return name

add_kvs

add_kvs(
    position_xy: Optional[tuple[float, float]] = None,
    *,
    position_lat_lon: Optional[tuple[float, float]] = None,
    name: Optional[str] = None
) -> str

Place a cable cabinet at position_xy or position_lat_lon.

Exactly one of position_xy (local ENU metres) or position_lat_lon (WGS84 degrees) must be set. The lat / lon variant projects through the layout's :attr:frame_origin_lat_lon; if no frame origin is set, a :class:RuntimeError is raised.

Returns the assigned KVS name (auto-generated when name is None). The returned name is what callers pass to :meth:add_pen_cable as start=....

Source code in src/groundfield/generators/ortsnetz_builder.py
def add_kvs(
    self,
    position_xy: Optional[tuple[float, float]] = None,
    *,
    position_lat_lon: Optional[tuple[float, float]] = None,
    name: Optional[str] = None,
) -> str:
    """Place a cable cabinet at ``position_xy`` or ``position_lat_lon``.

    Exactly one of ``position_xy`` (local ENU metres) or
    ``position_lat_lon`` (WGS84 degrees) must be set. The lat /
    lon variant projects through the layout's
    :attr:`frame_origin_lat_lon`; if no frame origin is set, a
    :class:`RuntimeError` is raised.

    Returns the assigned KVS name (auto-generated when ``name``
    is ``None``). The returned name is what callers pass to
    :meth:`add_pen_cable` as ``start=...``.
    """
    if (position_xy is None) == (position_lat_lon is None):
        raise ValueError(
            "OrtsnetzLayout.add_kvs: exactly one of "
            "``position_xy`` and ``position_lat_lon`` must be set."
        )
    if position_lat_lon is not None:
        position_xy = self.lat_lon_to_xy(*position_lat_lon)
    if name is None:
        name = f"kvs_{len(self.kvs_placements)}"
    if any(k.name == name for k in self.kvs_placements):
        raise ValueError(
            f"OrtsnetzLayout.add_kvs: name {name!r} is already taken."
        )
    if name == self.substation_name:
        raise ValueError(
            f"OrtsnetzLayout.add_kvs: name {name!r} clashes with "
            "the substation name."
        )
    assert position_xy is not None
    self.kvs_placements.append(
        KvsPlacement(name=name, position_xy=tuple(position_xy)),
    )
    return name

add_pen_cable

add_pen_cable(
    *,
    start: str,
    end_xy: Optional[tuple[float, float]] = None,
    end_lat_lon: Optional[tuple[float, float]] = None,
    end: Optional[str] = None,
    name: Optional[str] = None,
    min_segment_length_m: float = 10.0,
    clearance_m: Optional[float] = None,
    escape_radius_m: Optional[float] = None
) -> PenCable

Add a PEN cable routed Manhattan-style.

Exactly one of end, end_xy or end_lat_lon must be set. When end is the name of another anchor (KVS) the cable terminates there and the receiving cabinet is recorded as :attr:PenCable.end_anchor. When end_xy or end_lat_lon is set, the cable's last waypoint is the free coordinate and houses near it can still tap into the trunk. end_lat_lon is projected through the layout's :attr:frame_origin_lat_lon (raises if no frame origin is set).

Parameters:

Name Type Description Default
start str

Name of an anchor present in this layout (substation_name or the name of a KVS).

required
end_xy Optional[tuple[float, float]]

Free \((x, y)\) endpoint coordinate in metres.

None
end Optional[str]

Name of the destination anchor. Mutually exclusive with end_xy.

None
name Optional[str]

Optional cable id. Auto-generated when None.

None
min_segment_length_m float

Grid size of the Manhattan A* router. Defaults to 10 m -- a reasonable street-segment length and an order of magnitude longer than a typical foundation footprint.

10.0
clearance_m Optional[float]

Per-call override for :attr:obstacle_clearance_m.

None
escape_radius_m Optional[float]

Radius around the start and end anchor inside which obstacles are not enforced. Defaults to min_segment_length_m (one cell of slack) which is usually enough to handle a substation that landed inside or right next to a foundation polygon in a real OSM extract. Raise this when an anchor is wedged into a dense city block; pass 0.0 for the strict pre-v0.7 behaviour.

None

Returns:

Type Description
PenCable

The freshly-routed cable. Already registered in :attr:pen_cables.

Source code in src/groundfield/generators/ortsnetz_builder.py
def add_pen_cable(
    self,
    *,
    start: str,
    end_xy: Optional[tuple[float, float]] = None,
    end_lat_lon: Optional[tuple[float, float]] = None,
    end: Optional[str] = None,
    name: Optional[str] = None,
    min_segment_length_m: float = 10.0,
    clearance_m: Optional[float] = None,
    escape_radius_m: Optional[float] = None,
) -> PenCable:
    """Add a PEN cable routed Manhattan-style.

    Exactly one of ``end``, ``end_xy`` or ``end_lat_lon`` must
    be set. When ``end`` is the name of another anchor (KVS)
    the cable terminates there and the receiving cabinet is
    recorded as :attr:`PenCable.end_anchor`. When ``end_xy`` or
    ``end_lat_lon`` is set, the cable's last waypoint is the
    free coordinate and houses near it can still tap into the
    trunk. ``end_lat_lon`` is projected through the layout's
    :attr:`frame_origin_lat_lon` (raises if no frame origin is
    set).

    Parameters
    ----------
    start
        Name of an anchor present in this layout
        (``substation_name`` or the name of a KVS).
    end_xy
        Free $(x, y)$ endpoint coordinate in metres.
    end
        Name of the destination anchor. Mutually exclusive
        with ``end_xy``.
    name
        Optional cable id. Auto-generated when ``None``.
    min_segment_length_m
        Grid size of the Manhattan A\\* router. Defaults to
        10 m -- a reasonable street-segment length and an
        order of magnitude longer than a typical foundation
        footprint.
    clearance_m
        Per-call override for :attr:`obstacle_clearance_m`.
    escape_radius_m
        Radius around the start *and* end anchor inside which
        obstacles are not enforced. Defaults to
        ``min_segment_length_m`` (one cell of slack) which is
        usually enough to handle a substation that landed inside
        or right next to a foundation polygon in a real OSM
        extract. Raise this when an anchor is wedged into a
        dense city block; pass ``0.0`` for the strict pre-v0.7
        behaviour.

    Returns
    -------
    PenCable
        The freshly-routed cable. Already registered in
        :attr:`pen_cables`.
    """
    # Exactly one of {end, end_xy, end_lat_lon} must be provided.
    provided = sum(
        arg is not None for arg in (end, end_xy, end_lat_lon)
    )
    if provided != 1:
        raise ValueError(
            "OrtsnetzLayout.add_pen_cable: exactly one of "
            "``end``, ``end_xy`` or ``end_lat_lon`` must be set."
        )
    if name is None:
        name = f"pen_{len(self.pen_cables)}"
    if any(c.name == name for c in self.pen_cables):
        raise ValueError(
            f"OrtsnetzLayout.add_pen_cable: name {name!r} is taken."
        )

    start_xy = self._anchor_position(start)
    if end is not None:
        end_xy_resolved = self._anchor_position(end)
    elif end_lat_lon is not None:
        end_xy_resolved = self.lat_lon_to_xy(*end_lat_lon)
    else:
        assert end_xy is not None  # narrowing for type checkers
        end_xy_resolved = tuple(end_xy)

    clearance = (
        self.obstacle_clearance_m if clearance_m is None else clearance_m
    )
    obstacles = self._obstacle_boxes()
    waypoints = route_manhattan(
        start=start_xy,
        end=end_xy_resolved,
        obstacles=obstacles,
        grid_size=min_segment_length_m,
        clearance_m=clearance,
        escape_radius_m=escape_radius_m,
    )
    cable = PenCable(
        name=name,
        start_anchor=start,
        waypoints=waypoints,
        end_anchor=end,
        grid_size_m=min_segment_length_m,
    )
    self.pen_cables.append(cable)
    return cable

add_voltage_probe

add_voltage_probe(
    *,
    inline_fraction: Optional[float] = None,
    perpendicular_fraction: Optional[float] = None,
    perpendicular_side: str = "left",
    position_xy: Optional[tuple[float, float]] = None,
    position_lat_lon: Optional[tuple[float, float]] = None,
    name: str = "probe",
    depth_m: float = 0.0
) -> str

Place the voltage probe (Spannungssonde).

The voltage probe replaces the "remote earth" reference of a simple U_sub / I measurement with the actual ground potential at a chosen point — exactly what a field-deployed grounding-impedance meter does.

Four positioning kinds are accepted:

  • inline_fraction — along the substation → aux axis, at a fraction :math:f of the aux distance (:math:f = 0 is the substation, :math:f = 1 is the Hilfserder, :math:f = 0.5 is the midpoint, which is the standard "50 % rule"). Requires :meth:add_auxiliary_electrode to have been called.
  • perpendicular_fraction (with perpendicular_side "left" or "right") — 90° rotated from the substation → aux axis (left = CCW = north when aux runs east, right = CW = south). The probe distance from the substation is :math:f \cdot D_\text{aux}. This matches the senkrecht zur Hilfserdertrasse setup commonly used to suppress inductive coupling between the current-feed loop and the voltage-probe lead.
  • position_xy — explicit ENU metres.
  • position_lat_lon — WGS84 (projects through :attr:frame_origin_lat_lon).

Examples:

Classic 50 % inline probe between substation and a 200 m east Hilfserder:

>>> layout.add_auxiliary_electrode(distance_m=200.0,
...                                direction_deg=0.0)
>>> layout.add_voltage_probe(inline_fraction=0.5)
# probe ends up at (100, 0) relative to the substation

90° perpendicular probe at the same fraction, on the "left" (= north) side:

>>> layout.add_voltage_probe(perpendicular_fraction=0.5,
...                          perpendicular_side="left")
# probe ends up at (0, 100) relative to the substation
Source code in src/groundfield/generators/ortsnetz_builder.py
def add_voltage_probe(
    self,
    *,
    inline_fraction: Optional[float] = None,
    perpendicular_fraction: Optional[float] = None,
    perpendicular_side: str = "left",
    position_xy: Optional[tuple[float, float]] = None,
    position_lat_lon: Optional[tuple[float, float]] = None,
    name: str = "probe",
    depth_m: float = 0.0,
) -> str:
    r"""Place the voltage probe (Spannungssonde).

    The voltage probe replaces the "remote earth" reference of
    a simple ``U_sub / I`` measurement with the *actual*
    ground potential at a chosen point — exactly what a
    field-deployed grounding-impedance meter does.

    Four positioning kinds are accepted:

    * ``inline_fraction`` — along the substation → aux axis,
      at a fraction :math:`f` of the aux distance
      (:math:`f = 0` is the substation, :math:`f = 1` is the
      Hilfserder, :math:`f = 0.5` is the midpoint, which is
      the standard "50 % rule"). Requires
      :meth:`add_auxiliary_electrode` to have been called.
    * ``perpendicular_fraction`` (with ``perpendicular_side``
      ``"left"`` or ``"right"``) — 90° rotated from the
      substation → aux axis (left = CCW = north when aux runs
      east, right = CW = south). The probe distance from the
      substation is :math:`f \cdot D_\text{aux}`. This matches
      the *senkrecht zur Hilfserdertrasse* setup commonly
      used to suppress inductive coupling between the
      current-feed loop and the voltage-probe lead.
    * ``position_xy`` — explicit ENU metres.
    * ``position_lat_lon`` — WGS84 (projects through
      :attr:`frame_origin_lat_lon`).

    Examples
    --------
    Classic 50 % inline probe between substation and a 200 m
    east Hilfserder:

    >>> layout.add_auxiliary_electrode(distance_m=200.0,
    ...                                direction_deg=0.0)
    >>> layout.add_voltage_probe(inline_fraction=0.5)
    # probe ends up at (100, 0) relative to the substation

    90° perpendicular probe at the same fraction, on the
    "left" (= north) side:

    >>> layout.add_voltage_probe(perpendicular_fraction=0.5,
    ...                          perpendicular_side="left")
    # probe ends up at (0, 100) relative to the substation
    """
    provided = sum(
        arg is not None
        for arg in (
            inline_fraction,
            perpendicular_fraction,
            position_xy,
            position_lat_lon,
        )
    )
    if provided != 1:
        raise ValueError(
            "OrtsnetzLayout.add_voltage_probe: exactly one of "
            "``inline_fraction``, ``perpendicular_fraction``, "
            "``position_xy`` or ``position_lat_lon`` must be set."
        )
    if perpendicular_side not in ("left", "right"):
        raise ValueError(
            "OrtsnetzLayout.add_voltage_probe: perpendicular_side "
            f"must be 'left' or 'right', got {perpendicular_side!r}."
        )

    if inline_fraction is not None or perpendicular_fraction is not None:
        if self.auxiliary_electrode is None:
            raise RuntimeError(
                "OrtsnetzLayout.add_voltage_probe: the inline / "
                "perpendicular fractions need an auxiliary "
                "electrode -- call ``add_auxiliary_electrode`` "
                "first."
            )
        sx, sy = self.substation_xy
        ax, ay = self.auxiliary_electrode.position_xy
        d_vec = (ax - sx, ay - sy)
        d_aux = math.hypot(*d_vec)
        if d_aux <= 0.0:
            raise RuntimeError(
                "OrtsnetzLayout.add_voltage_probe: the auxiliary "
                "electrode coincides with the substation -- the "
                "fractional placement is undefined."
            )
        u_inline = (d_vec[0] / d_aux, d_vec[1] / d_aux)
        if inline_fraction is not None:
            f = float(inline_fraction)
            if not 0.0 <= f <= 1.0:
                raise ValueError(
                    "OrtsnetzLayout.add_voltage_probe: "
                    "inline_fraction must be in [0, 1], got "
                    f"{f}."
                )
            xy = (sx + f * d_aux * u_inline[0],
                  sy + f * d_aux * u_inline[1])
        else:
            f = float(perpendicular_fraction)
            if not 0.0 <= f <= 1.0:
                raise ValueError(
                    "OrtsnetzLayout.add_voltage_probe: "
                    "perpendicular_fraction must be in [0, 1], "
                    f"got {f}."
                )
            # 90° rotation: left = CCW, right = CW.
            if perpendicular_side == "left":
                u_perp = (-u_inline[1], u_inline[0])
            else:
                u_perp = (u_inline[1], -u_inline[0])
            xy = (sx + f * d_aux * u_perp[0],
                  sy + f * d_aux * u_perp[1])
    elif position_lat_lon is not None:
        xy = self.lat_lon_to_xy(*position_lat_lon)
    else:
        assert position_xy is not None
        xy = tuple(position_xy)

    if name == self.substation_name:
        raise ValueError(
            f"OrtsnetzLayout.add_voltage_probe: name {name!r} "
            "clashes with the substation name."
        )
    if any(k.name == name for k in self.kvs_placements):
        raise ValueError(
            f"OrtsnetzLayout.add_voltage_probe: name {name!r} "
            "clashes with an existing KVS."
        )
    if (
        self.auxiliary_electrode is not None
        and name == self.auxiliary_electrode.name
    ):
        raise ValueError(
            f"OrtsnetzLayout.add_voltage_probe: name {name!r} "
            "clashes with the auxiliary electrode."
        )

    self.voltage_probe = VoltageProbePlacement(
        name=name,
        position_xy=xy,
        depth_m=depth_m,
    )
    return name

connect_all_buildings

connect_all_buildings(
    *,
    source_anchors: Optional[list[str]] = None,
    max_passes: int = 4,
    margin_m: float = 3.0,
    lateral_clearance_m: Optional[float] = None,
    min_segment_length_m: float = 10.0,
    escape_radius_m: Optional[float] = None,
    clearance_m: Optional[float] = None
) -> "LateralCoverageResult"

Connect every house to the LV network, adding laterals as needed.

:meth:connect_buildings only taps each house to the nearest existing cable with a short (at most two-segment) stub and skips houses whose stub would cross another footprint -- typically houses with no cable in reach. This method closes that gap so the layout models a real LV network in which every customer is served.

Strategy ("laterals from the nearest node" model):

  1. Tap whatever is already reachable via :meth:connect_buildings.
  2. For every still-unconnected house (farthest from any source node first), route an obstacle-avoiding PEN lateral (:meth:add_pen_cable, full Manhattan A*) from the nearest connected source node (substation or a KVS) to a free point just outside the house, then re-tap. Re-tapping frequently connects neighbouring houses to the same new lateral for free.
  3. Repeat until no house remains or no further progress is made.

Houses enclosed by other footprints on every side stay unconnected and are reported in :attr:LateralCoverageResult.islands rather than raising.

Parameters:

Name Type Description Default
source_anchors Optional[list[str]]

Names of the nodes laterals may originate from. None (the default) uses the substation plus every KVS galvanically connected to it through the existing PEN-cable graph (see :meth:_anchors_connected_to_substation), so a stranded KVS is never used as a source and cannot create an isolated sub-network.

None
max_passes int

Maximum number of tap/lateral rounds. Each round adds at most one lateral per still-unconnected house.

4
margin_m float

Extra distance, beyond the house's bounding-box half-diagonal, at which the lateral end point is placed outside the footprint.

3.0
lateral_clearance_m Optional[float]

Obstacle inflation used when searching for a free lateral end point. Defaults to :attr:obstacle_clearance_m.

None
min_segment_length_m float

Forwarded to :meth:add_pen_cable for the lateral routing (with one finer-grid retry on failure). clearance_m also overrides the tap clearance in :meth:connect_buildings.

10.0
escape_radius_m float

Forwarded to :meth:add_pen_cable for the lateral routing (with one finer-grid retry on failure). clearance_m also overrides the tap clearance in :meth:connect_buildings.

10.0
clearance_m float

Forwarded to :meth:add_pen_cable for the lateral routing (with one finer-grid retry on failure). clearance_m also overrides the tap clearance in :meth:connect_buildings.

10.0

Returns:

Type Description
LateralCoverageResult

laterals (added cable names), islands (still-unconnected footprint indices), and connections (snapshot after the pass).

Raises:

Type Description
RuntimeError

If source_anchors resolves to an empty list (e.g. passed explicitly as []).

Source code in src/groundfield/generators/ortsnetz_builder.py
def connect_all_buildings(
    self,
    *,
    source_anchors: Optional[list[str]] = None,
    max_passes: int = 4,
    margin_m: float = 3.0,
    lateral_clearance_m: Optional[float] = None,
    min_segment_length_m: float = 10.0,
    escape_radius_m: Optional[float] = None,
    clearance_m: Optional[float] = None,
) -> "LateralCoverageResult":
    r"""Connect *every* house to the LV network, adding laterals as needed.

    :meth:`connect_buildings` only taps each house to the nearest
    existing cable with a short (at most two-segment) stub and skips
    houses whose stub would cross another footprint -- typically houses
    with no cable in reach. This method closes that gap so the layout
    models a real LV network in which every customer is served.

    Strategy ("laterals from the nearest node" model):

    1. Tap whatever is already reachable via :meth:`connect_buildings`.
    2. For every still-unconnected house (farthest from any source node
       first), route an obstacle-avoiding PEN *lateral*
       (:meth:`add_pen_cable`, full Manhattan A\*) from the nearest
       connected source node (substation or a KVS) to a free point just
       outside the house, then re-tap. Re-tapping frequently connects
       neighbouring houses to the same new lateral for free.
    3. Repeat until no house remains or no further progress is made.

    Houses enclosed by other footprints on every side stay unconnected
    and are reported in :attr:`LateralCoverageResult.islands` rather
    than raising.

    Parameters
    ----------
    source_anchors
        Names of the nodes laterals may originate from. ``None`` (the
        default) uses the substation plus every KVS galvanically
        connected to it through the existing PEN-cable graph (see
        :meth:`_anchors_connected_to_substation`), so a stranded KVS is
        never used as a source and cannot create an isolated
        sub-network.
    max_passes
        Maximum number of tap/lateral rounds. Each round adds at most
        one lateral per still-unconnected house.
    margin_m
        Extra distance, beyond the house's bounding-box half-diagonal,
        at which the lateral end point is placed outside the footprint.
    lateral_clearance_m
        Obstacle inflation used when searching for a free lateral end
        point. Defaults to :attr:`obstacle_clearance_m`.
    min_segment_length_m, escape_radius_m, clearance_m
        Forwarded to :meth:`add_pen_cable` for the lateral routing
        (with one finer-grid retry on failure). ``clearance_m`` also
        overrides the tap clearance in :meth:`connect_buildings`.

    Returns
    -------
    LateralCoverageResult
        ``laterals`` (added cable names), ``islands`` (still-unconnected
        footprint indices), and ``connections`` (snapshot after the
        pass).

    Raises
    ------
    RuntimeError
        If ``source_anchors`` resolves to an empty list (e.g. passed
        explicitly as ``[]``).
    """
    import warnings

    if not self.footprints:
        return LateralCoverageResult([], [], list(self.connections))

    clearance = (
        self.obstacle_clearance_m if clearance_m is None else clearance_m
    )
    lat_clear = (
        clearance if lateral_clearance_m is None else lateral_clearance_m
    )

    if source_anchors is None:
        source_anchors = self._anchors_connected_to_substation()
    nodes = [(a, self._anchor_position(a)) for a in source_anchors]
    if not nodes:
        raise RuntimeError(
            "OrtsnetzLayout.connect_all_buildings: no source anchor to "
            "originate laterals from. Pass ``source_anchors`` or route "
            "a PEN cable from the substation first."
        )

    def _tap() -> None:
        # connect_buildings warns per skipped house; islands are reported
        # at the end, so silence the per-house noise here. It also raises
        # when there are no cables yet -- skip the tap in that case so the
        # very first lateral can still be laid from the substation.
        if not self.pen_cables:
            return
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", UserWarning)
            self.connect_buildings(
                only_unconnected=True, clearance_m=clearance,
            )

    def _nearest_distance(i: int) -> float:
        cx, cy = self.footprints[i].centroid_xy_m()
        return min(
            math.hypot(cx - px, cy - py) for _, (px, py) in nodes
        )

    laterals: list[str] = []
    for _ in range(max_passes):
        _tap()
        done = {c.house_idx for c in self.connections}
        orphans = [
            i for i in range(len(self.footprints)) if i not in done
        ]
        if not orphans:
            break
        progressed = False
        for i in sorted(orphans, key=_nearest_distance, reverse=True):
            if i in {c.house_idx for c in self.connections}:
                continue  # already tapped via a neighbour's lateral
            fp = self.footprints[i]
            cx, cy = fp.centroid_xy_m()
            src, _src_xy = min(
                nodes,
                key=lambda nd: math.hypot(cx - nd[1][0], cy - nd[1][1]),
            )
            end_xy = self._free_point_near_house(
                fp, self._anchor_position(src),
                clearance=lat_clear, margin_m=margin_m,
            )
            if end_xy is None:
                continue
            name = f"lateral_house_{i:04d}"
            if any(c.name == name for c in self.pen_cables):
                continue
            routed = False
            eff_escape = (
                escape_radius_m
                if escape_radius_m is not None
                else min_segment_length_m
            )
            # Grid ladder: configured -> half (tighter final bridges) ->
            # fifth (maze escape on long winding routes, where a fine grid
            # would otherwise exhaust the A* iteration budget).
            for grid, esc in (
                (min_segment_length_m, escape_radius_m),
                (max(1.0, min_segment_length_m / 2.0), 2.0 * eff_escape),
                (max(1.0, min_segment_length_m / 5.0), 2.0 * eff_escape),
            ):
                try:
                    self.add_pen_cable(
                        start=src, end_xy=end_xy, name=name,
                        min_segment_length_m=grid,
                        clearance_m=clearance, escape_radius_m=esc,
                    )
                    routed = True
                    break
                except RuntimeError:
                    continue
            if not routed:
                continue
            laterals.append(name)
            progressed = True
            _tap()  # tap house i (and any neighbours now in reach)
        if not progressed:
            break

    done = {c.house_idx for c in self.connections}
    islands = [i for i in range(len(self.footprints)) if i not in done]
    return LateralCoverageResult(laterals, islands, list(self.connections))

connect_buildings

connect_buildings(
    *,
    only_unconnected: bool = True,
    clearance_m: Optional[float] = None
) -> list[BuildingConnection]

Attach every house to the closest PEN cable.

For each footprint not yet listed in :attr:connections (when only_unconnected=True, the default), this method:

  1. Computes the Manhattan-shortest tap point on every cable.
  2. Picks the cable whose tap point is closest to the house centroid and whose stub does not cross another foundation (after the same clearance_m inflation used for cable routing).
  3. Builds the stub polyline (at most two segments) and records a :class:BuildingConnection.

Houses that cannot be connected to any cable (e.g. surrounded by foundations on all sides) are reported via a :class:UserWarning and skipped; they remain unconnected and can be revisited after the user adds another cable.

Source code in src/groundfield/generators/ortsnetz_builder.py
def connect_buildings(
    self,
    *,
    only_unconnected: bool = True,
    clearance_m: Optional[float] = None,
) -> list[BuildingConnection]:
    """Attach every house to the closest PEN cable.

    For each footprint not yet listed in :attr:`connections`
    (when ``only_unconnected=True``, the default), this method:

    1. Computes the Manhattan-shortest tap point on every
       cable.
    2. Picks the cable whose tap point is closest to the
       house *centroid* and whose stub does not cross another
       foundation (after the same ``clearance_m`` inflation
       used for cable routing).
    3. Builds the stub polyline (at most two segments) and
       records a :class:`BuildingConnection`.

    Houses that cannot be connected to *any* cable (e.g.
    surrounded by foundations on all sides) are reported via
    a :class:`UserWarning` and skipped; they remain
    unconnected and can be revisited after the user adds
    another cable.
    """
    import warnings

    if not self.pen_cables:
        raise RuntimeError(
            "OrtsnetzLayout.connect_buildings: no PEN cables "
            "in the layout. Call ``add_pen_cable`` first."
        )
    clearance = (
        self.obstacle_clearance_m if clearance_m is None else clearance_m
    )

    connected_idx = (
        {c.house_idx for c in self.connections} if only_unconnected
        else set()
    )

    new_connections: list[BuildingConnection] = []
    for i, fp in enumerate(self.footprints):
        if i in connected_idx:
            continue
        centroid = fp.centroid_xy_m()
        # Treat every *other* footprint as a stub obstacle.
        other_obstacles = [
            self._footprint_to_box(self.footprints[j], j).inflated(clearance)
            for j in range(len(self.footprints)) if j != i
        ]
        best: Optional[BuildingConnection] = None
        best_length = math.inf
        for cable in self.pen_cables:
            tap, stub = self._stub_to_cable(
                centroid, cable, other_obstacles,
            )
            if stub is None:
                continue
            length = sum(
                abs(p1[0] - p0[0]) + abs(p1[1] - p0[1])
                for p0, p1 in zip(stub[:-1], stub[1:])
            )
            if length < best_length:
                best_length = length
                best = BuildingConnection(
                    house_idx=i,
                    cable_name=cable.name,
                    stub_waypoints=stub,
                    tap_point_xy=tap,
                )
        if best is None:
            warnings.warn(
                f"OrtsnetzLayout.connect_buildings: house {i} "
                f"(centroid {centroid}) cannot be tapped to any "
                "existing cable without crossing another "
                "footprint. Add another PEN cable or reduce "
                "``clearance_m``.",
                UserWarning,
                stacklevel=2,
            )
            continue
        new_connections.append(best)
    self.connections.extend(new_connections)
    return new_connections

foundation_mask

foundation_mask(
    penetration: float, *, salt: int = 0
) -> list[bool]

Deterministic per-house Bernoulli mask for foundation electrodes (Fundamenterder).

For each footprint a stable pseudo-random number :math:r_i \in [0, 1) is derived from the footprint's osm_id (or its index as a fallback) and the user- supplied salt. The mask entry is True iff :math:r_i < p where :math:p = penetration. Two properties hold:

  • Reproducible. Calling layout.foundation_mask(0.15) always returns the same mask for the same set of footprints, regardless of when or in which process you call it.
  • Nested in p. For a fixed salt, every house with a foundation at :math:p_1 also has one at :math:p_2 > p_1 — increasing the penetration only grows the foundation-equipped subset. This is the natural property for penetration-rate sweep studies.

Parameters:

Name Type Description Default
penetration float

Penetration rate :math:p \in [0, 1]. 0 returns an all-False mask, 1 an all-True mask.

required
salt int

Optional integer that shifts the mask deterministically into a different realisation. Useful for Monte-Carlo studies where the user wants several independent runs at the same penetration rate.

0

Returns:

Type Description
list of bool

One entry per footprint, in the layout's declared order. Length equals len(self.footprints).

Examples:

>>> mask_15 = layout.foundation_mask(0.15)
>>> mask_30 = layout.foundation_mask(0.30)
>>> all(b1 <= b2 for b1, b2 in zip(mask_15, mask_30))   # nested
True
>>> layout.foundation_mask(0.15) == layout.foundation_mask(0.15)
True
Source code in src/groundfield/generators/ortsnetz_builder.py
def foundation_mask(
    self,
    penetration: float,
    *,
    salt: int = 0,
) -> list[bool]:
    r"""Deterministic per-house Bernoulli mask for foundation
    electrodes (`Fundamenterder`).

    For each footprint a stable pseudo-random number
    :math:`r_i \in [0, 1)` is derived from the footprint's
    ``osm_id`` (or its index as a fallback) and the user-
    supplied ``salt``. The mask entry is ``True`` iff
    :math:`r_i < p` where :math:`p =` ``penetration``. Two
    properties hold:

    * **Reproducible.** Calling
      ``layout.foundation_mask(0.15)`` always returns the same
      mask for the same set of footprints, regardless of when
      or in which process you call it.
    * **Nested in p.** For a fixed ``salt``, every house with
      a foundation at :math:`p_1` also has one at
      :math:`p_2 > p_1` — increasing the penetration only
      *grows* the foundation-equipped subset. This is the
      natural property for penetration-rate sweep studies.

    Parameters
    ----------
    penetration
        Penetration rate :math:`p \in [0, 1]`. ``0`` returns
        an all-``False`` mask, ``1`` an all-``True`` mask.
    salt
        Optional integer that shifts the mask deterministically
        into a different realisation. Useful for Monte-Carlo
        studies where the user wants several independent runs
        at the *same* penetration rate.

    Returns
    -------
    list of bool
        One entry per footprint, in the layout's declared
        order. Length equals ``len(self.footprints)``.

    Examples
    --------
    >>> mask_15 = layout.foundation_mask(0.15)
    >>> mask_30 = layout.foundation_mask(0.30)
    >>> all(b1 <= b2 for b1, b2 in zip(mask_15, mask_30))   # nested
    True
    >>> layout.foundation_mask(0.15) == layout.foundation_mask(0.15)
    True
    """
    if not 0.0 <= penetration <= 1.0:
        raise ValueError(
            "OrtsnetzLayout.foundation_mask: penetration must be "
            f"in [0, 1], got {penetration}."
        )
    out: list[bool] = []
    for i, fp in enumerate(self.footprints):
        key = fp.osm_id if fp.osm_id is not None else i
        r = _stable_uniform(salt, key)
        out.append(r < penetration)
    return out

from_footprints classmethod

from_footprints(
    footprints: list[BuildingFootprint],
    *,
    substation_xy: tuple[float, float],
    substation_name: str = "substation",
    obstacle_clearance_m: float = 1.0,
    frame_origin_lat_lon: Optional[
        tuple[float, float]
    ] = None
) -> "OrtsnetzLayout"

Build an empty layout from a list of footprints.

frame_origin_lat_lon is optional; pass it when the footprints were projected by an external pipeline and you still want to add KVS / PEN endpoints by lat / lon later.

Source code in src/groundfield/generators/ortsnetz_builder.py
@classmethod
def from_footprints(
    cls,
    footprints: list[BuildingFootprint],
    *,
    substation_xy: tuple[float, float],
    substation_name: str = "substation",
    obstacle_clearance_m: float = 1.0,
    frame_origin_lat_lon: Optional[tuple[float, float]] = None,
) -> "OrtsnetzLayout":
    """Build an empty layout from a list of footprints.

    ``frame_origin_lat_lon`` is optional; pass it when the
    footprints were projected by an external pipeline and you
    still want to add KVS / PEN endpoints by lat / lon later.
    """
    return cls(
        footprints=list(footprints),
        substation_xy=substation_xy,
        substation_name=substation_name,
        obstacle_clearance_m=obstacle_clearance_m,
        frame_origin_lat_lon=frame_origin_lat_lon,
    )

from_osm classmethod

from_osm(
    *,
    center_lat_deg: float,
    center_lon_deg: float,
    radius_m: float,
    substation_lat_lon: Optional[
        tuple[float, float]
    ] = None,
    substation_xy: Optional[tuple[float, float]] = None,
    kvs_lat_lons: Optional[
        list[tuple[float, float]]
    ] = None,
    kvs_xys: Optional[list[tuple[float, float]]] = None,
    substation_name: str = "substation",
    obstacle_clearance_m: float = 1.0,
    min_area_m2: float = 16.0,
    cache_dir=None,
    force_refresh: bool = False,
    endpoint: Optional[str] = None,
    timeout_s: Optional[int] = None,
    max_retries: int = 1
) -> "OrtsnetzLayout"

One-shot constructor: query OSM and pre-place anchors.

Parameters:

Name Type Description Default
center_lat_deg float

Centre of the buildings-in-radius Overpass query. Also the WGS84 origin of the local ENU frame :attr:frame_origin_lat_lon so subsequent position_lat_lon arguments stay consistent.

required
center_lon_deg float

Centre of the buildings-in-radius Overpass query. Also the WGS84 origin of the local ENU frame :attr:frame_origin_lat_lon so subsequent position_lat_lon arguments stay consistent.

required
radius_m float

Search radius in metres.

required
substation_lat_lon Optional[tuple[float, float]]

Optional substation location as WGS84 (lat, lon). Mutually exclusive with substation_xy. When both are omitted the substation defaults to the local origin (0, 0).

None
substation_xy Optional[tuple[float, float]]

Substation location in already-projected metres. Mutually exclusive with substation_lat_lon.

None
kvs_lat_lons Optional[list[tuple[float, float]]]

Optional list of KVS locations in WGS84 (lat, lon). Each entry is projected and appended to :attr:kvs_placements. Names are auto-generated.

None
kvs_xys Optional[list[tuple[float, float]]]

Optional list of KVS locations in already-projected metres. Mutually exclusive with kvs_lat_lons.

None
substation_name str

Anchor id for the substation. Default "substation".

'substation'
obstacle_clearance_m float

Forwarded to the layout / OSM query respectively.

1.0
min_area_m2 float

Forwarded to the layout / OSM query respectively.

1.0
cache_dir

Forwarded to :func:groundfield.geo.osm.query_and_project.

None
force_refresh

Forwarded to :func:groundfield.geo.osm.query_and_project.

None
endpoint

Forwarded to :func:groundfield.geo.osm.query_and_project.

None
timeout_s

Forwarded to :func:groundfield.geo.osm.query_and_project.

None
max_retries

Forwarded to :func:groundfield.geo.osm.query_and_project.

None

Returns:

Type Description
OrtsnetzLayout

Layout with footprints loaded, projector seeded, and substation / KVS pre-placed. Routing and connections are still up to the caller.

Examples:

Build the layout for Mulmke (Heudeber / Nordharz) in a 300 m radius around the village centre, with the substation at a chosen lat / lon and two KVS along the main road:

>>> layout = OrtsnetzLayout.from_osm(
...     center_lat_deg=51.9136, center_lon_deg=10.8432,
...     radius_m=300.0,
...     substation_lat_lon=(51.9138, 10.8418),
...     kvs_lat_lons=[
...         (51.9133, 10.8445),
...         (51.9140, 10.8460),
...     ],
... )
Source code in src/groundfield/generators/ortsnetz_builder.py
@classmethod
def from_osm(
    cls,
    *,
    center_lat_deg: float,
    center_lon_deg: float,
    radius_m: float,
    substation_lat_lon: Optional[tuple[float, float]] = None,
    substation_xy: Optional[tuple[float, float]] = None,
    kvs_lat_lons: Optional[list[tuple[float, float]]] = None,
    kvs_xys: Optional[list[tuple[float, float]]] = None,
    substation_name: str = "substation",
    obstacle_clearance_m: float = 1.0,
    min_area_m2: float = 16.0,
    cache_dir=None,
    force_refresh: bool = False,
    endpoint: Optional[str] = None,
    timeout_s: Optional[int] = None,
    max_retries: int = 1,
) -> "OrtsnetzLayout":
    """One-shot constructor: query OSM and pre-place anchors.

    Parameters
    ----------
    center_lat_deg, center_lon_deg
        Centre of the buildings-in-radius Overpass query. Also
        the WGS84 origin of the local ENU frame
        :attr:`frame_origin_lat_lon` so subsequent
        ``position_lat_lon`` arguments stay consistent.
    radius_m
        Search radius in metres.
    substation_lat_lon
        Optional substation location as WGS84 ``(lat, lon)``.
        Mutually exclusive with ``substation_xy``. When both
        are omitted the substation defaults to the local
        origin ``(0, 0)``.
    substation_xy
        Substation location in already-projected metres.
        Mutually exclusive with ``substation_lat_lon``.
    kvs_lat_lons
        Optional list of KVS locations in WGS84 ``(lat, lon)``.
        Each entry is projected and appended to
        :attr:`kvs_placements`. Names are auto-generated.
    kvs_xys
        Optional list of KVS locations in already-projected
        metres. Mutually exclusive with ``kvs_lat_lons``.
    substation_name
        Anchor id for the substation. Default ``"substation"``.
    obstacle_clearance_m, min_area_m2
        Forwarded to the layout / OSM query respectively.
    cache_dir, force_refresh, endpoint, timeout_s, max_retries
        Forwarded to :func:`groundfield.geo.osm.query_and_project`.

    Returns
    -------
    OrtsnetzLayout
        Layout with footprints loaded, projector seeded, and
        substation / KVS pre-placed. Routing and connections
        are still up to the caller.

    Examples
    --------
    Build the layout for Mulmke (Heudeber / Nordharz) in a
    300 m radius around the village centre, with the
    substation at a chosen lat / lon and two KVS along the
    main road:

    >>> layout = OrtsnetzLayout.from_osm(
    ...     center_lat_deg=51.9136, center_lon_deg=10.8432,
    ...     radius_m=300.0,
    ...     substation_lat_lon=(51.9138, 10.8418),
    ...     kvs_lat_lons=[
    ...         (51.9133, 10.8445),
    ...         (51.9140, 10.8460),
    ...     ],
    ... )
    """
    from groundfield.geo.osm import query_and_project

    if substation_lat_lon is not None and substation_xy is not None:
        raise ValueError(
            "OrtsnetzLayout.from_osm: substation_lat_lon and "
            "substation_xy are mutually exclusive."
        )
    if kvs_lat_lons is not None and kvs_xys is not None:
        raise ValueError(
            "OrtsnetzLayout.from_osm: kvs_lat_lons and kvs_xys "
            "are mutually exclusive."
        )

    query_kwargs: dict = {
        "min_area_m2": min_area_m2,
        "force_refresh": force_refresh,
        "max_retries": max_retries,
    }
    if cache_dir is not None:
        query_kwargs["cache_dir"] = cache_dir
    if endpoint is not None:
        query_kwargs["endpoint"] = endpoint
    if timeout_s is not None:
        query_kwargs["timeout_s"] = timeout_s

    footprints, projector = query_and_project(
        lat0_deg=center_lat_deg,
        lon0_deg=center_lon_deg,
        radius_m=radius_m,
        **query_kwargs,
    )

    if substation_lat_lon is not None:
        sub_xy = projector.to_xy_m(*substation_lat_lon)
    elif substation_xy is not None:
        sub_xy = tuple(substation_xy)
    else:
        sub_xy = (0.0, 0.0)

    layout = cls(
        footprints=footprints,
        substation_xy=sub_xy,
        substation_name=substation_name,
        obstacle_clearance_m=obstacle_clearance_m,
        frame_origin_lat_lon=(center_lat_deg, center_lon_deg),
    )

    if kvs_lat_lons:
        for lat, lon in kvs_lat_lons:
            layout.add_kvs(position_lat_lon=(lat, lon))
    elif kvs_xys:
        for xy in kvs_xys:
            layout.add_kvs(position_xy=xy)

    return layout

lat_lon_to_xy

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

Project a WGS84 (lat, lon) to the layout's local frame.

Raises :class:RuntimeError when no :attr:frame_origin_lat_lon has been set (i.e. the layout was not built via :meth:from_osm).

Source code in src/groundfield/generators/ortsnetz_builder.py
def lat_lon_to_xy(
    self, lat_deg: float, lon_deg: float,
) -> tuple[float, float]:
    """Project a WGS84 ``(lat, lon)`` to the layout's local frame.

    Raises :class:`RuntimeError` when no
    :attr:`frame_origin_lat_lon` has been set (i.e. the layout
    was not built via :meth:`from_osm`).
    """
    projector = self.projector
    if projector is None:
        raise RuntimeError(
            "OrtsnetzLayout.lat_lon_to_xy: layout has no "
            "frame_origin_lat_lon. Build via ``from_osm`` or "
            "pass ``frame_origin_lat_lon`` to ``from_footprints`` "
            "to enable lat / lon based placement."
        )
    return projector.to_xy_m(lat_deg, lon_deg)

measured_grounding_impedance

measured_grounding_impedance(
    result: object,
    *,
    frequency_index: int = 0,
    source_magnitude_A: float = 1.0,
    probe_xy: Optional[tuple[float, float]] = None,
    probe_depth_m: float = 0.0
) -> complex

Compute the simulated fall-of-potential measurement reading.

Returns:

Name Type Description
complex

:math:Z_\text{meas} = (\varphi_\text{sub} - \varphi_\text{probe}) / I_\text{src} at the requested frequency.

When neither ``probe_xy`` nor :attr:`voltage_probe` is set,
the reference falls back to remote earth, i.e. the method
returns math:`\varphi_\text{sub} / I_\text{src}`.

Parameters:

Name Type Description Default
result object

:class:FieldResult from :meth:Engine.solve.

required
frequency_index int

Pick the frequency and the test-current normalisation.

0
source_magnitude_A int

Pick the frequency and the test-current normalisation.

0
probe_xy Optional[tuple[float, float]]

Optional explicit probe position in local ENU metres (overrides :attr:voltage_probe for this call only). Lets the caller compare several probe geometries from a single solve -- useful for the "0° vs 90° probe at the same distance" comparison without having to call add_voltage_probe / to_world again.

None
probe_depth_m float

Sampling depth for the override (default 0 = surface). Ignored when probe_xy is None.

0.0

Raises:

Type Description
RuntimeError

If no auxiliary electrode is configured (the measurement loop is then physically open).

Source code in src/groundfield/generators/ortsnetz_builder.py
def measured_grounding_impedance(
    self,
    result: object,
    *,
    frequency_index: int = 0,
    source_magnitude_A: float = 1.0,
    probe_xy: Optional[tuple[float, float]] = None,
    probe_depth_m: float = 0.0,
) -> complex:
    r"""Compute the simulated fall-of-potential measurement reading.

    Returns
    -------
    complex
        :math:`Z_\text{meas} =
        (\varphi_\text{sub} - \varphi_\text{probe}) / I_\text{src}`
        at the requested frequency.

    When neither ``probe_xy`` nor :attr:`voltage_probe` is set,
    the reference falls back to remote earth, i.e. the method
    returns :math:`\varphi_\text{sub} / I_\text{src}`.

    Parameters
    ----------
    result
        :class:`FieldResult` from :meth:`Engine.solve`.
    frequency_index, source_magnitude_A
        Pick the frequency and the test-current normalisation.
    probe_xy
        Optional explicit probe position in local ENU metres
        (overrides :attr:`voltage_probe` for this call only).
        Lets the caller compare several probe geometries from
        a single solve -- useful for the
        "0° vs 90° probe at the same distance" comparison
        without having to call ``add_voltage_probe`` /
        ``to_world`` again.
    probe_depth_m
        Sampling depth for the override (default 0 = surface).
        Ignored when ``probe_xy`` is ``None``.

    Raises
    ------
    RuntimeError
        If no auxiliary electrode is configured (the
        measurement loop is then physically open).
    """
    if self.auxiliary_electrode is None:
        raise RuntimeError(
            "OrtsnetzLayout.measured_grounding_impedance: no "
            "auxiliary electrode is configured. Call "
            "``add_auxiliary_electrode`` first."
        )

    # Find the substation cluster's anchor in the result.
    electrode_potentials = getattr(result, "electrode_potentials", None)
    if electrode_potentials is None:
        raise TypeError(
            "OrtsnetzLayout.measured_grounding_impedance: "
            "``result`` does not look like a FieldResult."
        )
    sub_anchor: Optional[str] = None
    prefix = f"{self.substation_name}_"
    for ename in electrode_potentials:
        if ename.startswith(prefix):
            sub_anchor = ename
            break
    if sub_anchor is None:
        raise RuntimeError(
            "OrtsnetzLayout.measured_grounding_impedance: no "
            "electrode with prefix "
            f"{self.substation_name!r} found in the result. "
            "Did you re-run ``to_world`` before solving?"
        )

    phi_sub = electrode_potentials[sub_anchor][frequency_index]

    # Pick the probe location: explicit override > layout's
    # voltage_probe > remote earth fall-back.
    if probe_xy is not None:
        sample_xy = tuple(probe_xy)
        sample_depth = probe_depth_m
    elif self.voltage_probe is not None:
        sample_xy = self.voltage_probe.position_xy
        sample_depth = self.voltage_probe.depth_m
    else:
        sample_xy = None

    if sample_xy is None:
        phi_ref: complex = 0.0 + 0.0j
    else:
        point = np.asarray(
            [[sample_xy[0], sample_xy[1], sample_depth]],
            dtype=float,
        )
        phi_ref = complex(
            result.potential(
                point, frequency_index=frequency_index,
            )[0]
        )

    return (phi_sub - phi_ref) / source_magnitude_A

plot

plot(
    *,
    ax: Optional["_mpl_axes.Axes"] = None,
    show_unconnected: bool = True,
    annotate_houses: bool = False,
    foundation_mask: Optional[list[bool]] = None,
    figsize: tuple[float, float] = (12.0, 8.0)
) -> "_mpl_axes.Axes"

Render the layout with matplotlib.

Parameters:

Name Type Description Default
foundation_mask Optional[list[bool]]

Optional per-house bool list (one entry per footprint). When supplied, houses with True get a saturated fill colour (foundation electrode present) and the remaining houses get a faded fill (no foundation). Combine with :meth:foundation_mask to visualise different penetration scenarios.

None

Returns:

Type Description
Axes

The axes the layout was drawn on. The figure can be retrieved via ax.figure.

Source code in src/groundfield/generators/ortsnetz_builder.py
def plot(
    self,
    *,
    ax: Optional["_mpl_axes.Axes"] = None,
    show_unconnected: bool = True,
    annotate_houses: bool = False,
    foundation_mask: Optional[list[bool]] = None,
    figsize: tuple[float, float] = (12.0, 8.0),
) -> "_mpl_axes.Axes":
    """Render the layout with matplotlib.

    Parameters
    ----------
    foundation_mask
        Optional per-house bool list (one entry per footprint).
        When supplied, houses with ``True`` get a saturated
        fill colour (foundation electrode present) and the
        remaining houses get a faded fill (no foundation).
        Combine with :meth:`foundation_mask` to visualise
        different penetration scenarios.

    Returns
    -------
    matplotlib.axes.Axes
        The axes the layout was drawn on. The figure can be
        retrieved via ``ax.figure``.
    """
    import matplotlib.pyplot as plt

    if ax is None:
        _, ax = plt.subplots(figsize=figsize)

    connected_idx = {c.house_idx for c in self.connections}

    if foundation_mask is not None and len(foundation_mask) != len(
        self.footprints,
    ):
        raise ValueError(
            "OrtsnetzLayout.plot: foundation_mask length "
            f"{len(foundation_mask)} does not match footprints "
            f"count {len(self.footprints)}."
        )

    # Building footprints.
    for i, fp in enumerate(self.footprints):
        xs = [p[0] for p in fp.polygon_xy_m] + [fp.polygon_xy_m[0][0]]
        ys = [p[1] for p in fp.polygon_xy_m] + [fp.polygon_xy_m[0][1]]
        if foundation_mask is not None:
            # With-foundation: saturated green-grey; without: faded.
            if foundation_mask[i]:
                colour = "#7fbf7f"   # has Fundamenterder
                edge = "#2a6f2a"
            else:
                colour = "#f4f4f4"   # no Fundamenterder
                edge = "0.7"
        else:
            colour = "0.92" if i in connected_idx else "#fde0dc"
            edge = "0.4"
        ax.fill(xs, ys, color=colour, edgecolor=edge,
                linewidth=0.6, zorder=1)
        if annotate_houses:
            cx, cy = fp.centroid_xy_m()
            ax.text(cx, cy, str(i), ha="center", va="center",
                    fontsize=7, color="0.3", zorder=2)

    # PEN cables.
    cable_colours = {}
    palette = plt.get_cmap("tab10").colors
    for k, cable in enumerate(self.pen_cables):
        colour = palette[k % len(palette)]
        cable_colours[cable.name] = colour
        xs = [p[0] for p in cable.waypoints]
        ys = [p[1] for p in cable.waypoints]
        ax.plot(xs, ys, color=colour, lw=2.2, zorder=4,
                label=f"PEN {cable.name}")
        # Mark every corner.
        for x, y in cable.waypoints:
            ax.plot(x, y, marker="o", color=colour, markersize=3,
                    zorder=5)

    # Service drops.
    drew_stub_label = False
    for c in self.connections:
        colour = cable_colours.get(c.cable_name, "saddlebrown")
        xs = [p[0] for p in c.stub_waypoints]
        ys = [p[1] for p in c.stub_waypoints]
        ax.plot(xs, ys, color=colour, lw=1.0, ls="--", zorder=3,
                label=("PEN service drop" if not drew_stub_label
                       else None))
        drew_stub_label = True
        ax.plot(c.tap_point_xy[0], c.tap_point_xy[1],
                marker="x", color=colour, markersize=6, zorder=6)

    # Substation marker.
    ax.plot(*self.substation_xy, marker="D",
            color="tab:orange", markersize=14, zorder=7,
            label="Substation")

    # KVS markers.
    for k in self.kvs_placements:
        ax.plot(*k.position_xy, marker="s",
                color="tab:blue", markersize=11, zorder=7)
    if self.kvs_placements:
        ax.plot([], [], marker="s", color="tab:blue",
                markersize=11, linestyle="None", label="KVS")

    # Auxiliary current electrode (Hilfserder).
    if self.auxiliary_electrode is not None:
        aux = self.auxiliary_electrode
        # Dotted return-current line from substation to aux.
        ax.plot(
            [self.substation_xy[0], aux.position_xy[0]],
            [self.substation_xy[1], aux.position_xy[1]],
            color="purple", lw=0.8, ls=":", zorder=2,
            label="Measurement feed (return)",
        )
        ax.plot(*aux.position_xy, marker="v",
                color="purple", markersize=13, zorder=7,
                label=f"Auxiliary {aux.name!s} (Hilfserder)")

    # Voltage probe (Spannungssonde).
    if self.voltage_probe is not None:
        probe = self.voltage_probe
        ax.plot(*probe.position_xy, marker="*",
                color="lime", markersize=18, zorder=8,
                markeredgecolor="darkgreen", markeredgewidth=1.2,
                label=f"Probe {probe.name!s} (Spannungssonde)")
    if show_unconnected and foundation_mask is None and any(
        i not in connected_idx for i in range(len(self.footprints))
    ):
        ax.plot([], [], marker="s", color="#fde0dc",
                markersize=11, linestyle="None",
                label="Unconnected house")
    if foundation_mask is not None:
        ax.plot([], [], marker="s", color="#7fbf7f",
                markersize=11, linestyle="None",
                label="House with Fundamenterder")
        ax.plot([], [], marker="s", color="#f4f4f4",
                markeredgecolor="0.7",
                markersize=11, linestyle="None",
                label="House without Fundamenterder")

    ax.set_aspect("equal")
    ax.set_xlabel("x / m")
    ax.set_ylabel("y / m")
    ax.set_title("TN-Ortsnetz layout (Manhattan PEN routing)")
    ax.legend(loc="upper right", fontsize=8)
    ax.grid(True, alpha=0.3)
    return ax

plot_surface_potential

plot_surface_potential(
    result: object,
    world: World,
    *,
    frequency_index: int = 0,
    padding_m: float = 50.0,
    n: int = 150,
    levels: int = 41,
    log: bool = False,
    symmetric: bool = False,
    two_slope: bool = True,
    cmap: str = "RdBu_r",
    show_electrodes: bool = True,
    figsize: tuple[float, float] = (11.0, 8.5),
    title: Optional[str] = None
)

Surface-potential pseudo-colour plot with measurement markers (substation + Hilfserder + Spannungssonde).

Renders \(\varphi(x, y, z=0)\) on a regular grid sized to the world bounding box plus padding_m and overlays the substation, Hilfserder and Spannungssonde markers plus a dotted return-current line. Three colour-axis modes are supported:

  • two_slope=True (default) -- uses :class:matplotlib.colors.TwoSlopeNorm centred at \(\varphi = 0\) so the colour resolution above and below zero is balanced regardless of the actual range. Ideal for the fall-of-potential setup, where the Hilfserder sits at a strongly negative potential (e.g. -10 V) and the substation + foundations together rise to only a few volts -- on a single linear scale the positive trumpet collapses into a single colour. The colourbar uses non-uniform contour levels so each half of the scale carries the same number of fills.
  • symmetric=True -- the classic :math:[-|\varphi|_\text{max}, +|\varphi|_\text{max}] range. Equivalent to TwoSlopeNorm only when the extremes are symmetric; otherwise large negative potentials clip the positive side. Setting symmetric=True implicitly turns off two_slope.
  • log=True -- log-scale on \(|\varphi|\). Useful when the potential decays across several decades toward remote earth.

Parameters:

Name Type Description Default
result object

:class:FieldResult from :meth:Engine.solve.

required
world World

Companion world used to derive the plot extent and (when show_electrodes=True) to overlay the electrode geometry.

required
frequency_index int

Index into :attr:FieldResult.frequencies.

0
padding_m float

Extra space around the world bounding box in m.

50.0
n int

Grid resolution per axis (n × n evaluation points).

150
levels int

Number of contour-fill levels. Split half-and-half between the negative and positive ranges when two_slope=True.

41
log bool

Mutually-influencing scaling modes (see above).

False
symmetric bool

Mutually-influencing scaling modes (see above).

False
two_slope bool

Mutually-influencing scaling modes (see above).

False
cmap str

Matplotlib colormap (diverging recommended).

'RdBu_r'
show_electrodes bool

Overlay the electrode geometry (small markers + lines).

True
figsize tuple[float, float]

Figure size and optional title override.

(11.0, 8.5)
title tuple[float, float]

Figure size and optional title override.

(11.0, 8.5)

Returns:

Type Description
Figure

The figure the plot was drawn on. Use fig.axes[0] for further annotations.

Source code in src/groundfield/generators/ortsnetz_builder.py
def plot_surface_potential(
    self,
    result: object,
    world: World,
    *,
    frequency_index: int = 0,
    padding_m: float = 50.0,
    n: int = 150,
    levels: int = 41,
    log: bool = False,
    symmetric: bool = False,
    two_slope: bool = True,
    cmap: str = "RdBu_r",
    show_electrodes: bool = True,
    figsize: tuple[float, float] = (11.0, 8.5),
    title: Optional[str] = None,
):
    r"""Surface-potential pseudo-colour plot with measurement
    markers (substation + Hilfserder + Spannungssonde).

    Renders $\varphi(x, y, z=0)$ on a regular grid sized to the
    world bounding box plus ``padding_m`` and overlays the
    substation, Hilfserder and Spannungssonde markers plus a
    dotted return-current line. Three colour-axis modes are
    supported:

    * ``two_slope=True`` (default) -- uses
      :class:`matplotlib.colors.TwoSlopeNorm` centred at
      $\varphi = 0$ so the colour resolution above and below
      zero is balanced regardless of the actual range. Ideal
      for the fall-of-potential setup, where the Hilfserder
      sits at a strongly negative potential (e.g. -10 V) and
      the substation + foundations together rise to only a
      few volts -- on a single linear scale the positive
      trumpet collapses into a single colour. The colourbar
      uses non-uniform contour levels so each half of the
      scale carries the same number of fills.
    * ``symmetric=True`` -- the classic
      :math:`[-|\varphi|_\text{max}, +|\varphi|_\text{max}]`
      range. Equivalent to ``TwoSlopeNorm`` only when the
      extremes are symmetric; otherwise large negative
      potentials clip the positive side. Setting
      ``symmetric=True`` *implicitly* turns off
      ``two_slope``.
    * ``log=True`` -- log-scale on $|\varphi|$. Useful when the
      potential decays across several decades toward remote
      earth.

    Parameters
    ----------
    result
        :class:`FieldResult` from :meth:`Engine.solve`.
    world
        Companion world used to derive the plot extent and
        (when ``show_electrodes=True``) to overlay the
        electrode geometry.
    frequency_index
        Index into :attr:`FieldResult.frequencies`.
    padding_m
        Extra space around the world bounding box in m.
    n
        Grid resolution per axis (``n × n`` evaluation points).
    levels
        Number of contour-fill levels. Split half-and-half
        between the negative and positive ranges when
        ``two_slope=True``.
    log, symmetric, two_slope
        Mutually-influencing scaling modes (see above).
    cmap
        Matplotlib colormap (diverging recommended).
    show_electrodes
        Overlay the electrode geometry (small markers + lines).
    figsize, title
        Figure size and optional title override.

    Returns
    -------
    matplotlib.figure.Figure
        The figure the plot was drawn on. Use ``fig.axes[0]``
        for further annotations.
    """
    import matplotlib.pyplot as plt
    from matplotlib.colors import LogNorm, Normalize, TwoSlopeNorm

    from groundfield.postprocess.plotting import (
        _draw_electrodes, _make_grid, world_bounds_xy,
    )

    # Mutual exclusion of the scaling modes -- log dominates;
    # symmetric overrides two_slope. Default is two_slope.
    if log:
        two_slope = False
        symmetric = False
    elif symmetric:
        two_slope = False

    x_min, x_max, y_min, y_max = world_bounds_xy(world)
    extent = (
        x_min - padding_m, x_max + padding_m,
        y_min - padding_m, y_max + padding_m,
    )
    A, B, flat = _make_grid("xy", extent, 0.0, n)
    phi = result.potential(flat, frequency_index=frequency_index).real
    phi = phi.reshape(A.shape)

    fig, ax = plt.subplots(figsize=figsize)

    if log:
        phi_abs = np.abs(phi)
        positive = phi_abs[phi_abs > 0]
        v_min = float(positive.min()) if positive.size else 1e-9
        v_max = float(phi_abs.max()) if phi_abs.size else 1.0
        norm = LogNorm(vmin=max(v_min, 1e-9),
                       vmax=max(v_max, 10 * v_min))
        cs = ax.contourf(A, B, phi_abs, levels=levels,
                         cmap=cmap, norm=norm)
        cbar_label = "|φ| in V (log scale)"
    elif symmetric:
        v = float(np.max(np.abs(phi)))
        norm = Normalize(vmin=-v, vmax=v)
        cs = ax.contourf(A, B, phi, levels=levels,
                         cmap=cmap, norm=norm)
        cbar_label = "Potential φ in V"
    elif two_slope:
        v_neg = float(min(phi.min(), -1e-9))
        v_pos = float(max(phi.max(), +1e-9))
        norm = TwoSlopeNorm(vmin=v_neg, vcenter=0.0, vmax=v_pos)
        # Split contour levels half-and-half so each side of
        # zero carries the same visual resolution.
        n_per_side = max(2, levels // 2)
        neg_levels = np.linspace(v_neg, 0.0, n_per_side + 1)[:-1]
        pos_levels = np.linspace(0.0, v_pos, n_per_side + 1)
        level_array = np.concatenate([neg_levels, pos_levels])
        cs = ax.contourf(A, B, phi, levels=level_array,
                         cmap=cmap, norm=norm, extend="both")
        cbar_label = (
            "Potential φ in V (TwoSlopeNorm: equal colour "
            "resolution above / below 0)"
        )
    else:
        cs = ax.contourf(A, B, phi, levels=levels, cmap=cmap)
        cbar_label = "Potential φ in V"

    cbar = fig.colorbar(cs, ax=ax)
    cbar.set_label(cbar_label)

    ax.set_xlabel("x in m")
    ax.set_ylabel("y in m")
    ax.set_aspect("equal")
    if title is None:
        f_hz = result.frequencies[frequency_index]
        title = (
            f"Surface potential φ(x, y, z = 0 m), "
            f"f = {f_hz:g} Hz"
        )
    ax.set_title(title)

    if show_electrodes:
        _draw_electrodes(ax, world, "xy")

    # Substation marker.
    ax.plot(*self.substation_xy, marker="D",
            color="tab:orange", markersize=14, zorder=10,
            markeredgecolor="black", markeredgewidth=0.8,
            label="Substation")
    # Auxiliary electrode (Hilfserder).
    if self.auxiliary_electrode is not None:
        aux = self.auxiliary_electrode
        ax.plot(
            [self.substation_xy[0], aux.position_xy[0]],
            [self.substation_xy[1], aux.position_xy[1]],
            color="purple", lw=0.8, ls=":", zorder=9,
        )
        ax.plot(*aux.position_xy, marker="v",
                color="purple", markersize=13, zorder=10,
                markeredgecolor="black", markeredgewidth=0.8,
                label="Hilfserder")
    # Voltage probe (Spannungssonde).
    if self.voltage_probe is not None:
        probe = self.voltage_probe
        ax.plot(*probe.position_xy, marker="*",
                color="lime", markersize=18, zorder=11,
                markeredgecolor="darkgreen", markeredgewidth=1.2,
                label="Spannungssonde")
    ax.legend(loc="upper right", fontsize=9, framealpha=0.9)
    return fig

to_world

to_world(
    *,
    name: str = "ortsnetz_layout",
    soil: Optional[SoilModel] = None,
    substation_grounding: Optional[
        GroundingSystemSpec
    ] = None,
    kvs_grounding: Optional[GroundingSystemSpec] = None,
    house_grounding: Optional[GroundingSystemSpec] = None,
    pen_wire_radius_m: float = 0.005,
    pen_segment_length_m: Optional[float] = 5.0,
    include_source: bool = True,
    source_magnitude_A: float = 1.0,
    seed: int = 0,
    foundation_mask: Optional[list[bool]] = None
) -> World

Materialise the layout into a :class:groundfield.World.

Each :class:PenCable becomes a chain of straight-line :class:Conductor segments between PEN junction electrodes (tiny rods at every corner and tap point) so the resulting cable physically follows the Manhattan polyline. Each :class:BuildingConnection becomes one additional conductor from the house foundation anchor to the nearest PEN junction on the serving cable.

Parameters:

Name Type Description Default
foundation_mask Optional[list[bool]]

Optional per-house bool list. When given, only houses with a True entry receive a foundation electrode (and therefore a PEN service drop); the remaining houses are kept in the geometry for visualisation but are not materialised in the world. Combine with :meth:foundation_mask for a reproducible penetration sweep.

None
Notes

Other parameters follow the v0.7 :meth:to_world defaults. When :attr:auxiliary_electrode is set (i.e. :meth:add_auxiliary_electrode was called), the source's return_to is wired to the auxiliary anchor so the engine treats it as the test-current return path. Without an auxiliary electrode the source returns through the remote-earth boundary (= the historical pre-v0.7 behaviour).

Source code in src/groundfield/generators/ortsnetz_builder.py
def to_world(
    self,
    *,
    name: str = "ortsnetz_layout",
    soil: Optional[SoilModel] = None,
    substation_grounding: Optional[GroundingSystemSpec] = None,
    kvs_grounding: Optional[GroundingSystemSpec] = None,
    house_grounding: Optional[GroundingSystemSpec] = None,
    pen_wire_radius_m: float = 0.005,
    pen_segment_length_m: Optional[float] = 5.0,
    include_source: bool = True,
    source_magnitude_A: float = 1.0,
    seed: int = 0,
    foundation_mask: Optional[list[bool]] = None,
) -> World:
    """Materialise the layout into a :class:`groundfield.World`.

    Each :class:`PenCable` becomes a chain of straight-line
    :class:`Conductor` segments between *PEN junction*
    electrodes (tiny rods at every corner and tap point) so
    the resulting cable physically follows the Manhattan
    polyline. Each :class:`BuildingConnection` becomes one
    additional conductor from the house foundation anchor to
    the nearest PEN junction on the serving cable.

    Parameters
    ----------
    foundation_mask
        Optional per-house bool list. When given, only houses
        with a ``True`` entry receive a foundation electrode
        (and therefore a PEN service drop); the remaining
        houses are kept in the geometry for visualisation but
        are *not* materialised in the world. Combine with
        :meth:`foundation_mask` for a reproducible penetration
        sweep.

    Notes
    -----
    Other parameters follow the v0.7 :meth:`to_world` defaults.
    When :attr:`auxiliary_electrode` is set (i.e.
    :meth:`add_auxiliary_electrode` was called), the source's
    ``return_to`` is wired to the auxiliary anchor so the
    engine treats it as the test-current return path. Without
    an auxiliary electrode the source returns through the
    remote-earth boundary (= the historical pre-v0.7
    behaviour).
    """
    if foundation_mask is not None and len(foundation_mask) != len(
        self.footprints,
    ):
        raise ValueError(
            "OrtsnetzLayout.to_world: foundation_mask length "
            f"{len(foundation_mask)} does not match footprints "
            f"count {len(self.footprints)}."
        )
    rng = np.random.default_rng(seed)
    world = create_world(
        name=name,
        soil=soil or HomogeneousSoil(resistivity=100.0),
    )

    # 1. Substation grounding.
    substation_spec = substation_grounding or _default_substation_grounding()
    substation_anchor = substation_spec.build_at(
        world,
        site_xy=self.substation_xy,
        name_prefix=self.substation_name,
        rng=rng,
    )
    if substation_anchor is None:
        raise RuntimeError(
            "OrtsnetzLayout.to_world: substation grounding "
            "produced zero electrodes."
        )

    # 2. KVS groundings.
    kvs_spec = kvs_grounding or _default_kvs_grounding()
    kvs_anchors: dict[str, str] = {}
    for k in self.kvs_placements:
        anchor = kvs_spec.build_at(
            world,
            site_xy=k.position_xy,
            name_prefix=k.name,
            rng=rng,
        )
        if anchor is None:
            raise RuntimeError(
                "OrtsnetzLayout.to_world: KVS "
                f"{k.name!r} grounding produced zero electrodes."
            )
        kvs_anchors[k.name] = anchor

    # 3. House groundings (foundations). When a foundation_mask is
    #    supplied, only houses with mask[i] == True receive an
    #    electrode -- the remaining ones stay in the geometry
    #    (still rendered by ``plot``) but contribute nothing to
    #    the solver world.
    house_spec = house_grounding or _default_house_grounding()
    house_anchors: dict[int, str] = {}
    for i, fp in enumerate(self.footprints):
        if foundation_mask is not None and not foundation_mask[i]:
            continue
        centroid = fp.centroid_xy_m()
        anchor = house_spec.build_at(
            world,
            site_xy=centroid,
            name_prefix=f"house_{i:03d}",
            rng=rng,
        )
        if anchor is not None:
            house_anchors[i] = anchor

    # 3a. Auxiliary current electrode (Hilfserder), if configured.
    # Uses the same GroundingSystemSpec.build_at path as the
    # substation / KVS so any electrode topology is supported.
    # The returned anchor is the first present electrode (e.g.
    # ``aux_rod_0`` for the default 3-rod triangle); the
    # remaining electrodes are galvanically bonded inside
    # build_at, so the whole bundle forms one cluster.
    aux_anchor: Optional[str] = None
    if self.auxiliary_electrode is not None:
        aux = self.auxiliary_electrode
        aux_anchor = aux.grounding.build_at(
            world,
            site_xy=aux.position_xy,
            name_prefix=aux.name,
            rng=rng,
        )
        if aux_anchor is None:
            raise RuntimeError(
                "OrtsnetzLayout.to_world: auxiliary electrode "
                "grounding produced zero electrodes "
                f"({aux.name!r}). Increase presence_prob."
            )

    # 4. PEN cabling -- one junction electrode per waypoint and
    #    tap point, conductors between consecutive junctions.
    common_pen_kwargs: dict = dict(
        conductor_type="pen",
        wire_radius=pen_wire_radius_m,
        cross_section="from_radius",
        coupling_to_soil="isolated",
    )
    if pen_segment_length_m is not None:
        common_pen_kwargs["discretize_segment_length"] = pen_segment_length_m

    # Build a lookup from cable.name -> ordered list of
    # ``(anchor_name, (x, y))`` along the polyline. Endpoints
    # reuse existing anchors (substation / KVS); internal
    # waypoints and tap points get fresh "pen_node" rods.
    cable_node_map: dict[str, list[tuple[str, tuple[float, float]]]] = {}
    for cable in self.pen_cables:
        nodes: list[tuple[str, tuple[float, float]]] = []
        taps_for_cable = sorted(
            {
                tuple(c.tap_point_xy): c.house_idx
                for c in self.connections
                if c.cable_name == cable.name
            }.items(),
            key=lambda kv: (kv[0][0], kv[0][1]),
        )
        tap_lookup = {pos: house_idx for pos, house_idx in taps_for_cable}

        for k, wp in enumerate(cable.waypoints):
            wp_t = tuple(wp)
            if k == 0:
                nodes.append((self._resolve_anchor(
                    cable.start_anchor, substation_anchor, kvs_anchors,
                ), wp_t))
                continue
            if k == len(cable.waypoints) - 1 and cable.end_anchor is not None:
                nodes.append((self._resolve_anchor(
                    cable.end_anchor, substation_anchor, kvs_anchors,
                ), wp_t))
                continue
            # Internal corner -- create a tiny rod electrode.
            node_name = f"{cable.name}_node_{k}"
            _create_pen_node(world, node_name, wp_t)
            nodes.append((node_name, wp_t))
        # Insert tap nodes between corner nodes where the tap
        # falls on the cable segment.
        nodes = self._insert_tap_nodes_on_cable(
            cable=cable,
            nodes=nodes,
            tap_lookup=tap_lookup,
            world=world,
        )
        cable_node_map[cable.name] = nodes
        # Trunk conductors between consecutive nodes.
        for j, ((a0, _p0), (a1, _p1)) in enumerate(
            zip(nodes[:-1], nodes[1:])
        ):
            if a0 == a1:
                continue
            create_conductor(
                world,
                name=f"{cable.name}_seg_{j}",
                start=a0,
                end=a1,
                **common_pen_kwargs,
            )

    # 5. Service drops -- foundation anchor → tap node on cable.
    for c in self.connections:
        if c.house_idx not in house_anchors:
            continue
        tap_anchor = self._tap_anchor_for(c, cable_node_map)
        if tap_anchor is None:
            continue
        create_conductor(
            world,
            name=f"service_{c.house_idx:03d}",
            start=tap_anchor,
            end=house_anchors[c.house_idx],
            **common_pen_kwargs,
        )

    # 6. Source at the substation. When an auxiliary electrode is
    # configured (fall-of-potential measurement setup) the test
    # current physically returns through that electrode. The v0.7
    # image solver consumes :attr:`Source.attached_to` only --
    # :attr:`Source.return_to` is recorded for documentation /
    # future solver upgrades but is not honoured by the engine.
    # We therefore *explicitly* close the measurement loop by
    # adding a second :class:`CurrentSource` at the auxiliary
    # anchor whose magnitude has the opposite sign
    # (``phase_deg=180``). Net injected charge is then zero, the
    # potential field develops the expected dipole pattern with
    # a positive trumpet at the substation and a negative one at
    # the Hilfserder -- exactly what a real measurement loop
    # produces.
    if include_source:
        create_source(
            world,
            attached_to=substation_anchor,
            return_to=aux_anchor,
            magnitude=source_magnitude_A,
        )
        if aux_anchor is not None:
            create_source(
                world,
                name=f"{aux_anchor}_return",
                attached_to=aux_anchor,
                magnitude=source_magnitude_A,
                phase_deg=180.0,
            )

    return world

verify_current_balance

verify_current_balance(
    result: object,
    *,
    frequency_index: int = 0,
    source_magnitude_A: float = 1.0,
    tolerance: float = 0.02
) -> dict

Plausibility check: sum of leakage currents must close the measurement loop.

With the v0.7 closed-loop wiring the engine sees:

  • a positive +I injection at the substation cluster, which distributes itself across the substation grounding and the PEN-bonded foundations (because the PEN cables are finite-impedance branches that share the substation potential);
  • a negative -I injection at the Hilfserder cluster.

Per Kirchhoff the total leakage current at every other electrode must vanish:

.. math:: \sum_e I_e^\text{(positive side)} \approx +I_\text{src} ,\qquad \sum_e I_e^\text{(aux side)} \approx -I_\text{src}

Returns a dict with the two sums and a boolean ok flag indicating that both sums match :math:\pm I_\text{src} within tolerance (default 2 % of the test current).

The "positive side" is every electrode whose name does not start with the auxiliary's name prefix; the "aux side" is every electrode whose name does start with that prefix.

Source code in src/groundfield/generators/ortsnetz_builder.py
def verify_current_balance(
    self,
    result: object,
    *,
    frequency_index: int = 0,
    source_magnitude_A: float = 1.0,
    tolerance: float = 0.02,
) -> dict:
    r"""Plausibility check: sum of leakage currents must close the
    measurement loop.

    With the v0.7 closed-loop wiring the engine sees:

    * a positive ``+I`` injection at the substation cluster, which
      distributes itself across the substation grounding *and* the
      PEN-bonded foundations (because the PEN cables are
      finite-impedance branches that share the substation
      potential);
    * a negative ``-I`` injection at the Hilfserder cluster.

    Per Kirchhoff the *total* leakage current at every other
    electrode must vanish:

    .. math::
        \sum_e I_e^\text{(positive side)} \approx +I_\text{src}
        ,\qquad
        \sum_e I_e^\text{(aux side)} \approx -I_\text{src}

    Returns a dict with the two sums and a boolean ``ok`` flag
    indicating that both sums match :math:`\pm I_\text{src}`
    within ``tolerance`` (default 2 % of the test current).

    The "positive side" is every electrode whose name does *not*
    start with the auxiliary's name prefix; the "aux side" is
    every electrode whose name *does* start with that prefix.
    """
    if self.auxiliary_electrode is None:
        raise RuntimeError(
            "OrtsnetzLayout.verify_current_balance: no auxiliary "
            "electrode is configured. The measurement loop is "
            "physically open; current balance is undefined."
        )
    aux_prefix = f"{self.auxiliary_electrode.name}_"
    currents = result.electrode_currents
    pos_sum: complex = 0j
    neg_sum: complex = 0j
    for ename, i_list in currents.items():
        i = i_list[frequency_index]
        if ename.startswith(aux_prefix) or ename == self.auxiliary_electrode.name:
            neg_sum += i
        else:
            pos_sum += i
    expected_pos = +source_magnitude_A
    expected_neg = -source_magnitude_A
    err_pos = abs(pos_sum - expected_pos)
    err_neg = abs(neg_sum - expected_neg)
    tol_abs = tolerance * source_magnitude_A
    ok = (err_pos <= tol_abs) and (err_neg <= tol_abs)
    return {
        "positive_side_A": pos_sum,
        "auxiliary_side_A": neg_sum,
        "expected_positive_A": expected_pos,
        "expected_auxiliary_A": expected_neg,
        "abs_error_positive_A": err_pos,
        "abs_error_auxiliary_A": err_neg,
        "tolerance_A": tol_abs,
        "ok": ok,
    }

xy_to_lat_lon

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

Inverse projection: local frame (x, y) → WGS84.

Mirrors :meth:lat_lon_to_xy.

Source code in src/groundfield/generators/ortsnetz_builder.py
def xy_to_lat_lon(
    self, x_m: float, y_m: float,
) -> tuple[float, float]:
    """Inverse projection: local frame ``(x, y)`` → WGS84.

    Mirrors :meth:`lat_lon_to_xy`.
    """
    projector = self.projector
    if projector is None:
        raise RuntimeError(
            "OrtsnetzLayout.xy_to_lat_lon: layout has no "
            "frame_origin_lat_lon. Build via ``from_osm`` first."
        )
    return projector.to_lat_lon(x_m, y_m)

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.

Source code in src/groundfield/geo/placement.py
def __len__(self) -> int:
    """Number of footprints after applying ``min_area_m2``."""
    return self.n_footprints

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).

Source code in src/groundfield/geo/placement.py
def footprint_at(
    self,
    i: int,
    *,
    strict: bool = False,
) -> Optional[BuildingFootprint]:
    """Return the footprint associated with site ``i``.

    Parameters
    ----------
    i
        Site index, matching the order returned by
        :meth:`generate`.
    strict
        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.

    Returns
    -------
    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
    ------
    IndexError
        If ``strict=True`` and ``i`` is outside
        ``[0, n_footprints)``.
    """
    filtered = self._filtered()
    n = len(filtered)
    if 0 <= i < n:
        return filtered[i]
    if strict:
        raise IndexError(
            "OsmBuildingPlacement.footprint_at: requested index "
            f"{i} but only {n} footprints are available after "
            f"min_area_m2={self.min_area_m2} filter "
            f"(raw list length {len(self.footprints)}). "
            "Use ``len(placement)`` for the available count, or "
            "pass ``strict=False`` (default) for a None fall-back."
        )
    return None

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.

Source code in src/groundfield/geo/placement.py
def generate(
    self,
    n: int,
    rng: "np.random.Generator",  # unused — kept for interface parity
) -> list[tuple[float, float]]:
    """Return ``n`` site positions as polygon centroids.

    Parameters
    ----------
    n
        Number of sites the generator wants to populate.
    rng
        Unused. Accepted for interface parity with the other
        :class:`PlacementSpec` variants, all of which take an
        RNG so the generator can call them uniformly.

    Returns
    -------
    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
    ------
    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.
    """
    filtered = self._filtered()
    if self.selection == "all":
        return [fp.centroid_xy_m() for fp in filtered]
    if n > len(filtered):
        raise ValueError(
            f"OsmBuildingPlacement.generate: requested {n} sites "
            f"but only {len(filtered)} footprints are available "
            f"after the min_area_m2={self.min_area_m2} filter."
        )
    return [filtered[i].centroid_xy_m() for i in range(n)]

PenCable

Bases: BaseModel

One PEN cable as a polyline of axis-aligned waypoints.

Attributes:

Name Type Description
name str

Unique cable id used for diagnostics, to_world naming and plot legends.

start_anchor str

Name of the anchor (substation or KVS) the cable roots at.

waypoints list[tuple[float, float]]

Ordered list of \((x, y)\) corners. The first entry equals the start anchor's position and consecutive entries differ in exactly one coordinate (Manhattan).

end_anchor Optional[str]

Optional name of the anchor (KVS) the cable terminates at. None when the cable runs to free coordinates (a :emphasis:stub end used as a tap for nearby houses but not for further trunking).

grid_size_m float

The grid size used when this cable was routed -- kept on the model so :meth:OrtsnetzLayout.connect_buildings can align stubs with the trunk.

PenConfig

Bases: GeneratorConfig

PEN cable backbone (distributed conductor, ADR-0003).

RadialTrunkTopology

Bases: BaseModel

Radial-feeder PEN topology with finite per-source slot budget.

The substation feeds :attr:n_feeders radial feeders. Each feeder runs along its own discrete direction (:attr:feeder_directions_deg or — by default — evenly spaced around the substation starting at 0°). Buildings are assigned to the angularly closest feeder, then sorted by distance from the substation along that feeder axis. The first :attr:slots_per_substation buildings tap directly to the substation; once the budget is exhausted, a KVS is inserted along the feeder axis and the next :attr:slots_per_kvs buildings tap to that KVS, and so on.

Attributes:

Name Type Description
n_feeders int

Number of radial feeders emanating from the substation. Default 4 (a typical "north / east / south / west" TN-Ortsnetz).

feeder_directions_deg Optional[list[float]]

Optional explicit feeder angles in degrees (CCW from +x). Length must equal :attr:n_feeders. None (default) auto-distributes the feeders evenly: :math:\theta_k = 360^\circ\,k/N for :math:k=0,\ldots,N-1.

max_feeder_length_m float

Maximum trunk length per feeder. Buildings beyond this distance (measured along the feeder axis) are dropped from the assignment and trigger a :class:UserWarning.

slots_per_substation int

Number of direct building taps each feeder may have at the substation. Once this many buildings have been wired, the next building forces a KVS extension.

slots_per_kvs int

Number of direct building taps per KVS. After the substation budget is exhausted, KVSes are inserted along the feeder until every assigned building is wired or the feeder reaches :attr:max_feeder_length_m.

kvs_spacing_m float

Step length (along the feeder axis) between adjacent KVSes when the topology decides to insert a new one. Default 60 m matches the typical street-section length between cable cabinets in urban LV networks.

name_prefix str

Prefix used to name the inserted KVSes (f"{prefix}_{feeder_idx}_{kvs_idx}"). Defaults to "kvs" so the names are consistent with the legacy star topology.

Notes

Trunk segments (substation → KVS and KVS → KVS) and service drops (tap anchor → building anchor) are realised as straight 3D conductors between the corresponding anchor electrodes. The Manhattan-ness of the layout is reflected in (a) the discrete feeder directions which keep the trunk axis-aligned and (b) the angular feeder assignment, which steers buildings toward the geometrically closest street axis. Corner-routed service drops are tracked as a v2 enhancement.

assign_buildings_to_feeders

assign_buildings_to_feeders(
    substation_xy: tuple[float, float],
    building_positions: list[tuple[float, float]],
) -> list[list[int]]

Group building indices by angularly closest feeder.

Parameters:

Name Type Description Default
substation_xy tuple[float, float]

Substation centre in m.

required
building_positions list[tuple[float, float]]

Per-building centres in m. Order is preserved across the assignment.

required

Returns:

Type Description
list of lists of int

out[feeder_idx] is the list of building indices assigned to feeder feeder_idx. Each list is sorted by distance from the substation along the feeder axis (signed projection onto the feeder direction). Buildings with a negative axial projection (behind the substation) are excluded from the feeder.

Source code in src/groundfield/generators/pen_topology.py
def assign_buildings_to_feeders(
    self,
    substation_xy: tuple[float, float],
    building_positions: list[tuple[float, float]],
) -> list[list[int]]:
    """Group building indices by angularly closest feeder.

    Parameters
    ----------
    substation_xy
        Substation centre in m.
    building_positions
        Per-building centres in m. Order is preserved across
        the assignment.

    Returns
    -------
    list of lists of int
        ``out[feeder_idx]`` is the list of building indices
        assigned to feeder ``feeder_idx``. Each list is sorted
        by distance from the substation along the feeder axis
        (signed projection onto the feeder direction). Buildings
        with a *negative* axial projection (behind the
        substation) are excluded from the feeder.
    """
    sx, sy = substation_xy
    feeder_dirs = self.resolved_directions_rad()
    groups: list[list[tuple[float, int]]] = [[] for _ in feeder_dirs]
    for i, (bx, by) in enumerate(building_positions):
        dx, dy = bx - sx, by - sy
        r = math.hypot(dx, dy)
        if r < 1e-9:
            # Building on the substation; assign to first feeder.
            groups[0].append((0.0, i))
            continue
        building_angle = math.atan2(dy, dx)
        # Pick feeder with smallest angular distance.
        best = min(
            range(len(feeder_dirs)),
            key=lambda k: _angle_diff(feeder_dirs[k], building_angle),
        )
        # Signed projection onto feeder direction.
        theta = feeder_dirs[best]
        proj = dx * math.cos(theta) + dy * math.sin(theta)
        if proj <= 0.0:
            # Behind the substation along this feeder axis;
            # cannot be reached without back-tracking. Drop.
            warnings.warn(
                f"RadialTrunkTopology: building at ({bx:.1f}, "
                f"{by:.1f}) projects behind the closest feeder "
                f"axis (theta={math.degrees(theta):.0f}°, "
                f"proj={proj:.2f} m); dropping from the PEN "
                "topology.",
                UserWarning,
                stacklevel=2,
            )
            continue
        if proj > self.max_feeder_length_m:
            warnings.warn(
                f"RadialTrunkTopology: building at ({bx:.1f}, "
                f"{by:.1f}) projects to {proj:.1f} m on its "
                "feeder axis, beyond max_feeder_length_m="
                f"{self.max_feeder_length_m} m; dropping from "
                "the PEN topology.",
                UserWarning,
                stacklevel=2,
            )
            continue
        groups[best].append((proj, i))
    # Sort each group by axial distance from the substation.
    out: list[list[int]] = []
    for g in groups:
        g.sort(key=lambda pair: pair[0])
        out.append([i for _, i in g])
    return out

plan_feeder_kvs_positions

plan_feeder_kvs_positions(
    substation_xy: tuple[float, float],
    feeder_idx: int,
    n_buildings_on_feeder: int,
) -> list[tuple[float, float]]

Compute KVS positions for a feeder given its building count.

The first :attr:slots_per_substation buildings tap directly to the substation. The next batch of up to :attr:slots_per_kvs buildings shares one KVS, the batch after that shares the next KVS, and so on. Each KVS sits on the feeder axis at the smallest multiple of :attr:kvs_spacing_m that is :math:\ge the projection of the first building it serves — but we don't have that projection here; we approximate by stepping one :attr:kvs_spacing_m per KVS. This keeps the layout predictable (KVSes are equispaced along the street) and matches typical Ortsnetz cable cabinet spacing.

Positions beyond :attr:max_feeder_length_m are clipped to :attr:max_feeder_length_m; if multiple KVSes would be clipped to the same maximum distance, only the first is kept and the remaining buildings will trigger a :class:UserWarning from the caller.

Source code in src/groundfield/generators/pen_topology.py
def plan_feeder_kvs_positions(
    self,
    substation_xy: tuple[float, float],
    feeder_idx: int,
    n_buildings_on_feeder: int,
) -> list[tuple[float, float]]:
    r"""Compute KVS positions for a feeder given its building count.

    The first :attr:`slots_per_substation` buildings tap directly
    to the substation. The next batch of up to
    :attr:`slots_per_kvs` buildings shares one KVS, the batch
    after that shares the next KVS, and so on. Each KVS sits on
    the feeder axis at the smallest multiple of
    :attr:`kvs_spacing_m` that is :math:`\ge` the projection of
    the *first* building it serves — but we don't have that
    projection here; we approximate by stepping one
    :attr:`kvs_spacing_m` per KVS. This keeps the layout
    predictable (KVSes are equispaced along the street) and
    matches typical Ortsnetz cable cabinet spacing.

    Positions beyond :attr:`max_feeder_length_m` are clipped to
    :attr:`max_feeder_length_m`; if multiple KVSes would be
    clipped to the same maximum distance, only the first is
    kept and the remaining buildings will trigger a
    :class:`UserWarning` from the caller.
    """
    sx, sy = substation_xy
    theta = self.resolved_directions_rad()[feeder_idx]
    cos_t, sin_t = math.cos(theta), math.sin(theta)
    remaining = max(0, n_buildings_on_feeder - self.slots_per_substation)
    if remaining <= 0:
        return []
    n_kvs = math.ceil(remaining / self.slots_per_kvs)
    positions: list[tuple[float, float]] = []
    last_distance: Optional[float] = None
    for k in range(n_kvs):
        distance = (k + 1) * self.kvs_spacing_m
        if distance > self.max_feeder_length_m:
            if last_distance is not None and last_distance >= (
                self.max_feeder_length_m - 1e-9
            ):
                break
            distance = self.max_feeder_length_m
        kvs_xy = (sx + distance * cos_t, sy + distance * sin_t)
        positions.append(kvs_xy)
        last_distance = distance
    return positions

resolved_directions_rad

resolved_directions_rad() -> list[float]

Return the feeder directions in radians.

If :attr:feeder_directions_deg is None, the directions are auto-distributed evenly around the substation. Otherwise the explicit list is consumed verbatim (after the :func:field_validator check that the length matches :attr:n_feeders).

Source code in src/groundfield/generators/pen_topology.py
def resolved_directions_rad(self) -> list[float]:
    """Return the feeder directions in radians.

    If :attr:`feeder_directions_deg` is ``None``, the directions
    are auto-distributed evenly around the substation. Otherwise
    the explicit list is consumed verbatim (after the
    :func:`field_validator` check that the length matches
    :attr:`n_feeders`).
    """
    if self.feeder_directions_deg is None:
        return [
            2.0 * math.pi * k / self.n_feeders
            for k in range(self.n_feeders)
        ]
    if len(self.feeder_directions_deg) != self.n_feeders:
        raise ValueError(
            "feeder_directions_deg length "
            f"({len(self.feeder_directions_deg)}) does not match "
            f"n_feeders ({self.n_feeders})."
        )
    return [math.radians(a) for a in self.feeder_directions_deg]

RingElectrodeSpec

Bases: _ElectrodeSpecBase

Ring earth electrode.

Horizontal circle of radius radius_m at depth depth_m. The classical foundation-ring or substation-ring electrode.

RodElectrodeSpec

Bases: _ElectrodeSpecBase

Driven rod (Tiefenerder).

Modelled as a vertical line conductor of length length_m starting at depth depth_m (rod head, 0 m = ground surface).

SoilLayerSpec

Bases: BaseModel

A single layer used by :class:MultiLayerSoilSpec.

thickness_m=None marks the semi-infinite bottom layer (only valid as the last entry in :class:MultiLayerSoilSpec.layers).

StarKvsTopology

Bases: BaseModel

Legacy star PEN topology.

Substation feeds each KVS directly, every building taps to its nearest KVS (Manhattan metric). KVS placement and count come from :class:groundfield.generators.tn_network.KvsConfig.

This is the default for backward compatibility — pre-v0.7 generator configurations behave bit-identically.

StripElectrodeSpec

Bases: _ElectrodeSpecBase

Horizontal strip earth electrode (Banderder).

Straight buried wire of length length_m at depth depth_m, rotated by orientation_deg around the vertical axis (0° = +x).

SubstationConfig

Bases: GeneratorConfig

Substation block — position + grounding system.

TnNetworkConfig

Bases: GeneratorConfig

Top-level configuration for a TN low-voltage distribution network.

Attributes:

Name Type Description
name str

World name.

soil SoilSpec

Soil-model spec — homogeneous, two-layer, or multi-layer (see :data:~groundfield.generators.soil_specs.SoilSpec).

substation SubstationConfig

Substation block (position + grounding system).

kvs KvsConfig

Cable-cabinet block (placement, count, grounding).

placement PlacementSpec

Building placement strategy. Default: Manhattan grid.

building_types list[BuildingTypeSpec]

Catalog of building types available in this run. Each type carries its own grounding system. Defaults to a four-type catalog (residential, small/medium/large industry).

building_counts dict[str, Union[int, AnyDistribution]]

Number of buildings per type. Keys must match a building_types[*].name. Values may be fixed or :class:Distribution. Buildings are placed in catalog order.

pen PenConfig

PEN backbone configuration.

source_magnitude_A Union[float, AnyDistribution]

Driving current at the substation cluster, in A.

TnNetworkGenerator

TnNetworkGenerator(
    cfg: C,
    *,
    seed: Optional[Union[int, np.random.Generator]] = None
)

Bases: WorldGenerator[TnNetworkConfig]

Generator for TN low-voltage distribution network reference worlds.

See the module docstring for the full pipeline. In one sentence: build soil → build substation grounding → place buildings (one per type-and-count) → build per-building grounding → place cable cabinets → build per-KVS grounding → wire the PEN backbone → attach the source.

Source code in src/groundfield/generators/base.py
def __init__(
    self,
    cfg: C,
    *,
    seed: Optional[Union[int, np.random.Generator]] = None,
) -> None:
    if not isinstance(cfg, GeneratorConfig):
        raise TypeError(
            f"cfg must be a GeneratorConfig, got {type(cfg).__name__}."
        )
    self.cfg = cfg
    self._rng = _coerce_rng(seed)

build

build(cfg: Optional[TnNetworkConfig] = None) -> World

Materialise one realisation of the configured TN network.

Resolves all stochastic fields against the generator's RNG, builds the substation grounding, places the configured number of buildings (with per-type electrode systems), routes the PEN backbone via the configured :class:~groundfield.generators.placement.Placement strategy, optionally adds a measurement setup, and returns the assembled :class:~groundfield.World ready for world.solve(engine).

Parameters:

Name Type Description Default
cfg TnNetworkConfig

Configuration overriding :attr:cfg. When None (default) the generator's own cfg is used.

None

Returns:

Type Description
World

The assembled world with soil, electrodes, conductors and the configured source.

Raises:

Type Description
ValueError

If the substation grounding resolves to zero electrodes, if the total building count is zero, or if a configured placement returns fewer positions than buildings.

Source code in src/groundfield/generators/tn_network.py
def build(self, cfg: Optional[TnNetworkConfig] = None) -> World:
    """Materialise one realisation of the configured TN network.

    Resolves all stochastic fields against the generator's RNG,
    builds the substation grounding, places the configured number
    of buildings (with per-type electrode systems), routes the PEN
    backbone via the configured
    :class:`~groundfield.generators.placement.Placement` strategy,
    optionally adds a measurement setup, and returns the assembled
    :class:`~groundfield.World` ready for ``world.solve(engine)``.

    Parameters
    ----------
    cfg : TnNetworkConfig, optional
        Configuration overriding :attr:`cfg`. When ``None``
        (default) the generator's own ``cfg`` is used.

    Returns
    -------
    World
        The assembled world with soil, electrodes, conductors and
        the configured source.

    Raises
    ------
    ValueError
        If the substation grounding resolves to zero electrodes,
        if the total building count is zero, or if a configured
        placement returns fewer positions than buildings.
    """
    cfg = cfg or self.cfg
    rng = self._rng

    # --- Soil + world ---
    soil = materialise_soil(cfg.soil, rng)
    world = create_world(name=cfg.name, soil=soil)

    # --- Substation grounding ---
    substation_anchor = cfg.substation.grounding.build_at(
        world,
        site_xy=cfg.substation.position,
        name_prefix="trafo",
        rng=rng,
    )
    if substation_anchor is None:
        raise ValueError(
            "TnNetworkGenerator: substation grounding produced zero "
            "electrodes. At least one electrode must have presence_prob > 0."
        )

    # --- Buildings ---
    building_assignments = self._resolve_building_counts(cfg, rng)
    n_buildings = sum(count for _, count in building_assignments)
    if n_buildings < 1:
        raise ValueError(
            "TnNetworkGenerator: total building count is 0. Populate "
            "building_counts with at least one type."
        )

    positions = cfg.placement.generate(n_buildings, rng)
    if len(positions) != n_buildings:
        raise RuntimeError(
            f"placement.generate returned {len(positions)} positions, "
            f"expected {n_buildings}."
        )

    # Footprint-aware placement (ADR-0011): if the configured
    # placement exposes a ``footprint_at(i)`` hook (currently
    # only :class:`OsmBuildingPlacement`), the per-building
    # grounding spec is rewritten so that every
    # :class:`FoundationElectrodeSpec` inherits ``size_xy_m``
    # and ``orientation_deg`` from the polygon's oriented
    # bounding rectangle. The Bernoulli on ``presence_prob``
    # is preserved, so the only stochastic axis the user
    # asked for survives the override.
    has_footprints = hasattr(cfg.placement, "footprint_at")

    building_anchors: list[tuple[str, tuple[float, float]]] = []
    idx = 0
    for type_idx, (btype, count) in enumerate(building_assignments):
        for k in range(count):
            site_xy = positions[idx]
            prefix = f"{btype.name}_{k}"
            grounding = btype.grounding
            if has_footprints:
                footprint = cfg.placement.footprint_at(idx)
                if footprint is not None:
                    grounding = _override_foundation_from_footprint(
                        grounding, footprint,
                    )
            anchor = grounding.build_at(
                world, site_xy=site_xy, name_prefix=prefix, rng=rng,
            )
            if anchor is not None:
                building_anchors.append((anchor, site_xy))
            idx += 1

    if not building_anchors:
        raise ValueError(
            "TnNetworkGenerator: every building's grounding had zero "
            "present electrodes. Increase presence_prob."
        )

    # --- Cable cabinets + PEN backbone ---
    # Two PEN topologies are supported (see ``pen_topology.py``):
    #
    # * ``StarKvsTopology`` (default, legacy) — KVS placement,
    #   count and grounding come from ``cfg.kvs``; every
    #   building taps to its nearest KVS by Manhattan distance.
    # * ``RadialTrunkTopology`` — the substation feeds N radial
    #   feeders with a finite slot budget per source. KVSes are
    #   inserted along the trunk axis as needed; ``cfg.kvs``
    #   contributes only the per-KVS *grounding* spec.
    if isinstance(cfg.pen_topology, RadialTrunkTopology):
        self._build_radial_trunk(
            world,
            cfg,
            substation_anchor,
            building_anchors,
        )
    else:
        # Star-KVS topology (default).
        n_kvs = self._resolve_kvs_count(cfg, n_buildings, rng)
        kvs_positions = cfg.kvs.placement.generate(n_kvs, rng)
        kvs_anchors: list[tuple[str, tuple[float, float]]] = []
        for k, site_xy in enumerate(kvs_positions):
            anchor = cfg.kvs.grounding.build_at(
                world,
                site_xy=site_xy,
                name_prefix=f"kvs_{k}",
                rng=rng,
            )
            if anchor is not None:
                kvs_anchors.append((anchor, site_xy))

        if not kvs_anchors:
            raise ValueError(
                "TnNetworkGenerator: every KVS grounding had zero "
                "present electrodes. Increase presence_prob or set "
                "fixed_count > 0."
            )

        self._build_pen_backbone(
            world, cfg, substation_anchor, kvs_anchors, building_anchors,
        )

    # --- Optional measurement setup ---
    # Materialise the auxiliary current electrode, the voltage
    # probe, and (optionally) the metallic feed / probe leads.
    # The resulting return-path anchor (or ``None`` if no
    # measurement setup is configured) is forwarded to the
    # source so the test current physically returns through the
    # aux electrode.
    return_anchor = self._build_measurement_setup(
        world, cfg, substation_anchor,
    )

    # --- Source ---
    # ``source_magnitude_A`` may be a Distribution; resolve lazily
    # like every other top-level numeric field.
    #
    # ``cfg.source_return_to`` is the explicit user-side override
    # for the source's ``return_to``. If it is set together with a
    # measurement setup, warn that the auto-routed aux anchor is
    # being overridden — make the precedence explicit rather than
    # silent.
    effective_return_to = return_anchor
    if cfg.source_return_to is not None:
        if (
            return_anchor is not None
            and cfg.source_return_to != return_anchor
        ):
            warnings.warn(
                "TnNetworkConfig.source_return_to="
                f"{cfg.source_return_to!r} takes precedence over "
                "the measurement-setup auxiliary electrode "
                f"({return_anchor!r}). The aux electrode and any "
                "metallic feed/probe leads remain in the world, "
                "but the source's return current is routed to "
                "the user-supplied electrode instead.",
                UserWarning,
                stacklevel=2,
            )
        effective_return_to = cfg.source_return_to

    create_source(
        world, attached_to=substation_anchor,
        return_to=effective_return_to,
        kind=cfg.source_kind,
        magnitude=_to_float(cfg.source_magnitude_A, rng),
    )

    return world

TwoLayerSoilSpec

Bases: BaseModel

Two-layer model — the default.

Upper layer of resistivity \(\rho_1\) and thickness \(h_1\) over a semi-infinite lower layer of resistivity \(\rho_2\).

Uniform

Bases: Distribution

Continuous uniform on \([\text{low}, \text{high})\).

The bounds are stored as Pydantic fields low and high; the model validator enforces high > low.

VoltageProbePlacement

Bases: BaseModel

Voltage probe (Spannungssonde) used during the simulated fall-of-potential measurement.

Unlike the :class:AuxiliaryElectrodePlacement, the probe is modelled as a pure sampling point in 3-D space: an ideal voltmeter has infinite input impedance, so a physical probe rod would carry zero current and therefore must not perturb the engine's potential field. In :meth:OrtsnetzLayout.to_world no rod electrode is created for the probe; in :meth:OrtsnetzLayout.measured_grounding_impedance the field is sampled at :attr:position_xy via :meth:FieldResult.potential.

Weibull

Bases: Distribution

Weibull distribution with shape \(k\) and scale \(\lambda\).

pdf \(f(x) = (k/\lambda)\,(x/\lambda)^{k-1}\,e^{-(x/\lambda)^k}\) on \(x \ge 0\). NumPy's rng.weibull(k) returns a sample from the Weibull with scale 1; we multiply by scale to obtain the desired scale parameter.

WorldGenerator

WorldGenerator(
    cfg: C,
    *,
    seed: Optional[Union[int, np.random.Generator]] = None
)

Bases: Generic[C]

Abstract base for World-producing generators.

Subclasses parameterise themselves on a concrete :class:GeneratorConfig subclass C and implement :meth:build, which constructs a :class:World from a resolved config (one with no remaining :class:Distribution fields).

The base class supplies:

  • RNG wiring via the seed constructor argument;
  • :meth:sample_world that resolves any distributions and hands the resolved config to :meth:build;
  • a guard in :meth:_assert_resolved that surfaces a clear error if :meth:build is called with an unresolved config.
Source code in src/groundfield/generators/base.py
def __init__(
    self,
    cfg: C,
    *,
    seed: Optional[Union[int, np.random.Generator]] = None,
) -> None:
    if not isinstance(cfg, GeneratorConfig):
        raise TypeError(
            f"cfg must be a GeneratorConfig, got {type(cfg).__name__}."
        )
    self.cfg = cfg
    self._rng = _coerce_rng(seed)

rng property

rng: Generator

The generator's :class:numpy.random.Generator.

build abstractmethod

build(cfg: Optional[C] = None) -> World

Build a :class:World from a resolved config.

Subclasses must implement this method. They may assume that cfg (or self.cfg if cfg is None) has no remaining :class:Distribution fields and may call :meth:_assert_resolved defensively.

Parameters:

Name Type Description Default
cfg Optional[C]

Optional override for self.cfg. Useful in sweeps that compute many resolved configs upfront and feed them into the same generator instance.

None
Source code in src/groundfield/generators/base.py
@abstractmethod
def build(self, cfg: Optional[C] = None) -> World:
    """Build a :class:`World` from a *resolved* config.

    Subclasses must implement this method. They may assume that
    ``cfg`` (or ``self.cfg`` if ``cfg is None``) has no remaining
    :class:`Distribution` fields and may call
    :meth:`_assert_resolved` defensively.

    Parameters
    ----------
    cfg
        Optional override for ``self.cfg``. Useful in sweeps that
        compute many resolved configs upfront and feed them into
        the same generator instance.
    """

sample_world

sample_world(
    rng: Optional[Union[int, np.random.Generator]] = None
) -> tuple[World, C]

Resolve any distributions in self.cfg and build a world.

Parameters:

Name Type Description Default
rng Optional[Union[int, Generator]]

Optional RNG override. None uses the generator's own RNG (self.rng), set at construction time.

None

Returns:

Type Description
tuple[World, GeneratorConfig]

The constructed world and the resolved config (same type as self.cfg). Persist the resolved config alongside the result for reproducibility.

Source code in src/groundfield/generators/base.py
def sample_world(
    self,
    rng: Optional[Union[int, np.random.Generator]] = None,
) -> tuple[World, C]:
    """Resolve any distributions in ``self.cfg`` and build a world.

    Parameters
    ----------
    rng
        Optional RNG override. ``None`` uses the generator's own
        RNG (``self.rng``), set at construction time.

    Returns
    -------
    tuple[World, GeneratorConfig]
        The constructed world and the resolved config (same type
        as ``self.cfg``). Persist the resolved config alongside
        the result for reproducibility.
    """
    rng_obj = self._rng if rng is None else _coerce_rng(rng)
    resolved = self.cfg.sample(rng_obj)
    return self.build(resolved), resolved

buried_lead

buried_lead(
    *,
    depth_m: float = 0.6,
    wire_radius_m: float = 0.005,
    inductance_model: Optional[str] = "neumann",
    segment_length_m: Optional[float] = 5.0
) -> MeasurementLeadConfig

Convenience factory for a buried-cable measurement lead.

Default depth 0.6 m (typical NS cable trench). Insulated cable (no soil leakage along the lead), Neumann inductance enabled.

Source code in src/groundfield/generators/measurement.py
def buried_lead(
    *,
    depth_m: float = 0.6,
    wire_radius_m: float = 0.005,
    inductance_model: Optional[str] = "neumann",
    segment_length_m: Optional[float] = 5.0,
) -> MeasurementLeadConfig:
    """Convenience factory for a buried-cable measurement lead.

    Default depth 0.6 m (typical NS cable trench). Insulated cable
    (no soil leakage along the lead), Neumann inductance enabled.
    """
    return MeasurementLeadConfig(
        depth_m=depth_m,
        wire_radius_m=wire_radius_m,
        conductor_type="cable_shield",
        coupling_to_soil="isolated",
        segment_length_m=segment_length_m,
        inductance_model=inductance_model,
    )

default_building_catalog

default_building_catalog() -> list[BuildingTypeSpec]

Return a sensible starting catalog of four building types.

Returns:

Type Description
list[BuildingTypeSpec]

Four entries: residential, small_industry, medium_industry, large_industry. Geometries follow rough European-LV practice; tune via copy + edit before running serious studies.

Source code in src/groundfield/generators/building.py
def default_building_catalog() -> list[BuildingTypeSpec]:
    """Return a sensible starting catalog of four building types.

    Returns
    -------
    list[BuildingTypeSpec]
        Four entries: ``residential``, ``small_industry``,
        ``medium_industry``, ``large_industry``. Geometries follow
        rough European-LV practice; tune via copy + edit before
        running serious studies.
    """
    return [
        BuildingTypeSpec(
            name="residential",
            description="Single-family house — foundation electrode only.",
            plot_size_m=(15.0, 15.0),
            grounding=GroundingSystemSpec(
                electrodes=[
                    FoundationElectrodeSpec(
                        size_m=10.0,
                        depth_m=0.8,
                        n_x=2, n_y=2,
                    ),
                ],
            ),
        ),
        BuildingTypeSpec(
            name="small_industry",
            description="Small commercial — foundation plus one driven rod.",
            plot_size_m=(20.0, 20.0),
            grounding=GroundingSystemSpec(
                electrodes=[
                    FoundationElectrodeSpec(
                        size_m=12.0, depth_m=0.8, n_x=2, n_y=2,
                    ),
                    RodElectrodeSpec(
                        length_m=2.0, depth_m=0.0,
                        offset_xy_m=(7.0, 0.0),
                    ),
                ],
            ),
        ),
        BuildingTypeSpec(
            name="medium_industry",
            description="Medium commercial — larger foundation plus four rods.",
            plot_size_m=(30.0, 30.0),
            grounding=GroundingSystemSpec(
                electrodes=[
                    FoundationElectrodeSpec(
                        size_m=20.0, depth_m=0.8, n_x=3, n_y=3,
                    ),
                    *rod_circle(n=4, radius_m=12.0, length_m=2.5),
                ],
            ),
        ),
        BuildingTypeSpec(
            name="large_industry",
            description=(
                "Large industrial site — perimeter ring, internal grid mesh, "
                "and eight driven rods spaced around the ring."
            ),
            plot_size_m=(60.0, 60.0),
            grounding=GroundingSystemSpec(
                electrodes=[
                    RingElectrodeSpec(radius_m=25.0, depth_m=0.8),
                    FoundationElectrodeSpec(
                        size_m=30.0, depth_m=0.8, n_x=4, n_y=4,
                    ),
                    *rod_circle(n=8, radius_m=25.0, length_m=3.0),
                    StripElectrodeSpec(
                        length_m=40.0, depth_m=0.8,
                        orientation_deg=0.0,
                        offset_xy_m=(0.0, 28.0),
                    ),
                    StripElectrodeSpec(
                        length_m=40.0, depth_m=0.8,
                        orientation_deg=0.0,
                        offset_xy_m=(0.0, -28.0),
                    ),
                ],
            ),
        ),
    ]

materialise_soil

materialise_soil(
    spec: Union[
        HomogeneousSoilSpec,
        TwoLayerSoilSpec,
        MultiLayerSoilSpec,
    ],
    rng: np.random.Generator,
) -> SoilModel

Convenience dispatcher: spec.to_soil(rng).

Provided so callers can write materialise_soil(cfg.soil, rng) without having to discriminate by isinstance themselves.

Source code in src/groundfield/generators/soil_specs.py
def materialise_soil(
    spec: Union[HomogeneousSoilSpec, TwoLayerSoilSpec, MultiLayerSoilSpec],
    rng: np.random.Generator,
) -> SoilModel:
    """Convenience dispatcher: ``spec.to_soil(rng)``.

    Provided so callers can write ``materialise_soil(cfg.soil, rng)``
    without having to discriminate by ``isinstance`` themselves.
    """
    return spec.to_soil(rng)

merge_collinear_waypoints

merge_collinear_waypoints(
    waypoints: list[tuple[float, float]]
) -> list[tuple[float, float]]

Drop interior waypoints that lie on the line of their neighbours.

The result keeps the polyline's geometric shape (every actual corner) but discards the artificial mid-cell stops that the A* search produced. Endpoints are always kept.

Source code in src/groundfield/generators/manhattan_routing.py
def merge_collinear_waypoints(
    waypoints: list[tuple[float, float]],
) -> list[tuple[float, float]]:
    """Drop interior waypoints that lie on the line of their neighbours.

    The result keeps the polyline's geometric shape (every actual
    corner) but discards the artificial mid-cell stops that the
    A\\* search produced. Endpoints are always kept.
    """
    if len(waypoints) < 3:
        return list(waypoints)
    out: list[tuple[float, float]] = [waypoints[0]]
    for i in range(1, len(waypoints) - 1):
        prev = out[-1]
        cur = waypoints[i]
        nxt = waypoints[i + 1]
        dx1, dy1 = cur[0] - prev[0], cur[1] - prev[1]
        dx2, dy2 = nxt[0] - cur[0], nxt[1] - cur[1]
        # Both segments must be along the same axis with the same sign
        # to be collinear and absorbable.
        same_x = math.isclose(dx1, 0.0) and math.isclose(dx2, 0.0)
        same_y = math.isclose(dy1, 0.0) and math.isclose(dy2, 0.0)
        if same_x and dy1 * dy2 > 0.0:
            continue
        if same_y and dx1 * dx2 > 0.0:
            continue
        out.append(cur)
    out.append(waypoints[-1])
    return out

neighbour_substation_grounding

neighbour_substation_grounding() -> GroundingSystemSpec

A neighbour substation's typical grounding (ring + 4 rods).

Used as the auxiliary electrode in measurements where the current returns through a remote, well-grounded substation instead of a single driven rod.

Source code in src/groundfield/generators/measurement.py
def neighbour_substation_grounding() -> GroundingSystemSpec:
    """A neighbour substation's typical grounding (ring + 4 rods).

    Used as the auxiliary electrode in measurements where the
    current returns through a remote, well-grounded substation
    instead of a single driven rod.
    """
    # Imported here to avoid a top-level circular import:
    # tn_network depends on this module.
    from groundfield.generators.electrode_specs import (
        RingElectrodeSpec,
        rod_circle,
    )
    return GroundingSystemSpec(
        electrodes=[
            RingElectrodeSpec(radius_m=4.0, depth_m=0.6),
            *rod_circle(n=4, radius_m=2.0, length_m=2.5),
        ],
    )

overhead_lead

overhead_lead(
    *,
    wire_radius_m: float = 0.005,
    inductance_model: Optional[str] = "neumann",
    segment_length_m: Optional[float] = 5.0
) -> MeasurementLeadConfig

Convenience factory for an overhead bare-copper measurement lead.

Surface routing (depth_m=0), bare copper, isolated soil coupling, Neumann inductance enabled.

Source code in src/groundfield/generators/measurement.py
def overhead_lead(
    *,
    wire_radius_m: float = 0.005,
    inductance_model: Optional[str] = "neumann",
    segment_length_m: Optional[float] = 5.0,
) -> MeasurementLeadConfig:
    """Convenience factory for an overhead bare-copper measurement lead.

    Surface routing (``depth_m=0``), bare copper, isolated soil
    coupling, Neumann inductance enabled.
    """
    return MeasurementLeadConfig(
        depth_m=0.0,
        wire_radius_m=wire_radius_m,
        conductor_type="bare_copper",
        coupling_to_soil="isolated",
        segment_length_m=segment_length_m,
        inductance_model=inductance_model,
    )

resolve_value

resolve_value(value: Any, rng: np.random.Generator) -> Any

Resolve a T | Distribution field to a concrete value.

Parameters:

Name Type Description Default
value Any

Either a fixed Python value (passed through unchanged) or a :class:Distribution instance (sampled via :meth:Distribution.sample).

required
rng Generator

Random generator forwarded to Distribution.sample.

required

Returns:

Type Description
object

The fixed value, or one sample from the distribution.

Source code in src/groundfield/generators/base.py
def resolve_value(value: Any, rng: np.random.Generator) -> Any:
    """Resolve a ``T | Distribution`` field to a concrete value.

    Parameters
    ----------
    value
        Either a fixed Python value (passed through unchanged) or a
        :class:`Distribution` instance (sampled via
        :meth:`Distribution.sample`).
    rng
        Random generator forwarded to ``Distribution.sample``.

    Returns
    -------
    object
        The fixed value, or one sample from the distribution.
    """
    if isinstance(value, Distribution):
        return value.sample(rng)
    return value

rod_circle

rod_circle(
    n: int,
    radius_m: float,
    *,
    length_m: Union[float, AnyDistribution] = 1.5,
    depth_m: Union[float, AnyDistribution] = 0.0,
    wire_radius_m: float = 0.008,
    presence_prob: Union[float, AnyDistribution] = 1.0,
    angle_offset_deg: float = 0.0
) -> list[RodElectrodeSpec]

Build n :class:RodElectrodeSpec arranged on a circle.

Typical use case: the four (or eight) driven rods inside the substation ring earth electrode. Each rod's offset_xy_m is set so the rods sit at angles \(\theta_k = \text{angle\_offset} + 2\pi k / n\) on a circle of radius radius_m centred on the site.

Parameters:

Name Type Description Default
n int

Number of rods.

required
radius_m float

Circle radius in m.

required
length_m Union[float, AnyDistribution]

Forwarded to every :class:RodElectrodeSpec. Each rod carries the same value (a single :class:Distribution passed in is shared and resampled per rod by the build machinery — pass independent copies if you want independent draws).

1.5
depth_m Union[float, AnyDistribution]

Forwarded to every :class:RodElectrodeSpec. Each rod carries the same value (a single :class:Distribution passed in is shared and resampled per rod by the build machinery — pass independent copies if you want independent draws).

1.5
wire_radius_m Union[float, AnyDistribution]

Forwarded to every :class:RodElectrodeSpec. Each rod carries the same value (a single :class:Distribution passed in is shared and resampled per rod by the build machinery — pass independent copies if you want independent draws).

1.5
presence_prob Union[float, AnyDistribution]

Forwarded to every :class:RodElectrodeSpec. Each rod carries the same value (a single :class:Distribution passed in is shared and resampled per rod by the build machinery — pass independent copies if you want independent draws).

1.5
angle_offset_deg float

Phase offset in degrees so the first rod can be rotated from the +x axis.

0.0

Returns:

Type Description
list[RodElectrodeSpec]

Length n. Drop straight into a :class:GroundingSystemSpec.electrodes list.

Source code in src/groundfield/generators/electrode_specs.py
def rod_circle(
    n: int,
    radius_m: float,
    *,
    length_m: Union[float, AnyDistribution] = 1.5,
    depth_m: Union[float, AnyDistribution] = 0.0,
    wire_radius_m: float = 0.008,
    presence_prob: Union[float, AnyDistribution] = 1.0,
    angle_offset_deg: float = 0.0,
) -> list[RodElectrodeSpec]:
    """Build *n* :class:`RodElectrodeSpec` arranged on a circle.

    Typical use case: the four (or eight) driven rods inside the
    substation ring earth electrode. Each rod's ``offset_xy_m`` is
    set so the rods sit at angles
    $\\theta_k = \\text{angle\\_offset} + 2\\pi k / n$ on a circle
    of radius ``radius_m`` centred on the site.

    Parameters
    ----------
    n
        Number of rods.
    radius_m
        Circle radius in m.
    length_m, depth_m, wire_radius_m, presence_prob
        Forwarded to every :class:`RodElectrodeSpec`. Each rod
        carries the same value (a single :class:`Distribution`
        passed in is shared and *resampled per rod* by the build
        machinery — pass independent copies if you want
        independent draws).
    angle_offset_deg
        Phase offset in degrees so the first rod can be rotated
        from the +x axis.

    Returns
    -------
    list[RodElectrodeSpec]
        Length ``n``. Drop straight into a
        :class:`GroundingSystemSpec.electrodes` list.
    """
    if n < 1:
        raise ValueError(f"rod_circle: n must be >= 1, got {n}.")
    if radius_m < 0:
        raise ValueError(f"rod_circle: radius_m must be >= 0, got {radius_m}.")
    out: list[RodElectrodeSpec] = []
    for k in range(n):
        angle = math.radians(angle_offset_deg) + 2.0 * math.pi * k / n
        offset = (radius_m * math.cos(angle), radius_m * math.sin(angle))
        out.append(
            RodElectrodeSpec(
                length_m=length_m,
                depth_m=depth_m,
                wire_radius_m=wire_radius_m,
                presence_prob=presence_prob,
                offset_xy_m=offset,
            )
        )
    return out

route_manhattan

route_manhattan(
    start: tuple[float, float],
    end: tuple[float, float],
    obstacles: Iterable[ObstacleBox] = (),
    *,
    grid_size: float = 10.0,
    clearance_m: float = 0.5,
    escape_radius_m: Optional[float] = None,
    max_iterations: int = 200000
) -> list[tuple[float, float]]

Route a Manhattan polyline from start to end.

Parameters:

Name Type Description Default
start tuple[float, float]

Endpoint coordinates in metres. The grid origin is locked to start so the first waypoint is exactly start; an axis-aligned bridge from the last grid cell to end is appended.

required
end tuple[float, float]

Endpoint coordinates in metres. The grid origin is locked to start so the first waypoint is exactly start; an axis-aligned bridge from the last grid cell to end is appended.

required
obstacles Iterable[ObstacleBox]

Building obstacle boxes the cable must avoid.

()
grid_size float

Cell size in metres. The minimum segment length of the returned polyline (after merging collinear segments) is at least :func:grid_size for any leg interior to the path; the trailing bridge may be shorter when end does not align with the grid.

10.0
clearance_m float

Additional padding around each obstacle. Defaults to 0.5 m so the cable keeps a small safety distance from every foundation wall.

0.5
escape_radius_m Optional[float]

Inside a disk of this radius around start and around end, obstacles are not enforced. This is the "release valve" for real-OSM layouts where the substation or a KVS lat/lon lands inside or very near a foundation polygon and every neighbouring grid cell would otherwise be blocked -- the cable physically has to exit the substation cubicle somehow, so allowing a short blind-spot around each anchor models reality and prevents spurious :class:RuntimeError from A* exhausted. None (default) maps to grid_size (i.e. exactly one cell of slack around each anchor). Pass 0.0 to restore the strict pre-v0.7 behaviour, or a larger value when an anchor lands inside a dense city block.

None
max_iterations int

Upper bound on the A* loop body. Increase for very large layouts (hundreds of obstacles spread across kilometres).

200000

Returns:

Type Description
list of (float, float)

Polyline waypoints. len(out) >= 2; the first entry is start and the last entry is end. Consecutive entries differ in exactly one coordinate (Manhattan).

Raises:

Type Description
RuntimeError

When no path exists (obstacle field encloses the substation or KVS).

Source code in src/groundfield/generators/manhattan_routing.py
def route_manhattan(
    start: tuple[float, float],
    end: tuple[float, float],
    obstacles: Iterable[ObstacleBox] = (),
    *,
    grid_size: float = 10.0,
    clearance_m: float = 0.5,
    escape_radius_m: Optional[float] = None,
    max_iterations: int = 200_000,
) -> list[tuple[float, float]]:
    r"""Route a Manhattan polyline from ``start`` to ``end``.

    Parameters
    ----------
    start, end
        Endpoint coordinates in metres. The grid origin is locked
        to ``start`` so the first waypoint is *exactly* ``start``;
        an axis-aligned bridge from the last grid cell to ``end``
        is appended.
    obstacles
        Building obstacle boxes the cable must avoid.
    grid_size
        Cell size in metres. The minimum segment length of the
        returned polyline (after merging collinear segments) is at
        least :func:`grid_size` for any leg interior to the path;
        the trailing bridge may be shorter when ``end`` does not
        align with the grid.
    clearance_m
        Additional padding around each obstacle. Defaults to
        ``0.5`` m so the cable keeps a small safety distance from
        every foundation wall.
    escape_radius_m
        Inside a disk of this radius around ``start`` *and* around
        ``end``, obstacles are *not* enforced. This is the "release
        valve" for real-OSM layouts where the substation or a KVS
        lat/lon lands inside or very near a foundation polygon and
        every neighbouring grid cell would otherwise be blocked --
        the cable physically has to exit the substation cubicle
        somehow, so allowing a short blind-spot around each anchor
        models reality and prevents spurious
        :class:`RuntimeError` from
        ``A* exhausted``. ``None`` (default) maps to
        ``grid_size`` (i.e. exactly one cell of slack around each
        anchor). Pass ``0.0`` to restore the strict pre-v0.7
        behaviour, or a larger value when an anchor lands inside a
        dense city block.
    max_iterations
        Upper bound on the A\* loop body. Increase for very large
        layouts (hundreds of obstacles spread across kilometres).

    Returns
    -------
    list of (float, float)
        Polyline waypoints. ``len(out) >= 2``; the first entry is
        ``start`` and the last entry is ``end``. Consecutive
        entries differ in exactly one coordinate (Manhattan).

    Raises
    ------
    RuntimeError
        When no path exists (obstacle field encloses the
        substation or KVS).
    """
    if grid_size <= 0.0:
        raise ValueError(
            f"route_manhattan: grid_size must be positive, got {grid_size}."
        )
    if escape_radius_m is None:
        escape_radius_m = grid_size
    if escape_radius_m < 0.0:
        raise ValueError(
            "route_manhattan: escape_radius_m must be >= 0, got "
            f"{escape_radius_m}."
        )

    obstacles = [o.inflated(clearance_m) for o in obstacles]

    # Grid origin locked to start so cell (0, 0) is exactly ``start``.
    ox, oy = start
    half = grid_size / 2.0

    # Bounding cells: include end and every obstacle, with margin.
    margin_cells = 3
    xs = [start[0], end[0]] + [o.x_min for o in obstacles] + [o.x_max for o in obstacles]
    ys = [start[1], end[1]] + [o.y_min for o in obstacles] + [o.y_max for o in obstacles]
    i_min = int(math.floor((min(xs) - ox) / grid_size)) - margin_cells
    i_max = int(math.ceil((max(xs) - ox) / grid_size)) + margin_cells
    j_min = int(math.floor((min(ys) - oy) / grid_size)) - margin_cells
    j_max = int(math.ceil((max(ys) - oy) / grid_size)) + margin_cells

    def cell_center(i: int, j: int) -> tuple[float, float]:
        return (ox + i * grid_size, oy + j * grid_size)

    def world_to_cell(x: float, y: float) -> tuple[int, int]:
        return (
            int(round((x - ox) / grid_size)),
            int(round((y - oy) / grid_size)),
        )

    start_cell = (0, 0)
    end_cell = world_to_cell(*end)

    # Snap end into the valid range (paranoia).
    end_cell = (
        max(i_min, min(i_max, end_cell[0])),
        max(j_min, min(j_max, end_cell[1])),
    )

    # Pre-compute the "escape" cell sets: every cell whose centre
    # is within ``escape_radius_m`` of the start (or end) endpoint
    # is exempt from obstacle blocking, no matter how dense the
    # surrounding building cluster is.
    escape_r2 = escape_radius_m * escape_radius_m

    def _in_escape_zone(i: int, j: int) -> bool:
        if escape_radius_m <= 0.0:
            return False
        cx, cy = cell_center(i, j)
        for (ax, ay) in (start, end):
            dx = cx - ax
            dy = cy - ay
            if dx * dx + dy * dy <= escape_r2:
                return True
        return False

    def is_blocked(i: int, j: int) -> bool:
        if (i, j) == start_cell or (i, j) == end_cell:
            return False
        if _in_escape_zone(i, j):
            return False
        cx, cy = cell_center(i, j)
        for o in obstacles:
            if o.rectangle_overlaps(cx, cy, half):
                return True
        return False

    # A*
    open_heap: list[tuple[int, int, tuple[int, int]]] = []
    counter = 0  # tie-breaker for the heap (stable order, no compare on tuples)
    heapq.heappush(
        open_heap,
        (abs(end_cell[0]) + abs(end_cell[1]), counter, start_cell),
    )
    g_score: dict[tuple[int, int], int] = {start_cell: 0}
    came_from: dict[tuple[int, int], tuple[int, int]] = {}
    iterations = 0
    found = False
    current = start_cell
    while open_heap:
        if iterations > max_iterations:
            raise RuntimeError(
                "route_manhattan: A* exceeded "
                f"{max_iterations} iterations before reaching end "
                f"= {end}. Increase max_iterations or grid_size, or "
                "relax clearance_m."
            )
        iterations += 1
        _, _, current = heapq.heappop(open_heap)
        if current == end_cell:
            found = True
            break
        i0, j0 = current
        for di, dj in ((1, 0), (-1, 0), (0, 1), (0, -1)):
            i, j = i0 + di, j0 + dj
            if not (i_min <= i <= i_max and j_min <= j <= j_max):
                continue
            if is_blocked(i, j):
                continue
            tentative = g_score[current] + 1
            if tentative < g_score.get((i, j), 10**18):
                g_score[(i, j)] = tentative
                came_from[(i, j)] = current
                f = tentative + abs(end_cell[0] - i) + abs(end_cell[1] - j)
                counter += 1
                heapq.heappush(open_heap, (f, counter, (i, j)))

    if not found:
        # Diagnose which side is enclosed so the caller gets an
        # actionable suggestion (move that anchor, widen the
        # escape_radius_m, or drop clearance_m).
        start_neighbours_open = sum(
            not is_blocked(start_cell[0] + di, start_cell[1] + dj)
            and i_min <= start_cell[0] + di <= i_max
            and j_min <= start_cell[1] + dj <= j_max
            for di, dj in ((1, 0), (-1, 0), (0, 1), (0, -1))
        )
        end_neighbours_open = sum(
            not is_blocked(end_cell[0] + di, end_cell[1] + dj)
            and i_min <= end_cell[0] + di <= i_max
            and j_min <= end_cell[1] + dj <= j_max
            for di, dj in ((1, 0), (-1, 0), (0, 1), (0, -1))
        )
        offender = "start" if start_neighbours_open == 0 else (
            "end" if end_neighbours_open == 0 else "both endpoints"
        )
        raise RuntimeError(
            "route_manhattan: no Manhattan path from "
            f"{start} to {end}. The {offender} anchor is enclosed "
            f"by obstacles (start has {start_neighbours_open}/4 free "
            f"neighbours, end has {end_neighbours_open}/4). "
            "Suggested fixes (in order of safety): "
            "(1) increase ``escape_radius_m`` (currently "
            f"{escape_radius_m:.1f} m -- raise to e.g. "
            f"{2 * escape_radius_m:.1f} m); "
            "(2) reduce ``clearance_m`` (currently "
            f"{clearance_m:.2f} m); "
            "(3) move the offending anchor by a few metres so it "
            "lands clearly outside any foundation footprint."
        )

    # Reconstruct path of cell centres.
    path_cells: list[tuple[int, int]] = [current]
    while current in came_from:
        current = came_from[current]
        path_cells.append(current)
    path_cells.reverse()
    waypoints = [cell_center(*c) for c in path_cells]

    # Anchor first waypoint at the *actual* start (cell (0,0) == start).
    waypoints[0] = start

    # Bridge from last cell centre to ``end`` (axis-aligned).
    last = waypoints[-1]
    if last != end:
        dx = end[0] - last[0]
        dy = end[1] - last[1]
        if math.isclose(dx, 0.0) or math.isclose(dy, 0.0):
            waypoints.append(end)
        else:
            # Choose the bridge corner that does not cross an
            # obstacle. Two options: go-x-first or go-y-first.
            corner_x_first = (end[0], last[1])
            corner_y_first = (last[0], end[1])
            for corner in (corner_x_first, corner_y_first):
                if not any(
                    segment_intersects_box(last, corner, o)
                    or segment_intersects_box(corner, end, o)
                    for o in obstacles
                ):
                    waypoints.append(corner)
                    waypoints.append(end)
                    break
            else:
                # Neither bridge works without an obstacle -- give up.
                raise RuntimeError(
                    "route_manhattan: cannot bridge final cell "
                    f"{last} to end {end} without crossing an "
                    "obstacle. Reduce grid_size."
                )

    return merge_collinear_waypoints(waypoints)

segment_intersects_box

segment_intersects_box(
    p0: tuple[float, float],
    p1: tuple[float, float],
    box: ObstacleBox,
    *,
    tol: float = 1e-09
) -> bool

True if the axis-aligned segment \(p_0 \to p_1\) enters box.

The segment is assumed axis-aligned (one of x or y coincides on both endpoints). Touching the box on the open boundary is not considered an intersection -- that is what makes the router happy when the cable runs along a street that is exactly tangent to a foundation.

Parameters:

Name Type Description Default
p0 tuple[float, float]

Segment endpoints.

required
p1 tuple[float, float]

Segment endpoints.

required
box ObstacleBox

Obstacle box.

required
tol float

Small slack so a segment running on the boundary of the box does not count as intersecting.

1e-09
Source code in src/groundfield/generators/manhattan_routing.py
def segment_intersects_box(
    p0: tuple[float, float],
    p1: tuple[float, float],
    box: ObstacleBox,
    *,
    tol: float = 1e-9,
) -> bool:
    r"""True if the *axis-aligned* segment $p_0 \to p_1$ enters ``box``.

    The segment is assumed axis-aligned (one of ``x`` or ``y``
    coincides on both endpoints). Touching the box on the open
    boundary is *not* considered an intersection -- that is what
    makes the router happy when the cable runs along a street
    that is exactly tangent to a foundation.

    Parameters
    ----------
    p0, p1
        Segment endpoints.
    box
        Obstacle box.
    tol
        Small slack so a segment running on the boundary of the
        box does not count as intersecting.
    """
    x0, y0 = p0
    x1, y1 = p1
    if math.isclose(y0, y1, abs_tol=tol):
        # Horizontal segment at y = y0
        if y0 <= box.y_min + tol or y0 >= box.y_max - tol:
            return False
        x_low, x_high = (x0, x1) if x0 <= x1 else (x1, x0)
        return x_low < box.x_max - tol and x_high > box.x_min + tol
    if math.isclose(x0, x1, abs_tol=tol):
        # Vertical segment at x = x0
        if x0 <= box.x_min + tol or x0 >= box.x_max - tol:
            return False
        y_low, y_high = (y0, y1) if y0 <= y1 else (y1, y0)
        return y_low < box.y_max - tol and y_high > box.y_min + tol
    raise ValueError(
        f"segment_intersects_box: segment ({p0}, {p1}) is not "
        "axis-aligned."
    )

single_rod_grounding

single_rod_grounding(
    *,
    length_m: Union[float, AnyDistribution] = 1.5,
    depth_m: Union[float, AnyDistribution] = 0.0
) -> GroundingSystemSpec

One driven rod — a typical Erdungsspieß used as an aux electrode.

Source code in src/groundfield/generators/measurement.py
def single_rod_grounding(
    *,
    length_m: Union[float, AnyDistribution] = 1.5,
    depth_m: Union[float, AnyDistribution] = 0.0,
) -> GroundingSystemSpec:
    """One driven rod — a typical *Erdungsspieß* used as an aux electrode."""
    return GroundingSystemSpec(
        electrodes=[
            RodElectrodeSpec(length_m=length_m, depth_m=depth_m),
        ],
    )

triangle_rod_grounding

triangle_rod_grounding(
    rod_length_m: float = 0.5, triangle_side_m: float = 0.5
) -> GroundingSystemSpec

Default Hilfserder geometry: three parallel vertical rods in an equilateral triangle.

Real-world fall-of-potential test sets use a bundle of rods rather than a single Tiefenerder so the auxiliary's own spreading resistance stays well below the substation's. Three 0.5 m rods at the corners of a 0.5 m equilateral triangle give roughly a third of the single-rod impedance.

Source code in src/groundfield/generators/ortsnetz_builder.py
def triangle_rod_grounding(
    rod_length_m: float = 0.5,
    triangle_side_m: float = 0.5,
) -> GroundingSystemSpec:
    r"""Default Hilfserder geometry: three parallel vertical rods in
    an equilateral triangle.

    Real-world fall-of-potential test sets use a *bundle* of rods
    rather than a single Tiefenerder so the auxiliary's own
    spreading resistance stays well below the substation's. Three
    0.5 m rods at the corners of a 0.5 m equilateral triangle give
    roughly a third of the single-rod impedance.
    """
    half = triangle_side_m / 2.0
    # Equilateral triangle, centroid at the origin: corners are at
    # angles 90 deg, 210 deg, 330 deg from the centre at a radius
    # ``triangle_side_m / sqrt(3)``.
    r = triangle_side_m / math.sqrt(3.0)
    offsets = [
        (r * math.cos(math.radians(a)),
         r * math.sin(math.radians(a)))
        for a in (90.0, 210.0, 330.0)
    ]
    return GroundingSystemSpec(
        electrodes=[
            RodElectrodeSpec(
                length_m=rod_length_m, depth_m=0.0,
                offset_xy_m=off,
            )
            for off in offsets
        ],
    )