Source code for lightsim2grid.network.from_pypowsybl._olf_bake

# Copyright (c) 2026, RTE (https://www.rte-france.com)
# See AUTHORS.txt
# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
# you can obtain one at http://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0
# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform.

"""
Bake PowSyBl Open Load Flow (OLF) outer-loop results into an IIDM network so
that a subsequent *outer-loop-free* power flow -- in OLF itself or in
lightsim2grid -- reproduces the converged state exactly.

Why this exists
---------------
lightsim2grid solves a single power-flow problem: it does not run the discrete
"outer loops" that OLF runs (distributed slack, reactive-limit PV<->PQ
switching, transformer/shunt voltage control, phase control, ...). Those loops
change the input of the inner solves (*eg* the input of the inner Newton
Raphson) between iterations: a generator that hits its Q limit becomes a
fixed-Q (PQ) injection, a tap changer settles on a discrete position, the
slack mismatch is spread over participating units, etc.

If you take the raw network, run OLF *with* outer loops, and then hand the same
raw network to lightsim2grid, the two engines disagree -- not because the
solvers differ, but because they are solving different problems.

``bake_outer_loops`` rewrites the network's input setpoints to the values the
outer loops settled on, and disables the corresponding regulation, so the
problem becomes a plain power flow. After baking, OLF (loop-free) and
lightsim2grid *should* agree to solver tolerance, and keep agreeing through
topology changes (line/transformer outages) applied identically to both --
this does not always hold in practice (see e.g. multi-root / basin-boundary
cases where the two engines land on different, both-valid, roots).

The loop-free OLF parameters that reproduce the baked operating point are
available as :func:`lightsim2grid.network.get_pypowsybl_loopfree_parameters`.

What gets baked
---------------
* Discrete tap / section positions (ratio tap, phase tap, shunt section):
  the solved position (``solved_tap_position`` / ``solved_section_count``) is
  copied into the input position and regulation is switched off. The copy is
  guarded: it only happens where the changer was regulating AND a solved value
  exists, because ``solved_tap_position`` is NaN for changers that did not take
  part in an outer loop -- copying NaN would destroy the input tap.
* Generators dispatched at (approximately) 0 MW with a strictly positive
  ``min_p``: OLF never sets these up as voltage-controlling PV buses in the
  first place (``generatorsWithZeroMwTargetAreNotStarted``, "not started"),
  regardless of their ``voltage_regulator_on``/``target_v`` attributes. Frozen
  to fixed-Q (their realized reactive output) before the reactive-limit switch
  below runs. A generator with ``min_p <= 0`` (legitimately allowed to sit at
  0 MW) is left alone.
* PV -> PQ reactive-limit switches (generators, VSC converter stations): only
  the units whose realized reactive power actually sits at a Q limit are frozen
  to fixed-Q at that value, with voltage regulation switched off. Units still
  inside their limits keep voltage control -- their equation type is unchanged.
  Units with NaN (unlimited) reactive limits are never frozen, since NaN
  comparisons are False.
* Generators OLF's own voltage-control consistency checks would discard for a
  reason other than "not started": too small a reactive range (default
  ``reactiveRangeCheckMode``, widest ``max_q - min_q`` below 1 MVar) or an
  implausible ``target_v`` (outside 0.8-1.2 pu of nominal, on buses above 20 kV).
  Frozen to fixed-Q the same way as the "not started" case above, since OLF
  itself never actually voltage-controlled them either.
* A remotely-regulating generator whose own bus already hosts another
  connected, locally-regulating generator: the shared bus's voltage is already
  pinned by the local unit, so the remote unit's reactive injection cannot
  independently move any voltage (including the one it targets). Frozen to
  fixed-Q the same way, mirroring OLF's own handling of this configuration
  (see :func:`_bake_remote_control_bus_conflicts`).
* Active-power redistribution from distributed slack / area interchange: the
  realized P is written back into the target P of generators and batteries
  (and optionally loads, if the slack was distributed on load).
* Generators OLF's own ``checkActivePowerControl`` would exclude from
  slack-distribution participation (dispatched at ~0 MW, an implausible
  ``max_p``, ``target_p`` outside ``[min_p, max_p]``, or a degenerate P range):
  their ``activePowerControl`` extension's ``participate`` flag is set to
  ``False`` (created if absent), so neither a subsequent pypowsybl OLF re-solve
  nor lightsim2grid's own distributed slack -- which reads that same flag --
  puts slack mismatch back onto them.
* Static var compensators whose realized reactive output sits at (or beyond)
  their voltage-dependent susceptance envelope (``Q(V) = b * V^2``, ``b`` in
  ``b_min``..``b_max``, recomputed in MVAr at the SVC's own solved terminal
  voltage) are frozen to fixed-Q (``REACTIVE_POWER`` mode), mirroring the
  generator/VSC reactive-limit switch above. An SVC still comfortably inside
  its envelope is left regulating (its target reproduces the OLF result
  exactly, since it isn't saturated).
* Static var compensators carrying a "standby automaton" (``standby=True``):
  OLF's own outer loop starts these as a fixed ``b0`` shunt (no voltage
  control) and only switches them to active voltage control once their bus
  voltage crosses a low/high threshold. The static ``regulating`` /
  ``target_v`` attributes do *not* reflect this -- they read as if the SVC
  were always actively regulating. Resolved before the reactive-limit
  switch above: an SVC that stayed within its deadband is frozen to fixed-Q
  (0 when, as commonly configured, ``b0 == 0``); one that crossed a
  threshold is switched to ``VOLTAGE`` mode targeting the corresponding
  ``low_voltage_setpoint`` / ``high_voltage_setpoint`` (then still eligible
  for the ordinary saturation freeze).

Verified against pypowsybl 1.15.0. The OLF-internal round trip (solve with
outer loops -> bake -> solve loop-free) reproduces bus voltages to ~2e-4 kV /
~1e-5 deg, both on IEEE-14 (with a forced reactive-limit switch) and on the
four-substations node-breaker network, which carries VSC and LCC HVDC, an SVC
regulating voltage, a shunt, and ratio + phase tap changers.
"""

