Audit log¶
Working record of the code-audit passes run over groundinsight between
2026-05 and 2026-07. It exists because the measurement protocol behind a
fix — how the bug was reproduced, what the negative control was, and
whether a mutation of the fixed code is actually caught by the new test —
is worth keeping, but does not belong in user-facing release notes.
What is authoritative for what
CHANGELOG.md
is authoritative for what shipped in which release. This page is
authoritative for how a finding was established. Where the two
disagree, the changelog wins.
Part 3 lists findings that are confirmed but not implemented. Nothing in Part 3 is a description of shipped behaviour. Some Part 3 entries were written before a later pass and have since been resolved without the entry being struck through; check the changelog before acting on one.
Each entry follows the same shape: what was observed, the test that proves it is a defect rather than a matter of taste, and the change. The project's rule is that a fix arrives with a test that fails without it; for changes to drawing code and to matrix assembly — where a test can pass for the wrong reason — the entry also records a mutation-testing result.
Part 1 — closed findings, by pass¶
Newest pass first. The pass numbering is chronological, not a severity ranking.
Internal (Audit pass 17 — release readiness)¶
The 0.5.0 cut was checked end to end instead of assumed. The library is green — 874 tests pass and
mkdocs build --strictis clean — but the release path was blocked in a way no test could have caught, because the blocker sits in the release tooling's own preconditions rather than in the package. Four items, all of them mechanical, none of them observable at runtime.
- Version drift made
scripts/release.pyabort before it started.pyproject.tomlandsrc/groundinsight/__init__.pyhad been bumped to0.5.0in Pass 6 whileCITATION.cffstayed at0.4.0. The script reads the current version from all three locations and exits with "version drift detected" when they disagree, andrelease set 0.5.0is refused as well because the target is not newer than the current value — so there was no way through the documented workflow at all. All three now read0.4.0, the last published version, which is what the working tree is supposed to carry:poetry run release minorperforms the bump, rolls[Unreleased]into## [0.5.0], commits, tagsv0.5.0and pushes. - A test pinned the version literal and would have failed on every
future release commit.
test_version_is_0_5_0assertedgi.__version__ == "0.5.0". Since the release script rewrites that literal and then commits, the release commit itself would have failed the CI from the next minor bump onwards. Replaced bytest_version_matches_pyproject, which reads the expectation fromtool.poetry.version, plustest_version_matches_citation_cff, which turns the drift described above into a test failure at development time. Both skip cleanly when the package is installed without its source tree. - Deprecation notes were attributed to the wrong release. Thirteen
docstring and documentation passages described behaviour that this
release changes as having been the case "up to v0.5.0" — an artefact of
the premature bump, since the last published tag is
v0.4.0. Corrected indatabase/migration.py,electrical_network.py,simulation/transient.py,docs/concepts.md,docs/transient.mdand three audit test modules. One of them,tests/test_audit_pass11_migration.py, quotedgit show v0.5.0:src/groundinsight/models/database_models.pyas the provenance of its legacy-schema fixture — a command that cannot run, because that tag does not exist. - Sandbox paths removed from the changelog. Five passages referenced
/tmp/probe_*.pyscratch files from the machine the measurements ran on. The file is rendered into the GitHub release notes, where such a path is noise.
Fixed (Audit pass 16 — 0 Hz is a frequency, not a special case)¶
f = 0has been an accepted entry inNetwork.frequenciesand infault.scalingssince the validator was written, and the solver is exact there:Y(f) · u(f) = i(f)has no frequency dependence of its own. What broke at DC was everything around it, and it broke in three independent places, each of which had to be told apart from a different case that looks identical in floating point. Until now the way to get a DC answer out ofgroundinsightwas to enterf = 0.1 Hzand accept the reactance that comes with it.The three failures are not variants of one bug and their fixes share no code: evaluating the formula string at zero, reading a finite reactance at zero, and inverting a zero impedance. The third one is unavoidable in transient studies — an FFT grid always contains a 0 Hz bin, so every transient run of every network with a purely inductive element hit it. Every figure below was measured against the real solver in a twelve-probe campaign, run before a line of code was changed, and
tests/test_audit_pass16_dc.pyadds 32 tests.This also closes the pass-15 roadmap item "
Z = 0at DC is modelled as an open circuit in the transient solver", which was left open pending exactly the decision the measurement below settles.
- A removable singularity at 0 Hz raised instead of resolving. Carson's
earth-return term,
ω · ln(658·√(ρ/f)/GMR), is0 · ∞atf = 0. The limit is 0 —ωvanishes linearly while the logarithm diverges only logarithmically — so a Carson-type conductor tends to its DC resistance, which is the one number a DC study is about. Floating point renders the limit asNaN, andNaNwas reported as a formula error, which made every Carson-type conductor unusable at DC and in every transient study. The limit is now determined numerically by approaching zero on the decade sequence1e-6, 1e-7, 1e-8 Hzand comparing two consecutive absolute differences. Measured on the reference cases:d₁ = 1.41e-8,d₂ = 1.41e-9for an inductance (shrinking, hence convergent) againstd₁ = 1.43e+12,d₂ = 1.43e+13for a capacitance (growing, hence a pole). The comparison is deliberately absolute: a relative criterion cannot classify a formula whose limit is zero, because there the relative change stays at 90 % per decade forever. - A true pole is still infinite, and infinity is still the answer. A
series capacitance is an open circuit at DC and
1/(j·ω·C) → ∞says so. The tie-break in the classifier is biased towards convergent on purpose: calling a pole convergent yields a large finite impedance, which behaves almost like the open circuit it should have been, whereas calling a convergent formula a pole would silently disconnect a real earthing conductor. - A genuine failure keeps failing.
√ρwith negativeρ, aNaNparameter,0/0: the approach sequence isNaNas well, the singularity cannot be classified, and the original error message — which names the formula, the frequency and the parameters — is the one that fires. Z = 0at DC was modelled as an open circuit — in both solvers. A purely inductive element is an exact zero at DC, which is correct physics, and zero has no reciprocal the nodal solve can use. Both solvers responded by dropping the element, i.e. by modelling a short circuit as a disconnection. Measured on a three-bus chain (Z_A = 0.8 Ω,Z_B = 12 Ω,Z_C = 3.5 Ω, bond A–B purely inductive,R_BC = 0.35 Ω, 1 kA DC injected at A, fault at C): the correct EPR isA = B = 57.07 V,C = −266.30 V; what the solver returned wasA = 800.00 V,B = −2649.84 V,C = −2727.13 V— wrong by factors of 14.0, 46.4 and 10.2, with the sign of bus B reversed. Bus A had been cut off from the network entirely and reported its own electrode instead of the parallel combination. Across eight further realistic networks the open-circuit treatment was wrong by factors from 28 to 4.5 million.- The replacement is a measured stand-in, not a guessed epsilon. Such an
element is now modelled with
√(machine epsilon) · Z_min = 1.4901e-8 · Z_minohm at the 0 Hz bin only, whereZ_minis the smallest finite non-zero impedance the network carries at that frequency. The rule was chosen by sweeping four candidates over eight networks × two failure modes against the analytically node-merged reference: tying it toZ_mingives a worst case of1.19e-5, toZ_median3.44e-2, toZ_max6.62e+1, and the obvious1e-9 · Z_min2.79e-4— twenty times worse than the rule chosen. Both scales that matter are local to the shorted element and a solver cannot see them, which is why the network-wide rule costs about1e-5instead of the3e-7a per-element optimum would reach. Several simultaneous bonds do not compound (1.25e-5for one,2.69e-5for three) and complex neighbours behave identically (4.4e-5 … 6.4e-5at X/R = 0.3). On the three-bus chain above the substitution reproduces the merged reference to1.58e-9(stationary) and8.33e-8(transient DC bin), and it beats an explicitly modelled1e-8 Ωbond. Every use is announced with aDCLimitWarningthat names the elements, the substitute, the reference it was scaled to, and the exact remedy: model the two buses of an ideal bond as a single bus. - Seven inversion sites, not one.
1/complex(0, 0)raisesZeroDivisionErrorfor Python complex numbers rather than returning infinity, so each place that inverts an impedance was its own crash path at DC, not merely a place that returns a wrong number. Four of them sit outside the matrix assembly and are reached by no test that only checks the EPR: the source injection (u_eff / Z_src), the phase-current split, the mutual-current transfer (Z_mutual / Z_self) and the bus-current loop, pluscompute_branch_currents. All seven now go through one method,ElectricalNetwork._resolved_impedance, so the substitution cannot be applied in one place and forgotten in another. - The transient path escaped the passivity check at 0 Hz entirely. It dropped the 0 Hz bin before validating, which was the only way to let a legitimate short circuit through — but it also let a formula that turns negative at DC through, unchecked. The bin is validated like every other now, with the short circuit handled by substitution rather than by looking away.
Changed (Audit pass 16 — what a formula may produce at 0 Hz)¶
- A finite reactance at 0 Hz falls back to the real part, with a
warning.
(0.25 + j·0.6)·l— the most common spelling in the wild — reports 0.6 Ω of reactance at every frequency including zero, and at DC that is a statement about nothing: a reactance either vanishes (j·ω·L → 0) or is infinite (1/(j·ω·C) → ∞). The 0 Hz bin now takes the real part and says so, naming X/R and the remedy (write the reactance asj*2*pi*f*Land it vanishes at DC by itself). All other frequencies are untouched. The warning quotes the ratio rather than the two values, because the ratio is length-invariant and therefore identical for every branch sharing aBranchType— which lets Python's default warning filter collapse a hundred-branch network into one line instead of a hundred. - The fallback is off for R/L/C parameters.
compute_real_valuepassesdc_real_fallback=False: an R, L or C field is real at every frequency by contract, so a complex value there is a mistake to report, not a value to repair. Silently dropping the imaginary part would have disabled the existing "produced a non-real value" check at 0 Hz and nowhere else. The DC limit still applies on that path. Z = 0and a non-invertible|Z|are accepted at 0 Hz and rejected above it. This is the one asymmetry the DC work introduces into the pass-15 rule, and it is asymmetric because the physics is: an inductance really is a short circuit at DC and really is not one at 50 Hz. The pass-15 message for the rejected case now says that 0 Hz would have been accepted, so a user who meets it at power frequency is not left wondering why the same value passes in a transient run. A negative real part stays rejected at every frequency, 0 Hz included: a passive element is passive at DC too.- New public helpers in
groundinsight.utils.impedance_calculator:is_short_circuit(z)asks the arithmetic (is 1/z finite?) rather than comparing against a hand-carried constant, so the boundary moves with the floating-point format instead of with a magic number; anddc_substitute_impedance(magnitudes, shorted_elements, context=)sizes the stand-in and emits the warning.
Docs (Audit pass 16)¶
docs/concepts.mdgains "Direct current (f = 0)": why the solver is exact at DC but the formula string is not, the three singularities and how they are told apart, the reactance fallback and how to write a formula that does not need it, what the short-circuit substitution costs in accuracy and when to merge two buses instead, and the DC entries aSourceand aFaultneed.docs/transient.md: the DC bin implementation note no longer claims the 0 Hz bin is skipped. It now states that every transient run evaluates every formula at zero frequency, names the three cases and links to the concepts chapter.- New notebook
notebooks/24_dc_studies.ipynb(15 code cells, all executed end to end): a DC earth-current study run atf = 0next to thef = 0.1 Hzworkaround it replaces, the reactance fallback made visible, Carson's limit and thed₁/d₂classifier reproduced by hand, the ideal-bond substitution checked against a hand-merged reference network and against explicitly modelled bond resistances from1e-2down to1e-8 Ω, the DC bin of the corresponding transient run, a capacitive bond staying an open circuit, and a mixed0 / 50 Hzstudy. - What the
f = 0.1 Hzworkaround cost, measured. For a conductor whose reactance is written asj*2*pi*f*Lthe workaround was accurate to eleven digits (456.621004575 Vat 0.1 Hz against456.621004566 Vat 0 Hz) and existed only becausef = 0used to raise. For the far more common(0.25 + j*0.6)*lspelling it reports 718.54 V instead of 456.62 V andZ_G = 2.87 Ωinstead of1.82 Ω— 57 % high, and the error does not shrink with the frequency because the reactance is constant.
Fixed (Audit pass 15 — an impedance that cannot become an admittance)¶
One subject: every impedance that reaches the diagonal of
Yis used as its reciprocal1/Z, and three kinds of value have no reciprocal the nodal solve can use. All three were swallowed without a word — none of them raised, none of them warned, and two of them returned a number an engineer would accept.The proof for the first is a limit test, run on a two-bus reference network (
Z_A = 10 Ω, branch1 Ω, 100 A injected at A, fault at B) in which a bus impedance going to zero has a non-zero limit to converge to. The sequenceZ_B → 0converges cleanly toEPR(A) = 1000/11 V,EPR(B) → 0, matching the closed-form solution of the same 2×2 system torel = 1e-9at every point from0.1 Ωdown to1e-12 Ω. The answer the model returned atZ_B = 0wasEPR(A) = 0,EPR(B) = 100 V— bit-for-bit identical toZ_B = ∞, and the mirror image of the limit. A perfect earth electrode and a missing one were the same object. Every figure quoted below was reproduced against the real solver with nothing changed but the new guard;tests/test_audit_pass15_zero_impedance.pyadds 47 tests.
Z = 0was modelled as an open circuit — the exact opposite of the ideal earth it looks like._is_opentreated a zero impedance the same way as an infinite one and dropped the element from the matrix, so a bus with a "perfect" grounding grid reported the full earth potential rise and no current into the soil. Zero is now rejected where the impedance is computed, with the element, the frequency and the formula in the message, and with the remedy: a near-ideal earth is a small finite value, not zero. The message quotes an accuracy for that remedy and the accuracy is measured —1e-6 Ωin a network whose impedances are of the order of1 Ωreproduces the ideal-earth limit to about seven digits (relative error9.09e-8), and every further decade buys another digit.- An impedance too small to invert put
infon the diagonal andNaNin the results.1/Zoverflows below1/DBL_MAX ≈ 5.5626846e-309, one representable step away from zero and not caught by an== 0test. AtZ_B = 5.5e-309theEPR_Vcolumn still looked correct whileI_bus_Acame back asNaNfor that bus, in both the 50 Hz row and the RMS row. The guard asksnp.isfinite(1/Z)rather than comparing against a hand-carried constant, so the boundary sits exactly where the arithmetic stops:5.6e-309is accepted,5.5e-309is not. - A negative real part produced a plausible-looking number and then a
misdiagnosis. An earth electrode, an earthing conductor and a cable
screen are passive, and a negative resistance walks the nodal determinant
towards zero. Measured on the reference network, which is singular at
Z_B = −11 Ω:−1 Ωgives 100 V,−10 Ωgives 1 kV,−10.9 Ωgives 10 kV,−10.99 Ωgives 100 kV and−10.999 Ωgives 1.0999 MV — all finite, all returned without comment. At exactly−11 Ωthe solve fails, and the error it raised was the wrong one: "the network has no path to reference earth. Ensure at least one bus has a finite grounding impedance" — advice that cannot fix a network in which every bus already has one. This is not an exotic input:0.05*rho - 2, a plausible fit for a rod electrode, goes negative belowρ_E = 40 Ω·m, which is ordinary wet soil. A ρ sweep over that fit now raises at 40 and below, naming the formula and the ρ, instead of solving. - The check runs a second time immediately before the matrix is
assembled, because impedances are not recomputed at solve time.
Rejecting the three cases only where a formula is evaluated would have
been half a fix: a value assigned directly to
bus.impedance[freq], or restored from a.dbor a JSON file written by an older version, reached the solver untouched. There are regression tests for all four routes.
Changed (Audit pass 15 — three impedance values that used to be accepted)¶
- Breaking: a zero, a sub-invertible or a negative-real-part impedance
raises
ValueErroronBusType/BranchTypeimpedance formulas (bus grounding impedance, and branch self impedance wheregrounding_conductor=True), onSource.source_impedance, and again at the top of_construct_Y_matrices. If you have a model that deliberately writes0.0to mean "ideal earth", replace it with a small finite value —1e-6 Ωis seven digits of the ideal limit in a 1 Ω network, and unlike0.0it actually behaves like one. - The scope is deliberately narrow, and pinned by tests as carefully as
the rule itself. Mutual impedances are not checked —
Z_mutual = 0is the ordinary way to say "no coupling" and stays legal. The self impedance of a branch withgrounding_conductor=Falseis never inverted and is not checked. Inactive buses and branches are not checked, nor are frequencies outsidenetwork.frequencies.infremains legal everywhere: it is the documented open-end sentinel (impedance_formula="nan") and1/inf = 0is the correct contribution for a tower without an electrode.NaNis left tocompute_impedance, which already reports it better. _is_openwas renamed to_is_open_circuitand no longer answers "yes" for zero. It now tests infinity only, which is what its name and its every call site always meant.- A pass-12 regression test was superseded. The cutoff formula
(0.30 + j*f*0.0025) * l * sqrt(1 - (f/500)**2), used there to show that a formula going complex above its cutoff no longer poisons the network, evaluates to−4.33 + 0.52j Ω/kmat 1000 Hz and is now rejected outright for its negative real part.test_cutoff_formula_no_longer_poisons_the_networkwas split into three tests that keep the original claim about the imaginary part while asserting the new rejection; the file is green at 61 tests.
Docs (Audit pass 15)¶
docs/concepts.mdgains "Which values a formula may produce" under Impedance formulas: the three rejected results and why, how to model a near-ideal earth and what accuracy that buys, why a negative real part is usually a fit evaluated outside its range, the exact scope of the rule, why it runs twice, and the transient 0 Hz exception.
Added (the plot helpers accept an axis and can release their figure)¶
The five plotting helpers each created their own figure and left it registered with
pyplot. That is right in a notebook and wrong everywhere else: two scenarios could not be put side by side in one figure, and a parameter sweep accumulated figures until matplotlib warned at twenty. Both are now caller-controlled.Implementing this meant rewriting every helper from the
pyplotstate machine (plt.bar,plt.xticks,plt.title) to the object-oriented API (ax.bar,ax.set_xticks,ax.set_title), which touches every drawing call in the module — so "it still looks right" was not accepted as evidence. A fingerprint of fifteen calls serialising bar positions and heights, tick locations, tick-label text, rotation and alignment, axis labels, titles, scales, limits, legend entries, grid state, line data and axis geometry is byte-identical before and after the rewrite.tests/test_plot_axes_and_close.pyadds 125 tests, and all 59 mutations of the changed code are killed by their intended test; the five recorded accepted equivalents are documentation, typing, an arbitrary guard order and one provably redundant call, each with the reason written out in the harness.
ax=draws into an axis you already have. The helper then treats the figure as the caller's: it applies notight_layout, closes nothing, and returns the caller's figure rather than a new one. This is what makes a base case and an outage case comparable in one figure:fig, axes = plt.subplots(1, 2, sharey=True)followed bygi.plot_bus_voltages(result=base, ax=axes[0])andgi.plot_bus_voltages(result=outage, ax=axes[1]).close=Truereleases the figure the helper created. The returned figure is still complete —savefigworks exactly as before — but it is unregistered frompyplot, so a sweep no longer accumulates figures. Equivalent to callingplt.close(fig)afterwards, and only ever applied to a figure the call itself created.- Both parameters are keyword-only, appended behind a
*, so the historical positional signature(result, frequencies, figsize, title, yscale)is unaffected.
Changed (the plot helpers reject two argument combinations and a bad figsize)¶
ax=together withfigsize=raisesValueError. The figure already exists and may hold other panels, so the requested size cannot be honoured; silently ignoring it would hand back a plot at a size the caller did not ask for. The message says how to size the figure instead.ax=together withclose=TrueraisesValueError.close=releases the figure this call created. Withax=the figure belongs to the caller, and closing it would take every sibling panel with it.- A
figsizethat cannot be used raisesValueErrorinstead of being replaced by the default. Matplotlib acceptsfigsize=(0, 0)when the figure is created and only fails much later, when the figure is drawn or saved, so the traceback pointed atsavefigrather than at the call responsible. Afigsizethat is not a pair of numbers at all —(), a scalar, a three-tuple — is reported the same way, because the exception it would otherwise raise (not enough values to unpack) names neither the parameter nor the helper. Previously a falsyfigsizewas quietly swapped for the default size. figsizenow defaults toNonerather than to a literal size. It resolves to the same(12, 6)for the bar helpers and(10, 5)for the transient helpers, so no existing call changes; the sentinel is what makes "no size given" distinguishable from "this size given".
Fixed (Audit pass 14 — results that look like answers)¶
The smaller confirmed findings left over from earlier passes, collected into one batch. They live in four different modules and share one shape: each hands back a plausible artefact — a bar chart, a comparison table, a loaded network, a solved result — for a question the code could not actually answer. None of them fails; that is what makes them worth fixing. Every finding was reproduced on a running network before a line of code was changed on a running network. Regression tests in
tests/test_audit_pass14_minor_findings.py(41 tests); all 29 mutations of the changed code are killed by their intended test, and the two recorded accepted equivalents are documentation and one provably equivalent spelling, each with the reason written out in the harness.
- A frequency that was never computed was plotted as a bar of height
zero.
plot_bus_voltages,plot_branch_currentsandplot_bus_currentsall read their per-frequency dict with.get(freq)and substituted0.0on a miss, although all three docstrings already promised aKeyError. On a 50 Hz result,frequencies=[250.0]produced a clean figure whose every bar sat at zero — which an engineer reads as "the fifth harmonic causes no earth potential rise at this station", when the truth is that 250 Hz was never part of the calculation. The promisedKeyErroris now raised, and its message names the requested frequencies, the mapping they are missing from, and the frequencies that are available. The partial case — a frequency present on some elements and missing on others, which would mix measured values and substituted zeros inside a single bar group — is rejected separately and names the offending elements. - The relative delta of an outage study divided by the reference value
without a guard.
OutageStudyResult._comparecomputed(value - ref) / ref * 100unconditionally, and a zero baseline is ordinary in a grounding study, not exotic. Measured on two realistic studies: a fault declared withscalings={250.0: 0.0}— "this fault current has no fifth harmonic" — made the entire 250 Hz block0/0, i.e. 6NaNrows out of 18; comparingagainst=a scenario that islands a station gave that station0 Vin the denominator and a real voltage in the numerator, i.e. 2+infrows out of 16. Theinfis the single most interesting row of the study, and it sorts to the top of every "largest relative change" ranking while makingmean()andmax()over the column useless; theNaNmarks the most boring row with the same symbol a solver divergence would produce. The column is nownullwherever the reference is zero — Polars aggregations skip it and matplotlib plots it as a gap — while the absolutedelta_vs_<ref>column keeps the full information. A non-zero baseline is unaffected, including the reference row's own0.0 %. - An unresolvable bus or branch type surfaced as an
AttributeErrorfrom inside the ORM.BusDB.to_pydanticandBranchDB.to_pydanticdereferencedself.type.to_pydantic()without checking the relationship, so a database whose type row had been deleted — a shared.dbsomeone pruned, a hand-edited file — failed withAttributeError: 'NoneType' object has no attribute 'to_pydantic'. That message names neither the element, nor the type it wanted, nor the network, and reads like a groundinsight bug rather than an inconsistent database. Both now raise aValueErrornaming all three and saying what to do, matching thePathDB.to_pydanticprecedent that was already there. - Two documented
ValueErrors were never raised.create_pathsdocumented that it rejects a network without sources or faults and did not, so a network missing itscreate_source(...)ran the full pipeline and returned a four-row table of0.0 V / 0.0 A— measured, no warning, no log line.create_network_assistantdocumented its argument contract and enforced none of it.
Changed (Audit pass 14 — two guards that reject calls that used to work)¶
- Breaking:
create_paths— and thereforerun_fault— rejects a network with no sources or no faults. Such a network previously produced a complete, structurally valid all-zero result. The check is on the collections being empty, never on the resulting path count, which is the distinction that matters: an outage scenario that islands the fault bus still runs and still legitimately returns all zeros, and there is a regression test pinning exactly that (a mutation tightening the guard to "no paths found" is killed by it). If you have a workflow that deliberately solves an unexcited network, this is the one item in this pass to object to. - Breaking:
create_network_assistantvalidatesnumber_busesandbranch_length. A line ofnbuses hasn − 1branches; passingnlengths silently dropped the last one, and passing too few raised a bareIndexErrorfrom inside the loop. Both now raise aValueErrorstating the two counts.number_busesmust be anint >= 1(aboolis rejected although Python considers it anint), and a scalarbranch_length=1.0— a natural thing to try — now gets a message instead ofTypeError: 'float' object is not subscriptable. Two tests in this repository carried exactly this off-by-one:test_network_assistant_creationpassed 10 lengths for 10 buses andtest_reduction_factor_for_bus7_fault7passed 30 for 30. Both were trimmed ton − 1after verifying that the removed entries were the ones already being dropped, so both networks are bit-for-bit unchanged and their numeric reference assertions remain valid.
Docs (Audit pass 14)¶
- The five plotting helpers now document the figure-ownership contract:
the returned figure is registered with
pyplotand stays open until the caller closes it, so a soil-resistivity sweep in a loop accumulates figures until matplotlib warns at twenty. This is documented, not fixed — the fix is anax=parameter, which is a new feature and needs a decision first. Two tests pin the current behaviour so the note cannot silently become wrong. docs/api/plotting.mdgains sections on the frequency guard and on figure ownership,docs/api/outage.mda table showing whendelta_pct_vs_<ref>isnulland why,docs/api/network_operations.mdthe excitation requirement and the n−1 rule, anddocs/quickstart.mdanddocs/concepts.mdshort notes on both.plotting.pytightens three presence tests fromif value:toif value is not None:. This is intent, not a behaviour change:ComplexNumberis a Pydantic model without__bool__, so every instance is truthy and both spellings take the same branch — and even if that changed, both branches assign0.0for a zero value. It is recorded as an accepted-equivalent mutation rather than claimed as a fix.
Fixed (Audit pass 13 — what a bisection result does not say)¶
Found by asking the inverse-rho searches the one question their return value could not answer: how much of this number did you actually prove? A log-bisection has four ways to stop and only one of them is a maximum, yet all four returned the same dict shape, and the only field that hinted at the difference —
iterations— took the value0for three of them, whosec_maxdiffered by a factor of 1e6 (0.001 against 1000) on the same network. Every finding below was reproduced numerically before a line of code was changed. Regression tests intests/test_audit_pass13_bisection.py(57 tests). All 35 mutations of the changed code are killed by their intended test; the two mutations recorded as accepted equivalents are unreachable second locks on a door the pass-12 formula guards already close, with the reason written out in the harness.
iterations == 0meant three different things, and the docstring documented one of them. It could mean the whole bracket was admissible (c_maxis the upper bound, the true maximum is above it and was never determined), the tolerance was already met on entry (c_maxis correct), or the loop never ran at all because an argument was degenerate (c_maxis the lower bound). The advice "ifiterationsis 0, widenc_bounds" was therefore actively wrong in two of the three cases: for a hit step cap it points at the wrong knob, and for a bracket already inside the tolerance it asks the caller to discard a converged answer. Results now carrystatus,converged,c_bracketandbracket_rel_width;iterationsis retained but no longer identifies a case.- Exhausting
max_iterwas silent.max_iter=3on the reference network returnedc_maxwith a 81 % relative error, in a dict indistinguishable from a converged one. It is nowstatus="max_iter_reached",converged=False, and alogger.warningnaming the bracket that was never closed.c_maxremains a verified admissible factor in this case — the search measured its EPR — it is simply not the largest one, and that is now the difference between the two keys rather than a difference the caller cannot see. tol_relwas not validated. A non-positive tolerance can never satisfy the exit test(c_hi - c_lo) / c_lo <= tol_rel, so the search spent every one of the 60 default steps — 62run_faultcalls against 16 for the same search with an honest tolerance — and returned a bracket it never closed. NaN fails the opposite way:width > nanisFalseon the first pass, so no step is taken and the lower bracket bound comes back as the answer.max_iterwas not validated.0and-5skipped the loop entirely and returnedc_bounds[0]— an admissible factor, hence indistinguishable from a real result.2.7silently meant three steps.- A NaN EPR limit passed the positivity guard.
nan <= 0isFalse, sou_max=nan(andu_limit=nan) was accepted; every later comparison against it isFalseas well, so the limit test carried no information and the search walked the bracket down toc_minand reported it. Both searches now require a finite positive limit, with the mechanism spelled out in the message. - The same NaN hole in
select_rho_f_from_catalog, where it is worse. That function does not return a scalar a reader might sanity-check but a table with anadmissiblecolumn, and every entry in it ismax_epr <= u_limit. Withu_limit=nanthe table reported that no soil model in the catalog is usable — measured: 0 of 3, where an honest limit gives 3 of 3 — next to an EPR column that is correct and finite. Nothing in the output pointed at the limit as the broken part. c_bounds=(1e-3, inf)passed the ordering check (0 < c_lo < c_hiholds for an infinite upper bound) and travelled into the solver as an infinite grounding impedance, surfacing several layers down as "no active bus is referenced to earth" — a diagnosis about the network for what is an argument error. Bounds are now checked for finiteness first, separately, so the message names the argument.
Changed (Audit pass 13 — the inverse-rho result contract)¶
find_max_rho_scalingandfind_max_rho_f_scalingreturn four new keys.statusis one of"converged","bracket_within_tol_on_entry","bracket_fully_admissible"or"max_iter_reached";convergedisTruefor the first two;c_bracketis the interval that provably contains the true threshold; andbracket_rel_widthis its relative width. For a fully admissible bracket the interval is reported as(c_hi, inf), which makes "widenc_bounds" machine-readable:math.isfinite(result["c_bracket"][1])isFalse. All previous keys keep their names and meanings, so existing code that readsc_maxcontinues to work — it just now has a way to find out whether that number is a maximum.- Breaking:
max_itermust be anint >= 1.max_iter=60.0now raises aValueErrorinstead of being accepted. The float was never harmless: the loop condition rounds it up, so2.7meant three steps, and a cap that does not mean what it says is worse than no cap. Callers passing a float should pass theint. - The shared checks and the report builder live in
groundinsight/analysis/_bisection.pyrather than being written twice, so the two searches cannot drift apart.evaluate_max_epr_under_kis deliberately left unvalidated: it is a pure evaluator with no limit, no tolerance and no loop, and adding guards it has no failure mode for would only obscure where the real ones are.
Fixed (Audit pass 12 — the road from a formula string to a number in Y)¶
Found by walking the single path every impedance takes — formula string → SymPy →
lambdify→ NumPy →ComplexNumber→ admittance matrix — and asking at each hop which inputs leave it silently wrong rather than loudly broken. Five of the six defects below produced a number, not an exception, and surfaced (if at all) several layers later as "singular admittance matrix" — a message that describes a topology error which does not exist. Regression tests intests/test_audit_pass12_formula.py(59 tests). All 48 mutations of the changed code are killed by their intended test; the one mutation that survives is recorded in the harness as an accepted equivalent with the reason written out.
- A formula that merely contained the letters
nanbecame an open circuit, without a word. The open-end sentinel was tested with"nan" in formula_str.lower()— a substring test — soresonance,resonanz,nanofarad,dominantanddiscriminantall matched. The first two are ordinary vocabulary in a resonant-earthed (Petersen coil) network, which is exactly the kind of system this package is written for: a parameter namedresonanz_fturned the element into an open end and the network was solved, with a plausible-looking answer. The sentinel is now matched against the whole stripped, case-folded string, so"nan","NaN"and" nan "still mean open end and nothing else does. - A parameter whose name collides with one of SymPy's ~680 exported names
lost its value.
sympifywas called withoutlocals, so names were resolved out of the SymPy namespace first. Two of the collisions are silent:Eevaluates to Euler's number 2.71828… andooto infinity, and the value the caller passed is simply discarded. The rest are loud but unhelpful —S(the conductor cross-section of IEC 60949),beta(its material constant),gamma(the propagation constant),N,Q,re,imresolve to a SymPy class or function and the arithmetic then fails withunsupported operand type(s), naming neither the parameter nor the reason. Declared parameters are now bound as plain symbols before parsing. Names that are not declared keep their SymPy meaning, sosqrt,log,expandpicontinue to work. params={"I": ...}was silently overwritten with the imaginary unit.Ifor a current is about as natural a name as exists in power engineering; the value was discarded and the formula evaluated with1jin its place.params={"j": ...}behaved the same way, andparams={"f": ...}leakedduplicate argument 'f' in function definitionout of generated code. The three names are now rejected with a message that leads with the offending key and suggests a rename.- Every formula whose argument goes negative collapsed to NaN.
lambdify(..., modules=["numpy"])picks the branch ofsqrtandlogfrom the dtype, not from the value:np.sqrt(-0.5625)isnanwhere SymPy says0.75*I. This is reachable in ordinary use — 0 Hz is a routine entry inNetwork.frequenciesand infault.scalings, andsqrt(f - f0)orlog(f/f0)is a routine dispersion term. Evaluation now retries on the complex plane at exactly the positions that came back NaN; every value the real-axis pass produced is kept bit-for-bit, so existing formulas are unaffected. - NaN was passed on as if it were a number — into
ComplexNumber, intocompute_real_value's R/L/C fields (whosenot np.isfiniteguard was written for theinfsentinel and swallowed NaN with it), and from there into the admittance matrix. NaN now raises, naming the formula, the frequency and the parameters.infstill passes through unchanged, because an open end and a capacitor at DC are both legitimately infinite — the split is between NaN andinf, not betweeninfand finite.1/(j*2*pi*f*C)at 0 Hz returnsinf+nan*jfrom IEEE 754 complex division; that NaN is bookkeeping, not a failed computation, and is normalised component-wise (rebuilding the value asreal + 1j*imagwould put it straight back, since1j * infisnan+inf*j). - A single un-earthed tower failed the whole network. With
impedance_formula="nan"— the documented way to model a bus without an electrode — the diagonal entry1 / complex(inf, inf)isnan+nan*j, not0: IEEE 754 complex division does not give the mathematical answer for an infinite operand. One NaN on the diagonal makes the LU factorisation report a singular matrix, so a chain of three towers of which one has no electrode did not return a result with that tower at 0 A — it failed entirely, with a message about a missing path to reference earth, while the other two towers were properly earthed. Infinite impedances are now short-circuited to zero admittance at all eight reciprocal sites (bus diagonal, branch self-admittance, Thevenin loop closure, Norton source injection, phase-side admittance in the automatic split, mutual Norton injection, per-bus earth current, branch current).
Changed (Audit pass 12 — the diagnosis for an unsolvable nodal system)¶
- NaN in
Yoriis now reported as a computation error before the solve, naming the buses and branches whose stored impedance is NaN (selfandmutualtold apart) — instead of reaching scipy and coming back as "singular matrix", which sends the engineer looking for a topology error. The injection vector is checked as well as the matrix: a NaN in a mutual impedance is a Norton current and never touchesY. - "No path to reference earth" now says which buses were looked at and
why each one failed to provide a reference, split into
Z = 0, infinite (open-end sentinel), NaN, and no impedance stored at that frequency. TheZ = 0group carries the explanation that matters most: in this model a zero grounding impedance contributes no admittance to the diagonal and therefore no connection at all — the opposite of what an engineer writing 0 for an ideal earth intends. Use a small finite value (e.g. 1e-6). Long lists are truncated after five names with the total reported, so a systematic modelling error in a 60-bus network does not bury the explanation. The wordSingularis kept at the front for callers matching on it. compute_real_value(..., name=...)now reaches the caller. The field name was documented as "used in error messages" but was only applied to the two checks in that function; every failure raised inside the shared impedance pipeline named the formula and not the field, so aBranchTypeusing the same expression forR_self_formulaandR_mutual_formulaproduced two byte-identical messages. All of them are now prefixed with the field name.- NumPy's floating-point warnings are silenced inside the formula evaluator.
They carry no information there: "invalid value encountered in sqrt" on
the real axis is exactly the case answered by re-evaluating on the complex
plane, and "divide by zero" is the capacitor-at-DC case normalised to a
clean infinity. A NaN that survives both steps is raised as a
ValueErrornaming the formula and the frequency, which is a better signal than aRuntimeWarningpointing at generated code.
Added (2026-07-28 — databases written by an older release are converted, not rejected)¶
Written because groundinsight is published: the author holds no production
.dbfiles, but users may. Detecting an old file and refusing it keeps their data safe and stops their work, which is not the same thing. Regression tests intests/test_audit_pass11_migration.py(27 tests, built on two independent legacy fixtures — one produced by down-converting a file the current code wrote, one written from the releasedCREATE TABLEstatements with no help from current code, so a shared mistake in the down-converter and the migration cannot cancel out). All 29 mutations of the migration code are killed by their intended test.
gi.migrate_database(path)converts a database written by the name-keyed schema to the current(network_name, name)one, andgi.needs_migration(path)classifies a file without touching it.Base.metadata.create_allonly ever creates missing tables — it never adds a column — so an old file used to open without complaint and fail later inside a query with a bareOperationalError: no such column: buses.network_name.gi.start_dbsession()now migrates automatically, before the engine is bound. Passmigrate=Falsefor the previous behaviour, which now raises aRuntimeErrornamingmigrate_databaseand your actual file.- The conversion is written to a temporary sibling and moved into place with
os.replace, so an interruption leaves either the old file or the new one, never a half-converted database. The unmodified original is copied to<path>.bakfirst; an existing backup is never overwritten (.bak.1,.bak.2, …). gi.MigrationReportreports what could not be recovered instead of quietly guessing, andreport.needs_attentionis a single flag for "read the warnings". Everything in it is also logged atWARNING:- shared elements — the legacy schema stored one row per element name, so an element two networks both referenced existed once. It is duplicated into each network and every such name is listed. If the two networks meant different things by that name, the older definition was already destroyed on the last save, before any migration ran.
- path segment order — the legacy
path_segmentstable had nopositioncolumn, so order is reconstructed from SQLite'srowid. That is an assumption, so it is verified: each path is walked bus by bus, and any path whose segments do not form a connected chain is listed inbroken_pathsfor rebuilding withgi.create_paths. - orphaned elements (belonging to no network — the current schema has nowhere to put them), dangling memberships (referenced but with no row of their own), and defaulted cells.
- Every converted network is loaded back before the file is swapped in. A
structurally valid file is not necessarily a usable one: the database
allows
NULLwhere the Pydantic model requires a value, so a legacy row withoutspecific_earth_resistance, or one naming a bus type the file never contained, converts cleanly and then raises on the user's firstload_network_from_db. Such networks are named inreport.unloadabletogether with the concrete error. They are reported, not raised — one bad network is no reason to withhold the rest of the file. - A missing measurement aborts the migration rather than being invented.
activeandsource_typedeclare defaults in the schema, so aNULLthere is filled with the schema's own value and counted indefaulted_cells.lengthandscalingsdeclare none: they are data, not schema conventions. Guessinglength = 0.0would hand back a network that solves without complaint and reports a completely different earth potential rise, with nothing in the output to show a number was made up. The migration stops instead and names the row; the original file is untouched, so refusing is recoverable and a wrong number would not be.
Changed (2026-07-28 — final temperatures split into an uninsulated and an insulated regime)¶
The catalogue previously shipped placeholder values from mixed sources — the National Grid Earthing Technical Specification Table 5a and IEC 60364-5-54, in one table. It now has exactly two regimes with one source each: EN 50522 Table 2, as printed in the edition held by the author, for a conductor that is not insulated, and IEC 60364-5-54 Table 54.2 for one that is. Cross-checked against the melting points of the coatings: tin melts at 231.9 °C, which is why tinned copper is the one uninsulated entry below 300 °C; zinc melts at 419.5 °C, so 300 °C for galvanised steel is not coating-limited.
IEC60949_MATERIALS["Steel"]["theta_final_default_C"]lowered from 400 °C to 300 °C.θ_fenters the IEC 60949 material constantkunder a logarithm, so the old, higher default produced a largerkand therefore permitted more current — the unsafe direction for a limit check. Studies that relied on it can be reproduced by passingtheta_final_C=400.0explicitly.- Uninsulated conductors follow EN 50522 Table 2: 300 °C for bare copper, aluminium and steel and for galvanised steel, 150 °C for tinned copper. The previous bare values (Cu 405 °C, Al 325 °C) came from the National Grid table and were more permissive.
- Insulated conductors are capped by their insulation, not by Table 2.
final_temperature(material, "PVC")returns 160 °C,"XLPE"and"EPR"return 250 °C — for every conductor material, because it is the insulation that fails first and it does not care what the metal is. These are the values of the newgi.CABLE_INSULATION_LIMITS, whichFINAL_TEMPERATURESsplices in rather than copying, so a corrected cap cannot reach one material and miss another. "PE"is deliberately not tabulated and raises. Neither source states a value for it that this package can cite, and both neighbouring answers — 300 °C from the uninsulated side, 160 °C from the PVC row — would be a number the docstring cannot point at a table for. Passtheta_final_Cexplicitly instead. Physically impossible pairings (("Al", "tinned"),("Cu", "galvanized")) raise for the same reason.
Fixed (2026-07-28 — the path set went stale after adding a fault or a source)¶
Found by asking, per input that
define_pathsreads, whether_active_topology_fingerprintactually covers it. Regression tests intests/test_audit_pass10_topology_and_transient.py(T1); every fix in this batch was mutation-tested by reverting the hunk and checking that exactly the intended tests fail.
- A fault created after the first
run_faultwas never given paths, and every bus then reported 0 V.define_pathsenumerates one path set per(source, fault)pair, so the excitation is part of the path set's input — but the fingerprint only looked at the active buses and branches. Adding a fault therefore left it unchanged,_needs_path_rebuild()returnedFalse, andrun_faultreused paths that all terminate at the old fault bus. No path reached the new one, and the result was 0 V at every bus — returned as a normal result, with no warning. The fingerprint now also covers the(name, bus)of every source and every fault. - The same omission made
find_max_rho_scalingover-estimate the admissible soil resistivity by a factor of ~3000. The bisection swept ρ against an EPR stuck at 0 V, never exceededu_max, and returned the upper bracket (100000 Ω·m instead of 33.5 Ω·m) withiterations: 0. Read as a design result that is the non-conservative direction: it declares a site adequate that is not. - Adding a source after the first solve failed the same way, but less visibly — the second source simply contributed nothing and the EPR came out plausible instead of zero (87.07 V against the correct 69.83 V at the source bus).
Fixed (2026-07-28 — parallel branches collapsed in two topology keys)¶
Found by constructing two topologies that differ only in the multiplicity of a parallel branch and checking whether the caches told them apart. Regression tests in
tests/test_audit_pass10_topology_and_transient.py(T2).
Network._active_topology_fingerprintandPathFinder._compute_topology_keyboth stored connectivity as afrozensetof bare(from_bus, to_bus)pairs. A set of endpoint pairs has no multiplicity, so parallel branches are indistinguishable from a single one. WithL3: A→DandL4: A→Dpresent, rewiringL4toA→B(whereL1: A→Balready exists) leaves the pair set{(A,B), (B,C), (A,D)}bit-identical. The stale path set and the cached adjacency list were reused, the second routeA→B→Cwas never enumerated, andrun_faultreported an EPR that was ~33 % off. Both keys now use(branch_name, from_bus, to_bus).- The branch-name term also supersedes the separate active-branch-name set in the fingerprint, which is now redundant.
Fixed (2026-07-28 — the state-space transient dropped or doubled the mutual coupling)¶
Found by running the transient solver against the stationary one on topologies where the two must agree in steady state. Regression tests in
tests/test_audit_pass10_topology_and_transient.py(T3).
- With
network.pathsempty or stale the entire Carson coupling was silently dropped. The state-space solver reads its phase factors offnetwork.paths; with no paths every factor stays zero, and the solve still succeeds. A transient study run straight afterbuild_network— i.e. without a precedingrun_fault, which is what populates the paths — returned a peak EPR 71 % away from the correct one (107.71 V against 62.82 V on a two-bus reference case), with nothing in the log. The solver now rebuilds the paths when they are missing or stale and says so vialogger.warning, pointing atrun_fault/create_paths. - A branch shared by two parallel paths of one source received a factor
of 2.0 instead of 1.0. The factors were accumulated with
+=behind a merely per-pathseenset, which contradicts both the block's own header comment and the stationary reference inElectricalNetwork._compute_phase_currents_from_paths, where the first path a branch appears on determines its direction and later appearances are ignored. On a five-bus feeder with a ring in the middle the transient EPR came out 32.5 % above the stationary one at the fault bus. The guard is now on the(branch, source)key, so it spans every path of that source. With the ring opened, so that a single path remains, both solvers always agreed — which is exactly why the existing tests did not catch it, and why the single-path case is now pinned by its own test.
Changed (2026-07-28 — breaking: the SQLite schema now keys elements per network)¶
Regression tests in
tests/test_audit_pass10_persistence.py(18 tests).
- Buses, branches, faults, sources and paths now carry a composite
primary key
(network_name, name). They were previously keyed bynamealone and attached to their network through fivenetwork_*association tables. Two networks in one database that both contain a bus called"A"therefore shared a single row: saving the second silently overwrote the first one's impedance, and loading either returned the survivor. The association tables are gone; the relationships are ordinary one-to-many withback_populates.path_segmentswas promoted from a bareTableto a mappedPathSegmentDBwith the primary key(network_name, path_name, position). - This breaks existing
.dbfiles. There is deliberately no auto-migration:save_networkandload_networkcallensure_current_schema(session)first, which raises aRuntimeErrornaming the legacy tables and pointing at the JSON round-trip (save_network_jsonon the old version,load_network_jsonon the new one) as the migration route. Silently rewriting a database whose rows may already have been merged across networks would destroy exactly the information needed to un-merge them. - Element order now survives the round-trip. Buses, branches, faults,
sources, paths and path segments carry a
positioncolumn and every relationship declaresorder_by. Before, the load order was whatever SQLite happened to return, which reorderedNetwork.busesand with it the Y-matrix assembly; the LU factorisation then differed in the last bits. With the order preserved, a save/load/re-solve is bit-identical.
Fixed (2026-07-28 — save_network could leave a half-written network on disk)¶
Regression tests in
tests/test_audit_pass10_persistence.py.
- The delete-then-insert in
save_networkwas committed in two steps. Overwriting an existing network first committed the deletion of the old rows and only then wrote the new ones, so any failure in between — a constraint violation, aKeyboardInterrupt, a full disk — left the database with the network gone and nothing in its place. The deletion is now only flushed; a singlecommit()covers the whole replacement, and the whole block is wrapped intry/exceptwithsession.rollback()before the exception is re-raised. save_networkwrote raw type rows instead of merging them. Bus and branch types are a global catalogue shared by every network; they are now written withsession.merge(...), matching whatsave_bustype/save_branchtypealready did, so re-saving a network with an edited type definition updates the catalogue rather than colliding with it.- Inconsistent path segments are now rejected before any database
access.
_validate_path_segmentsraises a namedValueErrornaming the path and the offending segment, instead of letting a path that does not connect end to end reach the disk and fail on load.
Fixed (2026-07-28 — pandapower import: schema inference, tk_s, line length)¶
Regression tests in
tests/test_audit_pass10_pandapower.py(19 tests, two of them honestly labelled as controls that survive mutation).
calc_sc'stk_s=1.0signature default was read as the protection's clearing time. pandapower copiestk_sintonet._optionsunconditionally, so a solved net always reports one — even a run that asked for neitherith=Truenor a duration. SinceI_adm = k·S/√t_k(IEC 60949), adopting 1.0 s where the project uses 3.0 s inflates the admissible current by √3 ≈ 1.73 and makes an undersized conductor look adequate.tk_sis now adopted only whenoptions["ith"]is truthy or the value differs from the 1.0 s placeholder;n_factoronly when passed explicitly or differing from the neutral 1.0. Every overwrite of an existing fault value is logged naming old → new.- Three
pl.DataFrame(list_of_dicts)sites relied on polars' schema inference. Polars inspects only the firstinfer_schema_length=100entries, so on a net with more than 100 buses a column that isNonefor the first 100 rows and numeric afterwards (kappawhere the zero-sequence data is incomplete,vn_kvfor a bus missing fromnet.bus) aborted the frame construction with aComputeError.preview_pandapower_import,read_shortcircuit_resultsandapply_shortcircuit_characteristicsnow declare their schemas explicitly, which also keeps a wholly absent column typedFloat64instead of collapsing it topl.Null. - A missing line length was silently replaced by 1.0 km. A fabricated
length propagates into every impedance (
Z ~ … · l) with no trace in the result. The fallback is unchanged but now always accompanied by alogger.warningnaming the line. A positive length is passed through verbatim, so the documentedlength = length_kmcontract holds exactly. - Zero and negative line lengths are now rejected. They previously fell
back to 1.0 km as well. A negative length flips the sign of the self and
mutual impedance; a zero length is worse than it looks, because
electrical_networkskips a branch whose impedance is exactly zero and the branch then behaves as an open circuit rather than a short (measured:length=1e-9gives EPR A = B = 107.19 V,length=0.0gives A = 1000.0 V, B = 563.29 V — a discontinuity, not a limit). The preview reportszero_lengthandnegative_lengthseparately so the zero case can be relaxed later;from_pandapowerraises aValueError.
Fixed (2026-07-28 — str() on two result objects raised AttributeError)¶
Found by an AST sweep of every
__str__/__repr__in the model modules for attribute names that no longer exist. Regression tests intests/test_thermal_optional.py(group 6).
ResultReductionFactor.__str__andResultGroundingImpedance.__str__referenced fields that do not exist. Both readself.reduction_factorresp.self.grounding_impedance; the field on either class is calledvalue. Anyprint(result.reduction_factor), f-string or log line carrying one of these objects therefore raisedAttributeError: 'ResultReductionFactor' object has no attribute 'reduction_factor'.- Why it went unnoticed for so long. pydantic generates its own
__repr__, and__repr__does not fall back to a broken__str__. A bareres.grounding_impedancecell in a notebook — the usual way these objects are looked at — renders through__repr__and looked perfectly healthy. Only the explicitstr()path was broken. The new tests assert thatstr()succeeds and that the printed text actually contains the value, so a__str__silently degraded to the pydantic default would fail too. - Both messages now also carry
fault_bus, which is what tells two results of the same network apart.
Fixed (2026-07-28 — a half-declared conductor was silently left unassessed)¶
Found by probing the optionality contract layer by layer; tests in
tests/test_thermal_optional.py(group 3b, 9 tests).
- An element carrying only one half of its thermal data was skipped
without a word.
check_conductor_limits/check_node_limitsassess a conductor only when both the material and the cross-section are present. Declaring nothing is a legitimate modelling choice and stays silent. Declaring aconductor_materialwithout across_section_mm2— or anearthing_conductor_cross_section_mm2without anearthing_conductor_material— is different: the user has clearly begun to describe the conductor and believes it is being checked. The row was emitted withwithin_limit = None, which at a glance is indistinguishable from a pass. Verified silent in all four variants (branch × 2, node element × 2) before the fix. - Such elements now raise a
logging.WARNINGnaming the element and the field that is missing, once per check with all offenders in one message. Complete and fully undeclared elements stay silent, so the warning channel does not become noise. - The assessment itself is unchanged: a half-declared element is still not judged. Guessing a cross-section would be the unsafe direction.
Internal (2026-07-28 — the thermal assessment is opt-in, and now provably so)¶
tests/test_thermal_optional.py(28 tests). Apart from the half-declared warning above, no behaviour change; the tests pin down a contract that was previously only implied.
- Contract: a grounding study is useful long before anybody has decided
on conductor materials and cross-sections, so every thermal field on
BusTypeandBranchTypeis optional and the whole electrical calculation must run without them. The tests fix six layers of that promise: run_faulton a network with no thermal and no IEC 60909 metadata produces the complete result — bus voltages,ia,i_inj, branch currents,Z_Gandr.- The thermal fields are inert: a network with every field declared
and one with none give bit-identical results
(
max |difference| = 0.0over all bus and branch quantities). check_conductor_limits/check_node_limitsdegrade into a pure current report: every branch resp. every active bus is still listed with its currents, and only the judgement columns (I_admissible_A,utilization,within_limit) areNone.- Such an unassessable frame keeps its declared dtypes, so the usual
df.filter(pl.col("within_limit") == False)gate still works and returns zero rows instead of raising. - Nothing is logged when nothing is declared — an undeclared element is
a modelling choice, not a defect. (Asserted as "no
WARNINGrecord at all", which is what makes the half-declared warning above distinguishable.) BusType/BranchTypewithout thermal fields survive the JSON and SQLite round-trips unchanged.- Documented boundary: the excitation is not optional. Once a check is
requested,
t_kand the DC characterisation (kappaorr_to_x) must come either from the arguments or fromFault.t_k_s/ the sources, and their absence raises aValueErrornaming the remedy. Tests assert the message, not just the exception type.
Fixed (2026-07-28 — a thermal check on an incomplete result reported "no violations")¶
Found while validating
notebooks/22_node_thermal_limits.ipynb, which built anElectricalNetworkto displayY,ianduand thereby silenced the branch check for the rest of the notebook. Regression tests intests/test_incomplete_results_guard.py(11 tests).
check_conductor_limits/check_node_limitscould silently report a violating network as clean. Both build their frame by looping over the stored result (result.branches/result.buses), so a missing entry produced no row — and a missing row is indistinguishable from a passing one. Reproduced on a 16 mm^2 steel shield at 292 % utilisation: after the result was emptied, two of the three natural safety gates reported clean ([r for r in df.iter_rows(named=True) if r["within_limit"] is False]->[], anddf.is_empty()->True), and the package's ownlogger.warning("Thermal limit exceeded ...")channel went silent. The third gate,df.filter(pl.col("within_limit") == False), raisedColumnNotFoundErrorinstead, becausepl.DataFrame([])carries no schema. Both checks now raiseValueErrornaming the missing branches/buses and the remedy.- How the half-built result arises through the public API.
ElectricalNetwork.solve_network()replacesnetwork.results[fault]with a freshResultcarrying bus rows only; the branch rows are filled in bycompute_branch_currents(), whichrun_faultcalls immediately afterwards. Solving a hand-builtElectricalNetworkon its own — the documented way to inspect the nodal system — therefore discarded the branch results of the precedingrun_fault. Clearing them is kept (stale branch currents beside fresh bus voltages would be silently inconsistent) and is now documented as a.. warning::onsolve_network, together with the non-mutating way to inspectY,iandu. - The guard also catches staleness in everyday use: a branch added or a
bus activated after
run_faultwould previously have been skipped without a word. Expectations respect the modelling rules — every branch is reported (inactive and open ones withi_s = 0), only active buses are, since inactive buses leave the nodal system entirely. - Output schema is now declared explicitly (
_BRANCH_SCHEMA,_NODE_SCHEMA). A legitimately empty frame — a network without branches — keeps all 15 (resp. 18) columns and stays selectable and filterable instead of collapsing to shape(0, 0). All-null columns keep their dtype too: a node whoseBusTypedeclares no element now yieldsmaterial: String/within_limit: Booleanrather than polars' inferredNull.
Added (2026-07-28 — node thermal limits: earthing conductor vs earth electrode, roadmap F4)¶
New feature demonstrated in
notebooks/22_node_thermal_limits.ipynb; tests intests/test_node_thermal_limits.py(33 tests). Full suite green (392 passed). Third increment of the "conductor thermal-limit check" roadmap item: F1 sized the shield between buses, this one sizes the two grounding elements at a bus.
EN 50522 / IEC 61936-1 size the earthing conductor (Erdungsleiter) and the earth electrode (Erder) for different currents, and the solver did not expose the first of them at all. Three physically distinct currents meet at a grounding bus, and mixing them up is the classic sizing error — in the verification network the two differ by a factor of 41.
ResultBus.i_inj/ResultBus.i_inj_freqreport the source-only nodal injection: the current a lumped earthing conductor carries into the grounding system at a source bus, or out of it at the fault bus, and zero everywhere else. This is notResultBus.ia = u_EPR / Z_B, which is only the share dissipated into the soil through the electrode.i_injis snapshotted before_add_mutual_currentsmutates the injection vector, so it deliberately excludes the mutual Norton equivalents — those model a distributed induced EMF along the line, not a current entering the node through a lumped conductor. Verified on a 3-bus chain: source bus+1000+0jA, fault bus-1000+0jA, middle bus0, whilemax|i_vector - source_injections| = 923.077 A. The nodal balance including the mutual terms remains exact (ia = i_vector + sum_br (u_other - u_self)*Y_self, residual <= 1.045e-13). Both fields default to0.0/{}, so results stored before this release still load — re-runrun_faultto populate them.BusTypegains ten fields, five per element:earthing_conductor_material,_cross_section_mm2,_theta_initial_C(20 °C),_theta_final_C,_current_split(1.0), and the same five underelectrode_. All round-trip through JSON and SQLite; the new columns are nullable and mapNULLback onto the pydantic default, so rows written by an earlier version load unchanged. A cross-section <= 0 and acurrent_splitoutside(0, 1]are rejected at the model level — a factor above 1 is not a split but an error.gi.check_node_limits(network, fault, t_k=, *, kappa=/r_to_x=, n=, f=, aggregation=, elements=)appliesI_th = I_rms * current_split * sqrt(m + n)(IEC 60909-0) againstI_adm = k*S/sqrt(t_k)(IEC 60949) per bus and element, and returns a long-format Polars frame (bus_name,element,I_rms_A,current_split,I_conductor_A,i_p_A,kappa,m,n,t_k_s,I_th_factor,I_th_A,material,cross_section_mm2,k,I_admissible_A,utilization,within_limit). Every bus is reported; an element theBusTypedoes not declare keeps its current columns filled and getswithin_limit = None, so it can be sized by hand without re-running. Verified against an independent hand calculation of all 18 columns.current_splitis a free factor in(0, 1], not derived automatically:1.0for a single conductor,1/Nfor N parallel legs,0.5for a ring fed at one point, or an IEEE Std 80 division factor. The split depends on geometry the nodal model does not carry, so guessing it would be worse than asking for it.gi.final_temperature(material, covering)andgi.FINAL_TEMPERATURESprovide theta_f with the source named inline per entry (National Grid ETS Table 5a for bare buried conductors, IEC 60364-5-54 Table 54.2 for PVC / XLPE). The catalog is deliberately incomplete rather than filled with plausible-looking numbers;final_temperatureraises for anything missing and names EN 50522 Table 2 as the source to consult.
The IEC 60909 excitation is resolved by a new shared helper
_resolve_sc_inputs, lifted verbatim out of check_conductor_limits, so
the branch and node views of one fault can no longer drift apart; a
regression test pins kappa, m and sqrt(m+n) equal across both.
Not changed, deliberately: the IEC60949_MATERIALS["Steel"] default of
400 °C is higher — i.e. more permissive, the unsafe direction for a
limit check — than the 300 °C the National Grid table gives for bare
buried steel. Moving a default silently would move every existing study,
so the discrepancy is documented as a warning in the source and in
docs/api/analysis.md instead, pending a check against EN 50522 Table 2.
Still open on this roadmap item: mechanical (electrodynamic i_p) limits,
and a dedicated EarthElectrode model for several electrodes per bus.
Added (2026-07-19 — IEC 60909 short-circuit characteristics, roadmap F2/F3)¶
New feature demonstrated in
notebooks/21_shortcircuit_characteristics.ipynb; tests intests/test_shortcircuit_60909.py(37 tests). Full suite green (359 passed). Second increment of the "conductor thermal-limit check" roadmap item: it removes the hand-enteredkappaandT_kthat F1 still required, by importing them from a solved pandapower short-circuit case.
gi.read_shortcircuit_results(net_pp, *, t_k_s=, n_factor=1.0, f=50.0, buses=)reads a solvedpandapower.shortcircuit.calc_sccase as IEC 60909 quantities and returns a Polars DataFrame (pp_bus_index,bus_name,vn_kv,fault_type,case,i_k_a,r1_ohm,x1_ohm,r0_ohm,x0_ohm,r_to_x,kappa,kappa_origin,t_k_s,n_factor,m,i_p_a,i_th_a). Fault type, case and clearing time default tonet._options, so a solved net is self-describing. Currents are reported in amperes, not kA.gi.apply_shortcircuit_characteristics(network, sc_results, fault, *, pp_bus=/bus_name=, sources=, set_source_values=False, frequency=, t_k_s=, n_factor=)writes those quantities onto a groundinsight model: each feeding source receives the loop'sr_to_x/kappaand a share ofI_k''proportional to its present injection, whileT_kandngo onto theFault(they describe the protection, not the infeed). Returns an audit frame includingi_k_previous_a, so a review sees exactly what changed.set_source_values=Falseby default — an existing excitation is never silently overwritten.Sourcegainsi_k_a,r_to_x,kappaandFaultgainst_k_s,n_factor, all validated (kappain(1, 2],nin(0, 1],r_to_x >= 0) and round-tripping through JSON and SQLite;gi.create_source/gi.create_faultaccept them directly. These are metadata: they never enter the linear solve.gi.resolve_fault_sc_characteristics(network, fault, *, frequency=, aggregation="weighted")resolves one effectivekappafor a fault from all feeding sources and returns aFaultShortCircuitData. The default current-weighted meankappa_eff = Σ(kappa_i·I_i)/Σ(I_i)reproduces the sum of the individual peaks exactly (verified to 1.6e-16);aggregation="max"is the strictly conservative variant.gi.check_conductor_limitsnow falls back toFault.t_k_s,Fault.n_factorand the resolvedkappawhen they are not passed explicitly, and reports two new columnsi_p_Aandt_k_s. Explicit arguments still win, so sensitivity studies are unaffected.gi.peak_short_circuit_current,gi.thermal_equivalent_currentexported; the 60909 primitives now live ingroundinsight.analysis.shortcircuitand are re-exported fromanalysis.thermalfor backwards compatibility.
Two deliberate deviations from pandapower, both pinned by tests:
ip_ka / ith_ka are entirely NaN for fault="1ph" — the case that
matters for grounding — so they are derived here; and I_th is always
recomputed with our m, because pandapower's _calc_ith sets m = 0 for
kappa > 1.99 where the analytic limit is m = 2, which under-estimates the
thermal stress. The R/X driving kappa is that of the earth-fault loop
2·Z1 + Z0, not R1/X1; where pandapower does publish ip_ka its
topology-aware kappa is preferred and recorded as kappa_origin.
Still open on this roadmap item: mechanical (electrodynamic i_p) limits and
node/bus-earth thermal limits.
Docs (2026-07-19 — F1/F2/F3 pages)¶
docs/api/analysis.mdgained a Conductor thermal limits (IEC 60949 / IEC 60909-0) section, including the what is superposed and what is not rule with the weighted-kappaformula; the page intro no longer claims the subpackage covers only the inverse rho problem.docs/api/io.mdgained a Short-circuit characteristics (IEC 60909-0) section coveringread_shortcircuit_results/apply_shortcircuit_characteristicsand the two deliberate deviations from pandapower; the intro now separates the topology import from the result import.docs/api/index.mdanddocs/index.mdlist the new entry points, so the overview pages no longer describe a pre-F1 feature set.
All four pages verified with a local mkdocs build — no new warnings, math
renders through arithmatex.
Fixed (2026-07-19 — dead "Research notebooks" nav section in mkdocs.yml)¶
The nav: block advertised a Research notebooks section with 16
../notebooks/*.ipynb entries. None of them ever reached the published site.
MkDocs only collects files below docs_dir, so every one of the 16 was
reported as
WARNING - A reference to '../notebooks/01_smoke_test.ipynb' is included in
the 'nav' configuration, which is not found in the documentation
files.
and then dropped from the navigation. The section was removed and replaced by a comment recording why the path shape cannot work.
Evidence, in the order it was collected:
- Reproduction.
mkdocs buildon the unmodified config emits exactly 16 warnings, one per entry; after the removal it emits 0. Under--strictthe old config exits 1, the new one exits 0. - Control test separating "file missing" from "path unresolvable". A real
notebook was copied to exactly
notebooks/01_smoke_test.ipynband the build repeated — the warning persisted verbatim. The cause is therefore structural (outsidedocs_dir), not a missing file, and nomkdocs-jupytersetting can repair it. - Why it stayed unnoticed.
.github/workflows/docs.ymlrunsmkdocs gh-deploy --force --clean --verbosewithout--strict, so the build stayed green while the section silently vanished. - Nothing is lost by the removal.
/notebooksis gitignored (.gitignore:144), so the files never reach a CI checkout in the first place;git ls-files notebooks/tracks only 01–08, which predate the ignore rule. The live site at https://ce1ectric.github.io/groundinsight/ was checked and has no "Research notebooks" section. - Stale on top of broken. The removed comment claimed "16 ipynb files (01..13 plus the four audit-pass demonstration notebooks)" while listing only 14–16; the working tree meanwhile holds notebooks up to 21. The list had drifted out of sync in addition to never rendering.
Curated notebooks belong in docs/examples/ (the Examples section, which does
render). The research notebooks stay a local working artefact.
Added (2026-07-19 — conductor thermal-limit check, roadmap F1)¶
New feature demonstrated in
notebooks/20_thermal_limits.ipynb; tests intests/test_thermal_limits.py(18 tests). Full suite green (322 passed). First increment of the "conductor thermal-limit check" roadmap item — the equipment-integrity counterpart to the planned EN 50522 touch-voltage (person-safety) assessment.
gi.check_conductor_limits(network, fault, t_k, *, kappa=/r_to_x=, n=1.0, f=)compares every grounding branch's thermally equivalent short-time current against its adiabatic thermal limit and returns a Polars DataFrame (I_s_rms_A,kappa,m,n,I_th_A,material,cross_section_mm2,k,I_admissible_A,utilization,within_limit).I_th = I_s_rms * sqrt(m + n)(IEC 60909-0) is applied to the superposed AC RMS shield current — linear superposition first, the non-linear peak/thermal factor on the aggregate, soI_this never superposed directly — andI_adm = k * S / sqrt(t_k)(IEC 60949). A branch is checked only when itsBranchTypedefines bothconductor_materialandcross_section_mm2.BranchTypegainsconductor_material("Cu"/"Al"/"Steel"),cross_section_mm2,theta_initial_C(default 20 °C) andtheta_final_C(default per material — bare-earthing-conductor values, EN 50522). All four round-trip through JSON and SQLite.- IEC helpers
gi.iec60949_k,gi.iec60909_m,gi.kappa_from_r_to_xand the material cataloggi.IEC60949_MATERIALS(base constantK+βfor Cu/Al/Steel, verified to reproduce the standardktables — e.g. copper XLPEk = 143, PVCk = 115, aluminium XLPEk = 94).
Still open on this roadmap item: mechanical (electrodynamic i_p) limits and
node/bus-earth thermal limits. (The automatic kappa / T_k from a
pandapower calc_sc import, listed as open when this entry was written, has
since been delivered — see the F2/F3 entry above.)
Fixed (Audit pass 9 — implemented 2026-07-19)¶
Resolved on the current audit branch and demonstrated in
notebooks/19_audit_pass9_fixes.ipynb. Regression coverage lives intests/test_audit_pass9_fixes.py(11 tests, all green); the full suite stays green (303 passed).
run_faultrebuilds paths when the active topology changed. It previously rebuilt paths only whennetwork.pathswas empty, so flippingBus.active/Branch.activein place (or rewiring a branch) and callingrun_faultagain silently reused stale paths while the Y-matrix was rebuilt from the current flags — a wrong EPR with no warning.Networknow carries an active-topology fingerprint (active buses, active branches, connectivity) recorded bydefine_paths;run_faultrebuilds viainvalidate_paths()whenever it changed. Theoutage_contextpath was already safe.- Transient solvers treat the source waveform as the literal injection. The
state-space solver multiplied the mutual-coupling phase current by
fault.scalingsbut left the shield injection unscaled, so the result moved withscalings(up to ~2x) even though the waveform was unchanged; the FFT solver ignoredscalingsentirely.scalings(a frequency-domain concept for the stationary solve) is now consistently not applied by either transient solver. The state-space solver additionally warns when a purely resistive branch carriesR_mutual/M_mutual(its mutual term is only modelled for inductive branches). - The inverse rho / rho-f routines restore network state.
find_max_rho_scaling,evaluate_max_epr_under_k(and thusfind_max_rho_f_scaling/select_rho_f_from_catalog) left the sharedNetworkmutated: a danglingactive_faultpointing at a deleted temporary fault on a fresh network, and aresultsentry for a reused pre-existing fault overwritten with the search's probe EPR. They now snapshot and restoreactive_fault(throughset_active_fault, re-syncing the_activeflags) and the affectedresultsin theirfinallyblocks. The returned figures were already correct. Network.resultssurvive the SQLite round-trip.NetworkDBgained aresultsJSON column (serialised viaResult.model_dump), so a solved network saved to and loaded from SQLite keeps its per-fault results — the JSON backend already did.Fault.activesurvives the JSON round-trip.Networkgained an after-validator that re-syncs each fault's read-onlyactiveflag withactive_faulton construction, somodel_validate_jsonno longer resets it toFalse(the SQLite path was already correct).- Duplicate pandapower line names no longer abort the import.
from_pandapowernow disambiguates line names by index (mirroring the bus handling), so two equally-named lines import as distinct branches instead of raising onadd_branch;preview_pandapower_importand the commit agree. Non-positivelength_kmvalues fall back to the 1.0 km default. - Singular / floating networks raise a clear error.
solve_networknow detects a floating network (no active bus referenced to earth) structurally before the solve — a tolerance-free check independent of scipy's version-dependent handling of a singular matrix (splumay raise aRuntimeError, return a non-finite solution, or return an arbitrary finite one) — and raises aValueErrornaming the frequency instead of a raw traceback or a downstream divide warning. The sparse solve keepsRuntimeError/ non-finite backstops for any residual singular case.
Fixed (Audit pass 8 — implemented 2026-07-19)¶
Resolved on the current audit branch and demonstrated in
notebooks/18_audit_pass8_fixes.ipynb. Regression coverage lives intests/test_audit_pass8_fixes.py(21 tests, all green); the full suite stays green (271 passed).
Network.define_pathsno longer shadows(source, fault)pairs that share a branch route. The dedup signature keyed only on the branch-name sequence, so two faults on the same bus — or two sources on the same bus — collided and one path was dropped. The shadowed fault then solved toEPR = 0and a shadowed source was silently ignored, i.e. the tool underestimated the earth-potential rise (safety-relevant). The signature now includessource_nameandfault_name. Verified: two faults on one bus give identical non-zero EPR; two equal sources on one bus double the drive; the single-fault path count is unchanged.- Open-end (
inf) values survive the JSON round-trip.ComplexNumber(andBus/Branch/Network) now setmodel_config = ConfigDict(ser_json_inf_nan="constants"). Pydantic's default serialised non-finite floats as JSONnull, so an open-end impedanceinf(from the documented"nan"formula) reloaded asnanand poisoned the solve, and aninfin a lumped RLC dict raised aValidationErroron reload. JSON now emitsInfinity/NaNand reloads them intact, matching the SQLite backend. (Note:Infinity/NaNare a Python-jsonextension, not strict RFC-8259 — a deliberate trade-off to keep the two backends consistent and avoid silent data loss.) - Impedance/RLC formula strings are no longer an arbitrary-code-execution
sink.
utils.validations.assert_safe_formulatokenises every formula and rejects dunder names, a denylist of dangerous builtins (eval,open, …), attribute access (.) and string literals before it reachessympy.sympify(which evaluates its input as Python). Applied in bothvalidate_impedance_formula_valueandimpedance_calculator._compile_formula, soload_network_from_jsonand the DB load path are covered. Ordinary free symbols (rho,f,l, therohtypo,pi,NaN, scientific notation, …) are unaffected.
Fixed (Audit pass 7 — implemented 2026-05-24)¶
The bullets in this sub-section have been resolved on branch
feature/audit-pass7-fixesand are demonstrated end-to-end innotebooks/17_audit_pass7_fixes.ipynb. Regression coverage lives intests/test_audit_pass7_fixes.py(13 tests, all green). The Pass-7-Backlog bullet „db_session synonym carries cross-API maintenance debt" and the Pass-7 Roadmap-bullet „ADR-0013 — Cross-reposhow_versionsconvention" on thegroundinsightside are now closed.
groundinsight._set_session(new)is the new single source of truth for the module-level scoped session globals.gi.sessionand the historic aliasgi.db_sessionare pinned in lock-step: every code path that rebinds the session (start_dbsession,close_dbsessionand any plannedswap_dbsessioncontext manager) routes through_set_sessioninstead of assigning the globals directly. Removes the cross-API drift risk flagged inaudit-report-changelogs-2026-05-18-pass7.md(item „db_session synonym carries cross-API maintenance debt"). The two helpers are now sole consumers of the globalengine/SessionLocalnames; only_set_sessiontouchessession/db_session.groundinsight.show_versions()is the cross-repo convention helper introduced for the Pass-7 Roadmap item „ADR-0013 — Cross-reposhow_versionsconvention". Returns a dict with at least{"groundinsight": __version__, "python": ..., "platform": ...}; the peer packagesgroundfieldandgroundmeasare added when importable. Returns a fresh mapping on every call so it is safe to mutate. Listed in__all__.docs/api/database.mdhas new sections "Session globals:gi.sessionandgi.db_session" (documents the lock-step contract enforced by_set_session) and "Cross-repo version helper:gi.show_versions()" (documents the new API and shows the example output).
Fixed (Audit pass 6 — implemented 2026-05-18)¶
The bullets in this sub-section have been resolved on branch
feature/audit-pass6-fixesand are demonstrated end-to-end innotebooks/16_audit_pass6_fixes.ipynb. Regression coverage lives intests/test_audit_pass6_fixes.py(18 tests, all green). The matching backlog bullets in the sixth 2026-05-14 review pass and seventh 2026-05-18 review pass sub-sections below are left in place so the audit reports stay self-consistent until the next release is cut.
pathfinder._GRAPH_CACHE/_FIND_PATHS_CACHEare now wrapped inOrderedDictwith a configurable LRU cap (default256). The cap is surfaced via the new helpersgi.set_pathfinder_cache_size(n)/gi.get_pathfinder_cache_size(). A 100-scenario outage sweep that previously accumulated 100 cache entries indefinitely now stays bounded; dashboard authors can raise the cap for larger working sets or lower it for tests that want to pin the eviction policy.simulation/outage.outage_contextclears the per-network pathfinder cache on exit as well as on entry. Combined with the new LRU cap this closes the multi-hundred-megabyte memory leak that appeared on long dashboard sessions over many outage scenarios.simulation/outage.outage_contextis nestable ("re-entrant safe"). Eachwithblock records a per-level baseline (captured before it flips its targets) on a module-level_OUTAGE_BASELINE_STACK[id(network)]list, and only reverts what that level touched on exit. Nested blocks no longer capture the already-modified outer state as their own baseline.Network._validate_frequenciesdocstring matches the implementation: DC (f = 0) is permitted, only strictly-negative values are rejected. The new error message readsNetwork.frequencies must be >= 0; got ... (f == 0for the DC bin is permitted)..Network.frequenciesemits a newNetworkFrequencyOrderWarning(UserWarning)when the input is not strictly increasing. The FFT transient solver insimulation/transient.TransientStudyuses the order ofNetwork.frequenciesto map spectral bins, so[100.0, 50.0]silently produced a transient with the spectral bins reversed. Mirrorsgroundfield.solver.engine.EngineFrequencyOrderWarningintroduced in the Pass-5groundfield 0.5.0cut. The new category is re-exported asgi.NetworkFrequencyOrderWarning.Network.invalidate_paths()is now an atomic rebind:self.paths = {}instead ofself.paths.clear(). External snapshots taken withsaved = dict(network.paths)before the call now survive — mirrors the Pass-4 atomic-rebind fix inanalysis.inverse_rho_f.evaluate_max_epr_under_k.gi.set_active_fault(network, fault_name, keep_results=False)is a new top-level factory that propagates the Pass-5keep_results=keyword to the bound method onNetwork. The documented public-API form now matches the bound method's capability.__version__bumped from0.4.0to0.5.0insrc/groundinsight/__init__.pyandpyproject.toml. The release cut was four passes overdue; the0.5.0minor release now bundles the Pass-4 + Pass-5 + Pass-6 implementation blocks (transient state-space solver, capacitance support, pandapower importer hardening,Network.invalidate_paths,db_sessionalias, frequency validator + order warning, pathfinder LRU cache, top-levelset_active_faultfactory).mkdocs.ymlflipped todocstring_style: numpyso the rendered API pages keep their per-field type annotations after the 2026-05-14 docstring sweep, and the Examples / Research-notebooks nav now lists all 16 on-disk notebooks (was 5 of 15).groundinsight.__all__gainsset_active_fault,clear_pathfinder_cache,get_pathfinder_cache_size,set_pathfinder_cache_size, andNetworkFrequencyOrderWarning— the Pass-6 surface additions are reachable viafrom groundinsight import *and visible to type-checkers.
Fixed (Audit pass 5 — implemented 2026-05-13)¶
The bullets in this sub-section have been resolved on branch
feature/audit-pass5-fixesand are demonstrated end-to-end innotebooks/15_audit_pass5_fixes.ipynb. Regression coverage lives intests/test_audit_pass5_fixes.py. The corresponding bullets remain in the legacy backlog sub-section fifth 2026-05-13 review pass below so that the audit reports stay self-consistent until the next release is cut.
Network.invalidate_paths()is now scoped to the calling network instance. Previously the helper calledclear_pathfinder_cache()unconditionally and blew away the cache for every other network live in the same Python process; a notebook iterating over two networks therefore paid a full DFS on every flip.clear_pathfinder_cache(network=...)is the new scoped form; the unscoped call (clear_pathfinder_cache()) still drops everything as a recovery / test-fixture fallback.pathfinder._GRAPH_CACHE/_FIND_PATHS_CACHEinclude a structural fingerprint(network.name, len(buses), len(branches))in the cache key in addition toid(network). CPython is allowed to recycleidvalues once an object has been garbage-collected; the structural component prevents the resulting false cache hit on a topologically different successor.groundinsight.__all__previously advertiseddb_sessionbut the module-level symbol was namedsession, so the documentedfrom groundinsight import db_sessionraisedImportError.db_sessionis now a real module-level alias forsession, kept in lock-step bystart_dbsessionandclose_dbsession, and both names are listed in__all__for symmetry.close_dbsessiontears the three module globals (session,engine,SessionLocal) down independently and never raises on a half-constructed state (e.g. after astart_dbsession(force=True)was interrupted betweenengine.dispose()and the re-assignment ofsession). The helper still logsWARNING: No database session to closewhen every global is alreadyNone.Network.frequenciesis validated at construction time: empty lists, duplicate frequencies, non-finite (nan,inf) and negative values are rejected with a clearValueError. DC (f = 0) remains a valid solve frequency. The bug used to silently double the work insolve_networkand double the amplitude of the corresponding FFT spectral bin in the transient solver.Network.set_active_fault(fault_name, keep_results=False)accepts a new keyword argument: withkeep_results=True, the previously cachedResultfor the activated fault is preserved so a notebook can re-plot the existing solve without recomputing. The default (False) preserves the historic clear behaviour.docs/api/core_models.mddocumentsNetwork.invalidate_pathsand the new active-subset semantics; the mkdocstrings dump now explicitly enumerates the public surface.docs/api/pathfinder.mddocuments the module-level caches, the new structural fingerprint and the scoped form ofclear_pathfinder_cache.mkdocs.ymlNotebooks nav lists all 15 notebooks (was: 3 of the 14 Pass-4 notebooks). The remaining Pass-5 follow-on items (mkdocs build --strict, notebook front-matter contract) are tracked in the Tests-backlog below.
Fixed (Audit pass 4 — implemented 2026-05-13)¶
The bullets in this sub-section have been resolved on branch
feature/audit-pass4-fixesand are demonstrated end-to-end innotebooks/14_audit_pass4_fixes.ipynb. Regression coverage lives intests/test_audit_pass4_fixes.pyplus updated cases intests/test_logging.py. The corresponding bullets have been left in the legacy backlog sub-sections below so that the four audit reports remain self-consistent until the next release is cut.
mkdocs.ymlno longer referencespolyfill.io. The original CDN entry was sold and later served malicious JavaScript; MathJax 3 does not require a polyfill for modern browsers. The comment block documenting why the entry was removed remains in the YAML so the next audit does not flag it for a fifth time.__init__.set_log_levelis now truly handler-idempotent across alternating levels. A private sentinel attribute (_groundinsight_console_handler) on the installedStreamHandlerlets the helper dedupe both stale (pre-0.5) handlers and accidental duplicates from notebook reload cycles. The docstring documents the interaction withlogging.basicConfigexplicitly so users can opt intopropagate=Falsewhen they hit the double-output case.groundinsight.__all__now lists every public top-level helper:set_log_level, the persistence factories (start_dbsession,close_dbsession,save_*_to_db/load_*_from_db,save_network_to_json,load_network_from_json).from groundinsight import *and the major type-checkers therefore see the full surface.__init__.start_dbsessionis hardened. A second call with the same path warns and re-uses the existing engine (no-op). A second call with a different path raisesRuntimeErrorunlessforce=Trueis given, in which case the old engine is properly disposed before the rebind — no more leakedscoped_sessionregistries or silently-replaced engines.save_bustype_to_db/load_bustypes_from_db/save_branchtype_to_db/load_branchtypes_from_db/save_network_to_db/load_network_from_dbnow close their scoped session viatry/finallyso a failure inside the underlying CRUD helper no longer leaks the session in the scoped-session registry.models.core_models.Fault.scalingsaccepts integer keys but coerces them tofloatin afield_validator(..., mode="before"). Unparsable keys are rejected at validator time with a clearValueError. The historic dead-int-key bug — where{50: 1.0}was silently ignored because the runtime lookup compared againstfloat(network.frequencies[i])— can no longer happen.pathfinder.PathFindercaches both the adjacency graph and thefind_pathsresults at module level, keyed on(id(network), frozenset(active_buses), frozenset(active_branches)). Multiple PathFinder constructions over the same logical topology (the inner loop ofanalysis.inverse_rho_f.evaluate_max_epr_under_k) now pay the DFS cost only on the first call. A newclear_pathfinder_cache()plusNetwork.invalidate_paths()give the user an explicit hook for the rare in-place-mutation case.io.pandapower_import._classify_buseslogs a warning and skips the row with the newvn_kv_unparsablereason whenevervn_kvis missing /None/NaN. The previous behaviour silently coerced the value to0and re-classified the row as a voltage-level mismatch, hiding the data quality issue from the user.io.pandapower_import._classify_linesexplicitly skips self-loop lines (from_bus == to_bus) with the newself_loopreason and a warning. Self-loops would have producedBranch(from_bus=X, to_bus=X)and tripped network validators only on some code paths.io.pandapower_import.from_pandapowerpromotes the zero-bus / zero-branch result to alogger.warning. A wrongvoltage_level_kVno longer produces a silent emptyNetwork.io.pandapower_import.preview_pandapower_importcarries the sameinclude_trafosparameter asfrom_pandapower(raisingNotImplementedErrorfor now). The preview-vs-commit asymmetry flagged in the third pass is resolved.simulation/waveforms.damped_oscillationrejectsdecay_tau <= 0andt_off <= t_onat factory time.simulation/waveforms.sinusoidal_with_dc_offsetrejects non-positivefrequency_hz,dc_decay_tau <= 0(when set), and inverted on/off windows. The classic ms-vs-s unit confusion now surfaces immediately.simulation/waveforms.steprejects inverted on/off windows for the same reason.analysis.inverse_rho_f.evaluate_max_epr_under_kraises a descriptiveLookupErrorwhen the swept bus is missing from the result frame (typically due to arun_fault_kwargs={"buses": [...]}filter). The previous bareStopIterationfrom the generator expression is gone. Thenetwork.pathsrestore at the end of the sweep is now an atomic rebind (rather thanclear()+update()), closing the transient-empty-dict window for concurrent readers.analysis.inverse_rho_f.find_max_rho_f_scalingre-evaluates the EPR breakdown atc_maxafter the bisection terminates so thatmax(epr_rms_per_bus_at_c_max.values()) == max_epr_rms_at_c_maxis structurally guaranteed regardless of how the loop exits.models.core_models.Network.invalidate_paths()is a new public helper that dropsnetwork.pathsand the module-level PathFinder caches in one call.
Part 2 — engineering detail on the 0.5.0 features¶
The reasoning behind the transient solver, the RLC parameterisation and
their persistence: state-vector layout, the Schur complement used to
eliminate the algebraic buses, the branch-current sign convention, the
pi-section lumping of branch shunt capacitance, the Carson substitution
at DC, and the database columns the new fields occupy. CHANGELOG.md
carries the user-facing summary of the same work.
Added¶
- Lumped RLC formulas on
BusTypeandBranchType— optional parallel parameterisation for the upcoming transient solvers. New fields are kept asOptional[str]and default toNone, so existing networks and stationary studies are unaffected: BusType:R_formula,L_formula,C_formula.BranchType:R_self_formula,L_self_formula,C_self_formula,R_mutual_formula,M_mutual_formula. Each formula is validated with the same SymPy parser as the existing impedance formulas and may use the symbolsrho,f(andlon branches). The frequency-domain solver continues to ignore the new fields entirely; the duplication is intentional so the stationary and transient parameterisations can be maintained independently (the decision recorded in the Phase 2 design discussion).- Evaluated RLC dicts on
BusandBranch—Bus.calculate_impedanceandBranch.calculate_impedancenow also populate the correspondingR,L,C(bus) andR_self,L_self,C_self,R_mutual,M_mutual(branch) dictionaries when the type has the matching formulas. Values are real (Dict[float, float]) and are produced by the new helpercompute_real_valueingroundinsight.utils.impedance_calculator, which re-uses the same compilation cache ascompute_impedanceand rejects formulas that evaluate to a non-negligible imaginary value. - Notebook
notebooks/09_rlc_parameters.ipynb— demonstrates the parallel maintenance ofimpedance_formulaand the lumped RLC formulas on a HF-aware substation grounding equivalent, verifies that the lumped RLC reconstructZ(f)of the stationary formula across five decades of frequency, and shows the matching pattern on a branch type. groundinsight.simulation.transientsub-module — first transient solver path, FFT-based. New public types:gi.TransientStudy(network, fault_name)— high-level study object withset_source_waveform,set_observation(buses=..., branches=...)andsolve(t_end, dt, solver='fft').gi.ResultTransient— Pydantic container withtime_s,epr_t,i_branch_t,source_tplus ato_polars()long-format export. Phase 3 limitations are documented in the module docstring: onlysource_type='current'sources are accepted, and mutual coupling is not evaluated by the FFT solver. Both restrictions go away with the state-space solver in Phase 4.groundinsight.simulation.waveformslibrary —step,sinusoidal_with_dc_offsetanddamped_oscillationfactory functions returning vectorised time-domain callables. Exposed asgi.waveforms.- Plotting helpers
gi.plot_epr_transientandgi.plot_branch_current_transient— matplotlib time-domain plots forResultTransient, mirroring the existingplot_*API and defaulting to all observed signals when no explicit selection is passed. - Notebook
notebooks/10_transient_fft.ipynb— fault-initiation / fault-clearing demo on a small two-bus inductive network: 50 Hz current with exponentially decaying DC offset, fault on at 20 ms, cleared at 120 ms, EPR and shield current shown over the entire switching cycle. - State-space ODE solver in
gi.TransientStudy— second transient solver path, accessible viasolve(t_end, dt, solver="state_space"). Builds the modified-nodal-analysis formdx/dt = A*x + B*u,y = C*x + D*udirectly from the lumped RLC fields onBusType/BranchType(parallelR || Lshunt at every active bus, seriesR_self + L_selfalong every grounding branch). State vector contains every bus-inductor current and every branch-inductor current; integration is done viascipy.signal.lsim. The right tool for fault-initiation and fault-clearing transients dominated by L/R behaviour, where the FFT solver cannot resolve the true ring-down. Sameset_source_waveform/set_observation/ResultTransientcontract as the FFT solver — switching between solver paths is a one-line change. Phase 4 limitations documented in the module: bus capacitance and branch shunt capacitance reserved for a later release; mutual coupling reserved; onlysource_type='current'accepted. - Notebook
notebooks/11_transient_state_space.ipynb— same fault-on / fault-off scenario as Notebook 10, run through the state-space path. Compares FFT and state-space EPR traces side by side and zooms into the clearing transient to make the post-event exponential decay visible. - Bus capacitance support in the state-space solver —
Bus.C(populated fromBusType.C_formula) is now part of the ODE: the bus voltage at every capacitive bus becomes a state variablev_CwithC * dv_C/dt = i_C. The state-vector layout is extended to[i_L_bus, i_L_branch, i_L_voltage_source, v_C_bus], and the non-capacitive bus voltages are eliminated algebraically via a Schur complement of the resistive conductance matrix. - Voltage-source support in the state-space solver — sources with
source_type='voltage'are now accepted by the state-space path.source_impedanceis decomposed atnetwork.frequencies[0]into a realR_srcand an inductive partL_src = imag/(2*pi*f_eval); forL_src > 0a synthetic loop branch between source bus and active fault bus is added with its own inductor state and the EMF (the user-supplied waveform) enters the di/dt equation directly. ForL_src == 0the loop is purely resistive and the source is reduced to its Norton equivalent (Y_srcloop closure plus injectedU/R_srcwaveform). The FFT solver still rejects voltage sources atsolve()time with a pointer to the state-space alternative. - Branch-current sign convention in the state-space solver —
inductive and resistive branch currents reported by the
state-space solver now follow the project-wide convention used by
compute_branch_currentsand the FFT solver (i_branch = (v_to - v_from) * Y_self). The previous state-space output was inverted; users comparing FFT and state-space traces on the same network will now see them sit on top of each other for the forced response. - Notebook
notebooks/12_transient_thevenin_rlc.ipynb— Thevenin source switched onto a substation bus modelled as parallel R || L || C. Two LC modes (bus-internal tank and source-loop tank) are visible in the EPR trace and confirmed by the post-event spectrum. - Automatic pi-section lumping of branch shunt capacitance in the
state-space solver — every grounding branch with a populated
C_self_formulanow contributesC_self / 2to each of its endpoint buses. The user no longer needs to maintain a duplicateC_formulaon the bus type when the cable's distributed capacitance is the dominant shunt. The frequency-domain solver path is unchanged:C_selfis not part of the FFT branch admittance, so the user continues to model the lumped C inBus.impedance_formulafor consistency between the two solvers. - Carson-style mutual coupling in the state-space solver —
R_mutualandM_mutualare now part of the ODE assembly via the substitutionz = i_shield + (M / L_self) * I_phase. The substitution eliminates theM * dI_phase/dtterm from the KVL of the shield branch and leaves a clean linear feedforward of the source waveform into bothB_kclandB_emf. Restricted to current sources for now (voltage-source phase currents are state-dependent and require a more involved substitution); voltage sources skip the mutual contribution and emit a one-time warning. - Notebook
notebooks/13_mv_ring_transient.ipynbupdated to exercise the two new features on a 20 kV / 20-bus medium-voltage ring with NA2XS(F)2Y cable (per-length values from datasheet). Compares FFT and state-space with mutual coupling on, and shows the reduction-factor effect of the cable shield.
Internal¶
- Persistence:
BusTypeDB/BranchTypeDBgained the new formula columns;BusDB/BranchDBgained nullable JSON columns for the evaluatedR/L/C(bus) andR_self/L_self/C_self/R_mutual/M_mutual(branch) dicts. Two helpers_real_dict_to_json/_real_dict_from_jsonkeep the (de)serialisation symmetric. JSON and SQLite roundtrip tests cover both the formula columns and the evaluated dicts.
Part 3 — open findings, not yet scheduled¶
Nothing below is implemented. These are findings that were confirmed
during a pass but deferred, kept in the wording of the pass that raised
them. Entries written before a later pass may have been resolved since
without being struck through here — check
CHANGELOG.md
first.
Fixed (Backlog — eighth 2026-05-25 review pass)¶
The eighth audit pass was run on 2026-05-25, one day after the Pass-7 implementation run on 2026-05-24 (which closed Pass-7 „db_session synonym carries cross-API maintenance debt" and the Roadmap-bullet „ADR-0013 — Cross-repo
show_versionsconvention"). The Pass-4 → Pass-7 implementation blocks still sit uncommitted onfeature/audit-pass5-fixes(60 modified files + 14 untracked files including the newsimulation/transient.py,simulation/waveforms.pyand five new example notebooks). Only the CHANGELOG is edited in this pass; no program code is touched.
__version__ = "0.5.0"is set but the0.5.0release is not cut —feature/audit-pass5-fixesstill holds the entire Pass-4 → Pass-7 implementation set as a single uncommitted blob (60 modified, 14 untracked).src/groundinsight/__init__.pyandpyproject.tomlboth carry0.5.0whileCITATION.cffstill carries0.4.0— the drift that makesscripts/release.pyabort before it does anything (resolved in the Pass-17 block above).git logis at04bd936 chore(release): v0.4.0. Five audit passes have flagged the missing release cut. The recommended commit-sequence fromaudit-implementation-report-groundinsight-2026-05-24-pass7.mdhas not been executed; the eighth pass elevates this to a release-blocker — every additional audit run rebuilds the same Pass-4/5/6/7 ledger because the work is not yet history.- Transient subsystem has no dedicated
Addedblock under[Unreleased].src/groundinsight/simulation/transient.py(untracked, 11 functions,ResultTransient+TransientStudy) andsrc/groundinsight/simulation/waveforms.py(untracked,step,sinusoidal_with_dc_offset,damped_oscillation) are the biggest single feature batch since the0.4.0cut, yet the only references in[Unreleased]are inline mentions in the Pass-6Fixedblock (theNetworkFrequencyOrderWarningmotivation paragraph) and theTests (Backlog)entries for individual bugs. Add an explicitAdded (Transient subsystem — implemented 2026-05-2x)block enumerating:TransientStudy.set_source_waveform,.set_observation,.solve(solver="fft" | "state_space");ResultTransient.epr_per_bus_t,branch_current_per_branch_t; thewaveformsmodule surface; the two new plotting helpersplot_epr_transientandplot_branch_current_transient; and the new docs pagesdocs/transient.md,docs/api/transient.md. - Five new example notebooks are uncommitted with no
Added (Examples — …)block under[Unreleased].docs/examples/minimal.ipynb,docs/examples/mv_ring.ipynb,docs/examples/mv_ring_transient.ipynb,docs/examples/pandapower_import.ipynb,docs/examples/fault_sweep.ipynbwere added to replace the three removedcired.ipynb/low_voltage.ipynb/simple.ipynbentries (theDmarkers ingit status). Theexamples/index.mdandmkdocs.ymlExamples:nav have been updated in lock-step. Add a CHANGELOG bullet so the rename rationale (the old examples were domain-specific; the new five are progressively complex teaching examples) survives the release. plot_epr_transient,plot_branch_current_transientand thewaveformsre-export at top level are insrc/groundinsight/__init__.py__all__but the Pass-7Fixed-block listsset_active_fault,clear_pathfinder_cache,get_pathfinder_cache_size,set_pathfinder_cache_size,NetworkFrequencyOrderWarningonly. The eight new Pass-8 public symbols miss the changelog. Add them under a dedicatedAdded (Plotting / Waveforms public surface — …)block before the release cut.docs/transient.mddoes not appear in anyAdded (Docs — …)/Docs (…)block. The page is the principal new user-facing document for the 0.5.0 release (FFT vs. state-space derivation, per-solver assumption table, side-by-side example), but the CHANGELOG has no entry that points to it. Add aDocs (Transient page — …)bullet.docs/api/transient.mdis untracked but the API-reference index does not mention it. The mkdocstrings dump exists andmkdocs.ymlhas theTransient simulations: api/transient.mdnav entry, butdocs/api/index.mdOverview prose still lists only the eight pre-Pass-7 modules. Add the entry under the API Reference table.gi.cross_reponamespace +docs/cross-repo.mdstill open. The Pass-7 implementation report explicitly deferred these pending ADR-0013 ingroundfield. ADR-0013 itself is still unwritten (docs/adr/ingroundfieldlists 0001–0012 only), so the seventh-pass Roadmap bullet stays open on thegroundinsightside. Track the dependency explicitly in the Pass-8 Roadmap section below.- Pass-4/5/6
Fixed (Backlog — …)sub-sections still duplicate the entries listed in the implementation blocks above. The audit-implementation reports document that the duplication is intentional until the release is cut, but the three sub-sections together carry > 1100 lines of backlog text that is structurally identical to the implementation blocks above. Once the release-cut PR merges, theFixed (Backlog — …)sub-sections for implemented passes must be deleted in a separatechore(changelog): purge resolved backlogcommit. Eighth-pass forcing function: open that PR alongside the release tag. mkdocs build --strictCI hook still missing — eighth pass in a row. Same finding as ingroundfieldandgroundmeas. The newdocs/transient.md,docs/api/transient.mdand the five new example notebooks would all be flagged by--strictimmediately if a stale link survived the rename (the three removed example notebooks are still referenced innotebooks/02_topologies.ipynbmarkdown cells — flagged by the seventh pass as a Pass-7Tests (Backlog)item, still open).db_sessionretire path withDeprecationWarningstill unscheduled. The Pass-7 implementation run chose Option 1 (central setter_set_session) and explicitly deferred Option 2 (retire alias withDeprecationWarning, drop in 0.6.0). Add anAdded (Deprecations — …)block once a date for the retire is pinned, so downstream code has a documented migration window.
Docs (Backlog — eighth 2026-05-25 review pass)¶
docs/quickstart.mddoes not show the new transient workflow. The page ends at the stationarygi.run_fault(...)call; an „Optional: transient analysis" appendix that drivesTransientStudy.set_source_waveform(...).solve("fft")would close the gap. Mirrors the pattern thegroundfieldquickstart uses for optional features.docs/concepts.mddoes not enumerate the new publicNetworktopology methods.Network.invalidate_paths(),Network.set_active_fault(name, keep_results=True), the re-entrantoutage_contextand theactiveflag onBus/Branchare referenced piecewise in API pages but not folded into the Concepts narrative. Add a „Network state-management contract" subsection.docs/api/index.mdstill does not enumerate the newtransient/ plotting / waveforms surface. Pass-7 Docs backlog re-emphasised the index drift; Pass-8 adds the transient subsystem and the two new plotting helpers.- README Roadmap drift (seventh-pass finding re-flagged).
The README's „Roadmap" section still references the bridge
to
groundfieldas „Near term — target 0.4.0"; the bridge shipped in 0.4.0 and the next milestone (transient + pandapower importer + outage-context) ships in 0.5.0. Update the bullet before the release cut.
Tests (Backlog — eighth 2026-05-25 review pass)¶
- No
mkdocs build --stricttest (seventh-pass finding re-flagged — eighth pass elevates again). - No regression test that the three removed example
notebooks (
cired.ipynb,low_voltage.ipynb,simple.ipynb) are not referenced in any docs page or notebook markdown cell. A one-line grep overdocs/andnotebooks/per removed filename catches the most common stale-link regression. - No regression test against
graphify-out/manifest.jsonstaleness. Same pattern as thegroundfield-side Pass-8 proposal: read the „Built from commit" line fromgraphify-out/GRAPH_REPORT.md, compare againstgit rev-parse HEAD. - No version-parity test for the new top-level symbols.
tests/test_audit_pass6_fixes.pyandtests/test_audit_pass7_fixes.pypin per-fix symbols; an umbrellatest_public_api_surface.pythat asserts every symbol listed in__all__is importable and notNonecatches incomplete merges and stale__all__entries at PR time. - No test that
simulation/waveforms.pydefaults survive JSON round-trip viaResultTransient.model_dump_json(). The transient subsystem is the only Pass-5/6/7 feature without a Pydantic round-trip test in the implementation blocks above.
Fixed (Backlog — pending implementation)¶
The following bugs 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. New entries from the second 2026-05-10 review pass (waveforms / inverse_rho_f / mkdocs) are appended at the end of the list below the original twelve items.
network_operations.create_fault(name, ..., active=True, network=None)raisesAttributeError: 'NoneType' object has no attribute 'set_active_fault'instead of a cleanValueError. Guard the activation block underif active:and require a network.io.pandapower_import._bus_in_serviceis reused for line rows (pandapower_import.py:343). Functionally correct because pandapower exposesin_serviceon both tables, but the misleading name will trip up future contributors. Rename to a neutral_in_servicehelper or split per element class.simulation/transient.py:_solve_state_spaceevaluates source impedance atnetwork.frequencies[0], which silently drops the reactive part ofZ_srcwhen DC (f=0) is the first entry of the frequency list. Pick the first non-zero frequency or refuse the decomposition with a clear message.- Stale
TransientStudy._TransientStudy__mutual_for_outputattribute survives across successivesolve()calls. Pass the mutual-coupling tuple as a local argument or reset it at the top of every solve. simulation/outage.OutageStudyResult.compare_buses/compare_branchesdivide by_ref_valuewithout guarding against zero, producing silentinf/NaNindelta_pct_vs_<ref>when the reference scenario carries an EPR or branch current of zero (typical at the source bus or on an open branch). Wrap withpl.when(_ref_value == 0).then(None).otherwise(...).electrical_network.solve_networkdoes not assignself.u_vectors[freq]on solver failure but its caller still dereferencesu_vectors[freq][idx]further down, raisingKeyError. Either store a zero vector on failure, or abort the whole solve.models.core_models.Sourcevoltage-mode validator accepts emptyvoltage/source_impedancedicts because thev_keys != z_keyscheck passes for two empty sets. Require both dicts to be non-empty whensource_type='voltage'.models.core_models.Fault.activeis acomputed_fieldrendered through the underscore-prefixed_set_active. Pydanticmodel_validatedoes not restore_activeon JSON / SQLite round-trip, sonetwork.active_faultis preserved butfault._activedefaults toFalse. Either use a regularactivefield or set_activeinNetwork.model_post_initbased onactive_fault.simulation/transient: FFT and state-space grids differ by one sample for the same(t_end, dt)(FFT forces even N; state space usesint(round(t_end/dt)) + 1). Side-by-side comparison notebooks exhibit a one-sample shift. Align both grid constructions or document precisely.simulation/transient._solve_state_spacesilently drops the mutual contribution of branches without anL_self_formula(they are not part ofbranch_inductiveand therefore never entermutual_branches). Emit alogger.warninglisting the skipped mutual contributions so the user notices.__init__.save_*_to_db/load_*_from_dbclose their session with a baredb_session.close()— notry/finally. A failed underlying_save_networkleaks the session in the scoped-session registry. Wrap each helper intry/finally.utils.validations.validate_impedance_formula_valueonly registersR, X, M, N, jas known symbols. A user typingZ = R_E + j*omega*L_E(typical EVU naming) gets the additional symbols accepted as free symbols and only fails much later insidecompute_impedance. Validate against the canonical{rho, f, l, rho1, rho2, h}set instead.electrical_network.compute_branch_currentsand_calculate_rmsuse asqrt(sum(|X_k|^2))definition, while the documentation calls the result an "RMS" value. For sinusoidal phasors the conventional definition issqrt(sum(|X_k|^2 / 2)). Either correct the formula or rename the field and clarify the docstring.
Additional findings from the second 2026-05-10 review pass:
simulation/waveforms.damped_oscillationdoes not validatedecay_tau > 0.decay_tau == 0divides by zero innp.exp(-tau_local / decay_tau);decay_tau < 0produces a silently exponentially growing waveform that the user will almost never want. RaiseValueErrorfordecay_tau <= 0at factory time, before the user spends solver wall-clock on a meaningless input.simulation/waveforms.sinusoidal_with_dc_offsetacceptsdc_decay_tau == 0silently — same division-by-zero risk as above. Requiredc_decay_tau > 0when a finite decay is intended; document thatdc_decay_tau is Nonemeans "no decay".simulation/waveforms.{step, sinusoidal_with_dc_offset, damped_oscillation}do not validatet_off > t_on. A user passingt_off < t_on(typically a unit confusion: ms vs s) gets a permanently-zero waveform and a baffling "transient is flat" plot. RaiseValueErrorwhent_off is not None and t_off <= t_on.simulation/waveforms.sinusoidal_with_dc_offsetraises no warning whenfrequency_hz <= 0. Negative frequency is silently equivalent to flipping the phase;frequency_hz == 0collapses to a constant offset that masks user confusion with the DC term. Reject non-positive frequencies at factory time.analysis.inverse_rho_f.evaluate_max_epr_under_kusesnetwork.paths.clear()+update(paths_backup)in thefinallyblock instead of an atomic replacement (network.paths = paths_backup). If the user readsnetwork.pathsfrom another thread during the sweep the observable state transiently holds an empty dict. Either document the non-atomic restore or atomically reassign.analysis.inverse_rho_f.evaluate_max_epr_under_kdoes not catch failures duringnetwork.add_fault— if the secondadd_faultraises (e.g. becauseFault.scalingsviolates a validator),temp_faults_createdstill lists faults that have already been added to the network and they are removed on the way out, but the firstadd_faultcall has already mutatednetwork.faultsso the rollback works only because of the ordering. Wrap theadd_faultloop in its owntry/exceptthat rolls back the partial list explicitly.simulation/outage.outage_contextkeeps a shallow copy ofnetwork.pathsviadict(network.paths).run_faultinside thewithblock mutates the samePathobjects in place (e.g. populatingcurrent_share). After thewithblock exits the restorednetwork.pathsstill references those mutatedPathobjects. Document the contract (the rollback restores names / topology, not internal solver state) or deep-copy.mkdocs.yml:90still referenceshttps://polyfill.io/...— the same security-relevant CDN entry thatgroundmeasalready removed (the domain was sold and the CDN later served malicious JavaScript). MathJax 3 does not require a polyfill for modern browsers. Drop the line; mirror the comment block used ingroundmeas/mkdocs.yml.simulation/transient.TransientStudy._solve_state_spacesilently skips grounding branches that have neitherR_selfnorL_self(logged as a warning at the end) but does not fail the solve. For a user who forgot to populate any RLC formula on theBranchType, all branches end up inskippedand the resulting state-space model is the bus-only parallel-RLC of the buses — likely not what was intended. Either fail loudly when every grounding branch was skipped or surface the count in the returnedResultTransient.metadata.__init__.set_log_levelonly inspectsself.handlersfor an existingStreamHandler, but does not consider inherited handlers from the root logger. Notebooks that configurelogging.basicConfig(level="INFO")end up with two output streams: one through the root logger, one through the package handler. Either setpropagate=Falseon the package logger or document the interaction withbasicConfig.
Additional findings from the third 2026-05-12 review pass (focus:
io/pandapower_import, deep look at the new transient and inverse-rho-f modules; the previous two passes had only spot-checked the importer):
io.pandapower_import._classify_busesreadsvn_kvwithfloat(row.get("vn_kv", 0.0) or 0.0)(pandapower_import.py:157). A bus row with a missing orNonevn_kvis silently treated as0 kVand re-classified as "voltage_level_mismatch" — no warning reaches the user. Emit alogger.warning(...)listing rows with unparsablevn_kvso the importer reports unexpected data instead of silently dropping it.io.pandapower_import.from_pandapowercalls_bus_in_service(row)on the line rows as well (pandapower_import.py:343). Functionally fine because pandapower exposesin_serviceon both tables, but the misleading name hides the intent. Already on the backlog from pass 1 — the third pass confirms the fix has not been merged.io.pandapower_import._classify_linesdoes not reject self-loops (from_idx == to_idx). A pandapower line with both endpoints on the same bus is mapped to aBranch(from_bus=X, to_bus=X)which later tripsNetwork.add_branchvalidators only in some code paths. Add an explicit skip withreason="self_loop".io.pandapower_import.from_pandapowerdoes not surface "no buses matched the requested voltage level". A user who passes the wrongvoltage_level_kVgets an emptyNetworkand no log entry beyond theImported pandapower net … 0 busesinfo line. Promote the zero-bus / zero-branch case to alogger.warningso the user notices.io.pandapower_import.preview_pandapower_importhas noinclude_trafosparameter butfrom_pandapowerdoes. The two helpers are advertised as "preview → commit"; the asymmetry means a user previewing a 110 / 20 kV net cannot see which trafos would be picked up. Add the same parameter to the preview function and surface the reserved status (NotImplementedError) consistently.analysis.inverse_rho_f.evaluate_max_epr_under_kcollects the bus' RMS EPR vianext(rb for rb in network.results[fname].buses if rb.name == b)(inverse_rho_f.py:194). If the matching bus is absent from the result frame (e.g. because it was pruned by a customrun_fault_kwargs={"buses": [...]}filter) the generator raisesStopIterationrather than a clear error. Wrap with aKeyError/LookupError.analysis.inverse_rho_f.find_max_rho_f_scalingreportsc_max = c_lo_initas the fall-back when the lower bracket is admissible but bisection then never updates it. The result dict reports"epr_rms_per_bus_at_c_max"taken from the last loop iterate, not from the iterate that actually producedc_max. Theeprs_lo_snapshotshadow variable closes most of the gap, but the branch that exits withiterations < max_iterdue to the tolerance test still re-uses the most recent admissible snapshot rather than the snapshot atc_max. Add a test that prints the EPR breakdown atc_maxand asserts the maximum equalsmax_epr_rms_at_c_max.simulation/transient.TransientStudyconstructor does not check thatfault_nameactually exists innetwork.faults. A typo surfaces only later as a bafflingKeyErrorfrom_build_state_space. Validate early in__init__.simulation/transient.pyandsimulation/outage.pyimportpolarsunconditionally at module top-level but thepyproject.tomlextras already declare apolarscore dep, so this is fine — but the future "lite" install path (e.g. for embedded use) is blocked by the import. Document the dependency in the module docstrings.network_operations.run_faultrebuilds the path cache viadefine_paths(network)every call whennetwork.pathsis empty, but the auto-parallel-coefficient pre-solve does not invalidate the cache on topology change (activetoggles a bus butnetwork.pathssurvives because the testif not network.pathsis "non-empty → reuse"). The outage helper already works around this byclear()-ing the cache; document the invariant or add aNetwork.invalidate_paths()method.
Additional findings from the fourth 2026-05-12 review pass (focus: re-export surface, set_log_level idempotency, notebook versus mkdocs nav coverage, repeated polyfill.io flag):
mkdocs.yml:90still referenceshttps://polyfill.io/v3/polyfill.min.js?features=es6— verified again on the second 2026-05-12 audit run; this is now the fourth consecutive pass that flags it. The line still has to be removed (or pinned to a trusted mirror) before the next public release.groundinsight.__all__does not includeset_log_leveleven though the helper is defined in__init__.py:120and the Changed-backlog entry below explicitly lists it as missing. Pass-1 noted the gap; pass 4 confirms the listing still ships in0.4.0. Type-checkers usingfrom groundinsight import *silently miss the helper.__init__.set_log_leveladds a freshStreamHandlerevery time the level changes, but keeps the previous handler when the user merely toggles back to the same level. Repeated calls from notebooks ("set_log_level("DEBUG")→ solve →set_log_level ("INFO")→ solve →set_log_level("DEBUG")") therefore leave the package logger with multipleStreamHandlers, each writing the same record once. Either deduplicate handlers on every call or calllogger.removeHandler(...)before re-attaching.__init__.start_dbsession/close_dbsessionuse a module-globaldb_session, mirroring the same anti-pattern flagged ingroundmeas.core.db._engine. A secondstart_dbsession("other.db")silently replaces the first, leaking the previously-boundscoped_sessionand any open transactions. Either guard the re-attach with an explicitclose_dbsession()precondition or dispose the old engine before swapping.pathfinder.PathFinderconstructs a fresh adjacency graph on everyfind_pathscall. For the multi-fault sweep loop inanalysis.inverse_rho_f.evaluate_max_epr_under_kthe same graph is rebuilt N_buses times even though the underlying topology (moduloactiveflips) is invariant within a single call. Cache the graph keyed on(frozenset(active_buses), frozenset(active_branches))to cut the inner-loop overhead.models/core_models.Fault.scalingsaccepts aDict[float, ComplexNumber]but every value is silently coerced tocomplexduringcompute_impedance. A typo such asscalings={50: 1.0+0j, 50.0: 0.5+0j}(int vs float key) leaves both entries in the dict but the lookup usesnetwork.frequencies[i](always float), so the int-keyed value is dead. Validate the keys againstnetwork.frequenciesat validator time.docs/api/io.mddoes not cross-linknotebooks/06_pandapower_ import.ipynb, even though the notebook is the only end-to-end example of the importer family. Thefrom_pandapowerrendered docstring shows an inline four-line snippet; for AP 1 readers the full notebook is the natural follow-up.notebooks/04_persistence.ipynbis not part of the doc-site navigation (mkdocs.ymlexamples nav only carriessimple.ipynb,cired.ipynb,low_voltage.ipynb). The notebook is the canonical demonstration ofsave_*_to_db/load_*_from_db; without it the persistence API has no walkthrough on the docs site.
Additional findings from the fifth 2026-05-13 review pass (focus: residual side-effects of the Pass-4 implementation block,
__all__integrity, pathfinder cache scope, frequency-list validation, doc-site notebook coverage that was only partially closed in Pass 4):
Network.invalidate_paths()clears the entire module-levelpathfinder._GRAPH_CACHEvia the unconditionalclear_pathfinder_cache()call (core_models.py:954). Two notebooks that each build a separateNetworkand callinvalidate_paths()on one of them blow away the cached graph for the other network too. The fix is to scope the clear to entries whose first key component isid(self), or to use a per-Network sub-dict. Code-quality regression that bites the first time a user runs the dashboard sweep against two networks in the same process.pathfinder._GRAPH_CACHEkeys onid(network)— Python re-uses ids once an object is garbage-collected. Two short-livedNetworkinstances with different topologies but the same recycled id and the samefrozenset(active_*)would silently share the cached graph. Either replace the integer id with aWeakKeyDictionary(keys must hash; this means upgradingNetworktofrozen=True-ish behaviour or wrapping it) or include(network.name, len(buses), len(branches))in the key as a defence-in-depth check.groundinsight.__all__advertisesdb_sessionat line 76 but no module-leveldb_sessionattribute is defined — the real handle issession(__init__.py:217).from groundinsight import db_sessiontherefore raisesImportErrorwhile the docstring promises a public surface. Either dropdb_sessionfrom__all__or aliasdb_session = sessionat module scope (matching the local variable name used in every persistence helper body).close_dbsessionassumes thesession is not Noneguard impliesengine is not None. If a previousstart_dbsession(force=True)was interrupted betweenengine.dispose()and re-assigningsession, the function raisesAttributeErroronengine.dispose()(line 300). Null each global independently and only log "no session" when all three (session, engine, SessionLocal) are unset.models/core_models.Network.frequencies: List[float]is unvalidated. A duplicate frequency ([50.0, 50.0]) silently doubles the work insolve_networkand doubles the amplitude of the corresponding spectral bin in the FFT transient. Reject duplicates / non-finite / non-positive frequencies in afield_validator(..., mode="after").models/core_models.Bus.active/Branch.activeare plain Pydantic fields. Flipping them in place leavesnetwork.pathspopulated with stale topology; the user must remember to callnetwork.invalidate_paths()(and the new Pass-4 helper exists exactly for this case). Either promote the field to a property + setter that emits aUserWarning("path cache is stale; call invalidate_paths()")on flip, or hook the validator to callinvalidate_paths()automatically.Network.set_active_fault(fault_name)silently deletesself.results[fault_name](core_models.py:940). The docstring mentions the clear, but a notebook user expecting a re-runnable scenario loses the previous solve. Expose akeep_results: bool = Falsekwarg and document the trade-off.docs/api/core_models.mddoes not documentNetwork.invalidate_paths()even though the Pass-4 implementation block lists it. The mkdocstrings auto-render picks it up only if the page enumerates the symbol; today the reader has to grep the source.mkdocs.yml"Notebooks" nav added 3 entries (04, 06, 14) but the remaining 10 notebooks (01, 02, 03, 05, 07, 08, 09, 10, 11, 12, 13) are still invisible to the docs site. Pass-2/3/4 finding only partially resolved; finishing the migration requires adding the rest under the samemkdocs-jupytercontract.docs/api/index.mdstill does not list the persistence helpers (start_dbsession,close_dbsession,save_*_to_db,load_*_from_db, JSON helpers). Pass-1 doc gap closed only in__all__; the rendered API index lags.scripts/release.pydoes not move the[Unreleased]block into a dated section on bump (Pass-2 finding). The maintainer has to do the cut by hand; port the helper fromgroundmeas/scripts/_changelog.py.
Additional findings from the sixth 2026-05-14 review pass (focus: secondary side-effects of the Pass-5 implementation block, doc-builder convention drift, mkdocs-jupyter nav reality vs. claim, cache memory bookkeeping, model/docstring drift introduced by the pass-5 frequency validator):
pathfinder._GRAPH_CACHE/_FIND_PATHS_CACHEare unbounded module-level dicts. Pass 5 fixed the false-hit problem by adding a structural fingerprint to the cache key; what remained open is that nothing ever evicts entries. A long-running notebook that callsevaluate_max_epr_under_kover a hundred-scenario outage sweep or that compares two networks across a hundred active-subset variations will accumulate one cache entry per visited topology and never reclaim memory. Add anOrderedDict- based LRU wrapper with a defaultmaxsize=256and surface the cap asgi.set_pathfinder_cache_size(n)so dashboard authors can tune it.outage_contextdoes not invalidate the module-level pathfinder cache on exit (simulation/outage.py:309). The cache key includesfrozenset(active_buses)andfrozenset(active_branches), so correctness is preserved (a new active subset gets a new slot), but the entries built during the scenario sweep are never reclaimed when the context exits. Thefinallybranch restoresnetwork.pathsbut leaves the module-level cache populated with stale active-subset slots. Coupled with the unbounded-growth finding above this is the fastest way to leak memory on a multi-scenario outage study. Fix: callclear_pathfinder_cache(network)from thefinallybranch after restoringnetwork.pathsso the cache footprint matches the user-visible state ofnetwork.Network._validate_frequenciesdocstring drift. The docstring (models/core_models.py:1022) reads "Reject empty / duplicate / non-finite / non-positive frequency lists", but the implementation acceptsf == 0(DC). The DC inclusion is the intended behaviour (FFT transient solvers use the zero-frequency bin); rephrase to "negative or non-finite" so users do not believe DC is rejected. Internal doc-vs-code drift; no behaviour change required.Network._validate_frequenciesdoes not warn on non-monotonic input.groundfield.solver.engine.Engineintroduced theEngineFrequencyOrderWarningfamily in Pass-5 to surface silently-sorted inputs; the symmetricNetwork.frequenciesvalidator silently accepts a descending or shuffled list. This is unsafe for downstream FFT bin assignment insimulation/transient.TransientStudy: the impedance dict uses the order fromNetwork.frequenciesto map to the FFT spectrum, so[100.0, 50.0]produces a transient with the spectral bins reversed. Add an analogousNetworkFrequencyOrderWarning(UserWarning)and emit it when the list is not strictly increasing.mkdocs.ymlmkdocstrings.python.options.docstring_styleis set togoogle, but the 2026-05-14 docstring sweep migrated the public API to numpy style (mkdocs.yml:76,audit-readme-docs-2026-05-14.md). Numpy-style Parameters / Returns sections render as plain text under the Google parser, dropping the per-field type annotations. The rendered docs site therefore degraded silently after the doc sweep even though the source-level docstrings improved. Switch todocstring_style: numpyand spot-checkdocs/api/network_operations.md/docs/api/pathfinder.md/docs/api/core_models.mdin a localmkdocs serve.mkdocs.ymlNotebooks nav claim vs. reality. Pass-5 Fixed-block declares "lists all 15 notebooks (was: 3 of the 14 Pass-4 notebooks)", but the on-diskmkdocs.ymlExamples nav only lists five curated.ipynbfiles (minimal,mv_ring,mv_ring_transient,pandapower_import,fault_sweep) and does not listnotebooks/14_audit_pass4_fixes.ipynb/notebooks/15_audit_pass5_fixes.ipynb. The 15-notebook claim did not materialise onmain; either revert the Pass-5 Fixed-block bullet or land the nav edit. Doc-vs-doc drift must be resolved before the next release tag.db_session/sessionsynonyms in__all__. Pass 5 madedb_sessiona real module-level alias forsessionand added both names to__all__. Two synonyms in the public surface multiply the maintenance cost: any future helper that re-binds one but not the other — e.g.start_dbsession(force=True)— produces a divergentfrom groundinsight import db_sessionvs.from groundinsight import sessionpair. Either keepdb_sessionas a one-way alias documented as "legacy spelling ofsession" or removesessionfrom__all__and the doc-site to retire the alternative spelling.outage_contextis not re-entrant safe. Nestedwith outage_context(net, OutageA):/ inside itwith outage_context(net, OutageB):saves outer-baseline flagsTruein the outer block; the inner block then captures the modified state (active=Falsefor OutageA targets) as its own baseline. On inner-exit the inner targets are restored correctly, but on outer-exit the outer baseline overrides every change the inner made to targets that are not part of OutageA. Either document the "outage contexts are not nestable" contract or deep-copynetwork.buses[...].activeper saved entry on every nesting level. Bug surface:simulation/outage.py:309-380.Network.invalidate_paths()mutatesself.pathsin place via.clear(). Callers that snapshotsaved = dict(network.paths)before the call will observe the snapshot lose its entries becausedict(...)produces a shallow copy whose values are shared withnetwork.pathsonly if the values were themselves mutable; safer is to rebindself.paths = {}(atomic rebind) instead of.clear(). Same pattern as the Pass-4 atomic-rebind fix ininverse_rho_f.evaluate_max_epr_under_k.set_active_fault(fault_name, keep_results=True)is not re-exported vianetwork_operations. The newkeep_resultskeyword is reachable only via the bound method onNetworkinstances; the top-levelgi.set_active_fault(network, fault, keep_results=...)factory wrapper that the README and quickstart use does not propagate it. Add the keyword to the factory signature so the Pass-5 ergonomics improvement is reachable from the public-API surface.
Additional findings from the seventh 2026-05-18 review pass (focus: status check four days after Pass-6 implementation block was deferred; release-cut overdue, doc-builder convention drift still open, validator docstring/code drift confirmed unfixed):
__version__still pinned at0.4.0— release cut overdue.pyproject.toml,src/groundinsight/__init__.py:__version__and the audit-readme docstring sweep all still report0.4.0as of 2026-05-18, even though the[Unreleased]block carries the Pass-4 + Pass-5 implementation blocks (transient state-space solver, capacitance support, pandapower importer hardening,Network.invalidate_paths,db_sessionalias,Networkfrequencies validator,keep_results=kwarg). The audit-readme-docs-2026-05-14 pass already flaggeddocs/api/io.md"New in 0.5" markers as a forward-looking inconsistency; the seventh pass confirms the bump is now four passes overdue. Cut0.5.0before the next audit so the backlog math returns to a normal cadence and the docs stop advertising features that the canonical__version__does not yet ship.mkdocs.yml docstring_style: googleconfirmed still set on 2026-05-18. Pass 6 flagged it (audit-readme-docs-2026-05-14migrated docstrings to numpy but the rendering parser stayed on Google); seventh-pass cross-check on the on-disk file shows the line is unchanged. Numpy-styleParameters/Returnssections continue to render as plain text under the Google parser. A single-linedocstring_style: numpyflip closes the regression; without it, every release of0.5.xwill ship docs with the per-field type annotations dropped.mkdocs.ymlExamples nav still lists 5 of 15 notebooks. On-disknotebooks/carries 15 ipynb files (01..13 plus14_audit_pass4_fixes.ipynband15_audit_pass5_fixes.ipynb); the rendered nav exposes onlyexamples/minimal.ipynb,mv_ring.ipynb,mv_ring_transient.ipynb,pandapower_import.ipynbandfault_sweep.ipynb. Thedocs/examples/directory only contains the five entries that the nav lists, so the remaining 10 notebooks are not even on the docs include path. Either bulk-promotenotebooks/01..13intodocs/examples/and wire them under aNotebooks:nav section, or document the convention "research notebooks live innotebooks/, curated examples indocs/examples/" indocs/index.md._validate_frequenciesdoc-vs-code drift still open. The field-validator docstring onNetwork.frequenciesreads "Reject empty / duplicate / non-finite / non-positive frequency lists" but the implementation explicitly acceptsf == 0(DC) — the comparison isif f < 0rather thanif f <= 0. Pass 6 flagged the drift; on-disk file as of 2026-05-18 still carries both the misleading docstring and the DC-friendly implementation. Rephrase the docstring to "Reject empty / duplicate / non-finite / negative frequency lists;f = 0(DC) is permitted for the FFT transient solver" — no behaviour change required._validate_frequenciesstill does not warn on non-monotonic input. Symmetric to thegroundfield.solver.engine.EnginePass-5 fix (EngineFrequencyOrderWarning): theNetwork.frequenciesvalidator on this side accepts a descending / shuffled list silently. The FFT transient solver uses the order ofNetwork.frequenciesto map to the FFT bins, so[100.0, 50.0]produces a transient with the spectral bins reversed. Add aNetworkFrequencyOrderWarning(UserWarning)category (and the symmetricNetwork.with_frequencies(*freqs, preserve_order=True)constructor) and emit on non-strict-monotone input. Seventh-pass re-emphasis on the Pass-6 cross-repo convention finding.Network.invalidate_paths()still mutates via.clear(). The body readsself.paths.clear(); an external snapshotsaved = dict(network.paths)captured before the call loses its values because the dict-keyedPathinstances were the same objects thatself.pathsreferred to (the snapshot is shallow). Rebindself.paths = {}instead so the snapshot survives — mirrors the Pass-4 atomic-rebind fix ininverse_rho_f.evaluate_max_epr_under_k. Six passes flagged the pattern; seven passes confirm it remains unfixed.pathfinder._GRAPH_CACHE/_FIND_PATHS_CACHEremain unbounded. Pass 5 added the structural fingerprint and Pass 6 proposed an LRU cap (gi.set_pathfinder_cache_size(n), default 256). The on-disk pathfinder still uses two plain module-level dicts with noOrderedDict/WeakKeyDictionary/functools.lru_cachewrapper. A 100-scenario outage sweep therefore still leaks one cache entry per scenario; pass 7 re-elevates this as the highest-leverage memory-bookkeeping finding on the package.outage_contextstill does not clear the pathfinder cache on exit. The Pass-6 Fixed-backlog entry recommended aclear_pathfinder_cache(network)call in thefinallybranch; the on-disksimulation/outage.pydoes not yet make the call. Combined with the unbounded-cache finding above, this is the fastest path to a multi-hundred-megabyte resident memory footprint on a long dashboard session over a 10-scenario sweep.set_active_fault(keep_results=)still not on the top-level factory. The bound method onNetworkaccepts the kwarg (Pass-5 implementation), butgroundinsight.network_operations.set_active_fault(network, fault_name)— the documented user-facing entry point — does not propagate it. Either add the kwarg to the factory wrapper or document the discrepancy indocs/quickstart.md. Seventh-pass re-emphasis.db_sessionsynonym carries cross-API maintenance debt. Pass 5 addeddb_session = sessionand listed both names in__all__; the seventh-pass review ofstart_dbsession/close_dbsessionconfirms both helpers rebind both names (lines 305, 348). The contract is currently kept by hand — any future helper that rebindssessionwithoutdb_session(e.g. a plannedswap_dbsession()context manager) will silently drift. Either retire the alias (and add aDeprecationWarningfor one release cycle) or introduce a module-level_SESSION_LOCK+ a_set_session(new)helper that the persistence factories must call, so the alias is pinned by a single source of truth.
Changed (Backlog — pending implementation)¶
__init__.__all__is incomplete. The persistence helpers (save_network_to_db,load_network_from_db,save_bustype_to_db,load_bustypes_from_db,save_branchtype_to_db,load_branchtypes_from_db,save_network_to_json,load_network_from_json,start_dbsession,close_dbsession) andset_log_levelare exposed at the top level via attribute access but missing from__all__. Add them so type-checkers andfrom groundinsight import *see them.network_operations.create_sourcedocstring does not point atcreate_voltage_source. Add aSee alsoparagraph so users reading only the current-source factory discover the Thevenin alternative.- Replace
lru_cache(maxsize=512)onutils.impedance_calculator._compile_formulawith an unboundedfunctools.cache(or aWeakValueDictionary) so long Pareto sweeps do not silently miss the cache when the 513th unique formula appears. The current limit is silent. network_operations._warning_parallel_coeffcienttypo — rename to_warning_parallel_coefficient. Internal but appears in stack traces.plotting.plot_bus_voltagessets alegend(title="Frequency")even in RMS-mode and re-runsxticks(rotation=45, ha="right")outside the mode branch, clobbering the carefully positioned ticks of the multi-frequency case. Move the duplicatextickscall into the RMS-mode branch and drop the misleading legend title in RMS mode.electrical_networkfalls back toscaling = 1for missing per-frequency entries inFault.scalings. Log a warning so users notice that they are getting an unintentional full-strength injection at a harmonic.
Docs (Backlog — pending implementation)¶
The package now exposes a long list of features (transient solver,
Thevenin source, pandapower importer, outage studies,
inverse_rho_f, RLC formula fields) that are documented only via
auto-generated docstrings. The doc site needs a structured pass
before the next release so the user-facing surface matches the
public API.
docs/api/transient.mdexists, but the example usesfrom groundinsight import waveformswhereas the canonical form isgi.waveforms.step(...). Pick one form and use it consistently across the page, theTransientStudydocstring, and the notebook10_transient_fft.ipynb.docs/api/transient.mdstill lacks a "Parameterising the lumped RLC formulas" section showing how to authorR_self_formula,L_self_formula,C_self_formula,R_mutual_formula,M_mutual_formulaon aBranchTypeand the matchingR_formula,L_formula,C_formulaon aBusType. The values are referenced in prose but never demonstrated.docs/api/outage.mdexample usesinclude_base_case=True— the actual API parameter isinclude_base(outage.py:349). Fix the example.docs/api/analysis.mduses reST:func:cross-references that mkdocstrings does not render. Convert to mkdocs link syntax.- No
docs/api/persistence.md—gi.start_dbsession,gi.close_dbsession, thesave_*_to_db/load_*_from_dbfamily and the JSON helpers are all top-level user-facing functions but only documented indirectly via the autodoc ofgroundinsight.database.crud. Add a dedicated page. docs/api/index.mddoes not listgi.set_log_level. The helper has shipped in 0.3.2 and is in__all__-via-attribute but appears nowhere in the docs.network_operations.parallel_coefficientdocstring claims defaultNone— the actual default is1.0. Fix.simulation/transient.pymodule docstring still claims "Mutual coupling is not yet evaluated by the FFT solver" without the corresponding "but is evaluated by the state-space solver" addendum. Refresh the "Design choices recorded" block.- README does not mention the new RLC formula fields, the state-space solver, or the inverse rho-f catalog selection helper. Bring it level with the [Unreleased] block before the next release tag.
Additional doc gaps from the second 2026-05-10 review pass:
- No
docs/api/waveforms.md. The three factory functionsgi.waveforms.step,gi.waveforms.sinusoidal_with_dc_offset,gi.waveforms.damped_oscillationare top-level user-facing — the user must call one to feedTransientStudy— but mkdocstrings only auto-documents them indirectly via thesimulation.transientpage. Add a dedicated page that lists each waveform with its mathematical form, parameter table and a one-cell rendering example. - No
docs/api/analysis.mdsection on the rho-f model inversion. The page exists but only documentsfind_max_rho_scaling(single-parameter inversion).evaluate_max_epr_under_k,find_max_rho_f_scalingandselect_rho_f_from_catalogare all part ofgi.analysis's public surface (analysis/__init__.pyre-exports them) and re-exported on the package top level, but the docs page does not even mention them by name. docs/api/outage.mdCompare semantics. The page documentscompare_buses(against=...)but never explains how the long format works (metriccolumn,value,delta_vs_<ref>,delta_pct_vs_<ref>), nor what happens when the reference scenario is missing from the study. A two-row sample frame would close the gap.- Notebook coverage in the docs site.
mkdocs.ymlregisters only three legacy notebooks underexamples/(simple.ipynb,cired.ipynb,low_voltage.ipynb). The 13 notebooks undernotebooks/01…13are not part of the doc site, so the user readsdocs/transient.mdand is left to hunt the matchingnotebooks/10_transient_fft.ipynbin the repo. Either include the full notebook set under a newNotebooks:nav section or link to them by URL fromdocs/transient.md. docs/concepts.mdanddocs/transient.mddo not cross-link to theanalysisrho-f helpers. A reader of "concepts" reaches the rho-f model formula but no pointer to the helpers that invert it. Add a short cross-reference.docs/installation.mddoes not list thepandapoweroptional extra in the same way the README does. The previous audit added an "Optional extras" section toinstallation.md; verify after the next docs rebuild that the syntax matchespyproject.toml.
Additional doc gaps from the third 2026-05-12 review pass:
- No
docs/api/pandapower_import.mdsub-page, even though the importer is now the recommended path for AP 1 case studies on real distribution-network data. The singledocs/api/io.mdpage lumps every importer together and rendering one big page makes it hard to find thevoltage_level_kVargument or the skip-reason vocabulary (voltage_level_mismatch,endpoint_off_target_voltage_level,endpoint_bus_missing). Split the page or add a top-level table of skip reasons. docs/api/transient.mddoes not document thebranch-current sign conventionchange shipped in[Unreleased]— users still on the old (inverted) convention will see a silent sign flip after upgrade. Add aMigrationcallout that mirrors the CHANGELOG entry.docs/api/transient.mdCarson-mutual section missing. The module shipsR_mutual/M_mutualsubstitution and a one-time warning for the voltage-source case, but neither the trigger condition nor the substitution itself is documented in the user-facing page. The reader has to open the source to learn why the mutual contribution silently disappears with a voltage source.docs/concepts.mddoes not mention the0.4.0activeflag on Bus / Branch and the resulting interaction with the pathfinder. New users importing from pandapower within_service=Falserows see the flag in the dataclass but no conceptual write-up.mkdocs.yml:90still referenceshttps://polyfill.io/v3/polyfill.min.js?features=es6— the pass-1 and pass-2 audits already noted the security-relevant CDN; verifying again on 2026-05-12 confirms the line is still there. Three-character delete; please ship before the next release.- Notebook front-matter consistency. Notebooks 01..13 do not
carry a uniform
Title/Released inblock, so the docs-site notebook index (currently three legacy notebooks) cannot pick them up automatically. Adopt themkdocs-jupytermetadata contract (# title:,# tags:markdown cell at the top).
Additional doc gaps from the fifth 2026-05-13 review pass:
docs/api/pathfinder.mddoes not document the new module caches (_GRAPH_CACHE,_FIND_PATHS_CACHE, the helperclear_pathfinder_cache()). After the Pass-4 implementation shipped the caches as a public surface, the failure mode (stale cache after in-place mutation) needs an admonition.docs/api/core_models.mddoes not enumerateNetwork.invalidate_pathseven though the helper is the intended escape hatch for the pathfinder cache. mkdocstrings picks the symbol up only when the page references it explicitly; add a one-line list entry.docs/concepts.mddoes not mention the "active subset" topology rules. Pass-4 implementation madeBus.active=FalseandBranch.active=Falsedrop the row from the adjacency graph; the concepts page still describes the topology as the full bus+branch tree. Update the relevant paragraph.docs/api/transient.mddoes not document theBranchType.R_mutual_formula/M_mutual_formulasubstitution warning (Pass-3 doc gap). The module ships the substitution and the one-time warning for the voltage-source case; the reader has to open the source to learn why the mutual contribution disappears with a voltage source.docs/installation.mddoes not list thepandapoweroptional extra in the same way the README does (Pass-2 doc gap, verified open). Sync the prose withpyproject.toml.README.mdRoadmap section still references items (Network.res_touch_voltages,assess_touch_voltage, PEN-awareBranchType) that have moved to the Roadmap section of this changelog. The README is the front door — it should either re-state the roadmap inline or link here.- No "Stable import surface" section in any docs page.
The package exposes nearly all helpers at top level
(
gi.create_network(...)) but the__init__.py__all__list now mixes a real symbol with thedb_sessiontypo (Fixed-backlog fifth-pass entry above). A short page that spells out the stable surface — and explicitly calls out which sub-modules (gi.models,gi.simulation.transient,gi.io.pandapower_import) are not part of the stable contract — would prevent the public-surface drift.
Additional doc gaps from the sixth 2026-05-14 review pass:
docs/api/pathfinder.mddoes not document the cache memory contract introduced by the Pass-5 structural fingerprint. A user-facing note that "the cache is unbounded by default; callclear_pathfinder_cache()between scenario sweeps or rely onNetwork.invalidate_paths()for per-network eviction" would give the reader a clear footgun guardrail. Coupled with the Fixed-backlog sixth-pass cache-cap proposal above.docs/api/core_models.mddoes not list the newkeep_results: bool = Falseparameter onNetwork.set_active_fault. Pass 5 added the keyword and an Args block on the method docstring, but the curated Markdown page has its own table that omits it. The documentation rendered on the website therefore still describes the old single-arg form.docs/quickstart.mduses the examplenet.set_active_fault("f1")without the newkeep_results=keyword. A short note "Passkeep_results=Trueif you want to replot the cached solve without recomputing" would surface the new ergonomics without complicating the quickstart.docs/concepts.md"Topology" section still claims "every bus is part of the admittance matrix"; the Pass-4active=Falsesemantics dropped this years ago. Drift on the conceptual page that new users hit first.docs/api/io.mdmentions "New in 0.5" features even though__version__is0.4.0(the audit-readme-docs- 2026-05-14 pass flagged this as a forward-looking marker). Either pin the docs to the actual installable version (and mark unreleased helpers with an admonition box) or push the 0.5 bump topyproject.toml,__init__.py.__version__andCITATION.cff. Sixth-pass: still open.docs/transient.mddoes not yet explain the monotonic-frequencies expectation. The transient solver FFT-bin assignment relies on the order ofNetwork.frequencies; without a docs warning the Fixed-backlog sixth-pass "non-monotonic frequencies" finding will keep reappearing in support questions.mkdocs.ymlExamples nav vs. notebooks contract. Either thenotebooks/14_audit_pass4_fixes.ipynbandnotebooks/15_audit_pass5_fixes.ipynbshould be promoted intodocs/examples/and listed under the Examples nav (single source of truth: the docs site), or they should be documented as "research artifacts, not site content" indocs/index.md. The current state — they exist on disk, the Pass-5 changelog claims they are on the nav, the nav doesn't list them — is the worst of both worlds.
Additional doc gaps from the seventh 2026-05-18 review pass:
mkdocs.yml docstring_stylestillgoogle(verified on 2026-05-18). Seventh-pass re-confirmation of the sixth-pass finding. A two-character edit (google→numpy) restores the type annotations on every mkdocstrings-rendered page. Until then the rendered docs silently degrade after each docstring sweep.docs/api/io.mdstill carries "New in 0.5" markers. Two**New in 0.5.**admonitions (around lines 91 and 94 — thevn_kv_unparsableandself_loopskip reasons) appear in the rendered docs even though the installable package reports__version__ = 0.4.0. Either move the admonitions into a!!! warning "Forward-looking documentation"block or push the0.5.0bump throughpyproject.toml,__init__.__version__andCITATION.cffbefore the next release tag.docs/api/core_models.mdstill does not document thekeep_results: bool = Falseparameter onNetwork.set_active_fault. Pass 5 added the keyword, Pass 6 flagged the doc gap, Pass 7 confirms the rendered Markdown page (lines around theset_active_faulttable) still omits it.docs/quickstart.mddoes not mentionkeep_results=. Pass-5 closed the kwarg on the bound method; the quickstart still demonstrates the single-arg form. Two-line edit.- No
docs/api/persistence.mdpage. Pass-1 finding, seventh-pass confirmation: the persistence helpers (start_dbsession,close_dbsession,save_*_to_db,load_*_from_db, JSON helpers) are top-level user-facing API but are only documented indirectly viadatabase.md. docs/concepts.md"Topology" paragraph still claims "every bus is part of the admittance matrix" despite the Pass-4 active-subset semantics. Sixth-pass flagged this; seventh-pass confirms the on-disk page still carries the pre-active-flag wording.- README "Quickstart" snippet not yet aligned with the
0.4.0features. Sixth-pass entry: README is the front door, and it currently lists Roadmap items that have already shipped. Seventh-pass re-elevation given the release cut is now overdue.
Tests (Backlog — pending implementation)¶
- No dedicated unit test for
gi.create_voltage_sourcevalidation paths (mismatched frequency keys, zerosource_impedance, missing fields, voltage-mode source withvaluesset). Exercise eachValueErrorbranch inSource._validate_source_mode. - No tests for
gi.set_log_level—tests/test_logging.pytests the package logger but does not exercise idempotence and level changes. tests/test_transient.pyonly uses single-frequency networks. Add a[50, 250]network state-space test cross-checking against the FFT solver to catch the multi-tone state-space mutual-coupling regression.tests/test_pandapower_import.pyhas no coverage for silently-skipped trafos and switches. Addtest_from_pandapower_silently_skips_trafos_and_switches.tests/test_outage.pydoes not assert dtype or finite-value semantics ondelta_pct_vs_baseand would not catch the_ref_value == 0inf/NaNbug listed above. Add a regression test where the reference bus sits on the source bus.- No JSON / SQLite round-trip test for the new RLC formula
fields (
R_formula,L_formula,C_formula,R_self_formula,L_self_formula,C_self_formula,R_mutual_formula,M_mutual_formula). Confirm the fields survive both serialisation paths. tests/test_inverse_rho_f.pynever exercises theevaluate_max_epr_under_k"best-effort restore active_fault" path. Add a test that pre-setsactive_fault="some_other"and asserts it is restored after the helper returns.
Additional test gaps from the third 2026-05-12 review pass:
- No regression test for the
vn_kv == Nonepandapower bus row (thefloat(row.get("vn_kv", 0.0) or 0.0)silent-zero bug listed above). Build a tiny syntheticpp.bustable withvn_kv=Noneand assert that the row is either skipped with a warning or raised on. - No regression test for
from_pandapowerreturning an empty Network. Pass avoltage_level_kVthat matches nothing and assert that the result reports zero buses plus a warning entry. - No regression test for
find_max_rho_f_scalingreporting the EPR breakdown at the actually-admissiblec_max. Verify thatmax(epr_rms_per_bus_at_c_max.values()) == max_epr_rms_at_c_maxafter the bisection terminates ontol_rel.
Additional test gaps from the fourth 2026-05-12 review pass:
- No test that asserts
set_log_levelis handler-idempotent. Call the helper three times with alternating levels and assertlen([h for h in logger.handlers if isinstance(h, logging.StreamHandler)]) == 1afterwards. Locks in the bug reported in the Fixed-backlog fourth pass above. - No test that asserts
start_dbsessionrejects a second call without a priorclose_dbsession. A two-step pytest fixture reusing the module-globaldb_sessionwould silently leak the previous engine; pin the contract once it is hardened. - No test for the
Fault.scalingsint-vs-float key coercion. Construct a fault withscalings={50: 1.0+0j}(int key) on a network withfrequencies=[50.0]and assert that either the fault is rejected at validation time or that the scaling is applied to the matching float-keyed frequency. - No test that
pathfinder.PathFinderis reused across calls on the same topology. Once the caching helper lands, lock in the cache-hit count via amocker.spyover the graph constructor. - No round-trip test for the docs-site notebook nav. Add a
tests/test_docs.py::test_examples_nav_covers_notebooksthat loadsmkdocs.yml, parses theExamplesnav, and asserts everynotebooks/*.ipynbis either listed or explicitly excluded — prevents future drift between notebook tree and rendered docs.
Additional test gaps from the fifth 2026-05-13 review pass:
- No test that
Network.invalidate_paths()is scoped to the current network. Build two networks, populate the_GRAPH_CACHEfor both viaPathFinder(net_a)/PathFinder(net_b), then callnet_a.invalidate_paths()and assert thatnet_b's graph is still in the cache. - No test that
pathfinder._GRAPH_CACHEdoes not falsely hit afteridrecycling. Build a network, drop it, garbage- collect, build another network at the (likely-recycled) id with a different topology, and assert that the new network's PathFinder builds a fresh graph rather than picking up the stale one. - No regression test for
from groundinsight import db_sessionImportError. Either removedb_sessionfrom__all__(and add a one-liner asserting the symbol is not importable) or alias it (and add the symmetric "imports the same scoped session" test). - No regression test for duplicate / negative / nan
frequencies on
Network.frequencies. Build a network withfrequencies=[50.0, 50.0]and assert the field validator rejects it (once it lands). Today the constructor accepts it silently and downstream solvers double-count. - No regression test that
set_active_faultclears the previousnetwork.results[fault_name]. The docstring promises the side effect; pin it so it cannot regress after the plannedkeep_resultsflag. - No regression test for
close_dbsessionwith a partially initialised state (session is Nonebutengine is not None). Construct the corrupted state manually and assert the helper either logs a warning or tears the engine down, rather than raisingAttributeError.
Additional test gaps from the sixth 2026-05-14 review pass:
- No regression test for monotonic
Network.frequencies. Build aNetwork(frequencies=[100.0, 50.0]), run aTransientStudyand assert that either aNetworkFrequencyOrderWarningfires or the FFT bin mapping uses the user-supplied order — whichever behaviour is locked in by the sixth-pass implementation. - No regression test for unbounded pathfinder cache. Run
Pa thFinderover 1000 distinct active subsets in a loop and assertlen(_GRAPH_CACHE) <= maxsizeafter the sixth-pass cache cap lands; today the same loop fills the dict indefinitely. - No regression test for
outage_contextcache eviction. Run a 10-scenario outage sweep and assertlen(pathfinder._GRAPH_CACHE)is back to the pre-sweep size after the contexts exit. Today the cache grows by 10 per sweep. - No regression test for nested
outage_context. Stack twooutage_contextblocks over disjoint and overlapping scenarios and assert the post-exit state matches the pre-entry state for every bus / branch. Today nested contexts leak the inner's restore through the outer's baseline. - No regression test for
Network.invalidate_paths()and external snapshot integrity. Capturesaved = dict(network.paths)before the call, then ensuresavedsurvives the invalidation call. Pins the atomic-rebind behaviour proposed in the sixth-pass Fixed-backlog. - No
mkdocs build --stricttest intests/test_docs.py/ CI. The Pass-3 / 4 / 5 doc backlog entries all assume the docs build is silent; without a strict-mode test the Pass-5 mkdocstrings-style mismatch (Google parser on numpy-style docstrings) goes undetected for at least one release cycle. Add asubprocess-basedmkdocs build --strict --site-dir tests/_site_buildinvocation guarded by amkdocsextra. - No regression test for
gi.set_active_faulttop-level factorykeep_results=plumbing. Once the keyword lands on the factory wrapper, pin it with a one-line test that callsgi.set_active_fault(net, "F1", keep_results=True)and assertsnet.results["F1"]is preserved.
Additional test gaps from the seventh 2026-05-18 review pass:
- No regression test for
pathfinder._GRAPH_CACHEeviction onoutage_contextexit. Sixth-pass entry; seventh-pass re-emphasis. Once thefinallybranch gains theclear_pathfinder_cache(network)call, pin the contract with a 5-scenario sweep that assertslen(_GRAPH_CACHE)is back to the pre-context size on exit. - No regression test for the
_validate_frequenciesdoc- vs-code drift. Construct aNetwork(frequencies=[0.0])and assert the validator accepts it (DC), and a separateNetwork(frequencies=[-1.0])and assert it rejects it. Pins the docstring rewording proposed in the seventh-pass Fixed-backlog. - No regression test that
set_log_levelis handler- idempotent across_groundinsight_console_handlercycles. Pass-4 added the sentinel-attribute strategy; pin it with a three-call alternating-level test that assertssum(1 for h in pkg_logger.handlers if isinstance(h, logging.StreamHandler)) == 1. - No CI-level
mkdocs build --stricttest. Sixth-pass finding remains open. Seventh-pass re-elevation given thedocstring_styledrift would be caught on the very first build. - No regression test for the
pyproject.toml-vs-__version__parity. A two-linetest_release.py:: test_version_matches_pyprojectwould catch the "advertise 0.5 in docs while shipping 0.4" drift before the next release tag.
Part 4 — roadmap proposals as recorded per pass¶
Superseded by the consolidated Roadmap section at the end of
CHANGELOG.md, which de-duplicates these blocks and drops the items that
have since shipped. Kept verbatim for provenance: this is what each pass
actually proposed, on the date it proposed it.
Roadmap (additions from the 2026-07-19 review pass)¶
Confirmed with the maintainer; sequenced after the pass-8 bug-fixes.
- Conductor thermal-limit check (equipment-integrity assessment, step 1).
Complements the planned EN 50522 touch-/step-voltage (person-safety) helper
with a conductor-integrity check. Add cross-section
Aand material constantk(Cu / Al / steel, per IEC 60949 / EN 60865-1) toBranchType/BusType, pluscheck_conductor_limits(network, fault, t_k)returning, per branch/bus, the thermally-equivalent short-time currentI_th, the admissiblek·A/√t_k, and a pass/fail flag. Mechanical (electrodynamic,F ∝ i_p², needs conductor geometry) is a deliberate second step. - ~~Short-circuit characteristic quantities on sources/faults (
i_p,I_th).~~ Done (F3) — see the F2/F3 entry above. Prerequisite for the thermal check. ExtendSource/Faultwith IEC 60909 quantities (I_k'',kappaorR/X, clearing timeT_k). Superposition rule: superpose the linear AC RMS branch/shield currents as today, then apply the (non-linear) peak/thermal factors to the aggregate (i_p = kappa·√2·I_s,RMS,I_th = I_s,RMS·√(m+n)); the existing transient solver is the exact fall-back for mixed-R/Xcases. Do not superposei_p/I_thdirectly. - ~~Import IEC 60909 results from pandapower
calc_sc.~~ Done (F2) — see the F2/F3 entry above. Rather than re-implementing 60909, ingest pandapower's short-circuit result (I_k'',i_p,I_th,R/X) into groundinsight sources — docking onto the existingauto_phase_currentsintegration hook (the code already notes it as "the intended integration point for … pandapower single-phase short-circuit results"). Chosen over a native 60909 core to reuse the ecosystem. Implementation note: pandapower does not publishi_p/I_thforfault="1ph", so those two are derived here rather than ingested; onlyI_k''and the sequence impedances come from pandapower. - Web GUI remains long-term; the reserved
api/package and the planned Plotly backend are the natural first building blocks (REST + Plotly before a thin front-end).
Roadmap (additions from the eighth 2026-05-25 review pass)¶
- Cut
0.5.0immediately. Five audit passes have flagged the missing release; the eighth pass treats this as the single most overdue maintenance item. gi.show_versions()documentation polish. Pass-7 shipped the helper; add adocs/api/database.md„Cross-repo version helper" sub-section so the helper is reachable from the rendered docs (the existing reference intests/test_audit_pass7_fixes.pyis the only public call site).gi.cross_reponamespace +docs/cross-repo.md— blocked on ADR-0013 ingroundfield(still unwritten). Track the cross-repo dependency in thegroundinsightRoadmap.gi.audit_apply(report_path)helper. Take the cross-repo proposal seriously: a single CLI entry-point that reads a CHANGELOG-formatted audit report and writes the bullets into the matching[Unreleased] → Fixed (Backlog)sub-section. Avoids the manual copy-and-paste drift that has produced eight repetitive audit reports.
Roadmap (additions from the 2026-05-10 audit)¶
The following items are not yet scheduled and should be triaged
into [Unreleased] once design questions are settled.
- Touch- / step-voltage assessment helpers —
Network.res_touch_voltages()and a thinassess_touch_voltage( t_clearing_ms, standard='EN50522'|'IEEE80')returning the admissible limit and a pass/fail flag per bus. The single most useful safety-engineering deliverable on top of the current steady-state solver and the natural follow-on to theResultTouchVoltageroadmap item. - PEN-conductor-aware
BranchType— currentBranchTypedistinguishes onlygrounding_conductor: bool. For TN-Ortsnetze the PEN sits in parallel with the cable shield and the soil; modelling it explicitly (pen_impedance_formula) gives a cleaner reduction-factor split for low-voltage networks. Specifically relevant to AP 1 of the dissertation. - Time-series
Source.from_waveform(waveform, frequencies)— convenience factory that does the FFT once for a Thevenin source, instead of forcing the user to assemble a per-frequencyvoltagedict by hand. Closes the FFT-transient input loop. Network.verify_steady_state_match(transient_result)— promote the manual cross-check used intest_state_space_matches_fft_on_lti_networkto a public diagnostic so a user can validate any new transient setup against the per-frequency phasor solve.- Parallel per-frequency solve —
concurrent.futures.ThreadPoolExecutorover the frequency loop insolve_network. SciPysplureleases the GIL, so a near-linear speed-up is realistic for harmonic studies (10–30 frequencies). Low effort, high payoff.
Additional roadmap candidates from the second 2026-05-10 review pass:
gi.waveforms.from_array(t_samples, values)— convenience factory that wraps a user-supplied numerical array (measured fault-current trace, e.g. from a digital fault recorder) into theCallable[[np.ndarray], np.ndarray]contract vianp.interp. Closes the "BYO waveform" path without forcing the user to write the lambda.gi.assess_against_en50522(network, fault, t_clearing_ms)— companion to the planned touch-voltage assessment helper. Reads the prospective touch voltages from the steady-state solve, pulls the EN 50522 Table B.4 limit from thegroundfield.postprocess.safetycompanion (once the[groundfield]extra is installed), returns a per-bus pass/fail DataFrame. Closes the safety-engineering loop on thegroundinsightside and avoids re-implementing the limit curve.gi.cut_unreleased_to_release()inscripts/release.py— thegroundmeasrelease script already moves the[Unreleased]block on bump (seescripts/_changelog.py).groundinsight/scripts/release.pydoes the version-string bump but not the changelog-section move; the maintainer has to do it by hand. Port the helper across repos.- Cut a
0.5.0release. The[Unreleased]block has grown by transient state-space solver, capacitance support, voltage-source state-space path, Carson-style mutual coupling, branch shunt pi-section lumping, four new notebooks. Cutting a release letsgroundfield/io/groundinsight.pypin a groundinsight version that has the rho-f catalog helper, and unsticks the cross-repo bridge work.
Additional roadmap candidates from the third 2026-05-12 review pass:
Network.invalidate_paths()public helper — formalise the contract thatnetwork.pathsmust be re-derived after anyactiveflip or topology mutation. Today the outage helper and the rho-f inverter both clear-and-update the dict by hand; exposing a method centralises the rule and lets future helpers (e.g.apply_outage(net, outage)outside of awithblock) share it.gi.from_pandapower_multi_voltage(net, defaults_map)— multi-voltage-level importer that takes aDict[float, ImportDefaults]and produces oneNetworkper voltage level (or one combined Network with trafo branches once theinclude_trafospath lands). Closes the gap between the single-voltage-level importer and the AP 1 real-network case studies that span 110 / 20 / 0.4 kV.gi.TransientStudy.from_steady_state(network, fault_name)— convenience factory that pre-populates the transient study from the most recentrun_faultresult on the same network, copying the fault scalings and source observation set. Cuts notebook boilerplate roughly in half for the Phase 4 examples.gi.SoilModel(two_layer)+ symbolic recognition — the long-promised two-layerSoilModelbridge togroundfieldis still in the roadmap "Near term" block but has not gained any implementation since 0.4.0; it has become the longest-standing open item on the cross-package interface. Prioritise it now that the rho-f catalog work is complete.
Additional roadmap candidates from the fifth 2026-05-13 review pass:
gi.PathfinderConfig(cache_scope="per_network" | "global" | "none")— make the cache-scope decision explicit instead of silently sharing the module-level dict across notebooks. Closes the Fixed-backlog fifth-pass concern aboutNetwork.invalidate_pathsover-clearing.gi.Network.frequenciesas atuple[float, ...]— switching the field type to a tuple (Pydantic accepts it) plus afield_validatorthat rejects duplicates, NaN, ±inf and non-positive values gives stricter contracts than the currentList[float]and also makes the network hashable for the pathfinder cache key (closes theid(network)collision risk).gi.diagnose(network)— one-call health check that reports stalenetwork.paths, duplicate frequencies, missing impedance formulas, untyped branches and the inverse-rho-f bus mismatch. Useful before eachrun_faultin long notebooks.scripts/release.pycross-port — copygroundmeas/scripts/_changelog.py(the stdlib-only[Unreleased]mover) intogroundinsight/scripts/and wire thereleasecommand to call it. Closes the Pass-2 roadmap item that has now slipped four passes.gi.connectors.dashboard_state— explicit serialisable state object so a future Streamlit dashboard can resume a notebook session. Mirrors thegroundmeasdashboard feedback loop and keeps the door open for a planned shared dashboard in the cross-repo toolchain.
Additional roadmap candidates from the sixth 2026-05-14 review pass:
gi.set_pathfinder_cache_size(n)— user-tunable LRU cap on the module-level pathfinder caches. Default 256, settable toNonefor the historic unbounded behaviour. Replaces the current "rely onclear_pathfinder_cache()" guidance with a predictable memory budget. Tied to the sixth-pass unbounded-cache Fixed-backlog entry.NetworkFrequencyOrderWarning(UserWarning)plus a symmetricNetwork.with_frequencies(*freqs, preserve_order=True)constructor — mirror the Pass-5groundfield.solver.engine.EngineFrequencyOrderWarningpattern for the network-side validator. Closes the sixth-pass FFT-bin-order finding.gi.docs.assert_api_pages_exist— small test-only helper that walks__all__and asserts every public symbol has at least one mkdocstrings:::directive somewhere underdocs/api/. Companion to the proposedmkdocs build --stricttest. Mirrors the plannedgf.docs.assert_api_pages_existingroundfield.gi.show_versions()— return a structured dict of the installed runtime versions forgroundinsight,numpy,scipy,sympy,pydantic,polars,sqlalchemyand optionallypandapower. Mirrors the plannedgf.show_versionsingroundfield; would be reused by the cross-packagegm-cli doctorproposal ingroundmeas. The three packages together form one toolchain, so the diagnostic helper should be cross-repo-consistent.gi.audit_apply(report_path)— read a Markdown audit bullet list (audit-report-changelogs-YYYY-MM-DD.md) and insert the bullets verbatim into the appropriate[Unreleased]Backlog sub-section. Six passes in a row of hand-merging suggests this should be a one-line command.
Additional roadmap candidates from the seventh 2026-05-18 review pass:
- Cut
0.5.0release. Pass 7 reiterates: the[Unreleased]block has now spanned four audit passes worth of implemented code (transient state-space solver, capacitance support, voltage-source state-space path, pandapower importer hardening,Network.invalidate_paths, frequencies validator,keep_results=); cutting0.5.0unsticks the cross-repo bridge work ingroundfield/io/groundinsight.py. Higher priority than any further bug-finding pass. - ADR for cross-repo
show_versionsconvention. Tie to the proposedADR-0013ingroundfield; the return shape must be identical acrossgi.show_versions(),gf.show_versions()and the plannedgm-cli doctorso a single dashboard / CI pipeline can consume all three. Pin the keys (package,python,numpy,scipy,sympy,pydantic,polars,sqlalchemy, optionallypandapower) and the_metablock before any of the three packages implements the helper. gi.cross_reponamespace — convenience re-exports of the ADR-bound cross-repo conventions (gi.cross_repo.show_versions,gi.cross_repo.audit_apply,gi.cross_repo.docs_assert). Stops the cross-cutting helpers from polluting the top-level namespace and keeps the discoverability hint ingi.cross_repo.*for future AP1 reviewers.docs/cross-repo.md— single page that lists the three-package toolchain (groundfieldPDE/field,groundinsightreduced network,groundmeasmeasurement store) and their data-flow contracts (rho-ffit handoff,multilayer_soil_modelbridge, plannedMeasurement → ImpedanceTableexporter). Currently spread across three CLAUDE.md files; a docs-site page is the forcing function for the contract pinning.