Skip to content

Diagnostics

The :mod:groundfield.diagnostics module provides pre-solve structural diagnostics for a :class:World. It is the typical counterpart to :mod:groundfield.validation (which is the post-solve cross-engine consistency check).

Why pre-solve diagnostics?

For an production-grade TN-Ortsnetz with several hundred electrodes a mistake in the geometry — an electrode placed at the wrong coordinate, a missing conductor, or a segmentation budget that silently triples the wall-clock time — should be caught before kicking off a multi-minute solver run. The three helpers below each address one common failure mode:

Helper What it answers
world_statistics "How big is this thing? Counts per kind, total wire length, footprint."
expected_segments "How many point-source segments will the discretiser produce?"
check_segment_resolution "Will the resolution be good enough? Will memory / time blow up?"

Example — full pre-flight check

import groundfield as gf
from groundfield.generators import TnNetworkGenerator, TnNetworkConfig, PenConfig

cfg = TnNetworkConfig(
    soil=gf.HomogeneousSoilSpec(resistivity=100.0),
    building_counts={"residential": 30},
    pen=PenConfig(segment_length_m=None),
)
world = TnNetworkGenerator(cfg, seed=42).build()
engine = gf.create_engine(backend="image", segment_length=0.5)

# 1. Structural snapshot (machine-readable; pretty-print as needed).
stats = gf.world_statistics(world)
print(f"electrodes:  {stats['n_electrodes']}  "
      f"by kind: {stats['n_electrodes_by_kind']}")
print(f"footprint:   {stats['footprint_area_m2']:.0f} m^2")
print(f"wire length: {stats['total_electrode_wire_length_m']:.1f} m  "
      f"(electrodes) + "
      f"{stats['total_conductor_length_m']:.1f} m (conductors)")

# 2. Segmentation budget.
budget = gf.expected_segments(world, engine)
print(f"predicted total segments: {budget['total']}  "
      f"(electrodes {budget['electrode_total']}, "
      f"conductors {budget['conductor_total']})")

# 3. Quality-of-discretisation warnings (empty list = all good).
for msg in gf.check_segment_resolution(world, engine):
    print(f"  WARN: {msg}")

expected_segments — exactness

The prediction is bit-exact for the image-family backends — image, image_2layer, image_nlayer, mom, mom_sommerfeld, cim and bem. It mirrors :mod:groundfield.solver.image's discretiser conventions:

  • rod: \(n = \max(1, \lceil L / \Delta s \rceil)\)
  • ring: \(n = \max(8, \lceil 2 \pi r / \Delta s \rceil)\)
  • strip: \(n = \max(1, \lceil L / \Delta s \rceil)\)
  • mesh / grid_mesh: per-wire \(n = \max(1, \lceil d_\text{axis} / \Delta s \rceil)\), summed over both wire directions
  • distributed conductor: \(n = \lceil L / \Delta s_c \rceil\) with $\Delta s_c = $ conductor.discretize_segment_length

The FEM backend (:mod:groundfield.solver.fem) uses an axisymmetric volume mesh that is not parameterised by segment_length; the prediction is not informative for FEM.

check_segment_resolution — heuristics

Three categories of warning are surfaced:

  • Thin-wire ratio \(\Delta s / r_\text{wire} \ge 5\) on every electrode and on every distributed conductor. Below this, the thin-wire average-potential self-action becomes biased.
  • Electrode smaller than one segment — the smallest geometric dimension of an electrode (rod length, ring perimeter, strip length, mesh wire length) must be at least one segment_length; otherwise the discretiser falls back to its lower floor.
  • Total segment-count budget at a soft threshold (5 000 segments — solve time runs from seconds to minutes) and a hard threshold (20 000 segments — the dense-system \(O(N^2)\) memory and \(O(N^3)\) solve scaling becomes painful).

The function never raises — use the returned list to inform the user. An empty list means no concerns detected.

API reference

diagnostics

Pre-solve world diagnostics — counts, mesh budget, resolution checks.