import numpy as np
import pandas as pd


# Tolerance (MVAr) for deciding a reactive injection sits "at" its Q limit: the
# larger of a small absolute floor (catches exact/near-exact hits, dominated by
# float rounding) and a fraction of the unit's own Q range (catches OLF's discrete
# reactive-limit outer loop settling a hair inside the limit -- e.g. because P kept
# shifting slightly in later outer-loop iterations after the PV->PQ switch already
# happened). A fixed absolute tolerance alone is either too tight for large-range
# units (missing genuine saturation) or too loose for small-range ones (freezing
# units that still have real headroom).
_Q_LIMIT_TOL_ABS = 1e-3
_Q_LIMIT_TOL_REL = 0.005  # 0.5% of (qmax - qmin)


def _q_limit_tol(qmin, qmax):
    # An unset reactive-limit box defaults to +/-Double.MAX in pypowsybl (an
    # "unbounded" sentinel, not a real 3.6e308 MVAr range): subtracting the two
    # overflows to +inf, which is exactly the "no limit" case filtered out below --
    # expected and already handled, so silence the resulting RuntimeWarning.
    with np.errstate(over="ignore"):
        rng = np.asarray(qmax) - np.asarray(qmin)
    # A relative tolerance on that overflowed/absurd range would swallow every
    # generator. Treat absurdly large / non-finite ranges as "no limit" -- no
    # relative bonus, just the absolute floor -- rather than let them dominate max().
    rng = np.where(np.isfinite(rng) & (rng < 1e6), rng, 0.0)
    return np.maximum(_Q_LIMIT_TOL_ABS, _Q_LIMIT_TOL_REL * rng)
            
            
def _keep_only_main_comp(df_el, df_bus):
    """
    keep only element (modeled in df_el) that are on the main component => bus_els["synchronous_component"] == 0
    
    This does not deactivate anything.
    """
    mask_conn = df_el["connected"]
    bus_els = df_bus.loc[df_el.loc[mask_conn, "bus_id"]]
    mask_main = (bus_els["synchronous_component"] == 0).to_numpy()
    df_el = df_el.loc[df_el.loc[mask_conn][mask_main].index]
    return df_el


def _reactive_limits(df: pd.DataFrame):
    """Return (qmin, qmax) in *generator* convention.

    Prefer the P-dependent capability-curve limits (``min_q_at_p`` /
    ``max_q_at_p``) when present and finite; fall back to the fixed
    ``min_q`` / ``max_q`` box otherwise.
    """
    if "min_q_at_p" in df.columns:
        qmin = df["min_q_at_p"].where(df["min_q_at_p"].notna(), df["min_q"])
        qmax = df["max_q_at_p"].where(df["max_q_at_p"].notna(), df["max_q"])
    else:
        qmin, qmax = df["min_q"], df["max_q"]
    return qmin, qmax


def _bound_at_qlimit(df: pd.DataFrame, regulating: pd.Series):
    """Boolean mask of rows that regulate voltage *and* sit at a Q limit,
    plus the realized reactive power in generator convention.

    ``df`` must carry result column ``q`` (load convention) and the reactive
    limit columns. ``regulating`` is the per-row voltage-regulation flag.
    """
    q_gen = -df["q"]  # result column is load convention; flip to generator
    qmin, qmax = _reactive_limits(df)
    tol = _q_limit_tol(qmin, qmax)
    at_max = regulating & (q_gen >= qmax - tol)
    at_min = regulating & (q_gen <= qmin + tol)
    return (at_max | at_min), q_gen


