Skip to content

World

The :class:groundfield.World class is the top-level container for the entire physics of a grounding problem: soil model, electrodes, conductors, sources, boundary conditions and (since 0.6.0) the ADR-0012 concrete-shell registry. It is intentionally free of numerics — the numerical evaluation (backend selection, mesh resolution, frequency list, tolerances) is configured in a Engine and applied to the world via world.solve(engine).

Concrete-shell registry — cleanup contract (0.7.0)

The public :attr:World.concrete_shell_corrections dict accumulates one entry per foundation electrode that was materialised with a non-None concrete_rho_ohm_m field (ADR-0012 V1 "lumped" path). Re-using a single World across multiple TnNetworkGenerator.build calls — the canonical Monte-Carlo pattern over the moisture distribution — would otherwise leak stale shell resistances from earlier samples into later ones. The explicit :meth:World.reset_concrete_corrections helper clears the registry and returns a shallow copy of the dropped entries.

import groundfield as gf

world = gf.create_world(soil=gf.HomogeneousSoil(resistivity=100.0))
# ... populate via TnNetworkGenerator.build(world=world) ...
dropped = world.reset_concrete_corrections()
# ... rebuild with a different moisture sample ...

Calling the helper on a fresh world is an idempotent no-op.

Source handles stay live across a solve (0.15.0)

World.solve(engine) snapshots every :attr:World.sources entry before the backend runs and restores it afterwards, so solving never rewrites the input world (opt out with snapshot_sources=False if a sweep cannot afford the deep copy). Since 0.15.0 that roll-back is identity-preserving: the recorded field values are written back onto the very objects create_source returned, and world.sources keeps its list object. The canonical parameter sweep therefore behaves as it reads:

import groundfield as gf

world  = gf.create_world(soil=gf.HomogeneousSoil(resistivity=100.0))
gf.create_electrode(world, "rod", name="g1", position=(0, 0, 0.0), length=3.0)
src    = gf.create_source(world, attached_to="g1", magnitude=10.0)
engine = gf.create_engine(backend="image")

for current in (10.0, 20.0, 40.0):
    src.magnitude = current       # ``src`` is still world.sources[0]
    print(world.solve(engine).electrode_potentials["g1"][0])

Up to 0.14.x the snapshot list was rebound onto the world, which detached the caller's handle after the first solve: every later mutation of src was a silent no-op and the loop above printed the same potential three times. Backend-side mutations of the source list (insert / remove / replace / wholesale rebind) are still rolled back.

API reference

world

Central World object: container for the entire physics.

A World bundles everything that belongs to the physical description of a grounding system:

  • a soil model (:class:groundfield.soil.SoilModel),
  • one or more electrodes (:class:groundfield.geometry.Electrode),
  • optional connection conductors (:class:groundfield.conductors.Conductor),
  • one or more sources (:class:groundfield.sources.Source),
  • the boundary conditions (:class:groundfield.boundary.BoundaryConditions).

The World is intentionally free of numerics. The numerical evaluation (backend selection, mesh resolution, frequency list, tolerances) is configured in a :class:groundfield.solver.Engine and applied to the world via world.solve(engine).

World

Bases: BaseModel

Top-level container for a grounding field problem.

Notes

A World is usually not instantiated directly but built via :func:groundfield.create_world. The helper methods add_* and the top-level factories gf.create_* populate the container incrementally.

add_conductor

add_conductor(conductor: Conductor) -> Conductor

Add a conductor.

Source code in src/groundfield/world.py
def add_conductor(self, conductor: Conductor) -> Conductor:
    """Add a conductor."""
    if any(c.name == conductor.name for c in self.conductors):
        raise ValueError(f"Conductor name '{conductor.name}' already taken.")
    self.conductors.append(conductor)
    return conductor

add_electrode

add_electrode(electrode: _ElectrodeBase) -> _ElectrodeBase

Add an electrode and check for unique name.

Source code in src/groundfield/world.py
def add_electrode(self, electrode: _ElectrodeBase) -> _ElectrodeBase:
    """Add an electrode and check for unique name."""
    if any(e.name == electrode.name for e in self.electrodes):
        raise ValueError(f"Electrode name '{electrode.name}' already taken.")
    self.electrodes.append(electrode)
    return electrode

add_source

add_source(source: Source) -> Source

Add a source.

Source code in src/groundfield/world.py
def add_source(self, source: Source) -> Source:
    """Add a source."""
    if any(s.name == source.name for s in self.sources):
        raise ValueError(f"Source name '{source.name}' already taken.")
    self.sources.append(source)
    return source