This module collects structural diagnostics that work directly on a :class:~groundfield.world.World (and optionally an :class:~groundfield.solver.engine.Engine), without invoking the solver. It is the typical counterpart to :mod:groundfield.validation (which is a post-solve cross-engine check):

  • :func:world_statistics — aggregate counts and lengths, bounding box, footprint area, conductor-length statistics.
  • :func:expected_segments — predicts the number of point-source segments that the image-family discretiser will produce per electrode kind, plus a total. Useful for budgeting wall-clock cost before kicking off a 200-EFH run.
  • :func:check_segment_resolution — heuristic warnings about the discretisation quality (thin-wire ratio, electrode size vs. segment length, segment-count budget). Returns a list of human-readable strings, empty when everything looks healthy.
Validity envelope
  • The segment counts in :func:expected_segments mirror the conventions of :mod:groundfield.solver.image (the image-family discretiser used by the image, image_2layer, image_nlayer, mom, mom_sommerfeld, cim and bem backends). FEM does not use this discretiser; for FEM the segment count is not predictive of cost.
  • Wire-length / segment computations follow the physical geometry. Sub-segment overheads from cluster-bonding, KCL pseudo-nodes and lumped-conductor branches are not double-counted.
References
  • ADR-0003 (docs/adr/0003-distributed-conductor-model.md) — distributed-conductor topology used by :func:expected_segments for conductors with finite discretize_segment_length.

HARD_LIMIT module-attribute

HARD_LIMIT: int = 20000

Total segment count above which the budget warning becomes urgent.

Promoted from the private _BUDGET_HARD_THRESHOLD for a stable public handle.

MIN_THINWIRE_RATIO module-attribute

MIN_THINWIRE_RATIO: float = 5.0

Minimum recommended ratio of segment_length to wire_radius.

Below this, the thin-wire approximation that underpins the average-potential method becomes increasingly biased: the self-action integral is computed under the assumption segment_length >> wire_radius.

SOFT_LIMIT module-attribute

SOFT_LIMIT: int = 5000

Total segment count above which the user should be aware of cost.

The dense Z-matrix scales as :math:O(N^2) in memory and the solve as :math:O(N^3) for the LU + multi-port factorisation, so ~ 5 000 segments is roughly where solve time becomes minutes rather than seconds on a typical laptop. Promoted from the private _BUDGET_WARN_THRESHOLD so tests and notebooks have a stable handle for the "soft warning" threshold.

check_segment_resolution

check_segment_resolution(
    world: "World", engine: "Engine"
) -> list[str]

Heuristic discretisation-quality check.

Inspects the world / engine pair for common typical modelling pitfalls and returns one human-readable string per finding. The empty list means no concerns detected. Categories:

  1. Thin-wire ratio. Each electrode must satisfy segment_length >= MIN_THINWIRE_RATIO * wire_radius so that the thin-wire average-potential self-action remains valid.
  2. Electrode smaller than one segment. An electrode whose smallest geometric dimension is below segment_length is either degenerately discretised (1 segment) or, for rings, falls back to the discretiser's max(8, ...) floor — in either case the user probably wants a finer segment_length.
  3. Distributed-conductor ratio. A finite discretize_segment_length must also stay above MIN_THINWIRE_RATIO * conductor.wire_radius.
  4. Total segment budget. Warns when the predicted total crosses :data:SOFT_LIMIT and again at :data:HARD_LIMIT.

The function never raises — it only reports. Use the returned list to inform the user before kicking off a long solve.

Parameters:

Name Type Description Default
world 'World'

World to inspect.

required
engine 'Engine'

Engine carrying segment_length.

required

Returns:

Type Description
list[str]

Zero or more diagnostic strings. The list is sorted by severity (budget warnings last, electrode-specific warnings first).

