Concepts¶
This page explains the physical and numerical model that groundinsight
implements. It is aimed at readers who want to understand why the code
returns the numbers it returns — for example to validate results against
measurements or analytical expressions.
Problem statement¶
Consider a power grid composed of substations (buses) and the cables and overhead lines (branches) that connect them. Each bus possesses a grounding grid tying it to remote earth; each branch carries a grounding conductor (cable shield, overhead-line earth wire) that is bonded to the grids of its two terminals. A single-phase-to-ground fault at one bus drives a fault current back towards the source. That current splits into two parallel paths:
- the local earth path through the grounding grid at the fault bus, and
- the metallic return path through the grounding conductor(s) of the connecting branches.
The split depends on the impedances of both paths and on the mutual coupling between the faulted phase and the grounding conductor that runs alongside it. The resulting EPR, the reduction factor and the grounding impedance are the quantities of interest.
Objects¶
groundinsight represents the grid with four Pydantic models:
Bus— a node. Carries a grounding impedance \(Z_{\text{B}}(\rho_E, f)\) that couples the bus to remote earth.Branch— an edge between two buses with a self impedance \(Z_{\text{self}}(\rho_E, f, l)\) (series impedance of the grounding conductor) and a mutual impedance \(Z_{\text{mutual}}(\rho_E, f, l)\) (coupling between faulted phase and grounding conductor).Source— a current source anchored at a bus. Holds a dictionary that maps each frequency to the injected phasor current.Fault— a marker at a bus. Holds a dictionary of frequency-dependent scaling factors that reduce the source current for each harmonic.
Bus and branch properties are derived from reusable types (BusType,
BranchType) whose impedances are parameterised by formula strings. The
formulas are parsed with SymPy, turned into lambdify callables and evaluated
for every network frequency.
Impedance formulas¶
Formula strings may contain the symbols
| Symbol | Meaning |
|---|---|
rho |
specific earth resistance \(\rho_E\) in \(\Omega\,\text{m}\) |
f |
frequency \(f\) in Hz |
l |
branch length \(l\) in km |
j |
imaginary unit (internally 1j) |
Expressions are evaluated symbolically, so any analytic expression supported
by SymPy is admissible. The literal string "nan" maps to an effectively
infinite impedance and can be used to model open ends (broken shields,
isolators etc.).
Nodal-admittance formulation¶
All computations take place per frequency \(f\) in the phasor domain.
groundinsight assembles an admittance matrix \(Y(f)\) of size \(N\times N\)
(where \(N\) is the number of buses):
Each entry can be switched off by setting grounding_conductor=False on the
corresponding branch, which is useful for modelling insulated shields.
The right-hand-side vector \(\underline{i}(f)\) holds the source injections scaled by the active fault's frequency scaling and — crucially — the mutual-coupling contributions. For every branch on a path from source to fault a Norton equivalent is added: the phase current \(I_{\text{phase}}\) driving the branch induces a shield current of magnitude \(I_{\text{mut}} = I_{\text{phase}}\,Z_{\text{mutual}}/Z_{\text{self}}\), injected as \(-I_{\text{mut}}\) at the from bus and \(+I_{\text{mut}}\) at the to bus (signs follow the direction source → fault).
The EPR vector is then
Numerically the system is solved with SciPy's sparse LU decomposition
(scipy.sparse.csc_matrix + splu) — this scales well to meshed
low-voltage networks with thousands of buses.
Path finding¶
Mutual-coupling injections require a direction. groundinsight derives that
direction by enumerating every simple path from each source bus to the active
fault bus via a depth-first search (PathFinder). Each path is stored as an
ordered list of Branch objects; its injection signs follow the traversal
order.
In ring or meshed topologies a single source–fault pair yields multiple
paths. By default every path carries the full source current. The optional
parallel_coefficient on a branch lets you pre-scale the current share of
individual parallel legs; if you set auto_parallel_coefficients=True on
run_fault, groundinsight solves a reduced phase-only network first and
uses its current distribution as the per-path scaling.
Derived quantities¶
Once \(\underline{u}(f)\) is known for every frequency, three result families are computed:
Earth potential rise (EPR)¶
The per-frequency bus voltages are stored as ComplexNumber entries on
ResultBus objects. RMS values across all frequencies are computed via
Branch currents¶
For every branch and frequency the current through the grounding conductor is
where the second term accounts for the Norton source representing the
mutual coupling (compute_branch_currents).
Reduction factor¶
The reduction factor \(r\) at the fault bus is defined as
groundinsight obtains the denominator by re-solving the same network with
all mutual-coupling Norton sources removed. For a single shielded line
directly between source and fault with identical impedances the expression
collapses to the familiar analytical form
The same closed form applies to a fully symmetric ring with the fault diametrically opposite the source — the Norton injections in the two ring halves are then perfectly anti-parallel and superpose to the same expression as the single-line case.
Frequency dependence of the reduction factor¶
For a shielded cable the impedances split into a resistive and an inductive part,
With full coupling between the faulted phase and the grounding conductor (the geometric ideal \(M = L\)) the closed form simplifies to
Two limits follow directly:
- \(f \to 0\): \(\omega L \to 0\), so \(Z_{\text{mutual}}/Z_{\text{self}} \to 0\) and \(r \to 1\). At DC the shield carries no induced current, the entire fault current flows through the local earth path and the reduction factor equals 1.
- \(f \to \infty\): the imaginary parts dominate, so \(Z_{\text{mutual}}/Z_{\text{self}} \to M/L = 1\) and \(r \to 0\). At high frequency the shield short-circuits the inductive coupling, almost the entire fault current returns metallically and the EPR collapses.
In a real cable the resistive part is small compared with \(\omega L\)
already at power frequency, which is why MV cables typically reach
\(r \approx 0.3 \ldots 0.4\) at 50 Hz and the reduction factor decays
quickly above one or two hundred Hz. This convergence is exercised
explicitly by
tests/test_topology_and_reduction.py::test_reduction_factor_sweep_*,
which sweeps a single MV cable section and a 20-bus symmetric ring
from 50 Hz to 5 kHz and asserts both the closed form above and the
monotonic decay towards zero.
Grounding impedance¶
The effective grounding impedance seen at the fault bus is
It is exposed per frequency and as RMS-scalar through
net.res_all_impedances().
Active flag and outage studies¶
Both Bus and Branch carry a boolean active field (default
True). The flag has a clean physical interpretation:
- An inactive
Busis removed from the nodal system entirely. Its row and column drop from \(Y(f)\) and the bus contributes nothing to the right-hand-side vector \(\underline{i}(f)\). - An inactive
Branchbehaves as an open circuit: no contribution to the admittance matrix, no Norton-equivalent injection from the mutual coupling, and a zero current in the result.
PathFinder skips inactive elements when enumerating source-to-fault
paths, so the per-path Norton bookkeeping stays consistent. The flag
is round-tripped through SQLite and JSON; existing payloads load
with active=True for every element, which keeps backwards
compatibility intact.
That makes maintenance scenarios, planned outages, broken shields
and N-1 contingencies expressible without rebuilding the network.
The groundinsight.simulation.outage sub-package wraps this into
two convenience entry points:
outage_context(network, outage)— context manager that flips the listed elements toactive=Falsefor the duration of awithblock and restores the previous state (including the cached path list) afterwards.run_outage_study(network, fault, scenarios=[...])— executes the base case plus one fault calculation perOutagescenario and returns anOutageStudyResultwhosecompare_buses(...)/compare_branches(...)accessors yield long-format Polars DataFrames with absolute and relative deltas against a chosen reference scenario.
Inverse rho analysis¶
The forward solve answers "given \(\rho_E\), what is the EPR?" The
sister inverse question — "how large can \(\rho_E\) become before
the EPR at the fault bus exceeds a touch-voltage limit
\(u_{\max}\)?" — is answered by
groundinsight.analysis.find_max_rho_scaling. It log-bisects a
uniform scaling factor \(c\) of Bus.specific_earth_resistance on a
user-selected bus set, re-evaluates each BusType.impedance_formula
through the existing SymPy machinery and triggers run_fault at
every trial value. The output is the largest \(c\) that still
satisfies \(|U_\text{EPR}(f)|_{\text{RMS}} \le u_{\max}\), the EPR at
that point, and the per-bus \(\rho_{\max} = c\,\rho_0\). The original
\(\rho\) values are restored via a finally block, so the network is
unchanged after the call. A frequency-dependent variant
(find_max_rho_f_scaling) extends the same idea to two-parameter
rho-f curves.
Summary of the calculation pipeline¶
run_fault(network, fault_name) executes the following steps (see
network_operations.run_fault and ElectricalNetwork for details):
set_active_fault— select the target fault.define_paths— if no paths are set yet, callPathFinderto enumerate them with a DFS.build_electrical_network— create anElectricalNetworkhelper that holds the numerical arrays.solve_network— build \(Y(f)\) and \(\underline{i}(f)\) and solve the linear system per frequency (sparse LU).compute_branch_currents— derive branch currents from \(\Delta u\) plus Norton contributions.compute_reduction_factors— re-solve without mutual Norton sources and take the EPR ratio at the fault bus.compute_grounding_impedance— evaluate \(Z_G\) per frequency and as RMS.
The persistent state of the calculation (sparse matrices, vectors) lives on
a private ElectricalNetwork attribute of the Network instance, so
re-solving after topology changes only requires a fresh run_fault call.