Postprocess¶
The postprocess subpackage turns a raw :class:FieldResult into
quantities the user actually needs: potential / EPR plots, a fitted
rho-f standard model, and a vector fit that produces a closed-form
SymPy expression compatible with
:class:groundinsight.BusType.impedance_formula.
Plotting helpers¶
The standard plot suite (plot_potential_contour,
plot_potential_profile, plot_potential_radial) is
re-exported at the package level for convenience and is documented
in Quickstart. Below we focus on the reduced
model path that closes the field → network bridge.
Vector fitting and SymPy export¶
Physical background¶
For a passive, linear, time-invariant grounding cluster, the driving-point impedance \(Z(s)\) at the feed-in electrode is a rational function of the Laplace variable \(s = j\omega\). Under the \(f \le 1\,\mathrm{kHz}\) quasi-static assumption, the function is well approximated by a low-order partial-fraction expansion
with poles \(p_k\) on the negative real axis (damped RC modes) and residues \(r_k\). Complex-conjugate pole pairs are admissible and produce damped resonant LC-like behaviour. The fit is constructed by the Vector Fitting algorithm of Gustavsen & Semlyen 1999, which iterates pole locations to a stable solution and is the de-facto standard for transmission-line and grounding modelling.
The output is a SymPy expression in a single free symbol s
with complex-conjugate pole pairs combined into real
second-order terms. With the symbolic substitution
\(s \to j\,2\pi f\) the expression matches the
groundinsight.BusType.impedance_formula parser, so a field-grade
Z(s) can be turned into a network-grade BusType without any
manual reformatting.
Example¶
import numpy as np
import groundfield as gf
from groundfield.postprocess.vector_fitting import (
vector_fit, fit_to_sympy, rho_f_from_field_result,
)
# 1. Run any groundfield engine and obtain Z(f) at the feed cluster.
soil = gf.TwoLayerSoil(rho_1=100.0, rho_2=500.0, h_1=2.0)
world = gf.create_world(soil=soil)
gf.create_electrode(
world, "ring", name="g1",
center=(0.0, 0.0, 0.8), radius=5.0, wire_radius=0.005,
)
gf.create_source(world, attached_to="g1", magnitude=1.0)
frequencies = np.array([10.0, 50.0, 150.0, 250.0, 500.0, 1000.0])
engine = gf.create_engine(backend="image", frequencies=frequencies.tolist())
result = world.solve(engine)
# 2. Fit a rational Z(s) directly from the FieldResult.
fit = rho_f_from_field_result(result, electrode_name="g1", n_poles=3)
# 3. Render as a SymPy expression and extract the formula string
# that groundinsight.BusType.impedance_formula consumes.
expr = fit_to_sympy(fit, decimals=6)
print(expr) # printable rational expression
formula_str = str(expr)
A pre-tabulated \(Z(f)\) array can be fitted directly with
:func:vector_fit(frequencies, Z_values, n_poles=3). The returned
:class:VectorFitResult carries poles, residues, and an
evaluate(frequencies) method for re-evaluation.
For the export of the fit as a BusType (JSON file or live
groundinsight instance) see the IO reference and
ADR-0008.
API reference — vector fitting¶
vector_fitting ¶
Vector fitting and SymPy export for the rho-f reduced model.
This module turns a frequency response computed by groundfield
into a closed-form rational impedance expression that
:mod:groundinsight can consume as BusType.impedance_formula.
It is the bridge between the field-grade reference computation
(groundfield) and the reduced equivalent-network model
(groundinsight).
Mathematical background
For a passive, linear, time-invariant grounding cluster, the driving-point impedance \(Z(s)\) at the feed-in electrode is a rational function of the Laplace variable \(s = j\omega\). Under the \(f \le 1\,\mathrm{kHz}\) assumption the function is well approximated by a low-order partial-fraction expansion
with poles \(p_k\) on the negative real axis (damped RC modes) and residues \(r_k\). Complex-conjugate pole pairs are admissible too — they produce damped resonant LC-like behaviour. The fit is constructed by the Vector Fitting algorithm of Gustavsen & Semlyen 1999, which iterates pole locations to a stable solution and is the de-facto standard for transmission-line and grounding modelling.
Implementation
This module implements a clean, dependency-free version of vector fitting in NumPy:
- Initial pole guess: linearly spaced on the negative real axis between the smallest and largest sampled frequencies.
- Pole relocation via Sanathanan-Koerner-style least squares with a shared denominator (the canonical Gustavsen iteration).
- Residue solve given the converged poles.
- Stability enforcement: any pole ending up in the right half plane is reflected.
The exported SymPy expression follows the
groundinsight.BusType.impedance_formula convention: a single
free symbol s and arbitrary numeric constants.
References
- Gustavsen, B. & Semlyen, A. (1999). Rational approximation of frequency domain responses by Vector Fitting. IEEE Trans. Power Delivery 14(3), 1052–1061.
- Gustavsen, B. (2006). Improving the pole relocating properties of vector fitting. IEEE Trans. Power Delivery 21(3), 1587–1592.
VectorFitConvergenceWarning ¶
Bases: UserWarning
The pole-relocation loop did not converge.
Emitted when the relative pole displacement of the last
relocation step is still above 1 % after n_iter iterations
(audit 2026-07-08, WP-B4 — the historic implementation ran a
fixed number of blind iterations with no convergence check).
Inspect VectorFitResult.pole_shift / n_iter_used and
consider more iterations, a different n_poles, or
weighting="inverse_magnitude" for wide-dynamic-range data.
VectorFitResult
dataclass
¶
VectorFitResult(
poles: np.ndarray,
residues: np.ndarray,
R_inf: float,
L_inf: float,
rms_error: float,
fit_frequencies: np.ndarray,
fit_values: np.ndarray,
n_iter_used: int = 0,
pole_shift: float = 0.0,
)
Result of a vector fit.
Attributes:
| Name | Type | Description |
|---|---|---|
poles |
ndarray
|
Complex poles \(p_k\) (1-D array, length \(N_p\)). Stable poles
have |
residues |
ndarray
|
Complex residues \(r_k\) (same shape as |
R_inf |
float
|
Real constant offset \(R_\infty\) (DC residual). |
L_inf |
float
|
Real proportional-to-\(s\) term \(L_\infty\) (high-frequency slope). 0 if the fit was performed without it. |
rms_error |
float
|
Root-mean-square residual fitting error in \(\Omega\), taken over the input frequency points. |
fit_frequencies |
ndarray
|
Frequencies (Hz) used for the fit, kept for diagnostics. |
fit_values |
ndarray
|
Original \(Z(s)\) values used for the fit. |
evaluate ¶
Evaluate the fitted \(Z(s)\) at arbitrary frequencies.
Source code in src/groundfield/postprocess/vector_fitting.py
VectorFitUnderdeterminedWarning ¶
Bases: UserWarning
Vector fit is under- or exactly-determined.
A vector fit with n_poles poles has 2 * n_poles + extras
real unknowns (residues plus optional R_inf / L_inf); the
real-imag stacking of an input of length N gives 2 N
equations. n_poles >= N therefore leads to an under- or
exactly-determined least-squares problem. The fit can succeed
numerically but is effectively an identity interpolation that
carries no information beyond the input samples.
A dedicated warning category lets users opt into / out of the
diagnostic with warnings.simplefilter("once",
VectorFitUnderdeterminedWarning).
fit_to_sympy ¶
Convert a :class:VectorFitResult to a SymPy expression.
The resulting expression is
Complex-conjugate pole/residue pairs are combined into a single
real second-order term to keep the formula compact and
interpretable for groundinsight:
The coefficients are rounded to decimals digits to keep
the printed formula readable.
Returns a :class:sympy.Expr. Free symbol: s.
Source code in src/groundfield/postprocess/vector_fitting.py
596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 | |
rho_f_from_field_result ¶
rho_f_from_field_result(
result,
electrode_name: str,
*,
n_poles: int = 4,
n_iter: int = 8,
include_R_inf: bool = True,
include_L_inf: bool = False,
complex_poles: bool = True
) -> VectorFitResult
Fit \(Z(s)\) for one electrode of a :class:FieldResult.
Computes
\(Z_k = U_k / I_k\)
at every frequency in result.frequencies for
result.electrode_potentials[electrode_name] and
result.electrode_currents[electrode_name], then runs
:func:vector_fit on the resulting series.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
:class: |
required | |
electrode_name
|
str
|
Name of the electrode to extract. |
required |
n_poles
|
int
|
Forwarded to :func: |
4
|
n_iter
|
int
|
Forwarded to :func: |
4
|
include_R_inf
|
int
|
Forwarded to :func: |
4
|
include_L_inf
|
int
|
Forwarded to :func: |
4
|
complex_poles
|
int
|
Forwarded to :func: |
4
|
Returns:
| Type | Description |
|---|---|
VectorFitResult
|
|
Source code in src/groundfield/postprocess/vector_fitting.py
vector_fit ¶
vector_fit(
frequencies: Sequence[float],
Z_values: Sequence[complex],
*,
n_poles: int = 4,
n_iter: int = 8,
include_R_inf: bool = True,
include_L_inf: bool = False,
complex_poles: bool = True,
weighting: str = "uniform"
) -> VectorFitResult
Fit a rational \(Z(s)\) approximation to a sampled frequency response.
Implements the Vector Fitting algorithm of Gustavsen & Semlyen
1999 in its single-output, equal-weight form. The denominator
polynomial is shared between numerator and denominator
estimation (Sanathanan-Koerner with iterative pole relocation);
after n_iter outer iterations the poles are taken as fixed
and the residues are solved from a final linear system.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
frequencies
|
Sequence[float]
|
Frequency samples in Hz, length \(N\). |
required |
Z_values
|
Sequence[complex]
|
Complex impedance samples \(Z(j2\pi f_k)\) in \(\Omega\), length \(N\). |
required |
n_poles
|
int
|
Target number of poles. Complex-conjugate pairs count as 2 poles each. |
4
|
n_iter
|
int
|
Number of pole-relocation iterations. |
8
|
include_R_inf
|
bool
|
Whether to fit a constant offset \(R_\infty\) (DC residual). |
True
|
include_L_inf
|
bool
|
Whether to fit a \(s\cdot L_\infty\) proportional term. |
False
|
complex_poles
|
bool
|
|
True
|
weighting
|
str
|
|
'uniform'
|
Notes
Since the 2026-07-08 audit (WP-B4) the least-squares systems use
the conjugate-pair real basis (see :func:_pair_basis):
complex pole pairs carry genuinely complex residues, matching
the cited Gustavsen & Semlyen algorithm. The pole-relocation
loop additionally monitors the relative pole displacement,
breaks early below 1e-8 and warns
(:class:VectorFitConvergenceWarning) when it is still above
1 % after n_iter iterations; the diagnostics are exposed as
VectorFitResult.n_iter_used / pole_shift.
Returns:
| Type | Description |
|---|---|
VectorFitResult
|
|
Source code in src/groundfield/postprocess/vector_fitting.py
345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 | |
Standard rho-f form¶
The five-coefficient ansatz
is fitted across a \(\rho\)-sweep and is already in the canonical
(rho, f) symbol set used by
groundinsight.BusType.impedance_formula. See
:mod:groundfield.postprocess.rho_f_standard and the
IO reference for the export path.
rho_f_standard ¶
Standard-form rho-f model for the groundinsight bridge.
The reduced grounding-cluster impedance uses the physically-motivated 5-coefficient form
with \(\rho\) a soil-resistivity parameter (typically the upper-layer \(\rho_1\) in a fixed-structure 2-layer setup) and \(f\) the frequency. The five real coefficients have direct physical interpretation:
- \(k_1\,\rho\) — DC spreading resistance (Dwight-class scaling with the dominant local soil resistivity).
- \(k_2\,f\) — purely-inductive coupling that does not depend on the soil (e.g. a metallic-cable loop-inductance term).
- \(k_3\,f\) — purely-resistive frequency-dependent term (negligible in most quasi-static typical studies).
- \(k_4\,f\,\rho\) — Carson-type earth-return resistance: scales with both frequency and soil resistivity.
- \(k_5\,f\,\rho\) — Carson-type earth-return reactance.
This is not a general rational function — it is a fixed
parametric ansatz that captures the leading orders for production-grade
grounding-cluster impedances. It is fitted from a parametric
family of groundfield runs that span both \(\rho\) and \(f\), and
exported as a SymPy expression with two free symbols \(\rho\)
(rho) and \(f\) (f) — the BusType.impedance_formula
convention used by groundinsight.
Mathematically the fit is a linear least-squares problem in the five real unknowns:
- Real part: \(\Re Z = k_1\rho + k_2 f + k_4 f\rho\) → 3-feature regression in (\(\rho\), \(f\), \(f\rho\)).
- Imaginary part: \(\Im Z = k_3 f + k_5 f\rho\) → 2-feature regression in (\(f\), \(f\rho\)).
Both halves are decoupled in the coefficients, so the fit is unique whenever the sample set spans at least two distinct \(\rho\) values and at least two distinct frequencies.
References
- The rho-f model is the reduced grey-box representation handed to
groundinsight. groundinsight.BusType.impedance_formula: the consumer of the SymPy expression returned by :func:fit_to_sympy_standard.
RhoFStandardFit
dataclass
¶
RhoFStandardFit(
k1: float,
k2: float,
k3: float,
k4: float,
k5: float,
rms_error: float,
rms_relative: float,
sample_rho: np.ndarray,
sample_f: np.ndarray,
sample_Z: np.ndarray,
)
Result of a standard-form rho-f fit.
Attributes:
| Name | Type | Description |
|---|---|---|
k1, k2, k3, k4, k5 |
The five real coefficients of the formula \(Z = k_1\rho + (k_2 + j k_3)f + (k_4 + j k_5)f\rho\). |
|
rms_error |
float
|
Root-mean-square residual error in \(\Omega\) over the input samples. |
rms_relative |
float
|
|
sample_rho, sample_f, sample_Z |
Original samples used for the fit (1-D arrays of length \(N\)), kept for diagnostics. |
evaluate ¶
Evaluate the fitted \(Z(\rho, f)\) at arbitrary points.
Source code in src/groundfield/postprocess/rho_f_standard.py
fit_rho_f_standard ¶
fit_rho_f_standard(
rho_samples: Sequence[float],
f_samples: Sequence[float],
Z_samples: Sequence[complex],
) -> RhoFStandardFit
Fit the 5-coefficient standard rho-f form via linear LSQ.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rho_samples
|
Sequence[float]
|
Soil-resistivity values \(\rho\) in \(\Omega\,\mathrm{m}\) for each sample (length \(N\)). |
required |
f_samples
|
Sequence[float]
|
Frequencies \(f\) in Hz for each sample (length \(N\)). |
required |
Z_samples
|
Sequence[complex]
|
Complex driving-point impedances \(Z\) in \(\Omega\) at each \((\rho, f)\) sample (length \(N\)). |
required |
Returns:
| Type | Description |
|---|---|
RhoFStandardFit
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If sample arrays have inconsistent lengths, or if there are fewer than two distinct \(\rho\) values or fewer than two distinct \(f\) values (the LSQ is then under-determined). |
Source code in src/groundfield/postprocess/rho_f_standard.py
121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | |
fit_to_sympy_standard ¶
Convert a :class:RhoFStandardFit to a SymPy expression.
Returns a :class:sympy.Expr in two free symbols rho and
f (both real), in the canonical typical form
The expression is suitable for direct insertion into
groundinsight.BusType.impedance_formula.
Source code in src/groundfield/postprocess/rho_f_standard.py
rho_f_standard_from_results ¶
rho_f_standard_from_results(
results: Sequence,
rhos: Sequence[float],
electrode_name: str,
) -> RhoFStandardFit
Build the (ρ, f, Z) sample table from a list of FieldResults.
Use case: parametric soil-resistivity sweep. Run one
Engine.solve per soil resistivity, collect the FieldResults
along with the driving \(\rho\), and pass the lot to this
function.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
results
|
Sequence
|
List of :class: |
required |
rhos
|
Sequence[float]
|
Soil-resistivity parameter \(\rho\) corresponding to each FieldResult (same length). |
required |
electrode_name
|
str
|
Name of the electrode to extract. |
required |
Returns:
| Type | Description |
|---|---|
RhoFStandardFit
|
|
Source code in src/groundfield/postprocess/rho_f_standard.py
Touch and step voltages¶
The :mod:groundfield.postprocess.safety module turns a
:class:FieldResult into the engineering safety quantities used
in EN 50522:2010 / IEC 61936-1.
Physical background¶
The earth potential rise of a touched grounding cluster is
Standing on the soil surface 1 m away from the touched part, the person's feet sit at the surface potential \(\varphi(\mathbf r_\text{feet})\) and the voltage appearing across the body is
The step voltage between two surface points at the typical \(d_\text{step} = 1\,\mathrm{m}\) separation is
Both quantities are returned as complex phasors per frequency index, so that inductive- and resistive-coupling effects above DC remain visible in typical studies.
Example¶
import groundfield as gf
soil = gf.HomogeneousSoil(resistivity=100.0)
world = gf.create_world(soil=soil)
gf.create_electrode(world, "rod", name="g1",
position=(0.0, 0.0, 0.5), length=1.5)
gf.create_source(world, attached_to="g1", magnitude=10.0)
result = gf.create_engine(backend="image", segment_length=0.05).solve(world)
# Touch voltage 1 m east of the rod:
U_T = gf.touch_voltage(result, world, electrode="g1", distance=1.0)
# Worst-case touch voltage on a circle around the rod:
angles, voltages = gf.touch_voltage_envelope(
result, world, electrode="g1", distance=1.0, n_angles=24,
)
U_T_worst = float(abs(voltages).max())
# Step voltage 1 m east of the rod, walking radially outward:
U_S = gf.step_voltage(result, position=(1.0, 0.0, 0.0),
direction=(1.0, 0.0, 0.0), step=1.0)
# Reference: EN 50522 Table B.4 permissible touch voltage at t_F = 0.5 s.
U_TP = gf.permissible_touch_voltage_en50522(0.5) # 225 V
Validity envelope¶
- Frequency: quasi-static envelope \(f \le 1\,\mathrm{kHz}\), inherited from the underlying Green's function.
- Backends: every solver that populates
point_sources(image, image_2layer, image_nlayer, mom, mom_sommerfeld, cim, bem). Stub backends raise inside :meth:FieldResult.potential. - Coordinate convention: soil surface at \(z = 0\), positive \(z\)
points downwards into the soil. The default
surface_z = 0.0models bare-foot contact on the ground surface. permissible_touch_voltage_en50522reproduces EN 50522:2010 Table B.4 verbatim — eight anchors at \(t_f \in \{0.05, 0.10, 0.20, 0.50, 1.00, 2.00, 5.00, 10.00\}\,\mathrm{s}\) with \(U_{TP} \in \{725, 655, 525, 225, 115, 95, 85, 85\}\,\mathrm{V}\), values rounded to 5 V in the standard. Log-log interpolation inside the grid; outside the grid the values are clamped to the table endpoints (no relaxation below 50 ms; the terminal 85 V plateau between 5 s and 10 s is reproduced exactly).
API reference — safety¶
safety ¶
Touch and step voltages for grounding-system safety assessment.
This module turns a :class:groundfield.solver.result.FieldResult into
the engineering quantities used in the grounding-system safety
verification according to EN 50522:2010 / IEC 61936-1: the touch
voltage :math:U_T and the step voltage :math:U_S. It also
provides the corresponding permissible values
:math:U_{TP}(t) from EN 50522:2010, Figure B.3.
Mathematical / physical content
A grounding cluster at earth potential rise
.. math::
U_E \;=\; \varphi_\text{cluster},
is connected — through any galvanically bonded metallic part —
to whatever a person can touch. Standing on the soil surface
1 m away from the touched part (the conventional test point of
EN 50522), the person's feet sit at the surface potential
:math:\varphi(\mathbf{r}_\text{feet}). The voltage
appearing across the body is
.. math::
U_T \;=\; U_E \;-\; \varphi(\mathbf{r}_\text{feet}).
For a step voltage the body bridges two surface points at the
typical step distance :math:d_\text{step} = 1\,\mathrm{m}:
.. math::
U_S \;=\; \varphi(\mathbf{r}_1) \;-\;
\varphi(\mathbf{r}_1 + d_\text{step}\,\hat{\mathbf e}).
Both quantities are returned as complex phasors per frequency
index — consistent with the rest of groundfield — so that
inductive- and resistive-coupling effects above DC remain
visible.
Validity envelope
- Frequency: quasi-static envelope :math:
f \le 1\,\mathrm{kHz}, inherited from the underlying Green's function. - Geometry: relies on
:meth:
FieldResult.potentialand :meth:FieldResult.electrode_potentials. Works for every backend that populatespoint_sources(image, image_2layer, image_nlayer, mom, mom_sommerfeld, cim, bem) and is silently inapplicable to stub backends (raises through :meth:FieldResult.potential). - Coordinate convention: the soil surface is at :math:
z = 0, positive :math:zpoints downwards into the soil (see :mod:groundfield.geometry.electrodes). The defaultsurface_z = 0.0therefore models bare-foot contact at the ground surface. - Permissible-voltage helper
:func:
permissible_touch_voltage_en50522covers the low-voltage standard curve (EN 50522:2010, Fig. B.3) for fault-clearing times :math:50\,\mathrm{ms} \le t_F \le 10\, \mathrm{s}. Outside that range the value is clamped to the table endpoints, mirroring the stationary plateau the standard prescribes for long-duration faults.
References
- EN 50522:2010 — Earthing of power installations exceeding 1 kV a.c., Annex B (allowable touch voltages).
- IEC 61936-1:2014 — Power installations exceeding 1 kV a.c.
- IEEE Std 80-2013 — Guide for safety in AC substation grounding.
permissible_touch_voltage_en50522 ¶
Permissible touch voltage :math:U_{TP}(t_F) per EN 50522:2010.
Returns the maximum admissible touch voltage as a function of
the fault-clearing time :math:t_F. The reference values are
taken verbatim from EN 50522:2010, Table B.4 — the
normative tabulation of the curve in Figure B.3 (values
rounded to 5 V in the standard).
Mathematically the helper is a piecewise-loglog interpolation over the canonical anchor points
.. math::
\{(t_k, U_{TP,k})\} \;=\;
\{(0.05, 725),\ (0.10, 655),\ (0.20, 525),\
(0.50, 225),\ (1.00, 115),\ (2.00, 95),\
(5.00, 85),\ (10.0, 85)\},
with units :math:\mathrm{s} and :math:\mathrm{V}. Outside
the grid the values are clamped to the endpoints — the standard
does not extend the curve below 50 ms, and the terminal plateau
at 85 V is explicit (the table reports identical values at 5 s
and 10 s).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
t_clear_s
|
float
|
Fault-clearing time :math: |
required |
Returns:
| Type | Description |
|---|---|
float
|
Permissible touch voltage :math: |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Notes
The EN 50522 curve is intended for engineering design; this
helper is used as a reference line on plots of computed touch
voltages, not as a regulatory pass/fail. Use
:func:touch_voltage to compute the actual :math:U_T and
compare against this reference.
Source code in src/groundfield/postprocess/safety.py
step_voltage ¶
step_voltage(
result: "FieldResult",
*,
position: tuple[float, float, float],
direction: tuple[float, float, float] = (1.0, 0.0, 0.0),
step: float = 1.0,
surface_z: float | None = None,
frequency_index: int = 0
) -> complex
Step voltage between two surface points.
Evaluates :math:U_S = \varphi(\mathbf{r}_1) -
\varphi(\mathbf{r}_1 + d_\text{step}\,\hat{\mathbf{e}})
on the soil surface.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
'FieldResult'
|
Solver output. |
required |
position
|
tuple[float, float, float]
|
First foot position :math: |
required |
direction
|
tuple[float, float, float]
|
Step direction in world coordinates. The horizontal
projection is used (the :math: |
(1.0, 0.0, 0.0)
|
step
|
float
|
Step length :math: |
1.0
|
surface_z
|
float | None
|
Optional explicit surface :math: |
None
|
frequency_index
|
int
|
Index into :attr: |
0
|
Returns:
| Type | Description |
|---|---|
complex
|
Phasor :math: |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/groundfield/postprocess/safety.py
touch_voltage ¶
touch_voltage(
result: "FieldResult",
world: "World",
*,
electrode: str,
distance: float = 1.0,
direction: tuple[float, float, float] = (1.0, 0.0, 0.0),
surface_z: float = 0.0,
frequency_index: int = 0
) -> complex
Touch voltage at a single point on the soil surface.
Evaluates :math:U_T = U_E - \varphi(\mathbf{r}_\text{feet}),
where :math:U_E is the cluster potential of the touched
electrode and :math:\mathbf{r}_\text{feet} is the surface
point distance metres away from the electrode's
:attr:connection_point in the (horizontal projection of the)
given direction.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
'FieldResult'
|
Solver output from :meth: |
required |
world
|
'World'
|
Companion world used to resolve the electrode's connection point. |
required |
electrode
|
str
|
Name of the touched electrode. Its cluster potential is the EPR seen by the hand. |
required |
distance
|
float
|
Horizontal distance between the touched part and the feet,
in metres. EN 50522 uses |
1.0
|
direction
|
tuple[float, float, float]
|
Direction in which the feet sit, expressed in world
coordinates. The horizontal projection of this vector is
used (the :math: |
(1.0, 0.0, 0.0)
|
surface_z
|
float
|
:math: |
0.0
|
frequency_index
|
int
|
Index into :attr: |
0
|
Returns:
| Type | Description |
|---|---|
complex
|
Phasor :math: |
Raises:
| Type | Description |
|---|---|
KeyError
|
If |
ValueError
|
If |
Source code in src/groundfield/postprocess/safety.py
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | |
touch_voltage_envelope ¶
touch_voltage_envelope(
result: "FieldResult",
world: "World",
*,
electrode: str,
distance: float = 1.0,
n_angles: int = 24,
surface_z: float = 0.0,
frequency_index: int = 0
) -> tuple[np.ndarray, np.ndarray]
Touch-voltage profile around an electrode.
Walks a horizontal circle of radius distance around the
electrode's :attr:connection_point in n_angles equal
angular steps and returns the touch voltage at every direction.
The maximum of |U_T| is the conservative envelope used in
safety verification.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
'FieldResult'
|
See :func: |
required |
world
|
'FieldResult'
|
See :func: |
required |
electrode
|
str
|
Name of the touched electrode. |
required |
distance
|
float
|
Radius of the circle in metres (1.0 by EN 50522). |
1.0
|
n_angles
|
int
|
Number of equally spaced sample directions
:math: |
24
|
surface_z
|
float
|
See :func: |
0.0
|
frequency_index
|
float
|
See :func: |
0.0
|
Returns:
| Name | Type | Description |
|---|---|---|
angles |
(ndarray, shape(n_angles))
|
Angles :math: |
voltages |
np.ndarray, shape (n_angles,), dtype complex
|
Touch-voltage phasor :math: |
Source code in src/groundfield/postprocess/safety.py
Current sharing and split factor¶
The :mod:groundfield.postprocess.current_balance module turns
the per-electrode currents in :class:FieldResult into the
engineering quantities that answer "where does the injected source
current actually return?".
Physical background¶
For each galvanic cluster \(c\) the net soil leakage is
with \(I_e\) the per-electrode soil-leakage current stored in
:attr:FieldResult.electrode_currents (positive in direction
electrode → soil). Because the cluster members share a
potential \(U_c\), the cluster impedance is
\(Z_c = U_c / I_c\), and the per-electrode share inside one cluster
is the complex ratio \(s_{e \mid c} = I_e / I_c\).
The split factor of a current source is
with \(c_\text{src}\) the cluster of the source's attached_to
electrode. By construction, \(s = 1 + 0\,j\) when the entire
injected current leaves the source cluster through the soil
(no metallic parallel path), and \(s < 1\) when a metallic
conductor (PEN trunk, parallel measurement lead, cable shield)
carries part of the current as a parallel resistive path. This
is the galvanic current division across parallel paths.
Not the same as the Reduktionsfaktor¶
In the German EVU / Schirmtechnik literature (Oeding & Oswald 2016) the Reduktionsfaktor refers to the additional transformatorische / inductive coupling correction between a current-carrying conductor and a parallel grounding / shield conductor. That quantity is angle-dependent: it vanishes when the two conductors are perpendicular (no flux linkage) but is large for collinear runs.
The split factor here is purely galvanic — present whenever
there are parallel resistive paths, irrespective of the
geometric angle between conductors. The proper Reduktionsfaktor
is on the roadmap; the inductance backends in
:mod:groundfield.coupling (Neumann, Carson, Sommerfeld) are
already in place and will be picked up by a dedicated future
helper.
Example¶
import groundfield as gf
soil = gf.HomogeneousSoil(resistivity=100.0)
world = gf.create_world(soil=soil)
g1 = gf.create_electrode(
world, "rod", name="g1", position=(0.0, 0.0, 0.5), length=1.5,
)
g_aux = gf.create_electrode(
world, "rod", name="g_aux", position=(50.0, 0.0, 0.5), length=1.5,
)
# Finite-impedance metallic feed lead between source and aux cluster.
gf.create_conductor(
world, name="feed_lead", start=g1, end=g_aux,
conductor_type="bare_copper", cross_section=50e-6,
)
gf.create_source(
world, name="src", attached_to="g1", return_to="g_aux", magnitude=10.0,
)
result = gf.create_engine(backend="image", segment_length=0.05).solve(world)
# Per-cluster summary, sorted by descending |ΣI|:
gf.cluster_current_balance(result)
# Per-electrode table (with kind / depth annotations):
gf.electrode_current_table(result, world=world)
# Split factor of the source — < 1 because the metallic feed lead
# carries part of I_src as a parallel resistive path:
s = gf.split_factor(result, world)
# Top-15 bar chart of |I| per electrode:
gf.plot_current_sharing(result, world=world, top_n=15)
Validity envelope¶
- Frequency: quasi-static envelope \(f \le 1\,\mathrm{kHz}\).
- Backends: every solver that populates
electrode_potentials/electrode_currents/clusters(image, image_2layer, image_nlayer, mom, mom_sommerfeld, cim, bem). Stub backends produce empty / NaN cluster tables. - The image backend treats
Source.return_toas informational metadata; the injected current dissipates via the Dirichlet far-field boundary. The split factor still detects metallic parallel paths because the cluster's net soil leakage is reduced by whatever current is shunted into a finite-impedance branch.
API reference — current sharing¶
current_balance ¶
Current sharing and split-factor analysis for a FieldResult.
This module turns the per-electrode currents stored in
:class:groundfield.solver.result.FieldResult into the engineering
quantities that answer "where does the injected source current
actually return?". In a TN-Ortsnetz with
hundreds of houses, dozens of cable cabinets and one or more
metallic return paths (PEN trunk, measurement leads, cable
shields), the soil-leakage current of every cluster matters and
cannot be read off electrode_currents directly without aggregation.
Quantities
- Per-cluster soil leakage
:math:
I_{c} = \sum_{e \in c} I_e. With ideal galvanic bonds (cross_section=None) the cluster members share a potential :math:U_c; the cluster impedance is then :math:Z_c = U_c / I_c. Returned as a tabular summary by :func:cluster_current_balance. - Per-electrode share of cluster current — what fraction of
the cluster's net soil leakage flows through this specific
electrode:
:math:
s_{e \mid c} = I_e / I_c(complex). Returned alongside the per-electrode potential / impedance by :func:electrode_current_table. - Split factor
:math:
s = I_{c_\text{src}} / I_\text{src} = \sum_{e \in c_\text{src}} I_e / I_\text{src}, with :math:c_\text{src}the cluster of the source'sattached_toelectrode.s = 1means the injected current leaves the source cluster entirely through the soil (no metallic short-cut to the return-path cluster).s < 1means a metallic conductor (PEN trunk, parallel measurement lead, cable shield) carries part of the current as a parallel resistive path.
This is the galvanic current-split between parallel paths.
It is not the Reduktionsfaktor in the German EVU /
Schirmtechnik sense (Oeding & Oswald 2016): that latter
quantity is the additional transformatorische / inductive
coupling correction between a current-carrying conductor and
a parallel grounding / shield conductor. The Reduktionsfaktor
vanishes when the two conductors are perpendicular (no flux
linkage) but the split factor still applies — the current
always splits among parallel resistive paths irrespective of
the geometric angle. A future helper may add the proper
Reduktionsfaktor based on the Neumann mutual-inductance / Carson
/ Sommerfeld backends already present in :mod:groundfield.coupling.
Validity envelope
- Frequency: quasi-static envelope :math:
f \le 1\,\mathrm{kHz}. - Conventions: :class:
FieldResult.electrode_currentscarries the per-electrode soil-leakage current in A (complex phasor), with positive sign in the direction electrode → soil. All quantities here are complex perfrequency_index. - Cluster discovery uses :attr:
FieldResult.clusters— the same map produced by every backend.
cluster_current_balance ¶
Per-cluster soil leakage, potential and impedance.
Returns one row per unique galvanic cluster present in the
result. The cluster impedance is computed as
:math:Z_c = U_c / I_c with :math:I_c = \sum_{e \in c} I_e.
Where :math:I_c = 0 (purely passive observer cluster) the
impedance columns are NaN.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
'FieldResult'
|
Solver output. |
required |
frequency_index
|
int
|
Index into :attr: |
0
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Columns
|
Source code in src/groundfield/postprocess/current_balance.py
electrode_current_table ¶
electrode_current_table(
result: "FieldResult",
world: "World | None" = None,
*,
frequency_index: int = 0
) -> pd.DataFrame
Per-electrode potential, current and share of cluster current.
For each electrode in the result the table reports the
potential :math:U_e (= cluster potential), the soil-leakage
current :math:I_e, the implied two-terminal impedance
:math:Z_e = U_e / I_e, and the fractional share of the
cluster total :math:s_{e \mid c} = I_e / I_c. The latter is
the engineering key indicator for "of all the soil current
leaving cluster c, how much physically flows through this
specific electrode" — useful when looking at a single
transformer station with a ring + several rods, or a building
cluster with multiple foundation parts.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
'FieldResult'
|
Solver output. |
required |
world
|
'World | None'
|
Optional companion world. When given, the table includes
the electrode kind ( |
None
|
frequency_index
|
int
|
Index into :attr: |
0
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Columns
|
Source code in src/groundfield/postprocess/current_balance.py
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 | |
plot_current_sharing ¶
plot_current_sharing(
result: "FieldResult",
world: "World | None" = None,
*,
by: Literal["electrode", "cluster"] = "electrode",
top_n: int = 15,
frequency_index: int = 0,
figsize: tuple[float, float] = (8.0, 5.0),
title: str | None = None
)
Bar chart of the top_n current contributors.
Renders |I| (in A) for either every electrode or every
galvanic cluster, sorted descending. The default by =
"electrode" is the default — which physical electrode
actually carries the test current? The companion option
by = "cluster" aggregates over each cluster.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
'FieldResult'
|
Solver output. |
required |
world
|
'World | None'
|
Optional companion world. Forwarded to
:func: |
None
|
by
|
Literal['electrode', 'cluster']
|
|
'electrode'
|
top_n
|
int
|
Maximum number of bars to render. Smaller contributors are
truncated. Pass |
15
|
frequency_index
|
int
|
Index into :attr: |
0
|
figsize
|
tuple[float, float]
|
Matplotlib figure size in inches. |
(8.0, 5.0)
|
title
|
str | None
|
Optional title override. |
None
|
Returns:
| Type | Description |
|---|---|
Figure
|
|
Source code in src/groundfield/postprocess/current_balance.py
462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 | |
split_factor ¶
split_factor(
result: "FieldResult",
world: "World",
*,
source_name: str | None = None,
frequency_index: int = 0
) -> complex
Split factor :math:s = I_{c_\text{src}} / I_\text{src}.
Computes the galvanic split factor of a current source: the fraction of the injected current that leaves the source cluster through the soil rather than through any metallic parallel path (PEN trunk, parallel measurement lead, cable shield).
Mathematically
.. math::
s \;=\; \frac{\sum_{e \in c_\text{src}} I_e}{I_\text{src}},
with :math:c_\text{src} the galvanic cluster of the source's
attached_to electrode and :math:I_\text{src} the complex
phasor of the source. By construction:
- For a stand-alone source on an electrode without metallic
parallel paths, KCL forces :math:
s = 1 + 0\,j. The injected current leaves the cluster entirely through the soil. - For a source whose cluster is connected to another
grounding cluster (e.g. the auxiliary electrode of a
fall-of-potential measurement) by a metallic conductor,
:math:
s < 1. The smaller the conductor's series impedance, the smaller :math:s. - The complex argument :math:
\arg sreveals the inductive content of the parallel path — a Carson- or Sommerfeld- corrected lead at frequency >0 produces an imaginary share.
Not to be confused with the Reduktionsfaktor
In the German EVU / Schirmtechnik literature (Oeding & Oswald 2016), the Reduktionsfaktor refers to the transformatorische / inductive coupling correction between a current-carrying conductor and a parallel grounding / shield conductor. That quantity is angle-dependent: it vanishes when the two conductors are perpendicular (no flux linkage) but is large for collinear runs.
The split factor implemented here is purely galvanic — the resistive division of current across parallel paths. It is present whenever there are multiple parallel paths, regardless of their geometric orientation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
'FieldResult'
|
Solver output. |
required |
world
|
'World'
|
Companion world used to resolve the source object. |
required |
source_name
|
str | None
|
Optional name of the current source. |
None
|
frequency_index
|
int
|
Index into :attr: |
0
|
Returns:
| Type | Description |
|---|---|
complex
|
Phasor :math: |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the world has no current source, multiple current
sources without explicit |
KeyError
|
If |
Source code in src/groundfield/postprocess/current_balance.py
352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 | |
Parameter sweeps and convergence studies¶
The :mod:groundfield.postprocess.sweep and
:mod:groundfield.postprocess.convergence modules turn the typical
parameter axes into a single tabular response. Both produce
long-format :class:pandas.DataFrame objects that feed naturally
into the :math:\rho-:math:f fit and the vector-fitting
pipelines.
sweep — Cartesian product over named axes¶
import groundfield as gf
def world_factory(*, rho, h_1):
soil = gf.TwoLayerSoil(rho_1=rho, rho_2=10*rho, h_1=h_1)
world = gf.create_world(soil=soil)
gf.create_electrode(world, "rod", name="g1",
position=(0, 0, 0.5), length=1.5)
gf.create_source(world, attached_to="g1", magnitude=1.0)
return world
eng = gf.create_engine(backend="image_2layer", segment_length=0.1,
frequencies=[50.0, 200.0])
df = gf.sweep(
world_factory,
eng,
axes={"rho": [50, 100, 500, 1000], "h_1": [1.0, 2.0, 5.0]},
)
gf.plot_sweep_lines(df, x="rho", y="abs_Z", color="h_1",
log_x=True, log_y=True)
gf.plot_sweep_heatmap(df, x="rho", y="h_1", response="abs_Z",
frequency_Hz=50.0)
The default response captures the cluster impedance and EPR at
the source's cluster per frequency (Z_re, Z_im,
abs_Z, arg_Z_deg, U_E_re, U_E_im, abs_U_E,
I_re, I_im, abs_I). Pass response=... to extract
custom scalars.
convergence_study — refinement over segment_length¶
df = gf.convergence_study(
world, eng, segment_lengths=[0.5, 0.2, 0.1, 0.05, 0.02],
)
gf.plot_convergence(df, response="abs_Z", reference=R_sunde)
The helper clones the engine via :meth:Engine.model_copy, so
the original engine is not mutated. The plot's x-axis is
inverted so finer segment_length sits on the right (the
convergence-asymptote direction). Pass an analytical reference
(Sunde, Dwight, IEEE 80) via reference=... for a clean
asymptote line.
API reference — sweep¶
sweep ¶
Cartesian-product parameter sweeps for parameter axis studies.
This module turns the typical study axes — soil resistivity
:math:\rho_1 / :math:\rho_2, layer thickness :math:h_1,
electrode geometry, frequency — into a single tabular response
that is cheap to plot and easy to feed into vector-fitting,
:math:\rho-:math:f regression, or downstream
groundinsight.
API surface
:func:sweep
Walk the Cartesian product of an arbitrary number of named
axes, build a fresh :class:World (and optionally a fresh
:class:Engine) per combination, solve, and extract a scalar
response per frequency. Returns a long-format
:class:pandas.DataFrame with one row per
(axis values × frequency).
:func:plot_sweep_lines
Line plot of one response column against a chosen axis,
optionally with one curve per value of a second axis.
:func:plot_sweep_heatmap
Pivot-table heatmap of one response column over a
(x_axis, y_axis) pair (e.g. :math:\rho_1 vs. :math:h_1).
Mathematical / physical content
The default response extractor picks up the
cluster impedance :math:Z_c = U_c / I_c at the source's
galvanic cluster, with :math:U_c the cluster potential and
:math:I_c = \sum_{e \in c_\text{src}} I_e the net soil
leakage. Both numerator and denominator are read from
:class:FieldResult per frequency_index, so the response is
the engineering :math:Z(\rho_1, h_1, f) curve that
:mod:groundfield.postprocess.rho_f_standard and
:mod:groundfield.postprocess.vector_fitting consume.
Validity envelope
- Frequency: quasi-static envelope :math:
f \le 1\,\mathrm{kHz}. - Linearity: the sweep does not interpolate between samples — for
a smooth :math:
Z(\rho, f)surface, hand the resulting DataFrame to :func:fit_rho_f_standard/ :func:fit_to_sympy_standard. - Cost: every Cartesian combination triggers one full
:meth:
Engine.solve. For 50 ρ-values × 10 h-values × 1 frequency list with N segments each, expect 500 dense system solves; budget accordingly with :func:expected_segments.
plot_sweep_heatmap ¶
plot_sweep_heatmap(
df: pd.DataFrame,
*,
x: str,
y: str,
response: str = "abs_Z",
frequency_Hz: float | None = None,
agg: str = "mean",
cmap: str = "viridis",
figsize: tuple[float, float] = (7.5, 5.5),
title: str | None = None
)
Heatmap of response over the (x, y) axis pair.
Pivot-tables df by y (rows) and x (columns),
aggregating response with the chosen agg (default
"mean" — useful when the sweep contains additional axes
that should collapse). Selects a single frequency slice
when frequency_Hz is given.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
df
|
DataFrame
|
Long-format DataFrame from :func: |
required |
x
|
str
|
Column names for the heatmap axes. Both must be numeric. |
required |
y
|
str
|
Column names for the heatmap axes. Both must be numeric. |
required |
response
|
str
|
Column to colour-code. Default |
'abs_Z'
|
frequency_Hz
|
float | None
|
If given and the DataFrame has a |
None
|
agg
|
str
|
Aggregation passed to :meth: |
'mean'
|
cmap
|
str
|
Standard matplotlib options. |
'viridis'
|
figsize
|
str
|
Standard matplotlib options. |
'viridis'
|
title
|
str
|
Standard matplotlib options. |
'viridis'
|
Returns:
| Type | Description |
|---|---|
Figure
|
|
Source code in src/groundfield/postprocess/sweep.py
278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 | |
plot_sweep_lines ¶
plot_sweep_lines(
df: pd.DataFrame,
*,
x: str,
y: str = "abs_Z",
color: str | None = None,
figsize: tuple[float, float] = (8.0, 5.0),
log_x: bool = False,
log_y: bool = False,
title: str | None = None
)
Line plot of y versus x, one curve per color value.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
df
|
DataFrame
|
Long-format DataFrame as produced by :func: |
required |
x
|
str
|
Column name to use on the x-axis. |
required |
y
|
str
|
Column name of the response. Default |
'abs_Z'
|
color
|
str | None
|
Optional second column name; one line per distinct value
is drawn. |
None
|
figsize
|
tuple[float, float]
|
Standard matplotlib options. |
(8.0, 5.0)
|
log_x
|
tuple[float, float]
|
Standard matplotlib options. |
(8.0, 5.0)
|
log_y
|
tuple[float, float]
|
Standard matplotlib options. |
(8.0, 5.0)
|
title
|
tuple[float, float]
|
Standard matplotlib options. |
(8.0, 5.0)
|
Returns:
| Type | Description |
|---|---|
Figure
|
|
Source code in src/groundfield/postprocess/sweep.py
sweep ¶
sweep(
world_factory: Callable[..., "World"],
engine: "Engine | Callable[..., Engine]",
*,
axes: dict[str, Sequence[Any]],
response: (
Callable[
["FieldResult", "World", int], dict[str, float]
]
| None
) = None
) -> pd.DataFrame
Cartesian-product parameter sweep across user-defined axes.
For every combination (a_1 = v_1, a_2 = v_2, ...) in the
Cartesian product of axes, the function
- builds a fresh world via
world_factory(**combination), - resolves the engine — either the static :class:
Enginepassed in or a per-combination engine viaengine(**combination)if a callable is given, - solves with :meth:
World.solve, - iterates over every frequency in
:attr:
FieldResult.frequenciesand extracts a row viaresponse(result, world, frequency_index).
The axis values and frequency_Hz are added to every row
automatically.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
world_factory
|
Callable[..., 'World']
|
Callable that builds a fresh :class: |
required |
engine
|
'Engine | Callable[..., Engine]'
|
Either a static :class: |
required |
axes
|
dict[str, Sequence[Any]]
|
Mapping |
required |
response
|
Callable[['FieldResult', 'World', int], dict[str, float]] | None
|
Optional response extractor. Receives
|
None
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Long-format. Columns are the axis names, |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/groundfield/postprocess/sweep.py
121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 | |
API reference — convergence¶
convergence ¶
Convergence study over the engine's segment_length.
The PDE / field model in groundfield is a reference
computation (see CLAUDE.md); to honour that role every
non-trivial result should be backed up by a refinement study.
This module turns the canonical "halve the segment length, watch
what happens" experiment into one function call.
Mathematical / physical content
The image-family discretiser splits each electrode into segments
of length :math:\Delta s. As :math:\Delta s \to 0 the
multi-port grounding matrix approaches the continuous integral
operator and the cluster impedance :math:Z_c converges to the
PDE-grade reference. The convergence is monotone in :math:\Delta
s for the average-potential method (cf. Sunde 1968; Tagg 1964).
A practical refinement plot therefore answers two questions at
once:
- "Has my chosen :math:
\Delta salready converged within X %?" — the curve flattens out. - "What is the asymptotic (PDE-grade) value?" — extrapolation / Richardson if needed.
Validity envelope
- Frequency: quasi-static envelope :math:
f \le 1\,\mathrm{kHz}. - Backends: any image-family solver (image / image_2layer /
image_nlayer / mom / mom_sommerfeld / cim / bem). FEM is
supported but its mesh is generated independently — the
segment_lengthknob does not directly control its accuracy. - The function clones the engine via :meth:
Engine.model_copy, so the original engine is not mutated.
convergence_study ¶
convergence_study(
world: "World",
engine: "Engine",
*,
segment_lengths: Sequence[float],
response: (
Callable[
["FieldResult", "World", int], dict[str, float]
]
| None
) = None
) -> pd.DataFrame
Solve the same world repeatedly with refining segment_length.
For every :math:\Delta s_k in segment_lengths the
function builds a clone of the engine (only segment_length
differs), solves the world, and extracts a scalar response
per frequency. The returned DataFrame is sorted descending
in segment_length_m so finer resolutions land on the right
in the default plot.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
world
|
'World'
|
World to solve. Not modified. |
required |
engine
|
'Engine'
|
Base engine. Cloned per refinement step via
:meth: |
required |
segment_lengths
|
Sequence[float]
|
Sequence of segment lengths in metres. Must be strictly positive and contain at least two distinct values. |
required |
response
|
Callable[['FieldResult', 'World', int], dict[str, float]] | None
|
Optional response extractor. Defaults to
:func: |
None
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Long-format. Columns |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/groundfield/postprocess/convergence.py
plot_convergence ¶
plot_convergence(
df: pd.DataFrame,
*,
response: str = "abs_Z",
reference: float | None = None,
figsize: tuple[float, float] = (7.5, 4.5),
title: str | None = None
)
Plot response versus segment_length (one line per frequency).
The x-axis is logarithmic and inverted so finer resolutions sit on the right (the convergence "asymptote direction"). When the DataFrame contains a single frequency only, the legend is suppressed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
df
|
DataFrame
|
DataFrame returned by :func: |
required |
response
|
str
|
Column name to plot. Default |
'abs_Z'
|
reference
|
float | None
|
Optional asymptotic value to draw as a horizontal dashed reference line — useful when an analytical reference (Sunde, Dwight, IEEE Std 80) is known. |
None
|
figsize
|
tuple[float, float]
|
Standard matplotlib options. |
(7.5, 4.5)
|
title
|
tuple[float, float]
|
Standard matplotlib options. |
(7.5, 4.5)
|
Returns:
| Type | Description |
|---|---|
Figure
|
|
Source code in src/groundfield/postprocess/convergence.py
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 | |
World-geometry plots (no solve required)¶
The :mod:groundfield.postprocess.geometry_plot module renders
the physical world — electrodes, conductors and current sources
— before the solver runs. production-grade networks with several
hundred electrodes benefit from a quick sanity check that catches
typos in coordinates, missing conductors, or sources attached to
the wrong electrode without paying for a full solve.
Conductor colour scheme¶
The conductor colour follows
:data:groundfield.conductors.ConductorType:
conductor_type |
Colour |
|---|---|
pen |
green (#2c7a2c) |
bare_copper |
orange (#d97300) |
cable_shield |
grey (#888888) |
overhead |
steel blue (#1f77b4) |
generic |
dark grey (#444444) |
The line style flags the soil-coupling mode:
coupling_to_soil="galvanic" is solid, "isolated" is
dashed.
Example¶
import groundfield as gf
world = gf.create_world(soil=gf.HomogeneousSoil(resistivity=100.0))
g_rod = gf.create_electrode(
world, "rod", name="g1", position=(0.0, 0.0, 0.5), length=1.5,
)
g_ring = gf.create_electrode(
world, "ring", name="g2", center=(10.0, 0.0, 0.8), radius=2.5,
)
gf.create_conductor(world, name="bond", start=g_rod, end=g_ring,
conductor_type="bare_copper")
gf.create_source(world, name="src", attached_to=g_rod, magnitude=10.0,
return_to=g_ring)
# Top-down 2-D view (default).
gf.plot_world(world, plane="xy")
# Vertical slice with the soil surface as a dotted grey line.
gf.plot_world(world, plane="xz")
# 3-D wireframe with inverted z (depth grows downwards on screen).
gf.plot_world_3d(world)
# Pure helper — bounding box of the geometry, useful for custom
# extents on the field plots.
x_min, x_max, y_min, y_max, z_min, z_max = gf.world_bounds_3d(world)
API reference — geometry plots¶
geometry_plot ¶
World-geometry plots (no solve required).
This module provides quick visualisations of the physical world — electrodes, conductors and current sources — before the solver runs. In default setups with several hundred electrodes (200 EFH plus KVS plus substation plus measurement aux/probe) it is very useful to inspect the geometry first to catch typos in positions, accidental clusters, missing conductors, or sources attached to the wrong electrode, without paying the cost of a field solve.
Functions:
| Name | Description |
|---|---|
world_bounds_3d |
Smallest axis-aligned bounding box in :math: |
plot_world |
2-D top-down ( |
plot_world_3d |
3-D wireframe with the soil-surface plane shown in light
grey and the :math: |
Conductor colour scheme
The conductor colour follows :data:groundfield.conductors.ConductorType:
================== ========================= =====================
conductor_type Colour Default style
================== ========================= =====================
pen #2c7a2c (green) solid
bare_copper #d97300 (orange) solid
cable_shield #888888 (grey) solid
overhead #1f77b4 (steel blue) solid
generic #444444 (dark grey) solid
================== ========================= =====================
The line style flags the soil-coupling mode of the conductor:
coupling_to_soil = "galvanic" is drawn solid, "isolated"
is drawn dashed.
Validity
Pure geometry — no solver result is required. The plot does
not visualise any field quantity; for that, see
:func:groundfield.postprocess.plotting.plot_potential_contour
and friends. Best used as a debugging step before
world.solve(...).
plot_world ¶
plot_world(
world: "World",
*,
plane: Literal["xy", "xz"] = "xy",
extent: tuple[float, float, float, float] | None = None,
padding_m: float = 5.0,
show_conductors: bool = True,
show_sources: bool = True,
annotate_electrodes: bool = False,
figsize: tuple[float, float] = (8.0, 6.0),
ax=None,
title: str | None = None
)
Top-down or vertical 2-D geometry plot of a :class:World.
Pure-geometry visualisation; no field quantity is evaluated
and no solver is invoked. Useful as a sanity check before
:meth:World.solve on a large typical network.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
world
|
'World'
|
World to draw. |
required |
plane
|
Literal['xy', 'xz']
|
|
'xy'
|
extent
|
tuple[float, float, float, float] | None
|
Optional explicit |
None
|
padding_m
|
float
|
Extra padding on each side of the bounding box in metres
(used only when |
5.0
|
show_conductors
|
bool
|
If |
True
|
show_sources
|
bool
|
If |
True
|
annotate_electrodes
|
bool
|
If |
False
|
figsize
|
tuple[float, float]
|
Matplotlib figure size in inches (used when |
(8.0, 6.0)
|
ax
|
Optional pre-existing :class: |
None
|
|
title
|
str | None
|
Optional title override; default |
None
|
Returns:
| Type | Description |
|---|---|
Figure
|
|
Source code in src/groundfield/postprocess/geometry_plot.py
286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 | |
plot_world_3d ¶
plot_world_3d(
world: "World",
*,
show_conductors: bool = True,
show_sources: bool = True,
show_surface: bool = True,
figsize: tuple[float, float] = (9.0, 7.0),
elev: float = 22.0,
azim: float = -55.0,
title: str | None = None
)
3-D wireframe of a :class:World using mpl_toolkits.mplot3d.
The :math:z axis is inverted so that depth points
downwards on screen (groundfield convention: positive
:math:z is into the soil). A faint grey surface plane at
:math:z = 0 marks the soil surface.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
world
|
'World'
|
World to draw. |
required |
show_conductors
|
bool
|
See :func: |
True
|
show_sources
|
bool
|
See :func: |
True
|
show_surface
|
bool
|
If |
True
|
figsize
|
tuple[float, float]
|
Matplotlib figure size in inches. |
(9.0, 7.0)
|
elev
|
float
|
Initial viewing angles (passed to
:meth: |
22.0
|
azim
|
float
|
Initial viewing angles (passed to
:meth: |
22.0
|
title
|
str | None
|
Optional title override. |
None
|
Returns:
| Type | Description |
|---|---|
Figure
|
|
Source code in src/groundfield/postprocess/geometry_plot.py
522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 | |
world_bounds_3d ¶
Smallest axis-aligned :math:(x, y, z) bounding box of the world.
Inspects every electrode in :attr:World.electrodes and every
conductor endpoint in :attr:World.conductors, returning the
six bounds (x_min, x_max, y_min, y_max, z_min, z_max) in
metres.
The :math:(x, y) portion is consistent with
:func:groundfield.postprocess.plotting.world_bounds_xy but
additionally includes conductor endpoints — important for
overhead lines or measurement leads that extend well beyond
the electrode footprint. The :math:z portion uses
- :class:
RodElectrode:[position[2], position[2] + length](head + foot of the rod). - :class:
RingElectrode, :class:StripElectrode, :class:PolylineElectrode, :class:MeshElectrode, :class:GridMeshElectrode: the :math:zcoordinate(s) of the electrode (single buried depth). - Conductors:
min/maxover both endpoint :math:zvalues (catches overhead :math:z<0and buried :math:z>0).
Returns:
| Type | Description |
|---|---|
tuple
|
|
Source code in src/groundfield/postprocess/geometry_plot.py
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | |
Plotting API reference¶
plotting ¶
Matplotlib plots for potential distributions and profiles.
Functions:
| Name | Description |
|---|---|
plot_potential_contour |
Contour / pseudo-colour plot of the potential in a slice plane
( |
plot_potential_profile |
Line plot of the potential along an arbitrary direction for one or several \(z\) depths. |
plot_potential_radial |
Radial profile \(\varphi(r)\) starting from an electrode for several depths — the standard "how far does the trumpet reach?" plot. |
All functions return the ``matplotlib.figure.Figure`` they produce so |
|
that notebooks can post-process them |
|
plot_potential_contour ¶
plot_potential_contour(
result: "FieldResult",
*,
world: "World | None" = None,
plane: Literal["xy", "xz"] = "xy",
z: float = 0.0,
y: float = 0.0,
extent: tuple[float, float, float, float] | None = None,
n: int = 120,
frequency_index: int = 0,
levels: int = 20,
log: bool = False,
cmap: str = "viridis"
)
Contour plot of the potential in a slice plane.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
'FieldResult'
|
Result object from :meth: |
required |
world
|
'World | None'
|
Optional companion world; if given, electrodes are drawn into the plot. |
None
|
plane
|
Literal['xy', 'xz']
|
|
'xy'
|
z
|
float
|
Depth or \(y\) value of the slice plane in metres. |
0.0
|
y
|
float
|
Depth or \(y\) value of the slice plane in metres. |
0.0
|
extent
|
tuple[float, float, float, float] | None
|
|
None
|
n
|
int
|
Resolution per axis. |
120
|
frequency_index
|
int
|
Index into :attr: |
0
|
levels
|
int
|
Number of contour levels. |
20
|
log
|
bool
|
Logarithmic colour scale (better for fields that span several decades). |
False
|
cmap
|
str
|
Matplotlib colormap name. |
'viridis'
|
Returns:
| Name | Type | Description |
|---|---|---|
fig |
Figure
|
|
Source code in src/groundfield/postprocess/plotting.py
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 | |
plot_potential_profile ¶
plot_potential_profile(
result: "FieldResult",
*,
start: tuple[float, float, float],
direction: tuple[float, float, float] = (1.0, 0.0, 0.0),
distance: float = 30.0,
n: int = 200,
depths: Iterable[float] | None = None,
frequency_index: int = 0
)
Potential along an arbitrary line, optionally for several depths.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
'FieldResult'
|
Result object. |
required |
start
|
tuple[float, float, float]
|
Start point |
required |
direction
|
tuple[float, float, float]
|
Direction vector (will be normalised). |
(1.0, 0.0, 0.0)
|
distance
|
float
|
Length of the line in metres. |
30.0
|
n
|
int
|
Number of evaluation points. |
200
|
depths
|
Iterable[float] | None
|
List of depths \(z\) in metres. One curve per depth. |
None
|
frequency_index
|
int
|
Index into :attr: |
0
|
Returns:
| Name | Type | Description |
|---|---|---|
fig |
Figure
|
|
Source code in src/groundfield/postprocess/plotting.py
plot_potential_radial ¶
plot_potential_radial(
result: "FieldResult",
*,
around: str | tuple[float, float, float],
world: "World | None" = None,
r_max: float = 30.0,
n: int = 200,
depths: Iterable[float] = (0.0, 0.5, 1.0),
frequency_index: int = 0,
log_x: bool = False
)
Trumpet-shape decay around an electrode (or fixed point).
Evaluates \(\varphi(r)\) along the +x direction starting
at the connection point of the given electrode (or at a fixed
point). Multiple depths produce multiple curves — the typical
"surface vs. deeper soil" comparison.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
around
|
str | tuple[float, float, float]
|
Electrode name (resolved via |
required |
world
|
'World | None'
|
Required when |
None
|
r_max
|
float
|
Maximum radius in metres. |
30.0
|
n
|
int
|
Number of evaluation points. |
200
|
depths
|
Iterable[float]
|
\(z\) depths for the comparison curves. |
(0.0, 0.5, 1.0)
|
frequency_index
|
int
|
Index into :attr: |
0
|
log_x
|
bool
|
If |
False
|
Returns:
| Name | Type | Description |
|---|---|---|
fig |
Figure
|
|
Source code in src/groundfield/postprocess/plotting.py
plot_surface_potential ¶
plot_surface_potential(
result: "FieldResult",
world: "World",
*,
z: float = 0.0,
padding_m: float = 15.0,
n: int = 200,
extent: tuple[float, float, float, float] | None = None,
frequency_index: int = 0,
levels: int = 25,
log: bool = False,
symmetric: bool = False,
cmap: str = "viridis",
show_electrodes: bool = True,
show_contour_lines: bool = True,
figsize: tuple[float, float] = (8.5, 7.0),
title: str | None = None
)
Surface-potential pseudo-colour plot over the entire world.
Differs from :func:plot_potential_contour in two ways:
- The default
extentis derived from the world's electrode bounding box (via :func:world_bounds_xy) pluspadding_m, not from the discretised current point sources. This makes the plot naturally cover all buildings, cable cabinets and the substation in a TN network — including the "boundary regions" where the potential decays back to remote earth. - The plot is locked to a horizontal slice (always
plane="xy"); this is what typical calls the surface potential. Use :func:plot_potential_contourfor vertical slices.
Mathematical content
For each grid point \((x_i, y_j, z)\) the function evaluates
.. math::
\varphi(x_i, y_j, z) \;=\; \frac{1}{4\pi\sigma_0}
\sum_k \frac{I_k}{|\mathbf{r}_{ij} - \mathbf{r}_k|}
\;+\; \text{image-charge series}
via :meth:FieldResult.potential, which dispatches to the
correct Green's function for the soil model the result was
computed with (homogeneous / 2-layer / multi-layer). The
real part is plotted; for log=True the colour scale uses
\(\log_{10} |\varphi|\) so the boundary decay is visible across
several decades.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
'FieldResult'
|
Solver output from :meth: |
required |
world
|
'World'
|
Companion world used both to derive the plot extent and (optionally) to overlay electrodes. |
required |
z
|
float
|
Depth of the slice in metres. |
0.0
|
padding_m
|
float
|
Extra space added on each side of the electrode bounding box, in metres. Larger values let you see how the potential decays towards remote earth. |
15.0
|
n
|
int
|
Resolution per axis (the grid is |
200
|
extent
|
tuple[float, float, float, float] | None
|
Optional explicit |
None
|
frequency_index
|
int
|
Index into :attr: |
0
|
levels
|
int
|
Number of contour fill levels. |
25
|
log
|
bool
|
Logarithmic colour scale based on |
False
|
symmetric
|
bool
|
Use a symmetric |
False
|
cmap
|
str
|
Matplotlib colormap name. |
'viridis'
|
show_electrodes
|
bool
|
Draw the electrode geometry on top of the contour. |
True
|
show_contour_lines
|
bool
|
Draw black iso-potential lines on top of the fill. |
True
|
figsize
|
tuple[float, float]
|
Matplotlib figure size in inches. |
(8.5, 7.0)
|
title
|
str | None
|
Optional title override; if |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
fig |
Figure
|
|
Source code in src/groundfield/postprocess/plotting.py
406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 | |
world_bounds_xy ¶
Compute the horizontal bounding box of a world's electrodes.
Inspects every electrode in world.electrodes and returns
the smallest axis-aligned rectangle in the \((x, y)\) plane that
contains the geometry. Used by :func:plot_surface_potential as
the natural "the whole world" extent for a surface contour plot.
The footprint of each electrode kind is treated explicitly:
- :class:
RodElectrode— point atposition[:2]. - :class:
RingElectrode— square of side \(2r\) around the centre. - :class:
StripElectrode— bounding box ofstartandend. - :class:
MeshElectrode/ :class:GridMeshElectrode—cornertocorner + size.
Returns:
| Type | Description |
|---|---|
tuple
|
|