get_conductor

get_conductor(name: str) -> Conductor

Return a conductor by name.

Source code in src/groundfield/world.py
def get_conductor(self, name: str) -> Conductor:
    """Return a conductor by name."""
    for c in self.conductors:
        if c.name == name:
            return c
    raise KeyError(f"Conductor '{name}' not found in world.")

get_electrode

get_electrode(name: str) -> Electrode

Return an electrode by name.

Source code in src/groundfield/world.py
def get_electrode(self, name: str) -> Electrode:
    """Return an electrode by name."""
    for e in self.electrodes:
        if e.name == name:
            return e
    raise KeyError(f"Electrode '{name}' not found in world.")

reset_concrete_corrections

reset_concrete_corrections() -> dict[str, float]

Clear and return the ADR-0012 V1 concrete-shell registry.

The :attr:concrete_shell_corrections dict accumulates one entry per foundation-electrode anchor that the generator pipeline materialised with a non-None concrete_rho_ohm_m field (see ADR-0012, V1 "lumped" path). Re-using a single :class:World across several :meth:TnNetworkGenerator.build calls — the canonical pattern for Monte-Carlo studies that flip concrete_rho_ohm_m per realisation — would otherwise leak stale shell resistances from earlier samples into later ones.

The helper is the explicit, opt-in counterpart of the "build, solve, throw away the world" pattern: when the user does want to keep the world but re-seed the corrections, calling world.reset_concrete_corrections() immediately before generator.build(world=...) (or before mutating the generator config and re-building) produces a clean slate.

Returns:

Type Description
dict[str, float]

The previous contents of the registry, before clearing. The returned dict is a shallow copy so the caller can inspect or log the dropped entries without holding a live reference to the (now empty) registry on the world.

Notes

Calling this on a world that was never touched by the concrete-encasement code path is a no-op; the returned dict is empty.

Source code in src/groundfield/world.py
def reset_concrete_corrections(self) -> dict[str, float]:
    """Clear and return the ADR-0012 V1 concrete-shell registry.

    The :attr:`concrete_shell_corrections` dict accumulates one entry
    per foundation-electrode anchor that the generator pipeline
    materialised with a non-``None`` ``concrete_rho_ohm_m`` field
    (see ADR-0012, V1 "lumped" path). Re-using a single :class:`World`
    across several :meth:`TnNetworkGenerator.build` calls — the
    canonical pattern for Monte-Carlo studies that flip
    ``concrete_rho_ohm_m`` per realisation — would otherwise leak
    stale shell resistances from earlier samples into later ones.

    The helper is the explicit, opt-in counterpart of the "build,
    solve, throw away the world" pattern: when the user *does*
    want to keep the world but re-seed the corrections, calling
    ``world.reset_concrete_corrections()`` immediately before
    ``generator.build(world=...)`` (or before mutating the
    generator config and re-building) produces a clean slate.

    Returns
    -------
    dict[str, float]
        The previous contents of the registry, before clearing. The
        returned dict is a shallow copy so the caller can inspect or
        log the dropped entries without holding a live reference to
        the (now empty) registry on the world.

    Notes
    -----
    Calling this on a world that was never touched by the
    concrete-encasement code path is a no-op; the returned dict is
    empty.
    """
    previous = dict(self.concrete_shell_corrections)
    self.concrete_shell_corrections.clear()
    return previous

set_boundary_conditions

set_boundary_conditions(
    **kwargs: Any,
) -> BoundaryConditions

Update individual fields of the boundary configuration.

Parameters:

Name Type Description Default
**kwargs Any

Fields of :class:BoundaryConditions (e.g. far_field, surface, reference_node).

{}

Returns:

Type Description
BoundaryConditions

The updated boundary-conditions object.

Warns:

Type Description
UserWarning

If any provided value differs from the defaults consumed by the v0.2.0 integral / image-charge backends (far_field="dirichlet", surface="neumann", reference_node=None). The non-default value is stored on the model and round-trips through serialisation, but no backend reads it. The fields are reserved for the upcoming FEM backend; see :class:groundfield.boundary.BoundaryConditions for the full implementation-status note.

UserWarning

If a field is reverted from a previously-set non-default value back to the default. The previous non-default value was never consumed by any backend, so a silent revert would suggest a change of behaviour that the user never actually experienced. The revert warning makes that visible.

