Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ Change Log

[upcoming release] - 2026-..-..
-------------------------------
- [ADDED] :code:`BinarySearchControl.for_stations`: one station controller instance managing many stations, each with its own control modus, set point, measurement, outputs and optional droop characteristic (droop is folded into the station residual instead of a chained :code:`DroopControl`); reduces run_control overhead by >10x for hundreds of stations
- [ADDED] opt-in :code:`update_method="jacobian"` for multi-station controllers: coupled Newton steps for V_ctrl stations from the dVm/dQ sensitivities of the powerflow Jacobian (fewer powerflows, converges electrically coupled stations that oscillate under independent iterations); new utility :code:`pandapower.control.util.sensitivity.calc_dvm_dq`
- [CHANGED] stabilized the BinarySearchControl update: residual-scaled first step for Q/PF/tan(phi) modi, bracketing (Illinois regula falsi) safeguards, bounded steps on flat measurement response (no more output blow-up), stagnation diagnostics; the power factor set point is no longer permanently overwritten by the near-zero clipping
- [CHANGED] vectorized the BinarySearchControl hot path (positional lookups precomputed in initialize_control, no per-iteration scan of all controllers); fixed crashes with partially out-of-service output elements
- [ADDED] station controller characterization and benchmark suites (pandapower/test/control/test_stactrl_characterization.py, .../benchmarks/)

[3.5.4] - 2026-07-08
-------------------------------
Expand Down
5 changes: 3 additions & 2 deletions doc/control.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ The control module allows you to simulate these control strategies by either usi
controller in an object oriented framework. The controller module is closely integrated with the timeseries module, which allows you to run quasi-static timeseries
simulations with controlled elements.

.. toctree::
.. toctree::
:maxdepth: 2

control/control_loop
control/run
control/controller
control/station_control
control/tutorials
5 changes: 5 additions & 0 deletions doc/control/controller.rst
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ Station Controller
The following controllers are used for the representation of station controllers as used in PowerFactory. The Vdroop is
a new controller class used for the local droop voltage control.

A ``BinarySearchControl`` created through its constructor controls one station; one controller instance can
also manage many stations at once via :meth:`BinarySearchControl.for_stations`, including per-station droop
characteristics and an opt-in Jacobian-based update (``update_method="jacobian"``). Usage, convergence
behaviour and benchmark results are documented in :doc:`station_control`.

**********************
Binary Search Control
**********************
Expand Down
371 changes: 371 additions & 0 deletions doc/control/station_control.rst

Large diffs are not rendered by default.

1,012 changes: 853 additions & 159 deletions pandapower/control/controller/station_control.py

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion pandapower/control/run_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ def get_controller_order(nets, controller):
controller_order.append([*zip(rel_controller[order.argsort()], nets[to_add][order.argsort()])])
# controller_order.append(net.controller[to_add].sort_values(["order"]).object.values)

if logger.level <= pplog.DEBUG:
# getEffectiveLevel resolves the NOTSET (0) default to the parent logger level; comparing
# logger.level directly made every run build the huge debug string below
if logger.getEffectiveLevel() <= pplog.DEBUG:
logger.debug("levellist: " + str(level_list))
logger.debug("order: " + str(controller_order)) # Note: creates a long string if many controllers are present

Expand Down
103 changes: 103 additions & 0 deletions pandapower/control/util/sensitivity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-

# Copyright (c) 2016-2026 by University of Kassel and Fraunhofer Institute for Energy Economics
# and Energy System Technology (IEE), Kassel. All rights reserved.

"""
Voltage sensitivities from the Newton-Raphson Jacobian of the last powerflow.

After every AC powerflow, pandapower keeps the final Newton-Raphson Jacobian in
``net._ppc["internal"]["J"]`` (see pandapower/pf/run_newton_raphson_pf.py). Its state
ordering is ``x = [Va(pv+pq); Vm(pq)]`` with the mismatch rows ``[dP(pv+pq); dQ(pq)]``,
all in ppci-internal bus numbering (``net._pd2ppc_lookups["bus"]`` maps pandapower bus
indices into that numbering after the powerflow).

At the solution, F(x, Q_spec) = 0 with F_Q = Q_calc(x) - Q_spec, so a unit increase of the
reactive power injection at PQ bus b gives J * dx/dQ_b = e_{row_Q(b)} and therefore
dVm_m/dQ_b = (J^-1)[row_Vm(m), row_Q(b)]. The adjoint formulation solves
J^T y = e_{row_Vm(m)} once per *measured* bus, which is usually the smaller dimension.
"""