Source code in src/groundfield/diagnostics.py
def check_segment_resolution(world: "World", engine: "Engine") -> list[str]:
    """Heuristic discretisation-quality check.

    Inspects the world / engine pair for common typical modelling
    pitfalls and returns one human-readable string per finding.
    The empty list means *no concerns detected*. Categories:

    1. **Thin-wire ratio.** Each electrode must satisfy
       ``segment_length >= MIN_THINWIRE_RATIO * wire_radius`` so that
       the thin-wire average-potential self-action remains valid.
    2. **Electrode smaller than one segment.** An electrode whose
       *smallest geometric dimension* is below ``segment_length``
       is either degenerately discretised (1 segment) or, for
       rings, falls back to the discretiser's ``max(8, ...)`` floor
       — in either case the user probably wants a finer
       ``segment_length``.
    3. **Distributed-conductor ratio.** A finite
       ``discretize_segment_length`` must also stay above
       ``MIN_THINWIRE_RATIO * conductor.wire_radius``.
    4. **Total segment budget.** Warns when the predicted total
       crosses :data:`SOFT_LIMIT` and again at :data:`HARD_LIMIT`.

    The function never raises — it only reports. Use the returned
    list to inform the user before kicking off a long solve.

    Parameters
    ----------
    world
        World to inspect.
    engine
        Engine carrying ``segment_length``.

    Returns
    -------
    list[str]
        Zero or more diagnostic strings. The list is sorted by
        severity (budget warnings last, electrode-specific
        warnings first).
    """
    from groundfield.geometry.electrodes import (
        GridMeshElectrode,
        MeshElectrode,
        PolylineElectrode,
        RingElectrode,
        RodElectrode,
        StarElectrode,
        StripElectrode,
    )

    ds = float(engine.segment_length)
    msgs: list[str] = []

    # 1 + 2: per-electrode checks
    for e in world.electrodes:
        ratio = ds / e.wire_radius
        if ratio < _MIN_THINWIRE_RATIO:
            msgs.append(
                f"thin-wire: electrode '{e.name}' ({e.kind}) has "
                f"segment_length / wire_radius = {ratio:.1f} "
                f"(< {_MIN_THINWIRE_RATIO:.0f}); "
                f"the thin-wire self-action assumption may bias the result."
            )

        # Smallest characteristic geometric dimension.
        if isinstance(e, RodElectrode):
            char = e.length
        elif isinstance(e, RingElectrode):
            char = 2.0 * math.pi * e.radius
        elif isinstance(e, StripElectrode):
            char = e.length
        elif isinstance(e, GridMeshElectrode):
            char = min(e.size[0] / max(1, e.n_x), e.size[1] / max(1, e.n_y))
        elif isinstance(e, MeshElectrode):
            char = min(e.size[0], e.size[1], e.spacing)
        else:  # pragma: no cover - defensive
            char = float("inf")

        if char < ds:
            msgs.append(
                f"resolution: electrode '{e.name}' ({e.kind}) has its "
                f"smallest dimension {char:.3f} m below the engine's "
                f"segment_length {ds:.3f} m — the discretiser will fall "
                f"back to its lower floor and the result will be coarse."
            )

    # 3: distributed-conductor wire / segment ratio
    for c in world.conductors:
        if not c.is_distributed:
            continue
        ratio = float(c.discretize_segment_length) / c.wire_radius
        if ratio < _MIN_THINWIRE_RATIO:
            msgs.append(
                f"thin-wire: distributed conductor '{c.name}' has "
                f"discretize_segment_length / wire_radius = {ratio:.1f} "
                f"(< {_MIN_THINWIRE_RATIO:.0f}); the longitudinal "
                f"self-inductance may be biased."
            )

    # 4: total segment-count budget
    est = expected_segments(world, engine)
    n_total = est["total"]
    if n_total >= _BUDGET_HARD_THRESHOLD:
        msgs.append(
            f"budget: predicted total segment count {n_total} exceeds "
            f"{_BUDGET_HARD_THRESHOLD} — expect a heavy memory and "
            f"solve-time cost (dense system: O(N²) memory, O(N³) solve). "
            f"Consider using a coarser segment_length or a sparser "
            f"distributed-conductor discretisation."
        )
    elif n_total >= _BUDGET_WARN_THRESHOLD:
        msgs.append(
            f"budget: predicted total segment count {n_total} exceeds "
            f"{_BUDGET_WARN_THRESHOLD} — solve time may run from "
            f"seconds to minutes on a typical laptop."
        )

    return msgs

expected_segments

expected_segments(
    world: "World", engine: "Engine"
) -> dict[str, Any]

Predict the number of point-source segments after discretisation.

Mirrors the image-family discretiser (:mod:groundfield.solver.image) so the prediction is exact for the image / image_2layer / image_nlayer / mom / mom_sommerfeld / cim / bem backends. The FEM backend (:mod:groundfield.solver.fem) uses an axisymmetric volume mesh that is unrelated to segment_length; for FEM this prediction is not informative.

Parameters:

Name Type Description Default
world 'World'

World to inspect.