Source code in src/groundfield/world.py
def set_boundary_conditions(self, **kwargs: Any) -> BoundaryConditions:
    """Update individual fields of the boundary configuration.

    Parameters
    ----------
    **kwargs
        Fields of :class:`BoundaryConditions` (e.g. ``far_field``,
        ``surface``, ``reference_node``).

    Returns
    -------
    BoundaryConditions
        The updated boundary-conditions object.

    Warns
    -----
    UserWarning
        If any provided value differs from the defaults consumed
        by the v0.2.0 integral / image-charge backends
        (``far_field="dirichlet"``, ``surface="neumann"``,
        ``reference_node=None``). The non-default value is stored
        on the model and round-trips through serialisation, but
        no backend reads it. The fields are reserved for the
        upcoming FEM backend; see
        :class:`groundfield.boundary.BoundaryConditions` for the
        full implementation-status note.
    UserWarning
        If a field is reverted from a previously-set non-default
        value back to the default. The previous non-default value
        was never consumed by any backend, so a silent revert
        would suggest a change of behaviour that the user never
        actually experienced. The revert warning makes that
        visible.
    """
    # Snapshot the previous boundary state so we can detect both
    # "non-default value set" and "non-default value reverted to
    # default" transitions on the keys the caller touched.
    previous = self.boundary.model_dump()

    new = self.boundary.model_copy(update=kwargs)
    # Force re-validation through a fresh model construction
    self.boundary = BoundaryConditions(**new.model_dump())

    # Warn if the caller asked for a value the v0.2.0 backends do
    # not actually implement. We only warn on the keys the caller
    # touched (so a no-op call after construction stays quiet).
    non_default = {
        k: v
        for k, v in kwargs.items()
        if k in _DEFAULT_BOUNDARY_VALUES
        and v != _DEFAULT_BOUNDARY_VALUES[k]
    }
    if non_default:
        warnings.warn(
            "BoundaryConditions field(s) "
            f"{sorted(non_default)} set to a non-default value, "
            "but the v0.2.0 integral / image-charge backends ignore "
            "this setting and report potentials relative to remote "
            "earth (φ → 0 at infinity, Neumann at z = 0). The value "
            "is preserved on the model for forward-compatibility "
            "with the upcoming FEM backend. See "
            "groundfield.boundary.BoundaryConditions for the full "
            "implementation-status note.",
            UserWarning,
            stacklevel=2,
        )

    # Revert detection: a key the caller now sets back to the
    # default *was* previously non-default. The previous value
    # never reached any backend; warning the user closes that
    # silent-no-op feedback gap.
    reverted = {
        k: previous[k]
        for k, v in kwargs.items()
        if k in _DEFAULT_BOUNDARY_VALUES
        and v == _DEFAULT_BOUNDARY_VALUES[k]
        and previous.get(k) != _DEFAULT_BOUNDARY_VALUES[k]
    }
    if reverted:
        warnings.warn(
            "BoundaryConditions field(s) "
            f"{sorted(reverted)} reverted to the default value. The "
            "previous non-default setting "
            f"{reverted!r} was never consumed by the v0.2.0 integral "
            "/ image-charge backends, so this revert does not change "
            "any computed result. See "
            "groundfield.boundary.BoundaryConditions for the full "
            "implementation-status note.",
            UserWarning,
            stacklevel=2,
        )
    return self.boundary

solve

solve(
    engine: "Engine", *, snapshot_sources: bool = True
) -> "FieldResult"

Run the simulation with the given Engine.

Delegates to :meth:Engine.solve, so users may write either world.solve(engine) or engine.solve(world).

Parameters:

Name Type Description Default
engine 'Engine'

The :class:~groundfield.solver.engine.Engine instance that drives the backend.

required
snapshot_sources bool

If True (default), every :attr:sources entry is deep-copied before the backend runs and restored on exit. This defends against backends that mutate Source.return_to in flight (see :class:groundfield.generators.measurement.MeasurementSetupConfig.build). Power users who have verified that their backend does not mutate the source list (typical in long :func:~groundfield.engines.compare_engines sweeps or :func:~groundfield.engines.convergence_study runs) may set snapshot_sources=False to skip the deep-copy cost.

True
Notes

The default snapshot_sources=True makes the contract explicit: solving never rewrites the input world. The opt-out is documented in docs/concepts.md ("Engine re-use across World.solve calls").

The roll-back is identity-preserving (since 0.15.0): the field values recorded in the snapshot are written back onto the very objects that :func:~groundfield.api.create_source returned, and :attr:sources keeps its list object. A handle the caller is holding therefore stays live across an arbitrary number of solves, so the canonical sweep pattern

.. code-block:: python

