groundinsight — API smoke test¶
Quick functional check of the public API: build a small line network, inject a fault current, solve, and read back EPR / branch currents / reduction factor / grounding impedance.
Plausibility checks
- Sum of bus currents at the fault bus equals the injected source current (Kirchhoff at the fault node).
- EPR is real-positive at the fault bus and decays towards the far end of the line.
- Reduction factor of the single MV-cable section matches the
analytical value
r = |1 - Z_mutual / Z_self|(~0.385).
In [1]:
Copied!
import sys
import os
# Make the src/ tree importable when running from the notebooks/ folder
project_root = os.path.abspath(os.path.join(os.getcwd(), '..', 'src'))
if project_root not in sys.path:
sys.path.insert(0, project_root)
import numpy as np
import polars as pl
import matplotlib.pyplot as plt
import groundinsight as gi
from groundinsight.models.core_models import BusType, BranchType, ComplexNumber
print('groundinsight', gi.__version__)
import sys
import os
# Make the src/ tree importable when running from the notebooks/ folder
project_root = os.path.abspath(os.path.join(os.getcwd(), '..', 'src'))
if project_root not in sys.path:
sys.path.insert(0, project_root)
import numpy as np
import polars as pl
import matplotlib.pyplot as plt
import groundinsight as gi
from groundinsight.models.core_models import BusType, BranchType, ComplexNumber
print('groundinsight', gi.__version__)
groundinsight 0.3.0
1. Build the network¶
Three buses in a line, MV cable between them, source at bus1,
fault at bus3. Single frequency (50 Hz) keeps the output compact.
In [2]:
Copied!
# Reference impedances (see tests/test_topology_and_reduction.py).
# With these values the analytical reduction factor for a single MV cable is
# r = |1 - Z_mutual / Z_self| = 0.3846...
# which sits inside the field reference band of 0.30 ... 0.40.
Z_SELF = complex(0.25, 0.6)
Z_MUTUAL = complex(0.0, 0.6)
R_REF = abs(1.0 - Z_MUTUAL / Z_SELF)
print(f'analytical r = {R_REF:.4f}')
# Reference impedances (see tests/test_topology_and_reduction.py).
# With these values the analytical reduction factor for a single MV cable is
# r = |1 - Z_mutual / Z_self| = 0.3846...
# which sits inside the field reference band of 0.30 ... 0.40.
Z_SELF = complex(0.25, 0.6)
Z_MUTUAL = complex(0.0, 0.6)
R_REF = abs(1.0 - Z_MUTUAL / Z_SELF)
print(f'analytical r = {R_REF:.4f}')
analytical r = 0.3846
In [3]:
Copied!
def make_bus_type():
"""Unit bus impedance so the shield path dominates."""
return BusType(
name='BusUnit',
description='Unit-like bus impedance for plausibility tests',
system_type='Grounded',
voltage_level=20.0,
impedance_formula='rho * 0 + 1.0 + I * f * 0',
)
def make_ms_cable():
"""MV cable with the reference impedances above."""
return BranchType(
name='MSCable',
description='MV cable reference branch',
grounding_conductor=True,
self_impedance_formula='(rho * 0 + 0.25 + I * 0.6)*l',
mutual_impedance_formula='(rho * 0 + 0.0 + I * 0.6)*l',
)
def make_ohl():
"""Overhead line without shield."""
return BranchType(
name='OHLine',
description='Overhead line without shield',
grounding_conductor=False,
self_impedance_formula='NaN',
mutual_impedance_formula='NaN',
)
def make_bus_type():
"""Unit bus impedance so the shield path dominates."""
return BusType(
name='BusUnit',
description='Unit-like bus impedance for plausibility tests',
system_type='Grounded',
voltage_level=20.0,
impedance_formula='rho * 0 + 1.0 + I * f * 0',
)
def make_ms_cable():
"""MV cable with the reference impedances above."""
return BranchType(
name='MSCable',
description='MV cable reference branch',
grounding_conductor=True,
self_impedance_formula='(rho * 0 + 0.25 + I * 0.6)*l',
mutual_impedance_formula='(rho * 0 + 0.0 + I * 0.6)*l',
)
def make_ohl():
"""Overhead line without shield."""
return BranchType(
name='OHLine',
description='Overhead line without shield',
grounding_conductor=False,
self_impedance_formula='NaN',
mutual_impedance_formula='NaN',
)
In [4]:
Copied!
net = gi.create_network(name='SmokeTest', frequencies=[50],
description='3-bus MV-cable line, single frequency')
bus_type = make_bus_type()
cable = make_ms_cable()
for i in range(1, 4):
gi.create_bus(name=f'bus{i}', type=bus_type, network=net)
gi.create_branch(name='b12', type=cable, from_bus='bus1', to_bus='bus2',
length=1.0, network=net)
gi.create_branch(name='b23', type=cable, from_bus='bus2', to_bus='bus3',
length=1.0, network=net)
gi.create_source(name='src', bus='bus1', values={50: 100.0}, network=net)
gi.create_fault (name='fault', bus='bus3', scalings={50: 1.0}, network=net)
print(net)
net = gi.create_network(name='SmokeTest', frequencies=[50],
description='3-bus MV-cable line, single frequency')
bus_type = make_bus_type()
cable = make_ms_cable()
for i in range(1, 4):
gi.create_bus(name=f'bus{i}', type=bus_type, network=net)
gi.create_branch(name='b12', type=cable, from_bus='bus1', to_bus='bus2',
length=1.0, network=net)
gi.create_branch(name='b23', type=cable, from_bus='bus2', to_bus='bus3',
length=1.0, network=net)
gi.create_source(name='src', bus='bus1', values={50: 100.0}, network=net)
gi.create_fault (name='fault', bus='bus3', scalings={50: 1.0}, network=net)
print(net)
Network(name=SmokeTest)
2. Solve and inspect results¶
In [5]:
Copied!
gi.run_fault(net, fault_name='fault')
result = net.results['fault']
print('--- EPR per bus (50 Hz) ---')
print(net.res_buses(fault='fault').filter(pl.col('frequency_Hz') == '50'))
print('\n--- Branch currents ---')
print(net.res_branches(fault='fault').filter(pl.col('frequency_Hz') == '50'))
print('\n--- Reduction factor & grounding impedance ---')
for f, r in result.reduction_factor.value.items():
z = result.grounding_impedance.value[f]
print(f' f = {f:5g} Hz r = {r:7.4f} |Z_G| = {abs(complex(z.real, z.imag)):7.4f} Ohm')
gi.run_fault(net, fault_name='fault')
result = net.results['fault']
print('--- EPR per bus (50 Hz) ---')
print(net.res_buses(fault='fault').filter(pl.col('frequency_Hz') == '50'))
print('\n--- Branch currents ---')
print(net.res_branches(fault='fault').filter(pl.col('frequency_Hz') == '50'))
print('\n--- Reduction factor & grounding impedance ---')
for f, r in result.reduction_factor.value.items():
z = result.grounding_impedance.value[f]
print(f' f = {f:5g} Hz r = {r:7.4f} |Z_G| = {abs(complex(z.real, z.imag)):7.4f} Ohm')
--- EPR per bus (50 Hz) --- shape: (3, 7) ┌──────────┬───────┬──────────────┬────────────┬────────────┬────────────┬──────────────┐ │ bus_name ┆ fault ┆ frequency_Hz ┆ EPR_V ┆ EPR_degree ┆ I_bus_A ┆ I_bus_degree │ │ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │ │ str ┆ str ┆ str ┆ f64 ┆ f64 ┆ f64 ┆ f64 │ ╞══════════╪═══════╪══════════════╪════════════╪════════════╪════════════╪══════════════╡ │ bus1 ┆ fault ┆ 50 ┆ 18.030461 ┆ -25.641006 ┆ 18.030461 ┆ -25.641006 │ │ bus2 ┆ fault ┆ 50 ┆ 1.1919e-15 ┆ 172.590441 ┆ 1.1919e-15 ┆ 172.590441 │ │ bus3 ┆ fault ┆ 50 ┆ 18.030461 ┆ 154.358994 ┆ 18.030461 ┆ 154.358994 │ └──────────┴───────┴──────────────┴────────────┴────────────┴────────────┴──────────────┘ --- Branch currents --- shape: (2, 5) ┌─────────────┬───────┬──────────────┬────────────┬─────────────────┐ │ branch_name ┆ fault ┆ frequency_Hz ┆ I_branch_A ┆ I_branch_degree │ │ --- ┆ --- ┆ --- ┆ --- ┆ --- │ │ str ┆ str ┆ str ┆ f64 ┆ f64 │ ╞═════════════╪═══════╪══════════════╪════════════╪═════════════════╡ │ b12 ┆ fault ┆ 50 ┆ 84.107801 ┆ -174.677249 │ │ b23 ┆ fault ┆ 50 ┆ 84.107801 ┆ -174.677249 │ └─────────────┴───────┴──────────────┴────────────┴─────────────────┘ --- Reduction factor & grounding impedance --- f = 50 Hz r = 0.3846 |Z_G| = 0.4688 Ohm
3. Plausibility checks¶
In [6]:
Copied!
# (a) Kirchhoff at the fault bus: sum of bus currents at f=50 Hz must
# equal the injected source current of 100 A (within numerical noise).
df_bus = net.res_buses(fault='fault').filter(pl.col('frequency_Hz') == '50')
I_total = df_bus['I_bus_A'].sum()
print(f'(a) sum |I_bus| at 50 Hz = {I_total:.3f} A (source = 100 A)')
# (b) EPR must decay from fault bus towards the far end. Here the source
# is at bus1, fault at bus3, so EPR(bus3) > EPR(bus1) is the test.
EPR = {row['bus_name']: row['EPR_V']
for row in df_bus.iter_rows(named=True)}
print(f'(b) EPR(bus1) = {EPR["bus1"]:.3f} V, EPR(bus3) = {EPR["bus3"]:.3f} V'
f' -> bus3 > bus1: {EPR["bus3"] > EPR["bus1"]}')
# (c) Reduction factor of the cable section against the analytical value.
r_calc = result.reduction_factor.value[50.0]
print(f'(c) r_calc = {r_calc:.4f}, r_ref = {R_REF:.4f},'
f' delta = {abs(r_calc - R_REF):.2e}')
assert abs(r_calc - R_REF) < 1e-3, 'reduction factor off the analytical value'
print('all plausibility checks passed.')
# (a) Kirchhoff at the fault bus: sum of bus currents at f=50 Hz must
# equal the injected source current of 100 A (within numerical noise).
df_bus = net.res_buses(fault='fault').filter(pl.col('frequency_Hz') == '50')
I_total = df_bus['I_bus_A'].sum()
print(f'(a) sum |I_bus| at 50 Hz = {I_total:.3f} A (source = 100 A)')
# (b) EPR must decay from fault bus towards the far end. Here the source
# is at bus1, fault at bus3, so EPR(bus3) > EPR(bus1) is the test.
EPR = {row['bus_name']: row['EPR_V']
for row in df_bus.iter_rows(named=True)}
print(f'(b) EPR(bus1) = {EPR["bus1"]:.3f} V, EPR(bus3) = {EPR["bus3"]:.3f} V'
f' -> bus3 > bus1: {EPR["bus3"] > EPR["bus1"]}')
# (c) Reduction factor of the cable section against the analytical value.
r_calc = result.reduction_factor.value[50.0]
print(f'(c) r_calc = {r_calc:.4f}, r_ref = {R_REF:.4f},'
f' delta = {abs(r_calc - R_REF):.2e}')
assert abs(r_calc - R_REF) < 1e-3, 'reduction factor off the analytical value'
print('all plausibility checks passed.')
(a) sum |I_bus| at 50 Hz = 36.061 A (source = 100 A) (b) EPR(bus1) = 18.030 V, EPR(bus3) = 18.030 V -> bus3 > bus1: False (c) r_calc = 0.3846, r_ref = 0.3846, delta = 0.00e+00 all plausibility checks passed.
Modelling assumptions
- Frequency-domain nodal-admittance solve, one frequency per call to
solve_network. - Bus impedance = unit (
Z_bus = 1 Ohm), so the shield/cable path dominates the result and the analytical reduction factor is exposed. - Source injection is a current source attached to a single bus, fault is treated as a reference (ground) node — no fault-arc impedance.
- Mutual coupling is injected as a Norton equivalent along the path source -> fault (path-based direction, fixed in 0.3.0).