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.
Files changed (53) hide show
  1. {pyelssa-0.1.0 → pyelssa-0.1.1}/PKG-INFO +2 -1
  2. {pyelssa-0.1.0 → pyelssa-0.1.1}/pyproject.toml +2 -1
  3. {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/__init__.py +1 -1
  4. {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/configuration.py +1 -1
  5. pyelssa-0.1.1/src/PyELSSA/core/__init__.py +27 -0
  6. pyelssa-0.1.1/src/PyELSSA/core/busbar.py +118 -0
  7. pyelssa-0.1.1/src/PyELSSA/core/carson.py +371 -0
  8. pyelssa-0.1.1/src/PyELSSA/core/check_section.py +773 -0
  9. pyelssa-0.1.1/src/PyELSSA/core/current_recovery.py +271 -0
  10. pyelssa-0.1.1/src/PyELSSA/core/elssa.py +807 -0
  11. pyelssa-0.1.1/src/PyELSSA/core/elssa_compat.py +200 -0
  12. pyelssa-0.1.1/src/PyELSSA/core/elssa_mat.py +216 -0
  13. pyelssa-0.1.1/src/PyELSSA/core/elssa_output_sheet.py +206 -0
  14. pyelssa-0.1.1/src/PyELSSA/core/elssa_sheet.py +168 -0
  15. pyelssa-0.1.1/src/PyELSSA/core/feeder_transformer.py +106 -0
  16. pyelssa-0.1.1/src/PyELSSA/core/feeders.py +111 -0
  17. pyelssa-0.1.1/src/PyELSSA/core/fieldslice.py +241 -0
  18. pyelssa-0.1.1/src/PyELSSA/core/freq_dependent.py +82 -0
  19. pyelssa-0.1.1/src/PyELSSA/core/g_bord_lines.py +39 -0
  20. pyelssa-0.1.1/src/PyELSSA/core/harmonic.py +99 -0
  21. pyelssa-0.1.1/src/PyELSSA/core/harmonic_sweep.py +183 -0
  22. pyelssa-0.1.1/src/PyELSSA/core/induced.py +191 -0
  23. pyelssa-0.1.1/src/PyELSSA/core/magnetic_field.py +97 -0
  24. pyelssa-0.1.1/src/PyELSSA/core/minihdf5.py +330 -0
  25. pyelssa-0.1.1/src/PyELSSA/core/modal.py +67 -0
  26. pyelssa-0.1.1/src/PyELSSA/core/mt.py +430 -0
  27. pyelssa-0.1.1/src/PyELSSA/core/mtstudy.py +368 -0
  28. pyelssa-0.1.1/src/PyELSSA/core/network.py +148 -0
  29. pyelssa-0.1.1/src/PyELSSA/core/oslo.py +260 -0
  30. pyelssa-0.1.1/src/PyELSSA/core/overlay.py +168 -0
  31. pyelssa-0.1.1/src/PyELSSA/core/plotting.py +53 -0
  32. pyelssa-0.1.1/src/PyELSSA/core/qqtt.py +144 -0
  33. pyelssa-0.1.1/src/PyELSSA/core/sc.py +701 -0
  34. pyelssa-0.1.1/src/PyELSSA/core/study.py +223 -0
  35. pyelssa-0.1.1/src/PyELSSA/core/switching.py +195 -0
  36. pyelssa-0.1.1/src/PyELSSA/core/system.py +1176 -0
  37. pyelssa-0.1.1/src/PyELSSA/core/train_epr.py +103 -0
  38. pyelssa-0.1.1/src/PyELSSA/core/train_model.py +252 -0
  39. pyelssa-0.1.1/src/PyELSSA/core/ybus.py +763 -0
  40. {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/gui/gui_base_frame.py +1 -1
  41. {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/gui/gui_main_page.py +1 -1
  42. {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/gui/gui_start.py +1 -1
  43. {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/gui/gui_sub_frame.py +1 -1
  44. {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/licensing.py +1 -1
  45. {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/master.py +1 -1
  46. {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/release_update.py +1 -1
  47. {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/shared_contents.py +1 -1
  48. {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/shared_msazure_api.py +1 -1
  49. {pyelssa-0.1.0 → pyelssa-0.1.1}/LICENSE +0 -0
  50. {pyelssa-0.1.0 → pyelssa-0.1.1}/README.md +0 -0
  51. {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/data/TestFile.txt +0 -0
  52. {pyelssa-0.1.0 → pyelssa-0.1.1}/src/PyELSSA/data/TestFile1.csv +0 -0
  53. {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.0
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.0"
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) v1.0
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) v1.0
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