src = gf.create_source(world, attached_to="g1", magnitude=10.0)
r1 = world.solve(engine)
src.magnitude = 20.0     # still attached to ``world``
r2 = world.solve(engine)

does what it reads like. Before 0.15.0 the snapshot list was rebound onto the world, which silently detached the caller's handles after the first solve and made every subsequent mutation a no-op.

Source code in src/groundfield/world.py
def solve(
    self,
    engine: "Engine",
    *,
    snapshot_sources: bool = True,
) -> "FieldResult":
    """Run the simulation with the given ``Engine``.

    Delegates to :meth:`Engine.solve`, so users may write either
    ``world.solve(engine)`` or ``engine.solve(world)``.

    Parameters
    ----------
    engine
        The :class:`~groundfield.solver.engine.Engine` instance that
        drives the backend.
    snapshot_sources
        If ``True`` (default), every :attr:`sources` entry is
        deep-copied before the backend runs and restored on exit.
        This defends against backends that mutate
        ``Source.return_to`` in flight (see
        :class:`groundfield.generators.measurement.MeasurementSetupConfig.build`).
        Power users who have verified that their backend does not
        mutate the source list (typical in long
        :func:`~groundfield.engines.compare_engines` sweeps or
        :func:`~groundfield.engines.convergence_study` runs) may set
        ``snapshot_sources=False`` to skip the deep-copy cost.

    Notes
    -----
    The default ``snapshot_sources=True`` makes the contract
    explicit: solving never rewrites the input world. The opt-out
    is documented in ``docs/concepts.md`` ("Engine re-use across
    ``World.solve`` calls").

    The roll-back is **identity-preserving** (since 0.15.0): the
    field values recorded in the snapshot are written back onto the
    very objects that :func:`~groundfield.api.create_source`
    returned, and :attr:`sources` keeps its list object. A handle
    the caller is holding therefore stays live across an arbitrary
    number of solves, so the canonical sweep pattern

    .. code-block:: python

        src = gf.create_source(world, attached_to="g1", magnitude=10.0)
        r1 = world.solve(engine)
        src.magnitude = 20.0     # still attached to ``world``
        r2 = world.solve(engine)

    does what it reads like. Before 0.15.0 the snapshot list was
    rebound onto the world, which silently detached the caller's
    handles after the first solve and made every subsequent
    mutation a no-op.
    """
    # Local import to avoid a circular dependency at module load.
    from groundfield.solver.engine import Engine

    if not isinstance(engine, Engine):
        raise TypeError(
            f"Expected an Engine, got {type(engine).__name__}. "
            "Build one with gf.create_engine(backend='image')."
        )
    if not snapshot_sources:
        # Caller has opted out — backends are now contractually
        # responsible for not mutating ``self.sources``.
        return engine.solve(self)
    # Snapshot every source via Pydantic's deep-copy semantics so
    # backends that mutate a source field in flight cannot leak
    # the change back into the caller's world. Keep a reference to
    # the live list *and* to the original objects: the roll-back
    # below writes the recorded values back onto those objects
    # instead of rebinding a fresh copy, so the handles returned by
    # ``create_source`` stay attached to this world (F31).
    source_list = self.sources
    originals = list(source_list)
    sources_snapshot = [s.model_copy(deep=True) for s in originals]
    try:
        return engine.solve(self)
    finally:
        for original, snapshot in zip(originals, sources_snapshot):
            if original != snapshot:
                # Source models are flat (str / float / None), so a
                # dict update is a complete field-wise roll-back.
                original.__dict__.update(snapshot.__dict__)
                original.__pydantic_fields_set__ = set(
                    snapshot.__pydantic_fields_set__
                )
        # Undo list-level mutations (insert / remove / replace) and
        # re-bind the original list object if a backend swapped the
        # attribute wholesale.
        if len(source_list) != len(originals) or any(
            live is not original
            for live, original in zip(source_list, originals)
        ):
            source_list[:] = originals
        if self.sources is not source_list:
            self.sources = source_list

summary

summary() -> str

Compact textual summary of the world.

Source code in src/groundfield/world.py
def summary(self) -> str:
    """Compact textual summary of the world."""
    soil_str = (
        f"{self.soil.kind}" if self.soil is not None else "<no soil model>"
    )
    return (
        f"World '{self.name}': soil={soil_str}, "
        f"electrodes={len(self.electrodes)}, "
        f"conductors={len(self.conductors)}, "
        f"sources={len(self.sources)}, "
        f"boundary.far_field={self.boundary.far_field}"
    )