import logging

import numpy as np
from scipy.sparse.linalg import splu

logger = logging.getLogger(__name__)

# one-slot cache of the LU factorization of the current Jacobian. Deliberately NOT stored in
# net (_ppc or controllers): SuperLU objects cannot be deepcopied or serialized and would
# break net copying. Identity of the J matrix object decides validity; run_control triggers
# one powerflow per iteration, so all controllers of an iteration share one factorization.
_LU_CACHE = {"J": None, "lu": None}


def _factorized_jacobian(J):

Check warning on line 35 in pandapower/control/util/sensitivity.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this parameter "J" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=e2nIEE_pandapower&issues=AZ8yc-d0PgOMskWIkroK&open=AZ8yc-d0PgOMskWIkroK&pullRequest=3057
if _LU_CACHE["J"] is not J:
_LU_CACHE["J"] = J
_LU_CACHE["lu"] = splu(J.tocsc())
return _LU_CACHE["lu"]


def calc_dvm_dq(net, q_bus_idx, vm_bus_idx):
"""Sensitivity of bus voltage magnitudes to reactive power injections.

Parameters
----------
net : pandapowerNet
Net after a converged Newton-Raphson powerflow (runpp).
q_bus_idx : array-like of int
Pandapower bus indices where reactive power is injected (positive injection =
generation, e.g. positive sgen q_mvar).
vm_bus_idx : array-like of int
Pandapower bus indices whose voltage magnitude response is wanted.

Returns
-------
numpy.ndarray of shape (len(vm_bus_idx), len(q_bus_idx))
dVm/dQ in pu per Mvar. Entries are NaN when either bus was not a PQ bus in the last
powerflow (slack/PV voltages are fixed; their Q is balanced by the generator), or
None if no Jacobian is available (e.g. no NR powerflow ran).
"""
ppc = net.get("_ppc") if hasattr(net, "get") else None
internal = ppc.get("internal") if ppc else None
J = internal.get("J") if internal else None
if J is None:
return None
pv, pq = internal["pv"], internal["pq"]
base_mva = internal["baseMVA"]
lookup = net["_pd2ppc_lookups"]["bus"]
npvpq = len(pv) + len(pq)
if J.shape[0] != npvpq + len(pq):
# FACTS/extended formulations append state variables; not supported here
logger.debug("calc_dvm_dq: Jacobian has extended state variables, skipping")
return None
pq_position = {int(b): i for i, b in enumerate(pq)}

def block_row(pd_bus):
internal_bus = int(lookup[int(pd_bus)])
position = pq_position.get(internal_bus)
return None if position is None else npvpq + position

q_rows = [block_row(b) for b in q_bus_idx]
vm_rows = [block_row(b) for b in vm_bus_idx]
result = np.full((len(vm_rows), len(q_rows)), np.nan)
try:
lu = _factorized_jacobian(J)
except RuntimeError: # singular
logger.debug("calc_dvm_dq: Jacobian factorization failed")
return None
n = J.shape[0]
active_vm = [(i, row) for i, row in enumerate(vm_rows) if row is not None]
if not active_vm:
return result
# one batched adjoint solve for all measured buses
rhs = np.zeros((n, len(active_vm)))
for column, (_, vm_row) in enumerate(active_vm):
rhs[vm_row, column] = 1.0
y = lu.solve(rhs, trans='T')
active_q = [(j, row) for j, row in enumerate(q_rows) if row is not None]
for column, (i, _) in enumerate(active_vm):
for j, q_row in active_q:
result[i, j] = y[q_row, column] / base_mva
return result
Empty file.
Loading
Loading