# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0
import numpy as np
import numba as nb
from typing import Optional, Tuple, Union
from VeraGridEngine.DataStructures.numerical_circuit import NumericalCircuit
from VeraGridEngine.Topology.admittance_matrices import AdmittanceMatrices, AdmittanceMatricesFast
from VeraGridEngine.enumerations import BusMode, ShuntControlMode, GeneratorControlMode
from VeraGridEngine.basic_structures import Vec, IntVec, CxVec
[docs]
def get_q_increment(V1, V2, k):
"""
Logistic function to get the Q increment gain using the difference
between the current voltage (V1) and the target voltage (V2).
The gain varies between 0 (at V1 = V2) and inf (at V2 - V1 = inf).
The default steepness factor k was set through trial an error. Other values may
be specified as a :ref:`PowerFlowOptions<pf_options>`.
Arguments:
**V1** (float): Current voltage
**V2** (float): Target voltage
**k** (float, 30): Steepness factor
Returns:
Q increment gain
"""
return 2 * (1 / (1 + np.exp(-k * np.abs(V2 - V1))) - 0.5)
[docs]
def control_q_direct(V, Vm, Vset, Q, Qmax, Qmin, types, original_types, verbose=False):
"""
Change the buses type in order to control the generators reactive power.
:param V: Array of complex voltages
:param Vm: Array of voltage modules (for speed)
:param Vset: array of voltage Set points
:param Q: Array of reactive power values per bus
:param Qmax: Array of Qmax per bus
:param Qmin: Array of Qmin per bus
:param types: Array of bus types
:param original_types: Array of original bus types
:param verbose: More info?
:return:
**Vnew** (list): New voltage values
**Qnew** (list): New reactive power values
**types_new** (list): Modified types array
**any_control_issue** (bool): Was there any control issue?
"""
"""
Logic:
ON PV-PQ BUS TYPE SWITCHING LOGIC IN POWER FLOW COMPUTATION
Jinquan Zhao
1) Bus i is a PQ bus in the previous iteration and its
reactive power was fixed at its lower limit:
If its voltage magnitude Vi β₯ Viset, then
it is still a PQ bus at current iteration and set Qi = Qimin .
If Vi < Viset , then
compare Qi with the upper and lower limits.
If Qi β₯ Qimax , then
it is still a PQ bus but set Qi = Qimax .
If Qi β€ Qimin , then
it is still a PQ bus and set Qi = Qimin .
If Qimin < Qi < Qi max , then
it is switched to PV bus, set Vinew = Viset.
2) Bus i is a PQ bus in the previous iteration and
its reactive power was fixed at its upper limit:
If its voltage magnitude Vi β€ Viset , then:
bus i still a PQ bus and set Q i = Q i max.
If Vi > Viset , then
Compare between Qi and its upper/lower limits
If Qi β₯ Qimax , then
it is still a PQ bus and set Q i = Qimax .
If Qi β€ Qimin , then
it is still a PQ bus but let Qi = Qimin in current iteration.
If Qimin < Qi < Qimax , then
it is switched to PV bus and set Vinew = Viset
3) Bus i is a PV bus in the previous iteration.
Compare Q i with its upper and lower limits.
If Qi β₯ Qimax , then
it is switched to PQ and set Qi = Qimax .
If Qi β€ Qimin , then
it is switched to PQ and set Qi = Qimin .
If Qi min < Qi < Qimax , then
it is still a PV bus.
"""
if verbose:
print('Q control logic (fast)')
n = len(V)
Qnew = Q.copy()
Vnew = V.copy()
types_new = types.copy()
any_control_issue = False
for i in range(n):
if types[i] == BusMode.Slack_tpe.value:
pass
elif types[i] == BusMode.PQ_tpe.value and original_types[i] == BusMode.PV_tpe.value:
if Vm[i] != Vset[i]:
if Q[i] >= Qmax[i]: # it is still a PQ bus but set Q = Qmax .
Qnew[i] = Qmax[i]
elif Q[i] <= Qmin[i]: # it is still a PQ bus and set Q = Qmin .
Qnew[i] = Qmin[i]
else: # switch back to PV, set Vnew = Vset.
types_new[i] = BusMode.PV_tpe.value
Vnew[i] = complex(Vset[i], 0)
if verbose:
print('Bus', i, 'switched back to PV')
any_control_issue = True
else:
pass # The voltages are equal
elif types[i] == BusMode.PV_tpe.value:
if Q[i] >= Qmax[i]: # it is switched to PQ and set Q = Qmax .
types_new[i] = BusMode.PQ_tpe.value
Qnew[i] = Qmax[i]
any_control_issue = True
if verbose:
print('Bus', i, 'switched to PQ: Q', Q[i], ' Qmax:', Qmax[i])
elif Q[i] <= Qmin[i]: # it is switched to PQ and set Q = Qmin .
types_new[i] = BusMode.PQ_tpe.value
Qnew[i] = Qmin[i]
any_control_issue = True
if verbose:
print('Bus', i, 'switched to PQ: Q', Q[i], ' Qmin:', Qmin[i])
else: # it is still a PV bus.
pass
else:
pass
return Vnew, Qnew, types_new, any_control_issue
[docs]
@nb.njit(cache=True)
def control_q_inside_method(Scalc: CxVec, S0: CxVec,
pv: IntVec, pq: IntVec, pqv: IntVec, p: IntVec,
Qmin: Vec, Qmax: Vec):
"""
Control of reactive power within the numerical method
:param Scalc: Calculated power array (changed inside)
:param S0: Specified power array (changed inside)
:param pv: array of pv bus indices (changed inside)
:param pq: array of pq bus indices (changed inside)
:param pqv: array of pqv bus indices (changed inside)
:param p: array of p bus indices (changed inside)
:param Qmin: Array of lower reactive power limits per bus in p.u.
:param Qmax: Array of upper reactive power limits per bus in p.u.
:return: any change?, Scalc, Sbus, pv, pq, pqv, p
"""
pv_indices = list()
changed = list()
for k, i in enumerate(pv):
Q = Scalc[i].imag
if Q > Qmax[i]:
S0[i] = np.complex128(complex(S0[i].real, Qmax[i]))
changed.append(i)
pv_indices.append(k)
elif Q < Qmin[i]:
S0[i] = np.complex128(complex(S0[i].real, Qmin[i]))
changed.append(i)
pv_indices.append(k)
if len(changed) > 0:
# convert PV nodes to PQ
pq_new = np.array(changed)
pq = np.concatenate((pq, pq_new))
pv = np.delete(pv, pv_indices)
pq.sort()
return changed, pv, pq, pqv, p
[docs]
@nb.njit(cache=True)
def control_discrete_shunts(Vm: Vec,
shunt_discrete_ctrl_idx: IntVec,
shunt_discrete_bus_idx: IntVec,
shunt_discrete_vmax: Vec,
shunt_discrete_vmin: Vec,
shunt_step: IntVec,
shunt_g_steps: np.ndarray,
shunt_b_steps: np.ndarray,
shunt_n_steps: IntVec,
sbase: float):
"""
Control discrete shunts within the numerical method.
The sparse Ybus diagonal update is returned to the caller and applied outside numba.
:param Vm: Voltage module array.
:param shunt_discrete_ctrl_idx: Array of controlled shunt indices in the full shunt arrays.
:param shunt_discrete_bus_idx: Array of bus indices for the controlled shunts.
:param shunt_discrete_vmax: Array of upper voltage bounds for the controlled shunts.
:param shunt_discrete_vmin: Array of lower voltage bounds for the controlled shunts.
:param shunt_step: Array of current shunt steps in the full shunt arrays (changed inside).
:param shunt_g_steps: Packed cumulative G steps for the controlled shunts.
:param shunt_b_steps: Packed cumulative B steps for the controlled shunts.
:param shunt_n_steps: Number of valid steps for each controlled shunt.
:param sbase: Base power.
:return: changed bus indices, Ybus diagonal increments, number of changed shunts.
"""
n_ctrl = len(shunt_discrete_ctrl_idx)
changed_bus_idx = np.empty(n_ctrl, dtype=np.int64)
changed_delta_y = np.empty(n_ctrl, dtype=np.complex128)
n_changed = 0
for ctrl_k, sh_i in enumerate(shunt_discrete_ctrl_idx):
bus_i = shunt_discrete_bus_idx[ctrl_k]
step_i = shunt_step[sh_i]
if Vm[bus_i] > shunt_discrete_vmax[ctrl_k]:
# decrease B
if step_i > 0:
prev_g = shunt_g_steps[ctrl_k, step_i] / sbase
prev_b = shunt_b_steps[ctrl_k, step_i] / sbase
step_i -= 1
shunt_step[sh_i] = step_i
g = prev_g - shunt_g_steps[ctrl_k, step_i] / sbase
b = prev_b - shunt_b_steps[ctrl_k, step_i] / sbase
changed_bus_idx[n_changed] = bus_i
changed_delta_y[n_changed] = np.complex128(complex(g, b) - complex(prev_g, prev_b))
n_changed += 1
elif Vm[bus_i] < shunt_discrete_vmin[ctrl_k]:
# increase B
if step_i < (shunt_n_steps[ctrl_k] - 1):
prev_g = shunt_g_steps[ctrl_k, step_i] / sbase
prev_b = shunt_b_steps[ctrl_k, step_i] / sbase
step_i += 1
shunt_step[sh_i] = step_i
g = prev_g + shunt_g_steps[ctrl_k, step_i] / sbase
b = prev_b + shunt_b_steps[ctrl_k, step_i] / sbase
changed_bus_idx[n_changed] = bus_i
changed_delta_y[n_changed] = np.complex128(complex(g, b) - complex(prev_g, prev_b))
n_changed += 1
else:
# within boundaries
pass
return changed_bus_idx, changed_delta_y, n_changed
[docs]
@nb.njit()
def control_q_for_generalized_method(Scalc: CxVec, S0: CxVec,
pv: IntVec, i_u_vm: IntVec, i_k_q: IntVec,
Qmin: Vec, Qmax: Vec):
"""
Control of reactive power within the numerical method
Assume we only want to change regular PV buses to PQ buses (as in the conventional method)
:param Scalc: Calculated power array (changed inside)
:param S0: Specified power array (changed inside)
:param pv: array of pv bus indices (changed inside)
:param i_u_vm: array of buses with unknown Vm (changed inside)
:param i_k_q: array of buses with known Q (changed inside)
:param Qmin: Array of lower bus reactive power limits per bus in p.u.
:param Qmax: Array of upper bus reactive power limits per bus in p.u.
:return: list of changed buses, i_u_vm, i_k_q
"""
changed = list()
for k, i in enumerate(pv):
Q = Scalc[i].imag
if Q > Qmax[i]:
S0[i] = np.complex128(complex(S0[i].real, Qmax[i]))
changed.append(i)
elif Q < Qmin[i]:
S0[i] = np.complex128(complex(S0[i].real, Qmin[i]))
changed.append(i)
if len(changed) > 0:
# convert PV nodes to PQ
# we are able to avoid deletions
vm_to_q = np.array(changed)
i_k_q = np.concatenate((i_k_q, vm_to_q))
i_u_vm = np.concatenate((i_u_vm, vm_to_q))
i_k_q.sort()
i_u_vm.sort()
return changed, i_u_vm, i_k_q
[docs]
@nb.njit(cache=True)
def update_qv_droop_generators(S0: CxVec,
Q0: Vec,
generator_q: Vec,
qv_droop_bus_idx: IntVec,
qv_droop_gen_idx: IntVec,
generator_bus_idx: IntVec,
generator_k_droop: Vec,
generator_dead_band: Vec,
generator_v: Vec,
generator_qmin: Vec,
generator_qmax: Vec,
Vm: Vec) -> bool:
"""
Update generators with QV droop control within the numerical method.
:param S0: Specified power array (changed inside).
:param Q0: Initial specified reactive power per bus.
:param generator_q: Reactive power per generator (changed inside).
:param qv_droop_bus_idx: Buses hosting QV droop generators.
:param qv_droop_gen_idx: Generator indices with QV droop control.
:param generator_bus_idx: Generator-to-bus map.
:param generator_k_droop: Generator droop gain.
:param generator_dead_band: Generator dead-band.
:param generator_v: Generator voltage set point.
:param generator_qmax: Generator reactive power upper limit (p.u.).
:param generator_qmin: Generator reactive power lower limit (p.u.).
:param Vm: Voltage module array.
:return: Was there any change?
"""
any_change = False
# initialize
S0.imag[qv_droop_bus_idx] = Q0[qv_droop_bus_idx]
for k in qv_droop_gen_idx:
bus_i = generator_bus_idx[k]
k_droop = generator_k_droop[k]
db = generator_dead_band[k]
dV = generator_v[k] - Vm[bus_i]
if abs(dV) > db:
if dV > db:
any_change = True
generator_q[k] = (dV - db) * k_droop * generator_qmax[k]
if generator_q[k] > generator_qmax[k]:
generator_q[k] = generator_qmax[k]
if generator_q[k] < generator_qmin[k]:
generator_q[k] = generator_qmin[k]
S0.imag[bus_i] += generator_q[k]
elif dV < -db:
any_change = True
generator_q[k] = (dV + db) * k_droop * generator_qmax[k]
if generator_q[k] > generator_qmax[k]:
generator_q[k] = generator_qmax[k]
if generator_q[k] < generator_qmin[k]:
generator_q[k] = generator_qmin[k]
S0.imag[bus_i] += generator_q[k]
else:
pass
else:
pass
return any_change
[docs]
class DiscreteShuntControlState:
"""
Python-side state holder for discrete shunt controls.
The packed arrays owned by this class are consumed by the Numba kernel so
the formulations only need to keep a lightweight wrapper object.
"""
__slots__ = (
"_sbase",
"_shunt_discrete_ctrl_idx",
"_shunt_step",
"_shunt_discrete_bus_idx",
"_shunt_discrete_vmax",
"_shunt_discrete_vmin",
"_shunt_g_steps",
"_shunt_b_steps",
"_shunt_n_steps",
)
def __init__(self, nc: NumericalCircuit) -> None:
"""
Build the discrete shunt control state.
:param nc: Numerical circuit hosting the shunt control data.
"""
max_shunt_steps: int
n_discrete_shunts: int
sh_i: int
k: int
g_steps: IntVec
b_steps: IntVec
n_steps: int
# Store the base power once because all shunt increments are normalized by it.
self._sbase = nc.Sbase
# Select only the shunts that participate in discrete control.
self._shunt_discrete_ctrl_idx = np.where(
nc.shunt_data.control_mode_int == ShuntControlMode.Discrete.idx()
)[0]
# Copy the mutable step vector so the solver state remains local to the formulation.
self._shunt_step = nc.shunt_data.step.copy()
self._shunt_discrete_bus_idx = nc.shunt_data.bus_idx[self._shunt_discrete_ctrl_idx].copy()
self._shunt_discrete_vmax = nc.shunt_data.vmax[self._shunt_discrete_ctrl_idx].copy()
self._shunt_discrete_vmin = nc.shunt_data.vmin[self._shunt_discrete_ctrl_idx].copy()
# Pack the ragged cumulative shunt step arrays into a dense layout once
# so the compiled kernel never touches Python objects during the solve.
max_shunt_steps = 0
for sh_i in self._shunt_discrete_ctrl_idx:
max_shunt_steps = max(max_shunt_steps, len(nc.shunt_data.b_steps[sh_i]))
else:
pass
n_discrete_shunts = len(self._shunt_discrete_ctrl_idx)
self._shunt_g_steps = np.zeros((n_discrete_shunts, max_shunt_steps), dtype=float)
self._shunt_b_steps = np.zeros((n_discrete_shunts, max_shunt_steps), dtype=float)
self._shunt_n_steps = np.zeros(n_discrete_shunts, dtype=int)
for k, sh_i in enumerate(self._shunt_discrete_ctrl_idx):
g_steps = nc.shunt_data.g_steps[sh_i]
b_steps = nc.shunt_data.b_steps[sh_i]
n_steps = len(b_steps)
self._shunt_g_steps[k, :n_steps] = g_steps
self._shunt_b_steps[k, :n_steps] = b_steps
self._shunt_n_steps[k] = n_steps
else:
pass
[docs]
def get_shunt_step(self) -> IntVec:
"""
Get the mutable shunt step vector.
:return: Current discrete shunt step vector.
"""
return self._shunt_step
[docs]
def apply(self,
Vm: Vec,
adm: Union[AdmittanceMatrices, AdmittanceMatricesFast],
yshunt_bus: Optional[CxVec] = None) -> bool:
"""
Apply one discrete shunt control step.
:param Vm: Voltage magnitude vector.
:param adm: Admittance container updated in place.
:param yshunt_bus: Optional external shunt-bus vector that must remain
synchronized with ``adm.Yshunt_bus``.
:return: ``True`` if any discrete shunt changed state, ``False`` otherwise.
"""
changed_bus_idx: IntVec
changed_delta_y: CxVec
n_changed: int
k: int
bus_i: int
delta_y: np.complex128
# Evaluate the compiled control law on the packed control arrays.
changed_bus_idx, changed_delta_y, n_changed = control_discrete_shunts(
Vm=Vm,
shunt_discrete_ctrl_idx=self._shunt_discrete_ctrl_idx,
shunt_discrete_bus_idx=self._shunt_discrete_bus_idx,
shunt_discrete_vmax=self._shunt_discrete_vmax,
shunt_discrete_vmin=self._shunt_discrete_vmin,
shunt_step=self._shunt_step,
shunt_g_steps=self._shunt_g_steps,
shunt_b_steps=self._shunt_b_steps,
shunt_n_steps=self._shunt_n_steps,
sbase=self._sbase
)
if yshunt_bus is None:
yshunt_bus = adm.Yshunt_bus
else:
pass
# Propagate every shunt increment to all admittance storages that must
# stay synchronized across later fast updates or full rebuilds.
for k in range(n_changed):
bus_i = changed_bus_idx[k]
delta_y = changed_delta_y[k]
yshunt_bus[bus_i] += delta_y
if yshunt_bus is not adm.Yshunt_bus:
adm.Yshunt_bus[bus_i] += delta_y
else:
pass
adm.Ybus[bus_i, bus_i] += delta_y
else:
pass
return n_changed > 0
[docs]
class QvDroopControlState:
"""
Python-side state holder for generator QV droop controls.
The arrays are allocated once and the numerical update remains delegated to
the Numba kernel.
"""
__slots__ = (
"_Q0",
"_generator_q",
"_qv_droop_gen_idx",
"_qv_droop_bus_idx",
"_generator_bus_idx",
"_generator_k_droop",
"_generator_dead_band",
"_generator_v",
"_generator_qmax",
"_generator_qmin",
"_sbase",
)
def __init__(self, S0: CxVec, nc: NumericalCircuit) -> None:
"""
Build the QV droop control state.
:param S0: Initial specified bus power.
:param nc: Numerical circuit hosting the generator control data.
"""
# Copy the initial reactive schedules because the droop update resets
# the controlled buses to that baseline on every control pass.
self._Q0 = S0.imag.copy()
self._generator_q = nc.generator_data.q.copy()
self._qv_droop_gen_idx = np.where(
nc.generator_data.control_mode_int == GeneratorControlMode.QVDroop.idx()
)[0]
self._qv_droop_bus_idx = np.unique(nc.generator_data.bus_idx[self._qv_droop_gen_idx])
self._generator_bus_idx = nc.generator_data.bus_idx
self._generator_k_droop = nc.generator_data.k_droop
self._generator_dead_band = nc.generator_data.dead_band
self._generator_v = nc.generator_data.v
self._generator_qmin: Vec = nc.generator_data.qmin / nc.Sbase
self._generator_qmax: Vec = nc.generator_data.qmax / nc.Sbase
self._sbase = nc.Sbase
[docs]
def get_generator_q(self) -> Vec:
"""
Get the per-generator reactive power buffer.
:return: Generator reactive power vector.
"""
return self._generator_q
[docs]
def apply(self, S0: CxVec, Vm: Vec) -> bool:
"""
Apply one QV droop control step.
:param S0: Specified bus power updated in place.
:param Vm: Voltage magnitude vector.
:return: ``True`` if any droop generator changed, ``False`` otherwise.
"""
return update_qv_droop_generators(
S0=S0,
Q0=self._Q0,
generator_q=self._generator_q,
qv_droop_bus_idx=self._qv_droop_bus_idx,
qv_droop_gen_idx=self._qv_droop_gen_idx,
generator_bus_idx=self._generator_bus_idx,
generator_k_droop=self._generator_k_droop,
generator_dead_band=self._generator_dead_band,
generator_v=self._generator_v,
generator_qmin=self._generator_qmin,
generator_qmax=self._generator_qmax,
Vm=Vm
)
[docs]
def compute_slack_distribution(Scalc: CxVec, vd: IntVec, bus_installed_power: Vec) -> Tuple[bool, Vec]:
"""
Slack distribution logic
:param Scalc: Computed power array
:param vd: slack indices
:param bus_installed_power: Amount of installed power
:return: is slack division possible?
"""
# Distribute the slack power
slack_power = Scalc[vd].real.sum()
total_installed_power = bus_installed_power.sum()
if total_installed_power > 0.0:
delta = slack_power * bus_installed_power / total_installed_power
ok = True
else:
delta = np.zeros(len(Scalc))
ok = False
return ok, delta