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 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
+ ]
@@ -0,0 +1,5 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def get_hook_dirs() -> list[str]:
5
+ return [str(Path(__file__).parent)]
@@ -0,0 +1,5 @@
1
+ from PyInstaller.utils.hooks import collect_data_files, collect_submodules
2
+
3
+
4
+ hiddenimports = collect_submodules("gridpf")
5
+ datas = collect_data_files("gridpf")
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")