gridpf 0.3.0__tar.gz → 0.3.2__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 (58) hide show
  1. {gridpf-0.3.0/gridpf.egg-info → gridpf-0.3.2}/PKG-INFO +1 -1
  2. gridpf-0.3.2/gridpf/_engine.py +592 -0
  3. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/_version.py +3 -3
  4. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/algebra/sbus.py +71 -27
  5. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/algebra/ybus.py +14 -22
  6. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/contract/runtime.py +4 -1
  7. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/contract/types.py +11 -16
  8. gridpf-0.3.2/gridpf/solvers/_common.py +50 -0
  9. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/solvers/dc_pf.py +15 -14
  10. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/solvers/gauss_seidel.py +38 -33
  11. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/solvers/newton_raphson.py +47 -52
  12. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/solvers/q_lims.py +65 -37
  13. {gridpf-0.3.0 → gridpf-0.3.2/gridpf.egg-info}/PKG-INFO +1 -1
  14. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf.egg-info/SOURCES.txt +3 -0
  15. gridpf-0.3.2/gridpf.egg-info/scm_file_list.json +50 -0
  16. gridpf-0.3.2/gridpf.egg-info/scm_version.json +8 -0
  17. {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_gauss_seidel.py +27 -0
  18. {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_newton_raphson.py +4 -3
  19. {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_robustness.py +1 -5
  20. gridpf-0.3.0/gridpf/_engine.py +0 -478
  21. {gridpf-0.3.0 → gridpf-0.3.2}/.github/workflows/ci.yml +0 -0
  22. {gridpf-0.3.0 → gridpf-0.3.2}/.github/workflows/release.yml +0 -0
  23. {gridpf-0.3.0 → gridpf-0.3.2}/.gitignore +0 -0
  24. {gridpf-0.3.0 → gridpf-0.3.2}/.pre-commit-config.yaml +0 -0
  25. {gridpf-0.3.0 → gridpf-0.3.2}/CHANGELOG.md +0 -0
  26. {gridpf-0.3.0 → gridpf-0.3.2}/LICENSE +0 -0
  27. {gridpf-0.3.0 → gridpf-0.3.2}/Makefile +0 -0
  28. {gridpf-0.3.0 → gridpf-0.3.2}/README.md +0 -0
  29. {gridpf-0.3.0 → gridpf-0.3.2}/build_cython.py +0 -0
  30. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/__init__.py +0 -0
  31. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/__pyinstaller/__init__.py +0 -0
  32. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/__pyinstaller/hook-gridpf.py +0 -0
  33. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/algebra/__init__.py +0 -0
  34. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/algebra/jacobian.py +0 -0
  35. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/contract/__init__.py +0 -0
  36. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/contract/serialize.py +0 -0
  37. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/contract/validate.py +0 -0
  38. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/contract/version.py +0 -0
  39. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/py.typed +0 -0
  40. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/solvers/__init__.py +0 -0
  41. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf.egg-info/dependency_links.txt +0 -0
  42. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf.egg-info/entry_points.txt +0 -0
  43. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf.egg-info/requires.txt +0 -0
  44. {gridpf-0.3.0 → gridpf-0.3.2}/gridpf.egg-info/top_level.txt +0 -0
  45. {gridpf-0.3.0 → gridpf-0.3.2}/pyproject.toml +0 -0
  46. {gridpf-0.3.0 → gridpf-0.3.2}/setup.cfg +0 -0
  47. {gridpf-0.3.0 → gridpf-0.3.2}/tests/__init__.py +0 -0
  48. {gridpf-0.3.0 → gridpf-0.3.2}/tests/conftest.py +0 -0
  49. {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_contract.py +0 -0
  50. {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_contract_types.py +0 -0
  51. {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_load_voltage.py +0 -0
  52. {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_numpy_only_runtime.py +0 -0
  53. {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_q_lims.py +0 -0
  54. {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_sbus.py +0 -0
  55. {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_solve.py +0 -0
  56. {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_v_plausible.py +0 -0
  57. {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_version.py +0 -0
  58. {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_ybus.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridpf
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Power system Power Flow: pure-Python Newton-Raphson / Gauss-Seidel on numpy/scipy, input via a versioned data contract (npz).
5
5
  Author-email: Evgeny Istomin <gena@alpisto.eu>
6
6
  License: MIT
@@ -0,0 +1,592 @@
1
+ """Ядро движка установившегося режима — внешний цикл (model-free).
2
+
3
+ Извлечено из адаптерного ``solve_pf`` без какой-либо привязки к классу модели
4
+ сети: на вход — собранный адаптером :class:`~gridpf.contract.types.PFInput`
5
+ (opaque p.u.) и опциональный тёплый старт ``init_v``; на выход —
6
+ :class:`~gridpf.contract.types.PFResult`. Запись результата обратно во внешнюю
7
+ модель — забота адаптера, движок ничего не пишет.
8
+
9
+ Логика: GS warm-start → Newton-Raphson + внешний Q-lim цикл → DC-fallback →
10
+ soft-fallback к классификации без переключений. Setpoints управляемых узлов
11
+ (``net.bus_v_set`` / ``net.bus_va_set``) клампятся ПОВЕРХ стартового
12
+ приближения (Rule №4).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from dataclasses import replace as _dc_replace
19
+ from typing import TYPE_CHECKING, get_args
20
+
21
+ import numpy as np
22
+ from scipy.sparse import coo_matrix
23
+ from scipy.sparse.csgraph import connected_components
24
+
25
+ from gridpf.algebra.sbus import build_sbus, classify_buses, compute_sbus, q_load_at
26
+ from gridpf.algebra.ybus import build_ybus
27
+ from gridpf.contract.types import BASE_MVA, Method, PFInput, PFOptions, PFResult
28
+ from gridpf.solvers.dc_pf import dc_powerflow
29
+ from gridpf.solvers.gauss_seidel import gauss_seidel
30
+ from gridpf.solvers.newton_raphson import NRResult, newton_raphson
31
+ from gridpf.solvers.q_lims import enforce_q_limits, q_limit_violations
32
+
33
+
34
+ if TYPE_CHECKING:
35
+ from scipy.sparse import csr_matrix
36
+
37
+
38
+ # Extra slack on top of q_lim_tol when *reporting* violations: buses that
39
+ # enforcement pinned exactly at their limit must not be counted as violators
40
+ # because of the final NR residual.
41
+ _Q_VIOLATION_EPS = 1e-6
42
+
43
+ # Final mismatch above this norm reads as a diverged (collapsing) voltage
44
+ # solution rather than a mere max-iterations stall.
45
+ _VOLTAGE_COLLAPSE_MISMATCH = 1e3
46
+
47
+
48
+ def _has_orphan_component(net: PFInput) -> bool:
49
+ """Есть ли связная компонента без slack-узла среди активных узлов?"""
50
+ n = net.n_bus
51
+ # Build the undirected adjacency over from_idx/to_idx branches as a sparse
52
+ # graph and label every bus with its connected component. With no edges each
53
+ # bus is its own component, which the labelling handles naturally.
54
+ adjacency = coo_matrix(
55
+ (np.ones(net.n_branch, dtype=np.int8), (net.from_idx, net.to_idx)),
56
+ shape=(n, n),
57
+ )
58
+ _, labels = connected_components(adjacency, directed=False)
59
+ slack_labels = set(labels[net.bus_type == 2].tolist())
60
+ # Orphaned iff at least one bus lives in a component that holds no slack bus.
61
+ return bool(np.any(~np.isin(labels, list(slack_labels))))
62
+
63
+
64
+ def _flat_start(net: PFInput) -> np.ndarray:
65
+ """Сформировать flat-старт: ``|V|=1, δ=0`` для всех шин.
66
+
67
+ Заданные модули/углы управляемых узлов накладываются отдельно через
68
+ :func:`_apply_setpoints`.
69
+ """
70
+ n = net.n_bus
71
+ Vm = np.ones(n, dtype=np.float64)
72
+ Va = np.zeros(n, dtype=np.float64)
73
+ result: np.ndarray = Vm * np.exp(1j * Va)
74
+ return result
75
+
76
+
77
+ def _apply_setpoints(net: PFInput, V0: np.ndarray) -> np.ndarray:
78
+ """Наложить заданные модули/углы управляемых узлов на ``V0``.
79
+
80
+ Читает материализованные адаптером ``net.bus_v_set`` (|V| p.u.) и
81
+ ``net.bus_va_set`` (угол, рад): где значение не ``NaN`` — оно замещает
82
+ соответствующую компоненту ``V0``. Поля ``None`` → старт не меняется.
83
+ """
84
+ Vm = np.abs(V0).copy()
85
+ Va = np.angle(V0).copy()
86
+ if net.bus_v_set is not None:
87
+ mask = ~np.isnan(net.bus_v_set)
88
+ Vm[mask] = net.bus_v_set[mask]
89
+ if net.bus_va_set is not None:
90
+ mask = ~np.isnan(net.bus_va_set)
91
+ Va[mask] = net.bus_va_set[mask]
92
+ result: np.ndarray = Vm * np.exp(1j * Va)
93
+ return result
94
+
95
+
96
+ @dataclass
97
+ class _NRState:
98
+ """Voltage / iteration / convergence accumulator across solver passes.
99
+
100
+ Every NR pass in the engine folds its outcome in through :meth:`absorb`,
101
+ so the "run solver, unpack four fields" boilerplate lives in one place.
102
+ """
103
+
104
+ V: np.ndarray
105
+ iters_nr: int = 0
106
+ mismatch: float = field(default=float("nan"))
107
+ converged: bool = False
108
+
109
+ def absorb(self, res: NRResult) -> None:
110
+ """Fold one solver pass into the accumulated state."""
111
+ self.V = res.V
112
+ self.iters_nr += res.iterations
113
+ self.mismatch = res.mismatch_max
114
+ self.converged = res.converged
115
+
116
+
117
+ @dataclass
118
+ class _Classification:
119
+ """Bus classification plus the state the Q-limit loop swaps alongside it.
120
+
121
+ A PV→PQ swap changes ``pv``/``pq``/``Sbus``/``locked_lim`` and (with
122
+ voltage-dependent load) ``net.bus_q_gen`` together; bundling them makes
123
+ snapshot/rollback a single-object operation instead of five parallel
124
+ variables.
125
+ """
126
+
127
+ pv: np.ndarray
128
+ pq: np.ndarray
129
+ Sbus: np.ndarray
130
+ locked_lim: np.ndarray
131
+ net: PFInput
132
+
133
+ def restore(self, snap: _Classification) -> None:
134
+ """Roll back to a previously taken snapshot."""
135
+ self.pv = snap.pv
136
+ self.pq = snap.pq
137
+ self.Sbus = snap.Sbus
138
+ self.locked_lim = snap.locked_lim
139
+ self.net = snap.net
140
+
141
+
142
+ def _run_nr(
143
+ state: _NRState,
144
+ cls: _Classification,
145
+ Ybus: csr_matrix,
146
+ ref: np.ndarray,
147
+ *,
148
+ options: PFOptions,
149
+ use_load: bool,
150
+ ) -> None:
151
+ """One Newton-Raphson pass from ``state.V`` under the current classification."""
152
+ state.absorb(
153
+ newton_raphson(
154
+ Ybus,
155
+ cls.Sbus,
156
+ state.V,
157
+ ref,
158
+ cls.pv,
159
+ cls.pq,
160
+ tol=options.tol,
161
+ max_iter=options.max_iter_nr,
162
+ net=cls.net,
163
+ voltage_dependent_load=use_load,
164
+ )
165
+ )
166
+
167
+
168
+ def _nr_with_q_limits(
169
+ state: _NRState,
170
+ cls: _Classification,
171
+ Ybus: csr_matrix,
172
+ ref: np.ndarray,
173
+ *,
174
+ options: PFOptions,
175
+ use_load: bool,
176
+ can_enforce: bool,
177
+ pv_original: np.ndarray,
178
+ v_set_arr: np.ndarray,
179
+ ) -> tuple[int, PFInput]:
180
+ """Newton-Raphson plus the outer PV→PQ enforcement loop.
181
+
182
+ Mutates ``state`` and ``cls`` in place. Without ``can_enforce`` this is
183
+ exactly one NR pass. Returns ``(q_lim_swaps, pre_swap_net)`` where
184
+ ``pre_swap_net`` is the network snapshot taken before the last committed
185
+ swap — the soft fallback uses it to restore ``bus_q_gen``.
186
+ """
187
+ q_min = cls.net.bus_q_min
188
+ q_max = cls.net.bus_q_max
189
+ snap = _dc_replace(cls)
190
+ outer_done = False
191
+ q_lim_swaps = 0
192
+
193
+ for _swap_iter in range(options.max_q_lim_swaps + 1):
194
+ _run_nr(state, cls, Ybus, ref, options=options, use_load=use_load)
195
+
196
+ if not can_enforce:
197
+ break
198
+ if not state.converged:
199
+ # NR не сошёлся при текущей классификации. Откатываем последнее
200
+ # переключение и пробуем добить решение без него — типично
201
+ # «overshoot» при массовом swap'е делает следующий NR
202
+ # неустойчивым.
203
+ cls.restore(snap)
204
+ _run_nr(state, cls, Ybus, ref, options=options, use_load=use_load)
205
+ break
206
+
207
+ assert q_min is not None and q_max is not None # защищено can_enforce
208
+ qlim_res = enforce_q_limits(
209
+ Ybus,
210
+ state.V,
211
+ cls.Sbus,
212
+ cls.pv,
213
+ cls.pq,
214
+ q_min,
215
+ q_max,
216
+ v_set_arr,
217
+ cls.locked_lim,
218
+ pv_original=pv_original,
219
+ allow_pq_to_pv=options.allow_pq_to_pv,
220
+ top_k=options.q_lim_top_k,
221
+ q_lim_tol=options.q_lim_tol,
222
+ # net передаётся ВСЕГДА: генераторная семантика лимитов
223
+ # (Q_gen = Q_calc + Q_load) не зависит от активности СХН — при
224
+ # неактивной СХН Q_load константна (= bus_q_load), но вычитать
225
+ # её обязательно (см. enforce_q_limits).
226
+ net=cls.net,
227
+ voltage_dependent_load=use_load,
228
+ )
229
+ if not qlim_res.changed:
230
+ outer_done = True
231
+ break
232
+
233
+ # Сохраняем «предыдущее хорошее» состояние перед коммитом swap'а
234
+ snap = _dc_replace(cls)
235
+ cls.pv = qlim_res.pv
236
+ cls.pq = qlim_res.pq
237
+ cls.locked_lim = qlim_res.locked_lim
238
+ # При активной СХН: фиксируем bus_q_gen на лимите, далее Sbus
239
+ # пересчитывается compute_sbus каждую NR-итерацию. Без СХН —
240
+ # legacy: Sbus.imag = Q_lim напрямую.
241
+ if use_load and qlim_res.bus_q_gen_new is not None:
242
+ cls.net = _dc_replace(cls.net, bus_q_gen=qlim_res.bus_q_gen_new)
243
+ cls.Sbus = compute_sbus(cls.net, state.V, voltage_dependent=True)
244
+ else:
245
+ cls.Sbus = qlim_res.Sbus
246
+ q_lim_swaps += len(qlim_res.actions)
247
+ state.converged = False # требуется ещё один прогон NR с новой классификацией
248
+
249
+ # Если outer-loop достиг лимита (а не отработал break по changed=False),
250
+ # делаем финальный NR с уже коммитнутой классификацией — даёт шанс
251
+ # «дотянуть» сходимость на тех моделях, где enforcement-цикл сошёлся
252
+ # фактически, но формально не успел подтвердиться.
253
+ if can_enforce and not outer_done and not state.converged:
254
+ _run_nr(state, cls, Ybus, ref, options=options, use_load=use_load)
255
+
256
+ return q_lim_swaps, snap.net
257
+
258
+
259
+ def _soft_fallback(
260
+ state: _NRState,
261
+ cls: _Classification,
262
+ Ybus: csr_matrix,
263
+ *,
264
+ options: PFOptions,
265
+ use_load: bool,
266
+ pre_swap_net: PFInput,
267
+ ) -> None:
268
+ """Retry with the *original* classification when enforcement failed.
269
+
270
+ Guarantees ``enforce_q_lims=True`` is never worse than the baseline run;
271
+ remaining violations are reported via ``PFResult.q_violations``. Mutates
272
+ ``state``/``cls`` only when the fallback NR converges (a failed fallback
273
+ leaves the previous best state, iteration counts included, untouched).
274
+ """
275
+ # Восстанавливаем исходную классификацию узлов и оригинальный
276
+ # bus_q_gen (без swap-фиксаций); СХН остаётся включённой.
277
+ net_orig = (
278
+ _dc_replace(cls.net, bus_q_gen=pre_swap_net.bus_q_gen)
279
+ if pre_swap_net is not cls.net
280
+ else cls.net
281
+ )
282
+ ref_orig, pv_orig_arr, pq_orig_arr = classify_buses(net_orig.bus_type)
283
+ sbus_orig = (
284
+ compute_sbus(net_orig, _flat_start(net_orig), voltage_dependent=True)
285
+ if use_load
286
+ else build_sbus(net_orig)
287
+ )
288
+ # Для устойчивости — повторный flat + GS warm-start.
289
+ V_fb = _apply_setpoints(net_orig, _flat_start(net_orig))
290
+ if options.method in ("gs", "gs+nr"):
291
+ gs_fb = gauss_seidel(
292
+ Ybus,
293
+ sbus_orig,
294
+ V_fb,
295
+ ref_orig,
296
+ pv_orig_arr,
297
+ pq_orig_arr,
298
+ tol=options.gs_tol,
299
+ max_iter=options.max_iter_gs,
300
+ net=net_orig,
301
+ voltage_dependent_load=use_load,
302
+ )
303
+ V_fb = gs_fb.V
304
+ nr_fb = newton_raphson(
305
+ Ybus,
306
+ sbus_orig,
307
+ V_fb,
308
+ ref_orig,
309
+ pv_orig_arr,
310
+ pq_orig_arr,
311
+ tol=options.tol,
312
+ max_iter=options.max_iter_nr,
313
+ net=net_orig,
314
+ voltage_dependent_load=use_load,
315
+ )
316
+ if nr_fb.converged:
317
+ state.absorb(nr_fb)
318
+ cls.net = net_orig
319
+ cls.pv = pv_orig_arr
320
+
321
+
322
+ def _dc_fallback(
323
+ state: _NRState,
324
+ cls: _Classification,
325
+ Ybus: csr_matrix,
326
+ *,
327
+ options: PFOptions,
328
+ use_load: bool,
329
+ ) -> None:
330
+ """DC warm-start retry: linearized angles, then one more NR pass.
331
+
332
+ Mutates ``state``/``cls`` only when the retry converges.
333
+ """
334
+ ref_d, pv_d, pq_d = classify_buses(cls.net.bus_type)
335
+ sbus_d = build_sbus(cls.net)
336
+ delta_dc = dc_powerflow(
337
+ cls.net,
338
+ P_inj=sbus_d.real,
339
+ ref=ref_d,
340
+ pv=pv_d,
341
+ pq=pq_d,
342
+ )
343
+ # Стартовое V для NR: модули как в flat+setpoints, углы из DC.
344
+ V_dc = _apply_setpoints(cls.net, _flat_start(cls.net))
345
+ V_dc = np.abs(V_dc) * np.exp(1j * delta_dc)
346
+ nr_dc = newton_raphson(
347
+ Ybus,
348
+ sbus_d,
349
+ V_dc,
350
+ ref_d,
351
+ pv_d,
352
+ pq_d,
353
+ tol=options.tol,
354
+ max_iter=options.max_iter_nr,
355
+ net=cls.net,
356
+ voltage_dependent_load=use_load,
357
+ )
358
+ if nr_dc.converged:
359
+ state.absorb(nr_dc)
360
+ cls.pv = pv_d
361
+
362
+
363
+ def _branch_flows(
364
+ net: PFInput, V: np.ndarray, Yf: csr_matrix, Yt: csr_matrix
365
+ ) -> tuple[np.ndarray, np.ndarray]:
366
+ """Branch power flows in MVA (``S_base × p.u.``) for both branch ends."""
367
+ if net.n_branch == 0:
368
+ empty = np.empty(0, dtype=np.complex128)
369
+ return empty, empty
370
+ s_from = V[net.from_idx] * np.conj(Yf @ V) * BASE_MVA
371
+ s_to = V[net.to_idx] * np.conj(Yt @ V) * BASE_MVA
372
+ return s_from, s_to
373
+
374
+
375
+ def _count_q_violations(
376
+ net: PFInput,
377
+ V: np.ndarray,
378
+ Ybus: csr_matrix,
379
+ pv_original: np.ndarray,
380
+ *,
381
+ q_lim_tol: float,
382
+ use_load: bool,
383
+ ) -> int:
384
+ """Count originally-PV buses violating generator Q-limits at the solution.
385
+
386
+ Generator semantics, shared with :func:`enforce_q_limits`: the limit is
387
+ compared against ``Q_gen = Q_inj + Q_load(|V|)``, not the net injection —
388
+ otherwise a loaded generator bus reports a false violation. The deadband
389
+ is widened by :data:`_Q_VIOLATION_EPS` so reporting stays consistent with
390
+ what enforcement pinned at the limit.
391
+ """
392
+ q_min, q_max = net.bus_q_min, net.bus_q_max
393
+ assert q_min is not None and q_max is not None # guarded by can_enforce
394
+ S_calc = V * np.conj(Ybus @ V)
395
+ q_gen = S_calc.imag + q_load_at(net, V, voltage_dependent=use_load)
396
+ return len(
397
+ q_limit_violations(q_gen, q_min, q_max, pv_original, tol=q_lim_tol + _Q_VIOLATION_EPS)
398
+ )
399
+
400
+
401
+ def _classify_failure(
402
+ net: PFInput,
403
+ mismatch: float,
404
+ *,
405
+ implausible_v_nodes: int,
406
+ q_violations_reported: bool,
407
+ ) -> str:
408
+ """Map a non-converged final state to a short ``failure_reason`` code."""
409
+ if implausible_v_nodes:
410
+ return "implausible_voltage"
411
+ if _has_orphan_component(net):
412
+ return "no_slack_component"
413
+ if not np.isfinite(mismatch):
414
+ return "singular_jacobian"
415
+ if mismatch > _VOLTAGE_COLLAPSE_MISMATCH:
416
+ return "voltage_collapse"
417
+ if q_violations_reported:
418
+ return "infeasible_q_lims"
419
+ return "max_iter_reached"
420
+
421
+
422
+ def run_powerflow(
423
+ net: PFInput,
424
+ options: PFOptions,
425
+ *,
426
+ init_v: np.ndarray | None = None,
427
+ ) -> PFResult:
428
+ """Рассчитать установившийся режим для ``net`` (model-free).
429
+
430
+ Args:
431
+ net: opaque p.u.-представление сети.
432
+ options: опции движка.
433
+ init_v: ``(n_bus,)`` complex — тёплый старт (например, из результатов
434
+ прошлого прогона, выровненный по ``net.bus_ids``). ``None`` →
435
+ flat-старт (``|V|=1, δ=0``), бит-в-бит с холодным запуском.
436
+
437
+ Returns:
438
+ :class:`PFResult` со всеми итоговыми величинами. Запись обратно —
439
+ не делается (забота адаптера).
440
+
441
+ Raises:
442
+ ValueError: если метод неизвестен или в сети нет slack-узла.
443
+
444
+ Note:
445
+ Сингулярный якобиан Newton НЕ бросает исключение: scipy на вырожденной
446
+ матрице возвращает NaN-вектор, итерация прерывается с последним конечным
447
+ ``V`` → ``PFResult(converged=False, failure_reason="singular_jacobian")``.
448
+ """
449
+ method = options.method
450
+ allowed_methods = get_args(Method)
451
+ if method not in allowed_methods:
452
+ allowed = ", ".join(repr(m) for m in allowed_methods)
453
+ raise ValueError(f"Неизвестный method={method!r}; ожидался один из: {allowed}.")
454
+
455
+ Ybus, Yf, Yt = build_ybus(net)
456
+ Sbus = build_sbus(net)
457
+ ref, pv, pq = classify_buses(net.bus_type)
458
+
459
+ # Стартовое приближение: тёплый старт init_v (выровнен адаптером по
460
+ # net.bus_ids) либо flat. None → flat (бит-в-бит).
461
+ # Setpoints PV/slack клампятся ПОВЕРХ старта (Rule №4): заданный модуль/угол
462
+ # всегда побеждает тёплый старт на управляемых узлах.
463
+ V0 = init_v if init_v is not None else _flat_start(net)
464
+ V0 = _apply_setpoints(net, V0)
465
+
466
+ # Начальные параметры enforce_q_lims (если выключено — массивы не используются).
467
+ pv_original = pv.copy()
468
+ v_set_arr = np.full(net.n_bus, np.nan, dtype=np.float64)
469
+ v_set_arr[pv] = np.abs(V0[pv])
470
+ can_enforce = options.enforce_q_lims and net.bus_q_min is not None and net.bus_q_max is not None
471
+
472
+ # СХН активна, если: пользователь не отключил, в сети есть нетривиальные
473
+ # коэффициенты и доступны базовые поля. Совместима с enforce_q_lims:
474
+ # внешний цикл swap'ов фиксирует bus_q_gen на Q-лимите, дальнейшие NR
475
+ # пересчитывают Sbus через compute_sbus(net, V).
476
+ use_load_v = options.use_load_voltage_dependency and net.has_voltage_dependent_load
477
+ # Перестраиваем Sbus с учётом СХН на flat-старте — чтобы GS не стартовал
478
+ # с устаревшей константой.
479
+ if use_load_v:
480
+ Sbus = compute_sbus(net, V0, voltage_dependent=True)
481
+
482
+ state = _NRState(V=V0)
483
+ cls = _Classification(
484
+ pv=pv,
485
+ pq=pq,
486
+ Sbus=Sbus,
487
+ locked_lim=np.full(net.n_bus, np.nan, dtype=np.float64),
488
+ net=net,
489
+ )
490
+ iters_gs = 0
491
+ q_lim_swaps = 0
492
+
493
+ # GS warm-start выполняется один раз — переключения PV/PQ затрагивают только NR-фазу.
494
+ if method in ("gs", "gs+nr"):
495
+ gs_res = gauss_seidel(
496
+ Ybus,
497
+ Sbus,
498
+ V0,
499
+ ref,
500
+ pv,
501
+ pq,
502
+ tol=options.tol if method == "gs" else options.gs_tol,
503
+ max_iter=options.max_iter_gs,
504
+ net=net,
505
+ voltage_dependent_load=use_load_v,
506
+ )
507
+ state.V = gs_res.V
508
+ iters_gs = gs_res.iterations
509
+ state.mismatch = gs_res.mismatch_max
510
+ if method == "gs":
511
+ state.converged = gs_res.converged
512
+
513
+ # Внешний цикл по Q-лимитам. Без enforce_q_lims — ровно одна итерация NR.
514
+ if method in ("nr", "gs+nr"):
515
+ q_lim_swaps, pre_swap_net = _nr_with_q_limits(
516
+ state,
517
+ cls,
518
+ Ybus,
519
+ ref,
520
+ options=options,
521
+ use_load=use_load_v,
522
+ can_enforce=can_enforce,
523
+ pv_original=pv_original,
524
+ v_set_arr=v_set_arr,
525
+ )
526
+ # Soft-fallback: если ничего не помогло, отказываемся от enforcement
527
+ # и возвращаемся к классификации **без переключений**.
528
+ if can_enforce and not state.converged:
529
+ _soft_fallback(
530
+ state,
531
+ cls,
532
+ Ybus,
533
+ options=options,
534
+ use_load=use_load_v,
535
+ pre_swap_net=pre_swap_net,
536
+ )
537
+
538
+ # DC warm-start fallback: если NR-расчёт (с/без enforcement) разошёлся,
539
+ # пробуем линеаризованное DC-приближение для углов и стартуем NR ещё раз.
540
+ if options.dc_fallback and not state.converged and method in ("nr", "gs+nr"):
541
+ _dc_fallback(state, cls, Ybus, options=options, use_load=use_load_v)
542
+
543
+ V = state.V
544
+ s_from, s_to = _branch_flows(cls.net, V, Yf, Yt)
545
+
546
+ q_violations = 0
547
+ if can_enforce and state.converged:
548
+ q_violations = _count_q_violations(
549
+ cls.net,
550
+ V,
551
+ Ybus,
552
+ pv_original,
553
+ q_lim_tol=options.q_lim_tol,
554
+ use_load=use_load_v,
555
+ )
556
+
557
+ # Sanity-гейт правдоподобия: NR может численно сойтись (mismatch < tol)
558
+ # в нижнюю ветвь PV-кривой (|V| ~ 0.1–0.3 p.u. при несогласованных
559
+ # инжекциях) — физически это несошедшийся режим.
560
+ implausible_v_nodes = 0
561
+ if state.converged and options.v_plausible_range is not None:
562
+ v_lo, v_hi = options.v_plausible_range
563
+ vm_final = np.abs(V)
564
+ implausible_v_nodes = int(np.count_nonzero((vm_final < v_lo) | (vm_final > v_hi)))
565
+ if implausible_v_nodes:
566
+ state.converged = False
567
+
568
+ failure_reason = ""
569
+ if not state.converged:
570
+ failure_reason = _classify_failure(
571
+ cls.net,
572
+ state.mismatch,
573
+ implausible_v_nodes=implausible_v_nodes,
574
+ q_violations_reported=can_enforce and q_violations > 0,
575
+ )
576
+
577
+ return PFResult(
578
+ converged=state.converged,
579
+ iterations_gs=iters_gs,
580
+ iterations_nr=state.iters_nr,
581
+ V=V,
582
+ bus_ids=cls.net.bus_ids,
583
+ S_from=s_from,
584
+ S_to=s_to,
585
+ mismatch_max=state.mismatch,
586
+ method=method,
587
+ q_lim_swaps=q_lim_swaps,
588
+ q_violations=q_violations,
589
+ failure_reason=failure_reason,
590
+ implausible_v_nodes=implausible_v_nodes,
591
+ voltage_dependent_load_active=use_load_v,
592
+ )
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.3.0'
22
- __version_tuple__ = version_tuple = (0, 3, 0)
21
+ __version__ = version = '0.3.2'
22
+ __version_tuple__ = version_tuple = (0, 3, 2)
23
23
 
24
- __commit_id__ = commit_id = 'g1f95d817b'
24
+ __commit_id__ = commit_id = 'gd41f7c0e4'