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.
- {gridpf-0.3.0/gridpf.egg-info → gridpf-0.3.2}/PKG-INFO +1 -1
- gridpf-0.3.2/gridpf/_engine.py +592 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/_version.py +3 -3
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/algebra/sbus.py +71 -27
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/algebra/ybus.py +14 -22
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/contract/runtime.py +4 -1
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/contract/types.py +11 -16
- gridpf-0.3.2/gridpf/solvers/_common.py +50 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/solvers/dc_pf.py +15 -14
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/solvers/gauss_seidel.py +38 -33
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/solvers/newton_raphson.py +47 -52
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/solvers/q_lims.py +65 -37
- {gridpf-0.3.0 → gridpf-0.3.2/gridpf.egg-info}/PKG-INFO +1 -1
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf.egg-info/SOURCES.txt +3 -0
- gridpf-0.3.2/gridpf.egg-info/scm_file_list.json +50 -0
- gridpf-0.3.2/gridpf.egg-info/scm_version.json +8 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_gauss_seidel.py +27 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_newton_raphson.py +4 -3
- {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_robustness.py +1 -5
- gridpf-0.3.0/gridpf/_engine.py +0 -478
- {gridpf-0.3.0 → gridpf-0.3.2}/.github/workflows/ci.yml +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/.github/workflows/release.yml +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/.gitignore +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/.pre-commit-config.yaml +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/CHANGELOG.md +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/LICENSE +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/Makefile +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/README.md +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/build_cython.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/__init__.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/__pyinstaller/__init__.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/__pyinstaller/hook-gridpf.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/algebra/__init__.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/algebra/jacobian.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/contract/__init__.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/contract/serialize.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/contract/validate.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/contract/version.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/py.typed +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf/solvers/__init__.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf.egg-info/dependency_links.txt +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf.egg-info/entry_points.txt +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf.egg-info/requires.txt +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/gridpf.egg-info/top_level.txt +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/pyproject.toml +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/setup.cfg +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/tests/__init__.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/tests/conftest.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_contract.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_contract_types.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_load_voltage.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_numpy_only_runtime.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_q_lims.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_sbus.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_solve.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_v_plausible.py +0 -0
- {gridpf-0.3.0 → gridpf-0.3.2}/tests/test_version.py +0 -0
- {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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 3,
|
|
21
|
+
__version__ = version = '0.3.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 2)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'gd41f7c0e4'
|