gridpf 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.
- gridpf/__init__.py +60 -0
- gridpf/__pyinstaller/__init__.py +5 -0
- gridpf/__pyinstaller/hook-gridpf.py +5 -0
- gridpf/_engine.py +452 -0
- gridpf/_version.py +24 -0
- gridpf/algebra/__init__.py +1 -0
- gridpf/algebra/jacobian.py +115 -0
- gridpf/algebra/sbus.py +151 -0
- gridpf/algebra/ybus.py +139 -0
- gridpf/contract/__init__.py +67 -0
- gridpf/contract/runtime.py +49 -0
- gridpf/contract/serialize.py +71 -0
- gridpf/contract/types.py +202 -0
- gridpf/contract/validate.py +117 -0
- gridpf/contract/version.py +93 -0
- gridpf/py.typed +0 -0
- gridpf/solvers/__init__.py +1 -0
- gridpf/solvers/dc_pf.py +101 -0
- gridpf/solvers/gauss_seidel.py +152 -0
- gridpf/solvers/newton_raphson.py +201 -0
- gridpf/solvers/q_lims.py +226 -0
- gridpf-0.1.0.dist-info/METADATA +171 -0
- gridpf-0.1.0.dist-info/RECORD +27 -0
- gridpf-0.1.0.dist-info/WHEEL +5 -0
- gridpf-0.1.0.dist-info/entry_points.txt +2 -0
- gridpf-0.1.0.dist-info/licenses/LICENSE +93 -0
- gridpf-0.1.0.dist-info/top_level.txt +1 -0
gridpf/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""gridpf — расчёт установившегося режима (Power Flow).
|
|
2
|
+
|
|
3
|
+
Чистый движок power-flow на numpy/scipy. Вход — версионированный
|
|
4
|
+
opaque-контракт :class:`PFInput` (p.u.), не привязанный к классу модели сети,
|
|
5
|
+
формату файла или единицам источника. Публичный API собран здесь; обзор — в
|
|
6
|
+
README, детали — в docstring'ах функций.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
from gridpf.algebra.ybus import build_ybus
|
|
12
|
+
from gridpf.contract import (
|
|
13
|
+
BASE_MVA,
|
|
14
|
+
CONTRACT_VERSION,
|
|
15
|
+
PQ,
|
|
16
|
+
PV,
|
|
17
|
+
SLACK,
|
|
18
|
+
Method,
|
|
19
|
+
PFContractValidationError,
|
|
20
|
+
PFInput,
|
|
21
|
+
PFOptions,
|
|
22
|
+
PFResult,
|
|
23
|
+
ValidationReport,
|
|
24
|
+
is_data_compatible,
|
|
25
|
+
load_pf_input_npz,
|
|
26
|
+
save_pf_input,
|
|
27
|
+
solve,
|
|
28
|
+
validate_input,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
# Версия выводится из git-тегов через setuptools_scm и впекается в метаданные
|
|
34
|
+
# пакета при сборке/установке; читаем её обратно из метаданных в рантайме.
|
|
35
|
+
# Фолбэк срабатывает только в «голом» дереве исходников без install/build.
|
|
36
|
+
__version__ = version("gridpf")
|
|
37
|
+
except PackageNotFoundError: # pragma: no cover - un-installed source tree
|
|
38
|
+
__version__ = "0+unknown"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"BASE_MVA",
|
|
43
|
+
"CONTRACT_VERSION",
|
|
44
|
+
"PQ",
|
|
45
|
+
"PV",
|
|
46
|
+
"SLACK",
|
|
47
|
+
"Method",
|
|
48
|
+
"PFContractValidationError",
|
|
49
|
+
"PFInput",
|
|
50
|
+
"PFOptions",
|
|
51
|
+
"PFResult",
|
|
52
|
+
"ValidationReport",
|
|
53
|
+
"__version__",
|
|
54
|
+
"build_ybus",
|
|
55
|
+
"is_data_compatible",
|
|
56
|
+
"load_pf_input_npz",
|
|
57
|
+
"save_pf_input",
|
|
58
|
+
"solve",
|
|
59
|
+
"validate_input",
|
|
60
|
+
]
|
gridpf/_engine.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
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 replace as _dc_replace
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from gridpf.algebra.sbus import build_sbus, classify_buses, compute_sbus
|
|
22
|
+
from gridpf.algebra.ybus import build_ybus
|
|
23
|
+
from gridpf.contract.types import BASE_MVA, PFInput, PFOptions, PFResult
|
|
24
|
+
from gridpf.solvers.dc_pf import dc_powerflow
|
|
25
|
+
from gridpf.solvers.gauss_seidel import gauss_seidel
|
|
26
|
+
from gridpf.solvers.newton_raphson import newton_raphson
|
|
27
|
+
from gridpf.solvers.q_lims import enforce_q_limits
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _has_orphan_component(network_pu: PFInput) -> bool:
|
|
31
|
+
"""Есть ли связная компонента без slack-узла среди активных узлов?"""
|
|
32
|
+
from collections import deque
|
|
33
|
+
|
|
34
|
+
n = network_pu.n_bus
|
|
35
|
+
adj: list[list[int]] = [[] for _ in range(n)]
|
|
36
|
+
for f, t in zip(network_pu.from_idx.tolist(), network_pu.to_idx.tolist(), strict=True):
|
|
37
|
+
adj[f].append(t)
|
|
38
|
+
adj[t].append(f)
|
|
39
|
+
slack_set = set(np.where(network_pu.bus_type == 2)[0].tolist())
|
|
40
|
+
seen = set(slack_set)
|
|
41
|
+
q: deque[int] = deque(slack_set)
|
|
42
|
+
while q:
|
|
43
|
+
u = q.popleft()
|
|
44
|
+
for v in adj[u]:
|
|
45
|
+
if v not in seen:
|
|
46
|
+
seen.add(v)
|
|
47
|
+
q.append(v)
|
|
48
|
+
return len(seen) < n # есть узлы недостижимые от slack
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _flat_start(network_pu: PFInput) -> np.ndarray:
|
|
52
|
+
"""Сформировать flat-старт: ``|V|=1, δ=0`` для всех шин.
|
|
53
|
+
|
|
54
|
+
Заданные модули/углы управляемых узлов накладываются отдельно через
|
|
55
|
+
:func:`_apply_setpoints`.
|
|
56
|
+
"""
|
|
57
|
+
n = network_pu.n_bus
|
|
58
|
+
Vm = np.ones(n, dtype=np.float64)
|
|
59
|
+
Va = np.zeros(n, dtype=np.float64)
|
|
60
|
+
result: np.ndarray = Vm * np.exp(1j * Va)
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _apply_setpoints(network_pu: PFInput, V0: np.ndarray) -> np.ndarray:
|
|
65
|
+
"""Наложить заданные модули/углы управляемых узлов на ``V0``.
|
|
66
|
+
|
|
67
|
+
Читает материализованные адаптером ``net.bus_v_set`` (|V| p.u.) и
|
|
68
|
+
``net.bus_va_set`` (угол, рад): где значение не ``NaN`` — оно замещает
|
|
69
|
+
соответствующую компоненту ``V0``. Поля ``None`` → старт не меняется.
|
|
70
|
+
"""
|
|
71
|
+
Vm = np.abs(V0).copy()
|
|
72
|
+
Va = np.angle(V0).copy()
|
|
73
|
+
if network_pu.bus_v_set is not None:
|
|
74
|
+
mask = ~np.isnan(network_pu.bus_v_set)
|
|
75
|
+
Vm[mask] = network_pu.bus_v_set[mask]
|
|
76
|
+
if network_pu.bus_va_set is not None:
|
|
77
|
+
mask = ~np.isnan(network_pu.bus_va_set)
|
|
78
|
+
Va[mask] = network_pu.bus_va_set[mask]
|
|
79
|
+
result: np.ndarray = Vm * np.exp(1j * Va)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run_powerflow(
|
|
84
|
+
net: PFInput,
|
|
85
|
+
options: PFOptions,
|
|
86
|
+
*,
|
|
87
|
+
init_v: np.ndarray | None = None,
|
|
88
|
+
) -> PFResult:
|
|
89
|
+
"""Рассчитать установившийся режим для ``net`` (model-free).
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
net: opaque p.u.-представление сети.
|
|
93
|
+
options: опции движка.
|
|
94
|
+
init_v: ``(n_bus,)`` complex — тёплый старт (например, из результатов
|
|
95
|
+
прошлого прогона, выровненный по ``net.bus_ids``). ``None`` →
|
|
96
|
+
flat-старт (``|V|=1, δ=0``), бит-в-бит с холодным запуском.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
:class:`PFResult` со всеми итоговыми величинами. Запись обратно —
|
|
100
|
+
не делается (забота адаптера).
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: если метод неизвестен или в сети нет slack-узла.
|
|
104
|
+
RuntimeError: если якобиан Newton сингулярен.
|
|
105
|
+
"""
|
|
106
|
+
method = options.method
|
|
107
|
+
if method not in ("gs+nr", "nr", "gs"):
|
|
108
|
+
raise ValueError(f"Неизвестный method={method!r}; ожидался 'gs+nr', 'nr' или 'gs'.")
|
|
109
|
+
tol = options.tol
|
|
110
|
+
max_iter_gs = options.max_iter_gs
|
|
111
|
+
max_iter_nr = options.max_iter_nr
|
|
112
|
+
gs_tol = options.gs_tol
|
|
113
|
+
enforce_q_lims = options.enforce_q_lims
|
|
114
|
+
max_q_lim_swaps = options.max_q_lim_swaps
|
|
115
|
+
allow_pq_to_pv = options.allow_pq_to_pv
|
|
116
|
+
q_lim_top_k = options.q_lim_top_k
|
|
117
|
+
dc_fallback = options.dc_fallback
|
|
118
|
+
use_load_voltage_dependency = options.use_load_voltage_dependency
|
|
119
|
+
|
|
120
|
+
network_pu = net
|
|
121
|
+
ybus, yf, yt = build_ybus(network_pu)
|
|
122
|
+
sbus = build_sbus(network_pu)
|
|
123
|
+
ref, pv, pq = classify_buses(network_pu.bus_type)
|
|
124
|
+
|
|
125
|
+
# Стартовое приближение: тёплый старт init_v (выровнен адаптером по
|
|
126
|
+
# network_pu.bus_ids) либо flat. None → flat (бит-в-бит).
|
|
127
|
+
V0 = init_v if init_v is not None else _flat_start(network_pu)
|
|
128
|
+
# Setpoints PV/slack клампятся ПОВЕРХ старта (Rule №4): заданный модуль/угол
|
|
129
|
+
# всегда побеждает тёплый старт на управляемых узлах.
|
|
130
|
+
V0 = _apply_setpoints(network_pu, V0)
|
|
131
|
+
|
|
132
|
+
# Начальные параметры enforce_q_lims (если выключено — массивы не используются).
|
|
133
|
+
pv_original = pv.copy()
|
|
134
|
+
v_set_arr = np.full(network_pu.n_bus, np.nan, dtype=np.float64)
|
|
135
|
+
v_set_arr[pv] = np.abs(V0[pv])
|
|
136
|
+
locked_lim = np.full(network_pu.n_bus, np.nan, dtype=np.float64)
|
|
137
|
+
q_min = network_pu.bus_q_min
|
|
138
|
+
q_max = network_pu.bus_q_max
|
|
139
|
+
can_enforce = enforce_q_lims and q_min is not None and q_max is not None
|
|
140
|
+
|
|
141
|
+
iters_gs = 0
|
|
142
|
+
iters_nr = 0
|
|
143
|
+
q_lim_swaps = 0
|
|
144
|
+
V = V0
|
|
145
|
+
converged = False
|
|
146
|
+
mismatch = float("nan")
|
|
147
|
+
|
|
148
|
+
# СХН активна, если: пользователь не отключил, в сети есть нетривиальные
|
|
149
|
+
# коэффициенты и доступны базовые поля. Совместима с enforce_q_lims:
|
|
150
|
+
# внешний цикл swap'ов фиксирует bus_q_gen на Q-лимите, дальнейшие NR
|
|
151
|
+
# пересчитывают Sbus через compute_sbus(network_pu, V).
|
|
152
|
+
use_load_v = use_load_voltage_dependency and network_pu.has_voltage_dependent_load
|
|
153
|
+
# Перестраиваем Sbus с учётом СХН на flat-старте — чтобы GS не стартовал
|
|
154
|
+
# с устаревшей константой.
|
|
155
|
+
if use_load_v:
|
|
156
|
+
sbus = compute_sbus(network_pu, V0, voltage_dependent=True)
|
|
157
|
+
|
|
158
|
+
# GS warm-start выполняется один раз — переключения PV/PQ затрагивают только NR-фазу.
|
|
159
|
+
if method in ("gs", "gs+nr"):
|
|
160
|
+
gs_local_tol = tol if method == "gs" else gs_tol
|
|
161
|
+
gs_res = gauss_seidel(
|
|
162
|
+
ybus,
|
|
163
|
+
sbus,
|
|
164
|
+
V0,
|
|
165
|
+
ref,
|
|
166
|
+
pv,
|
|
167
|
+
pq,
|
|
168
|
+
tol=gs_local_tol,
|
|
169
|
+
max_iter=max_iter_gs,
|
|
170
|
+
network_pu=network_pu,
|
|
171
|
+
voltage_dependent_load=use_load_v,
|
|
172
|
+
)
|
|
173
|
+
V = gs_res.V
|
|
174
|
+
iters_gs = gs_res.iterations
|
|
175
|
+
mismatch = gs_res.mismatch_max
|
|
176
|
+
if method == "gs":
|
|
177
|
+
converged = gs_res.converged
|
|
178
|
+
|
|
179
|
+
# Внешний цикл по Q-лимитам. Без enforce_q_lims — ровно одна итерация NR.
|
|
180
|
+
if method in ("nr", "gs+nr"):
|
|
181
|
+
last_swap_pv = pv.copy()
|
|
182
|
+
last_swap_pq = pq.copy()
|
|
183
|
+
last_swap_sbus = sbus.copy()
|
|
184
|
+
last_swap_locked = locked_lim.copy()
|
|
185
|
+
last_swap_net = network_pu # для отката bus_q_gen
|
|
186
|
+
outer_done = False
|
|
187
|
+
for _swap_iter in range(max_q_lim_swaps + 1):
|
|
188
|
+
nr_res = newton_raphson(
|
|
189
|
+
ybus,
|
|
190
|
+
sbus,
|
|
191
|
+
V,
|
|
192
|
+
ref,
|
|
193
|
+
pv,
|
|
194
|
+
pq,
|
|
195
|
+
tol=tol,
|
|
196
|
+
max_iter=max_iter_nr,
|
|
197
|
+
network_pu=network_pu,
|
|
198
|
+
voltage_dependent_load=use_load_v,
|
|
199
|
+
)
|
|
200
|
+
V = nr_res.V
|
|
201
|
+
iters_nr += nr_res.iterations
|
|
202
|
+
mismatch = nr_res.mismatch_max
|
|
203
|
+
converged = nr_res.converged
|
|
204
|
+
|
|
205
|
+
if not can_enforce:
|
|
206
|
+
break
|
|
207
|
+
if not converged:
|
|
208
|
+
# NR не сошёлся при текущей классификации. Откатываем последнее
|
|
209
|
+
# переключение и пробуем добить решение без него — типично
|
|
210
|
+
# «overshoot» при массовом swap'е делает следующий NR
|
|
211
|
+
# неустойчивым.
|
|
212
|
+
pv = last_swap_pv
|
|
213
|
+
pq = last_swap_pq
|
|
214
|
+
sbus = last_swap_sbus
|
|
215
|
+
locked_lim = last_swap_locked
|
|
216
|
+
network_pu = last_swap_net
|
|
217
|
+
nr_res = newton_raphson(
|
|
218
|
+
ybus,
|
|
219
|
+
sbus,
|
|
220
|
+
V,
|
|
221
|
+
ref,
|
|
222
|
+
pv,
|
|
223
|
+
pq,
|
|
224
|
+
tol=tol,
|
|
225
|
+
max_iter=max_iter_nr,
|
|
226
|
+
network_pu=network_pu,
|
|
227
|
+
voltage_dependent_load=use_load_v,
|
|
228
|
+
)
|
|
229
|
+
V = nr_res.V
|
|
230
|
+
iters_nr += nr_res.iterations
|
|
231
|
+
mismatch = nr_res.mismatch_max
|
|
232
|
+
converged = nr_res.converged
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
assert q_min is not None and q_max is not None # защищено can_enforce
|
|
236
|
+
qlim_res = enforce_q_limits(
|
|
237
|
+
ybus,
|
|
238
|
+
V,
|
|
239
|
+
sbus,
|
|
240
|
+
pv,
|
|
241
|
+
pq,
|
|
242
|
+
q_min,
|
|
243
|
+
q_max,
|
|
244
|
+
v_set_arr,
|
|
245
|
+
locked_lim,
|
|
246
|
+
pv_original=pv_original,
|
|
247
|
+
allow_pq_to_pv=allow_pq_to_pv,
|
|
248
|
+
top_k=q_lim_top_k,
|
|
249
|
+
network_pu=network_pu if use_load_v else None,
|
|
250
|
+
)
|
|
251
|
+
if not qlim_res.changed:
|
|
252
|
+
outer_done = True
|
|
253
|
+
break
|
|
254
|
+
# Сохраняем «предыдущее хорошее» состояние перед коммитом swap'а
|
|
255
|
+
last_swap_pv = pv
|
|
256
|
+
last_swap_pq = pq
|
|
257
|
+
last_swap_sbus = sbus
|
|
258
|
+
last_swap_locked = locked_lim
|
|
259
|
+
last_swap_net = network_pu
|
|
260
|
+
pv = qlim_res.pv
|
|
261
|
+
pq = qlim_res.pq
|
|
262
|
+
locked_lim = qlim_res.locked_lim
|
|
263
|
+
# При активной СХН: фиксируем bus_q_gen на лимите, далее sbus
|
|
264
|
+
# пересчитывается compute_sbus каждую NR-итерацию. Без СХН —
|
|
265
|
+
# legacy: Sbus.imag = Q_lim напрямую.
|
|
266
|
+
if use_load_v and qlim_res.bus_q_gen_new is not None:
|
|
267
|
+
network_pu = _dc_replace(network_pu, bus_q_gen=qlim_res.bus_q_gen_new)
|
|
268
|
+
sbus = compute_sbus(network_pu, V, voltage_dependent=True)
|
|
269
|
+
else:
|
|
270
|
+
sbus = qlim_res.Sbus
|
|
271
|
+
q_lim_swaps += len(qlim_res.actions)
|
|
272
|
+
converged = False # требуется ещё один прогон NR с новой классификацией
|
|
273
|
+
|
|
274
|
+
# Если outer-loop достиг лимита (а не отработал break по changed=False),
|
|
275
|
+
# делаем финальный NR с уже коммитнутой классификацией — даёт шанс
|
|
276
|
+
# «дотянуть» сходимость на тех моделях, где enforcement-цикл сошёлся
|
|
277
|
+
# фактически, но формально не успел подтвердиться.
|
|
278
|
+
if can_enforce and not outer_done and not converged:
|
|
279
|
+
nr_res = newton_raphson(
|
|
280
|
+
ybus,
|
|
281
|
+
sbus,
|
|
282
|
+
V,
|
|
283
|
+
ref,
|
|
284
|
+
pv,
|
|
285
|
+
pq,
|
|
286
|
+
tol=tol,
|
|
287
|
+
max_iter=max_iter_nr,
|
|
288
|
+
network_pu=network_pu,
|
|
289
|
+
voltage_dependent_load=use_load_v,
|
|
290
|
+
)
|
|
291
|
+
V = nr_res.V
|
|
292
|
+
iters_nr += nr_res.iterations
|
|
293
|
+
mismatch = nr_res.mismatch_max
|
|
294
|
+
converged = nr_res.converged
|
|
295
|
+
|
|
296
|
+
# Soft-fallback: если ничего не помогло, отказываемся от enforcement
|
|
297
|
+
# и возвращаемся к классификации **без переключений**. Гарантирует,
|
|
298
|
+
# что enforce_q_lims=True никогда не хуже baseline; нарушения
|
|
299
|
+
# репортуются через PFResult.q_violations.
|
|
300
|
+
if can_enforce and not converged:
|
|
301
|
+
# Восстанавливаем исходную классификацию узлов и оригинальный
|
|
302
|
+
# bus_q_gen (без swap-фиксаций); СХН остаётся включённой.
|
|
303
|
+
net_orig = (
|
|
304
|
+
_dc_replace(network_pu, bus_q_gen=last_swap_net.bus_q_gen)
|
|
305
|
+
if last_swap_net is not network_pu
|
|
306
|
+
else network_pu
|
|
307
|
+
)
|
|
308
|
+
ref_orig, pv_orig_arr, pq_orig_arr = classify_buses(net_orig.bus_type)
|
|
309
|
+
sbus_orig = (
|
|
310
|
+
compute_sbus(net_orig, _flat_start(net_orig), voltage_dependent=True)
|
|
311
|
+
if use_load_v
|
|
312
|
+
else build_sbus(net_orig)
|
|
313
|
+
)
|
|
314
|
+
# Для устойчивости — повторный flat + GS warm-start.
|
|
315
|
+
V_fb = _flat_start(net_orig)
|
|
316
|
+
V_fb = _apply_setpoints(net_orig, V_fb)
|
|
317
|
+
if method in ("gs", "gs+nr"):
|
|
318
|
+
gs_fb = gauss_seidel(
|
|
319
|
+
ybus,
|
|
320
|
+
sbus_orig,
|
|
321
|
+
V_fb,
|
|
322
|
+
ref_orig,
|
|
323
|
+
pv_orig_arr,
|
|
324
|
+
pq_orig_arr,
|
|
325
|
+
tol=gs_tol,
|
|
326
|
+
max_iter=max_iter_gs,
|
|
327
|
+
network_pu=net_orig,
|
|
328
|
+
voltage_dependent_load=use_load_v,
|
|
329
|
+
)
|
|
330
|
+
V_fb = gs_fb.V
|
|
331
|
+
nr_fb = newton_raphson(
|
|
332
|
+
ybus,
|
|
333
|
+
sbus_orig,
|
|
334
|
+
V_fb,
|
|
335
|
+
ref_orig,
|
|
336
|
+
pv_orig_arr,
|
|
337
|
+
pq_orig_arr,
|
|
338
|
+
tol=tol,
|
|
339
|
+
max_iter=max_iter_nr,
|
|
340
|
+
network_pu=net_orig,
|
|
341
|
+
voltage_dependent_load=use_load_v,
|
|
342
|
+
)
|
|
343
|
+
if nr_fb.converged:
|
|
344
|
+
V = nr_fb.V
|
|
345
|
+
iters_nr += nr_fb.iterations
|
|
346
|
+
mismatch = nr_fb.mismatch_max
|
|
347
|
+
converged = True
|
|
348
|
+
network_pu = net_orig
|
|
349
|
+
pv = pv_orig_arr # для подсчёта q_violations
|
|
350
|
+
|
|
351
|
+
# DC warm-start fallback: если NR-расчёт (с/без enforcement) разошёлся,
|
|
352
|
+
# пробуем линеаризованное DC-приближение для углов и стартуем NR ещё раз.
|
|
353
|
+
if dc_fallback and not converged and method in ("nr", "gs+nr"):
|
|
354
|
+
ref_d, pv_d, pq_d = classify_buses(network_pu.bus_type)
|
|
355
|
+
sbus_d = build_sbus(network_pu)
|
|
356
|
+
delta_dc = dc_powerflow(
|
|
357
|
+
n_bus=network_pu.n_bus,
|
|
358
|
+
from_idx=network_pu.from_idx,
|
|
359
|
+
to_idx=network_pu.to_idx,
|
|
360
|
+
branch_x=network_pu.branch_x,
|
|
361
|
+
tap_ratio=network_pu.tap_ratio,
|
|
362
|
+
P_inj=sbus_d.real,
|
|
363
|
+
ref=ref_d,
|
|
364
|
+
pv=pv_d,
|
|
365
|
+
pq=pq_d,
|
|
366
|
+
)
|
|
367
|
+
# Стартовое V для NR: модули как в flat+setpoints, углы из DC.
|
|
368
|
+
V_dc = _flat_start(network_pu)
|
|
369
|
+
V_dc = _apply_setpoints(network_pu, V_dc)
|
|
370
|
+
Vm_dc = np.abs(V_dc)
|
|
371
|
+
V_dc = Vm_dc * np.exp(1j * delta_dc)
|
|
372
|
+
nr_dc = newton_raphson(
|
|
373
|
+
ybus,
|
|
374
|
+
sbus_d,
|
|
375
|
+
V_dc,
|
|
376
|
+
ref_d,
|
|
377
|
+
pv_d,
|
|
378
|
+
pq_d,
|
|
379
|
+
tol=tol,
|
|
380
|
+
max_iter=max_iter_nr,
|
|
381
|
+
network_pu=network_pu,
|
|
382
|
+
voltage_dependent_load=use_load_v,
|
|
383
|
+
)
|
|
384
|
+
if nr_dc.converged:
|
|
385
|
+
V = nr_dc.V
|
|
386
|
+
iters_nr += nr_dc.iterations
|
|
387
|
+
mismatch = nr_dc.mismatch_max
|
|
388
|
+
converged = True
|
|
389
|
+
pv = pv_d
|
|
390
|
+
|
|
391
|
+
# Потоки ветвей в МВА (S_base × p.u.)
|
|
392
|
+
if network_pu.n_branch > 0:
|
|
393
|
+
s_from = V[network_pu.from_idx] * np.conj(yf @ V) * BASE_MVA
|
|
394
|
+
s_to = V[network_pu.to_idx] * np.conj(yt @ V) * BASE_MVA
|
|
395
|
+
else:
|
|
396
|
+
s_from = np.empty(0, dtype=np.complex128)
|
|
397
|
+
s_to = np.empty(0, dtype=np.complex128)
|
|
398
|
+
|
|
399
|
+
# Подсчёт PV-узлов с нарушением Q-лимитов в финальном решении.
|
|
400
|
+
# При активной СХН Q-лимит сравнивается с Q_gen = Q_inj + Q_load(|V|),
|
|
401
|
+
# а не с сетевой Q_inj — иначе мы ложно репортуем нарушение из-за
|
|
402
|
+
# переменной нагрузки на узле.
|
|
403
|
+
q_violations = 0
|
|
404
|
+
if can_enforce and converged:
|
|
405
|
+
I_bus = ybus @ V
|
|
406
|
+
S_calc_final = V * np.conj(I_bus)
|
|
407
|
+
if use_load_v and network_pu.bus_q_load is not None:
|
|
408
|
+
Vm_fin = np.abs(V)
|
|
409
|
+
b0 = network_pu.bus_q_b0
|
|
410
|
+
b1 = network_pu.bus_q_b1
|
|
411
|
+
b2 = network_pu.bus_q_b2
|
|
412
|
+
assert b0 is not None and b1 is not None and b2 is not None
|
|
413
|
+
q_load_fin = network_pu.bus_q_load * (b0 + b1 * Vm_fin + b2 * Vm_fin * Vm_fin)
|
|
414
|
+
else:
|
|
415
|
+
q_load_fin = np.zeros(network_pu.n_bus)
|
|
416
|
+
for k in pv_original.tolist():
|
|
417
|
+
qk_gen = float(S_calc_final[k].imag) + float(q_load_fin[k])
|
|
418
|
+
qmax_k = q_max[k] if q_max is not None else np.nan
|
|
419
|
+
qmin_k = q_min[k] if q_min is not None else np.nan
|
|
420
|
+
if (not np.isnan(qmax_k) and qk_gen > qmax_k + 1e-6) or (
|
|
421
|
+
not np.isnan(qmin_k) and qk_gen < qmin_k - 1e-6
|
|
422
|
+
):
|
|
423
|
+
q_violations += 1
|
|
424
|
+
|
|
425
|
+
failure_reason = ""
|
|
426
|
+
if not converged:
|
|
427
|
+
if _has_orphan_component(network_pu):
|
|
428
|
+
failure_reason = "no_slack_component"
|
|
429
|
+
elif np.isnan(mismatch) or not np.isfinite(mismatch):
|
|
430
|
+
failure_reason = "singular_jacobian"
|
|
431
|
+
elif mismatch > 1e3:
|
|
432
|
+
failure_reason = "voltage_collapse"
|
|
433
|
+
elif can_enforce and q_violations > 0:
|
|
434
|
+
failure_reason = "infeasible_q_lims"
|
|
435
|
+
else:
|
|
436
|
+
failure_reason = "max_iter_reached"
|
|
437
|
+
|
|
438
|
+
return PFResult(
|
|
439
|
+
converged=converged,
|
|
440
|
+
iterations_gs=iters_gs,
|
|
441
|
+
iterations_nr=iters_nr,
|
|
442
|
+
V=V,
|
|
443
|
+
bus_ids=network_pu.bus_ids,
|
|
444
|
+
S_from=s_from,
|
|
445
|
+
S_to=s_to,
|
|
446
|
+
mismatch_max=mismatch,
|
|
447
|
+
method=method,
|
|
448
|
+
q_lim_swaps=q_lim_swaps,
|
|
449
|
+
q_violations=q_violations,
|
|
450
|
+
failure_reason=failure_reason,
|
|
451
|
+
voltage_dependent_load_active=use_load_v,
|
|
452
|
+
)
|
gridpf/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Чистая алгебра PF: построение матриц проводимостей, вектора инъекций и якобиана."""
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Якобиан мощностного небаланса для Newton-Raphson PF.
|
|
2
|
+
|
|
3
|
+
Формулы — стандартные pandapower/MATPOWER (см. ``dSbus_dV.py`` PSERC):
|
|
4
|
+
|
|
5
|
+
.. code::
|
|
6
|
+
|
|
7
|
+
Ibus = Ybus · V
|
|
8
|
+
diagV = diag(V)
|
|
9
|
+
diagIbus = diag(Ibus)
|
|
10
|
+
diagVnorm = diag(V / |V|)
|
|
11
|
+
|
|
12
|
+
dS/d|V| = diagV · conj(Ybus · diagVnorm) + conj(diagIbus) · diagVnorm
|
|
13
|
+
dS/dδ = j · diagV · conj(diagIbus − Ybus · diagV)
|
|
14
|
+
|
|
15
|
+
Подматрицы для Newton:
|
|
16
|
+
|
|
17
|
+
.. code::
|
|
18
|
+
|
|
19
|
+
H = ∂P/∂δ = Re(dS/dδ)
|
|
20
|
+
N = ∂P/∂|V|= Re(dS/d|V|)
|
|
21
|
+
J = ∂Q/∂δ = Im(dS/dδ)
|
|
22
|
+
L = ∂Q/∂|V|= Im(dS/d|V|)
|
|
23
|
+
|
|
24
|
+
Активная часть якобиана собирается с строчно-столбцовым выбором по
|
|
25
|
+
``pvpq = pv ∪ pq`` для P-уравнений и ``pq`` для Q-уравнений.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
from scipy.sparse import csr_matrix, hstack, vstack
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def dSbus_dV(Ybus: csr_matrix, V: np.ndarray) -> tuple[csr_matrix, csr_matrix]:
|
|
35
|
+
"""Частные производные ``S = V · conj(Ybus · V)`` по ``|V|`` и ``δ``.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
Ybus: ``(n, n)`` CSR — узловые проводимости.
|
|
39
|
+
V: ``(n,)`` complex — текущие комплексные напряжения.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
``(dS/d|V|, dS/dδ)`` — обе CSR-матрицы ``(n, n)``.
|
|
43
|
+
"""
|
|
44
|
+
n = V.size
|
|
45
|
+
ib = np.arange(n, dtype=np.int64)
|
|
46
|
+
Ibus = Ybus @ V
|
|
47
|
+
|
|
48
|
+
diagV = csr_matrix((V, (ib, ib)), shape=(n, n))
|
|
49
|
+
diagIbus = csr_matrix((Ibus, (ib, ib)), shape=(n, n))
|
|
50
|
+
Vnorm = V / np.abs(V)
|
|
51
|
+
diagVnorm = csr_matrix((Vnorm, (ib, ib)), shape=(n, n))
|
|
52
|
+
|
|
53
|
+
dS_dVm = diagV @ (Ybus @ diagVnorm).conjugate() + diagIbus.conjugate() @ diagVnorm
|
|
54
|
+
dS_dVa = 1j * diagV @ (diagIbus - Ybus @ diagV).conjugate()
|
|
55
|
+
|
|
56
|
+
return dS_dVm.tocsr(), dS_dVa.tocsr()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_jacobian(
|
|
60
|
+
Ybus: csr_matrix,
|
|
61
|
+
V: np.ndarray,
|
|
62
|
+
pv: np.ndarray,
|
|
63
|
+
pq: np.ndarray,
|
|
64
|
+
*,
|
|
65
|
+
dS_load_dVm: np.ndarray | None = None,
|
|
66
|
+
) -> csr_matrix:
|
|
67
|
+
"""Собрать активный якобиан размера ``(n_pvpq + n_pq, n_pvpq + n_pq)``.
|
|
68
|
+
|
|
69
|
+
Структура:
|
|
70
|
+
|
|
71
|
+
.. code::
|
|
72
|
+
|
|
73
|
+
[ H N ]
|
|
74
|
+
[ J L ]
|
|
75
|
+
|
|
76
|
+
где::
|
|
77
|
+
|
|
78
|
+
H = (dP/dδ)[pvpq, pvpq]
|
|
79
|
+
N = (dP/d|V|)[pvpq, pq]
|
|
80
|
+
J = (dQ/dδ)[pq, pvpq]
|
|
81
|
+
L = (dQ/d|V|)[pq, pq]
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
Ybus: ``(n, n)`` CSR.
|
|
85
|
+
V: ``(n,)`` complex — текущая итерация напряжений.
|
|
86
|
+
pv: индексы PV-шин.
|
|
87
|
+
pq: индексы PQ-шин.
|
|
88
|
+
dS_load_dVm: ``(n,)`` complex или ``None``. Поправка от СХН на
|
|
89
|
+
диагонали ``dS/d|V|``: ``∂P_load/∂|V| + j·∂Q_load/∂|V|``.
|
|
90
|
+
Прибавляется к ``dS_dVm`` перед извлечением блоков.
|
|
91
|
+
Знак ``+`` — потому что в residual'е ``F = S_calc − S_inj``,
|
|
92
|
+
а ``S_inj = S_gen − S_load``, и ``∂F/∂|V|`` получает ``+∂S_load/∂|V|``.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Якобиан CSR.
|
|
96
|
+
"""
|
|
97
|
+
pvpq = np.concatenate([pv, pq]).astype(np.int64)
|
|
98
|
+
dS_dVm, dS_dVa = dSbus_dV(Ybus, V)
|
|
99
|
+
|
|
100
|
+
if dS_load_dVm is not None:
|
|
101
|
+
n = V.size
|
|
102
|
+
ib = np.arange(n, dtype=np.int64)
|
|
103
|
+
diag_load = csr_matrix((dS_load_dVm.astype(np.complex128), (ib, ib)), shape=(n, n))
|
|
104
|
+
dS_dVm = dS_dVm + diag_load
|
|
105
|
+
|
|
106
|
+
# Извлекаем нужные блоки. Slicing CSR по строкам и столбцам — поддерживается
|
|
107
|
+
# scipy: M[rows, :][:, cols].
|
|
108
|
+
H = dS_dVa[pvpq, :][:, pvpq].real
|
|
109
|
+
N = dS_dVm[pvpq, :][:, pq].real
|
|
110
|
+
J = dS_dVa[pq, :][:, pvpq].imag
|
|
111
|
+
L = dS_dVm[pq, :][:, pq].imag
|
|
112
|
+
|
|
113
|
+
top = hstack([H, N], format="csr")
|
|
114
|
+
bot = hstack([J, L], format="csr")
|
|
115
|
+
return vstack([top, bot], format="csr")
|