required
engine 'Engine'

Engine carrying segment_length (in metres).

required

Returns:

Type Description
dict

Keys:

  • per_electrode: dict[name, int] with the segment count of every named electrode,
  • per_kind: dict[kind, int] aggregated counts per electrode kind,
  • electrode_total (int),
  • per_conductor: dict[name, int] (only listed for distributed conductors; lumped conductors contribute 0 and are omitted),
  • conductor_total (int),
  • total (int) — sum of electrode and conductor segments.

Raises:

Type Description
ValueError

If engine.segment_length is not strictly positive.

Source code in src/groundfield/diagnostics.py
def expected_segments(world: "World", engine: "Engine") -> dict[str, Any]:
    """Predict the number of point-source segments after discretisation.

    Mirrors the image-family discretiser
    (:mod:`groundfield.solver.image`) so the prediction is
    *exact* for the ``image`` / ``image_2layer`` / ``image_nlayer``
    / ``mom`` / ``mom_sommerfeld`` / ``cim`` / ``bem`` backends.
    The FEM backend (:mod:`groundfield.solver.fem`) uses an
    axisymmetric volume mesh that is unrelated to ``segment_length``;
    for FEM this prediction is not informative.

    Parameters
    ----------
    world
        World to inspect.
    engine
        Engine carrying ``segment_length`` (in metres).

    Returns
    -------
    dict
        Keys:

        * ``per_electrode``: ``dict[name, int]`` with the segment
          count of every named electrode,
        * ``per_kind``: ``dict[kind, int]`` aggregated counts per
          electrode kind,
        * ``electrode_total`` (int),
        * ``per_conductor``: ``dict[name, int]`` (only listed for
          distributed conductors; lumped conductors contribute 0
          and are omitted),
        * ``conductor_total`` (int),
        * ``total`` (int) — sum of electrode and conductor segments.

    Raises
    ------
    ValueError
        If ``engine.segment_length`` is not strictly positive.
    """
    ds = float(engine.segment_length)
    if not math.isfinite(ds) or ds <= 0.0:
        raise ValueError(
            f"engine.segment_length must be > 0, got {engine.segment_length!r}."
        )

    per_electrode: dict[str, int] = {}
    per_kind: Counter[str] = Counter()
    e_total = 0
    for e in world.electrodes:
        n = _electrode_segment_count(e, ds)
        per_electrode[e.name] = n
        per_kind[e.kind] += n
        e_total += n

    per_conductor: dict[str, int] = {}
    c_total = 0
    for c in world.conductors:
        n = _conductor_segment_count(c)
        if n > 0:
            per_conductor[c.name] = n
        c_total += n

    return {
        "per_electrode": per_electrode,
        "per_kind": dict(per_kind),
        "electrode_total": e_total,
        "per_conductor": per_conductor,
        "conductor_total": c_total,
        "total": e_total + c_total,
    }

world_statistics

world_statistics(world: 'World') -> dict[str, Any]

Return a structural snapshot of a world.

Aggregates counts, lengths and the bounding box; complements :meth:World.summary (one-line text) with a richer machine-readable dictionary that scales to production-grade networks.

Parameters:

Name Type Description Default
world 'World'

World to inspect.

required

Returns:

Type Description
dict

Keys:

  • n_electrodes (int),
  • n_electrodes_by_kind (dict[str, int]: rod / ring / strip / mesh / grid_mesh),
  • n_conductors (int),
  • n_conductors_by_type (dict[str, int]),
  • n_distributed_conductors / n_lumped_conductors,
  • n_galvanic_conductors / n_isolated_conductors,
  • n_sources (int),
  • total_electrode_wire_length_m (float),
  • total_conductor_length_m (float),
  • conductor_length_stats: {min, median, max, mean} in metres (empty dict when no conductors),
  • bounds_3d: (x_min, x_max, y_min, y_max, z_min, z_max) in metres,
  • footprint_xy: (x_min, x_max, y_min, y_max),
  • footprint_area_m2 (float),
  • depth_range_m: (z_min, z_max),
  • has_layered_soil (bool — diagnostic flag set when the soil model is :class:TwoLayerSoil or :class:MultiLayerSoil).
