scr-screen 0.1.0__py3-none-any.whl

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.
scr_screen/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """SCR-Screen: short-circuit ratio screening for inverter-based resource interconnection."""
2
+
3
+ __version__ = "0.1.0"
scr_screen/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m scr_screen``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
scr_screen/check.py ADDED
@@ -0,0 +1,138 @@
1
+ """Check whether a network is ready for SCR-Screen, before scanning.
2
+
3
+ Lists, in plain language, anything missing or unsupported (for example a
4
+ generator without sub-transient reactance), and says which short-circuit
5
+ methods can run.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from dataclasses import dataclass, field
12
+
13
+ from .classical import UNSUPPORTED_TABLES, connected_to_source
14
+ from .netmap import MAX_MAP_BUSES, bus_label
15
+
16
+ ERROR, WARNING, INFO = "error", "warning", "info"
17
+ CLASSICAL_SLOW_ABOVE = 5000
18
+
19
+
20
+ @dataclass
21
+ class Finding:
22
+ level: str # error, warning, info
23
+ applies_to: str # "both", "classical", "iec60909", or "report"
24
+ message: str
25
+
26
+
27
+ @dataclass
28
+ class CheckReport:
29
+ n_buses: int
30
+ findings: list[Finding] = field(default_factory=list)
31
+
32
+ def ready(self, method: str) -> bool:
33
+ return not any(f.level == ERROR and f.applies_to in ("both", method) for f in self.findings)
34
+
35
+ def add(self, level, applies_to, message):
36
+ self.findings.append(Finding(level, applies_to, message))
37
+
38
+
39
+ def check_network(net) -> CheckReport:
40
+ live = [int(b) for b in net.bus.index[net.bus["in_service"]]] if len(net.bus) else []
41
+ rep = CheckReport(n_buses=len(live))
42
+ if not live:
43
+ rep.add(ERROR, "both", "The network has no in-service buses.")
44
+ return rep
45
+
46
+ # --- Sources --------------------------------------------------------------
47
+ eg = net.ext_grid[net.ext_grid["in_service"]] if "ext_grid" in net else []
48
+ gens = net.gen[net.gen["in_service"]] if "gen" in net else []
49
+ if len(eg) == 0 and len(gens) == 0:
50
+ rep.add(ERROR, "both", "No in-service external grid or synchronous generator: "
51
+ "there is no source of short-circuit current.")
52
+
53
+ for idx, row in (eg.iterrows() if len(eg) else []):
54
+ name = _name(row, f"external grid {idx}")
55
+ if not _pos(row.get("s_sc_max_mva")) or not _nonneg(row.get("rx_max")):
56
+ rep.add(ERROR, "both", f"{name} (bus {bus_label(net, int(row['bus']))}) needs "
57
+ "s_sc_max_mva and rx_max (short-circuit MVA and R/X of the grid source).")
58
+
59
+ missing_classical, missing_iec = [], []
60
+ for idx, row in (gens.iterrows() if len(gens) else []):
61
+ name = f"{_name(row, f'generator {idx}')} (bus {bus_label(net, int(row['bus']))})"
62
+ if not _pos(row.get("xdss_pu")) or not _pos(row.get("sn_mva")):
63
+ missing_classical.append(name)
64
+ elif not (_pos(row.get("vn_kv")) and _nonneg(row.get("rdss_ohm")) and _pos(row.get("cos_phi"))):
65
+ missing_iec.append(name)
66
+ if missing_classical:
67
+ rep.add(ERROR, "both", f"{len(missing_classical)} generator(s) lack sub-transient data "
68
+ f"(xdss_pu and sn_mva): {_short(missing_classical)}.")
69
+ if missing_iec:
70
+ rep.add(ERROR, "iec60909", f"{len(missing_iec)} generator(s) lack vn_kv, rdss_ohm or cos_phi, "
71
+ f"which IEC 60909 needs: {_short(missing_iec)}.")
72
+
73
+ # --- Elements the classical method does not model in v0.1 ---------------
74
+ for table in UNSUPPORTED_TABLES:
75
+ if table in net and len(net[table]) and "in_service" in net[table]:
76
+ n = int(net[table]["in_service"].sum())
77
+ if n:
78
+ rep.add(ERROR, "classical", f"{n} in-service '{table}' element(s): not modeled by "
79
+ "the classical method in v0.1 (try --method iec60909).")
80
+ if "switch" in net and len(net.switch):
81
+ n = int(((net.switch["et"] == "b") & net.switch["closed"]).sum())
82
+ if n:
83
+ rep.add(ERROR, "classical", f"{n} closed bus-bus switch(es): not supported by the "
84
+ "classical method in v0.1 (merge those buses, or try --method iec60909).")
85
+
86
+ # --- Connectivity ---------------------------------------------------------
87
+ if len(eg) or len(gens):
88
+ reach = connected_to_source(net, set(live))
89
+ isolated = [b for b in live if b not in reach]
90
+ if isolated:
91
+ rep.add(WARNING, "both", f"{len(isolated)} bus(es) have no path to any source and will "
92
+ f"be skipped in scans: {_short([bus_label(net, b) for b in isolated])}.")
93
+
94
+ # --- Information ------------------------------------------------------------
95
+ n_ibr = sum(int(net[t]["in_service"].sum()) for t in ("sgen", "storage") if t in net and len(net[t]))
96
+ if n_ibr:
97
+ rep.add(INFO, "both", f"{n_ibr} inverter-based resource(s) (sgen/storage) found; their "
98
+ "contribution is excluded from short-circuit MVA, as NERC's SCR definition requires.")
99
+ labels = [bus_label(net, b) for b in live]
100
+ if len(set(labels)) != len(labels):
101
+ rep.add(INFO, "report", "Bus names are not unique, so reports label buses by pandapower index.")
102
+ if "geo" in net.bus.columns and net.bus.loc[live, "geo"].notna().all():
103
+ rep.add(INFO, "report", "All buses have coordinates: the map will use them.")
104
+ else:
105
+ rep.add(INFO, "report", "Not all buses have coordinates: the map will use an automatic layout.")
106
+ if len(live) > MAX_MAP_BUSES:
107
+ rep.add(INFO, "report", f"{len(live)} buses: above the {MAX_MAP_BUSES}-bus limit, so the "
108
+ "report will omit the network map.")
109
+ if len(live) > CLASSICAL_SLOW_ABOVE:
110
+ rep.add(WARNING, "classical", f"{len(live)} buses: the classical method may be slow or run "
111
+ "out of memory at this size in v0.1; IEC 60909 may be faster.")
112
+ return rep
113
+
114
+
115
+ def _name(row, default):
116
+ n = row.get("name")
117
+ if n is None or (isinstance(n, float) and math.isnan(n)) or str(n).strip() == "":
118
+ return default
119
+ return str(n)
120
+
121
+
122
+ def _pos(v):
123
+ try:
124
+ return v is not None and math.isfinite(float(v)) and float(v) > 0
125
+ except (TypeError, ValueError):
126
+ return False
127
+
128
+
129
+ def _nonneg(v):
130
+ try:
131
+ return v is not None and math.isfinite(float(v)) and float(v) >= 0
132
+ except (TypeError, ValueError):
133
+ return False
134
+
135
+
136
+ def _short(items, n=8):
137
+ items = [str(i) for i in items]
138
+ return ", ".join(items[:n]) + (f" and {len(items) - n} more" if len(items) > n else "")
@@ -0,0 +1,256 @@
1
+ """Classical flat-start short-circuit MVA (Option B).
2
+
3
+ Method (common U.S. planning practice for SCR screening):
4
+ 1. Every bus starts at 1.0 pu voltage ("flat start").
5
+ 2. Sources are modeled as impedances to ground:
6
+ - external grids: Z = 1 / S_sc (pu), split by R/X, with NO IEC c-factor;
7
+ - synchronous generators: sub-transient reactance X''d (plus R).
8
+ Inverter-based resources (sgen, storage) are not sources, so their
9
+ contribution is excluded automatically.
10
+ 3. Lines and transformers are series impedances. Loads, shunts and line
11
+ charging are ignored; transformers are at nominal ratio.
12
+ 4. Build the bus admittance matrix Y, then for each bus i:
13
+ Z_th,i = [Y^-1]_ii and SCMVA_i = S_base / |Z_th,i| (V = 1.0 pu)
14
+
15
+ v0.1 supports: bus, ext_grid, gen, line, trafo (two-winding), and
16
+ line/transformer switches. Other in-service elements raise a clear error
17
+ rather than being silently ignored.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+
24
+ import numpy as np
25
+
26
+ from .metrics import InputError
27
+
28
+ S_BASE_MVA = 100.0
29
+
30
+ # Element tables that would change the result but aren't modeled in v0.1.
31
+ UNSUPPORTED_TABLES = ("trafo3w", "impedance", "xward", "ward", "dcline", "tcsc", "ssc", "vsc")
32
+
33
+
34
+ def scmva_classical(net, bus_list: list[int], skip_isolated: bool = False
35
+ ) -> tuple[dict[int, float], list[str]]:
36
+ """Return {bus: SCMVA} using the classical flat-start method, plus notes.
37
+
38
+ Buses with no electrical path to any source cannot have a short-circuit
39
+ level. With skip_isolated=True (used by full-network scans) they are left
40
+ out and listed in the notes; otherwise requesting one raises InputError.
41
+ """
42
+ _check_supported(net)
43
+
44
+ live = [int(b) for b in net.bus.index[net.bus["in_service"]]]
45
+ reachable = connected_to_source(net, set(live))
46
+ isolated = [b for b in live if b not in reachable]
47
+ buses = [b for b in live if b in reachable]
48
+ pos = {b: k for k, b in enumerate(buses)}
49
+ n = len(buses)
50
+ y = np.zeros((n, n), dtype=complex)
51
+ notes = [
52
+ "Classical flat-start: 1.0 pu pre-fault voltage, no IEC c-factor or "
53
+ "correction factors; loads, shunts and line charging ignored; "
54
+ "transformers at nominal ratio.",
55
+ ]
56
+
57
+ open_lines, open_trafos = _open_switches(net)
58
+
59
+ # --- Series branches: lines -------------------------------------------
60
+ for idx, ln in net.line.iterrows():
61
+ if not ln["in_service"] or idx in open_lines:
62
+ continue
63
+ f, t = int(ln["from_bus"]), int(ln["to_bus"])
64
+ if f not in pos or t not in pos:
65
+ continue
66
+ vn = float(net.bus.at[f, "vn_kv"])
67
+ par = float(ln.get("parallel", 1) or 1)
68
+ z_ohm = complex(ln["r_ohm_per_km"], ln["x_ohm_per_km"]) * ln["length_km"] / par
69
+ z_pu = z_ohm * S_BASE_MVA / vn**2
70
+ _add_branch(y, pos[f], pos[t], z_pu, f"line {idx}")
71
+
72
+ # --- Series branches: two-winding transformers ------------------------
73
+ for idx, tr in net.trafo.iterrows():
74
+ if not tr["in_service"] or idx in open_trafos:
75
+ continue
76
+ hv, lv = int(tr["hv_bus"]), int(tr["lv_bus"])
77
+ if hv not in pos or lv not in pos:
78
+ continue
79
+ zk = tr["vk_percent"] / 100.0
80
+ rk = tr["vkr_percent"] / 100.0
81
+ if zk <= 0 or rk < 0 or rk > zk:
82
+ raise InputError(f"Transformer {idx} has invalid vk_percent/vkr_percent.")
83
+ xk = math.sqrt(zk**2 - rk**2)
84
+ par = float(tr.get("parallel", 1) or 1)
85
+ z_pu = complex(rk, xk) * (S_BASE_MVA / tr["sn_mva"]) / par
86
+ _add_branch(y, pos[hv], pos[lv], z_pu, f"transformer {idx}")
87
+
88
+ # --- Sources: external grids ------------------------------------------
89
+ n_sources = 0
90
+ for idx, eg in net.ext_grid.iterrows():
91
+ if not eg["in_service"]:
92
+ continue
93
+ b = int(eg["bus"])
94
+ s_sc = eg.get("s_sc_max_mva", np.nan)
95
+ rx = eg.get("rx_max", np.nan)
96
+ if not _is_pos(s_sc) or not _is_nonneg(rx):
97
+ raise InputError(
98
+ f"External grid {idx} needs s_sc_max_mva and rx_max for the "
99
+ "classical method."
100
+ )
101
+ z_mag = S_BASE_MVA / s_sc
102
+ x = z_mag / math.sqrt(1 + rx**2)
103
+ _add_shunt(y, pos[b], complex(rx * x, x), f"external grid {idx}")
104
+ n_sources += 1
105
+
106
+ # --- Sources: synchronous generators -----------------------------------
107
+ for idx, g in net.gen.iterrows():
108
+ if not g["in_service"]:
109
+ continue
110
+ b = int(g["bus"])
111
+ xdss, sn = g.get("xdss_pu", np.nan), g.get("sn_mva", np.nan)
112
+ if not _is_pos(xdss) or not _is_pos(sn):
113
+ raise InputError(
114
+ f"Generator {idx} needs xdss_pu and sn_mva (sub-transient data) "
115
+ "for the classical method."
116
+ )
117
+ vn = float(net.bus.at[b, "vn_kv"])
118
+ rdss_ohm = g.get("rdss_ohm", 0.0)
119
+ r_pu = 0.0 if not _is_nonneg(rdss_ohm) else rdss_ohm * S_BASE_MVA / vn**2
120
+ x_pu = xdss * S_BASE_MVA / sn
121
+ _add_shunt(y, pos[b], complex(r_pu, x_pu), f"generator {idx}")
122
+ n_sources += 1
123
+
124
+ if n_sources == 0:
125
+ raise InputError("The network has no in-service external grid or generator.")
126
+
127
+ try:
128
+ z_bus = np.linalg.inv(y)
129
+ except np.linalg.LinAlgError:
130
+ raise InputError(
131
+ "The network matrix could not be inverted. Part of the network is "
132
+ "probably isolated from every source."
133
+ ) from None
134
+
135
+ if isolated:
136
+ notes.append(f"{len(isolated)} bus(es) have no path to any source and were "
137
+ "excluded: " + ", ".join(str(b) for b in isolated[:20])
138
+ + (" ..." if len(isolated) > 20 else "") + ".")
139
+ values = {}
140
+ for b in bus_list:
141
+ if b in isolated:
142
+ if skip_isolated:
143
+ continue
144
+ raise InputError(f"Bus {b} has no path to any source (isolated part of the network).")
145
+ if b not in pos:
146
+ raise InputError(f"Bus {b} is out of service.")
147
+ z_th = abs(z_bus[pos[b], pos[b]])
148
+ if not math.isfinite(z_th) or z_th <= 0:
149
+ raise InputError(f"Bus {b} has no valid path to a source.")
150
+ values[b] = S_BASE_MVA / z_th
151
+ return values, notes
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+
156
+ def connected_to_source(net, live: set[int]) -> set[int]:
157
+ """Buses connected (through in-service lines/transformers) to a grid source or generator."""
158
+ open_lines, open_trafos = _open_switches(net, allow_bus_bus=True)
159
+ adj: dict[int, set[int]] = {b: set() for b in live}
160
+
161
+ def link(a, b):
162
+ if a in adj and b in adj:
163
+ adj[a].add(b)
164
+ adj[b].add(a)
165
+
166
+ for idx, ln in net.line.iterrows():
167
+ if ln["in_service"] and idx not in open_lines:
168
+ link(int(ln["from_bus"]), int(ln["to_bus"]))
169
+ for idx, tr in net.trafo.iterrows():
170
+ if tr["in_service"] and idx not in open_trafos:
171
+ link(int(tr["hv_bus"]), int(tr["lv_bus"]))
172
+ if "trafo3w" in net and len(net.trafo3w):
173
+ for _, t3 in net.trafo3w.iterrows():
174
+ if t3["in_service"]:
175
+ link(int(t3["hv_bus"]), int(t3["mv_bus"]))
176
+ link(int(t3["hv_bus"]), int(t3["lv_bus"]))
177
+ if "impedance" in net and len(net.impedance):
178
+ for _, im in net.impedance.iterrows():
179
+ if im["in_service"]:
180
+ link(int(im["from_bus"]), int(im["to_bus"]))
181
+ if "switch" in net and len(net.switch):
182
+ for _, sw in net.switch.iterrows():
183
+ if sw["et"] == "b" and sw["closed"]:
184
+ link(int(sw["bus"]), int(sw["element"]))
185
+
186
+ stack = []
187
+ for table in ("ext_grid", "gen"):
188
+ if table in net and len(net[table]):
189
+ stack += [int(b) for b in net[table].loc[net[table]["in_service"], "bus"] if int(b) in adj]
190
+ seen = set(stack)
191
+ while stack:
192
+ b = stack.pop()
193
+ for n in adj[b]:
194
+ if n not in seen:
195
+ seen.add(n)
196
+ stack.append(n)
197
+ return seen
198
+
199
+
200
+ def _add_branch(y, i, j, z_pu, label):
201
+ if abs(z_pu) == 0:
202
+ raise InputError(f"{label} has zero impedance; merge the buses instead.")
203
+ adm = 1.0 / z_pu
204
+ y[i, i] += adm
205
+ y[j, j] += adm
206
+ y[i, j] -= adm
207
+ y[j, i] -= adm
208
+
209
+
210
+ def _add_shunt(y, i, z_pu, label):
211
+ if abs(z_pu) == 0:
212
+ raise InputError(f"{label} has zero impedance.")
213
+ y[i, i] += 1.0 / z_pu
214
+
215
+
216
+ def _open_switches(net, allow_bus_bus: bool = False):
217
+ """Lines and transformers disconnected by an open switch."""
218
+ open_lines, open_trafos = set(), set()
219
+ if "switch" not in net or len(net.switch) == 0:
220
+ return open_lines, open_trafos
221
+ for _, sw in net.switch.iterrows():
222
+ if sw["et"] == "b" and sw["closed"] and not allow_bus_bus:
223
+ raise InputError(
224
+ "Closed bus-bus switches are not supported by the classical "
225
+ "method in v0.1. Merge those buses in the model."
226
+ )
227
+ if not sw["closed"]:
228
+ if sw["et"] == "l":
229
+ open_lines.add(int(sw["element"]))
230
+ elif sw["et"] == "t":
231
+ open_trafos.add(int(sw["element"]))
232
+ return open_lines, open_trafos
233
+
234
+
235
+ def _check_supported(net):
236
+ for table in UNSUPPORTED_TABLES:
237
+ if table in net and len(net[table]) > 0 and "in_service" in net[table]:
238
+ if bool(net[table]["in_service"].any()):
239
+ raise InputError(
240
+ f"The network contains in-service '{table}' elements, which "
241
+ "the classical method does not model in v0.1."
242
+ )
243
+
244
+
245
+ def _is_pos(v) -> bool:
246
+ try:
247
+ return v is not None and math.isfinite(float(v)) and float(v) > 0
248
+ except (TypeError, ValueError):
249
+ return False
250
+
251
+
252
+ def _is_nonneg(v) -> bool:
253
+ try:
254
+ return v is not None and math.isfinite(float(v)) and float(v) >= 0
255
+ except (TypeError, ValueError):
256
+ return False