Source code for monee.model.formulation.nlp.el
"""Polar AC power flow: a smooth non-convex NLP (sin/cos in the flow
equations). The exact model - no relaxation involved."""
import math
import numpy as np
import monee.model.phys.nonlinear.ac as opfmodel
from monee.model.core import Intermediate, IntermediateEq, PostProcess
from ..core import BranchFormulation, NodeFormulation
SQRT_3 = np.sqrt(3)
# Smoothing scale [MW] for the current-magnitude sqrt - same idiom as
# smooth_abs in model.phys.nonlinear.smooth. Without it, \sqrt{p^2+q^2} has a
# singular Jacobian at exactly zero flow, and the min=0 bounds on i_*_ka
# pin the solver onto that point (e.g. a storage at zero dispatch).
CURRENT_SMOOTHING_EPS_MW = 1e-4
[docs]
class AcPolarNlpNodeFormulation(NodeFormulation):
[docs]
def ensure_var(self, model, simulation=False, grid=None):
# Some multi-grid control nodes subclass Bus (so they match here) without
# a vm_pu attribute - only act on real voltage buses.
if simulation and hasattr(model, "vm_pu"):
model.vm_pu_squared = PostProcess(lambda v: v.vm_pu**2)
[docs]
class AcPolarNlpBranchFormulation(BranchFormulation):
[docs]
def ensure_var(self, branch, simulation=False, grid=None):
# Current magnitude and line loading are report-only quantities: nothing
# in the power-flow model consumes them, and a line-loading limit (when
# present) is a *constraint* added by the optimisation problem. Declaring
# them as passive intermediates - instead of free Vars pinned by an
# equality - means they add no variables/constraints to the model unless
# such a constraint references them, in which case the defining
# expression inlines into it. Mirrors the MISOCP formulation. For a plain
# power flow / dispatch this removes 4 vars + 4 constraints (2 nonlinear)
# per branch and only evaluates them once, after the solve, for results.
branch.i_from_ka = Intermediate(0)
branch.i_to_ka = Intermediate(0)
branch.loading_from_pu = Intermediate(0)
branch.loading_to_pu = Intermediate(0)
[docs]
def equations(self, branch, grid, from_node_model, to_node_model, **kwargs):
y = np.linalg.pinv([[branch.br_r_pu + branch.br_x_pu * 1j]])[0][0]
g, b = (np.real(y), np.imag(y))
# Built once and reused for both i_*_ka and loading_*_pu, so the sqrt
# node is shared (the two intermediates differ only by the /max_i_ka).
i_from_ka = (
(branch.p_from_mw**2 + branch.q_from_mvar**2 + CURRENT_SMOOTHING_EPS_MW**2)
** 0.5
/ (from_node_model.vars["vm_pu"] * from_node_model.vars["base_kv"])
/ SQRT_3
)
i_to_ka = (
(branch.p_to_mw**2 + branch.q_to_mvar**2 + CURRENT_SMOOTHING_EPS_MW**2)
** 0.5
/ (to_node_model.vars["vm_pu"] * to_node_model.vars["base_kv"])
/ SQRT_3
)
return [
# All four P/Q flow equations, sharing the sub-terms common across
# them (vm_from*vm_to, the angle-difference sin/cos, vm**2) so the
# symbolic graph they contribute is ~halved (see ac.int_flows).
*opfmodel.int_flows(
p_from_var=branch.p_from_mw,
q_from_var=branch.q_from_mvar,
p_to_var=branch.p_to_mw,
q_to_var=branch.q_to_mvar,
vm_from_pu=from_node_model.vars["vm_pu"],
vm_to_pu=to_node_model.vars["vm_pu"],
va_from_rad=from_node_model.vars["va_radians"],
va_to_rad=to_node_model.vars["va_radians"],
g_branch=g,
b_branch=b,
tap=branch.tap,
shift=branch.shift,
cos_impl=kwargs["cos_impl"] if "cos_impl" in kwargs else math.cos,
sin_impl=kwargs["sin_impl"] if "sin_impl" in kwargs else math.sin,
g_from=branch.g_fr_pu,
b_from=branch.b_fr_pu,
g_to_pu=branch.g_to_pu,
b_to_pu=branch.b_to_pu,
on_off=branch.on_off,
),
# Report-only intermediates: inline into a line-loading limit when one
# references them, otherwise evaluated post-solve for results only.
IntermediateEq("i_from_ka", i_from_ka),
IntermediateEq("i_to_ka", i_to_ka),
IntermediateEq("loading_from_pu", i_from_ka / branch.max_i_ka),
IntermediateEq("loading_to_pu", i_to_ka / branch.max_i_ka),
]