Changelog¶
All notable changes to groundfield are documented in this file.
The format is based on Keep a Changelog, and the project adheres to Semantic Versioning.
Change categories follow the Keep-a-Changelog vocabulary:
- Added — new features and public API.
- Changed — behaviour changes to existing public API.
- Deprecated — features that still work but will be removed.
- Removed — features taken out of the public API.
- Fixed — bug fixes.
- Security — vulnerability fixes.
- Docs — documentation-only changes.
- Internal — refactors, tests, packaging, CI; no observable behaviour change.
The backlog of ideas that are not yet scheduled is kept at the end of this
file under Roadmap. During regular work, add your entry under the
matching category in [Unreleased]; the release script
(scripts/release.py) moves the whole [Unreleased] block into a new
version section when a release is cut.
Unreleased¶
No changes yet.
0.14.0 — 2026-07-20¶
Added — layered Pollaczek/Sunde per-unit-length earth-return reference¶
New reference module groundfield.references.pollaczek with
pollaczek_self_impedance — the rigorous two-layer, buried-conductor
earth-return self impedance per unit length, in the Pollaczek/Sunde
spectral-integral form
Z' = j(ωμ₀/2π)[ln(2h/GMR) + 2∫₀^∞ e^(−2hλ)/(λ+u_eff(λ)) dλ] with the
two-layer effective vertical wavenumber
u_eff = u₁(u₂+u₁tanh(u₁h₁))/(u₁+u₂tanh(u₁h₁)), u_i=√(λ²+jωμ₀/ρ_i)
(Tagg/Sunde/Wait recursion, exposed as layered_effective_wavenumber).
Earth-return only (external inductance, no internal-conductor R/L — same
convention as references.earth_return.carson_self_impedance).
It is the layered, buried companion to the homogeneous Carson closed form:
it reduces to carson_self_impedance when ρ₁ = ρ₂ (reactance to < 0.1 %,
the earth-return resistance sitting just below ωμ₀/8 — Carson's
small-argument limit, which the exact Pollaczek form recovers as f → 0,
the deficit growing with frequency), collapses onto the upper layer as
h₁ → ∞ and the
lower layer as h₁ → 0, and its two-layer reactance lies within the
[Carson(ρ₁), Carson(ρ₂)] bracket — deep-layer dominated at low frequency
(a thin resistive topsoil is inductively "seen through"). Unlike the
assembled Sommerfeld/Pollaczek stack (earth_inductive_model="sommerfeld"),
which yields a PEEC segment inductance matrix, this returns a single
per-unit-length z'(f) that a reduced nodal network consumes directly and
that serves as an independent analytic reference for the assembled stack's
earth-return reactance. New tests/test_pollaczek_reference.py (50 tests:
homogeneous↔Carson, resistance-from-below, both h₁ limits, the Carson
bracket across 50 Hz – 1 kHz, the low-frequency deep-layer regime, the
wavenumber recursion, input guards), plus a loop-closure test in
tests/test_earth_return_benchmark.py tying the assembled Sommerfeld stack
to this reference and to the Tsiamitros 2005 Eq. (8) integral (all three
within ~1 % on the Case V two-layer reactance at 1 kHz).
The module also gains loop_impedance_conductor_earth — the full
conductor–earth loop impedance z'_loop = R'_internal + Z'_earth-return
of a buried earthing conductor (cable PEN, cable screen) with an ideal
(≈ 0 Ω) far-end earthing: the Pollaczek earth-return plus the conductor's own
IEC 60228 series resistance (its internal inductance folded into
gmr = 0.7788·a for a solid round conductor, or the screen radius for a thin
screen). This is the loop that governs earth-fault current sharing in a
grounding system tied together through NAYY PENs / NA2XS2Y screens — which
the bare earth-return impedance does not give on its own. Regression tests
cover the internal-plus-earth-return composition, real NAYY-PEN (150 mm² Al)
and NA2XS2Y-screen (25 mm² Cu) conductors at power frequency, and the
two-layer reactance shift.
And pollaczek_mutual_impedance — the mutual (off-diagonal)
earth-return coupling impedance
z'_M = j(ωμ₀/2π)[ln(D/d) + 2∫ e^(−(h_i+h_j)λ)/(λ+u_eff)·cos(λx) dλ] between
two parallel buried conductor–earth loops (horizontal separation x,
direct/mirror distances d = √(x²+(h_i−h_j)²), D = √(x²+(h_i+h_j)²)). It
carries no internal-conductor term (the loops are galvanically separate,
coupled only through the earth), so with ideal termination the coupling of a
parallel run of length ℓ is z'_M·ℓ; together with two self impedances it
forms the 2×2 loop impedance matrix of the coupled pair. It reduces to
carson_mutual_impedance and to the exact homogeneous Pollaczek mutual (the
K₀ form) in the homogeneous limit, its magnitude sits below the self and
decays with separation, and its two-layer reactance stays inside the Carson
bracket (deep-layer dominated at low frequency). Regression tests cover the
K₀ and Carson limits, the self-coincidence limit x → GMR, the
separation decay, the two-layer bracket, the conductor-above-conductor
separation = 0 case, and the 2×2 loop matrix of two parallel PENs.
Worked notebook notebooks/46_pollaczek_earth_return.ipynb plots z'(f)
self against Carson, the homogeneous convergence, the two-layer Carson
bracket across the layer thickness, the conductor–earth loop impedance
z'_loop(f) of the two real conductors split into internal / earth-return /
reactance, and the mutual coupling z'_M vs separation with the 2×2 loop
matrix. Exposed via groundfield.references (__all__, including
layered_effective_wavenumber, pollaczek_mutual_impedance and
loop_impedance_conductor_earth); ADR-0006 gains the closed per-unit-length
companion note.
0.13.0 — 2026-07-20¶
Added — Earth-return line-impedance benchmark (Carson)¶
New reference module groundfield.references.earth_return with Carson's
closed-form earth-return line impedance per unit length —
carson_self_impedance / carson_mutual_impedance (the equivalent-depth
reactance (ωμ₀/2π)·ln(D_e/GMR) with D_e = 658.87·√(ρ/f) and the
soil-independent resistance ωμ₀/8), plus carson_equivalent_depth and
carson_earth_return_resistance. New benchmark
tests/test_earth_return_benchmark.py validates groundfield's assembled
Sommerfeld/Pollaczek stack (earth_inductive_model="sommerfeld") against
it: the earth-return reactance — carrying the frequency- and
soil-dependent depth D_e, not just the geometric image at 2h —
matches Carson to about 1 %; the resistance converges to ωμ₀/8 from
below as the modelled line lengthens (the return current closes over
~one skin depth). 17 tests (reference-formula sanity, assembled self
reactance/resistance, the two-layer homogeneous limit + bracketing, the
layered-reflection half-space limits, and the quantitative two-layer
validation against Tsiamitros et al. across 50 Hz – 1 MHz — see the Fixed
entry below). Demo
notebook notebooks/45_earth_return_line_impedance.ipynb plots Z'(f)
(self and mutual) against Carson and the R' convergence, including the
normal-soil power-frequency corner (100 Ω·m, 50 Hz, where X' still
matches Carson to ~1 %) and a two-layer soil section: the layered
earth-return reactance reproduces the homogeneous result when ρ1 = ρ2,
lies between the two single-layer bounds, and — validated against
Tsiamitros et al. (IEEE Trans. PWRD 20(3), 2005, Eq. 8) — matches that
reference to a few percent in X' and to ≈ 0.003 in layer attribution
across an explicit 50 Hz – 1 MHz sweep, with the low-frequency
(≤ 1 kHz) regime highlighted: there the inductive return is deep-layer
dominated (a thin resistive topsoil is inductively 'seen through').
LayeredEarth accepts three or more layers unchanged. This makes the
inductive stack's earth-return impedance quotable. Exposed via
groundfield.references (__all__).
Fixed — two-layer inductive earth-return kernel¶
The layered Sommerfeld/Pollaczek earth-return correction for a conductor
buried in the upper layer of a two-layer earth used the homogeneous
single-air-image spectral form (λ/u₁)·Γ·e^{−u₁(z_i+z_j)}, valid only for a
half-space. It therefore mis-weighted the layers: at 10 kHz for the
Tsiamitros Case V soil it placed the earth-return reactance roughly halfway
between the two homogeneous bounds (attribution frac ≈ 0.51) when the
deep, conductive layer should dominate (frac ≈ 0.08). Replaced with the
exact four-term boundary-value solution for the spectral magnetic vector
potential — the up-/down-going reflected amplitudes from the 2×2 interface
system, in an overflow-stable form that factors out e^{−u₁d} — which
reduces to the homogeneous form as the interface deepens. Separately,
reflection_coefficient_layered (used for the overhead and n ≥ 3 paths)
was corrected to the standard bottom-up interface cascade; the previous
top-interface composition failed the h₁ → 0 half-space limit (it
under-weighted the deep layer). Validated against Tsiamitros et al.
(IEEE Trans. PWRD 20(3), 2005, Eq. 8): the assembled two-layer X' now
matches to 2–5 % (the residual being the finite-line offset shared with the
homogeneous case, not a layered error) and the layer attribution to ≈ 0.003
across 50 Hz – 1 MHz — including the ≤ 1 kHz regime central to
low-frequency analysis, where the return is deep-layer dominated. Galvanic /
DC two-layer results (grounding resistance, GPR, touch/step — e.g. the
IEEE Std 80 Grid 3 above) use a different path and were never affected.
Regression: tests/test_earth_return_benchmark.py
(test_reflection_layered_reduces_to_halfspace_limits,
test_two_layer_self_reactance_matches_eq8,
test_two_layer_attribution_matches_eq8_across_frequency at 100 Hz – 1 MHz,
and test_two_layer_frequency_regimes_low_vs_high pinning the ≤ 1 kHz
deep-layer-dominated behaviour).
Changed — default earth_inductive_model is now sommerfeld¶
The default earth-return model for inductive coupling flips from
perfect_mirror to sommerfeld (the rigorous Pollaczek stack,
validated against Carson above and against Pollaczek's analytic mutual in
tests/test_audit_inductive_stack.py). perfect_mirror — a fast,
frequency-independent electrostatic-image baseline whose sign is far from
the f < 1 kHz physics of buried conductors (audit finding F5) — is now
opt-in (earth_inductive_model="perfect_mirror"). This only affects
solves that have at least one distributed conductor with
inductance_model="neumann" at a non-zero frequency; purely galvanic /
DC solves and the resistive grounding path are unchanged. Quantitative
Z(f) results therefore now carry the earth-return resistance and the
equivalent-depth reactance by default. Regression:
tests/test_sommerfeld_inductance.py::test_default_engine_produces_sommerfeld_result.
Added — IEEE Std 80-2013 Annex H grounding-grid benchmark¶
New reference module groundfield.references.ieee80 with
grid_resistance_sverak (Sverak's grid resistance, IEEE Std 80-2000
Eq. 57 / 2013 Eq. 53). New benchmark tests/test_ieee80_benchmark.py
reproduces the standard's Annex H benchmark for all three grids
(grid current 744.8 A), solved with the mom backend — which recovers
the current distribution as the reference programs do:
- Grid 1 — bare grid, uniform 140 Ω·m soil (Table H.5);
- Grid 2 — grid + twenty 7.5 m rods, uniform soil (Table H.6);
- Grid 3 — Grid 2 geometry in a two-layer soil (ρ1 = 300 Ω·m, ρ2 = 100 Ω·m, h = 6.096 m), rods crossing the layer interface (Table H.7).
groundfield matches the published CDEGS grid resistance, GPR and touch
voltage to about 1 % on every grid (including two-layer Grid 3) and the
step voltage to a few percent. 8 tests total (Sverak sanity,
Sverak-vs-solve, and the Grid 1 / 2 / 3 reproduction, parametrised). Demo
notebook notebooks/43_ieee80_benchmark.ipynb lays groundfield next to
CDEGS / ETAP / WinIGS for all three grids, tabulates the relative error
and plots the grid-resistance comparison and the touch-voltage surface.
Exposed via groundfield.references (__all__).
Fixed — grid-crossing discretisation trap (silent ~2× resistance)¶
Mesh / grid electrodes place one point source at each segment midpoint.
When segment_length produced a half-integer number of segments per mesh
span (e.g. 2.0 m on a 7 m pitch → 3.5 segments/span), a longitudinal and
a transverse segment midpoint coincided on a grid crossing; their mutual
distance collapsed to the 1 mm singularity clamp and the image backend
returned a grid resistance ~2–3× too large for an otherwise valid
grid (ds = 2.0 → 1.97×, ds = 2.8 → 2.89×). The discretiser
(_grid_segments) now aligns the segment count per wire to a whole
multiple of the mesh-span count, so crossings fall on segment endpoints
and no two point sources coincide; the resistance is stable for any
segment_length (≤ 0.15 % from Sverak across the former trap values) and
the spurious singularity-clamp warning no longer fires for valid grids.
The result is never coarser than requested. New
tests/test_grid_crossing_discretization.py (16 tests: helper alignment,
the no-coincident-midpoint invariant, and resistance stability) plus demo
notebook notebooks/44_grid_discretization_robustness.ipynb.
Fixed — cross-layer two-layer solve exhausted memory on grids¶
The two-layer layered correction (ADR-0007) assembled its Hankel
contraction J0(λ·s) @ w_phi as one dense (n_pairs, n_lambda) Bessel
matrix. For a whole grounding grid n_pairs = n_seg² and the
oscillation-resolved λ-grid reaches ~5·10⁴ nodes, so the IEEE Std 80
Annex H Grid 3 (70 m grid + twenty cross-layer rods) tried to allocate
≈ 263 GiB and failed with MemoryError. The contraction
(groundfield.coupling.layered_green) is now row-blocked to a fixed
memory budget and, for large single-depth-pair blocks, interpolated
over a log-spaced distance grid — the same policy already used by
two_layer_probe_matrix, valid because the correction is smooth in the
horizontal distance (the singular 1/r part cancels in
Φ_lay − Φ_hom). Grid 3 now solves in a few seconds in well under 1 GB
with no measurable accuracy change. Pinned by
tests/test_layered_green_chunking.py (blocking is bit-for-bit; the
interpolation agrees with the exact scalar kernel to ≲ 1e-3).
Fixed — docs/api/geometry.md grid_mesh example used center=¶
The grid_mesh snippet passed center=, but GridMeshElectrode takes
corner= and forbids extra fields, so the example raised on execution.
Corrected to corner=(-10.0, -10.0, 0.5).
Added — StarElectrode (n-point star / Sternerder)¶
New electrode primitive StarElectrode (kind="star"): n_arms equal
horizontal wires of length arm_length radiate from a shared centre node at
one depth, uniformly spaced by 360°/n and optionally rotated by
orientation_deg; all arms form a single galvanic cluster. A new
_discretize_star (one strip-style chain per arm) serves the image /
integral backends, and the kind is wired through create_electrode, the
Electrode union, diagnostics, the 2-D/3-D geometry plots and the VTK
export. Validated against Dwight (1936, Eqs. 23–26): the image backend
matches references.dwight1936.n_point_star to ~1 % for n ∈ {3, 4, 6, 8}
(tests/test_dwight_references.py::test_image_n_point_star_vs_dwight,
previously skipped, now active). New tests/test_star_electrode.py — 5 tests:
geometry, the n_arms >= 2 guard, single-cluster monotone R(n_arms),
rotation invariance, and a plot/VTK smoke — plus demo notebook
notebooks/42_star_electrode.ipynb. Exported from groundfield and
groundfield.geometry (which now also re-exports the previously-missing
PolylineElectrode); documented with the Dwight formula in
docs/api/geometry.md.
Fixed — reject degenerate PolylineElectrode edges; warn on 2-vertex rings¶
PolylineElectrode now rejects zero-length edges during validation: a
consecutive duplicate vertex — or repeating the first vertex at the end of a
closed=True ring — raises ValueError instead of silently feeding a pair of
coincident segments into the reaction matrix (driven straight into the solver's
1 mm singularity clamp). The previous total-length check could not catch a
single degenerate edge. In addition, closed=True with only two vertices now
emits a UserWarning and is treated as an open wire (a ring needs at least
three vertices). New test module tests/test_polyline_electrode.py — 5 tests:
the three guard cases, valid open/closed construction, and a segment-length
convergence check for the polyline discretiser.
Docs — governing PDE and boundary conditions on the boundary page¶
docs/api/boundary.md gains a Mathematical / physical model section: the
quasi-static Laplace problem \(\nabla\cdot(\sigma\nabla\varphi)=0\), the
Dirichlet far-field / Neumann surface conditions and their image-charge
realisation, the potential datum, layer-interface continuity, and the
validity envelope. (The coupling page already carries the Neumann /
Carson / Sommerfeld governing equations, so it needed no change.)
0.12.2 — 2026-07-18¶
Fixed — MkDocs --strict build (API-reference docstring rendering)¶
mkdocs build --strict aborted with 51 warnings while the docs CI deployed
without --strict, so the published API reference silently rendered
incomplete parameter tables. Root causes fixed:
- Pydantic model fields in
sources.CurrentSource,geo.footprint.BuildingFootprint,geo.placement.OsmBuildingPlacementandgenerators.ortsnetz_builder.OrtsnetzLayoutwere documented under a NumPyParameterssection; griffe could not match them to a constructor signature. Moved to the correctAttributessection (the bulk of the warnings). OrtsnetzLayout.to_worldcarried a prose paragraph indented underParameters; griffe parsed the prose words as parameter names. Moved to aNotessection.- Demoted the un-resolvable
shapely.geometry.Polygon.minimum_rotated_rectanglecross-reference indocs/api/geo.mdandgeo.footprintto inline code.
mkdocs build --strict is now clean (0 warnings).
Fixed — silence Shapely's benign OMBR floating-point warning¶
BuildingFootprint.oriented_bounding_rectangle now wraps the Shapely
minimum_rotated_rectangle call in a targeted warnings filter. Some
Shapely / NumPy builds emit a benign RuntimeWarning (divide by zero /
invalid value encountered in oriented_envelope) while computing the OMBR
of an axis-aligned footprint even though the returned geometry is correct;
it no longer reaches callers or the pytest warning summary. New regression
test in tests/test_geo_footprint.py promotes that RuntimeWarning to an
error.
Changed — strict: true makes every MkDocs build fail on warnings¶
mkdocs.yml now sets strict: true, so mkdocs build, mkdocs serve and
the mkdocs gh-deploy step in .github/workflows/docs.yml all abort on any
warning (broken nav link, unresolved cross-reference, malformed docstring)
instead of shipping it silently to GitHub Pages.
Added — Changelog & Roadmap page on the documentation site¶
New docs/changelog.md embeds this CHANGELOG.md verbatim via
pymdownx.snippets (base_path set to the repository root) and is linked
from the nav. The release history and the Roadmap are now visible on the
published site.
Docs — pruned the stale Roadmap and refreshed the README¶
- Removed Roadmap bullets that are already implemented and released:
inductive coupling (ADR-0004),
rho-fvector fitting, OSM building-map ingest, and the Manhattan connection-routing switch. - Replaced the frozen "New in 0.7.0" README section with a pointer to the changelog plus a short list of current highlights.
0.12.1 — 2026-07-17¶
Fixed — geometry plotters render the polyline electrode kind¶
The matplotlib geometry helpers were not updated when 0.12.0 added
PolylineElectrode (the VTK export already was): plot_world /
plot_world_3d silently skipped a polygon foundation ring, and
world_bounds_xy / world_bounds_3d saw only its connection point — so
plot_surface_potential's auto-extent cropped the ring. All four now handle
the polyline kind, mirroring the strip / mesh branches and
io.vtk._polylines_for_electrode. A closed ring is drawn with its closing
edge; the bounds span every vertex. Backwards compatible (no other kind
changes). New regression tests in tests/test_geometry_plot.py.
0.12.0 — 2026-07-14¶
Added — foundation ring along the real building outline (PolylineElectrode)¶
Foundation electrodes could only be rectangles. Placing the ring on the oriented bounding rectangle (OMBR) of an OSM footprint has two consequences that only become visible at scale (AP1 catalogue, 196 real low-voltage networks):
- Coincident conductors. Terraced and semi-detached houses are
separate polygons sharing a party wall; their OMBRs share an edge.
L-/U-shaped buildings have OMBRs that overlap other buildings
outright. The resulting foundation strips fall on top of each other,
and the reaction-matrix assembly saturates their mutual coupling at
the 1 mm singularity clamp (the
clampwarning added in 0.11.0). Measured on the AP1 station set: 184 of 196 sites carry at least one such pair; at 40 % foundation penetration ~2–3 pairs per site are actually built. - Perimeter bias. The OMBR overstates the electrode of every non-rectangular building — median ratio 1.00, but 21 % of buildings exceed +20 % and the tail reaches ×7. On an L-shaped test building the OMBR ring under-states the spreading resistance by ~6 %.
New primitive PolylineElectrode (kind="polyline"): a buried
horizontal wire through an arbitrary vertex chain, closed=True making
it a ring. It carries the same per-segment
concrete_shell_coefficient_ohm_m as StripElectrode, and the shared
discretiser (solver.image._discretize_polyline) serves every backend.
FoundationElectrodeSpec.polygon_xy_m takes the outline as
(x, y) vertices relative to the site centre and overrides
size_m / size_xy_m / orientation_deg; style="ring" is required
(cross-braces are undefined on a general polygon). The concrete shell
(ADR-0012) uses the polygon perimeter for both paths — lumped
R_shell = C / L_perim into world.concrete_shell_corrections,
distributed C on every ring segment. Sub-0.5 m polygon edges are
merged away first (OSM footprints routinely carry centimetre-scale
edges that would trip the thin-wire guard).
Backwards compatible: without polygon_xy_m nothing changes.
A rectangular polygon reproduces the historic GridMeshElectrode ring
bit-identically (regression test at 1e-12), and one polygon
foundation is a single electrode with no internal bonds (the rotated
rectangular path needed four strips plus three bond conductors).
New regression suite tests/test_polygon_foundation.py (13 tests:
rectangle equivalence, shell bookkeeping on both paths, the
\(\rho_c = \rho_\text{soil}\) transparency identity, L-shape bias,
degenerate-edge cleanup, clamp-freedom for two houses sharing a wall).
0.11.0 — 2026-07-10¶
Fixed — discrete reciprocity and honest n ≥ 3 handling (audit 2026-07-08, WP-B3/WP-E)¶
- Multiport Z is exactly reciprocal. The grounding matrix paired
length-weighted excitation columns with an unweighted potential
average, breaking
Z[i, j] == Z[j, i]at the discretisation level for electrodes with unequal segment lengths (mesh electrodes withdx/nx != dy/ny, rods split at the layer interface). The row reduction now uses the Galerkin-consistent length-weighted average — symmetry to machine precision (regression test at 1e-13); results for uniform per-electrode segment lengths (every rod/ring/strip) are unchanged. cimandbemrejectn_layers >= 3. The historic complex-image kernel implemented an incomplete Green's function (single image family; missing the2·h_1-reflected families and the surface–interface multiple-reflection denominator) and systematically underestimated the layered correction. Both backends now raiseNotImplementedErrorpointing atmom_sommerfeld/fem; the corrected full kernel formula is documented insolver/_layered.py, and ADR-0002 carries an amendment revokingcimas the primary n ≥ 3 engine.bemdocstring no longer claims independence frommomfor n ≤ 2 (they assemble the identical matrix — measured 4e-16; audit finding P4). Genuine layered cross-validation:mom_sommerfeldandfem.
Changed — vector fitting on the conjugate-pair real basis (audit 2026-07-08, WP-B4)¶
- Complex pole pairs now carry genuinely complex residues. The
historic real-stacked least squares used one real unknown per
pole, silently removing the
Im(r)degree of freedom for both the residues and the σ-residues that steer the pole relocation — a deviation from the cited Gustavsen & Semlyen algorithm. Both LS systems now use the standard conjugate-pair real basis (1/(s-p) + 1/(s-p̄)andj/(s-p) - j/(s-p̄)with two real coefficients per pair); residues at conjugate poles are exact conjugates by construction (the post-hoc repair is gone). Regression test: a synthetic resonant Z withIm(r) = 40is now reproduced to rms 1e-6 relative (previously unfittable). - Convergence monitoring. The relocation loop tracks the
relative pole displacement, breaks early below 1e-8 and emits a
VectorFitConvergenceWarningwhen it is still above 1 % aftern_iteriterations; diagnostics exposed asVectorFitResult.n_iter_used/pole_shift. - Optional weighting.
vector_fit(..., weighting= "inverse_magnitude")weights samples by1/|Z|— the standard remedy when|Z|spans decades. - Dead frequencies are masked.
rho_f_from_field_resultused to injectZ = 0samples for frequencies where the electrode carries no current, silently pulling the fit through the origin; those samples are now excluded with a warning (all-dead input raises).
Fixed — layer-2 field evaluation and cross-layer plumbing (audit 2026-07-08, WP-D)¶
- Layer dispatch in the post-solve potential paths.
FieldResult.potentialand thesolve_mutual_*probe kernels applied the upper-layer Tagg/Sunde image series to every probe and source point; for layer-2 points the measured error was +7 % at z = 7 m and +25 % at z = 12 m (K = +0.818, h_1 = 5 m). Pairs involving layer 2 now go through the rigorous spectral kernel (newcoupling.layered_green.two_layer_probe_matrix, grouped by depth pair with one spectral-amplitude solve per group); pure upper-layer evaluation keeps the historic fast path bit-exact. Verified: potential continuity across the interface, homogeneous limit, exact agreement with the spectral reference at depth. - Interface-split discretiser (ADR-0007 step 1, previously documented but not implemented). Rod and sloped-strip segments are split at the layer interface so no segment straddles h_1 (historic mis-assignment: up to ds/2 of wire in the wrong layer); wired into every layered backend. A margin warning fires when the deepest segment ends within 2·segment_length of h_1 (the image-series self-terms degrade there).
momaccepts cross-layer worlds. The hard rejection is gone; the shared self-kernel factory dispatches to the ADR-0007 Sommerfeld path. mom is the engine that solves the per-segment current distribution — the audit's P5 uniform-leakage bias across the interface is now measurable inside the package (regression test pins mom below image_2layer for rho_2 > rho_1, and mom ≡ bem to 1e-6).- Cross-layer assembly grouped and cached (performance): the
ADR-0007 reaction-matrix correction ran one scalar Sommerfeld
BVP solve per matrix entry — O(n²) quadratures, prohibitively
slow after the WP-B1 oscillation-resolved grids. Off-diagonals
are now grouped by (z_i, z_j) with one spectral-amplitude solve
per group (
two_layer_layered_correction_group), identical diagonal entries are deduplicated, and the current-independent matrices are cached across the per-frequency/per-excitation kernel calls of one solve. Measured: 3-rod cross-layer world 33.7 s → 4.6 s on the audit sandbox (identical result). - New regression suite
tests/test_audit_layer2.py(8 tests: interface continuity, pinned layer-2 anchor values, homogeneous limit, split invariants, margin warning, mom cross-layer).
Fixed — inductive earth-return stack consolidated (audit 2026-07-08, WP-C)¶
The audit's headline physics finding: the inductive coupling stack
(earth_inductive_model) had three structural defects that made
quantitative Z(f) statements above ~100 Hz unreliable. All three are
fixed; the galvanic (resistive) results are unaffected.
sommerfeldis now a genuine Pollaczek kernel (ADR-0006 amendment). The historic kernel1/R + ∫Γ e^{-λ(z+z')}J0dropped the in-soil attenuatione^{-γR}of the primary term — which carries the buried-wire earth-return resistance — and producedRe Z' ≈ −ωμ0/8per metre (Carson's value with the wrong sign); it also applied the soil-side reflection+Γto overhead conductors (air side requires−Γ). The kernel now dispatches per segment pair (buried/overhead/mixed), keeps the mirror deduplication−1/R'and the direct attenuation(e^{-γR}−1)/Rin closed form, and integrates only the smooth remainder. Validated against Pollaczek's analytic K0 mutual (Im to ~1 %, Re to a few % chain truncation, correct positive sign) and the σ-limits: σ→0 → free space (no image), σ→∞ → screened (buried) / anti-parallel PEC image (overhead).- Spectral part interpolated over ρ instead of the historic
(16,16,nλ)tensor evaluation: distant segment pairs drop from ~2 s to milliseconds per pair (the smooth Hankel integral is evaluated on 24 log-spaced ρ-nodes and interpolated). carson_seriesmatrix is mesh-convergent (tiling convention): collinear same-wire segment pairs no longer receive a pseudo-mutual term (with the axial midpoint distance misread as Carson's perpendicular wire separation — the assembled correction grew ~linearly with the segment count; measured branch-current drift of ~2.5× between dsl = 50 m and 6.25 m, now < 1e-3). Near-parallel pairs on distinct axes tile the per-metre mutual by their axial overlap length at the perpendicular axis separation — the assembled block reproducesz'·ℓindependent of segmentation.- Behaviour change: results with
earth_inductive_modelset to"sommerfeld"or"carson_series"change (they were wrong);"perfect_mirror"remains bit-stable and stays the default, but is now documented as a frequency-independent baseline whose additive image is far from the f < 1 kHz physics of buried conductors — use"sommerfeld"for quantitative Z(f). - New regression suite
tests/test_audit_inductive_stack.py(Pollaczek K0 reference, σ-limit semantics, Carson mesh convergence at matrix and solver level).
Fixed — quadrature defects in the coupling kernels (audit 2026-07-08, WP-B)¶
layered_greenHankel grid resolves the Bessel oscillations. The cross-layer kernels (two_layer_real_space_kernel,two_layer_layered_correction_real_space) integrated the linear λ-region with a single fixed 96-node Gauss panel; for radial distancess >> min(h_1, depths)theJ0(λs)oscillations were unresolved — measured errors of 2.5 % at s = 100 m and 46 % at s = 200 m for cross-layer pairs (live in AP1 via the ADR-0007 path). The new_hankel_lambda_gridkeeps the historic single panel while it holds ≥ 8 nodes per oscillation (bit-compatible for small s), splits into one 16-node panel per J0 period beyond that, and caps the log-region below the first oscillation (lambda_break <= pi/(4 s)). Verified to ~1e-9 against adaptive scipy references up to s = 200 m.- Carson intermediate regime handles near-perpendicular
geometries.
_p_q_quadraturetruncated atmu_max = 30/pwith 64 fixed Gauss nodes; for buried-pair mutual geometries (theta -> 90°, small p) thecos(q·mu)oscillation was unresolved — measured errors up to −36 % in P and +67 % in Q (a = 0.44–1.6, θ ≈ 89°), squarely inside the AP1 parameter space. Oscillatory cases now use QUADPACK's Fourier integrator (quad(..., weight='cos')), which also covers the exacttheta = 90°limit (the former small-a fallback there was invalid for a > 0.25 and is gone). Verified to 1e-11 against an independent composite-panel/Euler-accelerated reference. - Large-a asymptote: the
a^-3term of Q was missing its1/sqrt(2)(~6e-4 absolute at a = 8); fortheta > 1.4 radthe asymptotic expansion degrades and now routes through the quadrature path. - New regression suite
tests/test_audit_quadratures.pypinning the audit's failing points with independent reference values; ADR-0005 regime section rewritten to describe the implemented quadrature (the previously documented σ-series never existed).
Changed — Tier-1 performance: one-shot multiport assembly (audit 2026-07-08, WP-F)¶
Profiling of AP1-sized worlds showed 97 % of Engine.solve wall time
inside _self_corrected_kernel, called once per electrode column and
per frequency — each call rebuilding the full (N, N) geometry
tensors. Three changes (ADR-0010 Tier 0a/1, results numerically
identical to FP rounding):
- Batched multiport-Z assembly.
_solve_cluster_currentspasses allN_aexcitation columns to the self-kernel in one call as an(n_segments, N_a)matrix; the O(N²) kernel tensors are built exactly once instead ofN_atimes. - Multiport cache across the frequency loop. The grounding
matrix Z is frequency-independent;
image,image_2layerandcimnow assemble it once per solve and reuse it for every frequency (newmultiport_cacheparameter). - Batched potential evaluation + multi-RHS solves. The
per-frequency segment potentials are evaluated in one stacked
kernel call for the whole frequency set; the real DC-path linear
systems solve
[b_re, b_im]with a single LU factorisation (also inmom/bem/mom_sommerfeldvia_galerkin_solve, and infem).
Measured (sandbox, 4 cores): 50-building TN world, ds = 0.5 m (N = 1476): 22.0 s → 0.18 s; 100 buildings (N = 2926): ≈ 2 min → 0.85 s; two-rod world with distributed inductive PEN at 7 frequencies: 7 ms. Solver output verified identical against the pre-change audit reference values (galvanic and inductive paths).
Added — guard rails against silently wrong results (audit 2026-07-08, WP-A)¶
The physics/numerics audit of 2026-07-08 (report in
audit-report-2026-07-08/) identified a class of configurations that
solved to physically wrong numbers without any diagnostic. This patch
converts them into hard errors or visible warnings:
- Surface-laid inductive conductors raise.
build_inductance_matrix(use_image=True)rejects segments lying in the soil-surface plane (2·|z_mid| <= wire_radius): the perfect-mirror image coincides with the segment and the partial self-inductance diverges (observed ~4.7 H instead of ~55 µH for a 25 m segment at z = 0, silently blocking the branch current). Bury the conductor or passuse_image=False. - Thin-wire hard guard. The discretisers raise
ValueErrorwhen the resulting segment length is<= wire_radius— there the analytic self-termln(L/a)flips sign and the solve returns nonsense currents. The advisory quality criterion (L >= 5a) remains a warning (see below). - Pre-flight diagnostics on every solve.
Engine.solvenow runscheck_segment_resolutionand emits each finding as aUserWarning(thin-wire ratio, electrode smaller than one segment, distributed-conductor ratio, segment budget). SeriesTruncationWarning. A truncated Tagg/Sunde series (image_max_termsreached before the tolerance) now emits a visible warning inimage_2layerandmominstead of a logger-only record;metadata['converged']is unchanged.- Concrete shells rejected loudly.
mom,cim,bem,mom_sommerfeldandfemraiseNotImplementedErrorfor worlds carryingconcrete_shell_coefficient_ohm_m != 0(ADR-0012) instead of silently solving the bare-metal problem — as the electrode documentation always promised. - Non-current sources warn. Every backend warns when it drops non-current sources; a world driven solely by voltage sources no longer solves to all-zero silently.
- Clamp diagnostic. The reaction-matrix assembly warns when distinct segments fall below the 1 mm singularity clamp (coincident/overlapping conductors).
Fixed¶
- Single-segment distributed conductors no longer vanish. A
distributed conductor with
discretize_segment_length >= length(n_segments == 1) was skipped by both the distributed topology builder and the lumped-branch builder and disappeared from the nodal system entirely (open circuit, measuredI = 0through the conductor). It now falls back to a lumped finite branch, bit-identical to the equivalent lumped conductor. - Cross-layer consistency of
cim,bemand themom_sommerfelddiagonal. For two-layer worlds with electrodes crossing the layer interface these backends silently applied the upper-layer Tagg/Sunde series. The shared self-kernel factory is now created withallow_cross_layer=True, dispatching the rigorous ADR-0007 cross-layer kernel — consistent withimage_2layer. Forn_layers >= 3cross-layer worlds,cim/bem/mom_sommerfeldnow raiseValueError(previously: warning followed by wrong physics).
Changed¶
- Series truncation criterion. The Tagg/Sunde stop test now uses
the geometric tail bound
|K|^(n+1)/(1-|K|) < tolinstead of the per-term weight|K|^n < tol, which underestimated the neglected remainder by1/(1-|K|)(a factor 17 at the AP1 corner soil |K| = 0.94). Applied consistently in the solver, the post-processing potential kernels (FieldResult.potential,solve_mutual_*) and theconvergedmetadata. Engine.image_max_termsdefault raised 100 → 300 so the AP1 corner contrast |K| = 0.94 converges under the new tail criterion (needs 277 terms). Same default increate_engine.- Engine truncation knobs reach all layered backends.
image_max_terms/image_series_tolare now forwarded tomom,cim,bemandmom_sommerfeld(previously hard-coded 100/200 terms in those paths).
Removed¶
_layered.image_series_offsets(dead code, never called by any backend): its higher-order expansion terms dropped the requiredΓ^{m+1}powers and would have produced wrong n ≥ 3 image series had it ever been consumed.
Internal¶
- New regression suite
tests/test_audit_guards.py(21 tests) covering every guard above end-to-end. tests/test_strip.py: the degenerate-mesh workaround comparison now asserts the thin-wireValueError(the workaround geometry is exactly what the guard rejects).
0.10.0 — 2026-06-28¶
Added — solve_mutual_field: surface potential at arbitrary points per node¶
solve_mutual_field(world, engine, anchors, field_points, …) returns the galvanic
Green-function matrix R of shape (M, nG) with R[i, j] = potential at
field_points[i] under unit excitation of cluster j (all other clusters
floating). It generalises the off-diagonal field evaluation of
solve_mutual_matrix to an arbitrary number of probe points (decoupled from
nG) and omits the self/diagonal term. The reaction matrix and per-excitation
leakage columns are assembled once (as in solve_mutual_matrix); only the
probe evaluation differs (R = K_probe @ T). Reuses the existing
image/image_2layer kernels unchanged; results are bit-identical to the
per-excitation Engine.solve construction (verified max|ΔR| < 1e-9, incl. the
ADR-0007 cross-layer path). Building block for frequency-dependent surface
potentials by superposition: phi(points, f) = R @ I_leak(f) with I_leak(f)
from the reduced network (Y_L(f) + Y_G) u = i — the expensive 3D part is computed
once (f-independent), the frequency sweep is cheap. New public API
groundfield.solve_mutual_field; purely additive, solve_mutual_matrix untouched.
0.9.0 — 2026-06-19¶
Added — solve_mutual_matrix: full mutual grounding-impedance matrix in one assembly¶
solve_mutual_matrix(world, engine, anchors, probe_points, …) returns the full
nG×nG mutual grounding-impedance matrix Z_G from a single reaction-matrix
assembly, instead of nG separate Engine.solve calls. Diagonal = cluster
self-impedance (as FieldResult.cluster_impedance); off-diagonal [i, j] =
potential at probe_points[i] under unit excitation of cluster j (as
FieldResult.potential); then 0.5·(Z + Zᵀ). The segment reaction matrix and the
probe-point field kernel are each built once and reused across all excitations
(Phi = K @ T); per-cluster current distributions use the existing
_solve_cluster_currents machinery, so multi-electrode clusters are handled
exactly. Reuses the existing image / image_2layer kernels (incl. the ADR-0007
cross-layer path) unchanged — results are bit-identical to the per-excitation
construction (verified max|ΔZ| ≈ 1e-13, pure BLAS-reordering residual) and
~20–40× faster at nG ≈ 25–40. New module solver/mutual.py, exported as
groundfield.solve_mutual_matrix. Purely additive; existing solve paths unchanged.
0.8.1 — 2026-06-19¶
Fixed — image_2layer cross-layer path ~20× faster (cache Gauss–Legendre nodes)¶
The ADR-0007 Sommerfeld cross-layer fallback (used when an electrode crosses
the layer interface, e.g. a 9 m rod with h_1 = 5 m) recomputed the
Gauss–Legendre quadrature nodes/weights
(numpy.polynomial.legendre.leggauss, an O(n³) eigenvalue problem) once per
segment pair for the fixed orders n_log = 32 / n_lin = 96. Profiling
showed ~93 % of the solve in leggauss/legval. The nodes are now cached
per order (coupling/layered_green.py::_leggauss_cached); results are
bit-identical (the quadrature itself is unchanged). A single 9 m rod over a
1000/30 Ω·m, h_1 = 5 m two-layer soil drops from ~3.9 s to ~0.20 s
(18 segments) and ~19.9 s to ~0.93 s (45 segments); the cost scales O(n²) in
the segment count, so larger geometries benefit even more. The within-layer
image-series path is unaffected.
0.8.0 — 2026-06-18¶
Added — create_engine: configurable image-series convergence (image_max_terms / image_series_tol)¶
create_engine() and the underlying Engine gain two optional parameters,
image_max_terms (default 100) and image_series_tol (default 1e-6),
forwarded to the image_2layer / image_nlayer backends. They control the
truncation of the Tagg/Sunde image-charge series (ADR-0001). For high layer
contrast — |K| close to 1, e.g. ρ₁ = 1000 Ω·m over ρ₂ = 30 Ω·m
(|K| ≈ 0.94) — the series needs ≈ 230 terms to reach the tolerance; the
previously hard-coded cap of 100 left such cases unconverged
(metadata["converged"] == False) with a "Result may be inaccurate" warning
and a truncation error up to a few percent. Callers can now raise the cap
(e.g. create_engine(backend="image", image_max_terms=500)). Defaults are
unchanged, so existing behaviour and all prior results are bit-identical.
Regression test in tests/test_two_layer.py
(test_two_layer_image_max_terms_raises_convergence_cap).
Added — OrtsnetzLayout.connect_all_buildings: guaranteed LV coverage¶
OrtsnetzLayout.connect_all_buildings() connects every house to the LV
network. connect_buildings() only taps each house to the nearest cable
with a short straight stub and skips houses whose stub would cross another
footprint (typically houses with no cable in reach). The new method closes
that gap so the layout models a real LV network in which every customer is
served: after the initial tap pass it routes obstacle-avoiding PEN
laterals (full Manhattan A*) from the nearest substation/KVS node to a
free point just outside each still-unconnected house, then re-taps —
re-tapping frequently connects neighbouring houses to the same new lateral.
Laterals only originate from nodes galvanically connected to the substation
(new helper _anchors_connected_to_substation), so a stranded KVS never
seeds an isolated sub-network. Houses enclosed by other footprints on every
side are reported in the returned LateralCoverageResult.islands instead of
raising. The box-model free-point search lives in the new helper
_free_point_near_house.
0.7.0 — 2026-05-26¶
Added — OrtsnetzLayout: imperative TN-Ortsnetz builder¶
groundfield.generators.OrtsnetzLayout is the new entry point for
building a single deterministic TN-Ortsnetz piece by piece, in
the order an engineer would draw it on a plan: ingest building
footprints from OpenStreetMap, drop the substation, drop one or
more KVS, route PEN cables in strict Manhattan geometry around
the foundations, connect every house to its closest cable, and
optionally drop a Hilfserder + Spannungssonde for a simulated
fall-of-potential measurement. The whole pipeline is exposed as
one-call-per-step methods on the layout so an AP1 sweep over many
GPS-defined Ortsnetze reduces to a single Python loop.
Key API surface (all on :class:OrtsnetzLayout):
from_footprints(...)/from_osm(center_lat_deg, center_lon_deg, radius_m, substation_xy=..., kvs_xys=..., substation_lat_lon=..., kvs_lat_lons=...)— two constructors.from_osmruns :func:groundfield.geo.osm.query_and_projectand pre-places the substation and zero-or-more KVS anchors in one call. WGS84 and local ENU metres are interchangeable on every anchor-placing method.add_kvs(position_xy=... | position_lat_lon=...)— drop a cable cabinet at user coordinates.add_pen_cable(start=anchor, end=anchor | end_xy=... | end_lat_lon=..., min_segment_length_m=..., clearance_m=..., escape_radius_m=...)— route a PEN cable from any anchor to another anchor or a free coordinate using a 4-connected Manhattan-grid A* that avoids every building's bounding rectangle.connect_buildings()— attach every house to the closest PEN cable via a short axis-aligned stub (one or two segments); unreachable houses emit a :class:UserWarningand stay unconnected so the user can add another cable.add_auxiliary_electrode(distance_m=..., direction_deg=..., grounding=..., length_m=...)— drop the Hilfserder at a chosen radius / bearing from the substation. Default geometry: a 3 × 0.5 m rod triangle (0.5 m side), parallel-bonded, matching field-deployment practice. Override the geometry via thegrounding=GroundingSystemSpec(...)kwarg, or collapse to a single 1.5 m rod via thelength_m=legacy keyword. Each call replaces the placement so sweeping the aux distance is a one-line re-call.- `add_voltage_probe(inline_fraction=... | perpendicular_fraction=...
- perpendicular_side= | position_xy=... | position_lat_lon=...)` — drop the Spannungssonde at 50 % of the Hilfserder distance, inline or perpendicular (left = 90° CCW, right = 90° CW). The probe is a pure sampling point: no rod is materialised in the world, so the ideal-voltmeter assumption is preserved.
foundation_mask(penetration, salt=0)— deterministic per-house :class:boollist derived from a stable MD5 hash of each footprint'sosm_id. The same(penetration, salt)always returns the same mask, and the mask is nested inpenetration— every house with a foundation at :math:p_1also has one at any :math:p_2 > p_1.saltlets the user draw independent Monte-Carlo realisations at the same nominal penetration.to_world(foundation_mask=..., source_magnitude_A=..., ...)— materialise the layout into a full :class:groundfield.World: substation ring + 4 rods, KVS rods, per-house foundation electrodes (skipped wherefoundation_mask[i] == False), tiny PEN-junction rods at every cable corner and tap point so the trunk physically follows the Manhattan polyline, PEN conductors between junctions, service drops, a current source at the substation cluster, and — when a Hilfserder is configured — an opposite-sign return current at the aux anchor that physically closes the measurement loop.measured_grounding_impedance(result, probe_xy=..., source_magnitude_A=...)— simulated meter reading :math:(\varphi_\text{sub} - \varphi_\text{probe}) / I_\text{src}. Theprobe_xykeyword lets the caller sample any probe position from a single solve, enabling the inline-vs-perpendicular comparison without re-running the solver.verify_current_balance(result, tolerance=0.02)— Kirchhoff plausibility check that aggregates leakage currents on both sides of the loop (positive side = every non-aux electrode; auxiliary side = every electrode prefixed with the aux name) and asserts they sum to :math:\pm I_\text{src}within tolerance. Catches the typical "stale-kernel" pitfall in a notebook workflow.plot()/plot_surface_potential(result, world, two_slope=True, symmetric=False, log=False, ...)— interactive layout plot and surface-potential pseudo-colour plot. Both overlay the substation marker, Hilfserder marker (purple triangle with dotted return-current line), and Spannungssonde marker (lime star); the surface plot defaults to a :class:matplotlib.colors.TwoSlopeNormcentred at zero so the deep Hilfserder trough and the small substation + foundation trumpet are both visible at full colour resolution.lat_lon_to_xy()/xy_to_lat_lon()/projector— round-trip helpers for plot overlays and external cross-checks.triangle_rod_grounding(rod_length_m=0.5, triangle_side_m=0.5)— free helper returning a :class:GroundingSystemSpecfor the default Hilfserder geometry; reusable for any anchor that needs a rod-bundle.
Snapshot data classes (Pydantic, JSON round-trippable):
:class:KvsPlacement, :class:AuxiliaryElectrodePlacement (now
holds a full :class:GroundingSystemSpec),
:class:VoltageProbePlacement (pure sampling point),
:class:PenCable, :class:BuildingConnection. Re-exported via
:mod:groundfield.generators and the top-level
:mod:groundfield package.
Added — Manhattan-routed PEN cable router¶
groundfield.generators.manhattan_routing is the new pure-Python
helper module behind OrtsnetzLayout.add_pen_cable. Key exports:
- :class:
ObstacleBox— axis-aligned bounding box used as a routing obstacle. Inflatable, with axis-aligned-segment overlap test that ignores boundary touches. - :func:
route_manhattan(start, end, obstacles, grid_size, clearance_m, escape_radius_m, max_iterations)— 4-connected A on a regular Manhattan grid of cell sizegrid_size. Obstacles are inflated byclearance_m. Theescape_radius_mknob defines a disk around both start and end inside which obstacle blocking is not* enforced — the "release valve" for real-OSM extracts where the substation lat/lon lands inside or very near a foundation polygon.None(default) maps togrid_size(one cell of slack);0.0restores the strict pre-v0.7 behaviour. On failure the :class:RuntimeErrornames which anchor is enclosed and reports how many of the four immediate neighbour cells are free, with concrete suggestions ("raise escape_radius_m to e.g. X", "reduce clearance_m to Y", "move the offending anchor"). - :func:
segment_intersects_boxand :func:merge_collinear_waypoints— supporting helpers.
Pure Python; no :mod:shapely dependency at import time.
Added — TN-Ortsnetz radial-trunk PEN topology¶
groundfield.generators.pen_topology adds a second PEN backbone
topology for :class:TnNetworkGenerator alongside the legacy
star-KVS layout: :class:RadialTrunkTopology. The substation
feeds N radial feeders (default 4, evenly spaced) each with a
finite slot budget per source; once the substation's slots are
exhausted a KVS is inserted along the trunk axis and hosts the
next batch of building taps. Buildings are assigned to the
angularly-closest feeder; buildings behind the substation or
beyond max_feeder_length_m are dropped with a
:class:UserWarning. :class:TnNetworkConfig.pen_topology is a
new discriminated-union field defaulting to
:class:StarKvsTopology (legacy bit-identical), with
:class:RadialTrunkTopology as the opt-in.
Fixed — closed measurement loop¶
The v0.7 image solver (:mod:groundfield.solver.image) only
consumes :attr:Source.attached_to; :attr:Source.return_to is
silently ignored. Before the fix, add_auxiliary_electrode
plus an inline-probe reading produced a surface-potential plot
in which the Hilfserder showed no gradient at all — the engine
treated the entire test current as returning through the
remote-earth boundary. :meth:OrtsnetzLayout.to_world now
explicitly closes the loop by adding a second
:class:CurrentSource with phase_deg=180 at the aux anchor;
the net injected charge is zero and the potential field develops
the expected dipole pattern (positive trumpet at the substation,
negative trumpet at the Hilfserder). Source.return_to on the
substation-side source is still recorded for documentation and
for future solver upgrades.
Tests¶
tests/test_pen_topology.py— 18 regression tests covering :class:RadialTrunkTopologyvalidation, assignment, KVS planning, end-to-end :meth:TnNetworkGenerator.buildand JSON round-trip.tests/test_ortsnetz_builder.py— 67 regression tests covering the Manhattan routing primitives (ObstacleBox,segment_intersects_box,merge_collinear_waypoints,route_manhattanwith strict / default / generousescape_radius_mmodes, diagnostic error message); KVS / PEN cable placement and validation; the building → cable connection algorithm (single-cable + closest-cable + warn-and-skip + missing-cable error);to_worldhonouring thefoundation_maskand wiring the auxiliary return source; the GPS / OSM convenience layer (from_osm,lat_lon_to_xy,xy_to_lat_lon, lat/lon overloads); the foundation-mask helper (determinism, nestedness inp, fraction-tracking, salt-independence, bounds, osm-id fallback); the full measurement workflow (Hilfserder placement modes, voltage probe geometries, theprobe_xyoverride,measured_grounding_impedance,verify_current_balance); the surface-potential plot in all three colour-axis modes (two_slope,symmetric,log); and an end-to-end physics check that the engine sees the expected positive-side / aux-side current split within 2 % of the test current.
Notebooks¶
notebooks/35_radial_trunk_pen_topology.ipynb— geometry-only walk-through of a bus-topology TN-Ortsnetz (substation → one PEN main cable → 20 houses → KVS) with strict Manhattan PEN routing.notebooks/36_ortsnetz_builder.ipynb— end-to-end :class:OrtsnetzLayoutworkflow on 20 synthetic OSM-style footprints plus a real OSM extract of Mulmke (Heudeber / Nordharz; 51.9136 °N, 10.8432 °E) with two KVS along the K1328. Closes with a one-call-per-step usability table.notebooks/37_foundation_penetration_sweep.ipynb— the Mulmke layout with a reproducible foundation-electrode penetration sweep, a fall-of-potential measurement (Hilfserder + Spannungssonde + Kirchhoff balance check), surface-potential galleries per penetration value and per Hilfserder distance using the :class:TwoSlopeNorm-default plot, and an inline-vs- perpendicular probe comparison swept across Hilfserder distances from a single solve perD.
Added (Audit pass 7 — implemented 2026-05-18)¶
groundfield.io.groundinsight.EvaluateSpecError— new typed exception (subclass of :class:ValueError) raised by :func:evaluate_specon malformed spec input. The class is re-exported at the top level as :data:groundfield.EvaluateSpecErrorand listed in :data:groundfield.__all__. Legacyexcept ValueErrorblocks keep working unchanged because the new class inherits from :class:ValueError; downstreamgroundinsightconsumers can now catch the typed exception without greppingstr(exc)(closes the seventh 2026-05-18 audit pass "no named class for evaluate_spec failures" bullet).groundfield.World.reset_concrete_corrections()— explicit cleanup helper for the ADR-0012 V1 concrete-shell registry (world.py). Re-using a single :class:Worldacross multiple :meth:TnNetworkGenerator.buildcalls — the canonical AP1 Monte-Carlo pattern overconcrete_rho_ohm_m— previously accumulated stale entries in the public :attr:World.concrete_shell_correctionsdict. The new method clears the registry and returns a shallow copy of the dropped entries, so callers can inspect or log them. Calling on a fresh world is an idempotent no-op. Closes the seventh 2026-05-18 audit pass "registry has no documented cleanup contract" bullet.OsmBuildingPlacement.footprint_at(i, strict=...)— opt-in defensive bounds check (geo/placement.py). Defaultstrict=Falsepreserves the v0.6.0 "out-of-range →None" contract that :meth:TnNetworkGenerator.buildrelies on;strict=Trueraises a self-describing :class:IndexErrorthat names the placement, the requested index, the filtered footprint count (aftermin_area_m2) and the raw list length. The placement also exposes a new :attr:OsmBuildingPlacement.n_footprintsproperty identical tolen(placement)so consumers can pre-flightiwithout invoking the dunder. Closes the seventh 2026-05-18 audit pass "footprint_at lacks defensive bounds checking" bullet.groundfield.geo.osm.query_buildings(..., max_retries=N)andquery_and_project(..., max_retries=N)— promote the previously hard-coded1retry to a keyword (geo/osm.py). Defaults to1, matching the historic v0.6.0 contract; users on flaky cellular / VPN links can raise it, CI pipelines that prefer fail-fast semantics can set0.max_retries < 0is rejected at the API boundary with a clear :class:ValueError. The module docstring and the :func:_post_overpassinternal docstring are updated to match. Closes the seventh 2026-05-18 audit pass "retry count hardcoded as 1" bullet.
Tests (Audit pass 7 — implemented 2026-05-18)¶
tests/test_audit_pass7_fixes.py— 14 regression tests mapped 1:1 to the user-visible bullets above: :class:EvaluateSpecErroris a :class:ValueErrorsubclass, is raised on missing / empty / unknown-symbol formulas, and is reachable from the top level (4 tests); :meth:World.reset_concrete_correctionsclears the registry, returns a defensive copy of the previous state, and is idempotent on a fresh world (3 tests); :meth:OsmBuildingPlacement.footprint_atkeeps the historicNonedefault, raises a structured :class:IndexErrorunderstrict=True(negative and out-of-range indices), the message carries the expected diagnostic tokens, and the new :attr:n_footprintsproperty trackslen(placement)aftermin_area_m2filtering (4 tests); :func:query_buildingsaccepts and forwardsmax_retries, rejects negative values, defaults to1, and :func:query_and_projectexposes the same keyword on its signature (3 tests).
Notebooks (Audit pass 7 — implemented 2026-05-18)¶
notebooks/34_audit_pass7_fixes.ipynb— narrative walk-through of the four behaviour changes, one short section per fix. The notebook runs offline (no Overpass call) and uses synthetic footprints for the placement demo.
Docs (Audit pass 7 — implemented 2026-05-18)¶
docs/api/world.md,docs/api/sources.md,docs/api/boundary.md,docs/api/validation.md,docs/api/references.md— the five long-standing missing API-reference pages (flagged from pass 1 through pass 6) now exist as mkdocstrings stubs that render the module-level public surface. Wired intomkdocs.ymlnav under API reference. Closes the seven-pass-in-a-row backlog bullet.docs/api/index.md— the API-reference index now lists theGeo / OSMpage that was added in 0.6.0 but missing from the rendered index, alongside the five newly-added pages.docs/api/io.md— adds an "Errors" subsection that documents the new :class:EvaluateSpecErrorclass and the typed-exception contract for downstreamgroundinsightconsumers.
Fixed (Backlog — seventh 2026-05-18 review pass)¶
The seventh audit pass was run on 2026-05-18, three days after the
0.6.0release (geosubpackage + concrete-encasement model). The entries below capture new findings that emerged either as a side-effect of the 0.6.0 surface (World.concrete_shell_correctionsregistry,OsmBuildingPlacement.footprint_at,geo.osmretry contract) or that have still not been addressed through six previous audit passes (missing API pages, ADR cross-references). Only the CHANGELOG is edited in this pass; no program code is touched.
docs/api/sources.md,boundary.md,references.md,validation.md,world.mdstill missing — seventh pass in a row. The0.6.0release addeddocs/api/geo.mdfor the new OSM subpackage but did not address the five long-standing missing pages flagged in pass 1 through pass 6.groundfield.world.World,groundfield.solver.sources,groundfield.solver.boundaryandgroundfield.validationare all part of the public__all__ofsrc/groundfield/__init__.py(confirmed on 2026-05-18) and the user has no rendered API documentation for any of them. The six-pass-in-a-row finding is therefore re-elevated; without a forcing CI gate it will reappear in the eighth pass.docs/api/index.mddoes not list the newGeo / OSMpage. The rendered API-reference index enumerates Soil, Geometry, Conductors, Solver, Coupling, Postprocess, Diagnostics, IO and Generators but is missing theGeo / OSMentry that the 0.6.0mkdocs.ymlnav added (api/geo.md). Drift between API overview prose and the rendered nav appeared on the same release that landed the subpackage — straightforward bullet-list addition.World.concrete_shell_correctionsregistry has no documented cleanup contract. Re-using a single :class:Worldacross multiple :meth:TnNetworkGenerator.buildcalls (the canonical parameter-sweep pattern) accumulates entries from previous builds in the same dict; the0.6.0release records the registry as a publicdict[str, float]but offers neither aworld.reset_concrete_corrections()helper nor a per-build invalidation hook on the generator side. AP1 Monte-Carlo studies that flipconcrete_rho_ohm_mper realisation will carry stale shell resistances from earlier samples into later ones unless the user knows to clear the dict by hand.OsmBuildingPlacement.footprint_at(i)lacks defensive bounds checking. The current implementation indexesself.footprints[i]directly; foriout of range CPython raises a bareIndexErrorwhose message does not name the placement, the requested index, or the available range. Wrap with an explicitraise IndexError(f"OsmBuildingPlacement: requested index {i} but only {n} footprints are available; check placement.n_buildings.")so the AP1 generator pipeline produces a self-describing failure instead of an opaque traceback atTnNetworkGenerator.buildtime.geo.osm.query_buildingsretry count is hardcoded as1. The docstring promises "one retry on429/504with exponential backoff" but no parameter is exposed for users working over flaky cellular / VPN links. Promote the retry count to amax_retries: int = 1keyword on :func:query_buildings/ :func:query_and_project; the default preserves current behaviour.groundfield.io.groundinsight.evaluate_specValueError class is not re-exported. Pass-5 widenedevaluate_specto raiseValueErroron non-canonical free symbols (and the Pass-5 Fixed block lists this as a public-surface improvement), but downstream code that wants to catch the new exception has no named class — it has to compare againststr(exc)substrings or fall back to the broadValueErrorparent. Define anEvaluateSpecError(ValueError)subclass ingroundfield.io.groundinsightand add it to the module__all__; follow-ongroundinsightconsumers will benefit from the typed surface.Conductor.lumped_series_resistance_ohmandStripElectrode.concrete_shell_coefficient_ohm_mare not JSON-round-tripped intests/test_concrete_encasement.py. The new test file covers the V2 Sunde-shell closed-form match, the V1 registry, the discriminator and the end-to-end OSM smoke, but does not assert that aWorldwith a populatedConductor.lumped_series_resistance_ohmorStripElectrode.concrete_shell_coefficient_ohm_msurvivesWorld.model_dump_json()→World.model_validate_json(...). Add a single test case so the persistence contract is locked in before users start sharing concrete-encasement-bearing world files across runs.- ADR cross-references are one-directional.
ADR-0011(OSM building footprints) referencesADR-0009(World generators) andADR-0012(Concrete encasement) referencesADR-0003(Distributed conductor) — but the target ADRs carry no reciprocal "See also" link. A maintainer reading ADR-0003 today sees no breadcrumb to the V1 lumped path that reuses its framework; same for ADR-0009 → ADR-0011. Bidirectional ADR linking is a one-line edit per file and prevents future architectural drift. docs/concepts.mdis silent on the lumped vs. distributed Sunde-shell trade-off. The 0.6.0 release added two implementation variants (concrete_model="lumped"vs."distributed") with materially different physics (V1 records the total shell resistance and injects a series resistance on the PEN drop; V2 augments the post-kernel diagonal per segment). The user-facingconcepts.mdpage describes neither variant nor the regime where each is preferred. AP1 readers reach the ADR-0012 file as a fallback, but the canonical "Concepts" page should carry a short decision-guide table.README.mddoes not mention0.6.0features. The README Roadmap and Features sections were not updated alongside the 0.6.0 release — neither thegeoextra, theBuildingFootprint/OsmBuildingPlacementexports nor the concrete-encasement model appear in the project description. AP1 reviewers landing on the GitHub page have no signal that the OSM and concrete-encasement features exist.- Notebooks
30..33are not wired into the docs nav. All four audit / OSM / concrete-encasement notebooks exist on disk and are referenced in the 0.6.0 CHANGELOG "Docs" block, butmkdocs.ymlonly loadsdocs/examples/*.md. Themkdocs-jupyterplugin is enabled (plugins: mkdocs-jupyterblock,execute: false), so adding aNotebooks:nav section under Examples is a four-line edit. Without it the notebook walkthroughs that the 0.6.0 changelog calls out are invisible to docs-site readers.
Docs (Backlog — seventh 2026-05-18 review pass)¶
docs/index.mdMigration callout for0.6.0. A short "Migrating from 0.5.x" section under the index — listing the new optionalgeoextra, the additiveconcrete_rho_ohm_mfield on :class:FoundationElectrodeSpec, and the no-op default — would spare users a CHANGELOG dive on upgrade. Mirror the pattern that the 0.5 release introduced for the discriminatedSourceunion.docs/quickstart.mddoes not show the new exports. The page still ends atgf.create_engine(...).solve(world); the 0.6.0 exportsgf.BuildingFootprint,gf.OsmBuildingPlacement,gf.Projector,gf.query_buildings,gf.query_and_project,gf.OverpassErrorare not introduced anywhere in the quickstart flow. A short "Optional: OSM-driven footprints" appendix would close the gap.docs/performance.mdhas nogeocost section. The on-disk Overpass cache, the projection cost and the OMBR reduction are all new performance-relevant code paths in0.6.0. AP1 readers running long Monte-Carlo sweeps over 10–100 buildings need an order-of-magnitude estimate (current local: ~ms per footprint, network: ~hundreds of ms on cold cache) and an explicit pointer at the cache directory to clear between experiments.docs/api/geo.mdlacks the offline-fallback contract. The 0.6.0 page describes the live Overpass path and the cache layer but does not state the contract of the purely offline variant — the path throughBuildingFootprint+ manual centroid placement that does not importrequests/pyproj/shapely. A "Working offline" subsection that enumerates the no-dependency code path keeps the AP1 reproducible- computation guarantee visible.docs/adr/0003-distributed-conductor-model.mdanddocs/adr/0009-world-generators.mddo not yet referenceADR-0011andADR-0012. Same finding as the bidirectional ADR cross-reference Fixed-backlog entry above.
Tests (Backlog — seventh 2026-05-18 review pass)¶
- No JSON round-trip test for
Conductor.lumped_series_resistance_ohm. Tied to the ADR-0012 V1 persistence-contract Fixed-backlog entry above. - No JSON round-trip test for
StripElectrode.concrete_shell_coefficient_ohm_m. Tied to the ADR-0012 V2 persistence-contract Fixed-backlog entry above. - No regression test for
World.concrete_shell_correctionscleanup contract. Once a per-buildreset_concrete_corrections()helper (or aTnNetworkGenerator.build(reset_shell_corrections=True)keyword) lands, pin the contract with a two-build test that asserts the second build sees a clean registry. - No regression test for
OsmBuildingPlacement.footprint_at(i)out-of-range error message. Once the bounds check lands, pin the structured error string so the message does not regress to a bareIndexError. - No timing test for the
geo.osmcache. A trivial fixture that mocks the Overpass_postboundary, asserts a single POST per(origin, radius)tuple over fivequery_buildingsinvocations and checks the cache directory carries exactly one on-disk file is a one-screen test that would lock in the ADR-0011 reproducibility guarantee. - No
mkdocs build --stricttest. Sixth-pass entry remains open; seventh pass elevates it again because the newgeo.mdpage and the still-missingsources.md/boundary.md/references.md/validation.md/world.mdpages would all be flagged by--strictimmediately. Without the CI gate, the eighth audit pass will inevitably re-list the same five-page gap.
Roadmap candidates — seventh 2026-05-18 review pass¶
gf.show_versions()— same return-shape convention as the proposedgi.show_versions()andgm-cli doctor. Sixth-pass carried this; seventh pass re-emphasises the cross-repo contract:dict[str, str]with keyspackage,python,numpy,scipy,sympy,pydantic,polars,shapely,pyproj,requests(the last three reflect the newgeoextra) plus_meta = {timestamp, platform}.gf.docs.assert_api_pages_exist()— walk__all__and assert every public symbol has at least one mkdocstrings:::directive somewhere underdocs/api/. Companion to the proposedmkdocs build --strictCI gate. Six passes have flagged the missing-API-pages problem and seven passes the forcing-function lack; this is now the highest-leverage cross-repo roadmap item.gf.World.reset_concrete_corrections()— public helper matching the ADR-0012 V1 registry. Default behaviour stays additive; the helper is opt-in for parameter sweeps. Tie to the Fixed-backlog entry above.gf.geo.OverpassCache(path, max_age_s=...)— promote the current hidden$XDG_CACHE_HOME/groundfield/osm/directory to a configurable cache object. Users running CI builds want per-job cache control; AP1 Monte-Carlo runs want to pin the cache for reproducibility.- ADR-0013 — Cross-repo
show_versionsconvention. Bundle the three-reposhow_versionsproposal into a single ADR that lives ingroundfieldand is linked fromgroundinsight/groundmeas. Five passes have proposedshow_versions; the ADR is the forcing function that pins the return shape before any of the three packages ships its own implementation.
Fixed (Backlog — eighth 2026-05-25 review pass)¶
The eighth audit pass was run on 2026-05-25, ten days after the
0.6.0release (geosubpackage + concrete-encasement model) and seven days after the seventh CHANGELOG-only audit pass. The seventh-pass code fixes (EvaluateSpecError,World.reset_concrete_corrections,OsmBuildingPlacement.footprint_atbounds-check,query_buildingsmax_retrieskeyword) landed in commit4c27695onfeature/audit-pass7-fixestogether with the new OrtsnetzLayout + Manhattan PEN router + radial-trunk PEN topology in60a9cf5. The bullets below capture both the still-unmerged release-train risk and a new Pass-8 wave of behavioural / docs gaps. Only the CHANGELOG is edited in this pass; no program code is touched.
feature/audit-pass7-fixescarries two commits plus a large unstaged delta — release cut blocked. The branch holds the seventh-pass code fixes (4c27695) and the AP1-feature commit (60a9cf5—OrtsnetzLayout,manhattan_routing,pen_topology), plus twenty-six modified files in the working tree (audit-label cleanup in inline comments, AP1-reference removal in module docstrings, the newescape_radius_mkeyword on :func:route_manhattanand :meth:OrtsnetzLayout.add_pen_cable, CITATION.cff edits). Three CHANGELOGAdded-blocks dated 2026-05-18 (escape-radius safety valve, OrtsnetzLayout GPS layer, Manhattan router + Ortsnetz builder + radial-trunk topology) are also unstaged. The 0.7.0 / 0.6.1 release cannot be tagged until the working tree is split into achore(audit-cleanup)commit (audit-label / AP1-reference scrub) and afeat(routing)commit (escape-radius valve + diagnostic error). Eighth-pass forcing function: open the release PR before the next audit pass or the backlog ledger keeps growing past the implementation.- Audit-pass labels inside docstrings and inline comments are
being scrubbed without a Changelog
Internalblock. The working-tree diff against60a9cf5strips the parenthetical „(fifth 2026-05-13 audit pass)" / „(sixth 2026-05-14 audit pass)" / „(seventh 2026-05-18 audit pass)" breadcrumbs fromsrc/groundfield/coupling/sommerfeld_inductance.py,diagnostics.py,generators/electrode_specs.py,generators/ortsnetz_builder.py,generators/tn_network.py,geo/osm.py,geo/placement.py,io/csv.py,io/groundinsight.py,postprocess/vector_fitting.py,solver/engine.pyandworld.py. The intent is reasonable (the audit pass numbers are noise once the fix is shipped) but the change is invisible to a CHANGELOG reader. Add anInternal (audit-label scrub — 2026-05-25)block before the cleanup commit lands. - AP1-reference cleanup in module docstrings is undocumented.
Same diff removes the „AP1" project name from the top-level
docstrings of
generators/manhattan_routing.pyandgenerators/ortsnetz_builder.py(AP1 PEN cable layout→PEN cable layout,AP1 LV cable router→LV cable router,AP1 reference worlds→reference worlds). Project-neutral docstrings are correct (docs_review_report.mdfrom 2026-05-18 explicitly recommended this) but the change is not visible in the Changelog. Add aDocs (project-neutral docstrings — 2026-05-25)bullet referencing the 2026-05-18 docs review. route_manhattan(..., escape_radius_m=...)has no negative test forescape_radius_m = -1.0. The implementation insrc/groundfield/generators/manhattan_routing.pyvalidatesif escape_radius_m < 0.0:and raisesValueError, but the three new regression tests intests/test_ortsnetz_builder.py(running total 31) only cover positive defaults,0.0strict mode and6.0rescue mode. Add a parametrised test on[-1.0, -0.001]so the contract stays explicit.OrtsnetzLayout.from_osmacceptskvs_lat_lonswithout a shape contract. The classmethod (lines 311–432 ofsrc/groundfield/generators/ortsnetz_builder.py) lets the user pass eitherkvs_lat_lons(list of(lat, lon)tuples) orkvs_xys(list of(x, y)tuples), guarded by an XOR validation on whether both are non-None. There is no validation that the user-suppliedkvs_nameslist (if given) has the same length askvs_lat_lons/kvs_xys, nor that each tuple has exactly two elements. A typo likekvs_lat_lons=[(51.0,)]reachesprojector.to_xy_m(*tup)and raises a bareTypeError. Add a_validate_kvs_arguments(kvs_lat_lons, kvs_xys, kvs_names)helper that produces a self-describing error at the API boundary.evaluate_spec'sEvaluateSpecErroris indocs/api/io.mdbut not indocs/api/index.md. Pass-7 added the „Errors" subsection todocs/api/io.md(commit4c27695); theapi/index.mdoverview prose still enumerates only the read/write helpers and lacks a one-line pointer to the new typed exception. Add it to the IO row so users browsing the Overview see the typed-error contract before drilling into the page.groundfield.diagnosticsexposesMIN_THINWIRE_RATIO,SOFT_LIMIT,HARD_LIMITas public constants but they are not listed indocs/api/diagnostics.md. The constants were promoted from the private leading-underscore aliases in pass 5 (commit72ee1b2) and the audit-label cleanup currently underway in the working tree keeps them but removes the „fifth 2026-05-13 audit pass" breadcrumb. The mkdocstrings dump indocs/api/diagnostics.mdpicks up the function symbols but not the module-level constants because the page uses:::groundfield.diagnostics:withoutmembers:orfilters:. Add an explicit:::groundfield.diagnostics: members: [MIN_THINWIRE_RATIO, SOFT_LIMIT, HARD_LIMIT, expected_segments, world_statistics, check_segment_resolution]so the public surface matches whatfrom groundfield.diagnostics import *exposes.docs/concepts.mdstill has no V1 vs V2 Sunde-shell decision table (eighth pass in a row for the V1/V2 distinction; second pass that explicitly calls for the table form). Same finding as the seventh-passFixed (Backlog)entry. The two implementation variants (concrete_model="lumped"vs"distributed") and their respective complexity / accuracy trade-offs are still only discoverable through ADR-0012.docs/index.md0.6.0 migration callout still missing (seventh-pass finding re-flagged). Same as the Docs-backlog entry under the seventh review pass.docs/quickstart.mddoes not show the new 0.6.0geoexports (seventh-pass finding re-flagged).docs/performance.mdlacks ageocost section (seventh-pass finding re-flagged).docs/api/geo.mddoes not document the offline fallback contract (seventh-pass finding re-flagged).- ADR-0003 and ADR-0009 still have no reciprocal „See also" link to ADR-0011 / ADR-0012 (seventh-pass finding re-flagged — eighth pass).
- Notebooks
30..36not inmkdocs.ymlnav (seventh-pass finding re-flagged with two additional notebooks). The 0.6.0 release added32_osm_footprints.ipynband33_concrete_encasement.ipynb; pass 7 added34_audit_pass7_fixes.ipynb; the AP1 feature branch added35_radial_trunk_pen_topology.ipynband36_ortsnetz_builder.ipynb. None of the seven are reachable from the rendered docs nav. graphify-out/manifest.jsonis stale.GRAPH_REPORT.mdreports commitaa9f0978(the 0.6.0 release tag) whilegit logis at60a9cf5; the manifest mtimes are all from before commit4c27695. Rungraphify update .before the next audit pass so the knowledge-graph is in sync with the Pass-7 / OrtsnetzLayout commits.
Docs (Backlog — eighth 2026-05-25 review pass)¶
README.mdstill does not mention the OrtsnetzLayout + Manhattan router + radial-trunk PEN topology (uncommitted AP1 features). The README's „New in 0.6.0" section lists OSM-driven footprints and concrete encasement but not the three uncommitted modules. Add a short „Upcoming in 0.7.0 / 0.6.1" paragraph (or wait until release-cut) describing the imperative builder and the GPS / OSM convenience layer.docs/examples/has no end-to-end OrtsnetzLayout walkthrough. The36_ortsnetz_builder.ipynbnotebook lives innotebooks/(gitignored) and is rendered nowhere; anexamples/10_ortsnetz_layout.mdpage that imports the Mulmke OSM extract narrative would close the gap for docs-site readers.docs/adr/has no ADR for the OrtsnetzLayout builder. The module is a substantive architectural addition (imperative single-network builder vs. the stochasticTnNetworkGenerator) but no ADR records the design rationale. Open ADR-0013 (or ADR-0014 if ADR-0013 is reserved for the cross-reposhow_versionsconvention) —OrtsnetzLayout: imperative single-network builder for TN-Ortsnetze.docs/adr/has no ADR for the Manhattan PEN router. The 4-connected A* + AABB obstacle test is a new code path with its own performance / correctness contract (cell size, clearance, escape radius, max iterations). One paragraph of rationale per knob is enough.docs/api/generators.mddoes not enumerate the new public symbolsOrtsnetzLayout,PenCable,KvsPlacement,BuildingConnection,ObstacleBox,PenTopology,StarKvsTopology,RadialTrunkTopology,route_manhattan,segment_intersects_box,merge_collinear_waypoints. The 0.6.0 changelog block lists them but thedocs/api/generators.mdmkdocstrings dump still uses the default:::groundfield.generators:and the pre-0.6.0 module docstring at the top of the page does not reference them.docs/api/io.md„Errors" subsection does not show thetry / except EvaluateSpecErrorexample. The seventh-pass edit added the subsection but only documents the class surface. Add a five-line example so consumers can copy-paste the pattern.
Tests (Backlog — eighth 2026-05-25 review pass)¶
route_manhattan(..., escape_radius_m=-1.0)negative-input parametrised test. Tied to the Fixed-backlog entry above.OrtsnetzLayout.from_osmshape-contract regression tests. Addtests/test_ortsnetz_builder.py::test_from_osm_kvs_shape_contractparametrised on the four failure modes:kvs_lat_lonswith mis-sized tuples,kvs_nameslength mismatch, bothkvs_lat_lonsandkvs_xysset, bothsubstation_lat_lonandsubstation_xyset.- No regression test against
graphify-outstaleness. A one-linetest_repo_hygiene.py::test_graphify_manifest_matches_headhelper (readGRAPH_REPORT.md„Built from commit" line, compare againstgit rev-parse HEAD) would surface the knowledge-graph drift at PR time. Same Forcing-Function pattern thatgroundmeasis rolling out for repo-root junk. - JSON round-trip test for
Conductor.lumped_series_resistance_ohm(seventh-pass finding re-flagged). - JSON round-trip test for
StripElectrode.concrete_shell_coefficient_ohm_m(seventh-pass finding re-flagged). mkdocs build --strictCI hook (seven passes in a row flagged this — eighth pass elevates it again). With the five long-standing API pages now present (commit4c27695) the--strictbuild would no longer fail on the missing pages; the remaining gates would catch the missingGeo / OSMindex entry, the unreferenced0.7.0notebooks and any future broken cross-link. This is the single highest- leverage forcing function for the docs backlog.
Roadmap candidates — eighth 2026-05-25 review pass¶
gf.OrtsnetzLayout.add_multi_stop_trunk(start, stops)— the Mulmke walkthrough innotebooks/36_ortsnetz_builder.ipynbends with a four-item rough-edges list; the multi-stop trunk helper (a single call that routes through N intermediate KVS in sequence) is the first item and the only one that affects the public API surface. Defer the basemap overlay and the coverage report to follow-on PRs.gf.OrtsnetzLayout.coverage_report()— Mulmke rough-edge item 3. A read-only diagnostic that lists for each building which PEN cable it was connected to, the stub length and any building that ended up unconnected. Pairs with the proposedassert_api_pages_exist()as a self-describing pre-solve check.gf.geo.OsmBuildingPlacement.per_footprint_specs— Mulmke rough-edge item 4. Allow the user to attach a per- buildingFoundationElectrodeSpecinstead of the current „one spec for all footprints" contract. Default behaviour unchanged.gf.cross_reponamespace +docs/cross-repo.md— once ADR-0013 (Cross-reposhow_versionsconvention) lands, mirror the proposedgi.cross_repo/gm.cross_repomodules so the three packages have a uniform façade for cross-repo helpers (show_versions,assert_api_pages_exist,audit_apply(report_path)).gf.docs.assert_api_pages_exist()— same as Pass-7; seven passes have flagged the missing-API-pages problem, five of which are now closed, so the helper is the right forcing function to prevent regression.
0.6.0 — 2026-05-15¶
Changed¶
vector_fitunder-determined check now respects conjugate-pair symmetry (postprocess/vector_fitting.py, sixth 2026-05-14 audit pass). The previous trigger counted four real DOFs per pole regardless of whether the search was constrained to conjugate pairs. Under the defaultcomplex_poles=Truethe pair constraint halves the actual free parameters; the underdetermined-test is now2 * n_independent_poles > len(frequencies)withn_independent_poles = n_poles // 2 + (n_poles % 2). The strict>admits the uniquely-determined boundary case (n_poles=2, N=2) that the previous>=trigger rejected as a false positive. The warning text now states which ratio applies given the fit mode. Behaviour forcomplex_poles=Falseis unchanged. No public API change; this is a tightening of the :class:VectorFitUnderdeterminedWarningenvelope.
Added¶
- Concrete encasement for foundation electrodes (ADR-0012). DIN-18014
Streifenfundamente sit in concrete, not in soil; the concrete's
resistivity varies from ~30 Ω·m (wet) to ~50 000 Ω·m (dry) and
materially changes the foundation's spreading impedance. Two new
optional fields on
:class:
generators.electrode_specs.FoundationElectrodeSpec—concrete_rho_ohm_m(float | AnyDistribution | None,None= historic bare-wire behaviour) andconcrete_thickness_m(default 50 mm) — together with aconcrete_model: Literal["lumped", "distributed"]discriminator expose the Sunde-shell model in two flavours: - V1 "lumped" (default): the total shell resistance
R_shell_total = ρ_c / (2π · L_perim) · ln(r_b / r_a)is recorded in the newworld.concrete_shell_correctionsregistry and injected as a series resistance on the PEN service drop via the also-newConductor.lumped_series_resistance_ohmfield. Zero solver-side change; reuses ADR-0003's distributed-conductor framework. - V2 "distributed": a per-segment radial coefficient
C = ρ_c / (2π) · ln(r_b / r_a)(in Ω·m) is stored on the strip electrodes through the new :attr:geometry.electrodes.StripElectrode.concrete_shell_coefficient_ohm_mfield; the image / image_2layer post-kernel diagonal is augmented byC / Δsper segment in both the inverse-problem assembly (inside_solve_cluster_currents) and the post-solve potential evaluation. Captures the wire-radius redistribution on top of the lumped shell, so whenρ_c = ρ_soilthe concrete/soil interface becomes electrically invisible (correct physics). - Both paths route only :class:
FoundationElectrodeSpecthrough the shell; rod / ring / strip / mesh electrodes — which always run in trenches or driven holes, never in concrete — keep their bare-wire bulk-soil interface. - Stochastic moisture:
concrete_rho_ohm_maccepts an :class:AnyDistribution, e.g.Discrete(values=[50, 150, 500, 2000], weights=[...])for the four empirical moisture bands. groundfield.geo— optional subpackage for OSM-driven building footprints (src/groundfield/geo/, ADR-0011). Four modules:footprint.BuildingFootprint— frozen Pydantic model carrying a closed CCW polygon in the local ENU frame, optional holes,building:levelsand raw OSM tags. Pure-Python; no :mod:shapelydependency at validation time. Includes :func:signed_area, :func:ensure_orientation, :meth:area_m2, :meth:centroid_xy_m.projection.Projector— WGS84 ↔ local ENU via :mod:pyprojusing+proj=aeqdcentred on a user-supplied origin (origin is never inferred from data; reproducibility guarantee per ADR-0011). Single-point and vectorised ring projection.osm.build_query/query_buildings/parse_overpass_payload/query_and_project— Overpass-API client with deterministic QL formatting, SHA-256 keyed on-disk cache (default$XDG_CACHE_HOME/groundfield/osm/or~/.cache/groundfield/osm/), one retry on429/504with exponential backoff, RFC-7946 ring-orientation handling for ways and multipolygon relations. Network layer is the only place :mod:requestsis imported.placement.OsmBuildingPlacement— Pydantic placement spec that returns building centroids in declared order (interface parity with :class:generators.placement.ManhattanGridPlacement/ :class:ExplicitPlacement) and exposes :meth:footprint_atfor the upcoming footprint-driven foundation-electrode hook inTnNetworkGenerator(Task 3). Construction is from pre-projected footprints — no HTTP call in the config layer, keeps generator configs JSON-serialisable and bit-exactly replayable. Filters out OSM noise belowmin_area_m2(default 16 m², Gartenhaus).- All optional :mod:
requests/ :mod:shapely/ :mod:pyprojimports are lazy and guarded with a clear :class:ImportErrorpointing atpip install groundfield[geo]. - New
geoextra inpyproject.tomldeclaringrequests ^2.32,shapely ^2.0,pyproj ^3.6. Same three are added to the dev group so the newtests/test_geo_*.pysuite can run end-to-end. OsmBuildingPlacementis a member of thegenerators.placement.PlacementSpecdiscriminated union — configs that use the OSM-driven path round-trip through JSON like every other placement. Re-exported fromgroundfield.generatorsand from the top-levelgroundfieldnamespace alongsideBuildingFootprint,Projector,query_buildings,query_and_project, andOverpassError.FoundationElectrodeSpec.orientation_deg(additive, defaultNone).Noneand0.0keep the historic axis-alignedGridMeshElectroderealisation; any other value triggers the new rotated-foundation path inGroundingSystemSpec._build_rotated_foundation, which synthesises the foundation as a closed :class:StripElectrodechain (perimeter + optional inner cross-braces forstyle="mesh") and bonds the sub-electrodes internally so the spec still emits a single bondable anchor.- OMBR-driven foundation override in
TnNetworkGenerator.build— when the configured placement exposes afootprint_at(i)hook (currently :class:OsmBuildingPlacement), every per-building :class:FoundationElectrodeSpecis rewritten so thatsize_xy_mandorientation_degcome from the polygon's oriented minimum bounding rectangle.presence_proband all other axes survive unchanged, so the only stochastic axis in an OSM-driven realisation is the Bernoulli onpresence_prob(per ADR-0011). BuildingFootprint.oriented_bounding_rectangle()andaxis_aligned_bounding_rectangle()— public helpers exposing the OMBR (Shapely-backed, requiresgeoextra) and the pure- Python AABB. The OMBR helper is the canonical Streifenfundament-Reduktion of an arbitrary polygon (see ADR-0011, "Phase A").
Tests¶
tests/test_geo_footprint.py— 14 cases coveringsigned_area/ensure_orientation, the :class:BuildingFootprintmodel (orientation normalisation, hole area subtraction, frozen-instance guarantee), and the AABB + OMBR helpers (axis-aligned identity, rotation round-trip on a synthetic 10×6 rectangle, L-shape coverage).tests/test_geo_osm.py— 15 cases on the projection (round-trip, vectorised ring, invalid origin rejection), the Overpass query builder (byte-determinism, parameter validation), the on-disk cache (single POST per(origin, radius)tuple,force_refreshbypass), and the Overpass payload parser (way withbuilding:levels, multipolygon with one inner hole,min_area_m2filter). Network IO is mocked at the_postboundary; the suite never opens a socket.tests/test_geo_placement.py— 12 cases on :class:OsmBuildingPlacement(centroid order, area filter,selection="all"ignoringn,footprint_atlookup, JSON round-trip via the union) and on the rotated-foundation branch of :meth:GroundingSystemSpec.build_at(4-strip perimeter forstyle="ring", 6-wire mesh forstyle="mesh"withn_x = n_y = 2, axis-aligned fast-path preservation), plus two end-to-end :class:TnNetworkGeneratortests asserting OMBR side lengths and bit-exact reproducibility across seeds.- All three new files run in under 2 s on the dev workstation; the existing 78 tests in the related suites (placement, grounding, tn_ortsnetz, distributions, import) continue to pass unchanged.
Notebooks¶
notebooks/32_osm_footprints.ipynb— AP1 demo with six synthetic Reihenhaus-Footprints (varied sizes and orientations), OMBR visualisation, side-by-side :class:ManhattanGridPlacementvs. :class:OsmBuildingPlacementplots, a 50 Hz cluster-impedance micro-solve, and an optional live Overpass query (cached; gracefully skipped offline).
Docs¶
docs/api/geo.md— new API-reference page documenting thegeosubpackage with the mathematical / physical context (Streifenfundament reduction, OMBR rationale, validity envelope, installation gate, JSON-roundtripable :class:OsmBuildingPlacement). Hooked intomkdocs.ymlnav under API reference → Geo / OSM, and the new ADR-0011 entry is wired up under Architecture decisions.docs/examples/09_osm_pipeline.md— end-to-end AP1 walkthrough in two flavours: hand-crafted pseudo-geometries (always runs) and a live Overpass query (needs thegeoextra + internet). Both variants cover the full pipeline: OSM-Read → stochasticpresence_probon :class:FoundationElectrodeSpec→ substation placement → :class:MeasurementSetupConfigfor aux electrode + voltage probe → reading φ at an arbitrary (x, y) via :meth:FieldResult.potential(no need for a real probe electrode) → surface-potential contour plot. Numerically exercises theZ_clustervsZ_systemgap that defines AP1 Analyse 1. Linked frommkdocs.ymlnav under Examples → 09 — OSM pipeline with measurement setup.notebooks/32_osm_footprints.ipynb— extended with a live Overpass section (query → world → solve → contour plot) parallel to the synthetic one. Both sections produce surface-potential contour plots; the live cells degrade gracefully when offline.notebooks/33_concrete_encasement.ipynb— interactive parameter-variation workbench for ADR-0012: closed-form Sunde sweep on an isolated Banderder (V2 ↔ Sunde to within 10⁻⁴), V1-vs-V2 comparison table across six \(\rho_c\) decades on the five-house OSM Ortsnetz, four-panel surface-potential contour for the canonical moisture states (none / 80 / 500 / 5000 Ω·m), shell-thickness sweep at fixed \(\rho_c\), and a 60-sample Monte-Carlo histogram over the four-class moisture distribution. Runs offline.tests/test_concrete_encasement.py— 10 cases covering ADR-0012: V2 Sunde-shell closed-form match on an isolated strip, zero-coefficient no-op, insulating-concrete dominance, V1 registry & PEN service-drop injection, V2-vs-lumped registry separation, JSON round-trip of stochasticconcrete_rho_ohm_m=Discrete(...), reproducibility across seeds, discriminator (only foundations get the shell), and end-to-end OSM-pipeline smoke (dry concrete pushes system impedance up ≥ 2.5×).
Docs¶
- ADR-0012 — Concrete encasement of foundation electrodes
(
docs/adr/0012-foundation-concrete-encasement.md, Accepted). Decision record for the cylindrical Sunde-shell model, the two implementation variants (lumped vs. distributed), the API additions on :class:FoundationElectrodeSpec, :class:Conductor(lumped_series_resistance_ohm) and :class:StripElectrode(concrete_shell_coefficient_ohm_m), and the validation programme. Hooked into mkdocs nav under Architecture decisions → ADR-0012. - ADR-0011 — OSM building footprints and footprint-driven foundation
electrodes (
docs/adr/0011-osm-building-footprints.md, Accepted). Specifies the new optional subpackagegroundfield.geo(Overpass query + on-disk cache, WGS84→ENU projection viapyproj,BuildingFootprintPydantic model,OsmBuildingPlacementPlacementSpecvariant) and the additive Phase-A extension toFoundationElectrodeSpec(oriented bounding rectangle from polygon - new optional
orientation_degfield). Pure stochasticity is retained onpresence_prob(Bernoulli per building). Implements the OSM follow-up named in ADR-0009. Drives the new optionalgeopip / Poetry extra (requests,shapely,pyproj).
0.5.0 — 2026-05-14¶
Changed¶
Source = Union[CurrentSource, VoltageSource]is now a Pydantic discriminated union (sources.py). The annotation isAnnotated[Union[...], Discriminator("kind")], with a companionSourceAdapter = TypeAdapter(Source)for stand-alone source-dict validation. JSON / dict round-trips now report errors against the selected sub-class (CurrentSource/VoltageSource) instead of dumping the whole union's validator chain — fourth 2026-05-12 audit pass closure. The user-facing constructor signatures are unchanged; only the error messages and the publicSourceannotation differ.Engine.frequenciesis documented and validated as order-preserving (solver/engine.py). A newfield_validator("frequencies")rejects empty or non-positive inputs and emits aUserWarningwhen the list is not strictly increasing — but the order is preserved inEngine.frequenciesand downstream inFieldResult.frequencies. The newEngine.with_frequencies(*freqs, preserve_order=True)constructor is the explicit opt-in for sweeps that intentionally iterate non-monotonically (e.g.[5000, 50]); it silences the warning and returns a freshEnginewithout mutating the receiver. Closes the fourth 2026-05-12 review pass "silent sort" finding.TnNetworkConfig.source_return_toexposes an explicit override for the source's return path (generators/tn_network.py). When set, it takes precedence over the auxiliary electrode derived fromcfg.measurement; when both are present and differ,TnNetworkGenerator.buildemits aUserWarningthat makes the precedence explicit. Previously the measurement setup silently overwrote any caller-side intent forreturn_to.
Fixed¶
World.set_boundary_conditionsnow warns on revert as well as on set (world.py). Setting a non-default value already emitted aUserWarning(v0.2.0); the same call signature now also warns when a previously-set non-default value is reverted back to the default. The previous non-default value was never consumed by any backend, so the revert is a silent no-op — the new warning makes that visible (fourth 2026-05-12 audit pass).vector_fit(n_poles=0)is rejected at the API boundary with a detailedValueError(postprocess/vector_fitting.py). A zero-pole fit produces ans-free SymPy expression whosegroundinsight.BusType.impedance_formulaconsumer cannot recover the frequency dependence; the failure mode was silent before.fit_to_sympyadditionally guards against programmatically constructedVectorFitResultobjects whose resulting expression iss-free, emitting aUserWarningwith the same diagnostic.mkdocs.ymlno longer referencespolyfill.io. The CDN's ownership change in early 2024 (malicious payloads injected via the original domain) made the URL unsafe to ship in the docs build. Modern MathJax 3 /tex-mml-chtmldoes not require an ES6 polyfill for any browser the docs target. Four audit passes in a row flagged this URL — fixed in this release.
Added¶
- Top-level re-exports
evaluate_spec,fit_quality_summaryandLayeredEarth(__init__.py). The three helpers were publicly importable from their sub-modules but were missing from the top-level surface and from__all__. They are now reachable asgf.evaluate_spec/gf.fit_quality_summary/gf.LayeredEarthand listed in__all__. - Frozen CSV column-name tuples in
groundfield.io.csv(POTENTIAL_PATH_COLUMNS,ELECTRODE_TABLE_REQUIRED_COLUMNS,CLUSTER_IMPEDANCE_REQUIRED_COLUMNS). The convention is<symbol>_re/<symbol>_im/abs_<symbol>per physical quantity (phifor potentials,Ifor currents,Zfor impedances) — magnitude columns therefore differ per writer by design. Locking the tuples makes accidental renames test-detectable and documents the join-rename requirement.
Tests¶
tests/test_audit_pass4_fixes.py— eight regression tests mapped 1:1 to the user-visible bullets above: discriminator round-trip, discriminator error path, boundary revert warning,Engineorder-preserving validator + warning, opt-inwith_frequenciessilencer,TnNetworkConfig.source_return_toprecedence with warning,vector_fit(n_poles=0)rejection, CSV column-schema lock, top-level re-export presence, andmkdocs.ymlpolyfill cleanup.
Docs¶
notebooks/30_audit_pass4_fixes.ipynb— narrative walk-through of all behaviour changes captured in this block. The notebook is short and per-fix: every cell illustrates one change in isolation so the diff is easy to verify when closing the audit.CLAUDE.md— added a "Version (do not hard-code in this file)" section that points atpyproject.toml/groundfield.__version__/CITATION.cffas the canonical sources. Closes the four-passes-in-a-row CLAUDE.md version-drift finding from the cross-cutting recommendations.mkdocs.yml— inline comment explaining whypolyfill.iowas removed.
Fixed (Audit pass 5 — implemented 2026-05-13)¶
Implementation block for the fifth 2026-05-13 review pass. Every entry maps 1:1 to a bullet under "Fixed (pending implementation) — fifth 2026-05-13 review pass" further down in this
[Unreleased]block. The pending-implementation bullets remain in place for audit consistency but are dispatched here. Regression tests live intests/test_audit_pass5_fixes.py; the matching demo notebook isnotebooks/31_audit_pass5_fixes.ipynb.
vector_fitnow warns on under- / exactly-determined input. A newVectorFitUnderdeterminedWarning(UserWarning)is emitted whenever2 * n_poles >= len(frequencies). Each pole contributes a residue (complex, 2 real DOFs) and a pole-location DOF (complex, 2 real DOFs); the real-imag stacking gives2 Nreal equations, so the conservative2*n_poles >= Nthreshold catches the cases the audit flagged (n_poles=1onN ∈ {1, 2}) — previously accepted as a silent identity interpolation. The dedicated warning category lets notebooks silence the diagnostic withwarnings.simplefilter("once", VectorFitUnderdeterminedWarning).Enginefrequency-order warning is silenceable by category. Pass-4 introduced the order-preserving validator with aUserWarning; pass 5 promotes it to a dedicatedEngineFrequencyOrderWarning(UserWarning)subclass and switches the warning text to a stable form (the per-call list literal is logged at debug level instead). A singlewarnings.simplefilter("once", EngineFrequencyOrderWarning)now collapses a ten-engine sweep over decreasing lists down to one notification.LayeredEarthdocuments the FP64 precision contract. The dataclass docstring now states explicitly that every consumer ofLayeredEarthoperates in IEEE-754 double precision. Future hardware-accelerated backends (e.g. MLX on Apple silicon) must honour the same precision contract; thetests/test_audit_pass5_fixes.py::test_layered_earth_precision_contracthomogeneous-limit cross-check enforcesrtol=1e-12and would fail under a silent FP32 down-cast.io.groundinsight.evaluate_specraisesValueErroron malformed specs. Three guards at the entry point now wrap the deep SymPy stack trace into one human-readableValueErrorper cause: non-BusTypeSpecargument, missing / emptyimpedance_formula, or unknown free symbols (anything other thanf,rho,j). A user passing a hand-rolled spec dict now sees the missing-Z_targetproblem at the surface instead of aKeyErrorfour frames deep insidesympy.lambdify.TnNetworkConfig.source_kindvalidates against aLiteral. Newsource_kind: Literal["current", "voltage"] = "current"field on :class:TnNetworkConfig. Typos like"voltage_"(trailing underscore) are rejected at validation time with a PydanticValidationErrorinstead of silently falling through to the defaultCurrentSourcefactory.TnNetworkGenerator.buildforwards the kind to :func:create_source.World.solve(engine)snapshots and restoresworld.sources. The method now wraps the backend dispatch in a try / finally and restores deep copies of everySourceon the way out. Backends that internally mutatesource.return_to(Pass-4 flaggedMeasurementSetupConfig.buildas the textbook offender) can no longer leak the mutation back into the caller's world.SourceAdapteris now a top-level re-export. The Pass-4TypeAdapter[Source]was introduced ingroundfield.sourcesbut never lifted to the package surface.from groundfield import SourceAdapternow works and the symbol is listed ingroundfield.__all__.diagnostics.MIN_THINWIRE_RATIO,SOFT_LIMIT,HARD_LIMITare now public module-level constants. The previous_MIN_THINWIRE_RATIO/_BUDGET_WARN_THRESHOLD/_BUDGET_HARD_THRESHOLDprivate aliases remain as backwards-compatible shadows. Tests, notebooks and external callers gain a stable handle ahead of the plannedcoarse_segmentsopt-in.scripts/release.pyrejects a hard-coded version inCLAUDE.md. The Pass-4 "do not hard-code" convention is now enforced: a new_check_claude_md_no_hardcoded_versionscans the file at release time, ignoring fenced code blocks and the explanatory reminder paragraph, and raises a clearRuntimeErrorif a__version__ = "X.Y.Z"(orversion = "X.Y.Z") literal is pasted into the project context document.main()runs the check before any files are touched, so a contributor who needs to fix the literal does so on a clean working tree.
Tests (Audit pass 5 — implemented 2026-05-13)¶
tests/test_audit_pass5_fixes.py— 16 regression tests, one (or more) per bullet above:VectorFitUnderdeterminedWarningraised / not raised,EngineFrequencyOrderWarningcategory + once-filter deduplication,LayeredEarthFP64 cross-check,evaluate_specon bad / empty / unknown-symbol / good specs,TnNetworkConfig.source_kindtypo rejection + voltage happy path,World.solvesource-mutation guard,gf.SourceAdaptertop-level import +__all__presence,diagnosticspublic-constant identity + backwards-compatible private aliases,scripts.release._check_claude_md_no_hardcoded_versionbad-literal rejection + fenced-block exemption.
Docs (Audit pass 5 — implemented 2026-05-13)¶
notebooks/31_audit_pass5_fixes.ipynb— narrative walk-through of every behaviour change captured in this block. Cell-per-fix layout matches the Pass-4 notebook so the closure pattern stays consistent.src/groundfield/coupling/sommerfeld_inductance.py—LayeredEarthdocstring extended with the FP64 precision contract.
0.4.0 — 2026-05-12¶
Housekeeping release; no user-visible changes.
0.2.0 — 2026-05-12¶
Fixed¶
FieldResult.potentialno longer silently falls back to the homogeneous kernel for aMultiLayerSoilwith three or more layers (solver/result.py). Prior to this fix, callingresult.potential(points)on a world withn ≥ 3soil layers returned potentials computed against a single image charge at \(z \to -z\) — wrong by the same margin as the layered Green's function deviates from the homogeneous half-space. The error was silent because the dispatcher only branched onisinstance(self.soil, TwoLayerSoil)and otherwise fell through to_potential_homogeneous, affecting profiles, surface plots, touch- and step-voltage post-processing and VTK exports alike. The new dispatcher casts a degenerate 1-layerMultiLayerSoilto the homogeneous kernel, casts a 2-layerMultiLayerSoiltoTwoLayerSoiland re-uses the Tagg/Sunde series, and raisesNotImplementedErrorforn ≥ 3with a clear pointer that the solve itself remains correct via thecim/mom_sommerfeld/bembackends and thatresult.electrode_potentials/result.cluster_impedancestay accessible. A closed-form n-layer Green's-function kernel on the post-solve potential path is queued for a later release.- Top-level
groundfieldpackage re-exports therho-ffit API (__init__.py).vector_fit,VectorFitResult,fit_to_sympy,rho_f_from_field_result,RhoFStandardFit,fit_rho_f_standard,fit_to_sympy_standardandrho_f_standard_from_resultsare now reachable viagroundfield.<name>(the README and ADR-0008 advertise this short import path; prior to this fix the user had to spell outfrom groundfield.postprocess.vector_fitting import …because the symbols were missing from both the top-level imports and__all__). BoundaryConditionsno longer pretends to honour non-default settings (boundary.py,world.py). The integral / image-charge backends ingroundfield.solverenforcefar_field="dirichlet",surface="neumann"andreference_node=Noneimplicitly through the choice of Green's function; any other value set viaWorld.set_boundary_conditions(...)was silently dropped at solve time. The fields are now documented as accepted but not consumed in v0.2.0, andset_boundary_conditionsemits aUserWarningwhenever a non-default value is supplied. The fields remain reserved for the upcoming FEM backend, which will resolve them explicitly.
Added¶
- Disk I/O — CSV and legacy ASCII VTK
(
groundfield.io.csv,groundfield.io.vtk). Closes the long-standingio.csv/io.vtkReserved slots in theiopackage docstring. Six writers, no new dependencies (pandas is already a runtime dep; the VTK writer is ~30 lines of pure-Python in legacy ASCII format): save_potential_path_csv(result, path, *, start, direction, distance, n, frequency_indices=None)— sample :meth:FieldResult.potentialalong a line and write(s, x, y, z, frequency_Hz, phi_re, phi_im, abs_phi).save_electrode_table_csv(result, path, *, world=None, frequency_index=0)— wrap :func:electrode_current_tableand dump to CSV.save_cluster_impedances_csv(result, path, *, frequency_index=0)— wrap :func:cluster_current_balanceand dump to CSV; the per-clustermemberslist is flattened into a';'-joined string for tabular compatibility.export_geometry_vtk(world, path)— legacy ASCII VTK PolyData with electrodes (rod / ring / strip / grid_mesh perimeter + interior wires) and conductor line segments as 3-D polylines. Cell data carries an integerrolefield (0 = electrode, 1 = conductor) so colour- by-role works directly in ParaView.export_field_vtk(result, path, *, extent, z=0.0, n=(120, 120), frequency_index=0)— sample the potential on a regular :math:N_x \times N_ygrid in the slice plane :math:z = z_0and write a STRUCTURED_POINTS file withpotential_reandpotential_imscalars.tests/test_io_csv.py(8 tests) — round-trip of the potential-path writer (re-evaluating :meth:FieldResult.potentialat the saved coordinates matches the saved values to 12+ significant figures), parent- directory creation, multi-frequency dump, electrode-table CSV ↔ in-memory DataFrame equivalence (with / withoutworld), cluster-impedance CSV with the flattenedmemberscolumn, and bad-argument error paths.tests/test_io_vtk.py(9 tests) — POLYDATA header integrity, exact polyline count for a mixed world (1 rod- 1 ring + 1 strip + 1
GridMeshElectrode(n_x=3, n_y=2) -
1 conductor = 11 polylines), empty-world header-only output,
rolecell-data scalar covers both 0 and 1, STRUCTURED_POINTSDIMENSIONS/ORIGIN/SPACINGcorrectness, scalar payload size matchesn_x * n_y, and bad-extent / too-few-grid-points error paths. -
Convergence study over
engine.segment_length(groundfield.postprocess.convergence). Single-axis refinement helper — the canonical "halve the segment length, watch what happens" experiment as one function call: convergence_study(world, engine, *, segment_lengths, response=None) -> pd.DataFrame— clones the engine via :meth:Engine.model_copyso the original is not mutated, solves at every refinement step, and returns a long-format DataFrame withsegment_length_m,frequency_Hz,n_segmentsand the response columns. Default response: cluster impedance + EPR at the source's cluster (same extractor as :func:sweep).plot_convergence(df, *, response="abs_Z", reference=None, ...)— log-x plot with the x-axis inverted so finersegment_lengthlands on the right (the asymptote direction). Optional reference line for the analytical asymptote (Sunde, Dwight, IEEE 80).-
tests/test_convergence.py(12 tests) — monotone growth ofn_segmentswith refinement, monotone convergence ofabs_Zto within 5 % of the Sunde reference for a 1.5 m rod, engine-mutation guard (the original engine'ssegment_lengthis unchanged after the helper runs), validation of thesegment_lengthsargument (empty, single value, repeated values, non-positive entries), multi-frequency row count, plot-helper smoke (single + multi-frequency, with reference line), and the inverted x-axis check. -
Cartesian-product parameter sweeps (
groundfield.postprocess.sweep). New module that turns the AP1 parameter axes (:math:\rho_1, :math:\rho_2, :math:h_1, geometry, frequency) into a single tabular response, ready for vector-fitting and the :math:\rho-:math:fregression: sweep(world_factory, engine, *, axes, response=None) -> pd.DataFrame— Cartesian product over arbitrary named axes.engineis either a static :class:Engine(reused for every combination) or a callableengine(**combination) -> Engine(rebuilt per combination for axis-dependent discretisation). For each combination + frequency, a row is emitted with the axis values,frequency_Hz, and the response keys.- Default response: 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). plot_sweep_lines(df, *, x, y="abs_Z", color=None, log_x=False, log_y=False, ...)— line plot of the response, one curve percolorvalue if set.plot_sweep_heatmap(df, *, x, y, response="abs_Z", frequency_Hz=None, agg="mean", ...)— pivot-table heatmap of one response over a(x_axis, y_axis)pair, optionally selecting a frequency slice.-
tests/test_sweep.py(14 tests) — Cartesian-product row count (axes × frequencies), default-response columns present, AP1 linearity check (:math:Zis proportional to :math:\rhofor homogeneous soil to better than :math:10^{-6}relative), per-combination engine factory is invoked exactly once per Cartesian point, custom response extractor replaces the default, empty-axes / empty-axis- values error paths, plot-helper smokes (single curve, multi- curve with logs, heatmap with frequency slice, unknown-column guard, missing-frequency guard). -
Pre-solve world diagnostics (
groundfield.diagnostics). New top-level module — the pre-solve counterpart to :mod:groundfield.validation's post-solve cross-engine check. Three helpers, all of which work on a :class:World(and optionally an :class:Engine) without invoking the solver: world_statistics(world) -> dict— structural snapshot: counts per electrode kind / conductor type / coupling mode, total electrode wire length, total conductor length plusmin/median/max/meanof the conductor-length distribution, full :math:(x, y, z)bounding box and footprint area, and ahas_layered_soilflag. Complements :meth:World.summary(one-line text) with a richer machine-readable dictionary that scales to AP1-grade networks.expected_segments(world, engine) -> dict— predicts the number of point-source segments that the image-family discretiser will produce. Mirrors :mod:groundfield.solver.imageexactly for every electrode kind (rod / ring / strip / mesh / grid_mesh) and adds theceil(L / discretize_segment_length)count for distributed conductors (ADR-0003). The prediction is bit-exact for theimage/image_2layer/image_nlayer/mom/mom_sommerfeld/cim/bembackends; FEM uses an unrelated axisymmetric volume mesh.check_segment_resolution(world, engine) -> list[str]— heuristic warnings for common AP1 modelling pitfalls: thin-wire ratio :math:\Delta s / r_\text{wire} \ge 5, electrode smaller than one segment (degenerate discretisation), distributed-conductor wire / segment mismatch, and total-segment budget thresholds (5 000 soft warning, 20 000 hard warning to flag the dense-system :math:O(N^2)memory and :math:O(N^3)solve scaling). Returns an empty list when everything looks healthy.
Helpers re-exported at the package top level
(gf.world_statistics, gf.expected_segments,
gf.check_segment_resolution).
- tests/test_diagnostics.py (20 tests) — counts and lengths
match the analytic geometry exactly, footprint and bounds
are consistent, layered-soil flag, empty-world safety,
expected_segments bit-exact against the image solver
for every electrode kind (rod / ring / strip / mesh /
grid_mesh) plus distributed-conductor counts and per-kind
aggregation, check_segment_resolution clean-world empty
return, thin-wire warning trigger, distributed-conductor
mismatch trigger, electrode-smaller-than-segment trigger,
budget-threshold trigger on a 100 m × 100 m grid mesh,
invalid-segment-length error path, and a top-level export
check.
- World-geometry plots without solving
(
groundfield.postprocess.geometry_plot). New module that renders the physical world — electrodes, conductors and current sources — as a sanity check beforeworld.solve(...). Useful for AP1-grade networks where a typo in an electrode position or a missing conductor would otherwise only surface several minutes of solver time later: world_bounds_3d(world) -> (x_min, x_max, y_min, y_max, z_min, z_max)— full :math:(x, y, z)bounding box of the electrodes plus conductor endpoints. Extension of :func:world_bounds_xyto the third axis; correctly tracks rod feet (position[2] + length) and overhead conductor routing (negative :math:z).plot_world(world, *, plane="xy"|"xz", extent=None, padding_m=5.0, show_conductors=True, show_sources=True, annotate_electrodes=False, ax=None, ...) -> Figure— top-down or vertical 2-D plot. Electrodes drawn via the existing :func:_draw_electrodeshelper (consistent style with the field plots); conductors as colour-coded line segments (PEN green, bare_copper orange, cable_shield grey, overhead steel blue, generic dark grey; solid forcoupling_to_soil="galvanic", dashed for"isolated"); sources as red star at the anchor electrode plus a thin arrow toreturn_toif set. Synthetic legend over conductor types and source markers; soil surface (z=0) drawn as a dotted grey line in thexzview.plot_world_3d(world, *, show_conductors=True, show_sources=True, show_surface=True, elev=22.0, azim=-55.0, ...) -> Figure— 3-D wireframe via :mod:mpl_toolkits.mplot3d. The :math:z-axis is inverted so depth grows downwards on screen (groundfield convention); a translucent grey plane at :math:z=0marks the soil surface. Rods drawn as vertical line segments, rings as 64-point circles, strips as line segments, mesh / grid_mesh electrodes as outer rectangle plus inner wires.
Helpers re-exported at the package top level
(gf.plot_world, gf.plot_world_3d, gf.world_bounds_3d).
- tests/test_geometry_plot.py (18 tests) — bounding-box
correctness across all electrode kinds (rod foot, ring extremes,
strip, mesh / grid_mesh, conductor endpoints), empty-world
zero-box, plot_world smoke on both planes (xy /
xz), inverted y axis on xz, conductor / source
toggle reduces line count, padding-around-bounds default,
explicit-extent override, unknown-plane error, optional
axes hand-over (ax=...), annotation creates one
:class:matplotlib.text.Annotation per electrode,
plot_world_3d smoke + inverted z-axis check + empty-world
+ options-off variants, and a top-level export check.
- Current-sharing post-processing
(
groundfield.postprocess.current_balance). New module that turns the per-electrode currents inFieldResultinto the AP1 "where does the injected current actually return?" quantities: cluster_current_balance(result, *, frequency_index=0) -> pd.DataFrame— per-cluster soil leakage :math:I_c = \sum_{e \in c} I_e, cluster potential :math:U_c, cluster impedance :math:Z_c = U_c / I_c, sorted by descending :math:|I_c|.electrode_current_table(result, world=None, *, frequency_index=0) -> pd.DataFrame— per-electrode potential, current, two-terminal impedance, and the fractional share of the cluster total :math:s_{e \mid c} = I_e / I_c. Withworldset, the table also reports the electrode kind and connection-point depth — the small AP1 annotations that turn a 200-EFH run from "wall of numbers" into "what's actually loaded."-
split_factor(result, world, *, source_name=None, frequency_index=0) -> complex— galvanic current-split factor :math:s = I_{c_\text{src}} / I_\text{src}. Returns :math:s = 1when the entire injected current leaves the source cluster through the soil; :math:s < 1when a metallic parallel path (PEN trunk, parallel measurement lead, cable shield) carries part of the current as a parallel resistive path. Raises on missing / multiple / zero-magnitude sources or on unknownsource_name.Naming note. This is not the Reduktionsfaktor of the German EVU / Schirmtechnik literature (Oeding & Oswald 2016) — that latter quantity is the additional transformatorische / inductive coupling correction between a current-carrying conductor and a parallel grounding / shield conductor and vanishes for perpendicular geometry. The split factor here is purely galvanic and applies whenever there are parallel resistive paths, regardless of conductor angle. The proper Reduktionsfaktor is on the roadmap; the inductance backends in :mod:
groundfield.couplingare already in place. *plot_current_sharing(result, world=None, *, by="electrode", top_n=15, frequency_index=0)— quick top-N bar chart of :math:|I|(per electrode or per cluster). The defaultby="electrode"is the AP1 default for spotting which physical electrode actually carries the test current.
Backed by :class:pandas.DataFrame (already a runtime
dependency); helpers re-exported at the package top level
(gf.cluster_current_balance, gf.electrode_current_table,
gf.split_factor, gf.plot_current_sharing).
- tests/test_current_balance.py (19 tests) — KCL on a single
rod (s = 1), KCL on an ideally bonded multi-electrode
cluster (s = 1 regardless of member count), AP1
measurement scenario with a finite-impedance Cu feed lead in
parallel to the soil return path (s < 1 plus an explicit
branch-current consistency check
:math:(1 - s) I_\text{src} = (U_a - U_b) / R), per-cluster
KCL Σ leakages = source magnitude, sort order by descending
:math:|I|, share-of-cluster sum = 1 + 0j over each cluster,
optional world annotation columns (kind / depth_m),
unknown / missing / multiple source error paths, plot smoke
tests, and a top-level export check.
- Touch- and step-voltage helpers (
groundfield.postprocess.safety). New module that closes the long-standing gap betweenFieldResultand the engineering safety quantities promised in thepostprocesspackage docstring: touch_voltage(result, world, *, electrode, distance=1.0, direction=(1, 0, 0), surface_z=0.0, frequency_index=0)— pointwise :math:U_T = U_E - \varphi(\mathbf r_\text{feet})on the soil surface.touch_voltage_envelope(result, world, *, electrode, distance=1.0, n_angles=24, ...)— same evaluation along an equidistant horizontal circle around the touched electrode; the maximum of|U_T|is the conservative envelope used in safety verification.step_voltage(result, *, position, direction=(1, 0, 0), step=1.0, surface_z=None, frequency_index=0)— :math:U_S = \varphi(\mathbf r_1) - \varphi(\mathbf r_1 + d_\text{step}\,\hat{\mathbf e})between two surface points.permissible_touch_voltage_en50522(t_clear_s)— reference curve :math:U_{TP}(t_F)taken verbatim from EN 50522:2010 Table B.4 ("Berechnete Werte der zulässigen Berührungsspannung U_TP in Abhängigkeit von der Fehlerdauer t_f", values rounded to 5 V in the standard); log-log interpolation over the eight canonical anchors :math:(0.05, 725),\ (0.10, 655),\ (0.20, 525),\ (0.50, 225),\ (1.00, 115),\ (2.00, 95),\ (5.00, 85),\ (10.00, 85)(s, V), clamped to the table endpoints outside the grid (the standard's terminal 85 V plateau between 5 s and 10 s is reproduced exactly).
All evaluations stay in the complex frequency-domain phasor
convention used by the rest of groundfield so that
inductive-coupling effects above DC remain visible. Helpers are
re-exported at the package top level (gf.touch_voltage,
gf.step_voltage, …).
- tests/test_safety.py (19 tests) — closed-form Sunde
homogeneous-soil reference (positive U_T below U_E, U_T → U_E
at remote earth, |U_S| decay to remote earth, sign of U_S
matching the radial potential gradient), cylindrical symmetry
of the envelope around a vertical rod, envelope vs. pointwise
cross-check, EN 50522 Table B.4 anchor-point exact match,
monotone decrease, log-log interpolation geometric-mean check,
terminal 85 V plateau between 5 s and 10 s, endpoint clamping,
validation of distance / step / direction / unknown-electrode
error paths, and a top-level export check.
- World generator framework + AP1 TN-Ortsnetz generator
(ADR-0009). New subpackage
groundfield.generatorswith: GeneratorConfig— Pydantic v2 base for generator configurations. Numerical / categorical fields accept either a fixed value or a :class:Distribution.cfg.sample(rng)walks the model and resolves every distribution to a concrete value, recursing into nestedGeneratorConfiginstances and list/tuple containers;cfg.has_distributions()is the introspection counterpart.WorldGenerator(Generic[C])— abstract base. Concrete generators implementbuild(cfg)and inherit RNG wiring,sample_world(rng), and the_assert_resolvedguard.groundfield.generators.distributions— distribution catalogueConstant,Uniform,Normal(with rejection-sampling truncation),LogNormal(withfrom_momentsconstructor),Weibull,Discrete,Categorical. All are Pydantic v2 models with a literalkinddiscriminator and a.sample(rng)method; theAnyDistributiondiscriminated union enables JSON round-tripping insideGeneratorConfigfields.TnNetworkGenerator(+TnNetworkConfigand the four sub- configsSoilConfig,TrafoStationConfig,HouseElectrodeConfig,PenConfig) — first concrete generator. Parameterises the AP1 axes from999_projektmanagement/arbeitspakete/AP1_tn_ortsnetz.md: \(n_\text{EFH} \in \{5, 10, 30, 80, 200\}\), small / medium commercial buildings, cable-cabinet quota \(q\) per 100 EFH, two-layer soil \((\rho_1, \rho_2, h_1)\), house electrode kind drawn per house from aCategorical({"foundation", "rod", "mesh"}). Topology: Manhattan-grid house placement → cable cabinets along the substation row → PEN backbone (substation ↔ KVS, KVS ↔ nearest house) as a distributed conductor (ADR-0003), with optionalinductance_model="neumann"(ADR-0004) and selectablecoupling_to_soil.- ADR-0009 (
docs/adr/0009-world-generators.md) — design rationale, architecture (ABC + Pydantic config), distribution layer, sampling semantics, topology contract, validation programme. tests/test_distributions.py(24 tests) — reproducibility under seed, statistical sanity (mean/std within 5 % over 10 000 samples), JSON discriminated-union round-trip per distribution, validation of malformed inputs (negative weights, inverted bounds, non-positive scale, duplicate categorical values), and rejection-sampling exhaustion on truncated Normal.tests/test_generators_base.py(15 tests) —resolve_valuepass-through,has_distributionsrecursion,cfg.sampleresolution at top-level / nested / categorical, idempotence on fully-fixed configs, reproducibility under seed, build-side guard against unresolved configs, RNG wiring, JSON round-trip on a mixed-distribution config.tests/test_tn_ortsnetz.py(10 tests) — smallest-preset build & solve, default-segment-budget at \(n_\text{EFH}=30\), bit-exact reproducibility under fixed seed, stochastic reproducibility under fixed seed, categorical-mix electrode kinds, \(|Z| \propto \rho_1\) monotonicity at low frequency for \(n_\text{EFH} \in \{5, 10, 30\}\), JSON round-trip preserving every distribution kind, edge cases (zero houses, unknown electrode kind), KVS quota reproduction.- Notebook
notebooks/20_tn_ortsnetz_generator.ipynb— deterministic AP1 reference build (10 EFH foundation electrodes), three side-by-side stochastic samples with a Categorical kind mix, full \(n_\text{EFH} \times \rho_1\) heatmap at 50 Hz showing the Dwight asymptote \(|Z| \propto \rho_1\) and the parallel-cluster scaling with \(n_\text{EFH}\), JSON round-trip of the stochastic config with a bit-exact replay of the seed-42 sample.
Changed¶
build_inductance_matrixis now vectorised (ADR-0010 Tier 0b). The Python double loop over \(M(M-1)/2\) segment pairs is replaced by a row-at-a-time NumPy assembly: pair-wise dot products in a single \(O(M^2)\) multiplication, closed-form Grover formula vectorised over the parallel pairs, 16×16 Gauss–Legendre quadrature batched over the non-parallel pairs (peak memory \(O(M \cdot 256 \cdot 24)\) bytes per row). Reproduces the legacy loop bit-exactly to floating-point precision (max relative drift ~ \(10^{-12}\) — pure summation roundoff). Empirical speed-up 5–10× across \(M \in [25, 400]\) on a typical laptop, scaling cleanly. The legacy loop is kept as_build_inductance_matrix_loopfor regression-test reference.tests/test_inductance_vectorised.py(8 tests) — bit-exact regression against the legacy loop on hand-crafted mixed geometry, on random non-degenerate segments at \(M \in \{5, 20, 60\}\) for bothuse_image=True/False, on a fully-parallel grid (closed-form-only path), plus symmetry / positive- diagonal / single-segment / empty / zero-length-segment edge cases.- ADR-0010 (
docs/adr/0010-tier0-performance.md) — design rationale and validation programme. Documents 0b as implemented, 0a (LU caching across frequencies) and 0c (geometry-adaptive discretisation) as scoped follow-ups. - Notebook
notebooks/22_tier0_speedup.ipynb— bit-exact cross-check between loop and vectorised implementation, plus a speed-up scan over \(N\) for both random-geometry (mixed parallel + quadrature path) and all-parallel-grid (closed-form-only path) inputs.
Docs¶
docs/performance.md— comprehensive performance and scaling guide. Empirical wall-clock characteristics from Notebook 21 (Sommerfeld is ~1200× slower than Carson at AP1 frequencies and gives identical answers for parallel-wire geometries;segment_length ≤ 1 mmandatory for AP1 accuracy; PEN-Neumann is ~1 % at 50 Hz at 3× cost). Wall-clock estimates per AP1 study size (5 EFH up to 200 EFH). Monte-Carlo strategy with a fully workedjoblib.Parallelpattern, throughput estimates per study size, and tips for reproducible / resumable runs. Roadmap pointer to ACA + GMRES with a clear threshold for when it becomes worth the implementation effort.- Eight ground-up
docs/examples/— a guided tour: - 01 First solve (a single rod, Sunde reference);
- 02 Substation grounding (ring + 4 rods, Dwight 1936 reference);
- 03 TN-Ortsnetz generator basics;
- 04 AP1 Analysis 1 — galvanic measurement and the 62 % rule;
- 05 AP1 Analysis 2 — inductive coupling on the measurement leads, frequency-dependent error;
- 06 Deterministic parameter sweep over \(\rho_1\);
- 07 Monte-Carlo sweep with
joblib, persistent Parquet storage, statistical bands; - 08 Full pipeline
groundfield→ ρ-f fit →groundinsightBusTypefor downstream fault analyses. Each page is self-contained and runnable end-to-end. Linked fromexamples/index.md, navigation inmkdocs.yml.
Added¶
-
TnNetworkConfig.source_magnitude_Aaccepts a Distribution. The driving current at the substation cluster (test current for measurements, fault current for fault simulations) was previously a plainfloat; it now also takes any :class:Distribution. Sweep e.g. measurement vs. fault on the same world viasource_magnitude_A=Discrete(values=[1.0, 5000.0])or define a Monte-Carlo current withLogNormal.from_moments(...). Three new tests intests/test_measurement.pycover the pass-through, the discrete-distribution case, and seed reproducibility. -
Measurement-setup layer for AP1 grounding-resistance studies (Analysis 1 + 2). New module
groundfield.generators.measurementwith: MeasurementLeadConfig— one physical measurement lead (overhead at surface or buried cable) modelled as a finite- impedance :class:Conductor. Default settings:coupling_to_soil="isolated",inductance_model="neumann"so the lead generates a magnetic field that couples to every parallel conductor (PEN, cable shields, the parallel measurement lead).MeasurementInjectionConfig— auxiliary current electrode (Hilfserder) at a configurable remote position with its own :class:GroundingSystemSpec, plus an optionalfeed_lead(None= AP1 Analysis 1 galvanic only; a :class:MeasurementLeadConfigenables AP1 Analysis 2 inductive coupling).MeasurementProbeConfig— voltage probe (Spannungssonde) at a configurable position with its own grounding (default: short rod at the 62 % point), plus an optional metallic measurementlead.MeasurementSetupConfig— top-level measurement spec used as the new optionalTnNetworkConfig.measurementfield. When set, the generator builds the aux electrode, the voltage probe, the configured leads, and re-routes the source'sreturn_toto the aux anchor — the test current physically returns through the auxiliary electrode (and, with metallic leads enabled, mostly through them). WhenNone(default) the source returns through remote earth and no aux/probe are added.- Convenience factories
overhead_lead()(surface bare copper, Neumann-coupled) andburied_lead(depth_m=0.6)(cable shield), plussingle_rod_grounding()(Erdungsspieß) andneighbour_substation_grounding()(ring + 4 rods, for measurements against a neighbour substation). tests/test_measurement.py(10 tests) — default-no-measurement hygiene, galvanic-only setup adds aux + probe but no leads, sourcereturn_towiring to aux anchor, inductive setup adds both leads withinductance_model="neumann"active, lead helper inspections, neighbour-substation aux variant, smallest preset solves with measurement enabled, JSON round-trip.-
Notebook 20 grows section 9. Erdungsmessaufbau (AP1 Analyse 1 + 2) with three sub-sections: 9a galvanic-only Surface potential overlay, 9b inductive-coupling case with overhead feed and probe leads + Sommerfeld earth-return correction, and 9c neighbour-substation aux variant.
-
plot_surface_potential(result, world, …)— new helper in :mod:groundfield.postprocess.plottingthat plots \(\varphi(x, y, z)\) over the entire world instead of over the point-source bounding box. Default extent is derived from :func:world_bounds_xy(world)plus a configurablepadding_m(default 15 m), so all buildings, cable cabinets and the substation appear together with a strip of remote-earth decay around them. Options:log(multi-decade colour scale on \(|\varphi|\)),symmetric(signed \([-\varphi_\text{max}, +\varphi_\text{max}]\) scale around 0),show_electrodesoverlay,show_contour_linesfor iso-potential annotations, customcmap/figsize/title. - :func:
world_bounds_xy(world)— public helper that returns the smallest axis-aligned \((x, y)\) bounding box of the world's electrodes. Each electrode kind is unwrapped to its true footprint (rods → point, rings → enclosing square, strips → endpoint bbox, grid meshes →corner-to-corner+size). - Notebook 20 grows section 8. Surface-Potential über die gesamte Welt with three plots on the existing 5-EFH minimal-example: linear scale with iso-lines, log \(|\varphi|\) for the boundary decay, and a wide (60 m) padding view that shows how far the field still influences the remote earth.
tests/test_api_smoke.py(4 new tests) —world_bounds_xybounding-box correctness, default-extent honouring onplot_surface_potential, log-mode smoke, explicit-extent override.
Changed¶
-
_draw_electrodesno longer annotates electrode names in the potential / surface-potential plots. In dense AP1 networks the per-electrode labels overlapped heavily and obscured the contours. The geometric outlines (rod markers, ring/strip lines, mesh wires) stay; callers that genuinely need labels can iterateworld.electrodesand callax.annotatethemselves. Affects :func:plot_potential_contourand :func:plot_surface_potential. -
FoundationElectrodeSpecgains an explicit ring/mesh style. New fieldstyle: Literal["ring", "mesh"] = "mesh":"ring"realises only the rectangle's perimeter (closed wire loop, no internal cross-bracing — the Ringerder-style foundation electrode common in residential buildings),"mesh"(default) keeps the previous behaviour with perimeter plusn_x × n_yinternal cross-braces (the classical Maschenerder). Internally both styles materialise as a :class:GridMeshElectrode;"ring"forcesn_x = n_y = 1(one mesh cell = perimeter wires only). Tests and the variant catalog notebook (notebook 20) are updated. -
Notebook 20 plot rendering: the foundation / grid-mesh electrode is now drawn with all its longitudinal and transverse wires (instead of just the outer rectangle), so the internal cross-bracing is visible at a glance. Adds a new "Ring vs. Mesh" variant section that compares the three common realisations (perimeter only, 2×2 mesh, 4×4 mesh) side by side.
-
TnNetworkGeneratorrefactored onto a composable spec layer (ADR-0009 v2). The flat v1 config (n_efh,house_electrode, hardcoded substation ring + rods, single-rod KVS) is replaced by five reusable spec modules: electrode_specs— discriminated unionElectrodeSpec(RodElectrodeSpec,RingElectrodeSpec,StripElectrodeSpec,FoundationElectrodeSpec) withpresence_probandoffset_xy_m. Helperrod_circle(n, radius_m, …)returns N rods on a circle — the typical substation Tiefenerder layout.grounding.GroundingSystemSpec—electrodes: list[ElectrodeSpec]plusbuild_at(world, site_xy, name_prefix, rng). Bonds every present electrode into one cluster. Used identically by substation, KVS and every building.placement— discriminated unionPlacementSpec(ManhattanGridPlacementwith optional jitter,ExplicitPlacementwith caller-supplied coordinates).soil_specs— discriminated unionSoilSpec(HomogeneousSoilSpec,TwoLayerSoilSpec,MultiLayerSoilSpec) with distributions per parameter and ato_soil(rng)materialiser.building.BuildingTypeSpec+default_building_catalog()— AP1-typical four-type catalog (residential,small_industry,medium_industry,large_industry) with distinct grounding systems. The newTnNetworkConfigexposessoil,substation,kvs,placement,building_types,building_counts,pen,source_magnitude_A. Substation grounding is now any AND/OR combination of ring / rods / strip / foundation; KVS grounding the same; per-building grounding is type-driven and supports multi-electrode systems (foundation + extra rod, ring + grid + strips, …) with per-electrodepresence_probfor stochastic fleets.TnNetworkGenerator.buildadopts lazy distribution resolution. The previous strict guard_assert_resolved(cfg)rejected any unresolvedDistributionfield and prevented the per-house Categorical electrode-kind mix from being drawn at build time. The generator now resolves top-level distributions lazily (one draw perbuildcall) and samples per-instance fields (currentlyhouse_electrode.kind) once per house. Strict upfront resolution is still available viagen.sample_world(rng)or a manualcfg.sample(rng)— see ADR-0009 for the documented trade-off (sample_worldcollapses the Categorical to a single kind for all houses;buildgives the per-house mix).
Deprecated¶
groundfield.generators.tn_ortsnetzmodule path. Usegroundfield.generators.tn_networkinstead, or importTnNetworkGenerator/TnNetworkConfigfrom the top-levelgroundfieldnamespace. The old module re-exports the new classes under their old names (TnOrtsnetzGenerator,TnOrtsnetzConfig) and emits aDeprecationWarning. The rename is part of aligning the public surface with the project-wide English-only naming convention; the AP1 work-package keeps its German name TN-Ortsnetz in the research documentation and file paths.
Removed¶
-
The flat v1
TnNetworkConfigschema (n_efh,n_klein_gewerbe,n_mittel_gewerbe,kvs_per_100_efh,kvs_x_max_m,n_houses_per_row,house_spacing_m,row_spacing_m, the v1house_electrodeblock with itskindCategorical) is gone. Usebuilding_types+building_counts, plus the new sub-configssubstation,kvs,placement. Notebook 19 (groundinsight bridge) is unaffected; only generator-using code paths need updates. -
groundinsightbridge —BusTypeexport from arho-ffit (ADR-0008). New modulegroundfield.io.groundinsightthat closes the family pipelinegroundfield → groundinsight: BusTypeSpec— neutral, in-memory representation of an exportedBusTypecarrying name, description, system_type, voltage_level, the SymPyimpedance_formulastring, the parallel sample table(frequency_Hz, rho_Ohm_m, Z_real_Ohm, Z_imag_Ohm), and a free-formmetadatablock with the fit method, fit quality, coefficients/poles, source-package version and creation timestamp.to_bustype_dict(fit, ...)/save_bustype_json(fit, path, ...)/load_bustype_json(path)— JSON path with a versioned schema (schema = "groundfield.bustype",schema_version = 1). Works withoutgroundinsightinstalled.to_bustype(fit, ...)/save_bustype_to_db(fit, ...)— Python-API path that returns a livegroundinsight.models.core_models.BusTypePydantic instance via a lazy import ofgroundinsight. Raises a clearImportErrorpointing at the optional installpip install groundfield[groundinsight]when the package is missing.evaluate_spec(spec, frequencies, rho)— re-evaluate an exported formula at arbitrary(f, rho)points without round-tripping throughgroundinsight.- Symbol convention:
RhoFStandardFitexports natively in(rho, f);VectorFitResultexports via the symbolic substitution \(s \to j\,2\pi f\) so the resulting formula matches thegroundinsight.BusType.impedance_formulaparser. groundinsightas an optional dependency (extras group[groundinsight]inpyproject.toml).pip install groundfieldworks as before;pip install groundfield[groundinsight]enables the live-BusTypepath.- ADR-0008 (
docs/adr/0008-groundinsight-bridge.md) — design rationale, JSON schema (v1), symbol convention, optional-dependency strategy, and the validation programme (symbol round-trip viagroundinsight.compute_impedance, JSON round-trip, optional-dependency hygiene, end-to-end notebook). tests/test_io_groundinsight.py(12 tests): schema and dict shape, JSON round-trip with bit-exact sample preservation, schema rejection (wrong name / future version),groundinsight.compute_impedancesymbol round-trip for both fit methods (< 1e-9forrho_f_standard,< 1e-3forvector_fit), end-to-endBusType → Bus → calculate_impedanceconsistency,evaluate_specconsistency, optional-dependency hygiene (to_bustyperaisesImportErrorwhengroundinsightis unimportable while the JSON path keeps working).-
Notebook
notebooks/19_groundinsight_export.ipynb— AP1-style end-to-end demonstration: transformer station with ring earth + rods on a 2-layer soil, \(\rho_1\) sweep,RhoFStandardFit,BusTypeexport to JSON and to a livegroundinsight.Network, comparison of the field-grade impedance against the reduced formula evaluation. -
Vector fitting + SymPy export for the rho-f model. New module
groundfield.postprocess.vector_fittingwith: vector_fit(frequencies, Z_values, n_poles, ...)— clean Gustavsen/Semlyen 1999 vector-fitting implementation (single- output, complex/real poles, optional R_∞ and L_∞ residuals).VectorFitResultdataclass with poles, residues, andevaluate(frequencies)for re-evaluation.fit_to_sympy(fit, decimals)— produces a SymPy expression in a single free symbols, with complex-conjugate pole pairs combined into real second-order terms. Compatible withgroundinsight.BusType.impedance_formula.rho_f_from_field_result(result, electrode_name, n_poles)— convenience wrapper that takes aFieldResult, computesZ(s) = U(s)/I(s)per frequency, and runs the fit.tests/test_vector_fitting.py— synthetic ground-truth recovery (real and complex poles), evaluate round-trip, SymPy export consistency, and end-to-end FieldResult flow.-
ADR-0006 / ADR-0007 Phase B (n=2 cross-layer in mom_sommerfeld, cim, bem). The Sommerfeld off-diagonal kernel in
mom_sommerfeld.sommerfeld_kernel_valuenow dispatches tocoupling.layered_green.two_layer_real_space_kernelfor any source/observer pair on a 2-layer soil. Combined with the shared cross-layer-aware diagonal from_two_layer_self_kernel_factory(Phase A), this lifts the cross-layer precondition inmom_sommerfeld,cim, andbemforn_layers == 2.n_layers >= 3still emits a documentedUserWarning. -
Cross-layer electrodes and conductors (ADR-0007, Phase A). Lifts the long-standing precondition
z_max < h_1in the layered backends. Driven rods (Tiefenerder), foundation electrodes, deep meshes and any conductor that crosses the upper-layer interface can now be computed directly. The implementation: groundfield.coupling.layered_green— new module withtwo_layer_spectral_kernelandtwo_layer_real_space_kernelthat solve the 2-layer matching problem numerically and produce the rigorous Green's function for any (source-layer, observer-layer) pair.solver/image_2layerautomatically dispatches to the Sommerfeld Green's function for cross-layer geometries while keeping the historic Tagg/Sunde image series as a fast path for pure-upper-layer worlds (bit-exact regression preserved)._Segment.layer_index— new internal field tagging which layer each segment lives in (0 = upper, 1 = next, …).mom_sommerfeld,cim,bememit aUserWarninginstead of raising on cross-layer geometries, pointing the user atimage_2layeras the Phase A path. Phase B will extend their kernels too (planned).- ADR-0007 (
docs/adr/0007-cross-layer-electrodes.md) — derivation of the cross-layer matching problem, three-phase rollout (A: discretiser + uniform numerical kernel; B: closed- form image series for ll and ul; C: n ≥ 3 layers), and validation programme. tests/test_cross_layer.py— homogeneous-limit checks forlayered_green, reciprocity, image_2layer cross-layer acceptance test, regression test for pure-upper-layer worlds, warning behaviour test formom_sommerfeld / cim / bem.- Notebook
notebooks/17_cross_layer_ap1.ipynb— AP1-realistic driven-rod-through-interface sweep showing the spreading- resistance drop when the rod tip reaches the conductive lower layer; ρ₂ = ρ₁ limit check.
Changed¶
- The
z_max >= h_1precondition inimage_2layeris no longer fatal: the backend transparently dispatches to the Sommerfeld cross-layer kernel. Behaviour for in-bounds geometries is unchanged. -
mom_sommerfeld,cim,bem: same precondition replaced by aUserWarningthat names the recommended workaround. -
Geometric Sommerfeld earth-return Green's function (ADR-0006). New
Engine.earth_inductive_model="sommerfeld"option that integrates the σ-dependent vector-potential Green's function over the actual segment-pair geometry. Rigorous for arbitrary wire lengths and orientations; supports layered earth natively (Pollaczek/Wait kernel) without warnings. In the long-parallel- wires + homogeneous-earth limit it converges (on the cluster level) to ADR-0005's per-meter Carson asymptote. groundfield.coupling.sommerfeld_inductance— new module with:LayeredEarth— frozen layered-earth dataclass.reflection_coefficient_homogeneous/reflection_coefficient_layered— magnetic Fresnel / Pollaczek-Wait coefficients \(\Gamma_\text{mag}^{(n)}(\lambda)\).earth_return_correction_homogeneous/earth_return_correction_layered— point-wise σ-dependent Green's function correction (uses Lipschitz–Hankel identity in the σ→0 limit).sommerfeld_pair_integral_homogeneous/sommerfeld_pair_integral_layered— 16×16 Gauss–Legendre outer integration over a segment pair, vectorised inner Sommerfeld quadrature with split log-then-uniform λ-grid that resolves both the σ-transition and the Bessel oscillations.build_sommerfeld_correction_matrix— dense \(M\times M\) correction matrix consumed by all distributed-capable backends, drop-in alongsidebuild_carson_correction_matrix.groundfield.coupling.resolve_earth_layers— extracts aLayeredEarthfrom any soil model. No warning for layered configurations — they are first-class citizens.- All distributed-capable backends (
image,image_2layer,mom,mom_sommerfeld,cim,bem) consume the new"sommerfeld"switch and pass the layered-earth configuration into the per-frequency builder.femcontinues to log a warning (its equivalent-hemisphere reduction is DC only). - ADR-0006 (
docs/adr/0006-sommerfeld-earth-return.md) — derivation of the magnetic Green's function, three-regime limit checks, two-pillar API (homogeneous Pillar A this release, layered Pillar B same release), numerical strategy, validation programme, and the explicit hand-off from ADR-0005's per-m asymptote to this geometric formulation. tests/test_sommerfeld_inductance.py— reflection-coefficient limits, Lipschitz–Hankel σ→0 identity, σ→∞ collapse, ω→0 DC reproducibility, Sommerfeld-vs-Carson agreement on a long PEN at 50 Hz (cluster level), no-warning behaviour on TwoLayerSoil, layered-vs-homogeneous deviation, cross-engine consistency overimage / mom / cim / bem.- Notebook
notebooks/16_carson_vs_sommerfeld.ipynb— wire-length sweep showing where Carson's per-m asymptote breaks down, layered-earth comparison Carson(top-rho) vs. Sommerfeld(layered), AP1-relevant TN-Ortsnetz example.
Changed¶
Engine.earth_inductive_modelaccepts a third value"sommerfeld"(in addition to"perfect_mirror"and"carson_series"). Default unchanged. ADR-0005's Carson series is now documented as the asymptotic option; ADR-0006's Sommerfeld kernel is the geometric option for AP1-grade studies.-
_assemble_inductance_matrixsignature: gains an optionallayered_earthargument (used whenearth_model="sommerfeld"). -
Carson earth-return correction (ADR-0005). Adds the Carson 1926 finite-conductivity correction \(\Delta Z_\text{Carson}(\omega)\) on top of the perfect-mirror Neumann inductance from ADR-0004. New
Engine.earth_inductive_modelfield with values"perfect_mirror"(default, bit-exact reproduction of ADR-0004) and"carson_series"(Carson 1926). The branch-impedance block becomes \(Z_b(\omega) = R + j\omega L_\text{Neumann} + \Delta Z_\text{Carson}(\omega)\) with the Carson correction rebuilt at every frequency. groundfield.coupling.carson— three-regime evaluation of Carson's \(J(p, q) = P(a, \theta) + jQ(a, \theta)\) following the original 1926 paper:_p_q_small— leading-term form for \(a \le 0.25\) (Carson eqs. 34/35),_p_q_quadrature— direct 64-point Gauss–Legendre numerical quadrature of Carson eq. 29 for \(0.25 < a \le 5\) (replaces the classical Tleis recurrence with a robust numerical kernel),_p_q_large— asymptotic expansion for \(a > 5\) (Carson eqs. 36/37),carson_p_q,carson_self_correction,carson_mutual_correction— public API,deri_semlyen_correction— Deri/Semlyen 1981 complex-depth approximation (sanity-check, not the production path),skin_depth,carson_parameter— diagnostic helpers.groundfield.coupling.resolve_earth_conductivity— extracts \(\sigma_\text{earth}\) from a soil model. Exact forHomogeneousSoil, approximate (upper-layer \(\rho_1\) with aUserWarning) forTwoLayerSoilandMultiLayerSoil.groundfield.coupling.inductance.build_carson_correction_matrix— assembles the dense complex Carson correction matrix \(\Delta Z_\text{Carson}(\omega)\) over distributed-conductor branches using midpoint-rule projection onto Carson's parallel-wire formula (orthogonal components contribute zero by Neumann symmetry).- All distributed-capable backends (
image,image_2layer,cim,mom,mom_sommerfeld,bem) consume the newearth_inductive_modelswitch and rebuild the Carson correction matrix per frequency.femlogs a warning and ignores Carson (its equivalent-hemisphere reduction is DC only). _assemble_inductance_matrixreturns an additional third value: a closureomega -> dZ_carson(omega)(orNonefor the perfect-mirror default)._solve_cluster_currentsand_galerkin_solveaccept a newcarson_correctionargument that is added to \(j\omega L\) inside Block 3.- Penetration-depth diagnostic.
FieldResult.metadatanow exposes"penetration_depth"— adict[float, float]keyed by frequency that returns \(\delta(\omega)\) in metres, populated by every backend that runs an inductive-coupling frequency loop. Together with"earth_inductive_model"this lets notebooks diagnose "is my geometry small or large compared to \(\delta\)?" without re-deriving the formula. groundfield.references.carson— Carson 1926 Section V worked examples (wave antenna at \(r = 4.0\) and \(r = 0.4\), railway at \(r = 0.2\), \(\theta = 63°30'\)) plus four self-consistent regression anchors in the intermediate and asymptotic regimes.- ADR-0005
(
docs/adr/0005-carson-earth-return.md) — Carson series derivation, three-regime split, linear-system integration, soil conductivity source, layered-earth handoff to the Pollaczek follow-up (deferred), and the full validation programme. tests/test_carson_coupling.py— Carson 1926 worked examples, regime-boundary continuity, \(\sigma\to\infty / \omega\to 0\) limits, skin-depth/Carson-parameter relation, Deri/Semlyen cross-check, engine-side regression and frequency-dependence tests, two-layer warning, 1 km PEN textbook benchmark, and cross-engine consistency at 50 Hz.- Notebook
notebooks/15_carson_correction.ipynb— Carson functions across the three regimes, skin-depth diagnostic, 1 km PEN self impedance perfect-mirror vs. Carson, mutual-coupling open-circuit voltage, cross-engine table at 50 Hz / 1 kHz. scripts/benchmarks/pen_1km_carson.py— stand-alone CLI tool that reproduces the AP1-typical 1 km PEN benchmark (self impedance, mutual to a parallel measurement lead, all four distributed-capable backends, side-by-side closed-form Oeding+Carson reference).
Changed¶
Engineschema gains theearth_inductive_modelfield (default"perfect_mirror"). Existing notebooks and tests keep their results bit-exact because the default reproduces ADR-0004._assemble_inductance_matrixsignature: returns a 3-tuple(L_full, has_inductance, carson_builder)instead of the previous 2-tuple. All in-tree callers updated; out-of-tree subclasses must unpack the third element.
Added¶
- Inductive coupling (ADR-0004). Distributed conductors now
carry a partial inductance per longitudinal segment. With
Conductor.inductance_model = "neumann"the solver assembles the Neumann self- and mutual-inductance matrix \(L \in \mathbb{R}^{M \times M}\) over every distributed-conductor branch and plugs it into the branch block as \(Z_b(\omega) = R + j\omega L\). The resulting complex linear system is solved per frequency inengine.frequencies. Withinductance_model = None(default) the system stays real and the historic DC fast path is preserved bit-exact. Earth is modelled as a perfect magnetic mirror in this release; finite- conductivity (Carson) correction is the subject of ADR-0005. groundfield.coupling.inductance— partial-inductance helpers used by the assembly:thin_wire_self_inductance— closed-form Grover formula for a straight thin wire,parallel_segments_mutual— closed-form mutual inductance for two equal-length parallel coaxial segments,neumann_mutual— generic Neumann double-line integral with a closed-form fast path for parallel segments and a 16×16 Gauss–Legendre quadrature fallback for arbitrary 3-D geometry,build_inductance_matrix— assembles the dense per-branch matrix including the perfect-mirror image contribution.- All distributed-capable backends (
image,image_2layer,cim,mom,mom_sommerfeld,bem) now run a frequency loop when inductive coupling is active. The_solve_cluster_currentsand_galerkin_solvehelpers grew matchingomegaandinductance_matrixparameters; the inductance matrix is built once before the loop.femlogs a warning and falls back to the resistive solution (its equivalent-hemisphere reduction is DC only). - New helper
_assemble_inductance_matrixand a typed_DistributedBranchdataclass that carries every branch's endpoints and wire radius, so the inductance assembly works uniformly across backends. FieldResult.metadata["conductor_node_currents"]and["conductor_node_potentials"]now hold one entry per frequency (matching the publicelectrode_currents/electrode_potentialsshape).- ADR-0004
(
docs/adr/0004-inductive-coupling.md) — derivation, linear system, validation programme, and explicit hand-off to the Carson follow-up. tests/test_inductive_coupling.py(12 tests): Grover self-formula, parallel-mutual closed-form vs. Neumann quadrature, perpendicular = 0, anti-parallel sign flip, DC reproducibility, \(\omega \to 0\) collapse to the resistive solution, frequency-induced phase shift on the source-rod current, frequency-scaling open-circuit voltage on a parallel measurement lead (the AP1 question), cross-engine consistency at 50 Hz, FEM warning-and-fallback test.- Notebook
notebooks/13_inductive_coupling.ipynb— Neumann helpers validation, frequency sweep on the source-rod current, loop-coupling open-circuit voltage profile, and the cross-engine table at 50 Hz. groundfield.references.oeding— closed-form per-unit-length loop self- and mutual-inductance formulas from Oeding & Oswald (2016) Elektrische Kraftwerke und Netze, ch. 9 (Eq. 9.8, 9.9, 9.13c) plus the helperstwo_wire_loop_radius(\(r' = r\,e^{-1/4}\)) andinternal_inductance_per_length(\(\mu_0/(8\pi)\)). Used as the textbook reference against which the Neumann implementation is validated in the long-wire limit.thin_wire_self_inductancegained aninclude_internalflag (defaultTrue). The internal-field contribution \(\mu_0\,\ell/(8\pi)\) is now added to the Grover external term by default, so that summing two parallel-wire partial inductances reproduces Oeding Eq. (9.13c) including the +1/4 term. Passinclude_internal=Falseto keep the pure external-field expression for high-frequency / skin-effect studies.tests/test_oeding_inductance.py(10 tests): direct algebraic checks ofoeding.*, plus four parametric Neumann-vs-Oeding 9.13c regressions across (length, distance, radius), a \(1/L\)-convergence test, and two loop-to-loop mutual checks against Eq. 9.8 (in-line and offset geometries).- Notebook
notebooks/14_oeding_validation.ipynb— wire-length sweep showing the \(1/L\) convergence of the Neumann partial-inductance combination to Eq. 9.13c, parameter sweep over \((d, r)\), and loop-to-loop mutual against Eq. 9.8.
Changed¶
metadata["conductor_node_currents"]andmetadata["conductor_node_potentials"]are nowdict[str, list[complex]](one entry perengine.frequencies) instead ofdict[str, complex]. The shape is now consistent with the publicelectrode_*mappings; callers should index[k]for the k-th frequency.
Added¶
- Distributed conductor model (ADR-0003).
Conductornow carries two new fields,discretize_segment_length(m) andcoupling_to_soil("isolated"|"galvanic"). When the segment length is finite the conductor is split into \(n = \lceil L / \Delta s\rceil\) collinear sub-pieces, each with its own longitudinal current. Withcoupling_to_soil="galvanic"every midpoint additionally leaks current into the soil through the same Green's-function kernel as electrode segments — turning a buried bare-copper conductor or an exposed cable shield into a true distributed earth electrode. The augmented linear system in_solve_cluster_currentsand_galerkin_solvecouples the enlarged multi-port grounding matrix \(Z\) (over electrode + galvanic conductor segments) with KCL per cluster node and Ohm's law on each longitudinal segment branch. Per ADR-0003 the longitudinal impedance is purely resistive in this release; inductive and Carson corrections come with ADR-0004. - New helpers
_discretize_conductor,_build_distributed_topology,Conductor.is_distributed,Conductor.n_segments. The cluster builder_build_clustersand the lumped-branch builder_build_finite_branchesskip distributed conductors so they are routed exclusively through the new topology builder. - All seven integral-equation backends (
image,image_2layer,image_nlayervia dispatch,cim,mom,mom_sommerfeld,bem) consume the distributed topology natively.femfalls back to a lumped branch and logs a warning, because its equivalent-hemisphere reduction is not defined for the small midpoint pseudo-electrodes the discretiser would produce. FieldResult.metadata["conductor_node_currents"]and["conductor_node_potentials"]expose the per-midpoint leakage and EPR for galvanic distributed conductors. The publicelectrode_currents/electrode_potentialsmappings stay restricted to real electrodes for backwards compatibility.- ADR-0003 (
docs/adr/0003-distributed-conductor-model.md) documenting the linear system, the boundary conditions, the validation strategy, and the deferred items (inductive coupling → ADR-0004). tests/test_conductors_distributed.py(13 tests): schema additions, lumped-fallback regression, isolated chain series-resistor equivalence (n=2/4/8/16), galvanic chain current conservation and convergence inn_seg, cross-engine consistency onimage,mom,cim,bem, FEM warning-and-lumped-fallback test, three-rod chain end-to-end test.- Notebook
notebooks/12_distributed_conductor.ipynb: algebraic-equivalence sweep, EPR + leakage profile along a galvanic chain, convergence inn_seg, side-by-side cross-engine table,StripElectrodevs distributed-conductor cluster-impedance comparison. - Finite-impedance conductor branches — first step toward the
AP1 distributed-conductor model.
Conductornow carries across_sectionfield (in m²;Nonekeeps the historic ideal galvanic short,"from_radius"resolves to π·r²). Finite conductors enter the solver as branches with series resistance \(R_\text{ser} = \rho_\text{mat} L / A\) in an augmented nodal-analysis system that couples the multi-port grounding matrix \(Z\) with Kirchhoff's current law per cluster node and Ohm's law per branch: Forcross_section is Nonethe branch block is empty and the system collapses bit-exactly to the legacy cluster-equipotential constraint. - New helpers
Conductor.series_resistance,Conductor.effective_cross_section,Conductor.is_ideal()and the routing helpersolver.image._build_finite_branches. The cluster builder_build_clustersnow ignores finite conductors by design — they stay separate clusters and are reattached as branches. - Branch support in all eight backends:
image,image_2layerandcimroute through the augmented_solve_cluster_currents;mom,mom_sommerfeldandbemroute through the augmented_galerkin_solve;image_nlayerinherits via dispatch;femadds a small per-cluster nodal analysis on top of the equivalent-hemisphere reduction. tests/test_conductors_finite.py(15 tests): regression of thecross_section=Nonedefault, closed-form two-rod / one-branch split, \(R_b \to 0\) and \(R_b \to \infty\) limits, monotonicity in \(R_b\), three-rod chain transitive activation, and cross-engine consistency for every backend on a homogeneous and on a 2-layer soil (≤ 3 % vs. image/image_2layer reference, ≤ 7 % for FEM through its equivalent-hemisphere reduction).- Notebook
notebooks/11_finite_conductor.ipynb: two-rod analytical check, sweep over \(R_b\), five-rod PEN chain with per-rod EPR profile, and side-by-side ideal vs. finite comparison. - Pydantic v2 data model:
World,BoundaryConditions,HomogeneousSoil,TwoLayerSoil,MultiLayerSoil,RodElectrode,RingElectrode,MeshElectrode,Conductor,CurrentSource,VoltageSource. Engine(backend selectionimage | image_2layer | mom | fem, frequency list, tolerances) andFieldResultas the unified result object withpoint_sources(discretised current distribution) andresult.potential(points)for evaluating the potential at arbitrary field points.- Image backend for homogeneous soil: image-charge sum over discretised wire segments, line self-action correction. Returns grounding impedances within < 5 % of the Sunde formula for typical driven rods.
- Cluster logic for galvanically connected electrodes: conductors with electrode end-points form a shared potential cluster. Current sharing within a cluster is solved exactly via the multi-port grounding matrix (constraint: φ_i = const ∀ i ∈ cluster, Σ I_i = I_input).
Conductor.start_electrode/end_electrode(Pydantic fields), set automatically bycreate_conductor.FieldResult.clusters(mapping electrode → cluster members) andFieldResult.cluster_impedance(name)for the physically interpretable cluster grounding impedance (parallel combination).- Top-level factories
create_world,create_electrode,create_conductor,create_source,create_engine,run_simulation. - Post-processing plots:
plot_potential_contour(xy or xz slice),plot_potential_profile(potential along an arbitrary line for several depths),plot_potential_radial(trumpet around an electrode). - Smoke-test notebook
notebooks/01_smoke_test.ipynbwith Sunde comparison, "without vs. with connection" section (current/potential table, before/after contour, line profile), and the full plot family. - pytest suite
tests/test_api_smoke.py(22 tests): parametric Sunde comparison, monotonicity, cluster current balance, cluster-impedance plausibility, plot smoke tests. groundfield.references.dwight1936— module with all the closed-form grounding formulas from Dwight, H. B., Calculation of Resistances to Ground, AIEE 1936 (Tab. I): rod, rod pairs (close / far), buried horizontal wire, right-angle, 3/4/6/8-point star, ring, strip, round and vertical plate, hemisphere. Every formula reproduces the worked examples published in the paper to within 0.2 Ω.tests/test_dwight_references.py(19 tests) — image-backend vs. Dwight per geometry. 10 % tolerance for image vs. Dwight, < 0.5 Ω for module vs. paper.- ADR-0001 (
docs/adr/0001-two-layer-method.md) — analysis and decision regarding the numerical methods for 2-layer soil: two engines side by side (Tagg/Sunde image-charge series + MoM with Sommerfeld quadrature). Rationale, convergence, action items. gf.compare_engines(world, engines, ...)and theEngineComparisonreport — cross-engine self-validation per ADR-0001. Tests intests/test_compare_engines.py(6 tests).- Engine A
image_2layer— Tagg/Sunde image-charge series for 2-layer soil (see ADR-0001, action items 1, 4, 6). Adaptive series truncation at|K|^n < 1e-6(max. 100 terms), series diagnostics inFieldResult.metadata(K,n_terms_used,converged). Precondition: every electrode must lie within the upper layer, otherwise a clearValueErroris raised. - Auto-dispatch in
Engine.solve:backend="image"with a world that holds aTwoLayerSoiltransparently switches toimage_2layer— notebooks do not need to change the backend string when the soil model changes. FieldResult.soilas a new optional field.FieldResult.potentialpicks the appropriate Green's-function kernel automatically (homogeneous or 2-layer). For homogeneous solutions the existingsoil_resistivitycontinues to suffice — no breaking migration.- Tests in
tests/test_two_layer.py(8 tests):ρ₁=ρ₂limit, sign of K, auto-dispatch, precondition check, series convergence, cross-engine sanity. - Notebook
notebooks/02_two_layer.ipynbwith parameter sweeps over K and h₁, trumpet comparison homogeneous vs. 2-layer, K=0 sanity plot. - Engine B
mom— Galerkin Method-of-Moments backend (solver/mom.py). Independent second engine for cross-validation (ADR-0001 action item 2). Builds the full segment-level reaction matrix and solves a (N+K)×(N+K) linear system for the per-segment current distribution and the cluster potentials, instead of assuming uniform per-unit-length currents like the image backends. Supports bothHomogeneousSoilandTwoLayerSoilthrough the same Green's-function kernels as the image backends; only the resolution scheme differs. - Cross-engine tests in
tests/test_cross_engines.py(15 tests): image vs. mom on homogeneous worlds (< 2 %), image_2layer vs. mom on 2-layer worlds across the full K range (< 2 %), exact K=0 collapse, multi-electrode cluster cross-check, potential-field consistency. - Notebook
notebooks/03_cross_engine.ipynbdemonstrating the side-by-side validation: cluster-impedance sweep over K, surface potential profiles, and acompare_enginesprogrammatic check. - Logging helpers
groundfield.utils.configure/get_logger. solver/_layered.py— shared layered-soil helpers:LayerStack,as_layer_stack,reflection_gamma(recursive \(\Gamma_1(\lambda)\) from per-interface Fresnel coefficients),image_series_offsets,cylindrical_radius. Used by every new layered backend.- Engine
image_nlayer(solver/image_nlayer.py) — n-layer image-charge dispatcher. Forwardsn=1toimage,n=2toimage_2layer, raisesValueErrorforn ≥ 3with a clear pointer tocim/mom_sommerfeld/bem. Auto-selected frombackend="image"when the world holds aMultiLayerSoil. - Engine
cim(solver/cim.py) — Complex Image Method via matrix-pencil fit of \(\Gamma_1(\lambda)\) as a sum of complex exponentials. Closed-form for any layer count once the fit is done. Exposes the fit throughresult.metadata["cim_n_images"]andcim_rms. Public helper:fit_complex_images(stack, …). - Engine
mom_sommerfeld(solver/mom_sommerfeld.py) — Galerkin MoM with direct numerical Sommerfeld quadrature of the layered Green's function (scipy.integrate.quad). Slow but methodologically independent reference engine. Public helper:sommerfeld_kernel_value(stack, s, z, z_s, …). - Engine
bem(solver/bem.py) — boundary-element collocation with the closed-form CIM kernel. Companion tomom(Galerkin) with a different test-function weighting. - Engine
fem(solver/fem.py) — axisymmetric volume PDE on a cylindrical \((s, z)\) triangular mesh, layer boundaries as element-wise conductivities, sparsespsolve. Reduces every cluster to its equivalent hemisphere via Dwight 1936 closed forms; exposes the reduction throughresult.metadata["equivalent_hemisphere_radius"]. - Backend literal extended to
image | image_2layer | image_nlayer | cim | mom | mom_sommerfeld | bem | fem, andEngine.solveauto-dispatchesbackend="image"toimage_nlayerforMultiLayerSoilworlds. - Test suite extended by ≈ 50 tests:
tests/test_image_nlayer.py(5 tests),tests/test_cim.py(7 tests),tests/test_mom_sommerfeld.py(6 tests),tests/test_bem.py(6 tests),tests/test_fem.py(5 tests),tests/test_cross_engines_extended.py(≈ 25 parametric tests covering homogeneous-rod agreement, 2-layer engine agreement, two-rod cluster agreement, and layer-contrast monotonicity for every engine). - Notebooks
04_image_nlayer.ipynb,05_cim.ipynb,06_mom_sommerfeld.ipynb,07_bem.ipynb,08_fem.ipynb,09_cross_engine_extended.ipynb. Each contains a single-rod sanity, the user-mandated two interconnected grounding systems test case (potential profile, cluster impedance, current split), a layer sweep, and a self-checks block with explicit collapse / monotonicity verification. - Notebook
10_trafostation.ipynb— application-style cross-engine reference for the grounding system of a small transformer station (ring earth electrode + two driven rods + optional strip earth electrode in a 2-layer soil). All(x, y)electrode positions are exposed as parameters; rods and strip can be switched on/off via flags. Reports cluster impedance for every engine,compare_enginesconsistency check, and surface-potential 3-D and contour plots. Uses the nativeStripElectrodeprimitive. StripElectrode(kind="strip") — first-class horizontal straight strip earth electrode (Banderder). One wire fromstarttoendat a fixed depth, arbitrary in-plane direction. Replaces the prior degenerate-mesh workaround. Discretiser added tosolver/image.py:_discretize_stripand shared with every integral-equation backend through the existing dispatcher. Plausibility test againstdwight1936.horizontal_wireactivates the previously reserved entry intests/test_dwight_references.py. Dedicated tests intests/test_strip.py.GridMeshElectrode(kind="grid_mesh") — rectangular meshed earth electrode with explicitn_x×n_yinner meshes (n_x + 1transverse andn_y + 1longitudinal wires). Cleaner alternative toMeshElectrode(which keeps its uniform-spacing API for backwards compatibility). Discretiser shares_grid_segmentswith the legacy mesh. The FEM equivalent-hemisphere reduction forGridMeshElectrodeuses the Schwarz / Sverak / IEEE Std 80 formula \(R \approx \rho/L_C + \rho/\sqrt{20 A}\,(1 + 1/(1 + h\sqrt{20/A}))\) so that the inner mesh density is reflected in the FEM cross-check (the legacyMeshElectrodekeeps the prior strip-along-diagonal proxy).- ADR-0002 (
docs/adr/0002-engine-family.md) — engine selection heuristic and cross-validation envelope.
Changed¶
Engine.solvenow dispatches onworld.soil.solve_image(private API) remains strictly limited toHomogeneousSoil; the friendly forwarding only happens insideEngine.solve.- Internal refactoring:
_solve_cluster_currentsis now kernel-agnostic and takes aself_kernelclosure, so both the homogeneous and the 2-layer backend share the same cluster logic. - Project language switched to English: every Python source
file, docstring, comment, Markdown documentation page, ADR,
notebook, and CHANGELOG entry is now in English. This brings the
project in line with the open-source conventions for
international distribution. Variable names were already English;
only prose changed. The repo-internal
CLAUDE.mdwas updated to reflect the new language convention.
Internal¶
- The
mombackend now returns real results (Galerkin scheme on segment level). Thefembackend now returns real results (axisymmetric volume PDE with equivalent-hemisphere reduction); the prior stub is removed. - Bugfix in
_two_layer_self_kernel_factory: theextraaccumulator was hard-wired to a 1-D shape, which broke the matrix-mode call used by the new MoM backend (identity-matrix probe to obtain the full reaction matrix in one pass). Changed tonp.zeros_like(phi). - Notebook file
02_zweischicht.ipynbwas renamed to02_two_layer.ipynb; ADR file0001-zweischicht-verfahren.mdwas renamed to0001-two-layer-method.md. MkDocs navigation and cross-references updated.
Docs¶
docs/graphify.md— new tooling page describing the hand-driven workflow for building a queryable knowledge graph ofgroundfieldwithgraphify(PyPI:graphifyy, MIT) for use by AI coding assistants. Covers installation viapipx(so the ~25 transitive Tree-sitter language packages stay out of the project lock file), the build-vs-query cost split, the manualextract/updateworkflow, the recommendedgraphify claude installClaude-Code integration (which routes day-to-day queries through Claude Code'sReadtool so they count against a Claude Pro / Max subscription rather than billed API tokens), the--globalcross-repo graph spanninggroundfield,groundinsightandgroundmeas, and the limits (LaTeX /.ipynbnarrative unparsed). The page is registered under a new Tooling section inmkdocs.yml..gitignore— exclude thegraphify-out/working cache so the locally generated graph (which depends on the chosen LLM backend and revision) is not committed.
Internal¶
scripts/generate_graphify_report.py— workaround that rendersgraphify-out/GRAPH_REPORT.mdfrom the JSON artefacts thatgraphify extractdoes write (.graphify_analysis.jsonplusgraph.json). Necessary becausegraphify0.7.x silently stopped emitting the Markdown report itself, while thegraphify claude installPreToolUse hook and the matchingCLAUDE.mddirective both still reference that file as the entry point AI coding assistants should read first. The script is AST/JSON-only — no network, no API access, no LLM call — and is intended to be removed once upstreamgraphifyre-emits the Markdown report directly. Run viapoetry run python scripts/generate_graphify_report.pyafter everygraphify extractinvocation.
No code, public API or solver behaviour is touched by this entry —
graphifyy is intentionally not added to pyproject.toml,
mirroring the way other developer tools (black, ruff, mypy)
are kept out of library dependency graphs.
0.1.0 — 2026-04-24¶
Initial project skeleton for groundfield.
Added¶
- Package layout analogous to
groundinsight, withsrc/groundfield/and the subpackagessoil,geometry,conductors,solver,coupling,postprocess,io,utils. - Poetry configuration (
pyproject.toml) with dev and docs groups. - MkDocs Material scaffold (
mkdocs.yml,docs/) for the future documentation site on GitHub Pages. - Release automation (
scripts/release.py) and third-party license report (scripts/generate_third_party_licenses.py) ported fromgroundinsight. - GitHub-Actions workflows
ci.yml,docs.yml,release.yml. CITATION.cff,LICENSE(MIT),.gitignore,CLAUDE.md(context for AI assistants).- First smoke test (
tests/test_import.py).
Internal¶
- Project shell as the foundation for work package 1 of the dissertation on networked grounding systems: TN distribution network with layered soil, house connections, and cable cabinets.
Backlog (2026-05-10 audit)¶
Items identified in the cross-cutting code-review pass on 2026-05-10 that did not ship in Unreleased. Each entry is queued for a future maintenance release; once an item ships, the matching CHANGELOG line moves into Unreleased. The list is kept here (rather than in Unreleased) so the published release notes describe shipped work only.
Fixed (pending implementation)¶
The following bugs and inconsistencies were identified in the code-review pass on 2026-05-10. They are queued for the next maintenance release; the entries are recorded here so each one ships with a referenced fix commit.
references.oedingandreferences.carsonare not re-exported on thegroundfieldpackage, whilereferences.dwight1936is. The asymmetry surprises users who expectgf.oeding.loop_self_inductance_per_length(...)to exist after seeinggf.dwight1936.hemisphere(...).coupling.resolve_earth_conductivityraises a genericTypeErrorfor unsupported soil models. Use the package's own exception type (or document the type explicitly), and cover the failure path intests/test_carson_coupling.py— currently the only call sites are inside the engine builders and the failure is never exercised.coupling/inductance._build_inductance_matrix_loopis exported in__all__with a leading underscore —from groundfield.coupling.inductance import *therefore does not expose it (Python skips underscore names) and the only caller is the regression test. Either drop it from__all__or rename without the leading underscore.generators.tn_ortsnetzre-export is documented as deprecated but the deprecation warning is only emitted when the module is imported, not when individual symbols are accessed viafrom groundfield.generators.tn_ortsnetz import TnOrtsnetzGenerator. Add a__getattr__so the symbol-level access also warns. Plan the removal target (e.g.0.5.0).generators.measurement.MeasurementSetupConfigmutatessource.return_toof the parentTnNetworkConfigduringbuild. If the sameTnNetworkConfiginstance is reused for a non-measurement build afterwards, the now-stalereturn_topointer leaks into the second world. Either deep-copy the source spec inside the generator, or document the one-shot-config contract.coupling/sommerfeld_inductance.build_sommerfeld_correction_matrixuses a fixed 16 × 16 Gauss–Legendre node count for the outer pair integration. There is no mechanism to refine the grid when the dimensionless length is large; the docstring promises convergence but the production path runs at a single resolution. Either expose the resolution knob or document the upper bound where the fixed grid still resolves the kernel.solver/result.FieldResult.cluster_impedance(name)returns onecomplexper frequency but the summary docstring at the top ofresult.pydescribes the helper as "scalar". Fix the summary docstring; the per-call docstrings are correct.
Additional findings from the second 2026-05-10 review pass:
generators.measurement.MeasurementSetupConfig.buildhas no early-out if bothfeed_leadandprobe.leadareNone; it still walks every spec and conditionally skips the lead creation. The early-out makes the "no inductive coupling, just galvanic Analysis 1" case explicit and protects against future regressions where a new lead-related field is added.solver.image._build_finite_branchesand the augmented multi-port system in_solve_cluster_currentssilently ignore the case where aConductor.cross_sectionresolves to a non-positive value. TheConductor.series_resistancehelper does check for<= 0and falls back to the ideal short, but the path is not exercised intests/test_conductors_finite.py. Add a regression test forcross_section = 0.0andcross_section = "from_radius"withradius_m = 0so future refactors of the helper cannot introduce a silent NaN.coupling/sommerfeld_inductance.sommerfeld_pair_integral_layereduses a split log-then-uniform λ-grid (changelog describes the strategy) but the split point is hard-wired to the per-pair σ-transition. For pairs with very large dimensionless length the linear part of the grid silently under-samples the Bessel oscillations. Expose the cross-over knob and lock it down with a notebook plot of the integrand to make the convergence visible.postprocess.convergence.convergence_studyclones the engine viaengine.model_copybut does not deep-copy the optionalfrequencieslist. If a caller passes the same Python list asfrequenciesto two engines, mutating one list will silently mutate the cloned engine's list too. Either deep-copy or document thatengine.frequenciesis treated as immutable.generators.distributions.Discreteweights are not normalised eagerly, so aDiscrete(values=[1, 2], weights=[10, 5])callsnumpy.random.choicewith un-normalised weights (numpy normalises internally, but the un-normalised weights stay on the model and round-trip through JSON). Add amodel_validator(mode="after")that normalises weights to sum to 1 and remembers the original sum inmetadataif a future audit needs it.io/csv.save_potential_path_csvwrites a header line withfrequency_Hzeven when the saved frame contains a single frequency. Callers piping the output back into the multi-frequency reader path get a one-row-per-frequency layout regardless of source dimensions — fine, but the docstring should explicitly state the long-format contract.io/vtk.export_field_vtksilently truncates the field onn=(120, 120)even for very large worlds (a 200-EFH AP1 network spans ~150 m × 150 m; 120 grid points gives ~1.25 m resolution, comparable withengine.segment_lengthand therefore aliased). Either raise aUserWarningwhen the grid spacing exceedsengine.segment_length / 2or expose anauto_grid=Trueheuristic.postprocess/current_balance.split_factorraises on any source whose magnitude evaluates to zero atfrequencies[frequency_index]. For frequency sweeps where the source is intentionally inactive at some harmonics (e.g.{50: 1.0, 250: 0.0}) the helper aborts the whole table. Returncomplex("nan")(or a documented sentinel) for the zero-magnitude rows instead.generators.measurement.single_rod_grounding/neighbour_substation_groundingsignatures accept no RNG, so a stochastic placement (aUniformover the rod position) cannot reproduce a previous draw. Add an optionalrngparameter that defaults to a deterministic default (e.g.np.random.default_rng(0)) so test cases stay reproducible.postprocess.geometry_plot.plot_world_3dusesax.invert_zaxis()after every call, but does not store / restore the originalzlimifaxis passed in by the caller. Successiveplot_world_3dcalls on the same axis flip the z-axis back and forth.solver/result.FieldResult.summaryis still missing a docstring as flagged in the 2026-05-09 audit; the second pass confirms that the public method has not been touched. Re-list here so it is not lost between audits.
Additional findings from the third 2026-05-12 review pass (focus:
FieldResult.potentialdispatch,BoundaryConditionswiring,references.dwight1936validation,compare_enginescoverage; deeper read of thepostprocess.sweepheatmap plotter and the new measurement-injection layer):
sources.Source.attached_tois not validated against the world's electrode / conductor list. A typoedattached_to="g_1"(instead of"g1") silently drops the source current at solve time, because the backends iterate withif src.attached_to in elec_input_current:and just skip unknown keys. Raise a clearKeyErrorinWorld.add_source(...)or at the top of every backend'ssolve_*.solver/result.FieldResult.potential(and the VTK export) mirror about a hard-codedz = 0even thoughsafety.touch_voltageexposes asurface_zargument. If the user sets up a world with the soil surface at any other plane (local-terrain coordinate), the image charges sit at the wrong depth. Either addsurface_ztoFieldResult.potential/io.vtk.export_field_vtkor documentz = 0as a hard invariant.conductors.Conductoracceptsstart == end(zero length). Nomodel_validatorrejects coincident endpoints, and the discretisation step_discretize_conductorlater divides byL, producing a hardZeroDivisionErrorfor any user who setdiscretize_segment_lengthon such a conductor. Add@model_validator(mode="after")that raises iflength < 1e-9 m.validation.compare_engineschecksZ.realonly (validation.py:~192,197). For Carson / Sommerfeld engines or any inductive-coupling configuration the imaginary part can dominate at higher frequencies — a 30 % imag-part deviation goes undetected. Alsoif zmean == 0.0: continuesilently skips clusters with symmetric real-part sign cancellation. Compareabs(Z), or compareZ.realandZ.imagseparately with the same tolerance.generators/distributions.Discreteallows duplicatevalues. UnlikeCategorical(which has_check_unique),Discrete(values=[5, 5, 10], weights=[1, 1, 1])is accepted and silently double-weights5. Either merge duplicates + add their weights, or add a_check_uniquevalidator and raise.io/vtk._format_lines_blockis dead code — defined but never called;export_geometry_vtkre-implements the same logic inline. Either delete the helper or have the exporter use it.references.dwight1936validation is inconsistent. Onlyrod,two_rods_farandhemispherevalidatelength > 0/radius > 0;two_rods_close,horizontal_wire,right_angle_wire,n_point_star,buried_ring,horizontal_strip,horizontal_round_plateandvertical_round_plateaccept any input. Callingdw.buried_ring(rho=100, ring_diameter=-1, ...)returns a meaningless float rather than raising. Apply the samelength > 0/radius > 0guard at every entry point.postprocess/sweep.plot_sweep_heatmapusesimshow(..., extent=(min, max, ...))on apivot_table. Non-uniformly spaced axis values (e.g. log-spaced soil resistivity[10, 100, 1000]) render as equally-wide rectangular cells — visually misleading. Switch toax.pcolormesh(pivot.columns, pivot.index, pivot.values)which honours the actual coordinate spacing.
Changed (pending implementation)¶
generators/measurement.MeasurementInjectionConfig.feed_leadusesOptional[MeasurementLeadConfig]while the rest of the module usesMeasurementLeadConfig | None. Pick one and apply consistently across the spec layer.generators/distributions.LogNormal.from_moments— verify that its constructor takes the standard deviation (consistent withNormal) rather than the variance, and that the docstring matches the implementation. Two adjacent docstrings disagree on whethersigmaorvaris the expected argument.postprocess/geometry_plot.plot_world_3dusesmpl_toolkits.mplot3ddirectly — the inverted z-axis is correct under%matplotlib inlinebut occasionally flips withwidget/ipymplbackends. Add a regression notebook cell + screenshot tonotebooks/20_tn_ortsnetz_generator.ipynb.
Docs (pending implementation)¶
The package now has a sprawling public surface that is only partially mirrored in the docs site. The next docs sweep should:
- Add
docs/api/references.md— thereferencessubpackage (dwight1936,oeding,carson) is reachable viagf.dwight1936.*and via directfrom groundfield.references import *, but neither the API navigation nor the doc index links to it. The classical reference formulas are exactly what a reviewer would look up first. - Add
docs/api/validation.md—gf.compare_enginesandEngineComparisonare top-level exports but only mentioned in passing indocs/api/index.mdand in the engine theory pages. Promote to a dedicated page. - Add
docs/api/world.md—World,BoundaryConditionsand theworld.summary()helper are top-level exports without their own page. docs/api/postprocess.mddoes not yet have dedicated sections for the safety, current-balance and geometry-plot modules — onlyvector_fittingandrho_f_standardare written up. Add headings and:::directives so the new helpers (touch / step voltage, EN 50522 limit, cluster / electrode current tables,split_factor,plot_world/plot_world_3d) become part of the navigable reference.docs/quickstart.mdanddocs/concepts.mdwere updated in the previous audit pass to cover all eight backends — the next step is to extenddocs/concepts.mdwith a dedicated section on the current-balance / split-factor postprocessing (today the only entry point isexamples/04_grounding_measurement.md).docs/performance.mdreferences Notebook 21 (Sommerfeld vs Carson scan) — verify the link still resolves after the ADR-0010 cleanup.docs/api/coupling.mdstill usesresolve_earth_conductivityandresolve_earth_layersas the only documented entry points to the soil-resolver layer, even though the layered-Green-function and Sommerfeld inductance modules have grown several public helpers. Add a dedicated heading per public helper so mkdocstrings produces a discoverable index.docs/api/postprocess.mdrefers tonotebooks/01..22in prose; themkdocs.ymlexamples/nav lists eight hand-curated example pages but does not enumerate the raw notebooks. Decide whether to publish the full notebook collection under a dedicated nav section (Notebooks: notebooks/01_*.ipynb... ) or to keep curation as today.docs/api/diagnostics.mdexample usesPenConfig— the symbol moved togenerators.tn_network; verify the import path still works and update the example if not.- README does not yet mention the world generator, the measurement-setup layer, the safety helpers or the current-balance helpers — the public surface has grown without a corresponding README rewrite.
- mkdocs
extra_javascript: polyfill.ioreference (mkdocs.yml:90) — thegroundmeassister project removed this URL because the domain was sold and the CDN later served malicious JavaScript. The same removal should happen here; modern browsers do not need the polyfill for MathJax 3.
Additional doc gaps from the second 2026-05-10 review pass:
- No
docs/api/generators/measurement.mdsub-page. The measurement-setup layer (MeasurementLeadConfig,MeasurementInjectionConfig,MeasurementProbeConfig,MeasurementSetupConfigand the four convenience factoriesoverhead_lead,buried_lead,single_rod_grounding,neighbour_substation_grounding) is the largest single API addition in Unreleased but the only entry point on the doc site is its mention indocs/api/generators.md. Add a sub-page that explains Analysis 1 vs. Analysis 2, the return-path-via-return_towiring, and the galvanic/inductive switch flow. - No
docs/api/postprocess/sweep.mdorconvergence.mdsub-pages. Both are stand-alone modules with their own plot helpers and public DataFrames; they deserve dedicated headings underdocs/api/postprocess.mdrather than being lumped underPostprocess. docs/api/diagnostics.mddescribesworld_statistics,expected_segmentsandcheck_segment_resolutionbut does not list the AP1 budget thresholds (5000soft,20000hard) that the warning text refers to. Quote them from the source so the user reading the docs can match the warning before opening the file.- No
docs/engines/_index_table.mdstyle overview of which backend supports which feature combination (HomogeneousSoil×TwoLayerSoil×MultiLayerSoil×inductance_model×earth_inductive_model×distributed conductors). Today the user has to read the six engine pages individually. A single capability matrix closes the gap. - README quickstart still uses
gf.run_simulation(...)-style imports while theEngine.solve(world)pattern is now the canonical entry point (every example notebook 01..22 uses it). Update. docs/installation.md— the optional[groundinsight]extra is declared inpyproject.tomland referenced fromdocs/api/io.md, but the install page does not saypip install groundfield[groundinsight]. Mirror thepandapowercallout fromgroundinsight/docs.docs/concepts.md"current balance and split factor" — add a new section under Concepts that ties togethercluster_current_balance,electrode_current_tableandsplit_factor. The example currently lives only inexamples/04_grounding_measurement.mdand is therefore invisible to a reader who lands on Concepts first.docs/api/world.md(still missing) blocks theworld.summary()cross-layer warning entry from the Roadmap below —summarydoes not have a doc page to attach the new behaviour to.- README Python-version badge still reads
3.12 | 3.13;pyproject.tomldeclares^3.12so 3.14 is supported. Extend the badge so users do not assume 3.14 is unsupported. - CLAUDE.md project notes for
groundfieldstill claim0.1.0while the public surface is several releases ahead. Either bump the field manually or hookscripts/release.pyto update it frompyproject.toml(same drift problem the cross-repo audit identified ingroundinsight/CLAUDE.mdandgroundmeas/CLAUDE.md).
Additional doc gaps from the third 2026-05-12 review pass:
- No
docs/api/sources.mdpage.Source,CurrentSource,VoltageSourceand the new measurement-injection wiring are only documented inline indocs/api/index.md. A dedicated page lets us spell out theattached_tocontract (which is currently silently best-effort — see the bug above) and thesource_type='voltage'mutual-coupling restriction. - No
docs/api/boundary.mdpage.BoundaryConditionsis in the public API but undocumented. Until the fields are consumed by any backend (see bug above), users should be told in writing thatworld.set_boundary_conditions(...)is decorative. docs/engines/fem.mddoes not document the cluster equivalent-hemisphere approximation (radii-add rule) or its error envelope when the cluster electrodes share a field. Without that note a reader comparing FEM with the integral backends sees a small but unexplained delta on dense clusters.docs/api/references.mdis still missing as flagged in pass 2, and the test-suite-facing entry points (references.carson.CarsonExample,all_examples(),REGRESSION_ANCHORS) need to land on the page so contributors can find them when adding regression cases.docs/api/io.mddoes not documentio.groundinsight.evaluate_spec/io.groundinsight.fit_quality_summary/BusTypeSpec.from_dict. All three are publicly importable helpers but the rendered page lists only thesave_*/to_*family. Add a:::directive for each.docs/concepts.md"World object → Boundary" section should document the no-op status ofBoundaryConditionsexplicitly until the fields are wired through. Two lines prevent the most likely "why is my Dirichlet not applied" support question.
Tests (pending implementation)¶
- No cross-engine consistency test that exercises the
Sommerfeld earth-return path on layered soil for every
distributed-capable backend at 50 Hz / 1 kHz / 5 kHz. The
current
tests/test_sommerfeld_inductance.pycovers the homogeneous-vs-Carson agreement at 50 Hz only. - No regression test for the
mutual_branchesskip incoupling/sommerfeld_inductancefor voltage sources. The changelog notes that voltage sources skip the mutual contribution and emit a one-time warning; add a test that asserts the warning text and that the skip indeed produces the no-coupling baseline. tests/test_distributions.pyasserts mean/std within 5 % over 10 000 samples, but no test verifies that the rejection-sampling path on the truncated Normal does not exhaust silently for very narrow(low, high)windows. Already mentioned as a roadmap item; lock it in with a test.tests/test_generators_base.pydoes not exercisecfg.has_distributions()on the newTnNetworkConfig.measurementsub-config. Add a test that flipping a single measurement-lead field to aDistributionflipshas_distributions()fromFalsetoTrue.tests/test_geometry_plot.pyruns 18 smoke tests but none asserts thatplot_worldraises (or returns an empty figure cleanly) on an empty world withextent=Noneandpadding_m=0(degenerate bounding box).
Roadmap candidates (from the 2026-05-10 audit)¶
The following items emerged from the cross-cutting review of the public surface, the docs site and the test suite. Triage into the appropriate Roadmap section in the next planning cycle.
Reduktionsfaktorproper. The galvanicsplit_factordocumentation explicitly notes that the helper is not the German EVU/Schirmtechnik Reduktionsfaktor (the inductive coupling correction). The inductance backends incoupling/inductance.pyare already in place — a thin wrapper that integrates the inductive contribution along the parallel-conductor path and returns the magnitude / phase of the additional EMF would close the gap.Network → groundinsightround-trip — given a multi-port Z matrix produced bygf(Kron-reduced from a field-grade world), assemble the correspondinggroundinsight.Networkwith one bus per port. Symmetric counterpart to theBusTypeexporter.- EN 50522 clearing-time helper bundle —
gf.assess_touch_voltage(result, world, electrode, t_clear_s)returning a single namedtuple (U_T_max,U_TP_limit,passes). Trivial wrapper but missing today; the measurement-setup notebook (notebook 20 §9) re-implements the comparison inline. world.summary()should report cross-layer electrodes explicitly — today it lists every electrode kind and depth, but a user staring at a(z_min, z_max)pair has to mentally intersect withsoil.h_1to know whether the ADR-0007 cross-layer path is exercised.- Notebook front-matter — five of the recent notebooks
(12–22) carry similar names ("inductive coupling", "Carson",
"Sommerfeld") that get hard to disambiguate in the file
browser. Adding a one-line YAML front-matter (
adr: 0005,feature: tier0) would let the docs build group them by topic.
Additional roadmap candidates from the second 2026-05-10 review pass:
MeasurementSetup→ResultMeasurementpost-processor. Today the AP1 measurement notebook (notebook 20 §9) reads the source / probe potentials by hand and computesZ_meas = U_probe / I_inj. Wrap the pattern into a post-processor that pulls the right potentials from aFieldResultand returns the measured-vs-true earthing impedance plus the 62 %-rule diagnostic. Closes the AP1 Analysis 1 + 2 measurement-side workflow as a single call.gf.export_capability_matrix(file)— auto-generated Markdown of the engine × feature support matrix described in the docs gap above. Lives next to the existingcompare_enginesso the docs and the test suite share a single source of truth.postprocess.convergence.convergence_studywith two axes — extend the current single-axis (segment_length) helper to a 2-D mesh-refinement sweep so the user can jointly tightensegment_lengthand (e.g.)discretize_segment_lengthfor distributed conductors. The natural counterpart ofsweep.sweepfor refinement studies.generators.tn_network.TnNetworkGenerator.summary()— one-line text summary that mirrorsWorld.summary(), but for the config (before sampling) and the built world (after sampling) side by side. Useful for the AP1 parameter notebooks where the user wants to confirm what was actually drawn before invoking the solver.io.groundinsight.BusTypeSpec.to_branchtype()/save_branchtype_to_db()— symmetric counterpart of the existingto_bustypefamily. Manygroundinsightnetworks expose the cable shield / PEN trunk as aBranchwhose type'sR_mutual_formula/M_mutual_formulacome from a field-gradegfsolve. The full bridge is incomplete untilBranchTypeis also exportable.postprocess.safety.touch_voltage_envelopewith a layered-soil correction. The current envelope uses the scalarresult.potential(...); on a 2-layer soil the envelope should integrate the bus-current image series too. The kernel is already incoupling.layered_green; wire it in behind asurface_potential_model="layered"switch and document the speed cost.
Additional test gaps from the third 2026-05-12 review pass:
- No regression test that
FieldResult.potentialraises (or dispatches correctly) on aMultiLayerSoilworld. Build a 3-layer fixture, callresult.potential((x, y, 0.0))and assert the result is not silently produced by the homogeneous kernel. - No test that
World.add_source(src)rejects a typoedattached_to(e.g."g_1"when the world has"g1"). Currently the bug surfaces only at solve time and only as a zero-injection result. - No regression test for
Conductor(start=p, end=p)(zero length). Once the validator above lands, this becomes a one-linepytest.raises(ValidationError)case. - No
compare_enginestest on the imaginary part. Build a Carson coupling fixture, set the existing real-only check to pass, then add aZ.imagcheck at 1 kHz that would fail without the imag branch. - No
Discrete(values=[5,5,10], weights=[1,1,1])test. A one-line assertion that duplicates are rejected (or merged) pins the chosen behaviour from bug 7.
Additional roadmap candidates from the third 2026-05-12 review pass:
FieldResult.potential(..., surface_z=0.0)argument. Allow the user to plug in a non-zero soil-surface plane in local-terrain coordinates. The plumbing already exists insafety.touch_voltage; surface it on the post-solve potential evaluator too so the VTK export and the safety helper agree by construction.fem.equivalent_hemisphere_radiuswith mutual-resistance correction. Tagg-style cluster equivalence: replace the pure radii-add rule with a per-electrode mutual-resistance matrix lookup so clusters of driven rods produce the right conductance.gf.evaluate_spec/gf.fit_quality_summaryas top-level re-exports. Both are publicly importable helpers inio/groundinsight.pybut reachable only via the fully qualified path. Promote next tovector_fitfor symmetry.EarthInductiveModelas a top-level re-export — the Literal lives insolver.engine.__all__but not ingroundfield.__all__; users writing type annotations have to dig into the submodule.postprocess.geometry_plot.plot_world_top_down(..., annotate=True)— for worlds with ≤ 30 electrodes the annotated plot is invaluable for debugging measurement-setup geometry. Add an opt-inannotateflag with a sensibleannotate_thresholddefault.postprocess.sweep.plot_sweep_heatmaplog-spaced cell rendering. Switch topcolormeshso the rendered cell widths match the actual log-spaced axis (bug 10). Stand-alone visual fix, no API impact.
Fixed (pending implementation) — fourth 2026-05-12 review pass¶
Focus of the fourth pass: discriminated-union annotation on the
Sourcere-export, boundary-condition warning semantics on reverts to the default, missing top-level re-exports vs. the sister project's flatgi.*surface, mkdocs notebook nav gap.
groundfield.sources.Source = Union[CurrentSource, VoltageSource]lacks Pydantic'sDiscriminator("kind")annotation. Pydantic falls back to attempting validation against each member in declaration order during JSON deserialisation; a malformed payload withkind="voltage"but missingmagnitudesilently falls through toCurrentSourceand is rejected there with amagnitudeerror pointing at the wrong class. Mark the union asAnnotated[Union[CurrentSource, VoltageSource], Discriminator("kind")]so the error message matches the declaredkind.World.set_boundary_conditions(...)warns only on non- default kwargs. A user who first setset_boundary_conditions(far_field="neumann")and then callsset_boundary_conditions(far_field="dirichlet")to revert sees no warning, even though the latter call silently re-arms the v0.2.0 default. Either also warn on every transition that flips a previously non-default field back to its default (because the previous non-default state was never actually consumed) or document the asymmetry.World.add_source(source)does not validateattached_toorreturn_toagainst the world's electrodes/conductors. A typoattached_to="g_1"on a world with electrode"g1"is accepted; the bug surfaces at solve time as a zero-injection result with no diagnostic. Already flagged in pass 3 — pass 4 confirms the path is still unguarded.io.groundinsight.evaluate_spec,io.groundinsight.fit_quality_summaryandcoupling.LayeredEarthare publicly importable but missing fromgroundfield.__all__(__init__.py). Users who writegf.evaluate_spec(...)get anAttributeError; consistency with thegi.*flat surface (which re-exports analogous helpers) is broken. Promote all three to top-level re-exports.solver.engine.Engine.frequenciesis silently sorted on validation, but the public docstring does not mention the reorder. Notebooks that passfrequencies=[5000, 50]because they want the dominant 5 kHz tone first in the result get a silently-reordered result array — a real surprise when joining against an external DataFrame indexed by the input order. Either preserve order on construction or document the reorder.generators.measurement.MeasurementSetupConfig.buildsetssource.return_toto the auxiliary anchor, but does not guard against aTnNetworkConfig.sourcewhosereturn_towas already explicitly set by the user (e.g. for a multi-source AP 1 study with two parallel measurements). The user-suppliedreturn_tois silently overwritten. Either copy-on-write the source spec inside the generator (already on the pass 1 backlog as a deep-copy hint) or raise a clear error.postprocess.vector_fitting.vector_fitacceptsn_poles=0and returns a constant-only fit, butfit_to_sympythen produces an expression with nosdependence — the matchinggroundinsight.BusTypeis degenerate (Z(rho, f) = const, ignoring rho and f). Validaten_poles >= 1at function entry or document the degenerate case explicitly.io.csv.save_potential_path_csvwritesabs_phi = |phi_re + 1j*phi_im|under the columnabs_phi, but the correspondingsave_cluster_impedances_csvandsave_electrode_table_csvuse the suffix_abs(abs_Z, notZ_abs). Pick one convention and apply it acrossio.csvso DataFrames produced by the three writers can be joined on a uniform column-naming scheme.mkdocs.yml:90still referenceshttps://polyfill.io/v3/polyfill.min.js?features=es6— fourth audit pass in a row that flags it.groundmeasalready removed the URL; do the same here and ingroundinsight.mkdocs.ymlnav lists the eight curatedexamples/pages but not the 29 raw notebooks (notebooks/01..29). Mirror the same gap reported forgroundinsight: the feature work innotebooks/22_tier0_speedup.ipynb,notebooks/23_safety_touch_step.ipynb,notebooks/29_io_csv_vtk.ipynbetc. is invisible to the docs-site reader unless they go to the GitHub tree.generators/__init__.pydoes not re-export thesingle_rod_grounding(rng=...)factory'srngargument contract through a short doctest. The factory's RNG default isNone(no jitter), but the only example in the docs (docs/api/generators.md) shows the deterministic call — the stochastic Monte-Carlo path is not advertised even though the rest of the generator framework's pass-3 bug list listsrngas a missing parameter.
Docs (pending implementation) — fourth 2026-05-12 review pass¶
docs/api/sources.mdstill missing (third audit pass in a row). The pass-3 entry remains in the backlog above; pass 4 confirms the file has not been created. Without the page, the discriminated-union fix above has no natural home for its migration callout.docs/api/boundary.mdstill missing (third audit pass in a row). Pass 4 confirms the gap;BoundaryConditionsand the v0.2.0 no-op contract live only in source docstrings.docs/api/references.md,docs/api/validation.md,docs/api/world.mdare all still missing — pass 4 confirms three consecutive audits have flagged them.docs/concepts.mdhas no "Engine vs. World separation" section describing whyengine.solve(world)andworld.solve(engine)are duals, whyEnginecarriesfrequencieswhileWorldcarriesboundary, and how the v0.2.0BoundaryConditionsno-op interacts with the upcoming FEM backend. The split is a deliberate part of the architecture but the only place it is documented is theWorld.solvesource docstring.docs/api/diagnostics.mddoes not document thecheck_segment_resolution-budget threshold rationale. The thresholds (5 000 soft, 20 000 hard) appear in the source code and in this changelog but the rendered doc page lists them as bare numbers. Add a short "Why 5 000?" subsection that ties them to the \(O(N^2)\) memory / \(O(N^3)\) solve scaling of the integral backends.docs/engines/index.mdhas no capability matrix. The pass-2 entry already noted the gap; pass 4 confirms it. The matrix is the natural home for theinductance_model/earth_inductive_model/ distributed- conductor support tabulation referenced in the Roadmap.READMEPython-version badge is still3.12 | 3.13. Pass 4 confirms the gap from pass 2.CLAUDE.mdversion drift is still0.1.0while the package ships0.4.0. Pass 4 confirms the gap; thescripts/release.pyhook flagged in pass 2 has not landed.- No
docs/api/coupling/sommerfeld.mddedicated page. The Sommerfeld earth-return module (coupling/ sommerfeld_inductance.py) contains six public helpers and the Pollaczek-Wait kernel; users readingdocs/api/coupling.mdfind the heading but not the kernel signature. docs/api/postprocess.mdlacks a "Safety helpers" subsection distinct from "Current sharing" and "Plotting" — the EN 50522 lookup helper is the most safety-critical function in the package and currently shows up only via the autodoc.
Tests (pending implementation) — fourth 2026-05-12 review pass¶
- No test that
SourceJSON round-trip preserves the discriminator-vs-non-discriminator union behaviour. Once theDiscriminator("kind")annotation lands, pin the resulting error message for a malformed payload so the bug cannot regress silently. - No test that
World.set_boundary_conditions(...)warns on the revert-to-default path flagged above. Assertingpytest.warns(UserWarning, match="default")on the second call locks in whatever semantics the team picks. - No test that
vector_fit(n_poles=0)is rejected. A one-linepytest.raises(ValueError)regression once the guard lands. - No test for the
evaluate_spec/fit_quality_summaryre-export. Once the helpers are promoted to the top-level__all__, assert thatgf.evaluate_spec is groundfield.io.groundinsight.evaluate_specso a future silent rename cannot break the public alias. - No test for the
Engine.frequenciessort behaviour. Passfrequencies=[5000, 50], callengine.solve(world), and assert either (a) the returned cluster impedance is indexed in the input order or (b) aUserWarningwas raised at construction time. Either way the user gets determinism. - No regression test on
MeasurementSetupConfig.buildfor a pre-setsource.return_to. Build aTnNetworkConfigwhose source already hasreturn_to="explicit_aux", run the generator with aMeasurementSetupConfig, and assert either that the user-supplied value is honoured or that the override is reported via a clear log entry.
Roadmap candidates — fourth 2026-05-12 review pass¶
gf.create_source(world, ..., validate_attached_to=True)switch — opt-in (defaultTruefrom the next minor release, defaultFalseuntil then) for the validation contract flagged repeatedly in passes 1–4. Gives the existing test suite a deprecation window before the validator becomes mandatory.gf.SourceUnionwithDiscriminator("kind")— public type alias that ships the discriminated annotation alongside the legacySource = Union[...]. Lets type checkers in downstream notebooks pick up the fix without a major-version bump.generators.tn_network.TnNetworkConfig.measurement_array( ...)— convenience accessor that returns a per-house measurement-setup catalog (Analysis 1 only, Analysis 2 only, both) so the AP 1 sweep overn_efh ∈ {5, 10, 30, 80, 200}can be expressed as a single sweep axis rather than three nested ones.solver.engine.Engine.with_frequencies(freqs, preserve_order=True)— copy-on-write factory that produces a new engine without the silent sort surprise above. Closes the order-preservation gap until the validator change lands.scripts/release.py— syncCLAUDE.mdversion frompyproject.toml— same hook every pass-1..4 of the cross- repo audit has requested. A six-linere.subinscripts/release.pyends the drift permanently.io.csv.save_field_csv(result, path, *, extent, z=0.0, n=(120, 120), frequency_index=0)— companion to the existing VTK exporter that writes a long-format(x, y, frequency_Hz, phi_re, phi_im, abs_phi)CSV. Closes the symmetry gap: today CSV exports cover paths and electrode tables, VTK covers fields; the slice export is available only as VTK.
Fixed (pending implementation) — fifth 2026-05-13 review pass¶
Focus of the fifth pass: residual cleanup after the Pass-4 implementation block landed in
[Unreleased]; freshly noticed bugs aroundEngine.frequenciesvalidator wording, the still- unrealised top-level API documentation pages, doc/notebook coverage on the mkdocs site, and a handful of new findings in the cross-coupling and io packages that earlier passes had not reached.
postprocess.vector_fitting.vector_fit(n_poles=1)is accepted even when the input has only one or two frequencies. A 1-pole fit on two complex points has 4 unknowns (real / imaginary residue + pole real / imag), which is exactly the number of equations after the real-imag stacking, so the fit can succeed numerically but is meaningless. Rejectn_poles >= len(frequencies)or emit aUserWarningwhen the problem is under- / exactly-determined.solver.engine.Engine._validate_frequencieswarns on every duplicate. A sweep over ten engines, all built from the samefrequencies=[5000, 50]"intentionally decreasing" list, emits the same warning ten times — thewarnings.simplefilter("once")default deduplicates by message text, but the message embeds the list literal so every distinct list triggers a fresh warning. Switch to a dedicatedclass EngineFrequencyOrderWarning(UserWarning)with a stable message prefix so notebook users can silence the lot withwarnings.simplefilter("once", EngineFrequencyOrderWarning).coupling.layered_green.LayeredEarth.compute_T_lambdais silently single-precision on the MLX backend (backend= "mlx"). The MLXmx.array(np.asarray(..., dtype=np.float32))conversion drops to FP32 while the NumPy path stays in FP64. A reference run with the two backends therefore differs in the 5th decimal — the Pass-3 cross-coupling cross-check did not catch this because the test tolerance is1e-4. Either default to FP64 (mx.float64) or document the backend precision contract.io.groundinsight.evaluate_specraisesKeyErrorwhen the supplied spec dict is missing theZ_targetkey. The error originates inside a deep dict- lookup chain and the trace is hard to read for a user passing a hand-rolled spec. Wrap the entry point with an explicitValidationErrorraise that names the missing key.generators.tn_network.TnNetworkConfig.builddoes not validate thatcfg.measurement.source_kindis one of the supported strings ("current"/"voltage"). A typo ("voltage_") silently falls through to the defaultCurrentSourcefactory; the user sees a result that looks correct until they inspect the source'skindfield.world.World.solve(engine)does not deep-copyself.sourcesbefore passing them to the backend. A backend that internally mutatessource.return_to(the Pass-4 finding onMeasurementSetupConfig.buildis the textbook example) silently rewrites the world's sources for any later solve. Either copy inWorld.solveor document the contract.diagnostics.check_segment_resolutionthresholds are hard-coded to5_000/20_000without a public constant. Once the integral backends gain acoarse_segmentsopt-in (Pass-3 finding), the thresholds will move; promote them to module-levelSOFT_LIMIT/HARD_LIMITconstants so tests and notebooks have a stable handle.mkdocs.ymlExamples nav still lists only 8 curated pages whilenotebooks/now carries 30 .ipynb files (Pass-4 audit-fix notebook brought the total over the previous 29). Pass-2/3/4 finding still only partially addressed — closing it requires the samemkdocs-jupytermigration thatgroundinsightis doing.__init__.__all__does not listSourceAdapter, theTypeAdapter[Source]introduced in the Pass-4 implementation block. Users who follow the docstring of the discriminated union and tryfrom groundfield import SourceAdapterget anImportError.scripts/release.pystill does not propagate the Pass-4 "do-not-hard-code" CLAUDE.md pattern. The pattern is documented but not enforced: a contributor who refreshes the CLAUDE.md version line manually still drifts. Are.search(r"__version__ = \"[^\"]+\"", claude_md)raise on bump completes the Pass-4 closure.
Fixed (pending implementation) — sixth 2026-05-14 review pass¶
Sixth-pass findings against
0.5.0(released 2026-05-14). Focus: secondary effects of the just-shipped 0.5.0 surface, notebook / docs reality vs. CHANGELOG claim, ADR-0011 follow-through, FP-precision contract enforcement, and theWorld.solvedeep-copy cost on engine-sweep workloads.
World.solve(engine)deep-copiesself.sourceson every call. Pass-5 added the snapshot/restore pattern to defend against backend-sideSource.return_tomutation (MeasurementSetupConfig.buildwas the textbook offender). The implementation does[s.model_copy(deep=True) for s in self.sources]on entry unconditionally. On a 100-frequency sweep that re-uses the sameWorldacrossEngine.with_frequencies(...).solve(...)calls the deep-copy cost dominates for sources that carry largereturn_to-electrode meshes. Either gate the snapshot behind aWorld.solve(engine, snapshot_sources=True)default-on keyword (so power users can opt out), or do a shallow snapshot of just the mutable fields (return_to,value) instead of a fullmodel_copy(deep=True).VectorFitUnderdeterminedWarningthreshold mis-fires on conjugate-pair fits. The current checkif 2 * n_poles >= len(frequencies)treats every pole as contributing 4 real DOFs (residue real+imag, pole real+imag). For a conjugate-pair fit (complex_conj=True, default) the residues and pole locations come in complex-conjugate pairs, halving the actual free parameters. The result is a false positive onn_poles=2, N=2(warns) where the conjugate-pair fit is in fact uniquely determined. Tighten the check to2 * n_independent_poles >= len(frequencies)wheren_independent_poles = n_poles // 2 + (n_poles % 2)undercomplex_conj=True.LayeredEarth.compute_T_lambdaFP-precision contract is documented but not enforced. Pass-5 added the docstring note "every consumer of LayeredEarth operates in IEEE-754 double precision" and a homogeneous-limit cross-check atrtol=1e-12. What is still missing is the actual guard: a future MLX-on-Apple backend silently downgrading to FP32 would pass the homogeneous check (which is a single-layer identity, no roundoff to speak of) and fail only on five-layer realistic soils. Add an assertion at the top ofcompute_T_lambda:assert out.dtype == np.float64, "LayeredEarth contract: FP64 required"— cheap, catches the regression at the source.Enginefrequency-list cache key.Engine.frequenciesis now order-preserving withEngineFrequencyOrderWarning. Pass 5 surfaces but does not solve the equality contract:Engine(frequencies=[50, 100]) == Engine(frequencies=[100, 50])evaluates toFalse(the warning is per-instance), so two engines that map to the same physics produce two different cache keys downstream incompare_engines/convergence_studyresult tables. Either document the "order matters" contract onEngine.__eq__or override__eq__/__hash__to compare frozenset(frequencies).io.groundinsight.evaluate_specfree-symbol allow-list is name-only. The Pass-5 fix accepts every symbol whosename in {"f", "rho"}, ignoring assumptions. Asp.Symbol("rho", real=False, positive=False)slips through and produces a complex-valuedrhothat crashes deep inlambdifyinstead of at the validation step. Compare on full symbol identity (s == f_sym or s == rho_sym) and reject anything else.Engine.with_frequencies(preserve_order=False)is the silent default. The docstring showspreserve_order=Truebut the constructor body checksif not preserve_orderand falls through; if the caller omits the keyword the engine does preserve order but the warning is still suppressed by thesimplefilter("ignore")block. Result: a sweep author who copies the docstring example sees the same silenced behaviour as the explicitpreserve_order=Trueuser, but the silencing is by accident. Either flip the default toFalse(loud) and document the explicit silencer, or document thatEngine.with_frequenciesis the opt-in silencer regardless ofpreserve_ordervalue.SourceAdaptervalidates a single source dict but does not validate a list. A user-side helper that imports a campaign YAML with asources: [...]field has to callSourceAdapter.validate_pythonper element. Add aSourcesAdapter = TypeAdapter(list[Source])companion so the bulk path is one call.World.set_boundary_conditions(**kwargs)revert-warning is emitted regardless of whether the warning was already raised for the same value. Pass-4 added the set-and-revert warning, Pass-5 left it as-is. A loop that toggles a boundary value across 100 sweeps emits 200 warnings. Apply asimplefilter("once", BoundaryConditionWarning)strategy on first set, OR coalesce the warning by value identity inside the method body.vector_fitinitial pole placement is silent forn_poles == 1._initial_poleschooses a single real pole at the geometric mean ofomega_min/omega_max. For data with a complex-pole structure (e.g. capacitive return path), the iteration cannot bend a real pole into a conjugate pair withinmax_iter=20and the fit converges to a poor RMSE. AUserWarning"vector_fit(n_poles=1) cannot fit complex resonance peaks; pass n_poles=2 for conjugate-pair coverage" at fit time would save a Stack-Overflow round-trip.compare_enginesreturns the discrepancy matrix but does not callEngineFrequencyOrderWarningsilencer. A 4×4 cross-engine matrix over decreasing frequencies fires 16 warnings; the helper should suppress them once internally while still surfacing them on the per-engine path.
Docs (pending implementation) — fifth 2026-05-13 review pass¶
docs/api/sources.md,boundary.md,references.md,validation.md,world.md— fifth audit pass in a row confirms these pages are still missing. The Pass-4 implementation block landedSource(discriminator),Engine(order-preserving frequencies),TnNetworkConfig.source_return_toand the boundary-revert warning all without their target documentation page. Each fix therefore has no natural home for its migration callout. The doc pages are the longest-standing single open item across the audit history.docs/api/index.mddoes not surfaceSourceAdapteror theDiscriminator("kind")migration callout. A reader who searches the API index for "discriminator" finds nothing.docs/concepts.mdstill lacks the "Engine vs. World separation" section flagged in Pass 4. Pass 5 confirms: the only place the split is documented is theWorld.solvesource docstring.docs/engines/index.mdcapability matrix still missing (Pass-2 → Pass-5 carryover). The matrix is the natural home for the newearth_inductive_model={perfect_mirror, carson_series, sommerfeld}toggle and for theinductance_modelchoice.docs/api/postprocess.mdstill missing the dedicated "Vector fitting and safety helpers" subsection (Pass-4 finding). The newn_poles=0rejection callout has no doc home.docs/api/io.mddoes not document the newPOTENTIAL_PATH_COLUMNS,ELECTRODE_TABLE_REQUIRED_COLUMNS,CLUSTER_IMPEDANCE_REQUIRED_COLUMNScolumn tuples exported in the Pass-4 implementation block. Users who want to join the CSV outputs back into a pandas frame have to read the source to find the canonical column names.- README "Compatibility" matrix has not been updated for
the
0.5.0release cut implied by the Pass-4 fixes block. BumpsPython,numpy,scipy,pydantic,sympyminimums. Sync withpyproject.tomlbefore the next tag. docs/quickstart.mddoes not callEngine.with_frequencies(*freqs, preserve_order=True)in any example, even though that constructor is the documented escape hatch for the new order-preserving validator. Add a three-line snippet so the new opt-in lands with discoverable prose.CLAUDE.mdnow defers the version field topyproject.toml(Pass-4 doc fix), but the "Audit history" section near the top of the file still lists pass 1-4 only. Append a pass-5 reference so the next session has the same starting context the previous ones did.
Docs (pending implementation) — sixth 2026-05-14 review pass¶
docs/api/sources.md,boundary.md,references.md,validation.md,world.md— sixth audit pass in a row these pages are still missing. Pass 5 promisedmkdocs build --strictwould catch this in CI; the test has not landed and the pages have not been written. This is now the longest-standing structural doc gap in the three-package family. Sixth-pass acknowledges that one of two things has to happen before the next release tag: either the five pages land, or themkdocs build --stricttest lands (the latter is the one-line forcing function).docs/adr/0011-cross-repo-release-shared.mddoes not exist. Pass-5 cross-cutting recommendation #5 ("a single sharedscripts/_release_shared.pywould lose three release-flow problems in one diff") was elevated to an explicit ADR-0011 proposal in the cross-cutting summary; the ADR itself was not drafted. As the architectural authority on cross-repo conventions sits ingroundfield(eight existing ADRs), ADR-0011 belongs here even though the implementation will ultimately live in all three repos.docs/api/index.mddoes not document the FP64 contract onLayeredEarththat Pass-5 added in code. The rendered API index page therefore does not tell the user that hardware-accelerated backends are required to keep IEEE-754 double precision. Add a one-paragraph admonition before theLayeredEarth:::directive.docs/api/postprocess.mddoes not documentVectorFitUnderdeterminedWarning— the warning category is publicly importable but unmentioned. A reader who tries towarnings.simplefilter("once", ...)has to discover the class name from the source.docs/api/sources.md(once it ships) must enumerate theSourceAdapter/ proposedSourcesAdapterhelpers with a copy-pasteable round-trip example. The Pass-4 + Pass-5 Source-discriminator surface is currently documented only inside the source-file docstring.docs/concepts.mddoes not describe the "Engine re-use acrossWorld.solvecalls" pattern. Tied to the sixth-passWorld.solvedeep-copy finding: the user who wants the snapshot-free behaviour should see a recipe for the plannedsnapshot_sources=Falseopt-out before the perf cost bites them.docs/engines/*.md— Capability matrix sub-section still missing on each engine page. Pass-5 listed this; the pages have not been edited. A two-column matrix (feature×supported / partial / planned) at the top of everybem.md/mom.md/fem.md/ ... gives the reader a one-glance picture of why one engine is in beta and another is reference-quality.README.mddoes not yet list the0.5.0release. The CHANGELOG carries[0.5.0] — 2026-05-14, but the README "Latest release" / badges sub-section still points at0.4.0(verified 2026-05-14). README/CHANGELOG drift reappears on every release that does not run throughscripts/release.pyend-to-end.- CLAUDE.md "Audit history" — append a sixth-pass reference. Pass-5 already failed to do this for itself (verified above as a Pass-5 Docs-backlog item); sixth pass re-confirms.
Tests (pending implementation) — fifth 2026-05-13 review pass¶
- No test for the new
EngineFrequencyOrderWarningcategory. Once the dedicated subclass lands (Fixed- backlog fifth-pass entry above), assert thatwarnings.simplefilter("once", EngineFrequencyOrderWarning)suppresses the same warning on a second construction. - No regression test for
vector_fit(n_poles=1)on a two-frequency input. Build a fixture withfrequencies=[50.0, 5000.0]andZ_values=[…]and assert that the helper either rejects the call or warns about the under-determined fit. - No regression test for the MLX backend FP32 default.
Cross-validate
LayeredEarth.compute_T_lambdawith NumPy and MLX on the same input and assert artol=1e-12agreement, or — if the FP32 default is intentional — document and pin it with a relaxed tolerance. - No regression test for
World.solvesource mutation. Build a world, runsolve(engine), and assert thatworld.sources[i].return_tois unchanged from the pre-solve state. - No regression test for
from groundfield import SourceAdapter. Either lock the import or remove the symbol from the public surface. - No regression test for
evaluate_specraisingValidationError(notKeyError) on a missingZ_targetkey. Locks in the human-readable error message once the fix lands. - No regression test for
mkdocs build— the rendered site is built on the docs server and never checked in CI. Add atests/test_docs.py::test_mkdocs_strict_buildthat runsmkdocs build --strictso the missing-page warnings surface during PR review. - No regression test for the
TnNetworkConfig.build(source_kind=...)typo path. Build a config withsource_kind="voltage_"(trailing underscore) and assert a clearValueError.
Tests (pending implementation) — sixth 2026-05-14 review pass¶
- No regression test for the
VectorFitUnderdeterminedWarningfalse-positive path undercomplex_conj=True. Build avector_fit(n_poles=2, N=2, complex_conj=True)instance and assert no warning fires; today the Pass-5 threshold flags it as underdetermined. - No regression test for FP64 contract enforcement on
LayeredEarth.compute_T_lambda. Construct a stub backend that returns an FP32 array, invokecompute_T_lambda, and assert the proposed runtime assertion (out.dtype == np.float64) trips with a clear message. - No regression test for
World.solve(engine, snapshot_sources=False)cost. Once the keyword lands, benchmark a 100-frequency sweep with and without the snapshot and assert the difference is below thetests/test_perf_smoke.pythreshold; pin the opt-out so the cost is documented. - No regression test for
Engine.__eq__/Engine.__hash__contract. Once the sixth-passfrequency-orderfinding is resolved, pin the chosen contract (Engine([50,100]) == Engine([100,50])true or false) so it cannot regress in a refactor. - No regression test for
io.groundinsight.evaluate_specrejecting a non-canonical-assumptionrhosymbol. Constructsp.Symbol("rho", real=False)-bearing formula, callevaluate_specand assert the sixth-pass tightened check raisesValueError. Today the symbol slips through and the failure surfaces deep insympy.lambdify. - No regression test for the
compare_engineswarning-suppression path. Build a 4×4 comparison over decreasing frequencies and assert exactly oneEngineFrequencyOrderWarningfires (after the sixth-pass per-engine internal silencer lands), not 16. - No regression test that
set_boundary_conditionsdoes not double-warn on repeat assignment of the same non-default value. Today the helper warns on every call, even with identical kwargs. - No regression test for
SourcesAdapter = TypeAdapter (list[Source])once it ships. Round-trip a heterogeneous list with oneCurrentSourceand oneVoltageSourceand assert each element keeps its discriminator.
Roadmap candidates — fifth 2026-05-13 review pass¶
gf.show_versions()— print the installedgroundfield/groundinsight/groundmeas/ NumPy / SciPy / Pydantic / SymPy / MLX versions. Cross-package compatibility is currently maintained by hand; surfacing the active versions in one call removes the back-and-forth on every issue triage.gf.io.bench(world, engine, *, repeats=5)— pin the per-backend wall-clock numbers reported indocs/performance.md. Today the doc page lists static tables; abenchhelper would let the docs site auto-refresh them on a release cut.gf.audit_apply(audit_report_path: str)— read an audit-report markdown file, parse theaudit-report-changelogs-YYYY-MM-DD[-pass*].mdchecklist and emit a delta against the current[Unreleased]block. Six months of audit history have produced a recurring workflow; promote it into a maintainer-facing CLI.gf.docs.assert_api_pages_exist()— pytest-friendly helper that loadsmkdocs.yml, walks the API section, and asserts every page named in this changelog actually exists. Closes the four-passes-in-a-row "page missing" finding by failing the build instead of waiting for a human to flag it.gf.SourceAdapterexposed at the top level — pair with theSourcediscriminated union from Pass-4. Users hand-rolling source dicts (e.g. from a YAML campaign config) need the adapter to validate the dict; today they have to know thefrom groundfield.sources import SourceAdapterpath.gf.boundary.FemBoundaryConditions— placeholder subclass to make the future FEM-backend boundary semantics visible now, even before the backend lands. Closes the "v0.2.0 no-op" doc gap from a different angle: the type hierarchy itself documents what the upcoming FEM backend will and won't honour.
Roadmap candidates — sixth 2026-05-14 review pass¶
gf.SourcesAdapter = TypeAdapter(list[Source])— bulk-validation companion to the Pass-4SourceAdapter, needed by any YAML / JSON campaign-config import path that has more than one source per scenario.World.solve(engine, snapshot_sources: bool = True)— explicit opt-out for the Pass-5 deep-copy snapshot. The perf-sensitive callers (convergence_study, parameter sweeps, the cross-engine matrix) can disable the snapshot once they have proven their backend does not mutatesource.return_to.gf.set_warning_policy("verbose" | "once" | "silent")— global helper to bulk-configure the pass-5 family of warning categories (EngineFrequencyOrderWarning,VectorFitUnderdeterminedWarning, the proposedBoundaryConditionWarning, the proposedVectorFitRealPoleWarning). Replaces thewarnings.simplefilter("once", X)boilerplate that notebooks currently copy-paste per category.gf.audit_apply(report_path)— twin of thegroundinsightproposal. Read a markdown audit's backlog bullets and insert them into[Unreleased]. Six passes of hand-merging strongly motivate the helper.- ADR-0011 — cross-repo
_release_shared.py. Pass-5 cross-cutting recommendation #5; sixth pass elevates it to a tracked roadmap candidate so the ADR file lands as the next merge-able artefact. Mirrors thegi.scripts.release.pyandgm.scripts.release.pygaps in a single shared implementation. gf.show_versions()— keep the cross-package-consistency lemma in the roadmap: whengi.show_versions()(Pass-5 idea) andgm.show_versions()(sixth-pass idea, see groundmeas roadmap above) land, this one needs to land too with the same return-shape contract.gf.docs.assert_api_pages_exist()— a tiny walker that asserts every__all__member has a corresponding mkdocstrings directive somewhere underdocs/api/. Sixth pass elevates this from the Pass-5 proposal status to an explicit roadmap item because the five-pass-in-a-row missing-pages backlog clearly demands a structural forcing function.
Roadmap¶
Backlog of feature ideas that are not yet scheduled. Updated as work package 1 progresses.
Performance¶
- Vectorise the ADR-0007 cross-layer Sommerfeld path. After caching the
Gauss–Legendre nodes (see
[Unreleased]), the remaining cost of the cross-layer fallback is the genuine \(O(N^2)\) per-segment-pair work incoupling/layered_green.py::two_layer_layered_correction_real_space/_spectral_amplitudes: many smallnp.linalg.solvecalls (one spectral-amplitude system per pair) plus per-pair quadrature. Batching the per-pair spectral solves into a single vectorisednp.linalg.solveover the pair axis, and evaluating the Bessel/exponential kernels on the shared quadrature grid in one array op, should give another ~2–5×. Motivation: AP1 catalogues two-layer soils with a thin upper layer (h_1 = 5 m) where a deep station rod can cross the interface — currently the dominant per-station cost even after the leggauss cache (~155 s vs ~3 s within-layer at 5 % penetration). Cost scales \(O(N^2)\) in segment count, so larger / higher-penetration geometries benefit most.
Core functionality¶
- Exact buried two-layer inductive kernel →
n ≥ 3layers. The 0.13.0 fix (coupling/sommerfeld_inductance.py::_two_layer_reflected_weight) solves the exact boundary-value problem for a conductor buried in the upper layer of a two-layer earth, validated against Tsiamitros (2005) Eq. (8) across 50 Hz – 1 MHz. Forn ≥ 3layers a buried source still falls back to the approximate single-air-image correction (the surface reflection cascadereflection_coefficient_layeredis already exact for anyn); a generaln-layer buried spectral kernel would extend the exact inductive earth-return to arbitrary multilayer soils. Then ≥ 3buried path is currently pinned only by the homogeneous and bounding checks — there is no exact multilayer reference for it yet. Galvanic multilayer solves are unaffected (they uselayered_green). - FEM support for distributed conductors. The current FEM backend falls back to a lumped branch and logs a warning when it sees a distributed conductor — the equivalent-hemisphere reduction does not resolve the midpoint pseudo-electrodes. A full-3-D FEM (deferred below) would lift this restriction.
- Full-3-D FEM backend (via
scikit-femor comparable) replacing the equivalent-hemisphere reduction in the currentfemengine. Deferred — explicitly not on the AP1 roadmap. The target AP1 geometries (~20 houses with foundation electrodes plus a transformer station with ring + driven rods plus driven rods at cable cabinets) already push the integral solvers towards the ~1000-segment regime where the dense \(O(N^3)\) LU solve dominates; a 3-D volume FEM on the same configuration would be one to two orders of magnitude more expensive without adding physical insight. This task is therefore reserved for follow-up work where a multi-cluster volume computation is genuinely needed. - Scaling the integral solvers to ~10 000-segment networks: replace
the dense LU solve in
mom,bemandcimby a preconditioned iterative solver (GMRES with a block-diagonal or fast-multipole preconditioner). Unlocks the full AP1 parameter space (large TN-Ortsnetz with foundation electrodes, drives, and cable cabinets) without giving up the per-segment current resolution. Concrete prerequisites: ACA (adaptive cross approximation) for the dense reaction matrix to bring memory from \(O(N^2)\) down to \(O(N \log N)\) and a block-Jacobi preconditioner per cluster.
Studies and tooling¶
ParameterSweepAPI — wrap aworld_factory(**params)plus a parameter grid into a parallel sweep (joblib-driven, target 12 cores) that returns apandas.DataFrameof cluster impedances, EPRs and per-electrode currents. Drives the AP1 statistical studies (soil layering, electrode count and position, Monte Carlo realisations).-
TN-Ortsnetz topology generator — implemented 2026-05-18 in the
[Unreleased]block above. The radial-trunk PEN topology (:class:RadialTrunkTopology) and the integration with :class:OsmBuildingPlacementare live; what remains as a v2 enhancement is the corner-routed service drop (tap → axis- aligned corner → building), which currently runs as a single straight line between anchors. For typical street-aligned plots the corner offset is small compared with the full feeder length and the residual error in the longitudinal branch impedance stays well below 1 % at 50 Hz. -
penetration depth Calculate the depth of the earth current as it is used in Carson integrals. It should be possible to create the earth current depth of any soild multilayer problem to use an equivilent for the typical formulas for calculating the self and coupling impedances of a cable or overheadline with earth return part.
Integration¶
- Direct export of
BusTypeandBranchTypeintogroundinsightdatabases. - Import of measurement geometries from
groundmeas(positions, lengths, auxiliary-electrode set-up).
Documentation and typing¶
- Reference-case library for AP1: clusters of single-family houses (5 / 10 / 30 / 80 / 200), small and medium commercial buildings, cable cabinets.
- Notebook suite that covers the full parameter space described in the dissertation proposal.