Source code in src/groundfield/diagnostics.py
def world_statistics(world: "World") -> dict[str, Any]:
    """Return a structural snapshot of a world.

    Aggregates counts, lengths and the bounding box; complements
    :meth:`World.summary` (one-line text) with a richer
    machine-readable dictionary that scales to production-grade networks.

    Parameters
    ----------
    world
        World to inspect.

    Returns
    -------
    dict
        Keys:

        * ``n_electrodes`` (int),
        * ``n_electrodes_by_kind`` (``dict[str, int]``: rod / ring
          / strip / mesh / grid_mesh),
        * ``n_conductors`` (int),
        * ``n_conductors_by_type`` (``dict[str, int]``),
        * ``n_distributed_conductors`` / ``n_lumped_conductors``,
        * ``n_galvanic_conductors`` / ``n_isolated_conductors``,
        * ``n_sources`` (int),
        * ``total_electrode_wire_length_m`` (float),
        * ``total_conductor_length_m`` (float),
        * ``conductor_length_stats``: ``{min, median, max, mean}``
          in metres (empty dict when no conductors),
        * ``bounds_3d``: ``(x_min, x_max, y_min, y_max, z_min,
          z_max)`` in metres,
        * ``footprint_xy``: ``(x_min, x_max, y_min, y_max)``,
        * ``footprint_area_m2`` (float),
        * ``depth_range_m``: ``(z_min, z_max)``,
        * ``has_layered_soil`` (bool — diagnostic flag set when
          the soil model is :class:`TwoLayerSoil` or
          :class:`MultiLayerSoil`).
    """
    from groundfield.geometry.electrodes import (
        GridMeshElectrode,
        MeshElectrode,
        PolylineElectrode,
        RingElectrode,
        RodElectrode,
        StarElectrode,
        StripElectrode,
    )
    from groundfield.postprocess.geometry_plot import world_bounds_3d
    from groundfield.soil.models import MultiLayerSoil, TwoLayerSoil

    kind_map: dict[type, str] = {
        RodElectrode: "rod",
        RingElectrode: "ring",
        StripElectrode: "strip",
        PolylineElectrode: "polyline",
        StarElectrode: "star",
        MeshElectrode: "mesh",
        GridMeshElectrode: "grid_mesh",
    }
    by_kind: Counter[str] = Counter()
    total_e_wire = 0.0
    for e in world.electrodes:
        by_kind[kind_map.get(type(e), "unknown")] += 1
        total_e_wire += _electrode_wire_length(e)

    by_type: Counter[str] = Counter(c.conductor_type for c in world.conductors)
    n_distributed = sum(1 for c in world.conductors if c.is_distributed)
    n_galvanic = sum(1 for c in world.conductors if c.coupling_to_soil == "galvanic")
    cond_lengths = [float(c.length) for c in world.conductors]
    total_c_length = float(sum(cond_lengths))
    if cond_lengths:
        cond_stats = {
            "min": float(min(cond_lengths)),
            "median": float(median(cond_lengths)),
            "max": float(max(cond_lengths)),
            "mean": float(sum(cond_lengths) / len(cond_lengths)),
        }
    else:
        cond_stats = {}

    x_min, x_max, y_min, y_max, z_min, z_max = world_bounds_3d(world)
    footprint = (x_min, x_max, y_min, y_max)
    area = (x_max - x_min) * (y_max - y_min)

    has_layered = isinstance(world.soil, (TwoLayerSoil, MultiLayerSoil))

    return {
        "n_electrodes": len(world.electrodes),
        "n_electrodes_by_kind": dict(by_kind),
        "n_conductors": len(world.conductors),
        "n_conductors_by_type": dict(by_type),
        "n_distributed_conductors": n_distributed,
        "n_lumped_conductors": len(world.conductors) - n_distributed,
        "n_galvanic_conductors": n_galvanic,
        "n_isolated_conductors": len(world.conductors) - n_galvanic,
        "n_sources": len(world.sources),
        "total_electrode_wire_length_m": float(total_e_wire),
        "total_conductor_length_m": total_c_length,
        "conductor_length_stats": cond_stats,
        "bounds_3d": (x_min, x_max, y_min, y_max, z_min, z_max),
        "footprint_xy": footprint,
        "footprint_area_m2": float(area),
        "depth_range_m": (z_min, z_max),
        "has_layered_soil": has_layered,
    }