PyELSSA 0.1.0__tar.gz → 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyelssa-0.1.0 → pyelssa-0.1.1}/PKG-INFO +2 -1
- {pyelssa-0.1.0 → pyelssa-0.1.1}/pyproject.toml +2 -1
- {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/__init__.py +1 -1
- {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/configuration.py +1 -1
- pyelssa-0.1.1/src/PyELSSA/core/__init__.py +27 -0
- pyelssa-0.1.1/src/PyELSSA/core/busbar.py +118 -0
- pyelssa-0.1.1/src/PyELSSA/core/carson.py +371 -0
- pyelssa-0.1.1/src/PyELSSA/core/check_section.py +773 -0
- pyelssa-0.1.1/src/PyELSSA/core/current_recovery.py +271 -0
- pyelssa-0.1.1/src/PyELSSA/core/elssa.py +807 -0
- pyelssa-0.1.1/src/PyELSSA/core/elssa_compat.py +200 -0
- pyelssa-0.1.1/src/PyELSSA/core/elssa_mat.py +216 -0
- pyelssa-0.1.1/src/PyELSSA/core/elssa_output_sheet.py +206 -0
- pyelssa-0.1.1/src/PyELSSA/core/elssa_sheet.py +168 -0
- pyelssa-0.1.1/src/PyELSSA/core/feeder_transformer.py +106 -0
- pyelssa-0.1.1/src/PyELSSA/core/feeders.py +111 -0
- pyelssa-0.1.1/src/PyELSSA/core/fieldslice.py +241 -0
- pyelssa-0.1.1/src/PyELSSA/core/freq_dependent.py +82 -0
- pyelssa-0.1.1/src/PyELSSA/core/g_bord_lines.py +39 -0
- pyelssa-0.1.1/src/PyELSSA/core/harmonic.py +99 -0
- pyelssa-0.1.1/src/PyELSSA/core/harmonic_sweep.py +183 -0
- pyelssa-0.1.1/src/PyELSSA/core/induced.py +191 -0
- pyelssa-0.1.1/src/PyELSSA/core/magnetic_field.py +97 -0
- pyelssa-0.1.1/src/PyELSSA/core/minihdf5.py +330 -0
- pyelssa-0.1.1/src/PyELSSA/core/modal.py +67 -0
- pyelssa-0.1.1/src/PyELSSA/core/mt.py +430 -0
- pyelssa-0.1.1/src/PyELSSA/core/mtstudy.py +368 -0
- pyelssa-0.1.1/src/PyELSSA/core/network.py +148 -0
- pyelssa-0.1.1/src/PyELSSA/core/oslo.py +260 -0
- pyelssa-0.1.1/src/PyELSSA/core/overlay.py +168 -0
- pyelssa-0.1.1/src/PyELSSA/core/plotting.py +53 -0
- pyelssa-0.1.1/src/PyELSSA/core/qqtt.py +144 -0
- pyelssa-0.1.1/src/PyELSSA/core/sc.py +701 -0
- pyelssa-0.1.1/src/PyELSSA/core/study.py +223 -0
- pyelssa-0.1.1/src/PyELSSA/core/switching.py +195 -0
- pyelssa-0.1.1/src/PyELSSA/core/system.py +1176 -0
- pyelssa-0.1.1/src/PyELSSA/core/train_epr.py +103 -0
- pyelssa-0.1.1/src/PyELSSA/core/train_model.py +252 -0
- pyelssa-0.1.1/src/PyELSSA/core/ybus.py +763 -0
- {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/gui/gui_base_frame.py +1 -1
- {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/gui/gui_main_page.py +1 -1
- {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/gui/gui_start.py +1 -1
- {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/gui/gui_sub_frame.py +1 -1
- {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/licensing.py +1 -1
- {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/master.py +1 -1
- {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/release_update.py +1 -1
- {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/shared_contents.py +1 -1
- {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/shared_msazure_api.py +1 -1
- {pyelssa-0.1.0 → pyelssa-0.1.1}/LICENSE +0 -0
- {pyelssa-0.1.0 → pyelssa-0.1.1}/README.md +0 -0
- {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/data/TestFile.txt +0 -0
- {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/data/TestFile1.csv +0 -0
- {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/data/TestFile2.csv +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyELSSA
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: This is 'ELSSA in Python' package owned by Network Rail Infrastructure Limited.
|
|
5
5
|
License: Open Source Non-Commercial License (OSNCL) v2.0
|
|
6
6
|
License-File: LICENSE
|
|
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Requires-Dist: h5py
|
|
18
19
|
Requires-Dist: matplotlib
|
|
19
20
|
Requires-Dist: numpy
|
|
20
21
|
Requires-Dist: pandas
|
|
@@ -5,7 +5,7 @@ build-backend = "poetry.core.masonry.api"
|
|
|
5
5
|
|
|
6
6
|
[tool.poetry]
|
|
7
7
|
name = "PyELSSA"
|
|
8
|
-
version = "0.1.
|
|
8
|
+
version = "0.1.1"
|
|
9
9
|
description = "This is 'ELSSA in Python' package owned by Network Rail Infrastructure Limited."
|
|
10
10
|
authors = ["Jieming Ye <Jieming.Ye@networkrail.co.uk>"]
|
|
11
11
|
readme = "README.md"
|
|
@@ -29,6 +29,7 @@ pandas = "*"
|
|
|
29
29
|
matplotlib = "*"
|
|
30
30
|
scipy = "*"
|
|
31
31
|
psutil = "*"
|
|
32
|
+
h5py = "*"
|
|
32
33
|
|
|
33
34
|
[tool.poetry.group.dev.dependencies]
|
|
34
35
|
flake8 = "*"
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
# Copyright (c) 2026 [Jieming Ye]
|
|
9
9
|
#
|
|
10
10
|
# This Python source code is licensed under the
|
|
11
|
-
# Open Source Non-Commercial License (OSNCL)
|
|
11
|
+
# Open Source Non-Commercial License (OSNCL) v2.0
|
|
12
12
|
# See LICENSE for details.
|
|
13
13
|
#=================================================================
|
|
14
14
|
"""
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
# Copyright (c) 2026 [Jieming Ye]
|
|
9
9
|
#
|
|
10
10
|
# This Python source code is licensed under the
|
|
11
|
-
# Open Source Non-Commercial License (OSNCL)
|
|
11
|
+
# Open Source Non-Commercial License (OSNCL) v2.0
|
|
12
12
|
# See LICENSE for details.
|
|
13
13
|
#=================================================================
|
|
14
14
|
"""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from .network import Network, Conductor, CONDUCTOR_LIBRARY
|
|
2
|
+
from .carson import (series_impedance_matrix, shunt_admittance_matrix,
|
|
3
|
+
carson_PQ_theta, potential_coeff_matrix)
|
|
4
|
+
from .ybus import YbusBuilder
|
|
5
|
+
from .modal import modal_decompose, symmetrical_components
|
|
6
|
+
from .harmonic import sweep, mode_shape, find_resonances
|
|
7
|
+
from .elssa import (read_cross_section, parse_setup, build_from_elssa,
|
|
8
|
+
parse_locations, parse_conn_sets)
|
|
9
|
+
from .harmonic_sweep import sweep_Zfx, resonance_map, all_resonances_at
|
|
10
|
+
from .qqtt import (dispersion, band_edges, cell_abcd, loop_zy_from_network,
|
|
11
|
+
kronig_penney_comparison)
|
|
12
|
+
from .train_epr import train_epr_sweep, train_epr_profile
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"System",
|
|
16
|
+
"Network", "Conductor", "CONDUCTOR_LIBRARY",
|
|
17
|
+
"series_impedance_matrix", "shunt_admittance_matrix",
|
|
18
|
+
"carson_PQ_theta", "potential_coeff_matrix",
|
|
19
|
+
"YbusBuilder", "modal_decompose", "symmetrical_components",
|
|
20
|
+
"sweep", "mode_shape", "find_resonances",
|
|
21
|
+
"read_cross_section", "parse_setup", "build_from_elssa",
|
|
22
|
+
"parse_locations", "parse_conn_sets",
|
|
23
|
+
"sweep_Zfx", "resonance_map", "all_resonances_at",
|
|
24
|
+
"dispersion", "band_edges", "cell_abcd", "loop_zy_from_network",
|
|
25
|
+
"kronig_penney_comparison",
|
|
26
|
+
"train_epr_sweep", "train_epr_profile",
|
|
27
|
+
]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
busbar.py -- supply-point busbar element for PyELSSA.
|
|
3
|
+
|
|
4
|
+
In ELSSA a "busbar" is the common connection at a feeder station / supply point
|
|
5
|
+
where the grid supply (via the SGT/ATFT), the transformer secondary, and the
|
|
6
|
+
outgoing feeders all tie together. The base model represents the supply as a
|
|
7
|
+
centre-tapped source injected at the supply location; this module treats that
|
|
8
|
+
location as a first-class BUSBAR node and adds the quantities an engineer needs
|
|
9
|
+
there:
|
|
10
|
+
|
|
11
|
+
* Busbar FAULT LEVEL -- the fault current the busbar and its switchgear must
|
|
12
|
+
interrupt (a fault from the busbar OLE to the return/earth at the supply
|
|
13
|
+
point). This is the fundamental switchgear-rating and protection-grading
|
|
14
|
+
quantity, and it was the most likely intent of ELSSA's unfinished busbar tag.
|
|
15
|
+
|
|
16
|
+
* Busbar VOLTAGE -- the supply-point voltage that everything downstream
|
|
17
|
+
references (the point of common coupling with the grid).
|
|
18
|
+
|
|
19
|
+
* MULTI-FEEDER support -- several feeders sharing one supply busbar, so the
|
|
20
|
+
interaction between feeders through the shared supply is captured (one
|
|
21
|
+
feeder's load pulling the busbar voltage seen by the others).
|
|
22
|
+
|
|
23
|
+
This is implemented on top of the validated bordered-MNA solve: a busbar fault
|
|
24
|
+
is the same rank-1 (Sherman-Morrison) fault update Study.fault() uses, applied
|
|
25
|
+
at the busbar node. No change to the validated assembly -- the busbar is a view
|
|
26
|
+
onto the supply location plus the fault/voltage queries there.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
import numpy as np
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Optional, List, Dict
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class BusbarResult:
|
|
36
|
+
location_m: float
|
|
37
|
+
fault_level_ka: float # busbar fault current (OLE-to-rail at busbar)
|
|
38
|
+
voltage_kv: float # no-load busbar voltage (OLE-to-rail)
|
|
39
|
+
fault_mva: float # apparent fault level S = sqrt(3)*V*I (3-ph equiv)
|
|
40
|
+
cond_ole: int
|
|
41
|
+
cond_rail: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Busbar:
|
|
45
|
+
"""A supply-point busbar at a chainage. Provides fault level and voltage,
|
|
46
|
+
using the feeder's OLE and rail conductors as the busbar terminals.
|
|
47
|
+
|
|
48
|
+
study : a solved Study
|
|
49
|
+
location_m : the supply-point chainage (e.g. the ATFT/ATFS location)
|
|
50
|
+
feeder_key : which feeder's conductors define the busbar terminals
|
|
51
|
+
(defaults to the first feeder). The OLE conductor is the
|
|
52
|
+
busbar live side; the traction rail is the return.
|
|
53
|
+
"""
|
|
54
|
+
def __init__(self, study, location_m: float, feeder_key: Optional[str] = None):
|
|
55
|
+
self.study = study
|
|
56
|
+
self.location_m = location_m
|
|
57
|
+
if feeder_key is None:
|
|
58
|
+
feeder_key = next(iter(study.feeders)) if study.feeders else None
|
|
59
|
+
self.feeder_key = feeder_key
|
|
60
|
+
if feeder_key is not None and feeder_key in study.feeders:
|
|
61
|
+
f = study.feeders[feeder_key]
|
|
62
|
+
self.cond_ole = f.cw0
|
|
63
|
+
self.cond_rail = f.rail0
|
|
64
|
+
else:
|
|
65
|
+
# fall back to common defaults (OLE cond, rail cond) if no feeders
|
|
66
|
+
self.cond_ole = 4
|
|
67
|
+
self.cond_rail = 10
|
|
68
|
+
|
|
69
|
+
# ---- busbar voltage ------------------------------------------------
|
|
70
|
+
def voltage_kv(self) -> float:
|
|
71
|
+
"""No-load busbar voltage (OLE-to-rail) at the supply point."""
|
|
72
|
+
st = self.study
|
|
73
|
+
ia = st.node(self.location_m, self.cond_ole)
|
|
74
|
+
ib = st.node(self.location_m, self.cond_rail)
|
|
75
|
+
return abs(st.V0[ia] - st.V0[ib]) / 1e3
|
|
76
|
+
|
|
77
|
+
# ---- busbar fault level (switchgear rating) ------------------------
|
|
78
|
+
def fault_level(self, zf: complex = 0.0) -> BusbarResult:
|
|
79
|
+
"""Fault level AT the busbar: a fault from the busbar OLE to the rail
|
|
80
|
+
(return) at the supply location. This is the current the busbar
|
|
81
|
+
switchgear must interrupt. zf is an optional fault-arc impedance
|
|
82
|
+
(0 = bolted fault, the worst case)."""
|
|
83
|
+
st = self.study
|
|
84
|
+
r = st.fault(self.location_m, cond_a=self.cond_ole,
|
|
85
|
+
cond_b=self.cond_rail, **({} if zf == 0 else {}))
|
|
86
|
+
Ika = abs(r.I) / 1e3
|
|
87
|
+
Vkv = self.voltage_kv()
|
|
88
|
+
# apparent single-phase fault level at the busbar (V*I); for a 25 kV
|
|
89
|
+
# single-phase traction busbar this is the relevant rating figure.
|
|
90
|
+
mva = Vkv * Ika
|
|
91
|
+
return BusbarResult(location_m=self.location_m, fault_level_ka=Ika,
|
|
92
|
+
voltage_kv=Vkv, fault_mva=mva,
|
|
93
|
+
cond_ole=self.cond_ole, cond_rail=self.cond_rail)
|
|
94
|
+
|
|
95
|
+
# ---- feeder interaction through the busbar -------------------------
|
|
96
|
+
def feeder_voltages(self, feeder_keys: Optional[List[str]] = None,
|
|
97
|
+
sample_m: Optional[float] = None) -> Dict[str, float]:
|
|
98
|
+
"""No-load OLE-to-rail voltage seen by each feeder at the busbar (or at
|
|
99
|
+
`sample_m` if given). Sampling at the busbar location -- not the feeder
|
|
100
|
+
start, which may sit in an unenergised transition zone -- gives the
|
|
101
|
+
physically meaningful supply voltage each feeder sees. With load present
|
|
102
|
+
(via a train solve) this reveals the busbar/feeder interaction."""
|
|
103
|
+
st = self.study
|
|
104
|
+
keys = feeder_keys or list(st.feeders.keys())
|
|
105
|
+
x = sample_m if sample_m is not None else self.location_m
|
|
106
|
+
out = {}
|
|
107
|
+
for k in keys:
|
|
108
|
+
f = st.feeders[k]
|
|
109
|
+
ia = st.node(x, f.cw0); ib = st.node(x, f.rail0)
|
|
110
|
+
out[k] = abs(st.V0[ia] - st.V0[ib]) / 1e3
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def busbar_fault_levels(study, locations_m: List[float],
|
|
115
|
+
feeder_key: Optional[str] = None) -> List[BusbarResult]:
|
|
116
|
+
"""Compute busbar fault level at several supply points at once -- e.g. all
|
|
117
|
+
ATFT/ATFS locations -- for switchgear rating across the route."""
|
|
118
|
+
return [Busbar(study, loc, feeder_key).fault_level() for loc in locations_m]
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Carson's earth-return series impedance for a multiconductor system.
|
|
3
|
+
|
|
4
|
+
Rigorous infinite-series form (Carson 1926). All quantities in SI.
|
|
5
|
+
Geometry in metres, frequency in Hz, earth resistivity in ohm-metre.
|
|
6
|
+
|
|
7
|
+
The series impedance between conductors i and j (per unit length) is:
|
|
8
|
+
|
|
9
|
+
Z_ij = R_internal*delta_ij + j*omega*mu0/(2*pi) * ln(1/D_ij)
|
|
10
|
+
+ (Carson earth-return correction: 4*omega*1e-7 * (P + jQ))
|
|
11
|
+
|
|
12
|
+
where D_ij is the geometric distance term (GMR for self, axial spacing
|
|
13
|
+
for mutual) and P, Q are Carson's correction terms evaluated from the
|
|
14
|
+
series in the parameter `a`.
|
|
15
|
+
|
|
16
|
+
References: Carson (1926); Dommel EMTP Theory Book, ch. on line constants.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
MU0 = 4.0e-7 * np.pi # H/m
|
|
23
|
+
EPS0 = 8.8541878128e-12 # F/m
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _elssa_artificial_offset_active(x: np.ndarray) -> bool:
|
|
27
|
+
"""Detect ELSSA cross-sections that use 1000 m x-offset blocks as route IDs.
|
|
28
|
+
|
|
29
|
+
Braybrooke/6200-style ELSSA CSVs use x coordinates such as 1004,
|
|
30
|
+
2004, 5004, etc. Those thousand-metre offsets are not literal physical
|
|
31
|
+
separations for the Carson mutual impedance calculation; they are route /
|
|
32
|
+
track-group offsets used by ELSSA to keep parallel systems separated in
|
|
33
|
+
the cross-section table.
|
|
34
|
+
"""
|
|
35
|
+
x = np.asarray(x, float)
|
|
36
|
+
return bool(np.nanmax(x) > 900.0 and (np.nanmax(x) - np.nanmin(x)) > 900.0)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _elssa_far_offset_mutual_ohm_per_m(dx: float, f: float) -> complex:
|
|
40
|
+
"""ELSSA-compatible mutual Z for artificial route-offset separations.
|
|
41
|
+
|
|
42
|
+
This is a 50 Hz compatibility branch reverse-engineered against compiled
|
|
43
|
+
ELSSA Section.Z for the Braybrooke/6200 model. It is used only when the
|
|
44
|
+
cross-section clearly contains artificial thousand-metre route offsets and
|
|
45
|
+
the conductor pair is separated by a large route offset (default >=500 m).
|
|
46
|
+
|
|
47
|
+
Compiled ELSSA Section.Z for those pairs is approximately:
|
|
48
|
+
Re(Zm) = 0.049345 ohm/km at 50 Hz
|
|
49
|
+
Im(Zm) = A / (|dx| + B)^2 + C ohm/km at 50 Hz
|
|
50
|
+
|
|
51
|
+
where |dx| is the artificial route-offset separation in metres. Local
|
|
52
|
+
conductor pairs remain on the normal Carson branch.
|
|
53
|
+
"""
|
|
54
|
+
D = max(abs(float(dx)), 1.0)
|
|
55
|
+
scale = float(f) / 50.0
|
|
56
|
+
real_per_km = 0.049345 * scale
|
|
57
|
+
imag_per_km = (32666.8256 / ((D + 286.648982) ** 2) - 0.0001257778) * scale
|
|
58
|
+
# Do not suppress this term. Compiled ELSSA Section.Z extracts for the
|
|
59
|
+
# 6200/Braybrooke model retain this mutual coupling between the artificial
|
|
60
|
+
# route-offset groups. The accidental ``0 *`` here disconnected the groups
|
|
61
|
+
# magnetically while leaving only explicit bonds/cables between them.
|
|
62
|
+
return (real_per_km + 1j * imag_per_km) / 1000.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def carson_PQ_theta(a: float, theta: float, kmax: int = 60) -> tuple[float, float]:
|
|
66
|
+
"""
|
|
67
|
+
Carson's P and Q with the angle theta between the line connecting
|
|
68
|
+
conductor i and its own image, and the line connecting i to j's image.
|
|
69
|
+
|
|
70
|
+
For self impedance theta = 0.
|
|
71
|
+
For mutual impedance, theta is the angle subtended at conductor i by
|
|
72
|
+
conductor j and j's image (i.e. cos(theta) = (yi+yj)/Dij_image).
|
|
73
|
+
|
|
74
|
+
Convergent series (Dommel EMTP Theory Book eqns 4.50-4.61).
|
|
75
|
+
"""
|
|
76
|
+
# Coefficient magnitudes b_k
|
|
77
|
+
kmax = int(kmax)
|
|
78
|
+
b = np.zeros(kmax + 3)
|
|
79
|
+
c = np.zeros(kmax + 3)
|
|
80
|
+
d = np.zeros(kmax + 3)
|
|
81
|
+
sign = np.zeros(kmax + 3)
|
|
82
|
+
|
|
83
|
+
b[1] = np.sqrt(2.0) / 6.0
|
|
84
|
+
b[2] = 1.0 / 16.0
|
|
85
|
+
# sign sequence: +,+,-,-,+,+,...
|
|
86
|
+
s = 1.0
|
|
87
|
+
for k in range(1, kmax + 1):
|
|
88
|
+
sign[k] = s
|
|
89
|
+
if k % 2 == 0:
|
|
90
|
+
s = -s
|
|
91
|
+
# magnitude recurrence: b_k = b_{k-2} * 1/(k*(k+2))
|
|
92
|
+
for k in range(3, kmax + 1):
|
|
93
|
+
b[k] = b[k - 2] / (k * (k + 2.0))
|
|
94
|
+
# apply signs
|
|
95
|
+
bsig = sign * b
|
|
96
|
+
|
|
97
|
+
# c_2 and recurrence
|
|
98
|
+
c[2] = 1.3659315
|
|
99
|
+
for k in range(4, kmax + 1, 2):
|
|
100
|
+
c[k] = c[k - 2] + 1.0 / k + 1.0 / (k + 2.0)
|
|
101
|
+
d = (np.pi / 4.0) * bsig
|
|
102
|
+
|
|
103
|
+
P = np.pi / 8.0 * (1.0) # k=0 term
|
|
104
|
+
Q = -0.0386 + 0.5 * np.log(2.0 / a)
|
|
105
|
+
|
|
106
|
+
# k = 1 terms
|
|
107
|
+
P += -bsig[1] * a * np.cos(theta)
|
|
108
|
+
Q += bsig[1] * a * np.cos(theta)
|
|
109
|
+
|
|
110
|
+
# k = 2 terms (involve c2, d2, and ln)
|
|
111
|
+
a2 = a * a
|
|
112
|
+
P += (bsig[2] * a2) * (c[2] - np.log(a)) * np.cos(2 * theta) \
|
|
113
|
+
+ bsig[2] * a2 * theta * np.sin(2 * theta)
|
|
114
|
+
Q += d[2] * a2 * np.cos(2 * theta)
|
|
115
|
+
|
|
116
|
+
# k >= 3 terms
|
|
117
|
+
ak = a2 * a # a^3
|
|
118
|
+
for k in range(3, kmax + 1):
|
|
119
|
+
ck = np.cos(k * theta)
|
|
120
|
+
sk = np.sin(k * theta)
|
|
121
|
+
if k % 2 == 1: # odd k: contributes to P and Q via b,d
|
|
122
|
+
P += -bsig[k] * ak * ck
|
|
123
|
+
Q += bsig[k] * ak * ck
|
|
124
|
+
else: # even k
|
|
125
|
+
P += bsig[k] * ak * (c[k] - np.log(a)) * ck
|
|
126
|
+
P += bsig[k] * ak * theta * sk
|
|
127
|
+
Q += d[k] * ak * ck
|
|
128
|
+
ak *= a
|
|
129
|
+
return P, Q
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def elssa_series_impedance_matrix(
|
|
134
|
+
x: np.ndarray,
|
|
135
|
+
y: np.ndarray,
|
|
136
|
+
gmr: np.ndarray,
|
|
137
|
+
r_ac: np.ndarray,
|
|
138
|
+
f: float,
|
|
139
|
+
rho: float = 100.0,
|
|
140
|
+
) -> np.ndarray:
|
|
141
|
+
"""ELSSA/TIPS ``tracksectionz`` series matrix (ohm/m).
|
|
142
|
+
|
|
143
|
+
This is the numerical formulation recovered from the MATLAB R2014a
|
|
144
|
+
``createsection`` build audit. At 50 Hz and rho=100 ohm.m it reproduces
|
|
145
|
+
the exported 86x86 ECML matrix to floating-point precision.
|
|
146
|
+
|
|
147
|
+
ELSSA uses the classic simplified Carson constants rather than the
|
|
148
|
+
rigorous infinite series:
|
|
149
|
+
|
|
150
|
+
* earth-return resistance = 0.049345 ohm/km at 50 Hz;
|
|
151
|
+
* equivalent earth depth ``De = 658.5*sqrt(rho/f)`` metres;
|
|
152
|
+
* self X = k*ln(De/GMR);
|
|
153
|
+
* mutual X = k*ln(D_image/D_direct).
|
|
154
|
+
|
|
155
|
+
The compiled routine evaluates every ordered matrix entry using the signed
|
|
156
|
+
height difference ``De + y[j] - y[i]``. Consequently the exported matrix
|
|
157
|
+
is slightly non-symmetric when conductor heights differ. Although unusual,
|
|
158
|
+
preserving this behaviour is necessary for bit-level parity with the
|
|
159
|
+
p-coded function and the MATLAB solution.
|
|
160
|
+
"""
|
|
161
|
+
x = np.asarray(x, dtype=float)
|
|
162
|
+
y = np.asarray(y, dtype=float)
|
|
163
|
+
gmr = np.asarray(gmr, dtype=float)
|
|
164
|
+
r_ac = np.asarray(r_ac, dtype=float)
|
|
165
|
+
if not (len(x) == len(y) == len(gmr) == len(r_ac)):
|
|
166
|
+
raise ValueError("x, y, gmr and r_ac must have the same length")
|
|
167
|
+
if f <= 0 or rho <= 0:
|
|
168
|
+
raise ValueError("ELSSA series impedance requires f>0 and rho>0")
|
|
169
|
+
if np.any(gmr <= 0):
|
|
170
|
+
raise ValueError("all conductor GMR values must be positive")
|
|
171
|
+
|
|
172
|
+
n = len(x)
|
|
173
|
+
omega = 2.0 * np.pi * float(f)
|
|
174
|
+
xcoef_per_m = omega * MU0 / (2.0 * np.pi)
|
|
175
|
+
de = 658.5 * np.sqrt(float(rho) / float(f))
|
|
176
|
+
# Numeric constant exported by MATLAB at 50 Hz. The simplified Carson
|
|
177
|
+
# earth resistance is linear with frequency and independent of geometry.
|
|
178
|
+
re_per_m = (0.049345 * (float(f) / 50.0)) / 1000.0
|
|
179
|
+
|
|
180
|
+
Z = np.empty((n, n), dtype=complex)
|
|
181
|
+
for i in range(n):
|
|
182
|
+
Z[i, i] = (r_ac[i] + re_per_m) + 1j * (
|
|
183
|
+
xcoef_per_m * np.log(de / gmr[i])
|
|
184
|
+
)
|
|
185
|
+
for j in range(n):
|
|
186
|
+
if i == j:
|
|
187
|
+
continue
|
|
188
|
+
dx = x[j] - x[i]
|
|
189
|
+
dy = y[j] - y[i]
|
|
190
|
+
d_direct = np.hypot(dx, dy)
|
|
191
|
+
if d_direct <= 0.0:
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"conductors {i + 1} and {j + 1} occupy the same physical location"
|
|
194
|
+
)
|
|
195
|
+
# The compiled routine evaluates every ordered matrix entry. The
|
|
196
|
+
# signed height difference means Z[i,j] and Z[j,i] can differ when
|
|
197
|
+
# conductor heights differ. This non-symmetric result is unusual
|
|
198
|
+
# physically but is exactly what MATLAB exports and solves.
|
|
199
|
+
d_image = np.hypot(dx, de + dy)
|
|
200
|
+
Z[i, j] = re_per_m + 1j * (
|
|
201
|
+
xcoef_per_m * np.log(d_image / d_direct)
|
|
202
|
+
)
|
|
203
|
+
return Z
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def series_impedance_matrix(
|
|
207
|
+
x: np.ndarray,
|
|
208
|
+
y: np.ndarray,
|
|
209
|
+
gmr: np.ndarray,
|
|
210
|
+
r_ac: np.ndarray,
|
|
211
|
+
f: float,
|
|
212
|
+
rho: float = 100.0,
|
|
213
|
+
kmax: int = 60,
|
|
214
|
+
) -> np.ndarray:
|
|
215
|
+
"""
|
|
216
|
+
Full n x n series impedance matrix (ohm/m) via Carson's rigorous series.
|
|
217
|
+
|
|
218
|
+
Parameters
|
|
219
|
+
----------
|
|
220
|
+
x, y : (n,) arrays of horizontal / height coordinates (m). y > 0 above earth.
|
|
221
|
+
gmr : (n,) geometric mean radii (m).
|
|
222
|
+
r_ac : (n,) AC resistances per unit length (ohm/m) at frequency f.
|
|
223
|
+
f : frequency (Hz).
|
|
224
|
+
rho : earth resistivity (ohm.m).
|
|
225
|
+
"""
|
|
226
|
+
x = np.asarray(x, float)
|
|
227
|
+
y = np.asarray(y, float).copy()
|
|
228
|
+
y_raw = y.copy() # true heights, for direct-distance recovery below
|
|
229
|
+
# Floor heights: rails sit at ~grade (y=0) and some conductors are quoted
|
|
230
|
+
# slightly below. Carson's image method needs y>0. Use a small positive
|
|
231
|
+
# height for at-grade conductors (their self-impedance is then dominated by
|
|
232
|
+
# GMR and earth return, which is physically correct for a rail on ballast).
|
|
233
|
+
y = np.where(y < 0.05, 0.05, y)
|
|
234
|
+
gmr = np.asarray(gmr, float)
|
|
235
|
+
r_ac = np.asarray(r_ac, float)
|
|
236
|
+
n = len(x)
|
|
237
|
+
elssa_offset_mode = _elssa_artificial_offset_active(x)
|
|
238
|
+
omega = 2.0 * np.pi * f
|
|
239
|
+
Z = np.zeros((n, n), dtype=complex)
|
|
240
|
+
|
|
241
|
+
# a-parameter scale: a = D * sqrt(omega*mu0/rho)
|
|
242
|
+
k_a = np.sqrt(omega * MU0 / rho)
|
|
243
|
+
pref = omega * MU0 / (2.0 * np.pi) # for the ln term (jX_geo)
|
|
244
|
+
earth_pref = 4.0 * omega * 1.0e-7 # = omega*mu0/pi ; multiplies (P+jQ)
|
|
245
|
+
|
|
246
|
+
for i in range(n):
|
|
247
|
+
for j in range(i, n):
|
|
248
|
+
if i == j:
|
|
249
|
+
Dij = gmr[i]
|
|
250
|
+
# distance to own image
|
|
251
|
+
Dimg = 2.0 * y[i]
|
|
252
|
+
theta = 0.0
|
|
253
|
+
a = Dimg * k_a
|
|
254
|
+
P, Q = carson_PQ_theta(a, theta, kmax)
|
|
255
|
+
Xgeo = pref * np.log(Dimg / Dij) # ideal-earth reactance using image
|
|
256
|
+
Rc = earth_pref * P
|
|
257
|
+
Xc = earth_pref * Q
|
|
258
|
+
Z[i, i] = (r_ac[i] + Rc) + 1j * (Xgeo + Xc)
|
|
259
|
+
else:
|
|
260
|
+
dx = x[i] - x[j]
|
|
261
|
+
|
|
262
|
+
# ELSSA route-offset compatibility branch. In Braybrooke/6200
|
|
263
|
+
# style cross-sections, x positions around 1000, 2000, 5000...
|
|
264
|
+
# are route identifiers rather than literal 1-5 km conductor
|
|
265
|
+
# spacings. For those large artificial separations, compiled
|
|
266
|
+
# ELSSA Section.Z uses a far-offset mutual term rather than the
|
|
267
|
+
# rigorous Carson series evaluated at the literal distance.
|
|
268
|
+
if elssa_offset_mode and abs(dx) >= 500.0:
|
|
269
|
+
zij = _elssa_far_offset_mutual_ohm_per_m(dx, f)
|
|
270
|
+
Z[i, j] = zij
|
|
271
|
+
Z[j, i] = zij
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
# Direct physical separation must use the raw conductor
|
|
275
|
+
# heights whenever either conductor has been lifted by the
|
|
276
|
+
# numerical earth-height floor. The earlier implementation
|
|
277
|
+
# used the floored heights except when the separation collapsed
|
|
278
|
+
# to exactly zero. That was wrong for LSC/signalling cables
|
|
279
|
+
# placed at y=0/-0.01 m next to conductors at y=0.05 m: their
|
|
280
|
+
# vertical separation was lost and the mutual reactance became
|
|
281
|
+
# too large. ELSSA's compiled Section.Z uses the real direct
|
|
282
|
+
# separation for Dij while still using a positive image height
|
|
283
|
+
# for Dimg/Carson earth-return terms.
|
|
284
|
+
raw_Dij = np.hypot(dx, y_raw[i] - y_raw[j])
|
|
285
|
+
if (y_raw[i] < 0.05 or y_raw[j] < 0.05) and raw_Dij > 1e-9:
|
|
286
|
+
Dij = raw_Dij
|
|
287
|
+
else:
|
|
288
|
+
dy = y[i] - y[j]
|
|
289
|
+
Dij = np.hypot(dx, dy) # direct distance
|
|
290
|
+
if Dij < 1e-6: # final protection against coincident points
|
|
291
|
+
Dij = max(raw_Dij, 1e-3)
|
|
292
|
+
Dimg = np.hypot(dx, y[i] + y[j]) # distance to image of j
|
|
293
|
+
cos_t = (y[i] + y[j]) / Dimg
|
|
294
|
+
cos_t = np.clip(cos_t, -1.0, 1.0)
|
|
295
|
+
theta = np.arccos(np.clip(cos_t, -1.0, 1.0))
|
|
296
|
+
a = Dimg * k_a
|
|
297
|
+
P, Q = carson_PQ_theta(a, theta, kmax)
|
|
298
|
+
Xgeo = pref * np.log(Dimg / Dij)
|
|
299
|
+
Rc = earth_pref * P
|
|
300
|
+
Xc = earth_pref * Q
|
|
301
|
+
zij = Rc + 1j * (Xgeo + Xc)
|
|
302
|
+
Z[i, j] = zij
|
|
303
|
+
Z[j, i] = zij
|
|
304
|
+
return Z
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def potential_coeff_matrix(x, y, radius) -> np.ndarray:
|
|
308
|
+
"""
|
|
309
|
+
Maxwell potential coefficient matrix P (m/F units before 1/(2*pi*eps0)).
|
|
310
|
+
Returns P such that C = 2*pi*eps0 * inv(P) (F/m).
|
|
311
|
+
Uses method of images (perfect earth).
|
|
312
|
+
"""
|
|
313
|
+
x = np.asarray(x, float); y_raw = np.asarray(y, float).copy()
|
|
314
|
+
r = np.asarray(radius, float)
|
|
315
|
+
# Height floor for the image method (same as the series Carson matrix). Keep
|
|
316
|
+
# the raw heights to recover true separations when the floor collapses a pair.
|
|
317
|
+
y = np.where(y_raw < 0.05, 0.05, y_raw)
|
|
318
|
+
n = len(x)
|
|
319
|
+
P = np.zeros((n, n))
|
|
320
|
+
for i in range(n):
|
|
321
|
+
for j in range(n):
|
|
322
|
+
if i == j:
|
|
323
|
+
P[i, i] = np.log(2.0 * y[i] / r[i])
|
|
324
|
+
else:
|
|
325
|
+
dx = x[i] - x[j]
|
|
326
|
+
Dij = np.hypot(dx, y[i] - y[j])
|
|
327
|
+
if Dij < 1e-6:
|
|
328
|
+
# height floor collapsed two distinct conductors onto the
|
|
329
|
+
# same point: recover the true geometric separation from the
|
|
330
|
+
# raw (pre-floor) heights so log(Dimg/Dij) stays finite.
|
|
331
|
+
Dij = max(np.hypot(dx, y_raw[i] - y_raw[j]), 1e-3)
|
|
332
|
+
Dimg = np.hypot(dx, y[i] + y[j])
|
|
333
|
+
P[i, j] = np.log(Dimg / Dij)
|
|
334
|
+
return P / (2.0 * np.pi * EPS0)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def shunt_admittance_matrix(x, y, radius, f) -> np.ndarray:
|
|
338
|
+
"""Shunt admittance Y = j*omega*C (S/m), multiconductor.
|
|
339
|
+
|
|
340
|
+
The Maxwell image method assumes thin conductors well ABOVE earth
|
|
341
|
+
(height >> radius). At-grade and buried conductors (rails on ballast,
|
|
342
|
+
buried cable cores) violate this: their height is <= their radius, the
|
|
343
|
+
self potential-coefficient log(2y/r) goes to zero or negative, and the
|
|
344
|
+
resulting C matrix is non-physical (negative eigenvalues). Physically
|
|
345
|
+
such conductors sit essentially AT earth potential and couple
|
|
346
|
+
capacitively to earth, not through the air to the overhead line. We
|
|
347
|
+
therefore build C only among the genuinely-elevated conductors and give
|
|
348
|
+
the at-grade ones a direct (large) capacitance to earth with negligible
|
|
349
|
+
air-path mutual coupling. The result is a positive-definite, physical C.
|
|
350
|
+
"""
|
|
351
|
+
x = np.asarray(x, float); y = np.asarray(y, float); r = np.asarray(radius, float)
|
|
352
|
+
n = len(x)
|
|
353
|
+
# genuinely elevated: height comfortably exceeds radius (thin-wire valid)
|
|
354
|
+
elevated = y > np.maximum(3.0 * r, 0.3)
|
|
355
|
+
Y = np.zeros((n, n), dtype=complex)
|
|
356
|
+
if elevated.sum() >= 1:
|
|
357
|
+
idx = np.where(elevated)[0]
|
|
358
|
+
P = potential_coeff_matrix(x[idx], y[idx], r[idx])
|
|
359
|
+
C = np.linalg.inv(P)
|
|
360
|
+
for a, i in enumerate(idx):
|
|
361
|
+
for b, j in enumerate(idx):
|
|
362
|
+
Y[i, j] = 1j * 2.0 * np.pi * f * C[a, b]
|
|
363
|
+
# at-grade / buried conductors: capacitance to EARTH only (they are ~at
|
|
364
|
+
# earth potential). Use a representative large value; in practice these
|
|
365
|
+
# conductors' shunt path to earth is dominated by their bonding, not air
|
|
366
|
+
# capacitance, so the exact value barely matters for the solved network.
|
|
367
|
+
at_grade = ~elevated
|
|
368
|
+
for i in np.where(at_grade)[0]:
|
|
369
|
+
# small self-capacitance to earth (cable/rail to ground, ~order nF/km)
|
|
370
|
+
Y[i, i] += 1j * 2.0 * np.pi * f * 20e-9 / 1000.0 # 20 nF/km to earth
|
|
371
|
+
return Y
|