[docs] def bake_outer_loops( network, bake_taps: bool = True, bake_reactive_limits: bool = True, bake_generator_voltage_control_discards: bool = True, bake_active_power: bool = True, bake_active_power_control_participation: bool = True, bake_remote_voltage_control: bool = False, balance_on_loads: bool = False, load_power_factor_constant: bool = False, keep_only_main_comp: bool=True ): """Rewrite ``network`` input setpoints to the converged outer-loop state. Call this on a network that has just been solved by OLF *with* outer loops. Afterwards the network represents a plain power-flow problem: a loop-free OLF run (see :func:`get_pypowsybl_loopfree_parameters`) or a lightsim2grid run (via :func:`init_from_pypowsybl`) will reproduce the same operating point. Parameters ---------- network A pypowsybl network, freshly solved with the outer loops enabled. bake_taps Copy solved ratio/phase tap positions and shunt sections into the input positions and disable their regulation. bake_reactive_limits Freeze generators / VSC stations that hit a Q limit to fixed-Q (PQ). bake_generator_voltage_control_discards Freeze generators OLF's own voltage-control consistency checks would discard for a reason other than "not started": too small a reactive range, or an implausible ``target_v`` (see :func:`_bake_generator_voltage_control_discards`). Also gated by ``bake_reactive_limits`` -- has no effect if that is off. bake_active_power Write realized active power back into generator/battery target P (and load p0/q0 if ``balance_on_loads``). bake_active_power_control_participation Zero out (``activePowerControl`` extension ``participate=False``) slack-distribution participation for generators OLF's own ``checkActivePowerControl`` would exclude (see :func:`_bake_active_power_control_participation`). Also gated by ``bake_active_power`` -- has no effect if that is off. bake_remote_voltage_control Rewrite remote voltage control to local control at the solved terminal voltage (see :func:`_bake_remote_voltage_control`). Needed so that remote-regulating generators can sit on a (distributed) slack bus, which lightsim2grid v1 does not otherwise support. balance_on_loads Set if the slack was distributed on loads (BalanceType PROPORTIONAL_TO_LOAD / CONFORM_LOAD). load_power_factor_constant Mirror OLF's ``loadPowerFactorConstant``: also rewrite load q0 so the power factor is preserved. keep_only_main_comp Only elements of the main connected component are updated (True by default) Notes ----- Operates in place and is idempotent on an already-baked network. The voltage-regulation flag is *not* used as a switch signal: OLF does not flip it in IIDM, so PV->PQ is detected from the realized Q sitting at a limit. """ if bake_taps: _bake_taps_and_sections(network, keep_only_main_comp) if bake_reactive_limits: _bake_reactive_limit_switches( network, keep_only_main_comp, bake_generator_voltage_control_discards ) if bake_active_power: _bake_active_power( network, balance_on_loads=balance_on_loads, load_power_factor_constant=load_power_factor_constant, keep_only_main_comp=keep_only_main_comp, bake_active_power_control_participation=bake_active_power_control_participation ) if bake_remote_voltage_control: _bake_remote_voltage_control(network, keep_only_main_comp)
def _bake_taps_and_sections(network, keep_only_main_comp=True): # Ratio tap changers (transformer voltage control outer loop). df_bus = network.get_buses(attributes=["synchronous_component"]) rtc = network.get_ratio_tap_changers( attributes=["tap", "solved_tap_position", "regulating"] ) if len(rtc): # Only adopt the solved position where the changer was actually # regulating AND a solved position exists. ``solved_tap_position`` is # NaN when the changer did not participate in an outer loop; copying it # blindly would destroy the input tap. keep = rtc["regulating"] & rtc["solved_tap_position"].notna() if keep.any(): upd = pd.DataFrame(index=rtc.index[keep]) upd["tap"] = rtc["solved_tap_position"][keep].astype(int) # upd["regulating"] = False network.update_ratio_tap_changers(upd) # Phase tap changers (phase-shifter regulation outer loop). ptc = network.get_phase_tap_changers( attributes=["tap", "solved_tap_position", "regulating"] ) if len(ptc): keep = ptc["regulating"] & ptc["solved_tap_position"].notna() if keep.any(): upd = pd.DataFrame(index=ptc.index[keep]) upd["tap"] = ptc["solved_tap_position"][keep].astype(int) # upd["regulating"] = False network.update_phase_tap_changers(upd) # Shunt compensators (shunt voltage control outer loop). sh = network.get_shunt_compensators( attributes=[ "section_count", "solved_section_count", "voltage_regulation_on", "connected", "bus_id" ] ) if keep_only_main_comp: sh = _keep_only_main_comp(sh, df_bus) if len(sh): keep = sh["voltage_regulation_on"] & sh["solved_section_count"].notna() if keep.any(): upd = pd.DataFrame(index=sh.index[keep]) upd["section_count"] = sh["solved_section_count"][keep].astype(int) upd["voltage_regulation_on"] = False network.update_shunt_compensators(upd) # Tolerance (MW) for deciding a generator's target_p is "zero": mirrors OLF's own # POWER_EPSILON_SI = 1e-4 MW (AbstractLfGenerator.checkIfGeneratorStartedForVoltageControl). _ZERO_P_TOL = 1e-4 # OLF's own PlausibleValues.MIN_REACTIVE_RANGE (1 MVar): with the default # reactiveRangeCheckMode ("MAX"), a generator whose widest reactive range across its # whole active-power range (or its fixed min_q/max_q box, for a MIN_MAX-kind generator) # falls below this is discarded from voltage control # (AbstractLfGenerator.checkIfReactiveRangesAreLargeEnoughForVoltageControl). _MIN_REACTIVE_RANGE_MVAR = 1.0 # OLF's own defaults (LfNetworkParameters): a generator's targetV, expressed in per # unit of its (regulated bus) nominal voltage, is discarded from voltage control if # outside this range -- but only on buses above the nominal-voltage floor below (this # is minNominalVoltageTargetVoltageCheck, distinct from the *realistic-voltage-check* # floor that PARAMS_STANDARD sets to 180 kV; this one is left at its own OLF default). _MIN_PLAUSIBLE_TARGET_V_PU = 0.8 _MAX_PLAUSIBLE_TARGET_V_PU = 1.2 _MIN_NOMINAL_V_FOR_TARGET_V_CHECK_KV = 20.0 # OLF's own plausibleActivePowerLimit default (MW): a generator whose maxP exceeds # this is discarded from active-power (slack) participation regardless of any other # parameter (AbstractLfGenerator.checkActivePowerControl). _MAX_PLAUSIBLE_ACTIVE_POWER_MW = 10000.0 def _bake_generator_not_started(network, keep_only_main_comp=True): """Freeze a voltage-regulating generator dispatched at (approximately) 0 MW, with a strictly positive minimum active power, to fixed-Q (PQ) -- mirroring PowSyBl OpenLoadFlow's own generator setup rule (default-on parameter ``generatorsWithZeroMwTargetAreNotStarted``, ``AbstractLfGenerator.checkIfGeneratorStartedForVoltageControl``): such a generator is declared unable to legitimately run at 0 MW (``min_p > 0``), so OLF treats it as "not started" and never sets it up as a voltage-controlling PV bus in the first place -- regardless of its ``voltage_regulator_on`` / ``target_v`` attributes, which read as if it were actively regulating. A generator with ``min_p <= 0`` (legitimately allowed to sit at 0 MW, e.g. a curtailed renewable) is NOT affected -- OLF keeps it on voltage control. ``lightsim2grid``'s own C++ analogue (``GeneratorContainer::is_pseudo_off``, gated by ``turnedoff_no_pv`` / ``LightSimBackend(turned_off_pv=False)``) does not check ``min_p`` yet (see the CHANGELOG.rst TODO) and is off by default anyway (Grid2Op semantics: an agent redispatching a generator to ~0 MW mid-episode should not silently also drop its voltage support) -- so this OLF-specific case is instead resolved here, on the pypowsybl-loading path only, the same way ``_bake_svc_standby`` resolves the SVC standby automaton. Runs before the reactive-limit-switch check below: a generator resolved to PQ here is already frozen and skipped there (it only looks at ``voltage_regulator_on == True``). """ df_bus = network.get_buses(attributes=["synchronous_component"]) gen = network.get_generators( attributes=["voltage_regulator_on", "target_p", "min_p", "q", "connected", "bus_id"] ) if keep_only_main_comp: gen = _keep_only_main_comp(gen, df_bus) not_started = ( gen["voltage_regulator_on"] & (gen["target_p"].abs() < _ZERO_P_TOL) & (gen["min_p"] > _ZERO_P_TOL) ) if not not_started.any(): return upd = pd.DataFrame(index=gen.index[not_started]) # result "q" is load/receptor convention; Generator.target_q is generator convention upd["target_q"] = -gen["q"].to_numpy()[not_started] upd["voltage_regulator_on"] = False network.update_generators(upd) def _generator_max_reactive_range(network, gen_index): """OLF's own default ``reactiveRangeCheckMode`` is ``MAX``: the widest ``max_q - min_q`` across the whole active-power range of the reactive capability curve for a CURVE-kind generator, or simply ``max_q - min_q`` for a MIN_MAX (fixed box) generator. Returns a ``pandas.Series`` of ranges (MVAr), indexed like ``gen_index``, ``NaN`` for any id not found. """ rng = pd.Series(np.nan, index=gen_index) if not len(gen_index): return rng box = network.get_generators(attributes=["reactive_limits_kind", "min_q", "max_q"]) box = box.loc[box.index.intersection(gen_index)] is_box = box["reactive_limits_kind"] == "MIN_MAX" rng.loc[box.index[is_box]] = (box["max_q"] - box["min_q"])[is_box] curve_ids = box.index[~is_box] if len(curve_ids): pts = network.get_reactive_capability_curve_points() # pts is indexed on a (id, num) MultiIndex -- intersecting it directly against # a flat Index of generator ids matches nothing, since a tuple never equals a # bare id; filter on the first level instead. pts = pts.loc[pts.index.get_level_values(0).isin(curve_ids)] if len(pts): pt_range = pts["max_q"] - pts["min_q"] rng.update(pt_range.groupby(level=0).max()) return rng def _bake_generator_voltage_control_discards(network, keep_only_main_comp=True): """Freeze a voltage-regulating generator to fixed-Q when PowSyBl OLF's own consistency checks (``AbstractLfGenerator.checkVoltageControlConsistency``) would have discarded it from voltage control for a reason other than "not started" (handled separately by :func:`_bake_generator_not_started`, which must run before this so its frozen generators are excluded here): * too small a reactive range (``checkIfReactiveRangesAreLargeEnoughForVoltageControl``, see :func:`_generator_max_reactive_range` and ``_MIN_REACTIVE_RANGE_MVAR``); * an implausible target voltage (``checkTargetV``, see ``_MIN_PLAUSIBLE_TARGET_V_PU`` / ``_MAX_PLAUSIBLE_TARGET_V_PU``). Like ``_bake_generator_not_started``, these generators never actually reached voltage control in the reference (outer-loop) run: OLF falls back to a fixed-Q injection at the raw IIDM ``target_q`` (``LfGeneratorImpl.getTargetQ()``), *not* at any computed value -- so the realized ``q`` from the reference solve already equals that fixed output, and freezing to it here is exact. Without this, lightsim2grid -- which has no equivalent of these OLF-specific plausibility screens -- would treat such a generator as a normal PV bus after baking. """ df_bus = network.get_buses(attributes=["synchronous_component"]) gen = network.get_generators( attributes=["voltage_regulator_on", "target_v", "q", "voltage_level_id", "connected", "bus_id"] ) if keep_only_main_comp: gen = _keep_only_main_comp(gen, df_bus) reg = gen[gen["voltage_regulator_on"]] if not len(reg): return nominal_v = network.get_voltage_levels(attributes=["nominal_v"])["nominal_v"] # approximation: uses the generator's own terminal nominal voltage, not the # (possibly remote) regulated bus -- same base-case approximation already # documented for _bake_remote_voltage_control. gen_nominal_v = nominal_v.reindex(reg["voltage_level_id"]).to_numpy() max_range = _generator_max_reactive_range(network, reg.index).to_numpy() too_small_range = max_range < _MIN_REACTIVE_RANGE_MVAR target_v_pu = reg["target_v"].to_numpy() / gen_nominal_v implausible_v = ( (gen_nominal_v > _MIN_NOMINAL_V_FOR_TARGET_V_CHECK_KV) & ((target_v_pu < _MIN_PLAUSIBLE_TARGET_V_PU) | (target_v_pu > _MAX_PLAUSIBLE_TARGET_V_PU)) ) mask = too_small_range | implausible_v if not mask.any(): return upd = pd.DataFrame(index=reg.index[mask]) upd["target_q"] = -reg["q"].to_numpy()[mask] upd["voltage_regulator_on"] = False network.update_generators(upd) def _bake_remote_control_bus_conflicts(network, keep_only_main_comp=True): """Freeze a *remotely*-regulating generator to fixed-Q when its OWN bus already hosts another connected, *locally* voltage-regulating generator. Physical reason: if a co-located generator locally pins this bus's voltage (holding it at its own target, within its own reactive limits), that bus's voltage is not a free network unknown from the remote controller's point of view -- AC power flow couples buses purely through voltage phasors, so how the *combined* reactive injection at that bus splits between the two co-located generators has *zero* effect on any other bus's voltage, including the one the second generator is trying to remotely control. Its own voltage target is therefore unreachable regardless of its dispatched Q. Verified on a real grid carrying this exact configuration: solving the baked (loop-free) network with OLF, with BOTH generators still nominally regulating, reproduces almost exactly the voltage obtained by freezing the remote one to its realized Q (much closer than OLF gets to the -- provably unreachable -- raw remote target itself), showing OLF itself discards the remote controller rather than actually achieving its target through free reactive power. lightsim2grid's own C++ voltage-control extension has no equivalent discard: a lone remote controller sharing a bus with a local one produces a genuinely singular Jacobian (the remote controller's own reactive-injection unknown ends up with no equation to pair with) which either crashes (``ErrorType.SolverFactor``, if the shared bus is classified as a distributed-slack participant) or raises ``LSGrid::fill_voltage_control_ solver_data``'s own "not supported in v1" error (if it lands on an ordinary PV bus) -- so this must be resolved before ``init()`` runs. Runs after :func:`_bake_generator_not_started` and :func:`_bake_generator_voltage_control_discards`: a co-located generator already frozen there (e.g. "not started", or discarded for too small a reactive range) no longer counts as "locally regulating", so a remote generator sharing its bus is correctly left alone once the true conflict is resolved. """ df_bus = network.get_buses(attributes=["synchronous_component"]) gen = network.get_generators( attributes=["voltage_regulator_on", "regulated_element_id", "q", "connected", "bus_id"] ) if keep_only_main_comp: gen = _keep_only_main_comp(gen, df_bus) reg = gen["regulated_element_id"].fillna("") is_remote = gen["voltage_regulator_on"] & (reg != "") & (reg != gen.index) is_local = gen["voltage_regulator_on"] & ~is_remote if not is_remote.any() or not is_local.any(): return local_buses = set(gen.loc[is_local, "bus_id"]) conflict = is_remote & gen["bus_id"].isin(local_buses) if not conflict.any(): return upd = pd.DataFrame(index=gen.index[conflict]) upd["target_q"] = -gen["q"].to_numpy()[conflict] upd["voltage_regulator_on"] = False network.update_generators(upd) def _bake_reactive_limit_switches( network, keep_only_main_comp=True, bake_generator_voltage_control_discards=True): _bake_generator_not_started(network, keep_only_main_comp) if bake_generator_voltage_control_discards: _bake_generator_voltage_control_discards(network, keep_only_main_comp) _bake_remote_control_bus_conflicts(network, keep_only_main_comp) df_bus = network.get_buses(attributes=["synchronous_component"]) gen = network.get_generators( attributes=[ "voltage_regulator_on", "q", "min_q", "max_q", "min_q_at_p", "max_q_at_p", "connected", "bus_id" ] ) if keep_only_main_comp: gen = _keep_only_main_comp(gen, df_bus) mask, q_gen = _bound_at_qlimit(gen, gen["voltage_regulator_on"]) if mask.any(): upd = pd.DataFrame(index=gen.index[mask]) upd["target_q"] = q_gen[mask] upd["voltage_regulator_on"] = False network.update_generators(upd) vsc = network.get_vsc_converter_stations( attributes=[ "voltage_regulator_on", "q", "min_q", "max_q", "min_q_at_p", "max_q_at_p", "connected", "bus_id" ] ) if keep_only_main_comp: vsc = _keep_only_main_comp(vsc, df_bus) if len(vsc): mask, q_gen = _bound_at_qlimit(vsc, vsc["voltage_regulator_on"]) if mask.any(): upd = pd.DataFrame(index=vsc.index[mask]) upd["target_q"] = q_gen[mask] upd["voltage_regulator_on"] = False network.update_vsc_converter_stations(upd) _bake_svc_standby(network, keep_only_main_comp) _bake_svc_saturation(network, keep_only_main_comp) def _bake_svc_standby(network, keep_only_main_comp=True): """Resolve an SVC's "standby automaton" (PowSyBl OLF's ``MonitoringVoltageOuterLoop``) to the state the outer loop actually settled on. A standby SVC (``standbyAutomaton`` extension, ``standby=True``) starts the outer loop as a fixed-susceptance shunt (``b0``, no voltage control) rather than a normal voltage-regulating device -- regardless of what its static ``regulating`` / ``regulation_mode`` / ``target_v`` attributes say. It only switches to active PV control -- targeting ``low_voltage_setpoint`` / ``high_voltage_setpoint`` -- once its own bus voltage crosses ``low_voltage_threshold`` / ``high_voltage_threshold``. Inside the deadband it stays a plain ``b0`` shunt: the realized ``q`` there already equals ``b0 * v_mag_kv ** 2`` (0 when, as commonly configured, ``b0 == 0``), so freezing it to that realized value -- like a saturated SVC -- reproduces the deadband state exactly. Runs before ``_bake_svc_saturation``: an SVC resolved to VOLTAGE mode here (crossed a threshold) is still eligible for the ordinary saturation freeze; an SVC resolved to REACTIVE_POWER here is already frozen and the saturation check leaves it alone (it only looks at ``regulation_mode == "VOLTAGE"``). """ try: automaton = network.get_extensions("standbyAutomaton") except Exception: # noqa: BLE001 - extension unsupported / absent on old pypowsybl return automaton = automaton[automaton["standby"]] if not len(automaton): return df_bus = network.get_buses(attributes=["v_mag", "synchronous_component"]) svc = network.get_static_var_compensators(attributes=["connected", "bus_id"]) svc = svc.loc[svc.index.intersection(automaton.index)] if keep_only_main_comp: svc = _keep_only_main_comp(svc, df_bus) automaton = automaton.loc[svc.index] if not len(automaton): return v_kv = df_bus.loc[svc["bus_id"].values, "v_mag"].to_numpy() above_high = v_kv > automaton["high_voltage_threshold"].to_numpy() below_low = v_kv < automaton["low_voltage_threshold"].to_numpy() active = above_high | below_low if active.any(): upd = pd.DataFrame(index=automaton.index[active]) upd["target_v"] = np.where( above_high[active], automaton["high_voltage_setpoint"].to_numpy()[active], automaton["low_voltage_setpoint"].to_numpy()[active], ) upd["regulation_mode"] = "VOLTAGE" network.update_static_var_compensators(upd) idle_ids = automaton.index[~active] if len(idle_ids): # realized "q" (receptor convention, matches target_q) already equals b0 * v^2 svc_q = network.get_static_var_compensators(attributes=["q"]).loc[idle_ids, "q"] upd = pd.DataFrame(index=idle_ids) upd["target_q"] = svc_q upd["regulation_mode"] = "REACTIVE_POWER" network.update_static_var_compensators(upd) def _bake_svc_saturation(network, keep_only_main_comp=True): """Freeze a VOLTAGE-mode SVC whose realized reactive output sits at (or beyond) its voltage-dependent susceptance envelope to fixed-Q (REACTIVE_POWER mode), mirroring ``_bake_reactive_limit_switches`` for generators/VSC stations above. Unlike a generator's fixed Q box, an SVC's reactive range is ``Q(V) = b * V^2`` (``b`` in ``b_min``..``b_max``, in Siemens): recompute ``qmin``/``qmax`` in MVAr at the SVC's own solved terminal voltage before comparing against the realized ``q``. """ df_bus = network.get_buses(attributes=["v_mag", "synchronous_component"]) svc = network.get_static_var_compensators( attributes=["regulating", "regulation_mode", "q", "b_min", "b_max", "connected", "bus_id"] ) if keep_only_main_comp: svc = _keep_only_main_comp(svc, df_bus) is_voltage = svc["regulating"] & (svc["regulation_mode"] == "VOLTAGE") svc = svc[is_voltage] if not len(svc): return v_kv = df_bus.loc[svc["bus_id"].values, "v_mag"].to_numpy() # SVC "q" (like generators') is the terminal/receptor-convention result: flip to # generator/injection convention to compare against the susceptance envelope. q_gen = -svc["q"].to_numpy() qmax = svc["b_max"].to_numpy() * v_kv ** 2 # Q[MVAr] = B[S] * V[kV]^2 qmin = svc["b_min"].to_numpy() * v_kv ** 2 tol = _q_limit_tol(qmin, qmax) mask = (q_gen >= qmax - tol) | (q_gen <= qmin + tol) if mask.any(): upd = pd.DataFrame(index=svc.index[mask]) # unlike Generator.target_q (already generator convention), StaticVarCompensator # .target_q is receptor convention like its "q" result column -- write the raw # (unflipped) realized value. upd["target_q"] = svc["q"].to_numpy()[mask] upd["regulation_mode"] = "REACTIVE_POWER" network.update_static_var_compensators(upd) def _bake_remote_voltage_control(network, keep_only_main_comp=True): """Rewrite *remote* voltage control into *local* control at the solved terminal. A generator regulating a bus other than its own terminal (``regulated_element_id`` resolving to a different bus -- e.g. holding the 400 kV grid-connection point across its step-up transformer) is rewritten to regulate its OWN terminal at that terminal's solved magnitude, and the remote regulation is cleared so the converter treats it as local control. At the converged operating point this is exact: the generator's own terminal already sits at ``v_mag`` with the realized reactive output, so fixing it locally reproduces the same fixed point (same V everywhere, same Q; the formerly-regulated remote bus still lands on its solved value). A subsequent loop-free solve -- OLF (whose loop-free parameters disable remote control anyway) or lightsim2grid -- reproduces the baked state. Why it exists: lightsim2grid v1 cannot host a remote voltage controller on a slack bus, and the default distributed slack puts many remote-regulating generators on slack buses. Making every controller local sidesteps that. .. warning:: This is a *base-case* faithful approximation. Under a topology change the generator then holds its own terminal magnitude instead of the remote bus, so the post-contingency reactive behaviour differs from true remote control. Operates in place; idempotent (an already-local generator is left untouched). """ df_bus = network.get_buses(attributes=["v_mag", "synchronous_component"]) gen = network.get_generators( attributes=["voltage_regulator_on", "regulated_element_id", "connected", "bus_id"] ) if keep_only_main_comp: gen = _keep_only_main_comp(gen, df_bus) if not len(gen): return # "remote" matches the converter's own test (see _aux_add_generators.py): a non-empty # regulated element that is not the generator's own id. reg = gen["regulated_element_id"].fillna("") remote = gen["voltage_regulator_on"] & gen["connected"] & (reg != "") & (reg != gen.index) gen = gen[remote] if not len(gen): return upd = pd.DataFrame(index=gen.index) upd["target_v"] = df_bus.loc[gen["bus_id"].values, "v_mag"].values # own terminal, kV upd["regulated_element_id"] = gen.index # regulate own terminal -> local control network.update_generators(upd) def _bake_active_power_control_participation(network, gen): """Zero out active-power (slack-distribution) participation for generators that PowSyBl OLF's own ``AbstractLfGenerator.checkActivePowerControl`` would exclude: * dispatched at (approximately) zero MW (``POWER_EPSILON_SI``, regardless of ``min_p`` -- unlike :func:`_bake_generator_not_started`, this check has no ``min_p > 0`` guard); * an implausible ``max_p`` (``_MAX_PLAUSIBLE_ACTIVE_POWER_MW``, always checked); * ``target_p`` outside ``[min_target_p, max_target_p]``, or a degenerate (``max_target_p == min_target_p``) range -- OLF's own defaults have ``useActiveLimits`` on, so these are always checked too. ``min_target_p`` / ``max_target_p`` default to ``min_p`` / ``max_p`` unless the generator's own ``activePowerControl`` extension overrides them, mirroring ``ActivePowerControlHelper``. ``gen`` must be the pre-bake generator frame (``p``, ``target_p``, ``min_p``, ``max_p``), read *before* :func:`_bake_active_power` overwrites ``target_p`` with the realized dispatch -- the discard decision is about what OLF actually used to build the reference solve, not the post-bake value. Writes ``participate=False`` into the network's own ``activePowerControl`` extension (created where absent) rather than any lightsim2grid-side state: lightsim2grid's own ``_default_distributed_slack`` (mirroring the same OLF rule) already reads exactly this extension's ``participate`` flag when computing slack weights, so this single write keeps both lightsim2grid and any subsequent pypowsybl OLF re-solve from putting slack mismatch back onto a generator OLF itself excluded from participating. """ apc = network.get_extensions("activePowerControl") min_target_p = gen["min_p"].to_numpy(copy=True) max_target_p = gen["max_p"].to_numpy(copy=True) if len(apc): common = gen.index.intersection(apc.index) if len(common): pos = gen.index.get_indexer(common) if "min_target_p" in apc.columns: v = apc.loc[common, "min_target_p"].to_numpy() ok = ~np.isnan(v) min_target_p[pos[ok]] = v[ok] if "max_target_p" in apc.columns: v = apc.loc[common, "max_target_p"].to_numpy() ok = ~np.isnan(v) max_target_p[pos[ok]] = v[ok] target_p = gen["target_p"].to_numpy() max_p = gen["max_p"].to_numpy() zero_target = np.abs(target_p) < _ZERO_P_TOL maxp_not_plausible = max_p > _MAX_PLAUSIBLE_ACTIVE_POWER_MW outside_limits = (target_p > max_target_p) | (target_p < min_target_p) degenerate_range = (max_target_p - min_target_p) < _ZERO_P_TOL excluded = zero_target | maxp_not_plausible | outside_limits | degenerate_range if not excluded.any(): return excluded_ids = gen.index[excluded] already_apc = excluded_ids.intersection(apc.index) if len(apc) else excluded_ids[:0] new_apc = excluded_ids.difference(already_apc) if len(already_apc): network.update_extensions( "activePowerControl", pd.DataFrame({"participate": False}, index=already_apc) ) if len(new_apc): network.create_extensions( "activePowerControl", pd.DataFrame({"participate": False}, index=new_apc) ) def _bake_active_power( network, balance_on_loads, load_power_factor_constant, keep_only_main_comp=True, bake_active_power_control_participation=True ): df_bus = network.get_buses(attributes=["synchronous_component"]) gen = network.get_generators(attributes=["p", "target_p", "min_p", "max_p", "connected", "bus_id"]) if keep_only_main_comp: gen = _keep_only_main_comp(gen, df_bus) if len(gen): if bake_active_power_control_participation: _bake_active_power_control_participation(network, gen) # result p is load convention; target_p is generator convention network.update_generators( pd.DataFrame({"target_p": -gen["p"]}, index=gen.index) ) bat = network.get_batteries(attributes=["p", "connected", "bus_id"]) if keep_only_main_comp: bat = _keep_only_main_comp(bat, df_bus) if len(bat): network.update_batteries( pd.DataFrame({"target_p": -bat["p"]}, index=bat.index) ) if balance_on_loads: load = network.get_loads(attributes=["p", "q", "connected", "bus_id"]) if keep_only_main_comp: load = _keep_only_main_comp(load, df_bus) if len(load): upd = pd.DataFrame(index=load.index) upd["p0"] = load["p"] # load convention both sides, no flip if load_power_factor_constant: upd["q0"] = load["q"] network.update_loads(upd)