From 1b2fe9b8583a0abc57002d465a3f719bcd5a8b9a Mon Sep 17 00:00:00 2001 From: lukelowry Date: Tue, 14 Jul 2026 20:41:20 -0500 Subject: [PATCH 01/16] Docs and Implementation for REECB [skip ci] --- CHANGELOG.md | 1 + GridKit/CommonMath.hpp | 26 +- .../Model/PhasorDynamics/ComponentLibrary.hpp | 1 + .../PhasorDynamics/Converter/CMakeLists.txt | 1 + .../Model/PhasorDynamics/Converter/README.md | 2 +- .../Converter/REECB/CMakeLists.txt | 54 ++ .../PhasorDynamics/Converter/REECB/README.md | 473 ++++++++++++ .../PhasorDynamics/Converter/REECB/Reecb.cpp | 27 + .../PhasorDynamics/Converter/REECB/Reecb.hpp | 201 ++++++ .../Converter/REECB/ReecbData.hpp | 106 +++ .../REECB/ReecbDependencyTracking.cpp | 27 + .../Converter/REECB/ReecbEnzyme.cpp | 104 +++ .../Converter/REECB/ReecbImpl.hpp | 678 ++++++++++++++++++ GridKit/Model/PhasorDynamics/INPUT_FORMAT.md | 1 + .../Model/PhasorDynamics/SystemModelData.hpp | 3 + .../SystemModelDataJSONParser.hpp | 6 + .../Model/PhasorDynamics/SystemModelImpl.hpp | 57 ++ docs/Figures/PhasorDynamics/REECB/diagram.png | Bin 0 -> 77117 bytes .../Model/PhasorDynamics/Converter/README.md | 1 + .../PhasorDynamics/Converter/REECB/README.md | 6 + tests/UnitTests/PhasorDynamics/CMakeLists.txt | 10 + .../PhasorDynamics/ConverterReecbTests.hpp | 656 +++++++++++++++++ .../PhasorDynamics/runConverterReecbTests.cpp | 25 + 23 files changed, 2457 insertions(+), 9 deletions(-) create mode 100644 GridKit/Model/PhasorDynamics/Converter/REECB/CMakeLists.txt create mode 100644 GridKit/Model/PhasorDynamics/Converter/REECB/README.md create mode 100644 GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.cpp create mode 100644 GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp create mode 100644 GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp create mode 100644 GridKit/Model/PhasorDynamics/Converter/REECB/ReecbDependencyTracking.cpp create mode 100644 GridKit/Model/PhasorDynamics/Converter/REECB/ReecbEnzyme.cpp create mode 100644 GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp create mode 100644 docs/Figures/PhasorDynamics/REECB/diagram.png create mode 100644 docs/GridKit/Model/PhasorDynamics/Converter/REECB/README.md create mode 100644 tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp create mode 100644 tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index e552d2cf3..8e224714f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ - Remove unnecessary data copying while evaluating `PowerElectronics` models, speeding up large simulations by up to 3x - Added `HYGOV` governor model implementation for PhasorDynamics. - Added `REPCA` controller model implementation for PhasorDynamics. +- Added `REECB` controller model for PhasorDynamics. ## v0.1 diff --git a/GridKit/CommonMath.hpp b/GridKit/CommonMath.hpp index 8f9f8c5cf..f3e10d6d3 100644 --- a/GridKit/CommonMath.hpp +++ b/GridKit/CommonMath.hpp @@ -339,7 +339,8 @@ namespace GridKit * @brief Smooth anti-windup indicator for a limited state variable * * @tparam ScalarT - Scalar data type - * @tparam RealT - Real data type (see GridKit::ScalarTraits::RealT) + * @tparam LowerT - data type of the lower limit + * @tparam UpperT - data type of the upper limit * * @param[in] x - State variable * @param[in] f - Pre-limit derivative of the state variable @@ -347,14 +348,19 @@ namespace GridKit * @param[in] limit_max - Maximum limit * @return Scalar value in [0, 1]: 1 when dynamics should pass through, * 0 when integration should be blocked. + * + * @note The limit types intentionally may differ from the scalar type so + * that constant Real limits and algebraic-variable limits both work. */ - template + template __attribute__((always_inline)) inline ScalarT indicator( const ScalarT x, const ScalarT f, - const RealT limit_min, - const RealT limit_max) + const LowerT limit_min, + const UpperT limit_max) { + using RealT = typename GridKit::ScalarTraits::RealT; + assert(limit_min <= limit_max); ScalarT above_min = above(x, limit_min); @@ -374,20 +380,24 @@ namespace GridKit * and blocks motion that would push further into saturation. * * @tparam ScalarT - Scalar data type - * @tparam RealT - Real data type (see GridKit::ScalarTraits::RealT) + * @tparam LowerT - data type of the lower limit + * @tparam UpperT - data type of the upper limit * * @param[in] x - Limited state or limited output signal * @param[in] f - Pre-limit derivative * @param[in] limit_min - Minimum limit * @param[in] limit_max - Maximum limit * @return Smooth anti-windup limited derivative + * + * @note The limit types intentionally may differ from the scalar type so + * that constant Real limits and algebraic-variable limits both work. */ - template + template __attribute__((always_inline)) inline ScalarT antiwindup( const ScalarT x, const ScalarT f, - const RealT limit_min, - const RealT limit_max) + const LowerT limit_min, + const UpperT limit_max) { return indicator(x, f, limit_min, limit_max) * f; } diff --git a/GridKit/Model/PhasorDynamics/ComponentLibrary.hpp b/GridKit/Model/PhasorDynamics/ComponentLibrary.hpp index 21b7210ff..11083eee1 100644 --- a/GridKit/Model/PhasorDynamics/ComponentLibrary.hpp +++ b/GridKit/Model/PhasorDynamics/ComponentLibrary.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include diff --git a/GridKit/Model/PhasorDynamics/Converter/CMakeLists.txt b/GridKit/Model/PhasorDynamics/Converter/CMakeLists.txt index cafb2cc36..5a7dffab8 100644 --- a/GridKit/Model/PhasorDynamics/Converter/CMakeLists.txt +++ b/GridKit/Model/PhasorDynamics/Converter/CMakeLists.txt @@ -4,3 +4,4 @@ # ]] add_subdirectory(REGCA) +add_subdirectory(REECB) diff --git a/GridKit/Model/PhasorDynamics/Converter/README.md b/GridKit/Model/PhasorDynamics/Converter/README.md index ad38ba19a..ecd71d986 100644 --- a/GridKit/Model/PhasorDynamics/Converter/README.md +++ b/GridKit/Model/PhasorDynamics/Converter/README.md @@ -9,6 +9,6 @@ models and the bus equations, typically through commanded active and reactive cu The GridKit converter documentation includes: -- Renewable Energy Generator/Converter Model REGCA (See [REGCA](REGCA/README.md)) - Renewable Energy Generator/Converter Model REGCB (See [REGCB](REGCB/README.md)) - Renewable Energy Electrical Control Model REECA (See [REECA](REECA/README.md)) +- Renewable Energy Electrical Control Model REECB (See [REECB](REECB/README.md)) diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/CMakeLists.txt b/GridKit/Model/PhasorDynamics/Converter/REECB/CMakeLists.txt new file mode 100644 index 000000000..03492a503 --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/CMakeLists.txt @@ -0,0 +1,54 @@ +# [[ +# Author(s): +# - Luke Lowery +# ]] + +set(_install_headers Reecb.hpp ReecbData.hpp) + +if(GRIDKIT_ENABLE_ENZYME) + gridkit_add_library( + phasor_dynamics_converter_reecb + SOURCES ReecbEnzyme.cpp + HEADERS ${_install_headers} + INCLUDE_DIRECTORIES PRIVATE ${GRIDKIT_THIRD_PARTY_DIR}/magic-enum/include + LINK_LIBRARIES + PUBLIC + GridKit::phasor_dynamics_core + PUBLIC + GridKit::phasor_dynamics_signal + PRIVATE + ClangEnzymeFlags + COMPILE_OPTIONS + PRIVATE + -mllvm + -enzyme-auto-sparsity=1 + -fno-math-errno) +else() + gridkit_add_library( + phasor_dynamics_converter_reecb + SOURCES Reecb.cpp + HEADERS ${_install_headers} + INCLUDE_DIRECTORIES PRIVATE ${GRIDKIT_THIRD_PARTY_DIR}/magic-enum/include + LINK_LIBRARIES + PUBLIC + GridKit::phasor_dynamics_core + PUBLIC + GridKit::phasor_dynamics_signal) +endif() + +gridkit_add_library( + phasor_dynamics_converter_reecb_dependency_tracking + SOURCES ReecbDependencyTracking.cpp + INCLUDE_DIRECTORIES PRIVATE ${GRIDKIT_THIRD_PARTY_DIR}/magic-enum/include + LINK_LIBRARIES + PUBLIC + GridKit::phasor_dynamics_core + PUBLIC + GridKit::phasor_dynamics_signal_dependency_tracking) + +target_link_libraries( + phasor_dynamics_components + INTERFACE GridKit::phasor_dynamics_converter_reecb) +target_link_libraries( + phasor_dynamics_components_dependency_tracking + INTERFACE GridKit::phasor_dynamics_converter_reecb_dependency_tracking) diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/README.md b/GridKit/Model/PhasorDynamics/Converter/REECB/README.md new file mode 100644 index 000000000..b62567ea6 --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/README.md @@ -0,0 +1,473 @@ +# **Renewable Energy Electrical Control Model (REECB)** + +REECB is a WECC renewable electrical-control model for inverter-coupled +resources. + +## Notes + +- When used with REPCA active-power control, connect REPCA `pext` to REECB `pref`. + +## Block Diagram + +Standard REECB block diagram. + +![](../../../../../docs/Figures/PhasorDynamics/REECB/diagram.png) + +Figure 1: REECB block diagram. Figure courtesy of [PowerWorld](https://www.powerworld.com/WebHelp/) + +## Model Parameters + +Symbol | Units | JSON | Description | Typical Value | Note +------------------------------------|----------|----------|---------------------------------------------------------|---------------|------ +$S^\mathrm{base}$ | [MVA] | `mva` | REECB component power base | 100.0 | Required positive value; block name: `MVABase` +$s_{\mathrm{pf}}$ | [binary] | `PfFlag` | Power-factor control flag | 0 | Block name: `PfFlag`; 1 = power-factor control, 0 = Q control +$s_V$ | [binary] | `VFlag` | Voltage-control mode flag | 0 | Block name: `VFlag`; 1 = Q control, 0 = voltage control +$s_Q$ | [binary] | `QFlag` | Reactive-power control flag | 0 | Block name: `QFlag`; 1 = voltage/Q control, 0 = constant pf or Q control +$s_{PQ}$ | [binary] | `Pqflag` | P/Q priority flag for converter current limit | 0 | Block name: `Pqflag`; 0 = Q priority, 1 = P priority +$T_{\mathrm{rv}}$ | [sec] | `Trv` | Voltage-measurement filter time constant | 0.02 | State 1 +$T_{\mathrm{p}}$ | [sec] | `Tp` | Electrical-power measurement filter time constant | 0.0 | State 2 +$V_0^\mathrm{ref}$ | [p.u.] | `Vref0` | Outer-loop voltage reference | $V_{T,0}$ | Initialized from terminal voltage if omitted +$V_{\mathrm{dip}}$ | [p.u.] | `Vdip` | Low-voltage threshold for the voltage-band gate | 0.85 | +$V_{\mathrm{up}}$ | [p.u.] | `Vup` | High-voltage threshold for the voltage-band gate | 1.15 | +$D_1^\mathrm{db}$ | [p.u.] | `dbd1` | Lower deadband threshold for voltage-error response | 0.0 | +$D_2^\mathrm{db}$ | [p.u.] | `dbd2` | Upper deadband threshold for voltage-error response | 0.0 | +$K_{\mathrm{qv}}$ | [p.u.] | `kqv` | Reactive-current injection gain outside the voltage band | 5.0 | +$I_{q,\mathrm{inj}}^{\min}$ | [p.u.] | `Iql1` | Minimum reactive-current injection limit | -1.1 | +$I_{q,\mathrm{inj}}^{\max}$ | [p.u.] | `Iqh1` | Maximum reactive-current injection limit | 1.1 | +$Q^{\max}$ | [p.u.] | `Qmax` | Maximum reactive-power control limit | 0.436 | +$Q^{\min}$ | [p.u.] | `Qmin` | Minimum reactive-power control limit | -0.436 | +$K_{\mathrm{qp}}$ | [p.u.] | `Kqp` | Reactive-power control proportional gain | 0.0 | +$K_{\mathrm{qi}}$ | [p.u./s] | `Kqi` | Reactive-power control integral gain | 0.1 | +$V^{\max}$ | [p.u.] | `Vmax` | Maximum voltage-control limit | 1.1 | +$V^{\min}$ | [p.u.] | `Vmin` | Minimum voltage-control limit | 0.9 | +$K_{\mathrm{vp}}$ | [p.u.] | `Kvp` | Voltage-control proportional gain | 18.0 | +$K_{\mathrm{vi}}$ | [p.u./s] | `Kvi` | Voltage-control integral gain | 5.0 | +$T_{\mathrm{iq}}$ | [sec] | `Tiq` | Reactive-current command lag time constant | 0.02 | State 5 +$T_{\mathrm{pord}}$ | [sec] | `Tpord` | Active-power order filter time constant | 0.02 | State 6 +$R_P^{\max}$ | [p.u./s] | `dPmax` | Positive active-power order ramp-rate limit | 99.0 | +$R_P^{\min}$ | [p.u./s] | `dPmin` | Negative active-power order ramp-rate limit | -99.0 | +$P^{\max}$ | [p.u.] | `Pmax` | Maximum active-power order limit | 1.0 | +$P^{\min}$ | [p.u.] | `Pmin` | Minimum active-power order limit | 0.0 | +$I^{\max}$ | [p.u.] | `Imax` | Maximum total converter current | 1.3 | + +### Parameter Validation + +Invalid REECB parameter sets are rejected by the following checks. The displayed +equations use effective time constants with $\epsilon_T=10^{-3}$. + +```math +\begin{aligned} + T &\leftarrow \max\!\left(T, \epsilon_T\right) + \quad T\in\{T_{\mathrm{rv}},T_{\mathrm{p}}\} \\ + S^\mathrm{base} &> 0 \\ + s_{\mathrm{pf}}, s_V, s_Q, s_{PQ} + &\in \{0,1\} \\ + T_{\mathrm{rv}}, T_{\mathrm{p}} + &\ge 0 \\ + V_{\mathrm{dip}} + &< V_{\mathrm{up}} \\ + D_1^\mathrm{db} + &\le 0 \le D_2^\mathrm{db} \\ + I_{q,\mathrm{inj}}^{\min} + &\le I_{q,\mathrm{inj}}^{\max} \\ + Q^{\min} + &\le Q^{\max} \\ + V^{\min} + &\le V^{\max} \\ + T_{\mathrm{iq}}, T_{\mathrm{pord}} + &> 0 \\ + R_P^{\min} + &< 0 < R_P^{\max} \\ + P^{\min} + &\le P^{\max} \\ + I^{\max} + &\ge 0 +\end{aligned} +``` + +### Model Derived Parameters + +```math +\begin{aligned} + s_{\mathrm{pf}}^\mathrm{off} + &= 1 - s_{\mathrm{pf}} \\ + s_V^\mathrm{off} + &= 1 - s_V \\ + s_Q^\mathrm{off} + &= 1 - s_Q \\ + k_{\mathrm{base}} + &= \dfrac{S^\mathrm{sys}}{S^\mathrm{base}} +\end{aligned} +``` + +## Model Ports + +Name | Port | Init | Description +---------|--------|---------|------ +`bus` | Bus | Known | Terminal bus voltage +`pe` | Input | Unknown | Electrical active-power feedback +`qgen` | Input | Unknown | Reactive-power feedback +`qext` | Input | Unknown | External reactive-power command +`pfaref` | Input | Unknown | Power-factor angle reference +`pref` | Input | Unknown | External active-power reference +`iqcmd` | Output | Known | Reactive-current command output +`ipcmd` | Output | Known | Active-current command output + +## Model Variables + +### Internal Variables + +#### Differential + +Symbol | Units | Description | Note +------------------------|--------|-------------------------------------|------ +$V^\mathrm{meas}$ | [p.u.] | Filtered terminal voltage | State 1 in Fig. 1 +$P^\mathrm{meas}$ | [p.u.] | Filtered electrical power | State 2 in Fig. 1 +$x_Q^\mathrm{PI}$ | [p.u.] | Reactive-power PI controller state | State 3 in Fig. 1 +$x_V^\mathrm{PI}$ | [p.u.] | Voltage PI controller state | State 4 in Fig. 1 +$Q_V$ | [p.u.] | Reactive-current command lag state | State 5 in Fig. 1 +$P^\mathrm{ord}$ | [p.u.] | Filtered active-power order | State 6 in Fig. 1 + +#### Algebraic + +Symbol | Units | Description | Note +------------------------------------|----------|-------------------------------------|------ +$V_T$ | [p.u.] | Terminal voltage magnitude | +$V_{\mathrm{safe}}^\mathrm{meas}$ | [p.u.] | Safe filtered terminal voltage for divider blocks | Lower bounded by 0.01 +$s_{\mathrm{dip}}$ | [-] | Voltage inside-band control gate | +$e_V^\mathrm{db}$ | [p.u.] | Deadbanded voltage error | +$I_q^\mathrm{inj}$ | [p.u.] | Reactive-current injection candidate | Component base +$Q^\mathrm{ref}$ | [p.u.] | Selected reactive-power reference | +$e_Q$ | [p.u.] | Reactive-power control error | +$V_Q^\mathrm{PI}$ | [p.u.] | Reactive-power control PI output | +$e_V^\mathrm{PI}$ | [p.u.] | Voltage-control PI error | +$f_P^\mathrm{ord}$ | [p.u./s] | Active-power order derivative target | Before ramp-rate limit +$r_P^\mathrm{ord}$ | [p.u./s] | Ramp-rate-limited active-power order derivative target | +$I_q^\mathrm{circ}$ | [p.u.] | Reactive-current limit from converter current circle | +$I_p^\mathrm{circ}$ | [p.u.] | Active-current limit from converter current circle | +$I_q^{\max}$ | [p.u.] | Final reactive-current upper limit | Component base +$I_p^{\max}$ | [p.u.] | Final active-current upper limit | Component base +$I_q^\mathrm{base}$ | [p.u.] | Base reactive-current command | Component base +$I_q^\mathrm{raw}$ | [p.u.] | Raw reactive-current command before final limit | Component base +$I_q^\mathrm{cmd}$ | [p.u.] | Reactive-current command output | System base +$I_p^\mathrm{cmd}$ | [p.u.] | Active-current command output | System base + +### External Variables + +#### Differential +None. + +#### Algebraic + +Symbol | Units | Type | Description | Note +-------------------------------------|--------|---------|-----------------------------------|------ +$V_{\mathrm{r}}$ | [p.u.] | Known | Terminal voltage, real component | Bus input +$V_{\mathrm{i}}$ | [p.u.] | Known | Terminal voltage, imaginary component | Bus input +$P_e$ | [p.u.] | Unknown | Electrical active-power feedback | Signal port `pe`; system base +$Q^\mathrm{gen}$ | [p.u.] | Unknown | Reactive-power feedback | Signal port `qgen`; system base +$Q^\mathrm{ext}$ | [p.u.] | Unknown | External reactive-power command | Optional signal port `qext`; system base +$\phi^\mathrm{ref}$ | [rad] | Unknown | Power-factor angle reference | Optional signal port `pfaref` +$P^\mathrm{ref}$ | [p.u.] | Unknown | External active-power reference | Optional signal port `pref`; system base + +## Model Equations + +### Differential Equations + +```math +\begin{aligned} + 0 &= + -\dot{V}^\mathrm{meas} + + \dfrac{1}{T_{\mathrm{rv}}} + \left(V_T - V^\mathrm{meas}\right) \\ + 0 &= + -\dot{P}^\mathrm{meas} + + \dfrac{1}{T_{\mathrm{p}}} + \left(k_{\mathrm{base}}P_e - P^\mathrm{meas}\right) \\ + 0 &= + -\dot{x}_Q^\mathrm{PI} + + s_{\mathrm{dip}}\, + \text{antiwindup} + \left( + K_{\mathrm{qp}}e_Q + x_Q^\mathrm{PI},\, + K_{\mathrm{qi}}e_Q;\, + V^{\min}, V^{\max} + \right) \\ + 0 &= + -\dot{x}_V^\mathrm{PI} + + s_{\mathrm{dip}}\, + \text{antiwindup} + \left( + K_{\mathrm{vp}}e_V^\mathrm{PI} + x_V^\mathrm{PI},\, + K_{\mathrm{vi}}e_V^\mathrm{PI};\, + -I_q^{\max}, I_q^{\max} + \right) \\ + 0 &= + -\dot{Q}_V + + \dfrac{s_{\mathrm{dip}}}{T_{\mathrm{iq}}} + \left( + \dfrac{Q^\mathrm{ref}}{V_{\mathrm{safe}}^\mathrm{meas}} + - Q_V + \right) \\ + 0 &= + -\dot{P}^\mathrm{ord} + + s_{\mathrm{dip}}\, + \text{antiwindup} + \left(P^\mathrm{ord}, r_P^\mathrm{ord};\, P^{\min}, P^{\max}\right) +\end{aligned} +``` + +CommonMath defines the [Anti-Windup](../../../../CommonMath.md#anti-windup-indicator) +target and smooth approximation. + +### Algebraic Equations + +```math +\begin{aligned} + 0 &= + -V_T^2 + + V_{\mathrm{r}}^2 + + V_{\mathrm{i}}^2 \\ + 0 &= + -V_{\mathrm{safe}}^\mathrm{meas} + + \text{max} + \left(V^\mathrm{meas}, 0.01\right) \\ + 0 &= + -s_{\mathrm{dip}} + + \text{inside} + \left(V_T;\, V_{\mathrm{dip}}, V_{\mathrm{up}}\right) \\ + 0 &= + -e_V^\mathrm{db} + + \text{deadband2} + \left(V_0^\mathrm{ref} - V^\mathrm{meas};\, + D_1^\mathrm{db}, D_2^\mathrm{db}\right) \\ + 0 &= + -I_q^\mathrm{inj} + + \text{clamp} + \left(K_{\mathrm{qv}}e_V^\mathrm{db};\, + I_{q,\mathrm{inj}}^{\min}, I_{q,\mathrm{inj}}^{\max}\right) \\ + 0 &= + -Q^\mathrm{ref} + + s_{\mathrm{pf}}P^\mathrm{meas}\tan\!\left(\phi^\mathrm{ref}\right) + + s_{\mathrm{pf}}^\mathrm{off}k_{\mathrm{base}}Q^\mathrm{ext} \\ + 0 &= + -e_Q + + \text{clamp} + \left(Q^\mathrm{ref};\, Q^{\min}, Q^{\max}\right) + - k_{\mathrm{base}}Q^\mathrm{gen} \\ + 0 &= + -V_Q^\mathrm{PI} + + \text{clamp} + \left(K_{\mathrm{qp}}e_Q + x_Q^\mathrm{PI};\, + V^{\min}, V^{\max}\right) \\ + 0 &= + -e_V^\mathrm{PI} + + s_V V_Q^\mathrm{PI} + + s_V^\mathrm{off}Q^\mathrm{ref} + - V^\mathrm{meas} \\ + 0 &= + -f_P^\mathrm{ord} + + \dfrac{1}{T_{\mathrm{pord}}} + \left(k_{\mathrm{base}}P^\mathrm{ref} - P^\mathrm{ord}\right) \\ + 0 &= + -r_P^\mathrm{ord} + + \text{clamp} + \left(f_P^\mathrm{ord};\, R_P^{\min}, R_P^{\max}\right) \\ + 0 &= + -\left(I_q^\mathrm{circ}\right)^2 + + \left(I^{\max}\right)^2 + - s_{PQ}\left(k_{\mathrm{base}}I_p^\mathrm{cmd}\right)^2 \\ + 0 &= + -\left(I_p^\mathrm{circ}\right)^2 + + \left(I^{\max}\right)^2 + - \left(1-s_{PQ}\right)\left(k_{\mathrm{base}}I_q^\mathrm{cmd}\right)^2 \\ + 0 &= + -I_q^{\max} + + \left(1-s_{PQ}\right)I^{\max} + + s_{PQ}I_q^\mathrm{circ} \\ + 0 &= + -I_p^{\max} + + s_{PQ}I^{\max} + + \left(1-s_{PQ}\right)I_p^\mathrm{circ} \\ + 0 &= + -I_q^\mathrm{base} + + \text{clamp} + \left(K_{\mathrm{vp}}e_V^\mathrm{PI} + x_V^\mathrm{PI};\, + -I_q^{\max}, I_q^{\max}\right) \\ + 0 &= + -I_q^\mathrm{raw} + + s_Q I_q^\mathrm{base} + + s_Q^\mathrm{off}Q_V + + \left(1-s_{\mathrm{dip}}\right)I_q^\mathrm{inj} \\ + 0 &= + -k_{\mathrm{base}}I_q^\mathrm{cmd} + + \text{clamp} + \left(I_q^\mathrm{raw};\, + -I_q^{\max}, I_q^{\max}\right) \\ + 0 &= + -k_{\mathrm{base}}I_p^\mathrm{cmd} + + \text{clamp} + \left( + \dfrac{P^\mathrm{ord}}{V_{\mathrm{safe}}^\mathrm{meas}};\, + 0,\, + I_p^{\max} + \right) +\end{aligned} +``` + +CommonMath defines helper targets and smooth approximations for +[max, clamp, deadband2, and inside](../../../../CommonMath.md#derived-functions). + +## Initialization + +### Input Initialization + +```math +\begin{aligned} + V_{\mathrm{r}}, V_{\mathrm{i}} + &\leftarrow \text{terminal-bus voltage} \\ + I_q^\mathrm{cmd}, I_p^\mathrm{cmd} + &\leftarrow \text{current-command start} +\end{aligned} +``` + +### Internal Initialization + +Define + +```math +\begin{aligned} + \text{awinit}(x^\star,f;\ell,u) + &= + \begin{cases} + x^\star & f = 0 \\ + u + \epsilon_{\mathrm{sat}} & f > 0 \\ + \ell - \epsilon_{\mathrm{sat}} & f < 0 + \end{cases} +\end{aligned} +``` + +with $\epsilon_{\mathrm{sat}}>0$. + +Initialization is performed by evaluating the steady-state residuals in +dependency order. Let subscript $0$ denote initial values and set all internal +derivatives to zero: + +```math +\begin{aligned} + V_{T,0} + &= \sqrt{V_{\mathrm{r},0}^2 + V_{\mathrm{i},0}^2} \\ + V_0^\mathrm{ref} + &= V_{T,0}\quad\text{if omitted} \\ + V_0^\mathrm{meas} + &= V_{T,0} \\ + V_{\mathrm{safe},0}^\mathrm{meas} + &= \text{max}\left(V_0^\mathrm{meas}, 0.01\right) \\ + P_0^\mathrm{meas} + &= k_{\mathrm{base}}P_{e,0} \\ + s_{\mathrm{dip},0} + &= \text{inside} + \left(V_{T,0};\, V_{\mathrm{dip}}, V_{\mathrm{up}}\right) \\ + e_{V,0}^\mathrm{db} + &= + \text{deadband2} + \left(V_0^\mathrm{ref} - V_0^\mathrm{meas};\, + D_1^\mathrm{db}, D_2^\mathrm{db}\right) \\ + I_{q,0}^\mathrm{inj} + &= + \text{clamp} + \left(K_{\mathrm{qv}}e_{V,0}^\mathrm{db};\, + I_{q,\mathrm{inj}}^{\min}, I_{q,\mathrm{inj}}^{\max}\right) \\ + Q_0^\mathrm{ref} + &= + s_{\mathrm{pf}}P_0^\mathrm{meas} + \tan\!\left(\phi_0^\mathrm{ref}\right) + + s_{\mathrm{pf}}^\mathrm{off}k_{\mathrm{base}}Q_0^\mathrm{ext} \\ + e_{Q,0} + &= + \text{clamp} + \left(Q_0^\mathrm{ref};\, Q^{\min}, Q^{\max}\right) + - k_{\mathrm{base}}Q_0^\mathrm{gen} \\ + Q_{V,0} + &= \dfrac{Q_0^\mathrm{ref}}{V_{\mathrm{safe},0}^\mathrm{meas}} \\ + P_0^\mathrm{ord} + &= k_{\mathrm{base}}P_0^\mathrm{ref} \\ + f_{P,0}^\mathrm{ord} + &= 0 \\ + r_{P,0}^\mathrm{ord} + &= 0 \\ + u_{Q,0}^\mathrm{PI} + &= + \text{awinit} + \left( + s_V V_0^\mathrm{meas} + + s_V^\mathrm{off}Q_0^\mathrm{ref},\, + K_{\mathrm{qi}}e_{Q,0};\, + V^{\min}, V^{\max} + \right) \\ + V_{Q,0}^\mathrm{PI} + &= + \text{clamp} + \left(u_{Q,0}^\mathrm{PI};\, V^{\min}, V^{\max}\right) \\ + e_{V,0}^\mathrm{PI} + &= + s_V V_{Q,0}^\mathrm{PI} + + s_V^\mathrm{off}Q_0^\mathrm{ref} + - V_0^\mathrm{meas} \\ + x_{Q,0}^\mathrm{PI} + &= u_{Q,0}^\mathrm{PI} - K_{\mathrm{qp}}e_{Q,0} \\ + u_{V,0}^\mathrm{PI} + &= + \text{awinit} + \left( + k_{\mathrm{base}}Q_0^\mathrm{gen}/V_{\mathrm{safe},0}^\mathrm{meas},\, + K_{\mathrm{vi}}e_{V,0}^\mathrm{PI};\, + -I_{q,0}^{\max}, I_{q,0}^{\max} + \right) \\ + I_{q,0}^\mathrm{base} + &= + \text{clamp} + \left(u_{V,0}^\mathrm{PI};\, + -I_{q,0}^{\max}, + I_{q,0}^{\max}\right) \\ + x_{V,0}^\mathrm{PI} + &= u_{V,0}^\mathrm{PI} - K_{\mathrm{vp}}e_{V,0}^\mathrm{PI} +\end{aligned} +``` + +Initialization rejects negative current-circle radicands. + +### Output Initialization + +```math +\begin{aligned} + P_e + &\leftarrow V_{\mathrm{safe},0}^\mathrm{meas} I_{p,0}^\mathrm{cmd} \\ + Q^\mathrm{gen} + &\leftarrow V_{\mathrm{safe},0}^\mathrm{meas} I_{q,0}^\mathrm{cmd} \\ + Q^\mathrm{ext} + &\leftarrow Q_0^\mathrm{gen} \\ + \phi^\mathrm{ref} + &\leftarrow + \begin{cases} + \tan^{-1}\!\left(Q_0^\mathrm{gen}/P_{e,0}\right) & P_{e,0} \ne 0 \\ + 0 & P_{e,0} = 0 + \end{cases} \\ + P^\mathrm{ref} + &\leftarrow + \dfrac{1}{k_{\mathrm{base}}}\text{clamp} + \left(k_{\mathrm{base}}P_{e,0};\, P^{\min}, P^{\max}\right) +\end{aligned} +``` + +REECB writes the resolved feedback and reference values to attached `pe`, +`qgen`, `qext`, `pfaref`, and `pref` signal inputs. If no signal is attached, +those values are used as constant inputs. + +## Monitorable Outputs + +Output | Units | Description | Note +----------------|--------|-------------------------------------|------ +`iqcmd` | [p.u.] | Reactive-current command output | $I_q^\mathrm{cmd}$ (system base) +`ipcmd` | [p.u.] | Active-current command output | $I_p^\mathrm{cmd}$ (system base) +`vmeas` | [p.u.] | Filtered terminal voltage | $V^\mathrm{meas}$ +`pmeas` | [p.u.] | Filtered electrical power | $P^\mathrm{meas}$ (component base) diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.cpp b/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.cpp new file mode 100644 index 000000000..3b6cacabf --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.cpp @@ -0,0 +1,27 @@ +/** + * @file Reecb.cpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Non-Enzyme instantiation for the REECB electrical-control model. + */ + +#include "ReecbImpl.hpp" + +namespace GridKit +{ + namespace PhasorDynamics + { + namespace Converter + { + template + int Reecb::evaluateJacobian() + { + Log::misc() << "Evaluate Jacobian for Reecb..." << std::endl; + Log::misc() << "Jacobian evaluation is not implemented!" << std::endl; + return 0; + } + + template class Reecb; + template class Reecb; + } // namespace Converter + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp b/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp new file mode 100644 index 000000000..9d67526f6 --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp @@ -0,0 +1,201 @@ +/** + * @file Reecb.hpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Declaration of the REECB electrical-control model. + */ + +#pragma once + +#include +#include +#include + +#include +#include +#include +#include + +namespace GridKit +{ + namespace PhasorDynamics + { + template + class BusBase; + + template + class SignalNode; + + namespace Converter + { + /// Internal variables of a `Reecb`. + enum class ReecbInternalVariables : size_t + { + VMEAS, ///< Filtered terminal voltage + PMEAS, ///< Filtered electrical power + XPIQ, ///< Reactive-power PI state + XPIV, ///< Voltage PI state + QV, ///< Reactive-current command lag state + PORD, ///< Filtered active-power order + VT, ///< Terminal voltage magnitude + VMEASSAFE, ///< Safe filtered terminal voltage + SDIP, ///< Voltage inside-band control gate + VERR, ///< Deadbanded voltage error + IQV, ///< Reactive-current injection candidate + QREF, ///< Selected reactive-power reference + EQ, ///< Reactive-power control error + VPIQ, ///< Reactive-power PI output + EPIV, ///< Voltage-control PI error + FPORD, ///< Active-power order derivative before ramp limiting + RPORD, ///< Ramp-rate-limited active-power derivative + IQCIRC, ///< Reactive-current limit from current circle + IPCIRC, ///< Active-current limit from current circle + IQMAX, ///< Final reactive-current upper limit + IPMAX, ///< Final active-current upper limit + IQBASE, ///< Base reactive-current command + IQRAW, ///< Raw reactive-current command + IQCMD, ///< Reactive-current command output + IPCMD, ///< Active-current command output + MAXIMUM, + }; + + /// External variables of a `Reecb`. + enum class ReecbExternalVariables : size_t + { + PE, ///< Electrical active-power signal + QGEN, ///< Reactive-power signal + QEXT, ///< External reactive-power command + PFAREF, ///< Power-factor angle reference + PREF, ///< External active-power reference + MAXIMUM, + }; + + template + class Reecb : public Component + { + using Component::alpha_; + using Component::abs_tol_; + using Component::allocated_; + using Component::f_; + using Component::gridkit_component_id_; + using Component::J_cols_buffer_; + using Component::J_rows_buffer_; + using Component::J_vals_buffer_; + using Component::nnz_; + using Component::residual_indices_; + using Component::size_; + using Component::tag_; + using Component::va_system_base_; + using Component::variable_indices_; + using Component::wb_; + using Component::y_; + using Component::yp_; + + public: + using ScalarT = scalar_type; + using IdxT = index_type; + using RealT = typename Component::RealT; + using BusT = BusBase; + using SignalT = SignalNode; + using ModelDataT = ReecbData; + using MonitorT = Model::VariableMonitor; + + Reecb(BusT* bus); + Reecb(BusT* bus, const ModelDataT& data); + ~Reecb(); + + int setGridKitComponentID(IdxT) override final; + int allocate() override final; + int verify() const override final; + int initialize() override final; + int tagDifferentiable() override final; + int setAbsoluteTolerance(RealT) override final; + int evaluateResidual() override final; + int evaluateJacobian() override final; + + auto getSignals() + -> ComponentSignals& + { + return signals_; + } + + const Model::VariableMonitorBase* getMonitor() const override; + + __attribute__((always_inline)) inline int evaluateInternalResidual( + const ScalarT*, const ScalarT*, const ScalarT*, const ScalarT*, ScalarT*); + + private: + void initModelParams(const ModelDataT& data); + void initializeMonitor(); + void setDerivedParameters(); + + ScalarT toComponentBase(ScalarT value) const; + ScalarT toSystemBase(ScalarT value) const; + + ScalarT& Vr(); + ScalarT& Vi(); + + static constexpr RealT TIME_CONSTANT_MINIMUM = static_cast(1.0e-3); + static constexpr RealT VMEAS_MINIMUM = static_cast(0.01); + static constexpr RealT INIT_TOL = static_cast(1.0e-10); + static constexpr RealT SAT_MARGIN = static_cast(0.1); + + BusT* bus_{nullptr}; + + RealT mva_base_{static_cast(100.0)}; + RealT PfFlag_{ZERO}; + RealT VFlag_{ZERO}; + RealT QFlag_{ZERO}; + RealT Pqflag_{ZERO}; + RealT Trv_{static_cast(0.02)}; + RealT Tp_{ZERO}; + RealT Vref0_{ZERO}; + RealT Vdip_{static_cast(0.85)}; + RealT Vup_{static_cast(1.15)}; + RealT dbd1_{ZERO}; + RealT dbd2_{ZERO}; + RealT kqv_{static_cast(5.0)}; + RealT Iql1_{static_cast(-1.1)}; + RealT Iqh1_{static_cast(1.1)}; + RealT Qmax_{static_cast(0.436)}; + RealT Qmin_{static_cast(-0.436)}; + RealT Kqp_{ZERO}; + RealT Kqi_{static_cast(0.1)}; + RealT Vmax_{static_cast(1.1)}; + RealT Vmin_{static_cast(0.9)}; + RealT Kvp_{static_cast(18.0)}; + RealT Kvi_{static_cast(5.0)}; + RealT Tiq_{static_cast(0.02)}; + RealT Tpord_{static_cast(0.02)}; + RealT dPmax_{static_cast(99.0)}; + RealT dPmin_{static_cast(-99.0)}; + RealT Pmax_{ONE}; + RealT Pmin_{ZERO}; + RealT Imax_{static_cast(1.3)}; + RealT va_converter_base_{0}; + RealT Trv_eff_{TIME_CONSTANT_MINIMUM}; + RealT Tp_eff_{TIME_CONSTANT_MINIMUM}; + RealT pf_off_{1}; + RealT v_off_{1}; + RealT q_off_{1}; + + bool Vref0_given_{false}; + IdxT parameter_error_count_{0}; + + ScalarT qext_set_{0}; + ScalarT pe_set_{0}; + ScalarT qgen_set_{0}; + ScalarT pfaref_set_{0}; + ScalarT pref_set_{0}; + + ComponentSignals signals_; + std::unique_ptr monitor_; + + std::vector ws_; + std::vector ws_indices_; + }; + } // namespace Converter + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp new file mode 100644 index 000000000..c60ead105 --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp @@ -0,0 +1,106 @@ +/** + * @file ReecbData.hpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Modeling data for the REECB electrical-control model. + */ + +#pragma once + +#include + +namespace GridKit +{ + namespace PhasorDynamics + { + namespace Converter + { + /// Parameter keys for the REECB electrical-control model. + enum class ReecbParameters + { + mva, ///< REECB component power base + PfFlag, ///< Power-factor control flag + VFlag, ///< Voltage-control mode flag + QFlag, ///< Reactive-power control flag + Pqflag, ///< P/Q current-priority flag + Trv, ///< Voltage-measurement filter time constant + Tp, ///< Electrical-power measurement filter time constant + Vref0, ///< Outer-loop voltage reference + Vdip, ///< Low-voltage dip threshold + Vup, ///< High-voltage threshold + dbd1, ///< Lower voltage-error deadband threshold + dbd2, ///< Upper voltage-error deadband threshold + kqv, ///< Reactive-current injection gain + Iql1, ///< Minimum reactive-current injection limit + Iqh1, ///< Maximum reactive-current injection limit + Qmax, ///< Maximum reactive-power control limit + Qmin, ///< Minimum reactive-power control limit + Kqp, ///< Reactive-power proportional gain + Kqi, ///< Reactive-power integral gain + Vmax, ///< Maximum voltage-control limit + Vmin, ///< Minimum voltage-control limit + Kvp, ///< Voltage-control proportional gain + Kvi, ///< Voltage-control integral gain + Tiq, ///< Reactive-current command lag time constant + Tpord, ///< Active-power order filter time constant + dPmax, ///< Positive active-power ramp-rate limit + dPmin, ///< Negative active-power ramp-rate limit + Pmax, ///< Maximum active-power order limit + Pmin, ///< Minimum active-power order limit + Imax ///< Maximum converter current + }; + + /// Buses for the REECB electrical-control model. + enum class ReecbBuses : size_t + { + bus, ///< Terminal bus ID + SIZE + }; + + /// Signal inputs for the REECB electrical-control model. + enum class ReecbSignalInputs : size_t + { + pe, ///< Electrical active-power signal ID + qgen, ///< Reactive-power signal ID + qext, ///< Optional reactive-power command signal ID + pfaref, ///< Optional power-factor angle reference signal ID + pref, ///< Optional active-power reference signal ID + SIZE + }; + + /// Signal outputs for the REECB electrical-control model. + enum class ReecbSignalOutputs : size_t + { + iqcmd, ///< Reactive-current command output signal ID + ipcmd, ///< Active-current command output signal ID + SIZE + }; + + /// Variables available through the monitor interface. + enum class ReecbMonitorableVariables + { + iqcmd, ///< Reactive-current command output + ipcmd, ///< Active-current command output + vmeas, ///< Filtered terminal voltage + pmeas ///< Filtered electrical power + }; + + template + struct ReecbData : public ComponentData + { + ReecbData() = default; + + using Parameters = ReecbParameters; + using Buses = ReecbBuses; + using SignalInputs = ReecbSignalInputs; + using SignalOutputs = ReecbSignalOutputs; + using MonitorableVariables = ReecbMonitorableVariables; + }; + } // namespace Converter + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbDependencyTracking.cpp b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbDependencyTracking.cpp new file mode 100644 index 000000000..2f81e8274 --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbDependencyTracking.cpp @@ -0,0 +1,27 @@ +/** + * @file ReecbDependencyTracking.cpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Dependency-tracking instantiations for the REECB electrical-control model. + */ + +#include "ReecbImpl.hpp" + +namespace GridKit +{ + namespace PhasorDynamics + { + namespace Converter + { + template + int Reecb::evaluateJacobian() + { + Log::misc() << "Evaluate Jacobian for Reecb..." << std::endl; + Log::misc() << "Jacobian evaluation is not implemented!" << std::endl; + return 0; + } + + template class Reecb; + template class Reecb; + } // namespace Converter + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbEnzyme.cpp b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbEnzyme.cpp new file mode 100644 index 000000000..7b4967903 --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbEnzyme.cpp @@ -0,0 +1,104 @@ +/** + * @file ReecbEnzyme.cpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Enzyme sparse Jacobian for the REECB electrical-control model. + */ + +#include + +#include "ReecbImpl.hpp" + +namespace GridKit +{ + namespace PhasorDynamics + { + namespace Converter + { + template + int Reecb::evaluateJacobian() + { + Log::misc() << "Evaluate Jacobian for Reecb..." << std::endl; + Log::misc() << "Jacobian evaluation is experimental!" << std::endl; + + if (J_rows_buffer_ == nullptr) + { + auto size = static_cast(size_); + auto bus_size = static_cast(bus_->size()); + auto signal_size = ws_.size(); + auto buffer_size = 2 * size * size + size * bus_size + size * signal_size; + J_rows_buffer_ = new IdxT[buffer_size]; + J_cols_buffer_ = new IdxT[buffer_size]; + J_vals_buffer_ = new RealT[buffer_size]; + } + + using ModelT = GridKit::PhasorDynamics::Converter::Reecb; + using Fn = GridKit::Enzyme::Sparse::MemberFunctions; + + nnz_ = 0; + + GridKit::Enzyme::Sparse::DfDy::eval(this, + static_cast(f_.getSize()), + static_cast(y_.getSize()), + (this->getResidualIndices()).data(), + (this->getVariableIndices()).data(), + y_.getData(), + yp_.getData(), + wb_.data(), + ws_.data(), + J_rows_buffer_, + J_cols_buffer_, + J_vals_buffer_, + nnz_); + + GridKit::Enzyme::Sparse::DfDyp::eval(this, + static_cast(f_.getSize()), + static_cast(y_.getSize()), + (this->getResidualIndices()).data(), + (this->getVariableIndices()).data(), + y_.getData(), + yp_.getData(), + wb_.data(), + ws_.data(), + alpha_, + J_rows_buffer_, + J_cols_buffer_, + J_vals_buffer_, + nnz_); + + GridKit::Enzyme::Sparse::DfDwb::eval(this, + static_cast(f_.getSize()), + static_cast(bus_->size()), + (this->getResidualIndices()).data(), + (bus_->getVariableIndices()).data(), + y_.getData(), + yp_.getData(), + wb_.data(), + ws_.data(), + J_rows_buffer_, + J_cols_buffer_, + J_vals_buffer_, + nnz_); + + GridKit::Enzyme::Sparse::DfDws::eval(this, + static_cast(f_.getSize()), + ws_.size(), + (this->getResidualIndices()).data(), + ws_indices_.data(), + y_.getData(), + yp_.getData(), + wb_.data(), + ws_.data(), + J_rows_buffer_, + J_cols_buffer_, + J_vals_buffer_, + nnz_); + this->constructCoo(); + + return 0; + } + + template class Reecb; + template class Reecb; + } // namespace Converter + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp new file mode 100644 index 000000000..fb5545239 --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp @@ -0,0 +1,678 @@ +/** + * @file ReecbImpl.hpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Definition of the REECB electrical-control model. + */ + +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace GridKit +{ + namespace PhasorDynamics + { + namespace Converter + { + using Log = ::GridKit::Utilities::Logger; + + template + Reecb::Reecb(BusT* bus) + : bus_(bus) + { + size_ = static_cast(ReecbInternalVariables::MAXIMUM); + setDerivedParameters(); + } + + template + Reecb::Reecb(BusT* bus, const ModelDataT& data) + : bus_(bus), + monitor_(std::make_unique(data)) + { + initModelParams(data); + initializeMonitor(); + size_ = static_cast(ReecbInternalVariables::MAXIMUM); + } + + template + Reecb::~Reecb() + { + } + + template + scalar_type& Reecb::Vr() + { + return bus_->Vr(); + } + + template + scalar_type& Reecb::Vi() + { + return bus_->Vi(); + } + + template + void Reecb::setDerivedParameters() + { + va_converter_base_ = mva_base_ * static_cast(1.0e6); + Trv_eff_ = std::max(Trv_, TIME_CONSTANT_MINIMUM); + Tp_eff_ = std::max(Tp_, TIME_CONSTANT_MINIMUM); + pf_off_ = ONE - PfFlag_; + v_off_ = ONE - VFlag_; + q_off_ = ONE - QFlag_; + } + + template + scalar_type Reecb::toComponentBase( + scalar_type value) const + { + return value * va_system_base_ / va_converter_base_; + } + + template + scalar_type Reecb::toSystemBase( + scalar_type value) const + { + return value / toComponentBase(static_cast(ONE)); + } + + template + void Reecb::initModelParams(const ModelDataT& data) + { + using Params = typename ModelDataT::Parameters; + + parameter_error_count_ = 0; + Vref0_given_ = false; + + auto load_real = [&](auto key, RealT& target, const char* name) + { + if (!data.parameters.contains(key)) + { + return; + } + + const auto& value = data.parameters.at(key); + if (const auto* real_value = std::get_if(&value)) + { + target = *real_value; + } + else if (const auto* index_value = std::get_if(&value)) + { + target = static_cast(*index_value); + } + else + { + Log::error() << "Reecb: parameter '" << name << "' must be numeric\n"; + ++parameter_error_count_; + } + }; + + auto load_switch = [&](auto key, RealT& target, const char* name) + { + if (!data.parameters.contains(key)) + { + return; + } + + const auto& value = data.parameters.at(key); + if (const auto* bool_value = std::get_if(&value)) + { + target = ZERO; + if (*bool_value) + { + target = ONE; + } + } + else if (const auto* index_value = std::get_if(&value); + index_value && (*index_value == 0 || *index_value == 1)) + { + target = static_cast(*index_value); + } + else if (const auto* real_value = std::get_if(&value); + real_value && (*real_value == ZERO || *real_value == ONE) ) + { + target = *real_value; + } + else + { + Log::error() << "Reecb: parameter '" << name << "' must be bool or 0/1\n"; + ++parameter_error_count_; + } + }; + + if (!data.parameters.contains(Params::mva)) + { + Log::error() << "Reecb: missing required parameter 'mva'\n"; + ++parameter_error_count_; + } + load_real(Params::mva, mva_base_, "mva"); + load_switch(Params::PfFlag, PfFlag_, "PfFlag"); + load_switch(Params::VFlag, VFlag_, "VFlag"); + load_switch(Params::QFlag, QFlag_, "QFlag"); + load_switch(Params::Pqflag, Pqflag_, "Pqflag"); + load_real(Params::Trv, Trv_, "Trv"); + load_real(Params::Tp, Tp_, "Tp"); + if (data.parameters.contains(Params::Vref0)) + { + load_real(Params::Vref0, Vref0_, "Vref0"); + Vref0_given_ = true; + } + load_real(Params::Vdip, Vdip_, "Vdip"); + load_real(Params::Vup, Vup_, "Vup"); + load_real(Params::dbd1, dbd1_, "dbd1"); + load_real(Params::dbd2, dbd2_, "dbd2"); + load_real(Params::kqv, kqv_, "kqv"); + load_real(Params::Iql1, Iql1_, "Iql1"); + load_real(Params::Iqh1, Iqh1_, "Iqh1"); + load_real(Params::Qmax, Qmax_, "Qmax"); + load_real(Params::Qmin, Qmin_, "Qmin"); + load_real(Params::Kqp, Kqp_, "Kqp"); + load_real(Params::Kqi, Kqi_, "Kqi"); + load_real(Params::Vmax, Vmax_, "Vmax"); + load_real(Params::Vmin, Vmin_, "Vmin"); + load_real(Params::Kvp, Kvp_, "Kvp"); + load_real(Params::Kvi, Kvi_, "Kvi"); + load_real(Params::Tiq, Tiq_, "Tiq"); + load_real(Params::Tpord, Tpord_, "Tpord"); + load_real(Params::dPmax, dPmax_, "dPmax"); + load_real(Params::dPmin, dPmin_, "dPmin"); + load_real(Params::Pmax, Pmax_, "Pmax"); + load_real(Params::Pmin, Pmin_, "Pmin"); + load_real(Params::Imax, Imax_, "Imax"); + setDerivedParameters(); + } + + template + const Model::VariableMonitorBase* Reecb::getMonitor() const + { + return monitor_.get(); + } + + template + void Reecb::initializeMonitor() + { + using Variable = typename ModelDataT::MonitorableVariables; + auto index = [](ReecbInternalVariables variable) + { + return static_cast(variable); + }; + + monitor_->set(Variable::iqcmd, [this, index] + { return y_.getData()[index(ReecbInternalVariables::IQCMD)]; }); + monitor_->set(Variable::ipcmd, [this, index] + { return y_.getData()[index(ReecbInternalVariables::IPCMD)]; }); + monitor_->set(Variable::vmeas, [this, index] + { return y_.getData()[index(ReecbInternalVariables::VMEAS)]; }); + monitor_->set(Variable::pmeas, [this, index] + { return y_.getData()[index(ReecbInternalVariables::PMEAS)]; }); + } + + template + int Reecb::setGridKitComponentID(IdxT component_id) + { + gridkit_component_id_ = component_id; + return 0; + } + + template + int Reecb::allocate() + { + if (!allocated_) + { + this->allocateVectors(size_); + } + auto size = static_cast(size_); + + tag_.assign(size, false); + variable_indices_.resize(size); + residual_indices_.resize(size); + + wb_.assign(2, ScalarT{0}); + + auto signal_size = static_cast(ReecbExternalVariables::MAXIMUM); + ws_.assign(signal_size, ScalarT{0}); + ws_indices_.assign(signal_size, INVALID_INDEX); + + for (IdxT j = 0; j < size_; ++j) + { + this->setVariableIndex(j, j); + this->setResidualIndex(j, j); + } + + if (signals_.template isAssigned()) + { + auto* y = y_.getData(); + signals_.template getSignalNode()->set( + &y[static_cast(ReecbInternalVariables::IQCMD)], + &(this->getVariableIndex(static_cast(ReecbInternalVariables::IQCMD)))); + } + + if (signals_.template isAssigned()) + { + auto* y = y_.getData(); + signals_.template getSignalNode()->set( + &y[static_cast(ReecbInternalVariables::IPCMD)], + &(this->getVariableIndex(static_cast(ReecbInternalVariables::IPCMD)))); + } + + allocated_ = true; + return 0; + } + + template + int Reecb::verify() const + { + int ret = static_cast(parameter_error_count_); + + auto check = [&](bool condition, const char* message) + { + if (!condition) + { + Log::error() << "Reecb: " << message << '\n'; + ret += 1; + } + }; + + if (bus_ == nullptr) + { + Log::error() << "Reecb: bus pointer is null\n"; + ret += 1; + } + + check(mva_base_ > ZERO, "mva must be positive"); + check(va_converter_base_ > ZERO, "converter VA base must be positive"); + check(PfFlag_ == ZERO || PfFlag_ == ONE, "PfFlag must be 0 or 1"); + check(VFlag_ == ZERO || VFlag_ == ONE, "VFlag must be 0 or 1"); + check(QFlag_ == ZERO || QFlag_ == ONE, "QFlag must be 0 or 1"); + check(Pqflag_ == ZERO || Pqflag_ == ONE, "Pqflag must be 0 or 1"); + check(Trv_ >= ZERO, "Trv must be non-negative"); + check(Tp_ >= ZERO, "Tp must be non-negative"); + check(Vdip_ < Vup_, "Vdip must be less than Vup"); + check(dbd1_ <= ZERO && ZERO <= dbd2_, "dbd1 <= 0 <= dbd2 is required"); + check(Iql1_ <= Iqh1_, "Iql1 must be less than or equal to Iqh1"); + check(Qmin_ <= Qmax_, "Qmin must be less than or equal to Qmax"); + check(Vmin_ <= Vmax_, "Vmin must be less than or equal to Vmax"); + check(Tiq_ > ZERO, "Tiq must be positive"); + check(Tpord_ > ZERO, "Tpord must be positive"); + check(dPmin_ < ZERO && ZERO < dPmax_, "dPmin < 0 < dPmax is required"); + check(Pmin_ <= Pmax_, "Pmin must be less than or equal to Pmax"); + check(Imax_ >= ZERO, "Imax must be non-negative"); + + auto check_attached_signal = [&](bool attached, bool linked, const char* name) + { + if (attached && !linked) + { + Log::error() << "Reecb: " << name << " signal attached with no linked variable\n"; + ret += 1; + } + }; + + check_attached_signal( + signals_.template isAttached(), + signals_.template isAttached() + && signals_.template isLinked(), + "pe"); + check_attached_signal( + signals_.template isAttached(), + signals_.template isAttached() + && signals_.template isLinked(), + "qgen"); + check_attached_signal( + signals_.template isAttached(), + signals_.template isAttached() + && signals_.template isLinked(), + "qext"); + check_attached_signal( + signals_.template isAttached(), + signals_.template isAttached() + && signals_.template isLinked(), + "pfaref"); + check_attached_signal( + signals_.template isAttached(), + signals_.template isAttached() + && signals_.template isLinked(), + "pref"); + + return ret; + } + + template + int Reecb::initialize() + { + if (parameter_error_count_ > 0 || verify() > 0) + { + Log::error() << "Reecb: cannot initialize with invalid configuration\n"; + return 1; + } + + auto* y = y_.getData(); + auto* yp = yp_.getData(); + + const auto VMEAS = static_cast(ReecbInternalVariables::VMEAS); + const auto PMEAS = static_cast(ReecbInternalVariables::PMEAS); + const auto XPIQ = static_cast(ReecbInternalVariables::XPIQ); + const auto XPIV = static_cast(ReecbInternalVariables::XPIV); + const auto QV = static_cast(ReecbInternalVariables::QV); + const auto PORD = static_cast(ReecbInternalVariables::PORD); + const auto VT = static_cast(ReecbInternalVariables::VT); + const auto VMEASSAFE = static_cast(ReecbInternalVariables::VMEASSAFE); + const auto SDIP = static_cast(ReecbInternalVariables::SDIP); + const auto VERR = static_cast(ReecbInternalVariables::VERR); + const auto IQV = static_cast(ReecbInternalVariables::IQV); + const auto QREF = static_cast(ReecbInternalVariables::QREF); + const auto EQ = static_cast(ReecbInternalVariables::EQ); + const auto VPIQ = static_cast(ReecbInternalVariables::VPIQ); + const auto EPIV = static_cast(ReecbInternalVariables::EPIV); + const auto FPORD = static_cast(ReecbInternalVariables::FPORD); + const auto RPORD = static_cast(ReecbInternalVariables::RPORD); + const auto IQCIRC = static_cast(ReecbInternalVariables::IQCIRC); + const auto IPCIRC = static_cast(ReecbInternalVariables::IPCIRC); + const auto IQMAX = static_cast(ReecbInternalVariables::IQMAX); + const auto IPMAX = static_cast(ReecbInternalVariables::IPMAX); + const auto IQBASE = static_cast(ReecbInternalVariables::IQBASE); + const auto IQRAW = static_cast(ReecbInternalVariables::IQRAW); + const auto IQCMD = static_cast(ReecbInternalVariables::IQCMD); + const auto IPCMD = static_cast(ReecbInternalVariables::IPCMD); + + const ScalarT vr = Vr(); + const ScalarT vi = Vi(); + + y[VT] = std::sqrt(vr * vr + vi * vi); + if (!Vref0_given_) + { + Vref0_ = static_cast(y[VT]); + } + + y[VMEAS] = y[VT]; + y[VMEASSAFE] = Math::max(y[VMEAS], VMEAS_MINIMUM); + + const ScalarT ipcmd0 = toComponentBase(y[IPCMD]); + const ScalarT iqcmd0 = toComponentBase(y[IQCMD]); + ScalarT pe0 = ipcmd0 * y[VMEASSAFE]; + ScalarT qgen0 = iqcmd0 * y[VMEASSAFE]; + + const ScalarT qext0 = qgen0; + const ScalarT pref0 = Math::clamp(pe0, Pmin_, Pmax_); + + pe_set_ = toSystemBase(pe0); + qgen_set_ = toSystemBase(qgen0); + qext_set_ = toSystemBase(qext0); + pfaref_set_ = std::abs(static_cast(pe0)) > INIT_TOL ? static_cast(std::atan(static_cast(qgen0 / pe0))) : static_cast(ZERO); + pref_set_ = toSystemBase(pref0); + + if (signals_.template isAttached()) + { + signals_.template writeExternalVariable(pe_set_); + } + if (signals_.template isAttached()) + { + signals_.template writeExternalVariable(qgen_set_); + } + if (signals_.template isAttached()) + { + signals_.template writeExternalVariable(qext_set_); + } + if (signals_.template isAttached()) + { + signals_.template writeExternalVariable(pfaref_set_); + } + if (signals_.template isAttached()) + { + signals_.template writeExternalVariable(pref_set_); + } + + y[PMEAS] = pe0; + y[SDIP] = Math::inside(y[VT], Vdip_, Vup_); + y[VERR] = Math::deadband2(Vref0_ - y[VMEAS], dbd1_, dbd2_); + y[IQV] = Math::clamp(kqv_ * y[VERR], Iql1_, Iqh1_); + y[QREF] = PfFlag_ * y[PMEAS] * std::tan(pfaref_set_) + pf_off_ * qext0; + y[EQ] = Math::clamp(y[QREF], Qmin_, Qmax_) - qgen0; + y[QV] = y[QREF] / y[VMEASSAFE]; + y[PORD] = pref0; + y[FPORD] = ZERO; + y[RPORD] = ZERO; + + auto awinit = [](const ScalarT target, const ScalarT rate, const ScalarT lower, const ScalarT upper) -> ScalarT + { + if (std::abs(static_cast(rate)) <= INIT_TOL) + { + return target; + } + return rate > ZERO ? upper + static_cast(SAT_MARGIN) : lower - static_cast(SAT_MARGIN); + }; + + const ScalarT vpiq_arg = awinit(VFlag_ * y[VMEAS] + v_off_ * y[QREF], Kqi_ * y[EQ], static_cast(Vmin_), static_cast(Vmax_)); + y[VPIQ] = Math::clamp(vpiq_arg, Vmin_, Vmax_); + y[EPIV] = VFlag_ * y[VPIQ] + v_off_ * y[QREF] - y[VMEAS]; + y[XPIQ] = vpiq_arg - Kqp_ * y[EQ]; + + const ScalarT iqbase_target = qgen0 / y[VMEASSAFE]; + const ScalarT ip_star = y[PORD] / y[VMEASSAFE]; + + auto initializeReactiveBase = [&]() + { + const ScalarT piv_arg = awinit(iqbase_target, Kvi_ * y[EPIV], -y[IQMAX], y[IQMAX]); + y[IQBASE] = Math::clamp(piv_arg, -y[IQMAX], y[IQMAX]); + y[XPIV] = piv_arg - Kvp_ * y[EPIV]; + }; + + auto initializeReactiveCommand = [&]() + { + y[IQRAW] = QFlag_ * y[IQBASE] + q_off_ * y[QV] + (ONE - y[SDIP]) * y[IQV]; + y[IQCMD] = toSystemBase(Math::clamp(y[IQRAW], -y[IQMAX], y[IQMAX])); + }; + + if (Pqflag_ == ZERO) + { + y[IQCIRC] = Imax_; + y[IQMAX] = Imax_; + initializeReactiveBase(); + initializeReactiveCommand(); + + const ScalarT iqcmd = toComponentBase(y[IQCMD]); + const ScalarT ip_radicand = Imax_ * Imax_ - iqcmd * iqcmd; + if (static_cast(ip_radicand) < ZERO) + { + Log::error() << "Reecb: initial active-current circle radicand is negative\n"; + return 1; + } + y[IPCIRC] = std::sqrt(ip_radicand); + y[IPMAX] = y[IPCIRC]; + y[IPCMD] = toSystemBase(Math::clamp(ip_star, ZERO, y[IPMAX])); + } + else + { + y[IPCIRC] = Imax_; + y[IPMAX] = Imax_; + y[IPCMD] = toSystemBase(Math::clamp(ip_star, ZERO, y[IPMAX])); + + const ScalarT ipcmd = toComponentBase(y[IPCMD]); + const ScalarT iq_radicand = Imax_ * Imax_ - ipcmd * ipcmd; + if (static_cast(iq_radicand) < ZERO) + { + Log::error() << "Reecb: initial reactive-current circle radicand is negative\n"; + return 1; + } + y[IQCIRC] = std::sqrt(iq_radicand); + y[IQMAX] = y[IQCIRC]; + initializeReactiveBase(); + initializeReactiveCommand(); + } + + for (IdxT i = 0; i < yp_.getSize(); ++i) + { + yp[i] = ZERO; + } + + y_.setDataUpdated(); + yp_.setDataUpdated(); + + return 0; + } + + template + int Reecb::tagDifferentiable() + { + std::fill(tag_.begin(), tag_.end(), false); + tag_[static_cast(ReecbInternalVariables::VMEAS)] = true; + tag_[static_cast(ReecbInternalVariables::PMEAS)] = true; + tag_[static_cast(ReecbInternalVariables::XPIQ)] = true; + tag_[static_cast(ReecbInternalVariables::XPIV)] = true; + tag_[static_cast(ReecbInternalVariables::QV)] = true; + tag_[static_cast(ReecbInternalVariables::PORD)] = true; + return 0; + } + + template + int Reecb::setAbsoluteTolerance(RealT rel_tol) + { + abs_tol_.setToConst(static_cast(rel_tol)); + return 0; + } + + template + __attribute__((always_inline)) inline int + Reecb::evaluateInternalResidual( + const ScalarT* y, + const ScalarT* yp, + const ScalarT* wb, + const ScalarT* ws, + ScalarT* f) + { + const auto VMEAS = static_cast(ReecbInternalVariables::VMEAS); + const auto PMEAS = static_cast(ReecbInternalVariables::PMEAS); + const auto XPIQ = static_cast(ReecbInternalVariables::XPIQ); + const auto XPIV = static_cast(ReecbInternalVariables::XPIV); + const auto QV = static_cast(ReecbInternalVariables::QV); + const auto PORD = static_cast(ReecbInternalVariables::PORD); + const auto VT = static_cast(ReecbInternalVariables::VT); + const auto VMEASSAFE = static_cast(ReecbInternalVariables::VMEASSAFE); + const auto SDIP = static_cast(ReecbInternalVariables::SDIP); + const auto VERR = static_cast(ReecbInternalVariables::VERR); + const auto IQV = static_cast(ReecbInternalVariables::IQV); + const auto QREF = static_cast(ReecbInternalVariables::QREF); + const auto EQ = static_cast(ReecbInternalVariables::EQ); + const auto VPIQ = static_cast(ReecbInternalVariables::VPIQ); + const auto EPIV = static_cast(ReecbInternalVariables::EPIV); + const auto FPORD = static_cast(ReecbInternalVariables::FPORD); + const auto RPORD = static_cast(ReecbInternalVariables::RPORD); + const auto IQCIRC = static_cast(ReecbInternalVariables::IQCIRC); + const auto IPCIRC = static_cast(ReecbInternalVariables::IPCIRC); + const auto IQMAX = static_cast(ReecbInternalVariables::IQMAX); + const auto IPMAX = static_cast(ReecbInternalVariables::IPMAX); + const auto IQBASE = static_cast(ReecbInternalVariables::IQBASE); + const auto IQRAW = static_cast(ReecbInternalVariables::IQRAW); + const auto IQCMD = static_cast(ReecbInternalVariables::IQCMD); + const auto IPCMD = static_cast(ReecbInternalVariables::IPCMD); + + const auto PE = static_cast(ReecbExternalVariables::PE); + const auto QGEN = static_cast(ReecbExternalVariables::QGEN); + const auto QEXT = static_cast(ReecbExternalVariables::QEXT); + const auto PFAREF = static_cast(ReecbExternalVariables::PFAREF); + const auto PREF = static_cast(ReecbExternalVariables::PREF); + + const ScalarT vr = wb[0]; + const ScalarT vi = wb[1]; + + const ScalarT pe = toComponentBase(ws[PE]); + const ScalarT qgen = toComponentBase(ws[QGEN]); + const ScalarT qext = toComponentBase(ws[QEXT]); + const ScalarT pfaref = ws[PFAREF]; + const ScalarT pref = toComponentBase(ws[PREF]); + const ScalarT iqcmd = toComponentBase(y[IQCMD]); + const ScalarT ipcmd = toComponentBase(y[IPCMD]); + + f[VMEAS] = -yp[VMEAS] + (y[VT] - y[VMEAS]) / Trv_eff_; + f[PMEAS] = -yp[PMEAS] + (pe - y[PMEAS]) / Tp_eff_; + f[XPIQ] = -yp[XPIQ] + y[SDIP] * Math::antiwindup(Kqp_ * y[EQ] + y[XPIQ], Kqi_ * y[EQ], Vmin_, Vmax_); + f[XPIV] = -yp[XPIV] + y[SDIP] * Math::antiwindup(Kvp_ * y[EPIV] + y[XPIV], Kvi_ * y[EPIV], -y[IQMAX], y[IQMAX]); + f[QV] = -yp[QV] + y[SDIP] * (y[QREF] / y[VMEASSAFE] - y[QV]) / Tiq_; + f[PORD] = -yp[PORD] + y[SDIP] * Math::antiwindup(y[PORD], y[RPORD], Pmin_, Pmax_); + f[VT] = -y[VT] * y[VT] + vr * vr + vi * vi; + f[VMEASSAFE] = -y[VMEASSAFE] + Math::max(y[VMEAS], VMEAS_MINIMUM); + f[SDIP] = -y[SDIP] + Math::inside(y[VT], Vdip_, Vup_); + f[VERR] = -y[VERR] + Math::deadband2(Vref0_ - y[VMEAS], dbd1_, dbd2_); + f[IQV] = -y[IQV] + Math::clamp(kqv_ * y[VERR], Iql1_, Iqh1_); + f[QREF] = -y[QREF] + PfFlag_ * y[PMEAS] * std::tan(pfaref) + pf_off_ * qext; + f[EQ] = -y[EQ] + Math::clamp(y[QREF], Qmin_, Qmax_) - qgen; + f[VPIQ] = -y[VPIQ] + Math::clamp(Kqp_ * y[EQ] + y[XPIQ], Vmin_, Vmax_); + f[EPIV] = -y[EPIV] + VFlag_ * y[VPIQ] + v_off_ * y[QREF] - y[VMEAS]; + f[FPORD] = -y[FPORD] + (pref - y[PORD]) / Tpord_; + f[RPORD] = -y[RPORD] + Math::clamp(y[FPORD], dPmin_, dPmax_); + f[IQCIRC] = -y[IQCIRC] * y[IQCIRC] + Imax_ * Imax_ - Pqflag_ * ipcmd * ipcmd; + f[IPCIRC] = -y[IPCIRC] * y[IPCIRC] + Imax_ * Imax_ - (ONE - Pqflag_) * iqcmd * iqcmd; + f[IQMAX] = -y[IQMAX] + (ONE - Pqflag_) * Imax_ + Pqflag_ * y[IQCIRC]; + f[IPMAX] = -y[IPMAX] + Pqflag_ * Imax_ + (ONE - Pqflag_) * y[IPCIRC]; + f[IQBASE] = -y[IQBASE] + Math::clamp(Kvp_ * y[EPIV] + y[XPIV], -y[IQMAX], y[IQMAX]); + f[IQRAW] = -y[IQRAW] + QFlag_ * y[IQBASE] + q_off_ * y[QV] + (ONE - y[SDIP]) * y[IQV]; + f[IQCMD] = -y[IQCMD] + toSystemBase(Math::clamp(y[IQRAW], -y[IQMAX], y[IQMAX])); + f[IPCMD] = -y[IPCMD] + toSystemBase(Math::clamp(y[PORD] / y[VMEASSAFE], ZERO, y[IPMAX])); + + return 0; + } + + template + int Reecb::evaluateResidual() + { + const auto PE = static_cast(ReecbExternalVariables::PE); + const auto QGEN = static_cast(ReecbExternalVariables::QGEN); + const auto QEXT = static_cast(ReecbExternalVariables::QEXT); + const auto PFAREF = static_cast(ReecbExternalVariables::PFAREF); + const auto PREF = static_cast(ReecbExternalVariables::PREF); + + ws_[PE] = pe_set_; + ws_[QGEN] = qgen_set_; + ws_[QEXT] = qext_set_; + ws_[PFAREF] = pfaref_set_; + ws_[PREF] = pref_set_; + std::fill(ws_indices_.begin(), ws_indices_.end(), INVALID_INDEX); + + if (signals_.template isAttached()) + { + ws_[PE] = signals_.template readExternalVariable(); + ws_indices_[PE] = signals_.template readExternalVariableIndex(); + } + if (signals_.template isAttached()) + { + ws_[QGEN] = signals_.template readExternalVariable(); + ws_indices_[QGEN] = signals_.template readExternalVariableIndex(); + } + if (signals_.template isAttached()) + { + ws_[QEXT] = signals_.template readExternalVariable(); + ws_indices_[QEXT] = signals_.template readExternalVariableIndex(); + } + if (signals_.template isAttached()) + { + ws_[PFAREF] = signals_.template readExternalVariable(); + ws_indices_[PFAREF] = signals_.template readExternalVariableIndex(); + } + if (signals_.template isAttached()) + { + ws_[PREF] = signals_.template readExternalVariable(); + ws_indices_[PREF] = signals_.template readExternalVariableIndex(); + } + + wb_[0] = Vr(); + wb_[1] = Vi(); + + const auto* y = y_.getData(); + const auto* yp = yp_.getData(); + auto* f = f_.getData(); + + evaluateInternalResidual(y, yp, wb_.data(), ws_.data(), f); + f_.setDataUpdated(); + return 0; + } + } // namespace Converter + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/INPUT_FORMAT.md b/GridKit/Model/PhasorDynamics/INPUT_FORMAT.md index 8ae69fcb1..98a2b98df 100644 --- a/GridKit/Model/PhasorDynamics/INPUT_FORMAT.md +++ b/GridKit/Model/PhasorDynamics/INPUT_FORMAT.md @@ -152,6 +152,7 @@ are specified: [Gensal](SynchronousMachine/GENSAL/README.md) | 5th order salient-pole machine model [GenClassical](SynchronousMachine/GenClassical/README.md) | the classical machine model [Regca](Converter/REGCA/README.md) | WECC REGCA renewable generator/converter model + [Reecb](Controller/REECB/README.md) | the REECB renewable electrical-control model [Repca](Controller/REPCA/README.md) | the REPCA renewable plant-control model [Tgov1](Governor/Tgov1/README.md) | the TGOV1 governor model [Hygov](Governor/HYGOV/README.md) | the HYGOV hydro turbine-governor model diff --git a/GridKit/Model/PhasorDynamics/SystemModelData.hpp b/GridKit/Model/PhasorDynamics/SystemModelData.hpp index 224c95e3c..7878c5006 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelData.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelData.hpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -46,6 +47,7 @@ namespace GridKit using BusToSignalAdapterDataT = BusToSignalAdapterData; using BusFaultDataT = BusFaultData; using RegcaDataT = Converter::RegcaData; + using ReecbDataT = Converter::ReecbData; using RepcaDataT = Controller::RepcaData; using Tgov1DataT = Governor::Tgov1Data; using Esdc1aDataT = Exciter::Esdc1aData; @@ -104,6 +106,7 @@ namespace GridKit std::vector branch; ///< Branches within the model std::vector bus_fault; ///< Bus faults within the model std::vector regca; ///< REGCA converter instances within the model + std::vector reecb; ///< REECB electrical controllers within the model std::vector repca; ///< REPCA plant controllers within the model std::vector genrou; ///< GENROU instances within the model std::vector gensal; ///< GENSAL instances within the model diff --git a/GridKit/Model/PhasorDynamics/SystemModelDataJSONParser.hpp b/GridKit/Model/PhasorDynamics/SystemModelDataJSONParser.hpp index c1701687a..6847c769f 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelDataJSONParser.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelDataJSONParser.hpp @@ -141,6 +141,12 @@ namespace GridKit raw_component.get_to(regca); sm.regca.push_back(regca); } + else if (kind == "Reecb") + { + typename SystemModelData::ReecbDataT reecb; + raw_component.get_to(reecb); + sm.reecb.push_back(reecb); + } else if (kind == "Repca") { typename SystemModelData::RepcaDataT repca; diff --git a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp index 1edc3f62c..4bba2509f 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp @@ -163,6 +163,63 @@ namespace GridKit addComponent(regca); } + // Add REECB electrical controllers + for (const auto& reecbdata : data.reecb) + { + IdxT bus_index = 0; + if (reecbdata.buses.contains(ReecbBuses::bus)) + { + bus_index = reecbdata.buses.at(ReecbBuses::bus); + } + + auto* reecb = new Reecb(getBus(bus_index), reecbdata); + + if (reecbdata.signal_inputs.contains(ReecbSignalInputs::pe)) + { + const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::pe); + constexpr auto PE = ReecbExternalVariables::PE; + reecb->getSignals().template attachSignalNode(getSignal(signal)); + } + if (reecbdata.signal_inputs.contains(ReecbSignalInputs::qgen)) + { + const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::qgen); + constexpr auto QGEN = ReecbExternalVariables::QGEN; + reecb->getSignals().template attachSignalNode(getSignal(signal)); + } + if (reecbdata.signal_inputs.contains(ReecbSignalInputs::qext)) + { + const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::qext); + constexpr auto QEXT = ReecbExternalVariables::QEXT; + reecb->getSignals().template attachSignalNode(getSignal(signal)); + } + if (reecbdata.signal_inputs.contains(ReecbSignalInputs::pfaref)) + { + const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::pfaref); + constexpr auto PFAREF = ReecbExternalVariables::PFAREF; + reecb->getSignals().template attachSignalNode(getSignal(signal)); + } + if (reecbdata.signal_inputs.contains(ReecbSignalInputs::pref)) + { + const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::pref); + constexpr auto PREF = ReecbExternalVariables::PREF; + reecb->getSignals().template attachSignalNode(getSignal(signal)); + } + if (reecbdata.signal_outputs.contains(ReecbSignalOutputs::iqcmd)) + { + const IdxT signal = reecbdata.signal_outputs.at(ReecbSignalOutputs::iqcmd); + constexpr auto IQCMD = ReecbInternalVariables::IQCMD; + reecb->getSignals().template assignSignalNode(getSignal(signal)); + } + if (reecbdata.signal_outputs.contains(ReecbSignalOutputs::ipcmd)) + { + const IdxT signal = reecbdata.signal_outputs.at(ReecbSignalOutputs::ipcmd); + constexpr auto IPCMD = ReecbInternalVariables::IPCMD; + reecb->getSignals().template assignSignalNode(getSignal(signal)); + } + + addComponent(reecb); + } + // Add branches for (const auto& branchdata : data.branch) { diff --git a/docs/Figures/PhasorDynamics/REECB/diagram.png b/docs/Figures/PhasorDynamics/REECB/diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..c3aaa1478e941c1b11a4b616843b840270ab4720 GIT binary patch literal 77117 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
  • krS+*6$;OY6g|qYas&br7UUWzC-B)A_Uz|_BOuV zBcTD7?yYV$Ue~hAhhTv%@m%(#a6hs55ClW_>iAaS+wLoVJH+C%i{(r3q;4d1Yv_G; zXweaikGDCPcWR}zF#-$LqXFk35dRI+iV%G@5pTF?nX8Skwj$F$t?7eLqSWsUoO>SGx3C1DbYju!}k7a2C86S5LrNw1<|RN(<=Jf zoZRMy5VD-g^clzFW(rE!#hEUdPrsDP@Fg5b%DbuKSLB+PJ-`8DsRd=H@(| z%9-tDo-2TRptD$v(Es-p{awz?lH~Z)kAPX>u=vUCf!-Sb+nf7~`YQ1U{yoweqf*ye_KEO3=2#g z4%3d2Z#GwzR6FMrV83cyw{A^I2;Eq5AL3pe|NKMVb6LXS2qrAW$5;_;P}*aojoLM@ z!l$_WYpK|)%iF3SZur22;97JOcCC5-+Z27ld@|;t7m=+(`t9ldr?68|9$8Fp&h`^} zViofXO6_4%PgP=P z@&Ke`h6fkEUU;N-ZmV*^l4<&gYhtm@LBHGD=Hm+}e*|+CA3LuKpe6U~8bpv(5mH@x zC*D&1TB2KDKt^yzNMAlv*(;Du!;fLm0d7jD9NzSE6u3oSFX)Ry;wrL$5+@{lR&ESP zc%;lBCEg77XPXA8b)}&H3y5ju%R8^uW1iFt4IZpTZB4=oU6(@=&|i`)&~jH*6TGG+ zt5E~?{@&r%ooaCD*P)~@r~O+btHLv=p~9)HQOfzUQYqmL1QIaSxIoH`w060Q2mJ~h zp}*GB{c0X^0BvPiRd^NUR_rJ4;avx1mS&V85+~>h#bvFwU#=|*|6~6@$&8^%Af#G! zNna0x{o*+7-YC`A78owN$dajzkvLx_^^mR5rf>oZjz0ai%@0lPO}|}PHO-Eup9w39 zpx$%)AYt~hhGcn&X>TP8U0oga$M}Ai+mjVHX;1~v4O^n9z*%^+eGLg-r&0T>_Pwye zJe;?>iqe*-NsYGBx6b2BF~r#u0|1O|dK%{KU~HFG;KfC^X(({Y_jtq)Rqy3LT_nN- zA^(g+Om`#zVTc0&PCYP){gsS!u}{e8tY*`Cz#TSw9_%YnXiXmD1v8M9Xey%HwvZXpom1;INF4Qc9+&t zz8Kor(Ml&(u;=RB?f=%ZtwHi0F4x_=HDC2zAmzgmS2aG;+CdjaP#%CczG3_5W^h;mP2*X&+pJGl>^c+wE!5a0YBOzA zWg{D7k?7VorTy-reA~$WvI6^WDup^Tc64>JZC|YJCVMde4-1zoVL2aP;-{vIHBMr` z+XW5*(_*X{H`wA4e!7>;tB{s99Dw%Z`}O^6WOBtI7OJkBx8H{2BXVA7?(jqTGFpZa zps7fd?Kx5O;q;er#!j?ex*0EejOPp$W!NjdhZ`^O!DzD)APYD2kM+ZP6&~w!g2f58 zOUQeFN`JBJnqqXS!Og6FFX=+NIX4nJ1DN@=K3%JxGea8{h*7a{S!loXz+Yq|rAXVa zo5prGGymY7zbOBO4deJ4Q@$;2iFP;Am}--{@d4+0ArCF>J>R~wHHxFfe0vq$6IGY>NfF4G6$SyD39WAMq|B*A^=%6GEuO0`YFyv%-5vNPX39*rT|JM1v>R0W zFQxZ#or0ay78o)x+48@&(7eMDU95Ar>M9)zBRsnY4ofHL5MwgQnd5j;^(h9dl?CBs zRq|mlhTnUQupuJ>)^kacX=A>@ch8IIKd0Yqcs}IwAw*bCL{U#D zgz&@$AGG^)MrCVN8-6TcwtpG46J=97Z#d1G(l?e7Q&x|0lP{Mtun*`2iq_{9?lHxz zXt!bAV-r3WZ9AnScU;&E)w{SmJj<`cmQLe=fs-g%{xgEP@~13T`UMA;zq`ubcVT<% za+WXleHUJFY*0m}7m57|EcZwj>Zgs&mnUVN;6A?cN@YSTC9`^H>FqIT3@6}qwx)ac z+)O!8+Y7>0rF3Uc)ynA5E6R<)togo|pk=z4*_~wQrMSwztlj~>g`>2>qV$H1=lFu5 z;Q`WZ0jS=(y^tt}6b(Es>c@=sH&pXV5dZO`YVnNSv~!*^7oLt0Zmt1+%~JZTKcH3$ zAcLOv0VMJlMj-km7S4_-)Kn!KjoLXY*920sSr=#NYQM$W=uPfy8%(T|UBOb1?T$_x z37AsgVWl~Izyk}G@O+^h*m4D63{x%w%gdOOZstf-dW6u)Igg-IWBT;vxCJ{r5 z0(S`vSGuPOdp`OqPQ>Y@kXM+pi}QV4`~J&gn8*%Ke}jFR7Ax;d!e0zdl5J7RC1D%{ zeFw$_+vjs(becU03UCzghd#Rh^+voENI}G-i#z!yy1;5(vwnHFLA0t5JJ@tT`B$Ed z5b#-m?S>eCFal!nl}Ml;F`@KTnC0QDHA`>L1?Wv(&gQQw-t)=umWbaRvTXQb55_mx zYu^*(yK2Ip7V; zn>}dFn@iVD?my^Zq7>^b zQLZze>oBT=g+TL+Ab6Ny8xe+mx4WxowNSpK$nwr~^S8|fItZlf%zWd`FnguJ8XZ8& zryhy2H>t1+ohP{AA&=Yh{c>)!+8G9i8k z(Beakwxm2K_{Lqcvl#Zgg*#s5dO+w45GYw|zgG(OoNV;i++f?Pp3%sK@A1a$lVC$~ zV0Ysy*&w6rA1%5kcn-<-w>=$u4Y8i&%<_#7nwdid9UgvQM6Af!w4MVmH=r7<@$O3R z#Go?CN5?~EkZZirip3NTTPc`{5x-dInxz1N&$JNofN_+{ZtSir+FcGK}~E)95U%x!|#@#9?5G#z98x4&V6S>N8fXU>oR6|(z^p8r*z zjHd{5>N%I<&I7C+MsE6q1>Y!9x1e^fSqC5=fz zwF8T(5au*pF!GWuzo4BVt+8>evf_28UkaJ4kTIuw4v`QiYtxoD5_&apH^grWdA|7N z221j~bjSQ3$BRnUCmZ{bLj`X(c&(e1!pMb<_Eyf-r1%$kKxj>vz`b0UZzDE}Y@x0L zD_b;^TB9YMGbo@Tc@_p}a#K%ctZn41RwqwQ)(5NNa~w5O+Q&!I`AEnH3ER2Wuv#zw zClM)EJ9Jz5X>2R)65apk()hoyMt!oXF#-+wXSRsj)FhXMkfsvkBVuFEmYO$Kj>#OR zFAkYMtAGB>qupe2k!fyaJWzdn;kRL9SDR-b11vV)#2??3tT<_GlQRfSJQMy<^6neb zqw4iv_lvDR6$?KBAmSYS&pn~Kb2qAbHl8IehG}u?E0Q88kw==e@D9v?bxvA%*4L~QXUjk%-s7F553bWEE2UB z*&nz(`n(*@wSAyLba#Qt#l^IGe>dnmgkD0JeF``Q2W^m2f#4~z|HLsljEsIluVE*Q z8>HTXyr8WQ4o7*?3c1Nk<41zUZm$b-KkWL9op9qF!5f1>6#MYImlpCKEbi z%Q|iMgZ@30O0ILIoatHTme+1frlR#H-BwP5nIS!+Gtl`c$+t0~_jorMdkKr`H61RSl?50TDUb=Vl;c&Svm zdvB@%#0p$iB+S=xH%gimBITzR%qr`fZBB(uOg#Lw1tCfph*pMNvr2e5;Ay*kvxP7( z91HY_Z$a7o^RRG9K-T79yR>D-EPKnimQ`n!ThpfwmK7HEI7<$br<0o;vfQJhe=Zf! z?2^DooFrh09sH(;0bz=7>f@8{so(Gg!Qbw#rAIWMO>wqgknZtGvxCJGFWX9`s|tdO zpJyfe1{xu*&$@AINOyH$<7lo1Jv|!Sh7Cqyx=+5ct=DNE_aDt{>7mD87R1eo6Jy3D zw~d`>goeJi&0%i%#QsVu^E|)G)LUiwb~W|zce;6-uzcYtnE_aP>2(sC-cRT4<&GJ8 zT(^9GdMpUSwX~f(p5Ih$%~;ZnxILuan%>P(%YUPq=Ea!k-dJAw1IgoZ!7IgOGA8UM zWacwSXWH*Cv?r#M98(^QxGD?bW}qm?SkCqf73S{~zLmFbI^J!S$y2^}<3AaRc;ZN6Y9!it8*C5=3gCegj|3K>k|z=jVX!}uKkV51khC;)Cw&f4ifDjs=k@q2 z2=_wt^35wf*n&Kf_;i8_6-0i)plbRDAz#ns{XZ`0w^JYp4$Vg}5XVio6X$M=@2SQ$y?yn0#L^Ws`P@GdYONNxwxB2uQ3N zMhb$34G7PA{Lak`tYiDn83?=% zeY7r59@vV6yAay_(H3?jRw3&bViDdZuoEMuUi^t`MmRkf&momo%JmM)G>S z+5qH-nw~In^jE$r35OM=qM4|}9u7z0rq*ZzO7Auqo&6jT5#1osM$a5IkcF4{bT4|zU@T^0o zDapaAnk@)%loC$4<0X7WyJq;JbrA$Mz138$A72&~|9*R{#oo(PKFM^%m>%fYGGNwR z_mE$y?FiY(7>lde=j8*aZ9;OF>g-qrA-H_jz0+^H-j3GX|HDJ1^T_!hx9PGTm;m}< zAUWLIyJ@LXVb$>@IVe@w&XmS`<(BK_V8UkpUQF-NmRG%D)M`@h{3bU6UG%tjGJo~# z*6?gSJ*tlE?1nOw3;uDExK=y2;nClH2G2!h1s&ew(K#2tG>})}l6WIe8J;(4s6=rr ziYjqzg57Ks7`CiY?b9ZP@k>fY2JKhCzu$7;)@9iO0UgPZsh1sSp8+dnP>o9<&nSxA zu5+JkVfcD#%pSKNb(Tx7;au@dQ#-Jzoi(?9uEJM9b&=}bLc5C*7eAxNHwbU5^*_=~ z!OIo7Ranl2FaDD7PPu~Pai7f~Yi&i9NB~j&RZqWMSJ`E>LMuMiR<|V0nOqUMG3oIG z?riP4xIb8pP?l_Ru$?@*$0@9nX1oRsBdKT&*{$6s7;i_d_zf?i0P|U zqX%jgJ=^_xyL&~os4JvWN+w~Ts$&6=`*4}nV%}4Q&H>;yPJH=KD>TDVl30sW=wbqW zUc?tbdIzvLI$5elKuxYU^ZQajr^>c3z}M3m<+HB~ZHJb*&@BPG4Pu&*q9qg0WBLE1 z*_unY6_D`3&~Djw#Q!oB0K3w zQp6(u*>L-n*M0RTe{a(||B_8`-FW4Jej;lBwfQe`4>TG&UnGrlrJwX`(oL@kre_Xa zYu%ydjKZJkc#8FDCN@0A?(W&xS~UGoH)4V6h+TvBmb0_mG9Mb8Hh?7Zgv z&mbQeD^o|5o#=hx@%V;{)XC3$G3#f-S!#Q>6A_L0Os`G8jGul+GLx9UnVDtR%@=x3 zf2*${Z#vnX06&oppKX6ryMqv>7)};~+;++IA zj(7UHadN($)8eDZB*0a0aB0lcFI^EwUYR8tzqYDf)KPT>a&O>~lkzNQKXcd(Nbr6- zx+Btcpv9;rt=!nd+By|MCjHLIS00<=cj7yF6y!`cK6=Dt9WFx(cfY-TGo=wfZFYAG z?pdSv>W4#L4O22^A;J!3bPzI*$bnkWzRF$OeASs%?i{NsLt8Txb7sHO)hG7g4t^!T zw*E$ovCq}!N>4tkS^F-tcBoQ3dH5FnZP3ns>XXBR{`2B$e@@~~*71E^`nuPu(!-m= z-Hw9<)x^)iRkkJ6lW6%g{RM)mOc(RsEo5ljpUiy`TX~V z@!Mv%0(5X#+X?skoBQM>9K+iBx2Zt`c^|FA&a+?YGNE&(-gwB}fcZCbSlE_Fdg(=; zFF}6$o0#aU(DX?7ZHc2xui$3y=P8Q<347|r2x)B-zS&<;m%`S$&dD4@z z2|*(I<=a+}1~{^;MTdphjTDl$GY|!NW7F$|H*y@-FvGtq`0$xiY~F6h-sAMyEV80= zHsKdrWdsHzl$OL1SbsIUKIenLu7I0y>q}CX`~Th~*d@XVyZSYI_1W?zC@3S%V+shw zXDH$n)YbZI#gNbS+g4=G4Fnt*PG|e~Ppsu{Eky1QL*09CRkD&rAA+6KXNVZ=tMi4* zabD_5nxj+SFI5*lz0M2*ii`_^XGv!Q}MjfEI z+tL?Ea-f$2wBvvt+0C93Om;)VwN}eXH=5;qklqre67|=0NsU`P-~KO%U+5o&^ovqr zqY#a`i}Y6kkT8H9p;O9$i%CXDv9PKWV1xn4=&s5Y77n5WV`z9-*9;xk0m(QpO+5rD zEClRZJ?5e@)-U7sU2^$$eJc>>mnGN{JY()&hyu~xUm-ua0LiUb)8(eR=ruL# z(e3%O%ZK?h(#vXXYFtb2v{)wIAq>jsg+64VN#u+IiQ1NO)nB9eUKA}Aiuf#XRh^6f zv5Gk?y7hEN$wRL)ozu5DZK=8~dAus;T(Z<(>pH9w6IZ}W;CRK2j!)G!%1lrf)AIe| z|A=#%c^=umL|GvFWK5 z;?_R3v?O1G5p{79X0VzTQSAd5!<^*4(G!jOB)|aeg$F1-4@z+J9PR0mqU6PsgAD@C zf-RyfxyBi9u~f)s-x(30QDTMSJAr8h^94{+fbfAf1^oj9$e@y^FPyfX6D9fsvchKR z$fB?7`?P$Hj?FJT(8f`LwQyy)GsmutCn-Lux{F zw?KrM%R*>z-I@WZBvOV+Yi_D~*ND9$1{_f>5sw&g)B2=`5%;n;mZCuR^G1MkO7^8d zu`-n~ySWK;zj^neF9aFCm@qJ(k-xX-H-meO{0ffe0Im)E3ckP3fZVvNLe_ug_Q_z# zbuWzl#!5%fK`q>y7!<;Wk_Rgz6F9Z->Vi3NRXtS z(*J4%32b7q|LM2?)m9PweZ&Dj{osO0#H&C3{{O%9|LG2#Fnes>+T~6m_dvma%D2=M Ji{#A${u}PSD#QQ) literal 0 HcmV?d00001 diff --git a/docs/GridKit/Model/PhasorDynamics/Converter/README.md b/docs/GridKit/Model/PhasorDynamics/Converter/README.md index fa56e8dd8..e868967e5 100644 --- a/docs/GridKit/Model/PhasorDynamics/Converter/README.md +++ b/docs/GridKit/Model/PhasorDynamics/Converter/README.md @@ -8,6 +8,7 @@ REGCA REGCB REECA +REECB ``` ```{include} ../../../../../GridKit/Model/PhasorDynamics/Converter/README.md diff --git a/docs/GridKit/Model/PhasorDynamics/Converter/REECB/README.md b/docs/GridKit/Model/PhasorDynamics/Converter/REECB/README.md new file mode 100644 index 000000000..ff972cda5 --- /dev/null +++ b/docs/GridKit/Model/PhasorDynamics/Converter/REECB/README.md @@ -0,0 +1,6 @@ +# REECB + +```{include} ../../../../../../GridKit/Model/PhasorDynamics/Converter/REECB/README.md +:start-line: 1 +:relative-images: +``` diff --git a/tests/UnitTests/PhasorDynamics/CMakeLists.txt b/tests/UnitTests/PhasorDynamics/CMakeLists.txt index 6c8721977..85daaef87 100644 --- a/tests/UnitTests/PhasorDynamics/CMakeLists.txt +++ b/tests/UnitTests/PhasorDynamics/CMakeLists.txt @@ -132,6 +132,14 @@ target_link_libraries( GridKit::phasor_dynamics_bus_dependency_tracking GridKit::testing) +add_executable(test_phasor_converter_reecb runConverterReecbTests.cpp) +target_link_libraries( + test_phasor_converter_reecb + GridKit::definitions + GridKit::phasor_dynamics_systemmodel + GridKit::phasor_dynamics_systemmodel_dependency_tracking + GridKit::testing) + add_executable(test_phasor_controller_repca runControllerRepcaTests.cpp) target_link_libraries( test_phasor_controller_repca @@ -197,6 +205,7 @@ add_test(NAME PhasorDynamicsExciterEsdc1aTest COMMAND test_phasor_exciter_esdc1a add_test(NAME PhasorDynamicsGensalTest COMMAND test_phasor_gensal) add_test(NAME PhasorDynamicsExciterSexsPtiTest COMMAND test_phasor_exciter_sexspti) add_test(NAME PhasorDynamicsConverterRegcaTest COMMAND test_phasor_converter_regca) +add_test(NAME PhasorDynamicsConverterReecbTest COMMAND test_phasor_converter_reecb) add_test(NAME PhasorDynamicsControllerRepcaTest COMMAND test_phasor_controller_repca) add_test(NAME PhasorDynamicsStabilizerIeeestTest COMMAND test_phasor_stabilizer_ieeest) add_test(NAME PhasorDynamicsGenClassicalTest COMMAND test_phasor_gen_classical) @@ -224,6 +233,7 @@ install( test_phasor_gensal test_phasor_exciter_sexspti test_phasor_converter_regca + test_phasor_converter_reecb test_phasor_controller_repca test_phasor_stabilizer_ieeest test_phasor_gen_classical diff --git a/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp b/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp new file mode 100644 index 000000000..6526995d3 --- /dev/null +++ b/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp @@ -0,0 +1,656 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace GridKit +{ + namespace Testing + { + template + class ConverterReecbTests + { + public: + using ScalarT = scalar_type; + using IdxT = index_type; + using RealT = typename PhasorDynamics::Component::RealT; + using ReecbT = PhasorDynamics::Converter::Reecb; + using Var = PhasorDynamics::Converter::ReecbInternalVariables; + using Ext = PhasorDynamics::Converter::ReecbExternalVariables; + using Params = PhasorDynamics::Converter::ReecbParameters; + using Buses = PhasorDynamics::Converter::ReecbBuses; + using Outputs = PhasorDynamics::Converter::ReecbSignalOutputs; + + static constexpr ScalarT kTol = static_cast(1.0e-8); + + TestOutcome validation() + { + TestStatus success = true; + + PhasorDynamics::Bus bus(1.0, 0.0); + + ReecbT reecb(&bus, makeData()); + success *= (reecb.size() == static_cast(Var::MAXIMUM)); + success *= (reecb.getMonitor() != nullptr); + success *= (reecb.verify() == 0); + + auto minimal = makeMinimalData(); + minimal.parameters[Params::mva] = static_cast(100.0); + ReecbT minimal_model(&bus, minimal); + success *= (minimal_model.verify() == 0); + + ReecbT missing_mva(&bus, makeMinimalData()); + success *= (missing_mva.verify() > 0); + + auto bad_band = makeData(); + bad_band.parameters[Params::Vdip] = static_cast(1.2); + bad_band.parameters[Params::Vup] = static_cast(1.2); + ReecbT bad_band_model(&bus, bad_band); + success *= (bad_band_model.verify() > 0); + + auto bad_imax = makeData(); + bad_imax.parameters[Params::Imax] = static_cast(-1.0); + ReecbT bad_imax_model(&bus, bad_imax); + success *= (bad_imax_model.verify() > 0); + + ScalarT pe_value{0.5}; + IdxT pe_index = 20; + PhasorDynamics::SignalNode pe_node; + pe_node.set(&pe_value, &pe_index); + + ReecbT half_connected(&bus, makeData()); + half_connected.getSignals().template attachSignalNode(&pe_node); + success *= (half_connected.verify() == 0); + + PhasorDynamics::SignalNode unlinked_pe_node; + ReecbT unlinked(&bus, makeData()); + unlinked.getSignals().template attachSignalNode(&unlinked_pe_node); + success *= (unlinked.verify() > 0); + + return success.report(__func__); + } + + TestOutcome signals() + { + TestStatus success = true; + + PhasorDynamics::Bus bus(1.0, 0.0); + bus.allocate(); + bus.initialize(); + + ScalarT iqcmd_value{0.2}; + ScalarT ipcmd_value{0.6}; + IdxT iqcmd_index = 21; + IdxT ipcmd_index = 22; + + PhasorDynamics::SignalNode iqcmd_node; + PhasorDynamics::SignalNode ipcmd_node; + iqcmd_node.set(&iqcmd_value, &iqcmd_index); + ipcmd_node.set(&ipcmd_value, &ipcmd_index); + + ReecbT reecb(&bus, makeData()); + reecb.getSignals().template assignSignalNode(&iqcmd_node); + reecb.getSignals().template assignSignalNode(&ipcmd_node); + + success *= (reecb.allocate() == 0); + iqcmd_node.init(static_cast(0.2)); + ipcmd_node.init(static_cast(0.6)); + success *= (reecb.verify() == 0); + success *= (reecb.initialize() == 0); + success *= (reecb.tagDifferentiable() == 0); + success *= (reecb.evaluateResidual() == 0); + + success *= isEqual(reecb.y().getData()[index(Var::VMEAS)], static_cast(1.0), kTol); + success *= isEqual(reecb.y().getData()[index(Var::PMEAS)], static_cast(0.6), kTol); + success *= isEqual(reecb.y().getData()[index(Var::QREF)], static_cast(0.2), kTol); + success *= isEqual(reecb.y().getData()[index(Var::PORD)], static_cast(0.6), kTol); + success *= isEqual(iqcmd_node.read(), reecb.y().getData()[index(Var::IQCMD)], kTol); + success *= isEqual(ipcmd_node.read(), reecb.y().getData()[index(Var::IPCMD)], kTol); + success *= (reecb.tag()[index(Var::VMEAS)] == true); + success *= (reecb.tag()[index(Var::PMEAS)] == true); + success *= allZero(reecb); + + return success.report(__func__); + } + + TestOutcome publishRefs() + { + TestStatus success = true; + + PhasorDynamics::Bus bus(0.8, 0.6); + bus.allocate(); + bus.initialize(); + + auto data = makeData(); + data.parameters[Params::Qmin] = static_cast(-2.0); + data.parameters[Params::Qmax] = static_cast(2.0); + data.parameters[Params::Pmax] = static_cast(2.0); + data.parameters[Params::Imax] = static_cast(2.0); + + ScalarT iqcmd_value{-0.2}; + ScalarT ipcmd_value{1.6}; + ScalarT qext_value{99.0}; + ScalarT pfaref_value{99.0}; + ScalarT pref_value{99.0}; + IdxT iqcmd_index = 30; + IdxT ipcmd_index = 31; + IdxT qext_index = 32; + IdxT pfref_index = 33; + IdxT pref_index = 34; + + PhasorDynamics::SignalNode iqcmd_node; + PhasorDynamics::SignalNode ipcmd_node; + PhasorDynamics::SignalNode qext_node; + PhasorDynamics::SignalNode pfaref_node; + PhasorDynamics::SignalNode pref_node; + iqcmd_node.set(&iqcmd_value, &iqcmd_index); + ipcmd_node.set(&ipcmd_value, &ipcmd_index); + qext_node.set(&qext_value, &qext_index); + pfaref_node.set(&pfaref_value, &pfref_index); + pref_node.set(&pref_value, &pref_index); + + ReecbT reecb(&bus, data); + reecb.getSignals().template assignSignalNode(&iqcmd_node); + reecb.getSignals().template assignSignalNode(&ipcmd_node); + reecb.getSignals().template attachSignalNode(&qext_node); + reecb.getSignals().template attachSignalNode(&pfaref_node); + reecb.getSignals().template attachSignalNode(&pref_node); + + success *= (reecb.allocate() == 0); + iqcmd_node.init(static_cast(-0.2)); + ipcmd_node.init(static_cast(1.6)); + success *= (reecb.verify() == 0); + success *= (reecb.initialize() == 0); + success *= (reecb.evaluateResidual() == 0); + + const ScalarT expected_pfaref = static_cast(std::atan(static_cast(-0.2 / 1.6))); + success *= isEqual(qext_node.read(), static_cast(-0.2), kTol); + success *= isEqual(pfaref_node.read(), expected_pfaref, kTol); + success *= isEqual(pref_node.read(), static_cast(1.6), kTol); + success *= isEqual(reecb.y().getData()[index(Var::QREF)], static_cast(-0.2), kTol); + success *= allZero(reecb); + + return success.report(__func__); + } + + TestOutcome baseSignals() + { + TestStatus success = true; + + PhasorDynamics::Bus bus(1.0, 0.0); + bus.allocate(); + bus.initialize(); + + auto data = makeData(); + data.parameters[Params::mva] = static_cast(50.0); + + ScalarT pe_value{99.0}; + ScalarT qgen_value{99.0}; + ScalarT qext_value{99.0}; + ScalarT pfaref_value{99.0}; + ScalarT pref_value{99.0}; + ScalarT iqcmd_value{0.0}; + ScalarT ipcmd_value{0.0}; + IdxT pe_index = 40; + IdxT qgen_index = 41; + IdxT qext_index = 42; + IdxT pfaref_index = 43; + IdxT pref_index = 44; + IdxT iqcmd_index = 45; + IdxT ipcmd_index = 46; + + PhasorDynamics::SignalNode pe_node; + PhasorDynamics::SignalNode qgen_node; + PhasorDynamics::SignalNode qext_node; + PhasorDynamics::SignalNode pfaref_node; + PhasorDynamics::SignalNode pref_node; + PhasorDynamics::SignalNode iqcmd_node; + PhasorDynamics::SignalNode ipcmd_node; + pe_node.set(&pe_value, &pe_index); + qgen_node.set(&qgen_value, &qgen_index); + qext_node.set(&qext_value, &qext_index); + pfaref_node.set(&pfaref_value, &pfaref_index); + pref_node.set(&pref_value, &pref_index); + iqcmd_node.set(&iqcmd_value, &iqcmd_index); + ipcmd_node.set(&ipcmd_value, &ipcmd_index); + + ReecbT reecb(&bus, data); + reecb.getSignals().template attachSignalNode(&pe_node); + reecb.getSignals().template attachSignalNode(&qgen_node); + reecb.getSignals().template attachSignalNode(&qext_node); + reecb.getSignals().template attachSignalNode(&pfaref_node); + reecb.getSignals().template attachSignalNode(&pref_node); + reecb.getSignals().template assignSignalNode(&iqcmd_node); + reecb.getSignals().template assignSignalNode(&ipcmd_node); + + success *= (reecb.allocate() == 0); + iqcmd_node.init(static_cast(0.05)); + ipcmd_node.init(static_cast(0.25)); + success *= (reecb.verify() == 0); + success *= (reecb.initialize() == 0); + success *= (reecb.evaluateResidual() == 0); + + const ScalarT expected_pfaref = static_cast(std::atan(static_cast(0.1 / 0.5))); + success *= isEqual(reecb.y().getData()[index(Var::PMEAS)], static_cast(0.5), kTol); + success *= isEqual(reecb.y().getData()[index(Var::QREF)], static_cast(0.1), kTol); + success *= isEqual(reecb.y().getData()[index(Var::PORD)], static_cast(0.5), kTol); + success *= isEqual(pe_node.read(), static_cast(0.25), kTol); + success *= isEqual(qgen_node.read(), static_cast(0.05), kTol); + success *= isEqual(qext_node.read(), static_cast(0.05), kTol); + success *= isEqual(pfaref_node.read(), expected_pfaref, kTol); + success *= isEqual(pref_node.read(), static_cast(0.25), kTol); + success *= isEqual(iqcmd_node.read(), static_cast(0.05), kTol); + success *= isEqual(ipcmd_node.read(), static_cast(0.25), kTol); + success *= isEqual(reecb.y().getData()[index(Var::IQCMD)], static_cast(0.05), kTol); + success *= isEqual(reecb.y().getData()[index(Var::IPCMD)], static_cast(0.25), kTol); + success *= allZero(reecb); + + return success.report(__func__); + } + + TestOutcome feedbackBase() + { + TestStatus success = true; + + PhasorDynamics::Bus bus(1.0, 0.0); + bus.allocate(); + bus.initialize(); + + auto data = makeData(); + data.parameters[Params::mva] = static_cast(50.0); + + ScalarT pe_value{0.25}; + ScalarT qgen_value{0.05}; + ScalarT iqcmd_value{0.0}; + ScalarT ipcmd_value{0.0}; + IdxT pe_index = 40; + IdxT qgen_index = 41; + IdxT iqcmd_index = 42; + IdxT ipcmd_index = 43; + + PhasorDynamics::SignalNode pe_node; + PhasorDynamics::SignalNode qgen_node; + PhasorDynamics::SignalNode iqcmd_node; + PhasorDynamics::SignalNode ipcmd_node; + pe_node.set(&pe_value, &pe_index); + qgen_node.set(&qgen_value, &qgen_index); + iqcmd_node.set(&iqcmd_value, &iqcmd_index); + ipcmd_node.set(&ipcmd_value, &ipcmd_index); + + ReecbT reecb(&bus, data); + reecb.getSignals().template attachSignalNode(&pe_node); + reecb.getSignals().template attachSignalNode(&qgen_node); + reecb.getSignals().template assignSignalNode(&iqcmd_node); + reecb.getSignals().template assignSignalNode(&ipcmd_node); + + success *= (reecb.allocate() == 0); + iqcmd_node.init(static_cast(0.05)); + ipcmd_node.init(static_cast(0.25)); + success *= (reecb.verify() == 0); + success *= (reecb.initialize() == 0); + success *= (reecb.evaluateResidual() == 0); + + success *= isEqual(reecb.y().getData()[index(Var::PMEAS)], static_cast(0.5), kTol); + success *= isEqual(reecb.y().getData()[index(Var::QREF)], static_cast(0.1), kTol); + success *= isEqual(reecb.y().getData()[index(Var::PORD)], static_cast(0.5), kTol); + success *= isEqual(pe_node.read(), static_cast(0.25), kTol); + success *= isEqual(qgen_node.read(), static_cast(0.05), kTol); + success *= isEqual(reecb.y().getData()[index(Var::IQCMD)], static_cast(0.05), kTol); + success *= isEqual(reecb.y().getData()[index(Var::IPCMD)], static_cast(0.25), kTol); + success *= allZero(reecb); + + return success.report(__func__); + } + + TestOutcome zeroTime() + { + TestStatus success = true; + + PhasorDynamics::Bus bus(1.0, 0.0); + bus.allocate(); + bus.initialize(); + + auto data = makeData(); + data.parameters[Params::Trv] = static_cast(0.0); + data.parameters[Params::Tp] = static_cast(0.0); + + ReecbT reecb(&bus, data); + success *= (reecb.allocate() == 0); + success *= (reecb.verify() == 0); + reecb.y().getData()[index(Var::IQCMD)] = static_cast(0.2); + reecb.y().getData()[index(Var::IPCMD)] = static_cast(0.6); + reecb.y().setDataUpdated(); + success *= (reecb.initialize() == 0); + success *= (reecb.tagDifferentiable() == 0); + success *= (reecb.tag()[index(Var::VMEAS)] == true); + success *= (reecb.tag()[index(Var::PMEAS)] == true); + + reecb.yp().getData()[index(Var::VMEAS)] = static_cast(1.0); + reecb.yp().getData()[index(Var::PMEAS)] = static_cast(2.0); + reecb.yp().setDataUpdated(); + success *= (reecb.evaluateResidual() == 0); + success *= isEqual(reecb.getResidual().getData()[index(Var::VMEAS)], static_cast(-1.0), kTol); + success *= isEqual(reecb.getResidual().getData()[index(Var::PMEAS)], static_cast(-2.0), kTol); + + reecb.yp().getData()[index(Var::VMEAS)] = ZERO; + reecb.y().getData()[index(Var::VMEAS)] = static_cast(0.99); + reecb.y().setDataUpdated(); + reecb.yp().setDataUpdated(); + success *= (reecb.evaluateResidual() == 0); + success *= isEqual(reecb.getResidual().getData()[index(Var::VMEAS)], static_cast(10.0), kTol); + + return success.report(__func__); + } + + TestOutcome qPriority() + { + TestStatus success = true; + + PhasorDynamics::Bus bus(1.0, 0.0); + bus.allocate(); + bus.initialize(); + + auto data = makeData(); + data.parameters[Params::Pqflag] = static_cast(0); + data.parameters[Params::Imax] = static_cast(1.1); + + ReecbT reecb(&bus, data); + success *= (reecb.allocate() == 0); + success *= (reecb.verify() == 0); + reecb.y().getData()[index(Var::IQCMD)] = static_cast(0.3); + reecb.y().getData()[index(Var::IPCMD)] = static_cast(0.9); + reecb.y().setDataUpdated(); + success *= (reecb.initialize() == 0); + success *= (reecb.evaluateResidual() == 0); + + const ScalarT ipmax = std::sqrt(static_cast(1.1 * 1.1 - 0.3 * 0.3)); + success *= isEqual(reecb.y().getData()[index(Var::IQMAX)], static_cast(1.1), kTol); + success *= isEqual(reecb.y().getData()[index(Var::IPMAX)], ipmax, kTol); + success *= isEqual(reecb.y().getData()[index(Var::IQCMD)], static_cast(0.3), kTol); + success *= isEqual(reecb.y().getData()[index(Var::IPCMD)], static_cast(0.9), kTol); + success *= allZero(reecb); + + return success.report(__func__); + } + + TestOutcome pPriority() + { + TestStatus success = true; + + PhasorDynamics::Bus bus(1.0, 0.0); + bus.allocate(); + bus.initialize(); + + auto data = makeData(); + data.parameters[Params::Pqflag] = static_cast(1); + data.parameters[Params::Imax] = static_cast(1.1); + + ReecbT reecb(&bus, data); + success *= (reecb.allocate() == 0); + success *= (reecb.verify() == 0); + reecb.y().getData()[index(Var::IQCMD)] = static_cast(0.3); + reecb.y().getData()[index(Var::IPCMD)] = static_cast(0.9); + reecb.y().setDataUpdated(); + success *= (reecb.initialize() == 0); + success *= (reecb.evaluateResidual() == 0); + + const ScalarT iqmax = std::sqrt(static_cast(1.1 * 1.1 - 0.9 * 0.9)); + success *= isEqual(reecb.y().getData()[index(Var::IPMAX)], static_cast(1.1), kTol); + success *= isEqual(reecb.y().getData()[index(Var::IQMAX)], iqmax, kTol); + success *= isEqual(reecb.y().getData()[index(Var::IQCMD)], static_cast(0.3), kTol); + success *= isEqual(reecb.y().getData()[index(Var::IPCMD)], static_cast(0.9), kTol); + success *= allZero(reecb); + + return success.report(__func__); + } + + TestOutcome voltageBand() + { + TestStatus success = true; + + PhasorDynamics::Bus bus(0.8, 0.0); + bus.allocate(); + bus.initialize(); + + auto data = makeData(); + data.parameters[Params::QFlag] = static_cast(1); + data.parameters[Params::Vref0] = static_cast(1.0); + data.parameters[Params::Vdip] = static_cast(0.9); + data.parameters[Params::Vup] = static_cast(1.1); + data.parameters[Params::dbd1] = static_cast(0.0); + data.parameters[Params::dbd2] = static_cast(0.0); + data.parameters[Params::kqv] = static_cast(1.0); + data.parameters[Params::Imax] = static_cast(2.0); + + ReecbT reecb(&bus, data); + success *= (reecb.allocate() == 0); + success *= (reecb.verify() == 0); + reecb.y().getData()[index(Var::IQCMD)] = ZERO; + reecb.y().getData()[index(Var::IPCMD)] = static_cast(0.5); + reecb.y().setDataUpdated(); + success *= (reecb.initialize() == 0); + success *= (reecb.evaluateResidual() == 0); + + const ScalarT expected_sdip = Math::inside(reecb.y().getData()[index(Var::VT)], static_cast(0.9), static_cast(1.1)); + const ScalarT expected_iqraw = reecb.y().getData()[index(Var::IQBASE)] + (ONE - reecb.y().getData()[index(Var::SDIP)]) * reecb.y().getData()[index(Var::IQV)]; + success *= isEqual(reecb.y().getData()[index(Var::SDIP)], expected_sdip, kTol); + success *= isEqual(reecb.y().getData()[index(Var::IQRAW)], expected_iqraw, kTol); + success *= (reecb.y().getData()[index(Var::SDIP)] < static_cast(0.01)); + success *= (reecb.y().getData()[index(Var::IQV)] > static_cast(0.1)); + success *= allZero(reecb); + + return success.report(__func__); + } + + TestOutcome piSaturation() + { + TestStatus success = true; + + PhasorDynamics::Bus bus(1.13, 0.0); + bus.allocate(); + bus.initialize(); + + auto data = makeData(); + data.parameters[Params::QFlag] = static_cast(0); + data.parameters[Params::Pqflag] = static_cast(0); + data.parameters[Params::Vmin] = static_cast(0.9); + data.parameters[Params::Vmax] = static_cast(1.05); + data.parameters[Params::Kvp] = static_cast(10.0); + data.parameters[Params::Kvi] = static_cast(60.0); + data.parameters[Params::Vup] = static_cast(99.0); + data.parameters[Params::Vdip] = static_cast(-99.0); + data.parameters[Params::Imax] = static_cast(1.1); + + ReecbT reecb(&bus, data); + success *= (reecb.allocate() == 0); + success *= (reecb.verify() == 0); + reecb.y().getData()[index(Var::IQCMD)] = static_cast(0.15 / 1.13); + reecb.y().getData()[index(Var::IPCMD)] = static_cast(0.5 / 1.13); + reecb.y().setDataUpdated(); + success *= (reecb.initialize() == 0); + success *= (reecb.evaluateResidual() == 0); + + const ScalarT piv_arg = static_cast(10.0) * reecb.y().getData()[index(Var::EPIV)] + reecb.y().getData()[index(Var::XPIV)]; + success *= (piv_arg < -reecb.y().getData()[index(Var::IQMAX)]); + success *= allZero(reecb); + + return success.report(__func__); + } + +#ifdef GRIDKIT_ENABLE_ENZYME + TestOutcome jacobian() + { + TestStatus success = true; + + PhasorDynamics::Bus bus(1.0, 0.0); + bus.allocate(); + bus.initialize(); + + ReecbT reecb(&bus, makeData()); + success *= (reecb.allocate() == 0); + success *= (reecb.verify() == 0); + reecb.y().getData()[index(Var::IQCMD)] = static_cast(0.2); + reecb.y().getData()[index(Var::IPCMD)] = static_cast(0.6); + reecb.y().setDataUpdated(); + success *= (reecb.initialize() == 0); + success *= (reecb.evaluateResidual() == 0); + success *= (reecb.evaluateJacobian() == 0); + + auto* jac = reecb.getCooJacobian(); + success *= (jac != nullptr); + if (jac != nullptr) + { + success *= (jac->getNnz() > 0); + const auto* values = jac->getValues(); + for (IdxT i = 0; i < jac->getNnz(); ++i) + { + success *= std::isfinite(values[i]); + } + } + + return success.report(__func__); + } +#endif + + TestOutcome json() + { + TestStatus success = true; + + std::istringstream input(R"json( +{ + "header": { + "format_version": 0, + "format_revision": 1, + "case_name": "renewable electrical control", + "case_description": "REECB parser test", + "case_comments": "", + "freq_base": 60.0, + "va_base": 100000000.0 + }, + "buses": [ + { "number": 1, "class": "bus", "name": "Bus 1", "init": { "Vr": 1.0, "Vi": 0.0 }, "params": { "kv": 1.0 } } + ], + "signals": [ + { "signal_id": 12, "name": "Iqcmd" }, + { "signal_id": 13, "name": "Ipcmd" } + ], + "devices": [ + { + "class": "Reecb", + "ports": { "bus": 1, "iqcmd": 12, "ipcmd": 13 }, + "id": "REE1", + "params": { + "mva": 100.0, "PfFlag": 0, "VFlag": 1, "QFlag": 0, "Pqflag": 1, + "Trv": 0.0, "Tp": 0.02, "Vdip": 0.7, "Vup": 1.2, + "dbd1": -0.01, "dbd2": 0.01, "kqv": 0.0, "Iql1": -1.0, "Iqh1": 1.0, + "Qmax": 1.0, "Qmin": -1.0, "Kqp": 1.0, "Kqi": 0.0, + "Vmax": 1.2, "Vmin": 0.8, "Kvp": 1.0, "Kvi": 0.0, + "Tiq": 0.02, "Tpord": 0.02, "dPmax": 1.0, "dPmin": -1.0, + "Pmax": 1.0, "Pmin": 0.0, "Imax": 2.0 + } + } + ] +} +)json"); + + auto data = PhasorDynamics::parseSystemModelData(input); + success *= (data.reecb.size() == 1); + success *= (std::get(data.reecb[0].parameters.at(Params::Pqflag)) == static_cast(1)); + success *= (std::get(data.reecb[0].parameters.at(Params::mva)) == static_cast(100.0)); + success *= (data.reecb[0].buses.at(Buses::bus) == static_cast(1)); + success *= data.reecb[0].signal_inputs.empty(); + success *= (data.reecb[0].signal_outputs.at(Outputs::iqcmd) == static_cast(12)); + success *= (data.reecb[0].signal_outputs.at(Outputs::ipcmd) == static_cast(13)); + + PhasorDynamics::SystemModel system(data); + success *= (system.allocate() == 0); + success *= (system.initialize() == 0); + success *= (system.evaluateResidual() == 0); + success *= (system.size() == 27); + + return success.report(__func__); + } + + private: + static size_t index(Var variable) + { + return static_cast(variable); + } + + TestStatus allZero(const ReecbT& reecb) const + { + TestStatus success = true; + + for (size_t i = 0; i < reecb.getResidual().getSize(); ++i) + { + success *= isEqual(reecb.getResidual().getData()[i], static_cast(0.0), kTol); + success *= isEqual(reecb.yp().getData()[i], static_cast(0.0), kTol); + } + + return success; + } + + auto makeMinimalData() -> PhasorDynamics::Converter::ReecbData + { + using Mon = PhasorDynamics::Converter::ReecbMonitorableVariables; + + PhasorDynamics::Converter::ReecbData data; + data.device_class = "Reecb"; + data.disambiguation_string = "reecb_test"; + data.monitored_variables.insert(Mon::iqcmd); + data.monitored_variables.insert(Mon::ipcmd); + data.monitored_variables.insert(Mon::vmeas); + data.monitored_variables.insert(Mon::pmeas); + return data; + } + + auto makeData() -> PhasorDynamics::Converter::ReecbData + { + auto data = makeMinimalData(); + + data.parameters[Params::mva] = static_cast(100.0); + data.parameters[Params::PfFlag] = static_cast(0); + data.parameters[Params::VFlag] = static_cast(1); + data.parameters[Params::QFlag] = static_cast(0); + data.parameters[Params::Pqflag] = static_cast(1); + data.parameters[Params::Trv] = static_cast(0.0); + data.parameters[Params::Tp] = static_cast(0.02); + data.parameters[Params::Vdip] = static_cast(0.7); + data.parameters[Params::Vup] = static_cast(1.2); + data.parameters[Params::dbd1] = static_cast(-0.01); + data.parameters[Params::dbd2] = static_cast(0.01); + data.parameters[Params::kqv] = static_cast(0.0); + data.parameters[Params::Iql1] = static_cast(-1.0); + data.parameters[Params::Iqh1] = static_cast(1.0); + data.parameters[Params::Qmax] = static_cast(1.0); + data.parameters[Params::Qmin] = static_cast(-1.0); + data.parameters[Params::Kqp] = static_cast(1.0); + data.parameters[Params::Kqi] = static_cast(0.0); + data.parameters[Params::Vmax] = static_cast(1.2); + data.parameters[Params::Vmin] = static_cast(0.8); + data.parameters[Params::Kvp] = static_cast(1.0); + data.parameters[Params::Kvi] = static_cast(0.0); + data.parameters[Params::Tiq] = static_cast(0.02); + data.parameters[Params::Tpord] = static_cast(0.02); + data.parameters[Params::dPmax] = static_cast(1.0); + data.parameters[Params::dPmin] = static_cast(-1.0); + data.parameters[Params::Pmax] = static_cast(1.0); + data.parameters[Params::Pmin] = static_cast(0.0); + data.parameters[Params::Imax] = static_cast(2.0); + + return data; + } + }; + } // namespace Testing +} // namespace GridKit diff --git a/tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp b/tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp new file mode 100644 index 000000000..90c935e34 --- /dev/null +++ b/tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp @@ -0,0 +1,25 @@ +#include "ConverterReecbTests.hpp" + +int main() +{ + GridKit::Testing::TestingResults result; + + GridKit::Testing::ConverterReecbTests test; + + result += test.validation(); + result += test.signals(); + result += test.publishRefs(); + result += test.baseSignals(); + result += test.feedbackBase(); + result += test.zeroTime(); + result += test.qPriority(); + result += test.pPriority(); + result += test.voltageBand(); + result += test.piSaturation(); +#ifdef GRIDKIT_ENABLE_ENZYME + result += test.jacobian(); +#endif + result += test.json(); + + return result.summary(); +} From a3e01383f668940c47f201d0114c4d062dd90f00 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Mon, 27 Jul 2026 01:06:14 -0500 Subject: [PATCH 02/16] update and imrpove notation and layout --- .../Model/PhasorDynamics/Converter/README.md | 1 + .../PhasorDynamics/Converter/REECB/README.md | 295 ++- .../PhasorDynamics/Converter/REECB/Reecb.hpp | 82 +- .../Converter/REECB/ReecbImpl.hpp | 962 ++++++--- .../Model/PhasorDynamics/SystemModelImpl.hpp | 120 +- .../Math/SmoothnessIndicatorTests.hpp | 47 + .../Math/runSmoothnessIndicatorTests.cpp | 1 + tests/UnitTests/PhasorDynamics/CMakeLists.txt | 6 +- .../PhasorDynamics/ConverterReecbTests.hpp | 1786 ++++++++++++----- .../PhasorDynamics/runConverterReecbTests.cpp | 16 +- tests/UnitTests/Utilities/CaseFormatTests.hpp | 42 +- 11 files changed, 2371 insertions(+), 987 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Converter/README.md b/GridKit/Model/PhasorDynamics/Converter/README.md index ecd71d986..30b760ecb 100644 --- a/GridKit/Model/PhasorDynamics/Converter/README.md +++ b/GridKit/Model/PhasorDynamics/Converter/README.md @@ -9,6 +9,7 @@ models and the bus equations, typically through commanded active and reactive cu The GridKit converter documentation includes: +- Renewable Energy Generator/Converter Model REGCA (See [REGCA](REGCA/README.md)) - Renewable Energy Generator/Converter Model REGCB (See [REGCB](REGCB/README.md)) - Renewable Energy Electrical Control Model REECA (See [REECA](REECA/README.md)) - Renewable Energy Electrical Control Model REECB (See [REECB](REECB/README.md)) diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/README.md b/GridKit/Model/PhasorDynamics/Converter/REECB/README.md index b62567ea6..a23f748e2 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/README.md +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/README.md @@ -5,15 +5,16 @@ resources. ## Notes +- REECB is a control model only. It measures the terminal bus and publishes + current commands; it injects no current into the network. - When used with REPCA active-power control, connect REPCA `pext` to REECB `pref`. ## Block Diagram -Standard REECB block diagram. +![REECB electrical-control block diagram](../../../../../docs/Figures/PhasorDynamics/REECB/diagram.png) -![](../../../../../docs/Figures/PhasorDynamics/REECB/diagram.png) - -Figure 1: REECB block diagram. Figure courtesy of [PowerWorld](https://www.powerworld.com/WebHelp/) +Figure 1: REECB electrical-control model. Figure courtesy of the +[PowerWorld REEC_B model reference](https://www.powerworld.com/WebHelp/Content/TransientModels_HTML/Exciter%20REEC_B.htm). ## Model Parameters @@ -24,8 +25,8 @@ $s_{\mathrm{pf}}$ | [binary] | `PfFlag` | Power-factor control $s_V$ | [binary] | `VFlag` | Voltage-control mode flag | 0 | Block name: `VFlag`; 1 = Q control, 0 = voltage control $s_Q$ | [binary] | `QFlag` | Reactive-power control flag | 0 | Block name: `QFlag`; 1 = voltage/Q control, 0 = constant pf or Q control $s_{PQ}$ | [binary] | `Pqflag` | P/Q priority flag for converter current limit | 0 | Block name: `Pqflag`; 0 = Q priority, 1 = P priority -$T_{\mathrm{rv}}$ | [sec] | `Trv` | Voltage-measurement filter time constant | 0.02 | State 1 -$T_{\mathrm{p}}$ | [sec] | `Tp` | Electrical-power measurement filter time constant | 0.0 | State 2 +$T_{\mathrm{rv}}$ | [sec] | `Trv` | Voltage-measurement filter time constant | 0.02 | State 1; raised to the minimum-time floor +$T_{\mathrm{p}}$ | [sec] | `Tp` | Electrical-power measurement filter time constant | 0.0 | State 2; raised to the minimum-time floor $V_0^\mathrm{ref}$ | [p.u.] | `Vref0` | Outer-loop voltage reference | $V_{T,0}$ | Initialized from terminal voltage if omitted $V_{\mathrm{dip}}$ | [p.u.] | `Vdip` | Low-voltage threshold for the voltage-band gate | 0.85 | $V_{\mathrm{up}}$ | [p.u.] | `Vup` | High-voltage threshold for the voltage-band gate | 1.15 | @@ -42,8 +43,8 @@ $V^{\max}$ | [p.u.] | `Vmax` | Maximum voltage-cont $V^{\min}$ | [p.u.] | `Vmin` | Minimum voltage-control limit | 0.9 | $K_{\mathrm{vp}}$ | [p.u.] | `Kvp` | Voltage-control proportional gain | 18.0 | $K_{\mathrm{vi}}$ | [p.u./s] | `Kvi` | Voltage-control integral gain | 5.0 | -$T_{\mathrm{iq}}$ | [sec] | `Tiq` | Reactive-current command lag time constant | 0.02 | State 5 -$T_{\mathrm{pord}}$ | [sec] | `Tpord` | Active-power order filter time constant | 0.02 | State 6 +$T_{\mathrm{iq}}$ | [sec] | `Tiq` | Reactive-current command lag time constant | 0.02 | State 5; raised to the minimum-time floor +$T_{\mathrm{pord}}$ | [sec] | `Tpord` | Active-power order filter time constant | 0.02 | State 6; raised to the minimum-time floor $R_P^{\max}$ | [p.u./s] | `dPmax` | Positive active-power order ramp-rate limit | 99.0 | $R_P^{\min}$ | [p.u./s] | `dPmin` | Negative active-power order ramp-rate limit | -99.0 | $P^{\max}$ | [p.u.] | `Pmax` | Maximum active-power order limit | 1.0 | @@ -52,17 +53,14 @@ $I^{\max}$ | [p.u.] | `Imax` | Maximum total conver ### Parameter Validation -Invalid REECB parameter sets are rejected by the following checks. The displayed -equations use effective time constants with $\epsilon_T=10^{-3}$. +Invalid REECB parameter sets are rejected by the following checks: ```math \begin{aligned} - T &\leftarrow \max\!\left(T, \epsilon_T\right) - \quad T\in\{T_{\mathrm{rv}},T_{\mathrm{p}}\} \\ S^\mathrm{base} &> 0 \\ s_{\mathrm{pf}}, s_V, s_Q, s_{PQ} &\in \{0,1\} \\ - T_{\mathrm{rv}}, T_{\mathrm{p}} + T_{\mathrm{rv}}, T_{\mathrm{p}}, T_{\mathrm{iq}}, T_{\mathrm{pord}} &\ge 0 \\ V_{\mathrm{dip}} &< V_{\mathrm{up}} \\ @@ -74,8 +72,6 @@ equations use effective time constants with $\epsilon_T=10^{-3}$. &\le Q^{\max} \\ V^{\min} &\le V^{\max} \\ - T_{\mathrm{iq}}, T_{\mathrm{pord}} - &> 0 \\ R_P^{\min} &< 0 < R_P^{\max} \\ P^{\min} @@ -87,31 +83,43 @@ equations use effective time constants with $\epsilon_T=10^{-3}$. ### Model Derived Parameters +Let $\epsilon_T=10^{-3}\ \mathrm{s}$. A time constant below $\epsilon_T$ is +raised to that floor in place, so every equation below uses the raised value: + ```math \begin{aligned} + T_x + &\leftarrow \max\!\left(T_x,\epsilon_T\right), + \quad x\in\{\mathrm{rv},\mathrm{p},\mathrm{iq},\mathrm{pord}\} \\ s_{\mathrm{pf}}^\mathrm{off} &= 1 - s_{\mathrm{pf}} \\ s_V^\mathrm{off} &= 1 - s_V \\ s_Q^\mathrm{off} &= 1 - s_Q \\ - k_{\mathrm{base}} + k_\mathrm{base} &= \dfrac{S^\mathrm{sys}}{S^\mathrm{base}} \end{aligned} ``` +Multiplying by $k_\mathrm{base}$ converts system base to component base. + ## Model Ports -Name | Port | Init | Description ----------|--------|---------|------ -`bus` | Bus | Known | Terminal bus voltage -`pe` | Input | Unknown | Electrical active-power feedback -`qgen` | Input | Unknown | Reactive-power feedback -`qext` | Input | Unknown | External reactive-power command -`pfaref` | Input | Unknown | Power-factor angle reference -`pref` | Input | Unknown | External active-power reference -`iqcmd` | Output | Known | Reactive-current command output -`ipcmd` | Output | Known | Active-current command output +Name | Port | Init | Base | Description +---------|--------|---------|-------------|------ +`bus` | Bus | Known | - | Terminal-bus voltage +`pe` | Input | Unknown | System | Electrical active-power feedback +`qgen` | Input | Unknown | System | Reactive-power feedback +`qext` | Input | Unknown | System | External reactive-power command +`pfaref` | Input | Unknown | [rad] | Power-factor angle reference +`pref` | Input | Unknown | System | External active-power reference +`iqcmd` | Output | Known | System | Reactive-current command +`ipcmd` | Output | Known | System | Active-current command + +`Known` ports are seeded before `initialize()` and preserved by it. `Unknown` +inputs are resolved during initialization and written to attached signal +storage, or retained as constant inputs when the port is unattached. ## Model Variables @@ -134,7 +142,7 @@ Symbol | Units | Description ------------------------------------|----------|-------------------------------------|------ $V_T$ | [p.u.] | Terminal voltage magnitude | $V_{\mathrm{safe}}^\mathrm{meas}$ | [p.u.] | Safe filtered terminal voltage for divider blocks | Lower bounded by 0.01 -$s_{\mathrm{dip}}$ | [-] | Voltage inside-band control gate | +$s_{\mathrm{dip}}$ | [-] | Smooth voltage inside-band control gate | Approximately 1 inside the voltage band $e_V^\mathrm{db}$ | [p.u.] | Deadbanded voltage error | $I_q^\mathrm{inj}$ | [p.u.] | Reactive-current injection candidate | Component base $Q^\mathrm{ref}$ | [p.u.] | Selected reactive-power reference | @@ -159,7 +167,7 @@ None. #### Algebraic -Symbol | Units | Type | Description | Note +Symbol | Units | Init | Description | Note -------------------------------------|--------|---------|-----------------------------------|------ $V_{\mathrm{r}}$ | [p.u.] | Known | Terminal voltage, real component | Bus input $V_{\mathrm{i}}$ | [p.u.] | Known | Terminal voltage, imaginary component | Bus input @@ -182,7 +190,7 @@ $P^\mathrm{ref}$ | [p.u.] | Unknown | External active-power 0 &= -\dot{P}^\mathrm{meas} + \dfrac{1}{T_{\mathrm{p}}} - \left(k_{\mathrm{base}}P_e - P^\mathrm{meas}\right) \\ + \left(k_\mathrm{base}P_e - P^\mathrm{meas}\right) \\ 0 &= -\dot{x}_Q^\mathrm{PI} + s_{\mathrm{dip}}\, @@ -216,7 +224,7 @@ $P^\mathrm{ref}$ | [p.u.] | Unknown | External active-power \end{aligned} ``` -CommonMath defines the [Anti-Windup](../../../../CommonMath.md#anti-windup-indicator) +CommonMath defines the [`antiwindup`](../../../../CommonMath.md#antiwindup) target and smooth approximation. ### Algebraic Equations @@ -248,12 +256,12 @@ target and smooth approximation. 0 &= -Q^\mathrm{ref} + s_{\mathrm{pf}}P^\mathrm{meas}\tan\!\left(\phi^\mathrm{ref}\right) - + s_{\mathrm{pf}}^\mathrm{off}k_{\mathrm{base}}Q^\mathrm{ext} \\ + + s_{\mathrm{pf}}^\mathrm{off}k_\mathrm{base}Q^\mathrm{ext} \\ 0 &= -e_Q + \text{clamp} \left(Q^\mathrm{ref};\, Q^{\min}, Q^{\max}\right) - - k_{\mathrm{base}}Q^\mathrm{gen} \\ + - k_\mathrm{base}Q^\mathrm{gen} \\ 0 &= -V_Q^\mathrm{PI} + \text{clamp} @@ -267,7 +275,7 @@ target and smooth approximation. 0 &= -f_P^\mathrm{ord} + \dfrac{1}{T_{\mathrm{pord}}} - \left(k_{\mathrm{base}}P^\mathrm{ref} - P^\mathrm{ord}\right) \\ + \left(k_\mathrm{base}P^\mathrm{ref} - P^\mathrm{ord}\right) \\ 0 &= -r_P^\mathrm{ord} + \text{clamp} @@ -275,11 +283,11 @@ target and smooth approximation. 0 &= -\left(I_q^\mathrm{circ}\right)^2 + \left(I^{\max}\right)^2 - - s_{PQ}\left(k_{\mathrm{base}}I_p^\mathrm{cmd}\right)^2 \\ + - s_{PQ}\left(k_\mathrm{base}I_p^\mathrm{cmd}\right)^2 \\ 0 &= -\left(I_p^\mathrm{circ}\right)^2 + \left(I^{\max}\right)^2 - - \left(1-s_{PQ}\right)\left(k_{\mathrm{base}}I_q^\mathrm{cmd}\right)^2 \\ + - \left(1-s_{PQ}\right)\left(k_\mathrm{base}I_q^\mathrm{cmd}\right)^2 \\ 0 &= -I_q^{\max} + \left(1-s_{PQ}\right)I^{\max} @@ -299,13 +307,13 @@ target and smooth approximation. + s_Q^\mathrm{off}Q_V + \left(1-s_{\mathrm{dip}}\right)I_q^\mathrm{inj} \\ 0 &= - -k_{\mathrm{base}}I_q^\mathrm{cmd} - + \text{clamp} + -I_q^\mathrm{cmd} + + \dfrac{1}{k_\mathrm{base}}\text{clamp} \left(I_q^\mathrm{raw};\, -I_q^{\max}, I_q^{\max}\right) \\ 0 &= - -k_{\mathrm{base}}I_p^\mathrm{cmd} - + \text{clamp} + -I_p^\mathrm{cmd} + + \dfrac{1}{k_\mathrm{base}}\text{clamp} \left( \dfrac{P^\mathrm{ord}}{V_{\mathrm{safe}}^\mathrm{meas}};\, 0,\, @@ -321,36 +329,34 @@ CommonMath defines helper targets and smooth approximations for ### Input Initialization +The upstream source model seeds `ipcmd` and `iqcmd` before REECB initializes. +REECB snapshots them on component base first: + ```math \begin{aligned} V_{\mathrm{r}}, V_{\mathrm{i}} &\leftarrow \text{terminal-bus voltage} \\ - I_q^\mathrm{cmd}, I_p^\mathrm{cmd} - &\leftarrow \text{current-command start} + I_p^\mathrm{seed} + &\leftarrow k_\mathrm{base}I_p^\mathrm{cmd} \\ + I_q^\mathrm{seed} + &\leftarrow k_\mathrm{base}I_q^\mathrm{cmd} \end{aligned} ``` -### Internal Initialization +Initialization never replaces the system-base values held in +$I_p^\mathrm{cmd}$ and $I_q^\mathrm{cmd}$. -Define - -```math -\begin{aligned} - \text{awinit}(x^\star,f;\ell,u) - &= - \begin{cases} - x^\star & f = 0 \\ - u + \epsilon_{\mathrm{sat}} & f > 0 \\ - \ell - \epsilon_{\mathrm{sat}} & f < 0 - \end{cases} -\end{aligned} -``` +### Internal Initialization -with $\epsilon_{\mathrm{sat}}>0$. +The residual limits with the smooth CommonMath +[`clamp`](../../../../CommonMath.md#clamp), so a steady state is seeded with +the limiter *input*, not its output. With initialization tolerance +$\epsilon_0=10^{-10}$, $\text{clamp}^{-1}(z;\ell,u)$ is the input producing +output $z$, and $u_0^\mathrm{aw}(a,f;\ell,u)$ the input holding an anti-windup +path stationary: $a$ when $|f|\le\epsilon_0$, else just past the limit $f$ +drives toward. Both reject $z$ outside $[\ell,u]$. -Initialization is performed by evaluating the steady-state residuals in -dependency order. Let subscript $0$ denote initial values and set all internal -derivatives to zero: +Subscript $0$ denotes initial values; all internal derivatives start at zero: ```math \begin{aligned} @@ -363,7 +369,32 @@ derivatives to zero: V_{\mathrm{safe},0}^\mathrm{meas} &= \text{max}\left(V_0^\mathrm{meas}, 0.01\right) \\ P_0^\mathrm{meas} - &= k_{\mathrm{base}}P_{e,0} \\ + &= V_{\mathrm{safe},0}^\mathrm{meas}I_p^\mathrm{seed} \\ + k_\mathrm{base}Q_0^\mathrm{gen} + &= V_{\mathrm{safe},0}^\mathrm{meas}I_q^\mathrm{seed} \\ + I_{q,0}^\mathrm{circ} + &= + \begin{cases} + I^{\max} & s_{PQ}=0 \\ + \sqrt{(I^{\max})^2-(I_p^\mathrm{seed})^2} + & s_{PQ}=1 + \end{cases} \\ + I_{p,0}^\mathrm{circ} + &= + \begin{cases} + \sqrt{(I^{\max})^2-(I_q^\mathrm{seed})^2} + & s_{PQ}=0 \\ + I^{\max} & s_{PQ}=1 + \end{cases} \\ + I_{q,0}^{\max} + &= (1-s_{PQ})I^{\max}+s_{PQ}I_{q,0}^\mathrm{circ} \\ + I_{p,0}^{\max} + &= s_{PQ}I^{\max}+(1-s_{PQ})I_{p,0}^\mathrm{circ} +\end{aligned} +``` + +```math +\begin{aligned} s_{\mathrm{dip},0} &= \text{inside} \left(V_{T,0};\, V_{\mathrm{dip}}, V_{\mathrm{up}}\right) \\ @@ -377,33 +408,63 @@ derivatives to zero: \text{clamp} \left(K_{\mathrm{qv}}e_{V,0}^\mathrm{db};\, I_{q,\mathrm{inj}}^{\min}, I_{q,\mathrm{inj}}^{\max}\right) \\ + I_{q,0}^\mathrm{raw} + &= \text{clamp}^{-1} + \left(I_q^\mathrm{seed};\, + -I_{q,0}^{\max},I_{q,0}^{\max}\right) \\ + I_{q,0}^\mathrm{control} + &= I_{q,0}^\mathrm{raw} + -(1-s_{\mathrm{dip},0})I_{q,0}^\mathrm{inj} \\ + u_{p,0} + &= \text{clamp}^{-1} + \left(I_p^\mathrm{seed};\,0,I_{p,0}^{\max}\right) \\ + P_0^\mathrm{ord} + &= V_{\mathrm{safe},0}^\mathrm{meas}u_{p,0} \\ + f_{P,0}^\mathrm{ord} + &= \text{clamp}^{-1} + \left(0;\,R_P^{\min},R_P^{\max}\right) \\ + r_{P,0}^\mathrm{ord} + &= \text{clamp} + \left(f_{P,0}^\mathrm{ord};\,R_P^{\min},R_P^{\max}\right) +\end{aligned} +``` + +```math +\begin{aligned} Q_0^\mathrm{ref} &= - s_{\mathrm{pf}}P_0^\mathrm{meas} - \tan\!\left(\phi_0^\mathrm{ref}\right) - + s_{\mathrm{pf}}^\mathrm{off}k_{\mathrm{base}}Q_0^\mathrm{ext} \\ + \begin{cases} + \text{clamp}^{-1} + \left(k_\mathrm{base}Q_0^\mathrm{gen};\, + Q^{\min},Q^{\max}\right) + & s_Q=1\ \land\ s_V=1 \\ + V_0^\mathrm{meas} + & s_Q=1\ \land\ s_V=0 \\ + V_{\mathrm{safe},0}^\mathrm{meas}I_{q,0}^\mathrm{control} + & s_Q=0 + \end{cases} \\ e_{Q,0} &= \text{clamp} \left(Q_0^\mathrm{ref};\, Q^{\min}, Q^{\max}\right) - - k_{\mathrm{base}}Q_0^\mathrm{gen} \\ + - k_\mathrm{base}Q_0^\mathrm{gen} \\ Q_{V,0} &= \dfrac{Q_0^\mathrm{ref}}{V_{\mathrm{safe},0}^\mathrm{meas}} \\ - P_0^\mathrm{ord} - &= k_{\mathrm{base}}P_0^\mathrm{ref} \\ - f_{P,0}^\mathrm{ord} - &= 0 \\ - r_{P,0}^\mathrm{ord} - &= 0 \\ u_{Q,0}^\mathrm{PI} &= - \text{awinit} - \left( - s_V V_0^\mathrm{meas} - + s_V^\mathrm{off}Q_0^\mathrm{ref},\, - K_{\mathrm{qi}}e_{Q,0};\, - V^{\min}, V^{\max} - \right) \\ + \begin{cases} + \text{clamp}^{-1} + \left(V_0^\mathrm{meas};\,V^{\min},V^{\max}\right) + & s_Q=1\ \land\ s_V=1 \\ + u_0^\mathrm{aw} + \left(Q_0^\mathrm{ref},K_{\mathrm{qi}}e_{Q,0};\, + V^{\min},V^{\max}\right) + & s_Q=1\ \land\ s_V=0 \\ + u_0^\mathrm{aw} + \left(s_VV_0^\mathrm{meas}+s_V^\mathrm{off}Q_0^\mathrm{ref},\, + K_{\mathrm{qi}}e_{Q,0};\,V^{\min},V^{\max}\right) + & s_Q=0 + \end{cases} \\ V_{Q,0}^\mathrm{PI} &= \text{clamp} @@ -417,12 +478,16 @@ derivatives to zero: &= u_{Q,0}^\mathrm{PI} - K_{\mathrm{qp}}e_{Q,0} \\ u_{V,0}^\mathrm{PI} &= - \text{awinit} - \left( - k_{\mathrm{base}}Q_0^\mathrm{gen}/V_{\mathrm{safe},0}^\mathrm{meas},\, - K_{\mathrm{vi}}e_{V,0}^\mathrm{PI};\, - -I_{q,0}^{\max}, I_{q,0}^{\max} - \right) \\ + \begin{cases} + \text{clamp}^{-1} + \left(I_{q,0}^\mathrm{control};\, + -I_{q,0}^{\max},I_{q,0}^{\max}\right) + & s_Q=1 \\ + u_0^\mathrm{aw} + \left(0,K_{\mathrm{vi}}e_{V,0}^\mathrm{PI};\, + -I_{q,0}^{\max},I_{q,0}^{\max}\right) + & s_Q=0 + \end{cases} \\ I_{q,0}^\mathrm{base} &= \text{clamp} @@ -430,38 +495,68 @@ derivatives to zero: -I_{q,0}^{\max}, I_{q,0}^{\max}\right) \\ x_{V,0}^\mathrm{PI} - &= u_{V,0}^\mathrm{PI} - K_{\mathrm{vp}}e_{V,0}^\mathrm{PI} + &= u_{V,0}^\mathrm{PI} - K_{\mathrm{vp}}e_{V,0}^\mathrm{PI} \\ + 0 + &= -I_{q,0}^\mathrm{raw} + +s_Q I_{q,0}^\mathrm{base} + +s_Q^\mathrm{off}Q_{V,0} + +(1-s_{\mathrm{dip},0})I_{q,0}^\mathrm{inj} \end{aligned} ``` -Initialization rejects negative current-circle radicands. +The $s_Q=1$ path initializes the voltage PI output to reproduce +$I_{q,0}^\mathrm{control}$. The $s_Q=0$ path instead carries that target in +$Q_{V,0}$ and parks the otherwise inactive voltage PI path consistently. + +Initialization rejects an operating point when any of the following holds: + +- the bus voltage or either command seed is not finite; +- $I_p^\mathrm{seed}<0$; +- the command seeds leave the $I^{\max}$ circle, or a selected priority-circle + radicand is less than $-\epsilon_0$; +- the physical active-power target + $V_{\mathrm{safe},0}^\mathrm{meas}I_p^\mathrm{seed}$ lies outside + $[P^{\min},P^{\max}]$ by more than $\epsilon_0$; +- a required current, ramp-rate, reactive-power, voltage, or controller output + has no limiter input on its selected limits; or +- $s_{\mathrm{pf}}=1$, $|P_0^\mathrm{meas}|\le\epsilon_0$, and + $|Q_0^\mathrm{ref}|>\epsilon_0$. + +Every check resolves before any storage is written, so a rejected +initialization leaves state, command nodes, and external signals unchanged. ### Output Initialization ```math \begin{aligned} - P_e - &\leftarrow V_{\mathrm{safe},0}^\mathrm{meas} I_{p,0}^\mathrm{cmd} \\ - Q^\mathrm{gen} - &\leftarrow V_{\mathrm{safe},0}^\mathrm{meas} I_{q,0}^\mathrm{cmd} \\ - Q^\mathrm{ext} - &\leftarrow Q_0^\mathrm{gen} \\ - \phi^\mathrm{ref} + P_{e,0} + &\leftarrow V_{\mathrm{safe},0}^\mathrm{meas}I_p^\mathrm{cmd} \\ + Q_0^\mathrm{gen} + &\leftarrow V_{\mathrm{safe},0}^\mathrm{meas}I_q^\mathrm{cmd} \\ + Q_0^\mathrm{ext} + &\leftarrow \dfrac{Q_0^\mathrm{ref}}{k_\mathrm{base}} \\ + \phi_0^\mathrm{ref} &\leftarrow \begin{cases} - \tan^{-1}\!\left(Q_0^\mathrm{gen}/P_{e,0}\right) & P_{e,0} \ne 0 \\ - 0 & P_{e,0} = 0 + \tan^{-1}\!\left(Q_0^\mathrm{ref}/P_0^\mathrm{meas}\right) + & s_{\mathrm{pf}}=1 + \ \land\ |P_0^\mathrm{meas}|>\epsilon_0 \\ + 0 + & s_{\mathrm{pf}}=0 + \ \lor\ + \left(|P_0^\mathrm{meas}|\le\epsilon_0 + \ \land\ |Q_0^\mathrm{ref}|\le\epsilon_0\right) \end{cases} \\ - P^\mathrm{ref} + P_0^\mathrm{ref} &\leftarrow - \dfrac{1}{k_{\mathrm{base}}}\text{clamp} - \left(k_{\mathrm{base}}P_{e,0};\, P^{\min}, P^{\max}\right) + \dfrac{P_0^\mathrm{ord} + +T_{\mathrm{pord}}f_{P,0}^\mathrm{ord}} + {k_\mathrm{base}} \end{aligned} ``` -REECB writes the resolved feedback and reference values to attached `pe`, -`qgen`, `qext`, `pfaref`, and `pref` signal inputs. If no signal is attached, -those values are used as constant inputs. +These expressions are on system base. $Q_0^\mathrm{ext}$ is published even when +$s_{\mathrm{pf}}=1$, though the power-factor path does not consume it. ## Monitorable Outputs diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp b/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp index 9d67526f6..b5c994d33 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp @@ -69,6 +69,48 @@ namespace GridKit MAXIMUM, }; + /// Indices into the REECB state, derivative, and residual vectors. + struct ReecbIdx + { + static constexpr size_t VMEAS = static_cast(ReecbInternalVariables::VMEAS); + static constexpr size_t PMEAS = static_cast(ReecbInternalVariables::PMEAS); + static constexpr size_t XPIQ = static_cast(ReecbInternalVariables::XPIQ); + static constexpr size_t XPIV = static_cast(ReecbInternalVariables::XPIV); + static constexpr size_t QV = static_cast(ReecbInternalVariables::QV); + static constexpr size_t PORD = static_cast(ReecbInternalVariables::PORD); + static constexpr size_t VT = static_cast(ReecbInternalVariables::VT); + static constexpr size_t VMEASSAFE = static_cast(ReecbInternalVariables::VMEASSAFE); + static constexpr size_t SDIP = static_cast(ReecbInternalVariables::SDIP); + static constexpr size_t VERR = static_cast(ReecbInternalVariables::VERR); + static constexpr size_t IQV = static_cast(ReecbInternalVariables::IQV); + static constexpr size_t QREF = static_cast(ReecbInternalVariables::QREF); + static constexpr size_t EQ = static_cast(ReecbInternalVariables::EQ); + static constexpr size_t VPIQ = static_cast(ReecbInternalVariables::VPIQ); + static constexpr size_t EPIV = static_cast(ReecbInternalVariables::EPIV); + static constexpr size_t FPORD = static_cast(ReecbInternalVariables::FPORD); + static constexpr size_t RPORD = static_cast(ReecbInternalVariables::RPORD); + static constexpr size_t IQCIRC = static_cast(ReecbInternalVariables::IQCIRC); + static constexpr size_t IPCIRC = static_cast(ReecbInternalVariables::IPCIRC); + static constexpr size_t IQMAX = static_cast(ReecbInternalVariables::IQMAX); + static constexpr size_t IPMAX = static_cast(ReecbInternalVariables::IPMAX); + static constexpr size_t IQBASE = static_cast(ReecbInternalVariables::IQBASE); + static constexpr size_t IQRAW = static_cast(ReecbInternalVariables::IQRAW); + static constexpr size_t IQCMD = static_cast(ReecbInternalVariables::IQCMD); + static constexpr size_t IPCMD = static_cast(ReecbInternalVariables::IPCMD); + static constexpr size_t MAXIMUM = static_cast(ReecbInternalVariables::MAXIMUM); + }; + + /// Indices into the REECB external-signal buffers. + struct ReecbExt + { + static constexpr size_t PE = static_cast(ReecbExternalVariables::PE); + static constexpr size_t QGEN = static_cast(ReecbExternalVariables::QGEN); + static constexpr size_t QEXT = static_cast(ReecbExternalVariables::QEXT); + static constexpr size_t PFAREF = static_cast(ReecbExternalVariables::PFAREF); + static constexpr size_t PREF = static_cast(ReecbExternalVariables::PREF); + static constexpr size_t MAXIMUM = static_cast(ReecbExternalVariables::MAXIMUM); + }; + template class Reecb : public Component { @@ -127,28 +169,41 @@ namespace GridKit const ScalarT*, const ScalarT*, const ScalarT*, const ScalarT*, ScalarT*); private: - void initModelParams(const ModelDataT& data); + void initializeParameters(const ModelDataT& data); void initializeMonitor(); void setDerivedParameters(); + /// Solve the input required to produce a requested smooth-limiter output. + /// The limits may be constant Real parameters or algebraic variables. + template + bool solveLimiterInput(ScalarT requested_output, LowerT lower_limit, UpperT upper_limit, ScalarT& limiter_input) const; + + /// Select a limiter input that zeros an anti-windup rate to initialization tolerance. + /// The limits may be constant Real parameters or algebraic variables. + template + ScalarT steadyAntiWindupInput(ScalarT nominal_input, ScalarT rate, LowerT lower_limit, UpperT upper_limit) const; + + /// Evaluate log(1 - exp(-x)) without cancellation for positive x. + RealT logOneMinusExp(RealT x) const; + ScalarT toComponentBase(ScalarT value) const; ScalarT toSystemBase(ScalarT value) const; ScalarT& Vr(); ScalarT& Vi(); - static constexpr RealT TIME_CONSTANT_MINIMUM = static_cast(1.0e-3); - static constexpr RealT VMEAS_MINIMUM = static_cast(0.01); - static constexpr RealT INIT_TOL = static_cast(1.0e-10); - static constexpr RealT SAT_MARGIN = static_cast(0.1); + static constexpr RealT TIME_CONSTANT_MINIMUM = static_cast(1.0e-3); + static constexpr RealT VMEAS_MINIMUM = static_cast(0.01); + static constexpr RealT INITIALIZATION_TOLERANCE = static_cast(1.0e-10); + static constexpr RealT INITIALIZATION_LIMIT_OFFSET = static_cast(0.1); BusT* bus_{nullptr}; - RealT mva_base_{static_cast(100.0)}; - RealT PfFlag_{ZERO}; - RealT VFlag_{ZERO}; - RealT QFlag_{ZERO}; - RealT Pqflag_{ZERO}; + RealT mva_base_{ZERO}; + bool PfFlag_{false}; + bool VFlag_{false}; + bool QFlag_{false}; + bool Pqflag_{false}; RealT Trv_{static_cast(0.02)}; RealT Tp_{ZERO}; RealT Vref0_{ZERO}; @@ -175,11 +230,14 @@ namespace GridKit RealT Pmin_{ZERO}; RealT Imax_{static_cast(1.3)}; RealT va_converter_base_{0}; - RealT Trv_eff_{TIME_CONSTANT_MINIMUM}; - RealT Tp_eff_{TIME_CONSTANT_MINIMUM}; + RealT pf_on_{0}; RealT pf_off_{1}; + RealT v_on_{0}; RealT v_off_{1}; + RealT q_on_{0}; RealT q_off_{1}; + RealT p_priority_{0}; + RealT q_priority_{1}; bool Vref0_given_{false}; IdxT parameter_error_count_{0}; diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp index fb5545239..02a75d9e3 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp @@ -25,22 +25,37 @@ namespace GridKit { using Log = ::GridKit::Utilities::Logger; + /** + * @brief Construct a REECB controller without parameters + * + * The model is sized but left unconfigured. Every parameter keeps its + * documented default, the required power base is absent, and no monitor + * is created, so verify() reports configuration errors until the data + * constructor is used instead. + * + * @param[in] bus Terminal bus the controller measures. + */ template Reecb::Reecb(BusT* bus) : bus_(bus) { - size_ = static_cast(ReecbInternalVariables::MAXIMUM); - setDerivedParameters(); + size_ = static_cast(ReecbIdx::MAXIMUM); } + /** + * @brief Construct a REECB controller from model data + * + * @param[in] bus Terminal bus the controller measures. + * @param[in] data Parameters and monitored-variable selections. + */ template Reecb::Reecb(BusT* bus, const ModelDataT& data) : bus_(bus), monitor_(std::make_unique(data)) { - initModelParams(data); + initializeParameters(data); initializeMonitor(); - size_ = static_cast(ReecbInternalVariables::MAXIMUM); + size_ = static_cast(ReecbIdx::MAXIMUM); } template @@ -48,29 +63,230 @@ namespace GridKit { } + /** + * @brief Terminal-bus voltage, real component + * + * @return Reference to the bus variable. + */ template scalar_type& Reecb::Vr() { return bus_->Vr(); } + /** + * @brief Terminal-bus voltage, imaginary component + * + * @return Reference to the bus variable. + */ template scalar_type& Reecb::Vi() { return bus_->Vi(); } + /** + * @brief Resolve the parameter-derived constants and selector masks + * + * Raises each controller lag to the well-posedness floor, sizes the + * component power base, and turns the four mode flags into complementary + * multiplicative masks. The masks let the residual select control paths + * without parameter-dependent control flow, which keeps its structure + * fixed for sparse automatic differentiation. + */ template void Reecb::setDerivedParameters() { + // The lags are raised to the floor in place, so a negative value is + // rejected here while the value as read is still available. verify() + // reports the count. + auto check_non_negative = [&](RealT value, const char* name) + { + if (value < ZERO) + { + Log::error() << "Reecb: " << name << " must be non-negative\n"; + ++parameter_error_count_; + } + }; + + check_non_negative(Trv_, "Trv"); + check_non_negative(Tp_, "Tp"); + check_non_negative(Tiq_, "Tiq"); + check_non_negative(Tpord_, "Tpord"); + + if (Trv_ < TIME_CONSTANT_MINIMUM || Tp_ < TIME_CONSTANT_MINIMUM + || Tiq_ < TIME_CONSTANT_MINIMUM || Tpord_ < TIME_CONSTANT_MINIMUM) + { + Log::warning() << "Reecb: Trv, Tp, Tiq, and Tpord below " + << TIME_CONSTANT_MINIMUM + << " s are raised to that floor to keep the controller lags well posed\n"; + } + + Trv_ = std::max(Trv_, TIME_CONSTANT_MINIMUM); + Tp_ = std::max(Tp_, TIME_CONSTANT_MINIMUM); + Tiq_ = std::max(Tiq_, TIME_CONSTANT_MINIMUM); + Tpord_ = std::max(Tpord_, TIME_CONSTANT_MINIMUM); + va_converter_base_ = mva_base_ * static_cast(1.0e6); - Trv_eff_ = std::max(Trv_, TIME_CONSTANT_MINIMUM); - Tp_eff_ = std::max(Tp_, TIME_CONSTANT_MINIMUM); - pf_off_ = ONE - PfFlag_; - v_off_ = ONE - VFlag_; - q_off_ = ONE - QFlag_; + + pf_on_ = PfFlag_ ? ONE : ZERO; + v_on_ = VFlag_ ? ONE : ZERO; + q_on_ = QFlag_ ? ONE : ZERO; + + pf_off_ = ONE - pf_on_; + v_off_ = ONE - v_on_; + q_off_ = ONE - q_on_; + + p_priority_ = Pqflag_ ? ONE : ZERO; + q_priority_ = ONE - p_priority_; } + /** + * @brief Evaluate log(1 - exp(-x)) without cancellation + * + * Both terms approach one for a small argument, so the direct form loses + * precision exactly where the limiter inversions below need it. The + * hyperbolic identity is used under log 2 and log1p above it. + * + * @param[in] x Strictly positive argument. + * @return The logarithm, always negative. + */ + template + typename Reecb::RealT + Reecb::logOneMinusExp(RealT x) const + { + static constexpr RealT LOG_TWO = static_cast(0.6931471805599453); + + if (x < LOG_TWO) + { + return LOG_TWO - HALF * x + + std::log(std::sinh(HALF * x)); + } + return std::log1p(-std::exp(-x)); + } + + /** + * @brief Recover the input that a smooth clamp maps to a requested output + * + * Initialization uses the same smooth CommonMath clamp as the residual, + * so a steady state must be seeded with the limiter *input* rather than + * its output. The smooth clamp is asymptotic at both limits, so a + * requested output within the initialization tolerance of a limit is + * represented by a finite offset past it instead of by the true infinite + * preimage. + * + * @tparam LowerT Type of the lower limiter bound. + * @tparam UpperT Type of the upper limiter bound. + * + * @param[in] requested_output Output the limiter must reproduce. + * @param[in] lower_limit Lower limiter bound. + * @param[in] upper_limit Upper limiter bound. + * @param[out] limiter_input Input producing the requested output. + * @return false when the request lies outside the limits, or when the + * limits coincide at a different value; the output is then unset. + * + * @note The limit types intentionally may differ from the scalar type so + * that constant Real limits and algebraic-variable limits both work. + */ + template + template + bool Reecb::solveLimiterInput( + ScalarT requested_output, + LowerT lower_limit, + UpperT upper_limit, + ScalarT& limiter_input) const + { + const RealT output_value = static_cast(requested_output); + const RealT lower_value = static_cast(lower_limit); + const RealT upper_value = static_cast(upper_limit); + + if (lower_value > upper_value + || output_value < lower_value - INITIALIZATION_TOLERANCE + || output_value > upper_value + INITIALIZATION_TOLERANCE) + { + return false; + } + + const RealT width = upper_value - lower_value; + if (width <= INITIALIZATION_TOLERANCE) + { + limiter_input = static_cast(lower_value); + return std::abs(output_value - lower_value) <= INITIALIZATION_TOLERANCE; + } + + const RealT distance_from_lower = output_value - lower_value; + const RealT distance_from_upper = upper_value - output_value; + if (distance_from_lower <= INITIALIZATION_TOLERANCE) + { + limiter_input = static_cast(lower_value - INITIALIZATION_LIMIT_OFFSET); + return true; + } + if (distance_from_upper <= INITIALIZATION_TOLERANCE) + { + limiter_input = static_cast(upper_value + INITIALIZATION_LIMIT_OFFSET); + return true; + } + + const RealT scaled_lower_distance = Math::MU * distance_from_lower; + const RealT scaled_upper_distance = Math::MU * distance_from_upper; + const RealT correction = (scaled_lower_distance + + logOneMinusExp(scaled_lower_distance) + - logOneMinusExp(scaled_upper_distance)) + / Math::MU; + limiter_input = static_cast(lower_value + correction); + return true; + } + + /** + * @brief Choose a PI input whose anti-windup derivative is stationary + * + * An anti-windup integrator is at rest either because its rate is zero + * or because the rate pushes into a limit that blocks it. A nonzero rate + * is therefore parked just past the limit it drives toward. + * + * The zero-rate branch deliberately returns the nominal input without + * clamping it: an inactive PI history may legitimately sit outside its + * own output limits, and callers that need a representable output ask + * solveLimiterInput() for one explicitly. + * + * @tparam LowerT Type of the lower limiter bound. + * @tparam UpperT Type of the upper limiter bound. + * + * @param[in] nominal_input Input to keep when the rate is already zero. + * @param[in] rate Anti-windup integrator rate. + * @param[in] lower_limit Lower limiter bound. + * @param[in] upper_limit Upper limiter bound. + * @return A stationary integrator input. + * + * @note The limit types intentionally may differ from the scalar type so + * that constant Real limits and algebraic-variable limits both work. + */ + template + template + scalar_type Reecb::steadyAntiWindupInput( + ScalarT nominal_input, + ScalarT rate, + LowerT lower_limit, + UpperT upper_limit) const + { + const RealT rate_value = static_cast(rate); + if (std::abs(rate_value) <= INITIALIZATION_TOLERANCE) + { + return nominal_input; + } + if (rate_value > ZERO) + { + return upper_limit + static_cast(INITIALIZATION_LIMIT_OFFSET); + } + return lower_limit - static_cast(INITIALIZATION_LIMIT_OFFSET); + } + + /** + * @brief Convert a system-base power or current to REECB component base + * + * @param[in] value Quantity on the system base. + * @return The same quantity on the component base. + */ template scalar_type Reecb::toComponentBase( scalar_type value) const @@ -78,6 +294,12 @@ namespace GridKit return value * va_system_base_ / va_converter_base_; } + /** + * @brief Convert a component-base power or current to the system base + * + * @param[in] value Quantity on the component base. + * @return The same quantity on the system base. + */ template scalar_type Reecb::toSystemBase( scalar_type value) const @@ -85,8 +307,19 @@ namespace GridKit return value / toComponentBase(static_cast(ONE)); } + /** + * @brief Read the parameters out of the model data + * + * Only the component power base is required; every other parameter keeps + * the default documented in the model README when omitted. A missing + * required key, a non-numeric value, or a switch outside {0, 1} is + * counted and reported by verify() rather than throwing. Integer JSON + * values are accepted for real parameters. + * + * @param[in] data Parameters and monitored-variable selections. + */ template - void Reecb::initModelParams(const ModelDataT& data) + void Reecb::initializeParameters(const ModelDataT& data) { using Params = typename ModelDataT::Parameters; @@ -116,7 +349,7 @@ namespace GridKit } }; - auto load_switch = [&](auto key, RealT& target, const char* name) + auto load_switch = [&](auto key, bool& target, const char* name) { if (!data.parameters.contains(key)) { @@ -126,25 +359,17 @@ namespace GridKit const auto& value = data.parameters.at(key); if (const auto* bool_value = std::get_if(&value)) { - target = ZERO; - if (*bool_value) - { - target = ONE; - } + target = *bool_value; } else if (const auto* index_value = std::get_if(&value); index_value && (*index_value == 0 || *index_value == 1)) { - target = static_cast(*index_value); - } - else if (const auto* real_value = std::get_if(&value); - real_value && (*real_value == ZERO || *real_value == ONE) ) - { - target = *real_value; + target = (*index_value == 1); } else { - Log::error() << "Reecb: parameter '" << name << "' must be bool or 0/1\n"; + Log::error() << "Reecb: parameter '" << name + << "' must be bool or integer 0/1\n"; ++parameter_error_count_; } }; @@ -191,31 +416,47 @@ namespace GridKit setDerivedParameters(); } + /** + * @brief Access the monitor + * + * @return Monitor for this model, or nullptr when the model was + * constructed without data. + */ template const Model::VariableMonitorBase* Reecb::getMonitor() const { return monitor_.get(); } + /** + * @brief Bind the monitorable variables to their internal states + * + * The two current commands are published on the system base and the two + * filtered measurements on the component base, as documented in the + * model README. + */ template void Reecb::initializeMonitor() { + using I = ReecbIdx; using Variable = typename ModelDataT::MonitorableVariables; - auto index = [](ReecbInternalVariables variable) - { - return static_cast(variable); - }; - monitor_->set(Variable::iqcmd, [this, index] - { return y_.getData()[index(ReecbInternalVariables::IQCMD)]; }); - monitor_->set(Variable::ipcmd, [this, index] - { return y_.getData()[index(ReecbInternalVariables::IPCMD)]; }); - monitor_->set(Variable::vmeas, [this, index] - { return y_.getData()[index(ReecbInternalVariables::VMEAS)]; }); - monitor_->set(Variable::pmeas, [this, index] - { return y_.getData()[index(ReecbInternalVariables::PMEAS)]; }); + monitor_->set(Variable::iqcmd, [this] + { return y_.getData()[I::IQCMD]; }); + monitor_->set(Variable::ipcmd, [this] + { return y_.getData()[I::IPCMD]; }); + monitor_->set(Variable::vmeas, [this] + { return y_.getData()[I::VMEAS]; }); + monitor_->set(Variable::pmeas, [this] + { return y_.getData()[I::PMEAS]; }); } + /** + * @brief Set the component ID + * + * @param[in] component_id Identifier assigned by the system model. + * @return int 0 on success. + */ template int Reecb::setGridKitComponentID(IdxT component_id) { @@ -223,9 +464,23 @@ namespace GridKit return 0; } + /** + * @brief Allocate the model vectors and wire the command outputs + * + * Sizes the state, residual, bus-interface, and signal-interface + * buffers, seeds the identity index maps, and points each assigned + * command node at the internal state it publishes. Those nodes alias + * REECB storage from here on, which is how initialize() reads the seeds + * an upstream model wrote. Repeated calls reuse the allocated vectors. + * + * @return int 0 on success. + */ template int Reecb::allocate() { + using I = ReecbIdx; + using E = ReecbExt; + if (!allocated_) { this->allocateVectors(size_); @@ -238,7 +493,7 @@ namespace GridKit wb_.assign(2, ScalarT{0}); - auto signal_size = static_cast(ReecbExternalVariables::MAXIMUM); + auto signal_size = E::MAXIMUM; ws_.assign(signal_size, ScalarT{0}); ws_indices_.assign(signal_size, INVALID_INDEX); @@ -248,26 +503,36 @@ namespace GridKit this->setResidualIndex(j, j); } + auto* y = y_.getData(); + if (signals_.template isAssigned()) { - auto* y = y_.getData(); signals_.template getSignalNode()->set( - &y[static_cast(ReecbInternalVariables::IQCMD)], - &(this->getVariableIndex(static_cast(ReecbInternalVariables::IQCMD)))); + &y[I::IQCMD], + &(this->getVariableIndex(static_cast(I::IQCMD)))); } if (signals_.template isAssigned()) { - auto* y = y_.getData(); signals_.template getSignalNode()->set( - &y[static_cast(ReecbInternalVariables::IPCMD)], - &(this->getVariableIndex(static_cast(ReecbInternalVariables::IPCMD)))); + &y[I::IPCMD], + &(this->getVariableIndex(static_cast(I::IPCMD)))); } allocated_ = true; return 0; } + /** + * @brief Validate the REECB configuration + * + * Checks parameter-loading errors, static parameter relationships, + * terminal-bus association, and attached external signals. Seeded + * command feasibility is operating-point dependent and is checked by + * initialize(). + * + * @return int Number of configuration errors; zero when valid. + */ template int Reecb::verify() const { @@ -289,249 +554,336 @@ namespace GridKit } check(mva_base_ > ZERO, "mva must be positive"); - check(va_converter_base_ > ZERO, "converter VA base must be positive"); - check(PfFlag_ == ZERO || PfFlag_ == ONE, "PfFlag must be 0 or 1"); - check(VFlag_ == ZERO || VFlag_ == ONE, "VFlag must be 0 or 1"); - check(QFlag_ == ZERO || QFlag_ == ONE, "QFlag must be 0 or 1"); - check(Pqflag_ == ZERO || Pqflag_ == ONE, "Pqflag must be 0 or 1"); - check(Trv_ >= ZERO, "Trv must be non-negative"); - check(Tp_ >= ZERO, "Tp must be non-negative"); check(Vdip_ < Vup_, "Vdip must be less than Vup"); check(dbd1_ <= ZERO && ZERO <= dbd2_, "dbd1 <= 0 <= dbd2 is required"); check(Iql1_ <= Iqh1_, "Iql1 must be less than or equal to Iqh1"); check(Qmin_ <= Qmax_, "Qmin must be less than or equal to Qmax"); check(Vmin_ <= Vmax_, "Vmin must be less than or equal to Vmax"); - check(Tiq_ > ZERO, "Tiq must be positive"); - check(Tpord_ > ZERO, "Tpord must be positive"); check(dPmin_ < ZERO && ZERO < dPmax_, "dPmin < 0 < dPmax is required"); check(Pmin_ <= Pmax_, "Pmin must be less than or equal to Pmax"); check(Imax_ >= ZERO, "Imax must be non-negative"); - auto check_attached_signal = [&](bool attached, bool linked, const char* name) + // An attached port must resolve to writable signal storage. The + // enumerator is a template argument, so each port names itself once. + auto check_attached_signal = + [&](const char* name) { - if (attached && !linked) + if (signals_.template isAttached() + && !signals_.template isLinked()) { - Log::error() << "Reecb: " << name << " signal attached with no linked variable\n"; + Log::error() << "Reecb: " << name << " signal attached with no linked source\n"; ret += 1; } }; - check_attached_signal( - signals_.template isAttached(), - signals_.template isAttached() - && signals_.template isLinked(), - "pe"); - check_attached_signal( - signals_.template isAttached(), - signals_.template isAttached() - && signals_.template isLinked(), - "qgen"); - check_attached_signal( - signals_.template isAttached(), - signals_.template isAttached() - && signals_.template isLinked(), - "qext"); - check_attached_signal( - signals_.template isAttached(), - signals_.template isAttached() - && signals_.template isLinked(), - "pfaref"); - check_attached_signal( - signals_.template isAttached(), - signals_.template isAttached() - && signals_.template isLinked(), - "pref"); + check_attached_signal.template operator()("pe"); + check_attached_signal.template operator()("qgen"); + check_attached_signal.template operator()("qext"); + check_attached_signal.template operator()("pfaref"); + check_attached_signal.template operator()("pref"); return ret; } + /** + * @brief Initialize REECB from seeded current-command ports + * + * Reads the assigned system-base `ipcmd` and `iqcmd` nodes, resolves a + * component-base steady state that preserves those seeds, and initializes + * attached feedback/reference signals. All operating-point checks are + * completed before model or signal storage is modified. + * + * @pre allocate() has completed. + * @pre verify() has reported no configuration errors. + * @pre The terminal bus and assigned command nodes have been initialized. + * + * @return int 0 on success; nonzero when the commands are outside the + * current circle, the selected control path cannot represent + * them, or an initial reference is undefined. + */ template int Reecb::initialize() { - if (parameter_error_count_ > 0 || verify() > 0) + using I = ReecbIdx; + + auto* y = y_.getData(); + + // Assigned command nodes alias these entries after allocate(). Their + // system-base seeds remain untouched throughout initialization. + const ScalarT ipcmd0_system = y[I::IPCMD]; + const ScalarT iqcmd0_system = y[I::IQCMD]; + const ScalarT ipcmd0 = toComponentBase(ipcmd0_system); + const ScalarT iqcmd0 = toComponentBase(iqcmd0_system); + const RealT ipcmd0_value = static_cast(ipcmd0); + const RealT iqcmd0_value = static_cast(iqcmd0); + + const ScalarT vr = Vr(); + const ScalarT vi = Vi(); + const ScalarT vt0 = std::sqrt(vr * vr + vi * vi); + const ScalarT vmeas0 = vt0; + const ScalarT vmeas_safe0 = Math::max(vmeas0, VMEAS_MINIMUM); + const ScalarT pmeas0 = ipcmd0 * vmeas_safe0; + const ScalarT qgen0 = iqcmd0 * vmeas_safe0; + const RealT vref0 = Vref0_given_ ? Vref0_ : static_cast(vt0); + + if (!std::isfinite(ipcmd0_value) || !std::isfinite(iqcmd0_value) || !std::isfinite(static_cast(vt0))) { - Log::error() << "Reecb: cannot initialize with invalid configuration\n"; + Log::error() << "Reecb: initial bus voltage and current commands must be finite\n"; + return 1; + } + if (ipcmd0_value < ZERO) + { + Log::error() << "Reecb: initial active-current command must be non-negative\n"; return 1; } - auto* y = y_.getData(); - auto* yp = yp_.getData(); - - const auto VMEAS = static_cast(ReecbInternalVariables::VMEAS); - const auto PMEAS = static_cast(ReecbInternalVariables::PMEAS); - const auto XPIQ = static_cast(ReecbInternalVariables::XPIQ); - const auto XPIV = static_cast(ReecbInternalVariables::XPIV); - const auto QV = static_cast(ReecbInternalVariables::QV); - const auto PORD = static_cast(ReecbInternalVariables::PORD); - const auto VT = static_cast(ReecbInternalVariables::VT); - const auto VMEASSAFE = static_cast(ReecbInternalVariables::VMEASSAFE); - const auto SDIP = static_cast(ReecbInternalVariables::SDIP); - const auto VERR = static_cast(ReecbInternalVariables::VERR); - const auto IQV = static_cast(ReecbInternalVariables::IQV); - const auto QREF = static_cast(ReecbInternalVariables::QREF); - const auto EQ = static_cast(ReecbInternalVariables::EQ); - const auto VPIQ = static_cast(ReecbInternalVariables::VPIQ); - const auto EPIV = static_cast(ReecbInternalVariables::EPIV); - const auto FPORD = static_cast(ReecbInternalVariables::FPORD); - const auto RPORD = static_cast(ReecbInternalVariables::RPORD); - const auto IQCIRC = static_cast(ReecbInternalVariables::IQCIRC); - const auto IPCIRC = static_cast(ReecbInternalVariables::IPCIRC); - const auto IQMAX = static_cast(ReecbInternalVariables::IQMAX); - const auto IPMAX = static_cast(ReecbInternalVariables::IPMAX); - const auto IQBASE = static_cast(ReecbInternalVariables::IQBASE); - const auto IQRAW = static_cast(ReecbInternalVariables::IQRAW); - const auto IQCMD = static_cast(ReecbInternalVariables::IQCMD); - const auto IPCMD = static_cast(ReecbInternalVariables::IPCMD); - - const ScalarT vr = Vr(); - const ScalarT vi = Vi(); - - y[VT] = std::sqrt(vr * vr + vi * vi); - if (!Vref0_given_) + const RealT current_squared0 = ipcmd0_value * ipcmd0_value + iqcmd0_value * iqcmd0_value; + const RealT current_limit_squared0 = Imax_ * Imax_; + if (current_squared0 > current_limit_squared0 + INITIALIZATION_TOLERANCE) { - Vref0_ = static_cast(y[VT]); + Log::error() << "Reecb: initial current commands exceed the Imax circle\n"; + return 1; } - y[VMEAS] = y[VT]; - y[VMEASSAFE] = Math::max(y[VMEAS], VMEAS_MINIMUM); + const RealT pmeas0_value = static_cast(pmeas0); + if (pmeas0_value < Pmin_ - INITIALIZATION_TOLERANCE || pmeas0_value > Pmax_ + INITIALIZATION_TOLERANCE) + { + Log::error() << "Reecb: initial active power is outside Pmin/Pmax\n"; + return 1; + } - const ScalarT ipcmd0 = toComponentBase(y[IPCMD]); - const ScalarT iqcmd0 = toComponentBase(y[IQCMD]); - ScalarT pe0 = ipcmd0 * y[VMEASSAFE]; - ScalarT qgen0 = iqcmd0 * y[VMEASSAFE]; + const RealT iqcirc_squared0 = current_limit_squared0 - p_priority_ * ipcmd0_value * ipcmd0_value; + const RealT ipcirc_squared0 = current_limit_squared0 - q_priority_ * iqcmd0_value * iqcmd0_value; + if (iqcirc_squared0 < -INITIALIZATION_TOLERANCE || ipcirc_squared0 < -INITIALIZATION_TOLERANCE) + { + Log::error() << "Reecb: initial current commands violate the selected priority circle\n"; + return 1; + } - const ScalarT qext0 = qgen0; - const ScalarT pref0 = Math::clamp(pe0, Pmin_, Pmax_); + const ScalarT iqcirc0 = static_cast(std::sqrt(std::max(iqcirc_squared0, ZERO))); + const ScalarT ipcirc0 = static_cast(std::sqrt(std::max(ipcirc_squared0, ZERO))); + const ScalarT iqmax0 = q_priority_ * static_cast(Imax_) + p_priority_ * iqcirc0; + const ScalarT ipmax0 = p_priority_ * static_cast(Imax_) + q_priority_ * ipcirc0; - pe_set_ = toSystemBase(pe0); - qgen_set_ = toSystemBase(qgen0); - qext_set_ = toSystemBase(qext0); - pfaref_set_ = std::abs(static_cast(pe0)) > INIT_TOL ? static_cast(std::atan(static_cast(qgen0 / pe0))) : static_cast(ZERO); - pref_set_ = toSystemBase(pref0); + const ScalarT sdip0 = Math::inside(vt0, Vdip_, Vup_); + const ScalarT verr0 = Math::deadband2(static_cast(vref0) - vmeas0, dbd1_, dbd2_); + const ScalarT iqv0 = Math::clamp(kqv_ * verr0, Iql1_, Iqh1_); + const ScalarT iqinj0 = (ONE - sdip0) * iqv0; - if (signals_.template isAttached()) + ScalarT iqraw0{}; + if (!solveLimiterInput(iqcmd0, -iqmax0, iqmax0, iqraw0)) { - signals_.template writeExternalVariable(pe_set_); + Log::error() << "Reecb: initial reactive-current command is outside the available current limit\n"; + return 1; } - if (signals_.template isAttached()) + + ScalarT ip_limiter_input0{}; + if (!solveLimiterInput(ipcmd0, ZERO, ipmax0, ip_limiter_input0)) { - signals_.template writeExternalVariable(qgen_set_); + Log::error() << "Reecb: initial active-current command is outside the available current limit\n"; + return 1; } - if (signals_.template isAttached()) + const ScalarT pord0 = ip_limiter_input0 * vmeas_safe0; + + ScalarT fpord0{}; + if (!solveLimiterInput(static_cast(ZERO), dPmin_, dPmax_, fpord0)) { - signals_.template writeExternalVariable(qext_set_); + Log::error() << "Reecb: zero initial active-power ramp is outside dPmin/dPmax\n"; + return 1; } - if (signals_.template isAttached()) + const ScalarT rpord0 = Math::clamp(fpord0, dPmin_, dPmax_); + const ScalarT pref0 = pord0 + Tpord_ * fpord0; + + const ScalarT iq_control0 = iqraw0 - iqinj0; + + struct ReactiveSeed { - signals_.template writeExternalVariable(pfaref_set_); + ScalarT qv{}; + ScalarT qref{}; + ScalarT eq{}; + ScalarT vpiq{}; + ScalarT epiv{}; + ScalarT xpiq{}; + ScalarT iqbase{}; + ScalarT xpiv{}; + } reactive; + + if (!QFlag_) + { + reactive.qv = iq_control0; + reactive.qref = reactive.qv * vmeas_safe0; } - if (signals_.template isAttached()) + else if (!VFlag_) { - signals_.template writeExternalVariable(pref_set_); + reactive.qref = vmeas0; + reactive.qv = reactive.qref / vmeas_safe0; } - - y[PMEAS] = pe0; - y[SDIP] = Math::inside(y[VT], Vdip_, Vup_); - y[VERR] = Math::deadband2(Vref0_ - y[VMEAS], dbd1_, dbd2_); - y[IQV] = Math::clamp(kqv_ * y[VERR], Iql1_, Iqh1_); - y[QREF] = PfFlag_ * y[PMEAS] * std::tan(pfaref_set_) + pf_off_ * qext0; - y[EQ] = Math::clamp(y[QREF], Qmin_, Qmax_) - qgen0; - y[QV] = y[QREF] / y[VMEASSAFE]; - y[PORD] = pref0; - y[FPORD] = ZERO; - y[RPORD] = ZERO; - - auto awinit = [](const ScalarT target, const ScalarT rate, const ScalarT lower, const ScalarT upper) -> ScalarT + else { - if (std::abs(static_cast(rate)) <= INIT_TOL) + if (!solveLimiterInput(qgen0, Qmin_, Qmax_, reactive.qref)) { - return target; + Log::error() << "Reecb: initial reactive power is outside Qmin/Qmax\n"; + return 1; } - return rate > ZERO ? upper + static_cast(SAT_MARGIN) : lower - static_cast(SAT_MARGIN); - }; - const ScalarT vpiq_arg = awinit(VFlag_ * y[VMEAS] + v_off_ * y[QREF], Kqi_ * y[EQ], static_cast(Vmin_), static_cast(Vmax_)); - y[VPIQ] = Math::clamp(vpiq_arg, Vmin_, Vmax_); - y[EPIV] = VFlag_ * y[VPIQ] + v_off_ * y[QREF] - y[VMEAS]; - y[XPIQ] = vpiq_arg - Kqp_ * y[EQ]; + reactive.qv = reactive.qref / vmeas_safe0; + } - const ScalarT iqbase_target = qgen0 / y[VMEASSAFE]; - const ScalarT ip_star = y[PORD] / y[VMEASSAFE]; + reactive.eq = Math::clamp(reactive.qref, Qmin_, Qmax_) - qgen0; - auto initializeReactiveBase = [&]() + ScalarT vpiq_input0{}; + if (QFlag_ && VFlag_) { - const ScalarT piv_arg = awinit(iqbase_target, Kvi_ * y[EPIV], -y[IQMAX], y[IQMAX]); - y[IQBASE] = Math::clamp(piv_arg, -y[IQMAX], y[IQMAX]); - y[XPIV] = piv_arg - Kvp_ * y[EPIV]; - }; - - auto initializeReactiveCommand = [&]() + if (!solveLimiterInput(vmeas0, Vmin_, Vmax_, vpiq_input0)) + { + Log::error() << "Reecb: initial voltage is outside Vmin/Vmax\n"; + return 1; + } + } + else { - y[IQRAW] = QFlag_ * y[IQBASE] + q_off_ * y[QV] + (ONE - y[SDIP]) * y[IQV]; - y[IQCMD] = toSystemBase(Math::clamp(y[IQRAW], -y[IQMAX], y[IQMAX])); - }; + const ScalarT vpiq_nominal0 = v_on_ * vmeas0 + v_off_ * reactive.qref; + vpiq_input0 = steadyAntiWindupInput(vpiq_nominal0, Kqi_ * reactive.eq, Vmin_, Vmax_); + } - if (Pqflag_ == ZERO) - { - y[IQCIRC] = Imax_; - y[IQMAX] = Imax_; - initializeReactiveBase(); - initializeReactiveCommand(); + reactive.vpiq = Math::clamp(vpiq_input0, Vmin_, Vmax_); + reactive.epiv = v_on_ * reactive.vpiq + v_off_ * reactive.qref - vmeas0; + reactive.xpiq = vpiq_input0 - Kqp_ * reactive.eq; - const ScalarT iqcmd = toComponentBase(y[IQCMD]); - const ScalarT ip_radicand = Imax_ * Imax_ - iqcmd * iqcmd; - if (static_cast(ip_radicand) < ZERO) + ScalarT iqbase_input0{}; + if (QFlag_) + { + if (!solveLimiterInput(iq_control0, -iqmax0, iqmax0, iqbase_input0)) { - Log::error() << "Reecb: initial active-current circle radicand is negative\n"; + Log::error() << "Reecb: initial reactive-current command is outside the voltage-controller current limit\n"; return 1; } - y[IPCIRC] = std::sqrt(ip_radicand); - y[IPMAX] = y[IPCIRC]; - y[IPCMD] = toSystemBase(Math::clamp(ip_star, ZERO, y[IPMAX])); } else { - y[IPCIRC] = Imax_; - y[IPMAX] = Imax_; - y[IPCMD] = toSystemBase(Math::clamp(ip_star, ZERO, y[IPMAX])); + iqbase_input0 = steadyAntiWindupInput(static_cast(ZERO), Kvi_ * reactive.epiv, -iqmax0, iqmax0); + } + + reactive.iqbase = Math::clamp(iqbase_input0, -iqmax0, iqmax0); + reactive.xpiv = iqbase_input0 - Kvp_ * reactive.epiv; - const ScalarT ipcmd = toComponentBase(y[IPCMD]); - const ScalarT iq_radicand = Imax_ * Imax_ - ipcmd * ipcmd; - if (static_cast(iq_radicand) < ZERO) + ScalarT pfaref0 = static_cast(ZERO); + if (PfFlag_) + { + if (std::abs(pmeas0_value) <= INITIALIZATION_TOLERANCE) { - Log::error() << "Reecb: initial reactive-current circle radicand is negative\n"; - return 1; + if (std::abs(static_cast(reactive.qref)) > INITIALIZATION_TOLERANCE) + { + Log::error() << "Reecb: power-factor control cannot represent nonzero Qref at zero active power\n"; + return 1; + } + } + else + { + pfaref0 = static_cast(std::atan(static_cast(reactive.qref / pmeas0))); } - y[IQCIRC] = std::sqrt(iq_radicand); - y[IQMAX] = y[IQCIRC]; - initializeReactiveBase(); - initializeReactiveCommand(); } - for (IdxT i = 0; i < yp_.getSize(); ++i) + const ScalarT pe0_system = toSystemBase(pmeas0); + const ScalarT qgen0_system = toSystemBase(qgen0); + const ScalarT qext0_system = toSystemBase(reactive.qref); + const ScalarT pref0_system = toSystemBase(pref0); + + y[I::VMEAS] = vmeas0; + y[I::PMEAS] = pmeas0; + y[I::XPIQ] = reactive.xpiq; + y[I::XPIV] = reactive.xpiv; + y[I::QV] = reactive.qv; + y[I::PORD] = pord0; + y[I::VT] = vt0; + y[I::VMEASSAFE] = vmeas_safe0; + y[I::SDIP] = sdip0; + y[I::VERR] = verr0; + y[I::IQV] = iqv0; + y[I::QREF] = reactive.qref; + y[I::EQ] = reactive.eq; + y[I::VPIQ] = reactive.vpiq; + y[I::EPIV] = reactive.epiv; + y[I::FPORD] = fpord0; + y[I::RPORD] = rpord0; + y[I::IQCIRC] = iqcirc0; + y[I::IPCIRC] = ipcirc0; + y[I::IQMAX] = iqmax0; + y[I::IPMAX] = ipmax0; + y[I::IQBASE] = reactive.iqbase; + y[I::IQRAW] = iqraw0; + + if (!Vref0_given_) { - yp[i] = ZERO; + Vref0_ = vref0; } - y_.setDataUpdated(); - yp_.setDataUpdated(); + pe_set_ = pe0_system; + qgen_set_ = qgen0_system; + qext_set_ = qext0_system; + pfaref_set_ = pfaref0; + pref_set_ = pref0_system; + + if (signals_.template isAttached()) + { + signals_.template writeExternalVariable(pe_set_); + } + if (signals_.template isAttached()) + { + signals_.template writeExternalVariable(qgen_set_); + } + if (signals_.template isAttached()) + { + signals_.template writeExternalVariable(qext_set_); + } + if (signals_.template isAttached()) + { + signals_.template writeExternalVariable(pfaref_set_); + } + if (signals_.template isAttached()) + { + signals_.template writeExternalVariable(pref_set_); + } + y_.setDataUpdated(); + yp_.setToConst(static_cast(ZERO)); return 0; } + /** + * @brief Identify the differential variables + * + * The two measurement filters, the two PI states, the reactive-current + * lag, and the active-power order carry derivatives; every other + * internal variable is algebraic. + * + * @return int 0 on success. + */ template int Reecb::tagDifferentiable() { + using I = ReecbIdx; + std::fill(tag_.begin(), tag_.end(), false); - tag_[static_cast(ReecbInternalVariables::VMEAS)] = true; - tag_[static_cast(ReecbInternalVariables::PMEAS)] = true; - tag_[static_cast(ReecbInternalVariables::XPIQ)] = true; - tag_[static_cast(ReecbInternalVariables::XPIV)] = true; - tag_[static_cast(ReecbInternalVariables::QV)] = true; - tag_[static_cast(ReecbInternalVariables::PORD)] = true; + tag_[I::VMEAS] = true; + tag_[I::PMEAS] = true; + tag_[I::XPIQ] = true; + tag_[I::XPIV] = true; + tag_[I::QV] = true; + tag_[I::PORD] = true; return 0; } + /** + * @brief Compute the absolute tolerance for each variable in the model + * + * All REECB variables are per-unit voltages, powers, or currents of the + * same order, so they share the relative tolerance as their absolute + * floor. + * + * @param[in] rel_tol Solver relative tolerance. + * @return int 0 on success. + */ template int Reecb::setAbsoluteTolerance(RealT rel_tol) { @@ -539,6 +891,22 @@ namespace GridKit return 0; } + /** + * @brief Internal residual + * + * Evaluates the six controller states and the nineteen algebraic rows + * documented in the model README. The body is kept free of branches and + * loops so that sparse automatic differentiation resolves a fixed + * structure; the four mode selections enter as the multiplicative masks + * set by setDerivedParameters(). + * + * @param[in] y Internal variables. + * @param[in] yp Internal variable derivatives. + * @param[in] wb Terminal-bus voltage components. + * @param[in] ws External signal values on system base. + * @param[out] f Internal residuals. + * @return int 0 on success. + */ template __attribute__((always_inline)) inline int Reecb::evaluateInternalResidual( @@ -548,118 +916,128 @@ namespace GridKit const ScalarT* ws, ScalarT* f) { - const auto VMEAS = static_cast(ReecbInternalVariables::VMEAS); - const auto PMEAS = static_cast(ReecbInternalVariables::PMEAS); - const auto XPIQ = static_cast(ReecbInternalVariables::XPIQ); - const auto XPIV = static_cast(ReecbInternalVariables::XPIV); - const auto QV = static_cast(ReecbInternalVariables::QV); - const auto PORD = static_cast(ReecbInternalVariables::PORD); - const auto VT = static_cast(ReecbInternalVariables::VT); - const auto VMEASSAFE = static_cast(ReecbInternalVariables::VMEASSAFE); - const auto SDIP = static_cast(ReecbInternalVariables::SDIP); - const auto VERR = static_cast(ReecbInternalVariables::VERR); - const auto IQV = static_cast(ReecbInternalVariables::IQV); - const auto QREF = static_cast(ReecbInternalVariables::QREF); - const auto EQ = static_cast(ReecbInternalVariables::EQ); - const auto VPIQ = static_cast(ReecbInternalVariables::VPIQ); - const auto EPIV = static_cast(ReecbInternalVariables::EPIV); - const auto FPORD = static_cast(ReecbInternalVariables::FPORD); - const auto RPORD = static_cast(ReecbInternalVariables::RPORD); - const auto IQCIRC = static_cast(ReecbInternalVariables::IQCIRC); - const auto IPCIRC = static_cast(ReecbInternalVariables::IPCIRC); - const auto IQMAX = static_cast(ReecbInternalVariables::IQMAX); - const auto IPMAX = static_cast(ReecbInternalVariables::IPMAX); - const auto IQBASE = static_cast(ReecbInternalVariables::IQBASE); - const auto IQRAW = static_cast(ReecbInternalVariables::IQRAW); - const auto IQCMD = static_cast(ReecbInternalVariables::IQCMD); - const auto IPCMD = static_cast(ReecbInternalVariables::IPCMD); - - const auto PE = static_cast(ReecbExternalVariables::PE); - const auto QGEN = static_cast(ReecbExternalVariables::QGEN); - const auto QEXT = static_cast(ReecbExternalVariables::QEXT); - const auto PFAREF = static_cast(ReecbExternalVariables::PFAREF); - const auto PREF = static_cast(ReecbExternalVariables::PREF); + using I = ReecbIdx; + using E = ReecbExt; + + const ScalarT vmeas = y[I::VMEAS]; + const ScalarT pmeas = y[I::PMEAS]; + const ScalarT xpiq = y[I::XPIQ]; + const ScalarT xpiv = y[I::XPIV]; + const ScalarT qv = y[I::QV]; + const ScalarT pord = y[I::PORD]; + const ScalarT vt = y[I::VT]; + const ScalarT vmeas_safe = y[I::VMEASSAFE]; + const ScalarT sdip = y[I::SDIP]; + const ScalarT verr = y[I::VERR]; + const ScalarT iqv = y[I::IQV]; + const ScalarT qref = y[I::QREF]; + const ScalarT eq = y[I::EQ]; + const ScalarT vpiq = y[I::VPIQ]; + const ScalarT epiv = y[I::EPIV]; + const ScalarT fpord = y[I::FPORD]; + const ScalarT rpord = y[I::RPORD]; + const ScalarT iqcirc = y[I::IQCIRC]; + const ScalarT ipcirc = y[I::IPCIRC]; + const ScalarT iqmax = y[I::IQMAX]; + const ScalarT ipmax = y[I::IPMAX]; + const ScalarT iqbase = y[I::IQBASE]; + const ScalarT iqraw = y[I::IQRAW]; + const ScalarT iqcmd_system = y[I::IQCMD]; + const ScalarT ipcmd_system = y[I::IPCMD]; + + const ScalarT vmeas_dot = yp[I::VMEAS]; + const ScalarT pmeas_dot = yp[I::PMEAS]; + const ScalarT xpiq_dot = yp[I::XPIQ]; + const ScalarT xpiv_dot = yp[I::XPIV]; + const ScalarT qv_dot = yp[I::QV]; + const ScalarT pord_dot = yp[I::PORD]; const ScalarT vr = wb[0]; const ScalarT vi = wb[1]; - const ScalarT pe = toComponentBase(ws[PE]); - const ScalarT qgen = toComponentBase(ws[QGEN]); - const ScalarT qext = toComponentBase(ws[QEXT]); - const ScalarT pfaref = ws[PFAREF]; - const ScalarT pref = toComponentBase(ws[PREF]); - const ScalarT iqcmd = toComponentBase(y[IQCMD]); - const ScalarT ipcmd = toComponentBase(y[IPCMD]); - - f[VMEAS] = -yp[VMEAS] + (y[VT] - y[VMEAS]) / Trv_eff_; - f[PMEAS] = -yp[PMEAS] + (pe - y[PMEAS]) / Tp_eff_; - f[XPIQ] = -yp[XPIQ] + y[SDIP] * Math::antiwindup(Kqp_ * y[EQ] + y[XPIQ], Kqi_ * y[EQ], Vmin_, Vmax_); - f[XPIV] = -yp[XPIV] + y[SDIP] * Math::antiwindup(Kvp_ * y[EPIV] + y[XPIV], Kvi_ * y[EPIV], -y[IQMAX], y[IQMAX]); - f[QV] = -yp[QV] + y[SDIP] * (y[QREF] / y[VMEASSAFE] - y[QV]) / Tiq_; - f[PORD] = -yp[PORD] + y[SDIP] * Math::antiwindup(y[PORD], y[RPORD], Pmin_, Pmax_); - f[VT] = -y[VT] * y[VT] + vr * vr + vi * vi; - f[VMEASSAFE] = -y[VMEASSAFE] + Math::max(y[VMEAS], VMEAS_MINIMUM); - f[SDIP] = -y[SDIP] + Math::inside(y[VT], Vdip_, Vup_); - f[VERR] = -y[VERR] + Math::deadband2(Vref0_ - y[VMEAS], dbd1_, dbd2_); - f[IQV] = -y[IQV] + Math::clamp(kqv_ * y[VERR], Iql1_, Iqh1_); - f[QREF] = -y[QREF] + PfFlag_ * y[PMEAS] * std::tan(pfaref) + pf_off_ * qext; - f[EQ] = -y[EQ] + Math::clamp(y[QREF], Qmin_, Qmax_) - qgen; - f[VPIQ] = -y[VPIQ] + Math::clamp(Kqp_ * y[EQ] + y[XPIQ], Vmin_, Vmax_); - f[EPIV] = -y[EPIV] + VFlag_ * y[VPIQ] + v_off_ * y[QREF] - y[VMEAS]; - f[FPORD] = -y[FPORD] + (pref - y[PORD]) / Tpord_; - f[RPORD] = -y[RPORD] + Math::clamp(y[FPORD], dPmin_, dPmax_); - f[IQCIRC] = -y[IQCIRC] * y[IQCIRC] + Imax_ * Imax_ - Pqflag_ * ipcmd * ipcmd; - f[IPCIRC] = -y[IPCIRC] * y[IPCIRC] + Imax_ * Imax_ - (ONE - Pqflag_) * iqcmd * iqcmd; - f[IQMAX] = -y[IQMAX] + (ONE - Pqflag_) * Imax_ + Pqflag_ * y[IQCIRC]; - f[IPMAX] = -y[IPMAX] + Pqflag_ * Imax_ + (ONE - Pqflag_) * y[IPCIRC]; - f[IQBASE] = -y[IQBASE] + Math::clamp(Kvp_ * y[EPIV] + y[XPIV], -y[IQMAX], y[IQMAX]); - f[IQRAW] = -y[IQRAW] + QFlag_ * y[IQBASE] + q_off_ * y[QV] + (ONE - y[SDIP]) * y[IQV]; - f[IQCMD] = -y[IQCMD] + toSystemBase(Math::clamp(y[IQRAW], -y[IQMAX], y[IQMAX])); - f[IPCMD] = -y[IPCMD] + toSystemBase(Math::clamp(y[PORD] / y[VMEASSAFE], ZERO, y[IPMAX])); + const ScalarT pe = toComponentBase(ws[E::PE]); + const ScalarT qgen = toComponentBase(ws[E::QGEN]); + const ScalarT qext = toComponentBase(ws[E::QEXT]); + const ScalarT pfaref = ws[E::PFAREF]; + const ScalarT pref = toComponentBase(ws[E::PREF]); + const ScalarT iqcmd = toComponentBase(iqcmd_system); + const ScalarT ipcmd = toComponentBase(ipcmd_system); + + f[I::VMEAS] = -vmeas_dot + (vt - vmeas) / Trv_; + f[I::PMEAS] = -pmeas_dot + (pe - pmeas) / Tp_; + f[I::XPIQ] = -xpiq_dot + sdip * Math::antiwindup(Kqp_ * eq + xpiq, Kqi_ * eq, Vmin_, Vmax_); + f[I::XPIV] = -xpiv_dot + sdip * Math::antiwindup(Kvp_ * epiv + xpiv, Kvi_ * epiv, -iqmax, iqmax); + f[I::QV] = -qv_dot + sdip * (qref / vmeas_safe - qv) / Tiq_; + f[I::PORD] = -pord_dot + sdip * Math::antiwindup(pord, rpord, Pmin_, Pmax_); + f[I::VT] = -vt * vt + vr * vr + vi * vi; + f[I::VMEASSAFE] = -vmeas_safe + Math::max(vmeas, VMEAS_MINIMUM); + f[I::SDIP] = -sdip + Math::inside(vt, Vdip_, Vup_); + f[I::VERR] = -verr + Math::deadband2(Vref0_ - vmeas, dbd1_, dbd2_); + f[I::IQV] = -iqv + Math::clamp(kqv_ * verr, Iql1_, Iqh1_); + f[I::QREF] = -qref + pf_on_ * pmeas * std::tan(pfaref) + pf_off_ * qext; + f[I::EQ] = -eq + Math::clamp(qref, Qmin_, Qmax_) - qgen; + f[I::VPIQ] = -vpiq + Math::clamp(Kqp_ * eq + xpiq, Vmin_, Vmax_); + f[I::EPIV] = -epiv + v_on_ * vpiq + v_off_ * qref - vmeas; + f[I::FPORD] = -fpord + (pref - pord) / Tpord_; + f[I::RPORD] = -rpord + Math::clamp(fpord, dPmin_, dPmax_); + f[I::IQCIRC] = -iqcirc * iqcirc + Imax_ * Imax_ - p_priority_ * ipcmd * ipcmd; + f[I::IPCIRC] = -ipcirc * ipcirc + Imax_ * Imax_ - q_priority_ * iqcmd * iqcmd; + f[I::IQMAX] = -iqmax + q_priority_ * Imax_ + p_priority_ * iqcirc; + f[I::IPMAX] = -ipmax + p_priority_ * Imax_ + q_priority_ * ipcirc; + f[I::IQBASE] = -iqbase + Math::clamp(Kvp_ * epiv + xpiv, -iqmax, iqmax); + f[I::IQRAW] = -iqraw + q_on_ * iqbase + q_off_ * qv + (ONE - sdip) * iqv; + f[I::IQCMD] = -iqcmd_system + toSystemBase(Math::clamp(iqraw, -iqmax, iqmax)); + f[I::IPCMD] = -ipcmd_system + toSystemBase(Math::clamp(pord / vmeas_safe, ZERO, ipmax)); return 0; } + /** + * @brief Residuals of system equations + * + * Refreshes the bus and signal interface buffers and evaluates the + * internal residual. REECB injects no current, so there is no bus + * residual. An unattached input port falls back to the value latched by + * initialize(). + * + * @return int 0 on success. + */ template int Reecb::evaluateResidual() { - const auto PE = static_cast(ReecbExternalVariables::PE); - const auto QGEN = static_cast(ReecbExternalVariables::QGEN); - const auto QEXT = static_cast(ReecbExternalVariables::QEXT); - const auto PFAREF = static_cast(ReecbExternalVariables::PFAREF); - const auto PREF = static_cast(ReecbExternalVariables::PREF); - - ws_[PE] = pe_set_; - ws_[QGEN] = qgen_set_; - ws_[QEXT] = qext_set_; - ws_[PFAREF] = pfaref_set_; - ws_[PREF] = pref_set_; + using E = ReecbExt; + + ws_[E::PE] = pe_set_; + ws_[E::QGEN] = qgen_set_; + ws_[E::QEXT] = qext_set_; + ws_[E::PFAREF] = pfaref_set_; + ws_[E::PREF] = pref_set_; std::fill(ws_indices_.begin(), ws_indices_.end(), INVALID_INDEX); if (signals_.template isAttached()) { - ws_[PE] = signals_.template readExternalVariable(); - ws_indices_[PE] = signals_.template readExternalVariableIndex(); + ws_[E::PE] = signals_.template readExternalVariable(); + ws_indices_[E::PE] = signals_.template readExternalVariableIndex(); } if (signals_.template isAttached()) { - ws_[QGEN] = signals_.template readExternalVariable(); - ws_indices_[QGEN] = signals_.template readExternalVariableIndex(); + ws_[E::QGEN] = signals_.template readExternalVariable(); + ws_indices_[E::QGEN] = signals_.template readExternalVariableIndex(); } if (signals_.template isAttached()) { - ws_[QEXT] = signals_.template readExternalVariable(); - ws_indices_[QEXT] = signals_.template readExternalVariableIndex(); + ws_[E::QEXT] = signals_.template readExternalVariable(); + ws_indices_[E::QEXT] = signals_.template readExternalVariableIndex(); } if (signals_.template isAttached()) { - ws_[PFAREF] = signals_.template readExternalVariable(); - ws_indices_[PFAREF] = signals_.template readExternalVariableIndex(); + ws_[E::PFAREF] = signals_.template readExternalVariable(); + ws_indices_[E::PFAREF] = signals_.template readExternalVariableIndex(); } if (signals_.template isAttached()) { - ws_[PREF] = signals_.template readExternalVariable(); - ws_indices_[PREF] = signals_.template readExternalVariableIndex(); + ws_[E::PREF] = signals_.template readExternalVariable(); + ws_indices_[E::PREF] = signals_.template readExternalVariableIndex(); } wb_[0] = Vr(); diff --git a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp index 4bba2509f..7c940f8e9 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp @@ -163,63 +163,6 @@ namespace GridKit addComponent(regca); } - // Add REECB electrical controllers - for (const auto& reecbdata : data.reecb) - { - IdxT bus_index = 0; - if (reecbdata.buses.contains(ReecbBuses::bus)) - { - bus_index = reecbdata.buses.at(ReecbBuses::bus); - } - - auto* reecb = new Reecb(getBus(bus_index), reecbdata); - - if (reecbdata.signal_inputs.contains(ReecbSignalInputs::pe)) - { - const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::pe); - constexpr auto PE = ReecbExternalVariables::PE; - reecb->getSignals().template attachSignalNode(getSignal(signal)); - } - if (reecbdata.signal_inputs.contains(ReecbSignalInputs::qgen)) - { - const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::qgen); - constexpr auto QGEN = ReecbExternalVariables::QGEN; - reecb->getSignals().template attachSignalNode(getSignal(signal)); - } - if (reecbdata.signal_inputs.contains(ReecbSignalInputs::qext)) - { - const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::qext); - constexpr auto QEXT = ReecbExternalVariables::QEXT; - reecb->getSignals().template attachSignalNode(getSignal(signal)); - } - if (reecbdata.signal_inputs.contains(ReecbSignalInputs::pfaref)) - { - const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::pfaref); - constexpr auto PFAREF = ReecbExternalVariables::PFAREF; - reecb->getSignals().template attachSignalNode(getSignal(signal)); - } - if (reecbdata.signal_inputs.contains(ReecbSignalInputs::pref)) - { - const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::pref); - constexpr auto PREF = ReecbExternalVariables::PREF; - reecb->getSignals().template attachSignalNode(getSignal(signal)); - } - if (reecbdata.signal_outputs.contains(ReecbSignalOutputs::iqcmd)) - { - const IdxT signal = reecbdata.signal_outputs.at(ReecbSignalOutputs::iqcmd); - constexpr auto IQCMD = ReecbInternalVariables::IQCMD; - reecb->getSignals().template assignSignalNode(getSignal(signal)); - } - if (reecbdata.signal_outputs.contains(ReecbSignalOutputs::ipcmd)) - { - const IdxT signal = reecbdata.signal_outputs.at(ReecbSignalOutputs::ipcmd); - constexpr auto IPCMD = ReecbInternalVariables::IPCMD; - reecb->getSignals().template assignSignalNode(getSignal(signal)); - } - - addComponent(reecb); - } - // Add branches for (const auto& branchdata : data.branch) { @@ -351,6 +294,69 @@ namespace GridKit addComponent(gen); } + // Add REECB electrical controllers + // + // Added after the machines and converters that drive them: a source + // publishes its resolved current commands into the assigned iqcmd/ipcmd + // nodes during its own initialize(), and REECB reads those seeds when it + // initializes. Components initialize in insertion order, so REECB must + // come after anything that seeds it. + for (const auto& reecbdata : data.reecb) + { + IdxT bus_index = 0; + if (reecbdata.buses.contains(ReecbBuses::bus)) + { + bus_index = reecbdata.buses.at(ReecbBuses::bus); + } + + auto* reecb = new Reecb(getBus(bus_index), reecbdata); + + if (reecbdata.signal_inputs.contains(ReecbSignalInputs::pe)) + { + const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::pe); + constexpr auto PE = ReecbExternalVariables::PE; + reecb->getSignals().template attachSignalNode(getSignal(signal)); + } + if (reecbdata.signal_inputs.contains(ReecbSignalInputs::qgen)) + { + const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::qgen); + constexpr auto QGEN = ReecbExternalVariables::QGEN; + reecb->getSignals().template attachSignalNode(getSignal(signal)); + } + if (reecbdata.signal_inputs.contains(ReecbSignalInputs::qext)) + { + const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::qext); + constexpr auto QEXT = ReecbExternalVariables::QEXT; + reecb->getSignals().template attachSignalNode(getSignal(signal)); + } + if (reecbdata.signal_inputs.contains(ReecbSignalInputs::pfaref)) + { + const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::pfaref); + constexpr auto PFAREF = ReecbExternalVariables::PFAREF; + reecb->getSignals().template attachSignalNode(getSignal(signal)); + } + if (reecbdata.signal_inputs.contains(ReecbSignalInputs::pref)) + { + const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::pref); + constexpr auto PREF = ReecbExternalVariables::PREF; + reecb->getSignals().template attachSignalNode(getSignal(signal)); + } + if (reecbdata.signal_outputs.contains(ReecbSignalOutputs::iqcmd)) + { + const IdxT signal = reecbdata.signal_outputs.at(ReecbSignalOutputs::iqcmd); + constexpr auto IQCMD = ReecbInternalVariables::IQCMD; + reecb->getSignals().template assignSignalNode(getSignal(signal)); + } + if (reecbdata.signal_outputs.contains(ReecbSignalOutputs::ipcmd)) + { + const IdxT signal = reecbdata.signal_outputs.at(ReecbSignalOutputs::ipcmd); + constexpr auto IPCMD = ReecbInternalVariables::IPCMD; + reecb->getSignals().template assignSignalNode(getSignal(signal)); + } + + addComponent(reecb); + } + // Add Tgov1 governors for (const auto& govdata : data.gov) { diff --git a/tests/UnitTests/Math/SmoothnessIndicatorTests.hpp b/tests/UnitTests/Math/SmoothnessIndicatorTests.hpp index caaae8ebc..70f1b8371 100644 --- a/tests/UnitTests/Math/SmoothnessIndicatorTests.hpp +++ b/tests/UnitTests/Math/SmoothnessIndicatorTests.hpp @@ -345,6 +345,53 @@ namespace GridKit return success.report(__func__); } + + TestOutcome dynamicAntiWindupBounds() + { + TestStatus success = true; + + using Variable = GridKit::DependencyTracking::Variable; + + const Variable state{0.0, 0}; + const Variable rate{0.03, 1}; + const Variable lower{-0.05, 2}; + const Variable upper{0.05, 3}; + + const auto gate = Math::indicator(state, rate, lower, upper); + const auto limited = Math::antiwindup(state, rate, lower, upper); + + static_assert(std::is_same::type, + Variable>::value, + "Dynamic-bound indicator should retain dependency tracking."); + static_assert(std::is_same::type, + Variable>::value, + "Dynamic-bound antiwindup should retain dependency tracking."); + + success *= (gate.getValue() > kNearOne); + success *= within(limited.getValue(), rate.getValue(), kSmoothTolerance); + const auto& gate_dependencies = gate.getDependencies(); + const auto& limited_dependencies = limited.getDependencies(); + for (size_t variable = 0; variable < 4; ++variable) + { + success *= gate_dependencies.contains(variable); + success *= limited_dependencies.contains(variable); + } + for (const size_t bound : {size_t{2}, size_t{3}}) + { + const auto gate_bound = gate_dependencies.find(bound); + const auto limited_bound = limited_dependencies.find(bound); + if (gate_bound != gate_dependencies.end()) + { + success *= std::abs(gate_bound->second) > 0.0; + } + if (limited_bound != limited_dependencies.end()) + { + success *= std::abs(limited_bound->second) > 0.0; + } + } + + return success.report(__func__); + } }; } // namespace Testing diff --git a/tests/UnitTests/Math/runSmoothnessIndicatorTests.cpp b/tests/UnitTests/Math/runSmoothnessIndicatorTests.cpp index aecdc7ae8..3b6a14a12 100644 --- a/tests/UnitTests/Math/runSmoothnessIndicatorTests.cpp +++ b/tests/UnitTests/Math/runSmoothnessIndicatorTests.cpp @@ -16,6 +16,7 @@ int main() result += test.minMax(); result += test.antiWindupIndicator(); result += test.antiWindup(); + result += test.dynamicAntiWindupBounds(); return result.summary(); } diff --git a/tests/UnitTests/PhasorDynamics/CMakeLists.txt b/tests/UnitTests/PhasorDynamics/CMakeLists.txt index 85daaef87..266c14865 100644 --- a/tests/UnitTests/PhasorDynamics/CMakeLists.txt +++ b/tests/UnitTests/PhasorDynamics/CMakeLists.txt @@ -136,8 +136,10 @@ add_executable(test_phasor_converter_reecb runConverterReecbTests.cpp) target_link_libraries( test_phasor_converter_reecb GridKit::definitions - GridKit::phasor_dynamics_systemmodel - GridKit::phasor_dynamics_systemmodel_dependency_tracking + GridKit::phasor_dynamics_converter_reecb + GridKit::phasor_dynamics_converter_reecb_dependency_tracking + GridKit::phasor_dynamics_bus + GridKit::phasor_dynamics_bus_dependency_tracking GridKit::testing) add_executable(test_phasor_controller_repca runControllerRepcaTests.cpp) diff --git a/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp b/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp index 6526995d3..a01571b71 100644 --- a/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp @@ -1,23 +1,30 @@ #pragma once -#include +#include +#include +#include #include #include -#include +#include +#include +#include #include #include #include #include -#include -#include +#include #include #include +#include +#include namespace GridKit { namespace Testing { + using Log = ::GridKit::Utilities::Logger; + template class ConverterReecbTests { @@ -25,632 +32,1391 @@ namespace GridKit using ScalarT = scalar_type; using IdxT = index_type; using RealT = typename PhasorDynamics::Component::RealT; - using ReecbT = PhasorDynamics::Converter::Reecb; - using Var = PhasorDynamics::Converter::ReecbInternalVariables; - using Ext = PhasorDynamics::Converter::ReecbExternalVariables; - using Params = PhasorDynamics::Converter::ReecbParameters; - using Buses = PhasorDynamics::Converter::ReecbBuses; - using Outputs = PhasorDynamics::Converter::ReecbSignalOutputs; - static constexpr ScalarT kTol = static_cast(1.0e-8); + ConverterReecbTests() = default; + ~ConverterReecbTests() = default; + + // REECB initialization solves smooth-limiter inputs near their asymptotes. + // The resulting steady residuals are O(1e-10), so behavioral comparisons + // use a tolerance one order above the initialization guard. + static constexpr RealT kBehaviorTol = 1.0e-9; + + // Enzyme and dependency tracking traverse the same smooth expressions + // differently; their double-precision derivatives agree to O(1e-10). + static constexpr RealT kJacobianTol = 1.0e-9; + /// Construction and every verify() error class, including parameter + /// types, parameter relationships, bus ownership, and signal linkage. TestOutcome validation() { TestStatus success = true; PhasorDynamics::Bus bus(1.0, 0.0); - ReecbT reecb(&bus, makeData()); - success *= (reecb.size() == static_cast(Var::MAXIMUM)); - success *= (reecb.getMonitor() != nullptr); - success *= (reecb.verify() == 0); + PhasorDynamics::Converter::Reecb empty(&bus); + success *= (empty.size() == static_cast(I::MAXIMUM)); + success *= (empty.getMonitor() == nullptr); - auto minimal = makeMinimalData(); - minimal.parameters[Params::mva] = static_cast(100.0); - ReecbT minimal_model(&bus, minimal); - success *= (minimal_model.verify() == 0); + PhasorDynamics::Converter::Reecb configured(&bus, makeData()); + success *= (configured.size() == static_cast(I::MAXIMUM)); + success *= (configured.getMonitor() != nullptr); + success *= (configured.verify() == 0); - ReecbT missing_mva(&bus, makeMinimalData()); - success *= (missing_mva.verify() > 0); + noteExpectedLogs("Testing REECB defaults and invalid configurations. " + "Logged errors and time-constant warnings are expected."); - auto bad_band = makeData(); - bad_band.parameters[Params::Vdip] = static_cast(1.2); - bad_band.parameters[Params::Vup] = static_cast(1.2); - ReecbT bad_band_model(&bus, bad_band); - success *= (bad_band_model.verify() > 0); + auto minimal_data = makeMinimalData(); + minimal_data.parameters[Params::mva] = 100.0; + PhasorDynamics::Converter::Reecb minimal(&bus, minimal_data); + success *= (minimal.verify() == 0); + success *= defaultsMatchDocumentedValues(); - auto bad_imax = makeData(); - bad_imax.parameters[Params::Imax] = static_cast(-1.0); - ReecbT bad_imax_model(&bus, bad_imax); - success *= (bad_imax_model.verify() > 0); + success *= (empty.verify() > 0); - ScalarT pe_value{0.5}; - IdxT pe_index = 20; - PhasorDynamics::SignalNode pe_node; - pe_node.set(&pe_value, &pe_index); + PhasorDynamics::Converter::Reecb missing_mva(&bus, makeMinimalData()); + success *= (missing_mva.verify() > 0); - ReecbT half_connected(&bus, makeData()); - half_connected.getSignals().template attachSignalNode(&pe_node); - success *= (half_connected.verify() == 0); + success *= invalidParameterCase(bus, Params::mva, 0.0); + success *= invalidParameterCase(bus, Params::Trv, -0.1); + success *= invalidParameterCase(bus, Params::Tp, -0.1); + success *= invalidParameterCase(bus, Params::Tiq, -0.1); + success *= invalidParameterCase(bus, Params::Tpord, -0.1); + success *= invalidParameterCase(bus, Params::Vdip, 1.2); + success *= invalidParameterCase(bus, Params::dbd1, 0.1); + success *= invalidParameterCase(bus, Params::dbd2, -0.1); + success *= invalidParameterCase(bus, Params::Iql1, 2.0); + success *= invalidParameterCase(bus, Params::Qmin, 2.0); + success *= invalidParameterCase(bus, Params::Vmin, 2.0); + success *= invalidParameterCase(bus, Params::dPmin, 0.0); + success *= invalidParameterCase(bus, Params::dPmax, 0.0); + success *= invalidParameterCase(bus, Params::Pmin, 2.0); + success *= invalidParameterCase(bus, Params::Imax, -0.1); + + for (const Params flag : {Params::PfFlag, Params::VFlag, Params::QFlag, Params::Pqflag}) + { + auto bad_integer = makeData(); + bad_integer.parameters[flag] = static_cast(2); + PhasorDynamics::Converter::Reecb bad_integer_model(&bus, bad_integer); + success *= (bad_integer_model.verify() > 0); + + // Real-valued 0/1 is intentionally rejected: switches are JSON + // booleans or integer 0/1, matching REGCA's parameter contract. + auto bad_real = makeData(); + bad_real.parameters[flag] = static_cast(1.0); + PhasorDynamics::Converter::Reecb bad_real_model(&bus, bad_real); + success *= (bad_real_model.verify() > 0); + } - PhasorDynamics::SignalNode unlinked_pe_node; - ReecbT unlinked(&bus, makeData()); - unlinked.getSignals().template attachSignalNode(&unlinked_pe_node); - success *= (unlinked.verify() > 0); + auto integer_switches = makeData(); + integer_switches.parameters[Params::PfFlag] = static_cast(0); + integer_switches.parameters[Params::VFlag] = static_cast(1); + integer_switches.parameters[Params::QFlag] = static_cast(0); + integer_switches.parameters[Params::Pqflag] = static_cast(1); + PhasorDynamics::Converter::Reecb integer_switch_model( + &bus, + integer_switches); + success *= (integer_switch_model.verify() == 0); + + auto bad_numeric_type = makeData(); + bad_numeric_type.parameters[Params::mva] = true; + PhasorDynamics::Converter::Reecb bad_numeric_model( + &bus, + bad_numeric_type); + success *= (bad_numeric_model.verify() > 0); + + PhasorDynamics::Converter::Reecb busless(nullptr, makeData()); + success *= (busless.verify() > 0); + + success *= unlinkedSignalRejected(bus); + success *= unlinkedSignalRejected(bus); + success *= unlinkedSignalRejected(bus); + success *= unlinkedSignalRejected(bus); + success *= unlinkedSignalRejected(bus); + + // All four zero time constants use the documented numerical floor and + // still admit a consistent steady-state initialization. + auto zero_time = makeData(); + zero_time.parameters[Params::Trv] = 0.0; + zero_time.parameters[Params::Tp] = 0.0; + zero_time.parameters[Params::Tiq] = 0.0; + zero_time.parameters[Params::Tpord] = 0.0; + + Fixture fixture(zero_time); + success *= fixture.initialize(0.2, 0.6); + success *= (fixture.evaluate() == 0); + success *= allResidualsZero(fixture.reecb); return success.report(__func__); } - TestOutcome signals() + /// A nonidentity power-base initialization with every port attached. + /// Assigned command nodes are seeded after allocate() and must remain + /// unchanged while REECB initializes and publishes its feedback signals. + TestOutcome initializationAndSignals() { TestStatus success = true; - PhasorDynamics::Bus bus(1.0, 0.0); - bus.allocate(); - bus.initialize(); - - ScalarT iqcmd_value{0.2}; - ScalarT ipcmd_value{0.6}; - IdxT iqcmd_index = 21; - IdxT ipcmd_index = 22; - - PhasorDynamics::SignalNode iqcmd_node; - PhasorDynamics::SignalNode ipcmd_node; - iqcmd_node.set(&iqcmd_value, &iqcmd_index); - ipcmd_node.set(&ipcmd_value, &ipcmd_index); - - ReecbT reecb(&bus, makeData()); - reecb.getSignals().template assignSignalNode(&iqcmd_node); - reecb.getSignals().template assignSignalNode(&ipcmd_node); - - success *= (reecb.allocate() == 0); - iqcmd_node.init(static_cast(0.2)); - ipcmd_node.init(static_cast(0.6)); - success *= (reecb.verify() == 0); - success *= (reecb.initialize() == 0); - success *= (reecb.tagDifferentiable() == 0); - success *= (reecb.evaluateResidual() == 0); - - success *= isEqual(reecb.y().getData()[index(Var::VMEAS)], static_cast(1.0), kTol); - success *= isEqual(reecb.y().getData()[index(Var::PMEAS)], static_cast(0.6), kTol); - success *= isEqual(reecb.y().getData()[index(Var::QREF)], static_cast(0.2), kTol); - success *= isEqual(reecb.y().getData()[index(Var::PORD)], static_cast(0.6), kTol); - success *= isEqual(iqcmd_node.read(), reecb.y().getData()[index(Var::IQCMD)], kTol); - success *= isEqual(ipcmd_node.read(), reecb.y().getData()[index(Var::IPCMD)], kTol); - success *= (reecb.tag()[index(Var::VMEAS)] == true); - success *= (reecb.tag()[index(Var::PMEAS)] == true); - success *= allZero(reecb); + auto data = makeData(); + data.parameters[Params::mva] = 50.0; + + Fixture fixture(data, 0.8, 0.6); + fixture.attachAllInputs(99.0); + success *= fixture.initialize(0.05, 0.25); + success *= (fixture.reecb.tagDifferentiable() == 0); + success *= (fixture.evaluate() == 0); + + const auto* y = fixture.reecb.y().getData(); + success *= scalarMatches(y[I::VT], 1.0, "VT"); + success *= scalarMatches(y[I::VMEAS], 1.0, "VMEAS"); + success *= scalarMatches(y[I::PMEAS], 0.5, "PMEAS on component base"); + success *= scalarMatches(y[I::QREF], 0.1, "QREF on component base"); + success *= scalarMatches(y[I::PORD], 0.5, "PORD on component base"); + success *= scalarMatches(fixture.iqcmd(), 0.05, "seeded iqcmd"); + success *= scalarMatches(fixture.ipcmd(), 0.25, "seeded ipcmd"); + + success *= scalarMatches(fixture.input(E::PE), 0.25, "published pe"); + success *= scalarMatches(fixture.input(E::QGEN), 0.05, "published qgen"); + success *= scalarMatches(fixture.input(E::QEXT), 0.05, "published qext"); + success *= scalarMatches(fixture.input(E::PFAREF), 0.0, "inactive pfaref fallback"); + success *= scalarMatches(fixture.input(E::PREF), 0.25, "published pref"); + + RealT time = 0.0; + Model::VariableMonitorController monitor(time); + monitor.addMonitor(fixture.reecb.getMonitor()); + std::stringstream monitor_output; + monitor.addSink({Model::VariableMonitorFormat::CSV}, monitor_output); + monitor.start(); + monitor.print(); + monitor.stop(); + + std::string monitor_header; + std::string monitor_values; + std::getline(monitor_output, monitor_header); + std::getline(monitor_output, monitor_values); + success *= (monitor_header == "t,Reecb_reecb_test_iqcmd,Reecb_reecb_test_ipcmd," + "Reecb_reecb_test_vmeas,Reecb_reecb_test_pmeas"); + const auto monitored = Tokenizer(monitor_values, ',')(); + if (monitored.size() == 5) + { + success *= scalarMatches(monitored[1], 0.05, "monitored iqcmd"); + success *= scalarMatches(monitored[2], 0.25, "monitored ipcmd"); + success *= scalarMatches(monitored[3], 1.0, "monitored vmeas"); + success *= scalarMatches(monitored[4], 0.5, "monitored pmeas"); + } + else + { + std::cout << "REECB monitor emitted " << monitored.size() + << " values instead of 5\n"; + success = false; + } + + for (size_t i = 0; i < static_cast(fixture.reecb.size()); ++i) + { + const bool expected = i <= I::PORD; + if (fixture.reecb.tag()[i] != expected) + { + std::cout << "REECB differentiability tag " << i << " mismatch\n"; + success = false; + } + } + success *= allResidualsZero(fixture.reecb); + + // Every flag combination must preserve the seeded commands and produce + // a zero-derivative, zero-residual state. The nonzero PI gains ensure + // that active and parked anti-windup paths are both initialized. + for (const bool pf_flag : {false, true}) + { + for (const bool v_flag : {false, true}) + { + for (const bool q_flag : {false, true}) + { + for (const bool p_priority : {false, true}) + { + auto scenario_data = makeDynamicData(); + scenario_data.parameters[Params::PfFlag] = pf_flag; + scenario_data.parameters[Params::VFlag] = v_flag; + scenario_data.parameters[Params::QFlag] = q_flag; + scenario_data.parameters[Params::Pqflag] = p_priority; + + Fixture scenario(scenario_data); + scenario.attachAllInputs(99.0); + if (!scenario.initialize(0.05, 0.2)) + { + std::cout << "REECB initialization scenario failed: PfFlag=" << pf_flag + << ", VFlag=" << v_flag + << ", QFlag=" << q_flag + << ", Pqflag=" << p_priority << '\n'; + success = false; + continue; + } + + success *= (scenario.evaluate() == 0); + success *= allResidualsZero(scenario.reecb); + success *= scalarMatches(scenario.iqcmd(), 0.05, "scenario iqcmd preservation"); + success *= scalarMatches(scenario.ipcmd(), 0.2, "scenario ipcmd preservation"); + success *= scalarMatches(scenario.input(E::PE), 0.2, "scenario pe publication"); + success *= scalarMatches(scenario.input(E::QGEN), 0.05, "scenario qgen publication"); + } + } + } + } + + // Both voltage-band exits exercise the initialization compensation + // that removes Iq injection from the selected reactive-control path. + for (const RealT terminal_voltage : {static_cast(0.6), static_cast(1.3)}) + { + auto voltage_data = makeDynamicData(); + voltage_data.parameters[Params::QFlag] = false; + voltage_data.parameters[Params::VFlag] = true; + voltage_data.parameters[Params::Vref0] = 1.0; + + Fixture voltage_scenario(voltage_data, terminal_voltage); + voltage_scenario.attachAllInputs(); + success *= voltage_scenario.initialize(0.05, 0.2); + success *= (voltage_scenario.evaluate() == 0); + success *= allResidualsZero(voltage_scenario.reecb); + success *= scalarMatches(voltage_scenario.iqcmd(), 0.05, "voltage-band iqcmd preservation"); + success *= scalarMatches(voltage_scenario.ipcmd(), 0.2, "voltage-band ipcmd preservation"); + } return success.report(__func__); } - TestOutcome publishRefs() + /// Current, power, and selected-controller initialization domains. + /// Every rejection is atomic; zero-current and exact limiter boundaries + /// remain admissible. + TestOutcome initializationDomain() { TestStatus success = true; - PhasorDynamics::Bus bus(0.8, 0.6); - bus.allocate(); - bus.initialize(); - - auto data = makeData(); - data.parameters[Params::Qmin] = static_cast(-2.0); - data.parameters[Params::Qmax] = static_cast(2.0); - data.parameters[Params::Pmax] = static_cast(2.0); - data.parameters[Params::Imax] = static_cast(2.0); - - ScalarT iqcmd_value{-0.2}; - ScalarT ipcmd_value{1.6}; - ScalarT qext_value{99.0}; - ScalarT pfaref_value{99.0}; - ScalarT pref_value{99.0}; - IdxT iqcmd_index = 30; - IdxT ipcmd_index = 31; - IdxT qext_index = 32; - IdxT pfref_index = 33; - IdxT pref_index = 34; - - PhasorDynamics::SignalNode iqcmd_node; - PhasorDynamics::SignalNode ipcmd_node; - PhasorDynamics::SignalNode qext_node; - PhasorDynamics::SignalNode pfaref_node; - PhasorDynamics::SignalNode pref_node; - iqcmd_node.set(&iqcmd_value, &iqcmd_index); - ipcmd_node.set(&ipcmd_value, &ipcmd_index); - qext_node.set(&qext_value, &qext_index); - pfaref_node.set(&pfaref_value, &pfref_index); - pref_node.set(&pref_value, &pref_index); - - ReecbT reecb(&bus, data); - reecb.getSignals().template assignSignalNode(&iqcmd_node); - reecb.getSignals().template assignSignalNode(&ipcmd_node); - reecb.getSignals().template attachSignalNode(&qext_node); - reecb.getSignals().template attachSignalNode(&pfaref_node); - reecb.getSignals().template attachSignalNode(&pref_node); - - success *= (reecb.allocate() == 0); - iqcmd_node.init(static_cast(-0.2)); - ipcmd_node.init(static_cast(1.6)); - success *= (reecb.verify() == 0); - success *= (reecb.initialize() == 0); - success *= (reecb.evaluateResidual() == 0); - - const ScalarT expected_pfaref = static_cast(std::atan(static_cast(-0.2 / 1.6))); - success *= isEqual(qext_node.read(), static_cast(-0.2), kTol); - success *= isEqual(pfaref_node.read(), expected_pfaref, kTol); - success *= isEqual(pref_node.read(), static_cast(1.6), kTol); - success *= isEqual(reecb.y().getData()[index(Var::QREF)], static_cast(-0.2), kTol); - success *= allZero(reecb); + noteExpectedLogs("Testing inadmissible REECB current, power, and controller " + "initialization points. Logged errors are expected."); + + struct RejectionCase + { + const char* label; + RealT iqcmd; + RealT ipcmd; + RealT imax; + RealT pmin; + RealT pmax; + }; + + const std::array rejected{{ + {"negative active-current command", 0.0, -0.1, 1.0, 0.0, 1.0}, + {"command vector outside Imax", 0.8, 0.8, 1.0, 0.0, 1.0}, + {"active-power seed above Pmax", 0.0, 0.8, 1.0, 0.0, 0.5}, + {"active-power seed below Pmin", 0.0, 0.2, 1.0, 0.3, 1.0}, + }}; + + for (const auto& test_case : rejected) + { + auto data = makeData(); + data.parameters[Params::Imax] = test_case.imax; + data.parameters[Params::Pmin] = test_case.pmin; + data.parameters[Params::Pmax] = test_case.pmax; + success *= initializationRejectedAtomically( + data, test_case.iqcmd, test_case.ipcmd, 1.0, test_case.label); + } + + // With reactive-current control selected, the voltage-controller + // limiter input must reproduce the compensated reactive-current target. + auto controller_data = makeData(); + controller_data.parameters[Params::QFlag] = true; + controller_data.parameters[Params::Imax] = 1.0; + controller_data.parameters[Params::Vref0] = 1.0; + controller_data.parameters[Params::kqv] = 10.0; + controller_data.parameters[Params::Iql1] = -1.0; + controller_data.parameters[Params::Iqh1] = 1.0; + success *= initializationRejectedAtomically( + controller_data, + -0.5, + 0.0, + 0.6, + "reactive-current target outside the selected voltage-controller limits"); + + // VFlag requires limiter inputs for terminal voltage through Vmin/Vmax + // and initial reactive power through Qmin/Qmax. + auto voltage_data = makeData(); + voltage_data.parameters[Params::QFlag] = true; + voltage_data.parameters[Params::VFlag] = true; + voltage_data.parameters[Params::Vmax] = 0.9; + success *= initializationRejectedAtomically( + voltage_data, + 0.1, + 0.2, + 1.0, + "terminal voltage outside selected Vmin/Vmax"); + + auto reactive_power_data = makeData(); + reactive_power_data.parameters[Params::QFlag] = true; + reactive_power_data.parameters[Params::VFlag] = true; + reactive_power_data.parameters[Params::Qmin] = -0.1; + reactive_power_data.parameters[Params::Qmax] = 0.1; + success *= initializationRejectedAtomically( + reactive_power_data, + 0.2, + 0.2, + 1.0, + "initial reactive power outside selected Qmin/Qmax"); + + // Power-factor control cannot infer an angle for nonzero Q at zero + // active power. This late rejection proves initialization atomicity. + auto power_factor_data = makeData(); + power_factor_data.parameters[Params::PfFlag] = true; + success *= initializationRejectedAtomically( + power_factor_data, + 0.2, + 0.0, + 1.0, + "nonzero power-factor reactive reference at zero active power"); + + // Zero current and both exact current-circle boundaries stay admissible. + struct AdmissibleCase + { + RealT iqcmd; + RealT ipcmd; + RealT imax; + }; + + for (const auto& accepted : std::array{{ + {0.0, 0.0, 1.0}, + {0.0, 1.0, 1.0}, + {1.0, 0.0, 1.0}, + }}) + { + auto data = makeData(); + data.parameters[Params::Imax] = accepted.imax; + data.parameters[Params::Pmax] = 1.0; + + Fixture fixture(data); + success *= fixture.initialize(accepted.iqcmd, accepted.ipcmd); + success *= (fixture.evaluate() == 0); + success *= allResidualsZero(fixture.reecb); + } return success.report(__func__); } - TestOutcome baseSignals() + /// A fixed numerical answer key for all 25 REECB residual rows. The + /// expected values are literals, not a second implementation of REECB. + TestOutcome residualEquations() { TestStatus success = true; - PhasorDynamics::Bus bus(1.0, 0.0); - bus.allocate(); - bus.initialize(); - - auto data = makeData(); - data.parameters[Params::mva] = static_cast(50.0); - - ScalarT pe_value{99.0}; - ScalarT qgen_value{99.0}; - ScalarT qext_value{99.0}; - ScalarT pfaref_value{99.0}; - ScalarT pref_value{99.0}; - ScalarT iqcmd_value{0.0}; - ScalarT ipcmd_value{0.0}; - IdxT pe_index = 40; - IdxT qgen_index = 41; - IdxT qext_index = 42; - IdxT pfaref_index = 43; - IdxT pref_index = 44; - IdxT iqcmd_index = 45; - IdxT ipcmd_index = 46; - - PhasorDynamics::SignalNode pe_node; - PhasorDynamics::SignalNode qgen_node; - PhasorDynamics::SignalNode qext_node; - PhasorDynamics::SignalNode pfaref_node; - PhasorDynamics::SignalNode pref_node; - PhasorDynamics::SignalNode iqcmd_node; - PhasorDynamics::SignalNode ipcmd_node; - pe_node.set(&pe_value, &pe_index); - qgen_node.set(&qgen_value, &qgen_index); - qext_node.set(&qext_value, &qext_index); - pfaref_node.set(&pfaref_value, &pfaref_index); - pref_node.set(&pref_value, &pref_index); - iqcmd_node.set(&iqcmd_value, &iqcmd_index); - ipcmd_node.set(&ipcmd_value, &ipcmd_index); - - ReecbT reecb(&bus, data); - reecb.getSignals().template attachSignalNode(&pe_node); - reecb.getSignals().template attachSignalNode(&qgen_node); - reecb.getSignals().template attachSignalNode(&qext_node); - reecb.getSignals().template attachSignalNode(&pfaref_node); - reecb.getSignals().template attachSignalNode(&pref_node); - reecb.getSignals().template assignSignalNode(&iqcmd_node); - reecb.getSignals().template assignSignalNode(&ipcmd_node); - - success *= (reecb.allocate() == 0); - iqcmd_node.init(static_cast(0.05)); - ipcmd_node.init(static_cast(0.25)); - success *= (reecb.verify() == 0); - success *= (reecb.initialize() == 0); - success *= (reecb.evaluateResidual() == 0); - - const ScalarT expected_pfaref = static_cast(std::atan(static_cast(0.1 / 0.5))); - success *= isEqual(reecb.y().getData()[index(Var::PMEAS)], static_cast(0.5), kTol); - success *= isEqual(reecb.y().getData()[index(Var::QREF)], static_cast(0.1), kTol); - success *= isEqual(reecb.y().getData()[index(Var::PORD)], static_cast(0.5), kTol); - success *= isEqual(pe_node.read(), static_cast(0.25), kTol); - success *= isEqual(qgen_node.read(), static_cast(0.05), kTol); - success *= isEqual(qext_node.read(), static_cast(0.05), kTol); - success *= isEqual(pfaref_node.read(), expected_pfaref, kTol); - success *= isEqual(pref_node.read(), static_cast(0.25), kTol); - success *= isEqual(iqcmd_node.read(), static_cast(0.05), kTol); - success *= isEqual(ipcmd_node.read(), static_cast(0.25), kTol); - success *= isEqual(reecb.y().getData()[index(Var::IQCMD)], static_cast(0.05), kTol); - success *= isEqual(reecb.y().getData()[index(Var::IPCMD)], static_cast(0.25), kTol); - success *= allZero(reecb); + Fixture fixture(makeDynamicData(), kStateVr, kStateVi); + fixture.attachAllInputs(); + success *= fixture.initialize(0.1, 0.2); + setAnswerKeyInputs(fixture); + setAnswerKeyState(fixture.reecb); + success *= (fixture.evaluate() == 0); + + // Values are pinned after an independent one-time evaluation of the + // documented equations at setAnswerKeyState()/setAnswerKeyInputs(). + const std::array expected{{ + {I::VMEAS, 0.2400000000000002}, + {I::PMEAS, 0.1449999999999998}, + {I::XPIQ, 0.03399999970642038}, + {I::XPIV, 0.0}, + {I::QV, 0.1222222222222222}, + {I::PORD, 0.26}, + {I::VT, -0.02999999999999992}, + {I::VMEASSAFE, -0.01000000000000001}, + {I::SDIP, 0.2}, + {I::VERR, 2.821917786596795e-7}, + {I::IQV, -0.06999999999999998}, + {I::QREF, -0.2668756300679377}, + {I::EQ, 0.3499999999999999}, + {I::VPIQ, -0.1000000000000002}, + {I::EPIV, -0.04999999999999993}, + {I::FPORD, -0.1000000000000003}, + {I::RPORD, 0.05000000000000004}, + {I::IQCIRC, 0.3999999999999997}, + {I::IPCIRC, 0.8100000000000001}, + {I::IQMAX, 0.1000000000000001}, + {I::IPMAX, 0.2}, + {I::IQBASE, -0.3699999999999998}, + {I::IQRAW, -0.17}, + {I::IQCMD, -0.05000000000000004}, + {I::IPCMD, -0.06145833333333334}, + }}; + + success *= (static_cast(fixture.reecb.getResidual().getSize()) == expected.size()); + success *= residualsMatch(fixture.reecb, expected); return success.report(__func__); } - TestOutcome feedbackBase() + /// Flag selection, voltage/deadband behavior, injection limiting, + /// reactive lag, and upper/lower/restoring anti-windup behavior. + TestOutcome reactiveControl() { TestStatus success = true; - PhasorDynamics::Bus bus(1.0, 0.0); - bus.allocate(); - bus.initialize(); + struct FlagCase + { + const char* label; + bool pf; + bool voltage; + bool reactive; + RealT qref; + RealT epiv; + RealT iqraw; + }; + + // Toggle exactly one selector at a time so an accidental swap between + // PfFlag, VFlag, and QFlag cannot satisfy the same answer key. + const std::array cases{{ + {"all-off selectors", false, false, false, 0.4, -0.55, 0.31}, + {"PfFlag-only selectors", true, false, false, 0.11149051952976989, -0.8385094804702301, 0.31}, + {"VFlag-only selectors", false, true, false, 0.4, 0.05, 0.31}, + {"QFlag-only selectors", false, false, true, 0.4, -0.55, 0.21}, + }}; + + for (const auto& test_case : cases) + { + auto data = makeDynamicData(); + data.parameters[Params::PfFlag] = test_case.pf; + data.parameters[Params::VFlag] = test_case.voltage; + data.parameters[Params::QFlag] = test_case.reactive; + + Fixture fixture(data); + fixture.attachAllInputs(); + success *= fixture.initialize(0.1, 0.2); + + fixture.input(E::PFAREF) = 0.2; + fixture.input(E::QEXT) = 0.2; // 0.4 on the 50 MVA component base. + setState(fixture.reecb, + {{I::PMEAS, 0.55}, + {I::VMEAS, 0.95}, + {I::VPIQ, 1.0}, + {I::QREF, test_case.qref}, + {I::IQBASE, 0.2}, + {I::QV, 0.3}, + {I::SDIP, 0.9}, + {I::IQV, 0.1}, + {I::EPIV, test_case.epiv}, + {I::IQRAW, test_case.iqraw}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, + {{I::QREF, 0.0}, {I::EPIV, 0.0}, {I::IQRAW, 0.0}}, + test_case.label); + } - auto data = makeData(); - data.parameters[Params::mva] = static_cast(50.0); - - ScalarT pe_value{0.25}; - ScalarT qgen_value{0.05}; - ScalarT iqcmd_value{0.0}; - ScalarT ipcmd_value{0.0}; - IdxT pe_index = 40; - IdxT qgen_index = 41; - IdxT iqcmd_index = 42; - IdxT ipcmd_index = 43; - - PhasorDynamics::SignalNode pe_node; - PhasorDynamics::SignalNode qgen_node; - PhasorDynamics::SignalNode iqcmd_node; - PhasorDynamics::SignalNode ipcmd_node; - pe_node.set(&pe_value, &pe_index); - qgen_node.set(&qgen_value, &qgen_index); - iqcmd_node.set(&iqcmd_value, &iqcmd_index); - ipcmd_node.set(&ipcmd_value, &ipcmd_index); - - ReecbT reecb(&bus, data); - reecb.getSignals().template attachSignalNode(&pe_node); - reecb.getSignals().template attachSignalNode(&qgen_node); - reecb.getSignals().template assignSignalNode(&iqcmd_node); - reecb.getSignals().template assignSignalNode(&ipcmd_node); - - success *= (reecb.allocate() == 0); - iqcmd_node.init(static_cast(0.05)); - ipcmd_node.init(static_cast(0.25)); - success *= (reecb.verify() == 0); - success *= (reecb.initialize() == 0); - success *= (reecb.evaluateResidual() == 0); - - success *= isEqual(reecb.y().getData()[index(Var::PMEAS)], static_cast(0.5), kTol); - success *= isEqual(reecb.y().getData()[index(Var::QREF)], static_cast(0.1), kTol); - success *= isEqual(reecb.y().getData()[index(Var::PORD)], static_cast(0.5), kTol); - success *= isEqual(pe_node.read(), static_cast(0.25), kTol); - success *= isEqual(qgen_node.read(), static_cast(0.05), kTol); - success *= isEqual(reecb.y().getData()[index(Var::IQCMD)], static_cast(0.05), kTol); - success *= isEqual(reecb.y().getData()[index(Var::IPCMD)], static_cast(0.25), kTol); - success *= allZero(reecb); + Fixture limit_fixture(makeDynamicData()); + limit_fixture.attachAllInputs(); + success *= limit_fixture.initialize(0.1, 0.2); + limit_fixture.input(E::QGEN) = 0.0; + + // A Q reference driven past each limit; EQ is the clamped reference + // less the zeroed qgen feedback. + for (const auto& [qref, expected_eq] : std::array{{ + {2.0, 0.8}, + {-2.0, -0.7}, + }}) + { + setState(limit_fixture.reecb, {{I::QREF, qref}, {I::EQ, 0.0}}); + success *= (limit_fixture.evaluate() == 0); + success *= residualsMatch(limit_fixture.reecb, + {{I::EQ, expected_eq}}, + "reactive-power limit"); + } + + // The same sweep through the reactive-power PI output limits. + for (const auto& [eq, expected_vpiq] : std::array{{ + {4.0, 1.3}, + {-4.0, 0.7}, + }}) + { + setState(limit_fixture.reecb, {{I::EQ, eq}, {I::XPIQ, 0.0}, {I::VPIQ, 0.0}}); + success *= (limit_fixture.evaluate() == 0); + success *= residualsMatch(limit_fixture.reecb, + {{I::VPIQ, expected_vpiq}}, + "reactive-power PI voltage limit"); + } + + // Each row pins the smooth voltage-band/deadband/injection behavior + // without calling CommonMath in the expected-value path. + struct VoltageCase + { + RealT voltage; + RealT vmeas; + RealT expected_sdip_rhs; + RealT expected_verr_rhs; + RealT expected_iqv_rhs; + }; + + const std::array voltage_cases{{ + {0.6, 0.6, 0.0, 0.37, 0.5}, + {1.0, 1.0, 1.0, -3.104066702683552e-5, -6.208133405367104e-5}, + {1.3, 1.3, 0.0, -0.28, -0.4}, + }}; + + auto voltage_data = makeDynamicData(); + voltage_data.parameters[Params::Vref0] = 1.0; + Fixture voltage_fixture(voltage_data); + success *= voltage_fixture.initialize(0.0, 0.2); + for (const auto& test_case : voltage_cases) + { + setState(voltage_fixture.reecb, + {{I::VT, test_case.voltage}, + {I::VMEAS, test_case.vmeas}, + {I::SDIP, test_case.expected_sdip_rhs}, + {I::VERR, test_case.expected_verr_rhs}, + {I::IQV, test_case.expected_iqv_rhs}}); + success *= (voltage_fixture.evaluate() == 0); + success *= residualsMatch(voltage_fixture.reecb, + {{I::SDIP, 0.0}, {I::VERR, 0.0}, {I::IQV, 0.0}}, + "voltage band, deadband, and injection limit"); + } + + // The QV lag and both PI anti-windup rows are evaluated at three + // controller directions: upper saturation, lower saturation, and a + // restoring direction. The expected derivatives are fixed literals. + struct AntiWindupCase + { + RealT xpiq; + RealT eq; + RealT xpiv; + RealT epiv; + RealT expected_xpiq; + RealT expected_xpiv; + }; + + const std::array antiwindup_cases{{ + {2.0, 0.5, 2.0, 0.5, 0.0, 0.0}, + {-2.0, -0.5, -2.0, -0.5, 0.0, 0.0}, + {2.0, -0.5, 2.0, -0.5, -0.2, -0.25}, + }}; + + Fixture controller_fixture(makeDynamicData()); + success *= controller_fixture.initialize(0.0, 0.2); + for (const auto& test_case : antiwindup_cases) + { + setState(controller_fixture.reecb, + {{I::SDIP, 1.0}, + {I::XPIQ, test_case.xpiq}, + {I::EQ, test_case.eq}, + {I::XPIV, test_case.xpiv}, + {I::EPIV, test_case.epiv}, + {I::IQMAX, 1.0}}); + success *= (controller_fixture.evaluate() == 0); + success *= residualsMatch(controller_fixture.reecb, + {{I::XPIQ, test_case.expected_xpiq}, + {I::XPIV, test_case.expected_xpiv}}, + "anti-windup"); + } + + setState(controller_fixture.reecb, + {{I::SDIP, 1.0}, {I::QREF, 0.4}, {I::VMEASSAFE, 1.0}, {I::QV, 0.2}}); + setDerivative(controller_fixture.reecb, {{I::QV, 0.1}}); + success *= (controller_fixture.evaluate() == 0); + success *= residualsMatch(controller_fixture.reecb, + {{I::QV, 0.5666666666666667}}, + "reactive-current lag"); return success.report(__func__); } - TestOutcome zeroTime() + /// Active-power measurement/order filters, both ramp limits, power + /// bounds, and the safe-voltage current conversion. + TestOutcome activePowerControl() { TestStatus success = true; - PhasorDynamics::Bus bus(1.0, 0.0); - bus.allocate(); - bus.initialize(); + auto data = makeDynamicData(); + data.parameters[Params::dPmax] = 0.4; + data.parameters[Params::dPmin] = -0.3; + data.parameters[Params::Pmax] = 1.0; + data.parameters[Params::Pmin] = 0.1; + data.parameters[Params::Tpord] = 0.5; + data.parameters[Params::Tp] = 0.25; - auto data = makeData(); - data.parameters[Params::Trv] = static_cast(0.0); - data.parameters[Params::Tp] = static_cast(0.0); - - ReecbT reecb(&bus, data); - success *= (reecb.allocate() == 0); - success *= (reecb.verify() == 0); - reecb.y().getData()[index(Var::IQCMD)] = static_cast(0.2); - reecb.y().getData()[index(Var::IPCMD)] = static_cast(0.6); - reecb.y().setDataUpdated(); - success *= (reecb.initialize() == 0); - success *= (reecb.tagDifferentiable() == 0); - success *= (reecb.tag()[index(Var::VMEAS)] == true); - success *= (reecb.tag()[index(Var::PMEAS)] == true); + Fixture fixture(data); + fixture.attachAllInputs(); + success *= fixture.initialize(0.0, 0.2); - reecb.yp().getData()[index(Var::VMEAS)] = static_cast(1.0); - reecb.yp().getData()[index(Var::PMEAS)] = static_cast(2.0); - reecb.yp().setDataUpdated(); - success *= (reecb.evaluateResidual() == 0); - success *= isEqual(reecb.getResidual().getData()[index(Var::VMEAS)], static_cast(-1.0), kTol); - success *= isEqual(reecb.getResidual().getData()[index(Var::PMEAS)], static_cast(-2.0), kTol); + struct RampCase + { + RealT pord; + RealT pref_system; + RealT fpord; + RealT rpord; + RealT expected_fpord; + RealT expected_rpord; + RealT expected_pord; + }; + + const std::array cases{{ + {0.5, 0.5, 1.0, 0.4, 0.0, 0.0, 0.4}, + {0.7, 0.1, -1.0, -0.3, 0.0, 0.0, -0.3}, + {1.2, 0.5, -0.4, -0.3, 0.0, 0.0, -0.3}, + }}; + + for (const auto& test_case : cases) + { + fixture.input(E::PREF) = test_case.pref_system; + setState(fixture.reecb, + {{I::PORD, test_case.pord}, + {I::FPORD, test_case.fpord}, + {I::RPORD, test_case.rpord}, + {I::SDIP, 1.0}}); + setDerivative(fixture.reecb, {{I::PORD, 0.0}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, + {{I::FPORD, test_case.expected_fpord}, + {I::RPORD, test_case.expected_rpord}, + {I::PORD, test_case.expected_pord}}, + "active-power order"); + } - reecb.yp().getData()[index(Var::VMEAS)] = ZERO; - reecb.y().getData()[index(Var::VMEAS)] = static_cast(0.99); - reecb.y().setDataUpdated(); - reecb.yp().setDataUpdated(); - success *= (reecb.evaluateResidual() == 0); - success *= isEqual(reecb.getResidual().getData()[index(Var::VMEAS)], static_cast(10.0), kTol); + struct LowerPowerBoundCase + { + RealT rate; + RealT expected_residual; + const char* label; + }; + + const std::array lower_bound_cases{{ + {-0.3, 0.0, "Pmin blocks an outward active-power rate"}, + {0.4, 0.4, "Pmin admits a restoring active-power rate"}, + }}; + + for (const auto& test_case : lower_bound_cases) + { + setState(fixture.reecb, + {{I::PORD, -0.2}, {I::RPORD, test_case.rate}, {I::SDIP, 1.0}}); + setDerivative(fixture.reecb, {{I::PORD, 0.0}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, + {{I::PORD, test_case.expected_residual}}, + test_case.label); + } + + // PE is 0.3 on system base and 0.6 on the 50 MVA component base. + fixture.input(E::PE) = 0.3; + setState(fixture.reecb, + {{I::PMEAS, 0.5}, + {I::VMEAS, 0.005}, + {I::VMEASSAFE, 0.01}, + {I::PORD, 0.004}, + {I::IPMAX, 1.0}, + {I::IPCMD, 0.2}}); + setDerivative(fixture.reecb, {{I::PMEAS, 0.1}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, + {{I::PMEAS, 0.3}, + {I::VMEASSAFE, 0.00109701028057513}, + {I::IPCMD, 0.0}}, + "safe-voltage active-power path"); return success.report(__func__); } - TestOutcome qPriority() + /// P- and Q-priority at the current-circle boundary. In both cases the + /// low-priority command exactly binds the remaining-current root. + TestOutcome currentPriority() { TestStatus success = true; - PhasorDynamics::Bus bus(1.0, 0.0); - bus.allocate(); - bus.initialize(); - - auto data = makeData(); - data.parameters[Params::Pqflag] = static_cast(0); - data.parameters[Params::Imax] = static_cast(1.1); - - ReecbT reecb(&bus, data); - success *= (reecb.allocate() == 0); - success *= (reecb.verify() == 0); - reecb.y().getData()[index(Var::IQCMD)] = static_cast(0.3); - reecb.y().getData()[index(Var::IPCMD)] = static_cast(0.9); - reecb.y().setDataUpdated(); - success *= (reecb.initialize() == 0); - success *= (reecb.evaluateResidual() == 0); - - const ScalarT ipmax = std::sqrt(static_cast(1.1 * 1.1 - 0.3 * 0.3)); - success *= isEqual(reecb.y().getData()[index(Var::IQMAX)], static_cast(1.1), kTol); - success *= isEqual(reecb.y().getData()[index(Var::IPMAX)], ipmax, kTol); - success *= isEqual(reecb.y().getData()[index(Var::IQCMD)], static_cast(0.3), kTol); - success *= isEqual(reecb.y().getData()[index(Var::IPCMD)], static_cast(0.9), kTol); - success *= allZero(reecb); + struct PriorityCase + { + const char* label; + bool p_priority; + RealT iqcmd; + RealT ipcmd; + RealT iqcirc; + RealT ipcirc; + RealT iqmax; + RealT ipmax; + }; + + const std::array cases{{ + {"Q-priority", false, 0.8, 0.6, 1.0, 0.6, 1.0, 0.6}, + {"P-priority", true, 0.6, 0.8, 0.6, 1.0, 0.6, 1.0}, + }}; + + for (const auto& test_case : cases) + { + auto data = makeData(); + data.parameters[Params::Pqflag] = test_case.p_priority; + data.parameters[Params::Imax] = 1.0; + + Fixture fixture(data); + success *= fixture.initialize(test_case.iqcmd, test_case.ipcmd); + success *= (fixture.evaluate() == 0); + + success *= stateMatches(fixture.reecb, + {{I::IQCIRC, test_case.iqcirc}, + {I::IPCIRC, test_case.ipcirc}, + {I::IQMAX, test_case.iqmax}, + {I::IPMAX, test_case.ipmax}}, + test_case.label); + success *= residualsMatch(fixture.reecb, + {{I::IQCMD, 0.0}, {I::IPCMD, 0.0}}, + test_case.label); + success *= scalarMatches(fixture.iqcmd(), test_case.iqcmd, "priority iqcmd preservation"); + success *= scalarMatches(fixture.ipcmd(), test_case.ipcmd, "priority ipcmd preservation"); + success *= allResidualsZero(fixture.reecb); + } return success.report(__func__); } - TestOutcome pPriority() +#ifdef GRIDKIT_ENABLE_ENZYME + /// A single rich state and all five external inputs drive both + /// sensitivity paths; every Enzyme CSR row must match dependency tracking. + TestOutcome jacobian() { TestStatus success = true; - PhasorDynamics::Bus bus(1.0, 0.0); - bus.allocate(); - bus.initialize(); + const auto data = makeDynamicData(); - auto data = makeData(); - data.parameters[Params::Pqflag] = static_cast(1); - data.parameters[Params::Imax] = static_cast(1.1); - - ReecbT reecb(&bus, data); - success *= (reecb.allocate() == 0); - success *= (reecb.verify() == 0); - reecb.y().getData()[index(Var::IQCMD)] = static_cast(0.3); - reecb.y().getData()[index(Var::IPCMD)] = static_cast(0.9); - reecb.y().setDataUpdated(); - success *= (reecb.initialize() == 0); - success *= (reecb.evaluateResidual() == 0); + const auto dependency_jacobian = dependencyTrackingJacobian(data, success); + const auto enzyme_jacobian = enzymeJacobian(data, success); - const ScalarT iqmax = std::sqrt(static_cast(1.1 * 1.1 - 0.9 * 0.9)); - success *= isEqual(reecb.y().getData()[index(Var::IPMAX)], static_cast(1.1), kTol); - success *= isEqual(reecb.y().getData()[index(Var::IQMAX)], iqmax, kTol); - success *= isEqual(reecb.y().getData()[index(Var::IQCMD)], static_cast(0.3), kTol); - success *= isEqual(reecb.y().getData()[index(Var::IPCMD)], static_cast(0.9), kTol); - success *= allZero(reecb); + success *= (dependency_jacobian.size() == enzyme_jacobian.size()); + const auto rows = std::min(dependency_jacobian.size(), enzyme_jacobian.size()); + for (size_t row = 0; row < rows; ++row) + { + if (!isEqual(dependency_jacobian[row], enzyme_jacobian[row], kJacobianTol)) + { + std::cout << "REECB Jacobian row " << row + << " mismatch between dependency tracking and Enzyme\n"; + success = false; + } + } return success.report(__func__); } +#endif - TestOutcome voltageBand() + private: + using Params = PhasorDynamics::Converter::ReecbParameters; + using Vars = PhasorDynamics::Converter::ReecbInternalVariables; + using Ext = PhasorDynamics::Converter::ReecbExternalVariables; + using Mon = PhasorDynamics::Converter::ReecbMonitorableVariables; + using Data = PhasorDynamics::Converter::ReecbData; + using I = PhasorDynamics::Converter::ReecbIdx; + using E = PhasorDynamics::Converter::ReecbExt; + + /// A vector row paired with a value: either an input to write or an + /// expected result. Rows are `ReecbIdx`/`ReecbExt` constants, so a + /// failure report locates itself without any name string to maintain. + using Row = std::pair; + using Rows = std::initializer_list; + using ReecbT = PhasorDynamics::Converter::Reecb; + + /// A driven input value paired with the result it should produce. Kept + /// distinct from `Row`, whose first member is a vector position. + struct DrivenCase { - TestStatus success = true; + RealT input; + RealT expected; + }; + + /// Owns the terminal bus, REECB, assigned command nodes, and attached + /// input nodes. Signal storage is declared before the model so every + /// referenced node outlives REECB. Copying would invalidate the model and + /// signal-node pointers. + template + class Fixture + { + private: + std::array input_values_{}; + std::array input_indices_{}; + std::array, E::MAXIMUM> input_nodes_{}; + + PhasorDynamics::SignalNode iqcmd_node_; + PhasorDynamics::SignalNode ipcmd_node_; + + public: + explicit Fixture(const Data& data, + RealT vr = 1.0, + RealT vi = 0.0, + RealT system_va_base = 100.0e6) + : bus(static_cast(vr), static_cast(vi)), + reecb(&bus, data) + { + reecb.setSystemBase(60.0, system_va_base); + reecb.getSignals().template assignSignalNode(&iqcmd_node_); + reecb.getSignals().template assignSignalNode(&ipcmd_node_); + } - PhasorDynamics::Bus bus(0.8, 0.0); - bus.allocate(); - bus.initialize(); - - auto data = makeData(); - data.parameters[Params::QFlag] = static_cast(1); - data.parameters[Params::Vref0] = static_cast(1.0); - data.parameters[Params::Vdip] = static_cast(0.9); - data.parameters[Params::Vup] = static_cast(1.1); - data.parameters[Params::dbd1] = static_cast(0.0); - data.parameters[Params::dbd2] = static_cast(0.0); - data.parameters[Params::kqv] = static_cast(1.0); - data.parameters[Params::Imax] = static_cast(2.0); - - ReecbT reecb(&bus, data); - success *= (reecb.allocate() == 0); - success *= (reecb.verify() == 0); - reecb.y().getData()[index(Var::IQCMD)] = ZERO; - reecb.y().getData()[index(Var::IPCMD)] = static_cast(0.5); - reecb.y().setDataUpdated(); - success *= (reecb.initialize() == 0); - success *= (reecb.evaluateResidual() == 0); + Fixture(const Fixture&) = delete; + Fixture& operator=(const Fixture&) = delete; + + /// Attach fixture-owned storage to every external input. + void attachAllInputs(RealT initial_value = 0.0) + { + const IdxT external_index_base = reecb.size() + bus.size(); - const ScalarT expected_sdip = Math::inside(reecb.y().getData()[index(Var::VT)], static_cast(0.9), static_cast(1.1)); - const ScalarT expected_iqraw = reecb.y().getData()[index(Var::IQBASE)] + (ONE - reecb.y().getData()[index(Var::SDIP)]) * reecb.y().getData()[index(Var::IQV)]; - success *= isEqual(reecb.y().getData()[index(Var::SDIP)], expected_sdip, kTol); - success *= isEqual(reecb.y().getData()[index(Var::IQRAW)], expected_iqraw, kTol); - success *= (reecb.y().getData()[index(Var::SDIP)] < static_cast(0.01)); - success *= (reecb.y().getData()[index(Var::IQV)] > static_cast(0.1)); - success *= allZero(reecb); + for (size_t port = 0; port < E::MAXIMUM; ++port) + { + input_values_[port] = static_cast(initial_value); + input_indices_[port] = external_index_base + static_cast(port); + input_nodes_[port].set(&input_values_[port], &input_indices_[port]); + } - return success.report(__func__); + auto& signals = reecb.getSignals(); + signals.template attachSignalNode(&input_nodes_[E::PE]); + signals.template attachSignalNode(&input_nodes_[E::QGEN]); + signals.template attachSignalNode(&input_nodes_[E::QEXT]); + signals.template attachSignalNode(&input_nodes_[E::PFAREF]); + signals.template attachSignalNode(&input_nodes_[E::PREF]); + } + + /// Seed the assigned command nodes on the system base. + void seedCommands(RealT iqcmd, RealT ipcmd) + { + iqcmd_node_.init(static_cast(iqcmd)); + ipcmd_node_.init(static_cast(ipcmd)); + } + + /// Everything REECB initialization requires: allocation, verification, + /// an initialized terminal bus, and initialized command nodes. + bool prepare(RealT iqcmd, RealT ipcmd) + { + const bool success = (bus.allocate() == 0) && (reecb.allocate() == 0) + && (reecb.verify() == 0) && (bus.initialize() == 0); + if (!success) + { + std::cout << "REECB fixture preparation failed\n"; + return false; + } + + seedCommands(iqcmd, ipcmd); + return true; + } + + /// prepare() plus successful REECB initialization. + bool initialize(RealT iqcmd, RealT ipcmd) + { + if (!prepare(iqcmd, ipcmd)) + { + return false; + } + if (reecb.initialize() != 0) + { + std::cout << "REECB initialization failed\n"; + return false; + } + return true; + } + + int evaluate() + { + return reecb.evaluateResidual(); + } + + T iqcmd() const + { + return iqcmd_node_.read(); + } + + T ipcmd() const + { + return ipcmd_node_.read(); + } + + T& input(size_t port) + { + return input_values_[port]; + } + + IdxT inputIndex(size_t port) const + { + return input_indices_[port]; + } + + PhasorDynamics::Bus bus; + PhasorDynamics::Converter::Reecb reecb; + }; + + static constexpr RealT kStateVr = 0.9; + static constexpr RealT kStateVi = 0.4; + + Data makeMinimalData() const + { + Data data; + data.device_class = "Reecb"; + data.disambiguation_string = "reecb_test"; + data.monitored_variables.insert(Mon::iqcmd); + data.monitored_variables.insert(Mon::ipcmd); + data.monitored_variables.insert(Mon::vmeas); + data.monitored_variables.insert(Mon::pmeas); + return data; } - TestOutcome piSaturation() + Data makeExplicitDefaultData() const { - TestStatus success = true; + auto data = makeMinimalData(); + + // These are the documented defaults. Vref0 is the terminal-voltage + // fallback for the probe bus used by defaultsMatchDocumentedValues(). + data.parameters[Params::mva] = 100.0; + data.parameters[Params::PfFlag] = false; + data.parameters[Params::VFlag] = false; + data.parameters[Params::QFlag] = false; + data.parameters[Params::Pqflag] = false; + data.parameters[Params::Trv] = 0.02; + data.parameters[Params::Tp] = 0.0; + data.parameters[Params::Vref0] = 0.9848857801796105; + data.parameters[Params::Vdip] = 0.85; + data.parameters[Params::Vup] = 1.15; + data.parameters[Params::dbd1] = 0.0; + data.parameters[Params::dbd2] = 0.0; + data.parameters[Params::kqv] = 5.0; + data.parameters[Params::Iql1] = -1.1; + data.parameters[Params::Iqh1] = 1.1; + data.parameters[Params::Qmax] = 0.436; + data.parameters[Params::Qmin] = -0.436; + data.parameters[Params::Kqp] = 0.0; + data.parameters[Params::Kqi] = 0.1; + data.parameters[Params::Vmax] = 1.1; + data.parameters[Params::Vmin] = 0.9; + data.parameters[Params::Kvp] = 18.0; + data.parameters[Params::Kvi] = 5.0; + data.parameters[Params::Tiq] = 0.02; + data.parameters[Params::Tpord] = 0.02; + data.parameters[Params::dPmax] = 99.0; + data.parameters[Params::dPmin] = -99.0; + data.parameters[Params::Pmax] = 1.0; + data.parameters[Params::Pmin] = 0.0; + data.parameters[Params::Imax] = 1.3; + return data; + } + + Data makeData() const + { + auto data = makeMinimalData(); - PhasorDynamics::Bus bus(1.13, 0.0); - bus.allocate(); - bus.initialize(); + data.parameters[Params::mva] = 100.0; + data.parameters[Params::PfFlag] = false; + data.parameters[Params::VFlag] = true; + data.parameters[Params::QFlag] = false; + data.parameters[Params::Pqflag] = true; + data.parameters[Params::Trv] = 0.02; + data.parameters[Params::Tp] = 0.02; + data.parameters[Params::Vref0] = 1.0; + data.parameters[Params::Vdip] = 0.7; + data.parameters[Params::Vup] = 1.2; + data.parameters[Params::dbd1] = -0.01; + data.parameters[Params::dbd2] = 0.01; + data.parameters[Params::kqv] = 0.0; + data.parameters[Params::Iql1] = -1.0; + data.parameters[Params::Iqh1] = 1.0; + data.parameters[Params::Qmax] = 1.0; + data.parameters[Params::Qmin] = -1.0; + data.parameters[Params::Kqp] = 1.0; + data.parameters[Params::Kqi] = 0.0; + data.parameters[Params::Vmax] = 1.2; + data.parameters[Params::Vmin] = 0.8; + data.parameters[Params::Kvp] = 1.0; + data.parameters[Params::Kvi] = 0.0; + data.parameters[Params::Tiq] = 0.02; + data.parameters[Params::Tpord] = 0.02; + data.parameters[Params::dPmax] = 1.0; + data.parameters[Params::dPmin] = -1.0; + data.parameters[Params::Pmax] = 1.0; + data.parameters[Params::Pmin] = 0.0; + data.parameters[Params::Imax] = 2.0; + return data; + } + Data makeDynamicData() const + { auto data = makeData(); - data.parameters[Params::QFlag] = static_cast(0); - data.parameters[Params::Pqflag] = static_cast(0); - data.parameters[Params::Vmin] = static_cast(0.9); - data.parameters[Params::Vmax] = static_cast(1.05); - data.parameters[Params::Kvp] = static_cast(10.0); - data.parameters[Params::Kvi] = static_cast(60.0); - data.parameters[Params::Vup] = static_cast(99.0); - data.parameters[Params::Vdip] = static_cast(-99.0); - data.parameters[Params::Imax] = static_cast(1.1); - - ReecbT reecb(&bus, data); - success *= (reecb.allocate() == 0); - success *= (reecb.verify() == 0); - reecb.y().getData()[index(Var::IQCMD)] = static_cast(0.15 / 1.13); - reecb.y().getData()[index(Var::IPCMD)] = static_cast(0.5 / 1.13); - reecb.y().setDataUpdated(); - success *= (reecb.initialize() == 0); - success *= (reecb.evaluateResidual() == 0); + data.parameters[Params::mva] = 50.0; + data.parameters[Params::PfFlag] = true; + data.parameters[Params::VFlag] = true; + data.parameters[Params::QFlag] = true; + data.parameters[Params::Pqflag] = true; + data.parameters[Params::Trv] = 0.2; + data.parameters[Params::Tp] = 0.4; + data.parameters[Params::Vref0] = 1.02; + data.parameters[Params::Vdip] = 0.7; + data.parameters[Params::Vup] = 1.2; + data.parameters[Params::dbd1] = -0.02; + data.parameters[Params::dbd2] = 0.03; + data.parameters[Params::kqv] = 2.0; + data.parameters[Params::Iql1] = -0.4; + data.parameters[Params::Iqh1] = 0.5; + data.parameters[Params::Qmax] = 0.8; + data.parameters[Params::Qmin] = -0.7; + data.parameters[Params::Kqp] = 0.6; + data.parameters[Params::Kqi] = 0.4; + data.parameters[Params::Vmax] = 1.3; + data.parameters[Params::Vmin] = 0.7; + data.parameters[Params::Kvp] = 1.2; + data.parameters[Params::Kvi] = 0.5; + data.parameters[Params::Tiq] = 0.3; + data.parameters[Params::Tpord] = 0.25; + data.parameters[Params::dPmax] = 0.6; + data.parameters[Params::dPmin] = -0.5; + data.parameters[Params::Pmax] = 1.4; + data.parameters[Params::Pmin] = 0.1; + data.parameters[Params::Imax] = 1.5; + return data; + } - const ScalarT piv_arg = static_cast(10.0) * reecb.y().getData()[index(Var::EPIV)] + reecb.y().getData()[index(Var::XPIV)]; - success *= (piv_arg < -reecb.y().getData()[index(Var::IQMAX)]); - success *= allZero(reecb); + /// The external inputs the residual answer key is evaluated against. + template + void setAnswerKeyInputs(Fixture& fixture) const + { + fixture.input(E::PE) = 0.3; + fixture.input(E::QGEN) = -0.1; + fixture.input(E::QEXT) = 0.2; + fixture.input(E::PFAREF) = 0.15; + fixture.input(E::PREF) = 0.35; + } - return success.report(__func__); + /// The rich state shared by the residual answer key and the Jacobian + /// comparison. Every row is distinct so a swapped index cannot pass. + template + void setAnswerKeyState(PhasorDynamics::Converter::Reecb& reecb) const + { + setState(reecb, + {{I::VMEAS, 0.95}, {I::PMEAS, 0.55}, {I::XPIQ, 0.10}, {I::XPIV, -0.05}, {I::QV, 0.30}, {I::PORD, 0.65}, {I::VT, 1.00}, {I::VMEASSAFE, 0.96}, {I::SDIP, 0.80}, {I::VERR, 0.04}, {I::IQV, 0.15}, {I::QREF, 0.35}, {I::EQ, 0.20}, {I::VPIQ, 0.80}, {I::EPIV, -0.10}, {I::FPORD, 0.30}, {I::RPORD, 0.25}, {I::IQCIRC, 1.10}, {I::IPCIRC, 1.20}, {I::IQMAX, 1.00}, {I::IPMAX, 1.30}, {I::IQBASE, 0.20}, {I::IQRAW, 0.40}, {I::IQCMD, 0.25}, {I::IPCMD, 0.40}}); + setDerivative(reecb, + {{I::VMEAS, 0.01}, + {I::PMEAS, -0.02}, + {I::XPIQ, 0.03}, + {I::XPIV, -0.04}, + {I::QV, 0.05}, + {I::PORD, -0.06}}); } -#ifdef GRIDKIT_ENABLE_ENZYME - TestOutcome jacobian() + /// Omitting every optional parameter must give exactly the model built + /// from the defaults the README documents, at rest and under load. + bool defaultsMatchDocumentedValues() const { - TestStatus success = true; + auto implicit_data = makeMinimalData(); + implicit_data.parameters[Params::mva] = 100.0; - PhasorDynamics::Bus bus(1.0, 0.0); - bus.allocate(); - bus.initialize(); - - ReecbT reecb(&bus, makeData()); - success *= (reecb.allocate() == 0); - success *= (reecb.verify() == 0); - reecb.y().getData()[index(Var::IQCMD)] = static_cast(0.2); - reecb.y().getData()[index(Var::IPCMD)] = static_cast(0.6); - reecb.y().setDataUpdated(); - success *= (reecb.initialize() == 0); - success *= (reecb.evaluateResidual() == 0); - success *= (reecb.evaluateJacobian() == 0); + Fixture implicit_defaults(implicit_data, 0.9, 0.4); + Fixture explicit_defaults(makeExplicitDefaultData(), 0.9, 0.4); + implicit_defaults.attachAllInputs(); + explicit_defaults.attachAllInputs(); - auto* jac = reecb.getCooJacobian(); - success *= (jac != nullptr); - if (jac != nullptr) + bool success = implicit_defaults.initialize(0.1, 0.2) + && explicit_defaults.initialize(0.1, 0.2); + if (!success) { - success *= (jac->getNnz() > 0); - const auto* values = jac->getValues(); - for (IdxT i = 0; i < jac->getNnz(); ++i) - { - success *= std::isfinite(values[i]); - } + std::cout << "REECB documented-default comparison failed to initialize\n"; + return false; } - return success.report(__func__); + success *= (implicit_defaults.evaluate() == 0); + success *= (explicit_defaults.evaluate() == 0); + success *= vectorUnchanged(implicit_defaults.reecb.y(), + copyVector(explicit_defaults.reecb.y()), + "documented-default state"); + success *= vectorUnchanged(implicit_defaults.reecb.yp(), + copyVector(explicit_defaults.reecb.yp()), + "documented-default derivative"); + success *= vectorUnchanged(implicit_defaults.reecb.getResidual(), + copyVector(explicit_defaults.reecb.getResidual()), + "documented-default residual"); + + setAnswerKeyInputs(implicit_defaults); + setAnswerKeyInputs(explicit_defaults); + setAnswerKeyState(implicit_defaults.reecb); + setAnswerKeyState(explicit_defaults.reecb); + success *= (implicit_defaults.evaluate() == 0); + success *= (explicit_defaults.evaluate() == 0); + success *= vectorUnchanged(implicit_defaults.reecb.getResidual(), + copyVector(explicit_defaults.reecb.getResidual()), + "documented-default dynamic residual"); + return success; } -#endif - TestOutcome json() + bool invalidParameterCase(PhasorDynamics::Bus& bus, + Params parameter, + RealT value) const { - TestStatus success = true; + auto data = makeData(); + data.parameters[parameter] = value; + PhasorDynamics::Converter::Reecb model(&bus, data); + return model.verify() > 0; + } - std::istringstream input(R"json( -{ - "header": { - "format_version": 0, - "format_revision": 1, - "case_name": "renewable electrical control", - "case_description": "REECB parser test", - "case_comments": "", - "freq_base": 60.0, - "va_base": 100000000.0 - }, - "buses": [ - { "number": 1, "class": "bus", "name": "Bus 1", "init": { "Vr": 1.0, "Vi": 0.0 }, "params": { "kv": 1.0 } } - ], - "signals": [ - { "signal_id": 12, "name": "Iqcmd" }, - { "signal_id": 13, "name": "Ipcmd" } - ], - "devices": [ - { - "class": "Reecb", - "ports": { "bus": 1, "iqcmd": 12, "ipcmd": 13 }, - "id": "REE1", - "params": { - "mva": 100.0, "PfFlag": 0, "VFlag": 1, "QFlag": 0, "Pqflag": 1, - "Trv": 0.0, "Tp": 0.02, "Vdip": 0.7, "Vup": 1.2, - "dbd1": -0.01, "dbd2": 0.01, "kqv": 0.0, "Iql1": -1.0, "Iqh1": 1.0, - "Qmax": 1.0, "Qmin": -1.0, "Kqp": 1.0, "Kqi": 0.0, - "Vmax": 1.2, "Vmin": 0.8, "Kvp": 1.0, "Kvi": 0.0, - "Tiq": 0.02, "Tpord": 0.02, "dPmax": 1.0, "dPmin": -1.0, - "Pmax": 1.0, "Pmin": 0.0, "Imax": 2.0 - } - } - ] -} -)json"); - - auto data = PhasorDynamics::parseSystemModelData(input); - success *= (data.reecb.size() == 1); - success *= (std::get(data.reecb[0].parameters.at(Params::Pqflag)) == static_cast(1)); - success *= (std::get(data.reecb[0].parameters.at(Params::mva)) == static_cast(100.0)); - success *= (data.reecb[0].buses.at(Buses::bus) == static_cast(1)); - success *= data.reecb[0].signal_inputs.empty(); - success *= (data.reecb[0].signal_outputs.at(Outputs::iqcmd) == static_cast(12)); - success *= (data.reecb[0].signal_outputs.at(Outputs::ipcmd) == static_cast(13)); - - PhasorDynamics::SystemModel system(data); - success *= (system.allocate() == 0); - success *= (system.initialize() == 0); - success *= (system.evaluateResidual() == 0); - success *= (system.size() == 27); + template + bool unlinkedSignalRejected(PhasorDynamics::Bus& bus) const + { + PhasorDynamics::SignalNode unlinked_node; + PhasorDynamics::Converter::Reecb model(&bus, makeData()); + model.getSignals().template attachSignalNode(&unlinked_node); + return model.verify() > 0; + } - return success.report(__func__); + template + std::vector copyVector(const VectorT& vector) const + { + const auto* values = vector.getData(); + return std::vector(values, + values + static_cast(vector.getSize())); } - private: - static size_t index(Var variable) + /// Every row of a vector still holds its snapshot value. + template + bool vectorUnchanged(const VectorT& vector, + const std::vector& snapshot, + const char* what) const { - return static_cast(variable); + bool success = true; + const auto* values = vector.getData(); + for (size_t i = 0; i < snapshot.size(); ++i) + { + success &= rowMatches(static_cast(values[i]), snapshot[i], what, i, "changed"); + } + return success; } - TestStatus allZero(const ReecbT& reecb) const + bool initializationRejectedAtomically(const Data& data, + RealT iqcmd, + RealT ipcmd, + RealT terminal_voltage, + const char* label) const { - TestStatus success = true; + Fixture fixture(data, terminal_voltage); + fixture.attachAllInputs(77.0); + if (!fixture.prepare(iqcmd, ipcmd)) + { + return false; + } + + auto* y = fixture.reecb.y().getData(); + auto* yp = fixture.reecb.yp().getData(); + for (size_t i = 0; i < static_cast(fixture.reecb.y().getSize()); ++i) + { + y[i] = 0.125 + 0.01 * static_cast(i); + yp[i] = -0.25 - 0.01 * static_cast(i); + } + fixture.seedCommands(iqcmd, ipcmd); + fixture.reecb.y().setDataUpdated(); + fixture.reecb.yp().setDataUpdated(); + + const auto y_before = copyVector(fixture.reecb.y()); + const auto yp_before = copyVector(fixture.reecb.yp()); - for (size_t i = 0; i < reecb.getResidual().getSize(); ++i) + bool success = true; + if (fixture.reecb.initialize() == 0) { - success *= isEqual(reecb.getResidual().getData()[i], static_cast(0.0), kTol); - success *= isEqual(reecb.yp().getData()[i], static_cast(0.0), kTol); + std::cout << "Expected initialization rejection: " << label << "\n"; + success = false; } + success *= scalarMatches(fixture.iqcmd(), iqcmd, "rejected iqcmd preservation"); + success *= scalarMatches(fixture.ipcmd(), ipcmd, "rejected ipcmd preservation"); + for (size_t port = 0; port < E::MAXIMUM; ++port) + { + success &= rowMatches(fixture.input(port), 77.0, "external input", port, "changed"); + } + success *= vectorUnchanged(fixture.reecb.y(), y_before, "state"); + success *= vectorUnchanged(fixture.reecb.yp(), yp_before, "derivative"); return success; } - auto makeMinimalData() -> PhasorDynamics::Converter::ReecbData + /// Write state rows and publish the update, folding in the + /// setDataUpdated() that a hand-written write block has to remember. + template + void setState(PhasorDynamics::Converter::Reecb& reecb, Rows rows) const { - using Mon = PhasorDynamics::Converter::ReecbMonitorableVariables; + auto* y = reecb.y().getData(); + for (const auto& [row, value] : rows) + { + y[row] = static_cast(value); + } + reecb.y().setDataUpdated(); + } - PhasorDynamics::Converter::ReecbData data; - data.device_class = "Reecb"; - data.disambiguation_string = "reecb_test"; - data.monitored_variables.insert(Mon::iqcmd); - data.monitored_variables.insert(Mon::ipcmd); - data.monitored_variables.insert(Mon::vmeas); - data.monitored_variables.insert(Mon::pmeas); - return data; + /// setState() for the derivative vector. + template + void setDerivative(PhasorDynamics::Converter::Reecb& reecb, Rows rows) const + { + auto* yp = reecb.yp().getData(); + for (const auto& [row, value] : rows) + { + yp[row] = static_cast(value); + } + reecb.yp().setDataUpdated(); } - auto makeData() -> PhasorDynamics::Converter::ReecbData + /// Compare one vector row against its expected value. Every row check in + /// this suite reports through here, so failures share one format. Rows + /// are named by position, which is the `ReecbIdx` constant the + /// expectation was written with, leaving no name string to maintain. + static bool rowMatches(RealT actual, + RealT expected, + const char* what, + size_t row, + const char* context) { - auto data = makeMinimalData(); + if (isEqual(actual, expected, kBehaviorTol)) + { + return true; + } + std::cout << "REECB " << what << " row " << row << ' ' << context + << " mismatch: " << std::setprecision(16) << actual + << " != " << expected << '\n'; + return false; + } - data.parameters[Params::mva] = static_cast(100.0); - data.parameters[Params::PfFlag] = static_cast(0); - data.parameters[Params::VFlag] = static_cast(1); - data.parameters[Params::QFlag] = static_cast(0); - data.parameters[Params::Pqflag] = static_cast(1); - data.parameters[Params::Trv] = static_cast(0.0); - data.parameters[Params::Tp] = static_cast(0.02); - data.parameters[Params::Vdip] = static_cast(0.7); - data.parameters[Params::Vup] = static_cast(1.2); - data.parameters[Params::dbd1] = static_cast(-0.01); - data.parameters[Params::dbd2] = static_cast(0.01); - data.parameters[Params::kqv] = static_cast(0.0); - data.parameters[Params::Iql1] = static_cast(-1.0); - data.parameters[Params::Iqh1] = static_cast(1.0); - data.parameters[Params::Qmax] = static_cast(1.0); - data.parameters[Params::Qmin] = static_cast(-1.0); - data.parameters[Params::Kqp] = static_cast(1.0); - data.parameters[Params::Kqi] = static_cast(0.0); - data.parameters[Params::Vmax] = static_cast(1.2); - data.parameters[Params::Vmin] = static_cast(0.8); - data.parameters[Params::Kvp] = static_cast(1.0); - data.parameters[Params::Kvi] = static_cast(0.0); - data.parameters[Params::Tiq] = static_cast(0.02); - data.parameters[Params::Tpord] = static_cast(0.02); - data.parameters[Params::dPmax] = static_cast(1.0); - data.parameters[Params::dPmin] = static_cast(-1.0); - data.parameters[Params::Pmax] = static_cast(1.0); - data.parameters[Params::Pmin] = static_cast(0.0); - data.parameters[Params::Imax] = static_cast(2.0); + /// Check selected rows of a model vector against expected values. + template + bool rowsMatch(const VectorT& vector, + const Row* rows, + size_t count, + const char* what, + const char* context) const + { + bool success = true; + const auto* values = vector.getData(); + for (size_t i = 0; i < count; ++i) + { + const auto& [row, expected] = rows[i]; + success &= rowMatches(static_cast(values[row]), expected, what, row, context); + } + return success; + } - return data; + bool residualsMatch(const ReecbT& reecb, Rows rows, const char* context = "") const + { + return rowsMatch(reecb.getResidual(), rows.begin(), rows.size(), "residual", context); + } + + template + bool residualsMatch(const ReecbT& reecb, + const std::array& rows, + const char* context = "") const + { + return rowsMatch(reecb.getResidual(), rows.data(), size, "residual", context); + } + + bool stateMatches(const ReecbT& reecb, Rows rows, const char* context = "") const + { + return rowsMatch(reecb.y(), rows.begin(), rows.size(), "state", context); + } + + template + bool stateMatches(const ReecbT& reecb, + const std::array& rows, + const char* context = "") const + { + return rowsMatch(reecb.y(), rows.data(), size, "state", context); + } + + /// The model sits at a steady state: every residual and every derivative + /// is zero. + bool allResidualsZero(const ReecbT& reecb) const + { + bool success = true; + const auto* f = reecb.getResidual().getData(); + const auto* yp = reecb.yp().getData(); + for (size_t row = 0; row < static_cast(reecb.getResidual().getSize()); ++row) + { + success &= rowMatches(static_cast(f[row]), 0.0, "residual", row, "at rest"); + success &= rowMatches(static_cast(yp[row]), 0.0, "derivative", row, "at rest"); + } + return success; } + + bool scalarMatches(ScalarT actual, + ScalarT expected, + const char* label, + ScalarT tolerance = kBehaviorTol) const + { + if (isEqual(actual, expected, tolerance)) + { + return true; + } + std::cout << label << " mismatch: " << std::setprecision(16) << actual + << " != " << expected << "\n"; + return false; + } + + void noteExpectedLogs(const char* message) const + { + const auto previous_verbosity = Log::verbosity(); + Log::setVerbosity(Log::Verbosity::EVERYTHING); + Log::misc() << message << "\n"; + Log::setVerbosity(previous_verbosity); + } + +#ifdef GRIDKIT_ENABLE_ENZYME + void numberVariables(Fixture& fixture) const + { + auto* y = fixture.reecb.y().getData(); + auto* yp = fixture.reecb.yp().getData(); + auto* bus_y = fixture.bus.y().getData(); + + const auto model_size = static_cast(fixture.reecb.size()); + for (size_t i = 0; i < model_size; ++i) + { + y[i].setVariableNumber(i); + yp[i].setVariableNumber(i); + } + for (size_t i = 0; i < static_cast(fixture.bus.size()); ++i) + { + bus_y[i].setVariableNumber(model_size + i); + } + for (size_t port = 0; port < E::MAXIMUM; ++port) + { + fixture.input(port).setVariableNumber(fixture.inputIndex(port)); + } + + fixture.reecb.y().setDataUpdated(); + fixture.reecb.yp().setDataUpdated(); + fixture.bus.y().setDataUpdated(); + } + + std::vector dependencyTrackingJacobian( + const Data& data, + TestStatus& success) const + { + using DepVar = DependencyTracking::Variable; + + Fixture fixture(data, kStateVr, kStateVi); + fixture.attachAllInputs(); + success *= fixture.initialize(0.1, 0.2); + setAnswerKeyInputs(fixture); + setAnswerKeyState(fixture.reecb); + numberVariables(fixture); + success *= (fixture.evaluate() == 0); + + const auto model_size = static_cast(fixture.reecb.size()); + std::vector rows(model_size); + const auto* f = fixture.reecb.getResidual().getData(); + for (size_t i = 0; i < model_size; ++i) + { + rows[i] = f[i].getDependencies(); + } + return rows; + } + + std::vector enzymeJacobian( + const Data& data, + TestStatus& success) const + { + Fixture fixture(data, kStateVr, kStateVi); + fixture.attachAllInputs(); + success *= fixture.initialize(0.1, 0.2); + + for (IdxT i = 0; i < fixture.bus.size(); ++i) + { + fixture.bus.setVariableIndex(i, fixture.reecb.size() + i); + } + + setAnswerKeyInputs(fixture); + setAnswerKeyState(fixture.reecb); + fixture.reecb.updateTime(0.0, 1.0); + success *= (fixture.evaluate() == 0); + success *= (fixture.reecb.evaluateJacobian() == 0); + success *= (fixture.reecb.constructCsr() == 0); + return MapFromCsr(fixture.reecb.getCsrJacobian()); + } +#endif }; } // namespace Testing } // namespace GridKit diff --git a/tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp b/tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp index 90c935e34..8897d76a5 100644 --- a/tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp @@ -7,19 +7,15 @@ int main() GridKit::Testing::ConverterReecbTests test; result += test.validation(); - result += test.signals(); - result += test.publishRefs(); - result += test.baseSignals(); - result += test.feedbackBase(); - result += test.zeroTime(); - result += test.qPriority(); - result += test.pPriority(); - result += test.voltageBand(); - result += test.piSaturation(); + result += test.initializationAndSignals(); + result += test.initializationDomain(); + result += test.residualEquations(); + result += test.reactiveControl(); + result += test.activePowerControl(); + result += test.currentPriority(); #ifdef GRIDKIT_ENABLE_ENZYME result += test.jacobian(); #endif - result += test.json(); return result.summary(); } diff --git a/tests/UnitTests/Utilities/CaseFormatTests.hpp b/tests/UnitTests/Utilities/CaseFormatTests.hpp index e4f46bcc4..ad99de106 100644 --- a/tests/UnitTests/Utilities/CaseFormatTests.hpp +++ b/tests/UnitTests/Utilities/CaseFormatTests.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +40,7 @@ namespace GridKit using BusData = BusData; using BusType = typename BusData::BusType; using RegcaData = Converter::RegcaData; + using ReecbData = Converter::ReecbData; const char data[] = R"({ @@ -73,8 +75,9 @@ namespace GridKit "Tqop":0.75, "Xd":2.1, "Xdp":0.2, "Xdpp":0.18, "Xq":0.5, "Xqp": 0.0, "Xqpp":0.18, "Xl":0.15, "S10":0.0, "S12":0.0}, "mon": ["delta", "omega"] }, { "class": "Gensal", "ports": {"bus":1}, "id": "2", "params": {"p0":1.0, "q0":0.05013, "H":3.0, "D":0.0, "Ra":0.0, "Tdop":7.0, "Tdopp":0.04, "Tqopp":0.05, "Xd":2.1, "Xdp":0.2, "Xdpp":0.18, "Xq":0.5, "Xl":0.15, "S10":0.0, "S12":0.0}, "mon": ["delta", "omega"] }, - { "class": "BusFault", "ports": {"bus":1}, "id": "1", "params": {"state0": false, "R":0.0, "X":1e-3} }, - { "class": "Regca", "ports": {"bus":1}, "id": "CV1", "params": {"p0":0.0, "q0":0.0, "mva":100, "Tg":0.02, "TM":0.02, "Rqmax":999.0, "Rqmin":-999.0, "Rpmax":999.0, "sL":true, "IL1":1.1, "VL0":0.4, "VL1":0.9, "VA0":0.4, "VA1":0.9, "Vhvmax":1.2}, "mon": ["ir", "ii", "p", "q"] } + { "class": "Regca", "ports": {"bus":1}, "id": "CV1", "params": {"p0":0.0, "q0":0.0, "mva":100, "Tg":0.02, "TM":0.02, "Rqmax":999.0, "Rqmin":-999.0, "Rpmax":999.0, "sL":true, "IL1":1.1, "VL0":0.4, "VL1":0.9, "VA0":0.4, "VA1":0.9, "Vhvmax":1.2}, "mon": ["ir", "ii", "p", "q"] }, + { "class": "Reecb", "ports": {"bus":1}, "id": "REE1", "params": {"mva":50.0, "Pqflag":1}, "mon": ["iqcmd", "pmeas"] }, + { "class": "BusFault", "ports": {"bus":1}, "id": "1", "params": {"state0": false, "R":0.0, "X":1e-3} } ] })"; @@ -101,6 +104,7 @@ namespace GridKit success *= result.genrou.size() == 1; success *= result.gensal.size() == 1; success *= result.regca.size() == 1; + success *= result.reecb.size() == 1; success *= result.loadz.size() == 0; success *= result.bus[0].bus_id == 1; @@ -176,6 +180,14 @@ namespace GridKit success *= result.regca[0].monitored_variables.contains(RegcaData::MonitorableVariables::ii); success *= result.regca[0].monitored_variables.contains(RegcaData::MonitorableVariables::p); success *= result.regca[0].monitored_variables.contains(RegcaData::MonitorableVariables::q); + success *= std::get(result.reecb[0].parameters[ReecbData::Parameters::mva]) == 50.0; + success *= std::get(result.reecb[0].parameters[ReecbData::Parameters::Pqflag]) == 1; + success *= result.reecb[0].buses[ReecbData::Buses::bus] == 1; + success *= result.reecb[0].disambiguation_string == "REE1"; + success *= result.reecb[0].monitored_variables.contains( + ReecbData::MonitorableVariables::iqcmd); + success *= result.reecb[0].monitored_variables.contains( + ReecbData::MonitorableVariables::pmeas); success *= std::get(result.bus_fault[0].parameters[BusFaultParameters::R]) == 0.0; success *= std::get(result.bus_fault[0].parameters[BusFaultParameters::X]) == 1e-3; @@ -233,7 +245,14 @@ namespace GridKit { "signal_id": 18, "name": "Reactive Power Reference"}, { "signal_id": 19, "name": "Frequency Reference"}, { "signal_id": 20, "name": "Reactive Power Command"}, - { "signal_id": 21, "name": "Active Power Command"} + { "signal_id": 21, "name": "Active Power Command"}, + { "signal_id": 22, "name": "Electrical Power"}, + { "signal_id": 23, "name": "Reactive Power"}, + { "signal_id": 24, "name": "Reactive Reference"}, + { "signal_id": 25, "name": "Power Factor Reference"}, + { "signal_id": 26, "name": "Active Power Reference"}, + { "signal_id": 27, "name": "Reactive Current Command"}, + { "signal_id": 28, "name": "Active Current Command"} ], "devices": [ { "class": "Branch", "ports": {"bus1":1, "bus2":2}, "id": "BR1", "params": {"R":0.0, "X":0.1, "G":0.0, "B":0.0, "tap":1.05, "phase":0.1} }, @@ -247,6 +266,7 @@ namespace GridKit { "class": "Repca", "ports": {"bus":1, "ir":11, "ii":12, "p":13, "q":14, "freq":15, "vref":16, "pref":17, "qref":18, "freqref":19, "qext":20, "pext":21}, "id": "PC1", "params": {"mva":50, "VcompFlag":false, "RefFlag":true, "Freqflag":true, "Tfltr":0.2, "Vfrz":0.65, "Rc":0.02, "Xc":0.03, "Kc":0.4, "dbdlow":-0.02, "dbdupper":0.03, "emax":0.8, "emin":-0.7, "Kp":2.0, "Ki":3.0, "Qmax":0.9, "Qmin":-0.8, "Tft":0.2, "Tfv":1.5, "Tp":0.4, "fdbd1":-0.01, "fdbd2":0.015, "Ddn":2.0, "Dup":1.0, "femax":0.6, "femin":-0.5, "Kpg":1.7, "Kig":1.8, "Pmax":1.2, "Pmin":0.1, "Tlag":0.5}, "mon": ["qext", "pext", "vmeas", "qmeas", "pmeas"] }, { "class": "Ieeet1", "ports": {"bus":1, "speed": 1, "efd":3}, "id": "DV3", "params": {"Tr":0.0, "Ka":50.0, "Ta":0.04, "Ke":-0.06, "Te":0.6, "Kf":0.09, "Tf":1.46, "Vrmin":-1.0, "Vrmax":1.0, "E1":2.8, "E2":3.373, "Se1":0.04, "Se2":0.33, "Ispdlim":0.0}}, { "class": "SexsPti", "ports": {"bus":1, "efd":3}, "id": "DV4", "params": {"Ta":0.1, "Tb":0.5, "Te":0.8, "K":10.0, "Efdmax":5.0, "Efdmin":-5.0}}, + { "class": "Reecb", "ports": {"bus":1, "pe":22, "qgen":23, "qext":24, "pfaref":25, "pref":26, "iqcmd":27, "ipcmd":28}, "id": "EC1", "params": {"mva":100.0}}, { "class": "BusFault", "ports": {"bus":1}, "id": "1", "params": {"state0": false, "R":0.0, "X":1e-3} } ] })"; @@ -274,7 +294,8 @@ namespace GridKit success *= result.loadz.size() == 0; success *= result.exciter.size() == 1; success *= result.sexspti.size() == 1; - success *= result.signal.size() == 20; + success *= result.reecb.size() == 1; + success *= result.signal.size() == 27; success *= result.bus[0].bus_id == 1; success *= result.bus[0].bus_type == BusType::DEFAULT; @@ -310,6 +331,8 @@ namespace GridKit success *= result.signal[7].name == "Governor Load Reference"; success *= result.signal[8].signal_id == 9; success *= result.signal[8].name == "Governor Auxiliary Power"; + success *= result.signal[26].signal_id == 28; + success *= result.signal[26].name == "Active Current Command"; success *= std::get(result.branch[0].parameters[BranchParameters::R]) == 0.0; success *= std::get(result.branch[0].parameters[BranchParameters::X]) == 0.1; @@ -513,6 +536,17 @@ namespace GridKit success *= result.sexspti[0].signal_outputs[Exciter::SexsPtiSignalOutputs::efd] == 3; success *= result.sexspti[0].disambiguation_string == "DV4"; + using ReecbData = Converter::ReecbData; + success *= result.reecb[0].buses[ReecbData::Buses::bus] == 1; + success *= result.reecb[0].signal_inputs[ReecbData::SignalInputs::pe] == 22; + success *= result.reecb[0].signal_inputs[ReecbData::SignalInputs::qgen] == 23; + success *= result.reecb[0].signal_inputs[ReecbData::SignalInputs::qext] == 24; + success *= result.reecb[0].signal_inputs[ReecbData::SignalInputs::pfaref] == 25; + success *= result.reecb[0].signal_inputs[ReecbData::SignalInputs::pref] == 26; + success *= result.reecb[0].signal_outputs[ReecbData::SignalOutputs::iqcmd] == 27; + success *= result.reecb[0].signal_outputs[ReecbData::SignalOutputs::ipcmd] == 28; + success *= result.reecb[0].disambiguation_string == "EC1"; + success *= std::get(result.bus_fault[0].parameters[BusFaultParameters::R]) == 0.0; success *= std::get(result.bus_fault[0].parameters[BusFaultParameters::X]) == 1e-3; success *= !std::get(result.bus_fault[0].parameters[BusFaultParameters::state0]); From f196fac4ea68985522288d46325eda85b7853246 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Thu, 30 Jul 2026 12:00:14 -0500 Subject: [PATCH 03/16] Polish and clean to align with REGCA impl --- CHANGELOG.md | 2 +- GridKit/CommonMath.md | 4 +- .../PhasorDynamics/Converter/REECB/README.md | 60 +- .../PhasorDynamics/Converter/REECB/Reecb.cpp | 3 + .../PhasorDynamics/Converter/REECB/Reecb.hpp | 212 +++---- .../Converter/REECB/ReecbData.hpp | 96 +-- .../REECB/ReecbDependencyTracking.cpp | 5 + .../Converter/REECB/ReecbEnzyme.cpp | 121 ++-- .../Converter/REECB/ReecbImpl.hpp | 557 ++++++++++------- GridKit/Model/PhasorDynamics/INPUT_FORMAT.md | 2 +- .../Model/PhasorDynamics/SystemModelImpl.hpp | 40 +- .../PhasorDynamics/ConverterReecbTests.hpp | 584 ++++++++++++------ .../SystemSingleComponentTests.hpp | 152 +++++ .../runSystemSingleComponentTests.cpp | 1 + 14 files changed, 1166 insertions(+), 673 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e224714f..4141999ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,7 +78,7 @@ - Remove unnecessary data copying while evaluating `PowerElectronics` models, speeding up large simulations by up to 3x - Added `HYGOV` governor model implementation for PhasorDynamics. - Added `REPCA` controller model implementation for PhasorDynamics. -- Added `REECB` controller model for PhasorDynamics. +- Added `REECB` electrical-control model implementation for PhasorDynamics. ## v0.1 diff --git a/GridKit/CommonMath.md b/GridKit/CommonMath.md index 74e79289e..33b9f073f 100644 --- a/GridKit/CommonMath.md +++ b/GridKit/CommonMath.md @@ -67,8 +67,8 @@ q(x)=x^2\,\sigma(x) | `linseg` | Saturated linear segment contribution | `REGCA`, `REECA` | | `above` | Above-lower-limit indicator | `REPCA` | | `below` | Below-upper-limit indicator | - | -| `inside` | Interior pulse indicator | - | -| `outside` | Outside-band indicator | `REECA`, `REECB` | +| `inside` | Interior pulse indicator | `REECB` | +| `outside` | Outside-band indicator | `REECA` | | `antiwindup` | Anti-windup limited derivative | `IEEET1`, `SEXS-PTI`, `TGOV1`, `REECA`, `REECB`, `REPCA` | ### `max` diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/README.md b/GridKit/Model/PhasorDynamics/Converter/REECB/README.md index a23f748e2..ccc8e4170 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/README.md +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/README.md @@ -8,6 +8,9 @@ resources. - REECB is a control model only. It measures the terminal bus and publishes current commands; it injects no current into the network. - When used with REPCA active-power control, connect REPCA `pext` to REECB `pref`. +- Internal power/current states and limiter quantities are on component base. +- Power/current signal ports and the `iqcmd`/`ipcmd` outputs are on system base; + `pmeas` is monitored on component base. ## Block Diagram @@ -51,9 +54,14 @@ $P^{\max}$ | [p.u.] | `Pmax` | Maximum active-power $P^{\min}$ | [p.u.] | `Pmin` | Minimum active-power order limit | 0.0 | $I^{\max}$ | [p.u.] | `Imax` | Maximum total converter current | 1.3 | +Only `mva` is required. Every omitted control parameter uses the Typical Value +shown above; when `Vref0` is omitted, it is initialized from terminal voltage. + ### Parameter Validation -Invalid REECB parameter sets are rejected by the following checks: +Invalid REECB parameter sets are rejected by the following checks. Nonnegative +time constants below $\epsilon_T=10^{-3}\ \mathrm{s}$ are raised to +$\epsilon_T$ and logged as a warning. ```math \begin{aligned} @@ -83,13 +91,12 @@ Invalid REECB parameter sets are rejected by the following checks: ### Model Derived Parameters -Let $\epsilon_T=10^{-3}\ \mathrm{s}$. A time constant below $\epsilon_T$ is -raised to that floor in place, so every equation below uses the raised value: +Every equation below uses the raised time constants: ```math \begin{aligned} T_x - &\leftarrow \max\!\left(T_x,\epsilon_T\right), + &\leftarrow \text{max}\left(T_x,\epsilon_T\right), \quad x\in\{\mathrm{rv},\mathrm{p},\mathrm{iq},\mathrm{pord}\} \\ s_{\mathrm{pf}}^\mathrm{off} &= 1 - s_{\mathrm{pf}} \\ @@ -307,13 +314,13 @@ target and smooth approximation. + s_Q^\mathrm{off}Q_V + \left(1-s_{\mathrm{dip}}\right)I_q^\mathrm{inj} \\ 0 &= - -I_q^\mathrm{cmd} - + \dfrac{1}{k_\mathrm{base}}\text{clamp} + -k_\mathrm{base} I_q^\mathrm{cmd} + + \text{clamp} \left(I_q^\mathrm{raw};\, -I_q^{\max}, I_q^{\max}\right) \\ 0 &= - -I_p^\mathrm{cmd} - + \dfrac{1}{k_\mathrm{base}}\text{clamp} + -k_\mathrm{base} I_p^\mathrm{cmd} + + \text{clamp} \left( \dfrac{P^\mathrm{ord}}{V_{\mathrm{safe}}^\mathrm{meas}};\, 0,\, @@ -354,7 +361,25 @@ the limiter *input*, not its output. With initialization tolerance $\epsilon_0=10^{-10}$, $\text{clamp}^{-1}(z;\ell,u)$ is the input producing output $z$, and $u_0^\mathrm{aw}(a,f;\ell,u)$ the input holding an anti-windup path stationary: $a$ when $|f|\le\epsilon_0$, else just past the limit $f$ -drives toward. Both reject $z$ outside $[\ell,u]$. +drives toward. The inverse clamp rejects a requested output outside +$[\ell,u]$; the anti-windup initializer always returns a stationary input. + +Initialization rejects an operating point when any of the following holds: + +- the bus voltage or either command seed is not finite; +- $I_p^\mathrm{seed}<0$; +- the command seeds leave the $I^{\max}$ circle, or a selected priority-circle + radicand is less than $-\epsilon_0$; +- the physical active-power target + $V_{\mathrm{safe},0}^\mathrm{meas}I_p^\mathrm{seed}$ lies outside + $[P^{\min},P^{\max}]$ by more than $\epsilon_0$; +- a required current, ramp-rate, reactive-power, voltage, or controller output + has no limiter input on its selected limits; or +- $s_{\mathrm{pf}}=1$, $|P_0^\mathrm{meas}|\le\epsilon_0$, and + $|Q_0^\mathrm{ref}|>\epsilon_0$. + +Every check resolves before any storage is written, so a rejected +initialization leaves state, command nodes, and external signals unchanged. Subscript $0$ denotes initial values; all internal derivatives start at zero: @@ -508,23 +533,6 @@ The $s_Q=1$ path initializes the voltage PI output to reproduce $I_{q,0}^\mathrm{control}$. The $s_Q=0$ path instead carries that target in $Q_{V,0}$ and parks the otherwise inactive voltage PI path consistently. -Initialization rejects an operating point when any of the following holds: - -- the bus voltage or either command seed is not finite; -- $I_p^\mathrm{seed}<0$; -- the command seeds leave the $I^{\max}$ circle, or a selected priority-circle - radicand is less than $-\epsilon_0$; -- the physical active-power target - $V_{\mathrm{safe},0}^\mathrm{meas}I_p^\mathrm{seed}$ lies outside - $[P^{\min},P^{\max}]$ by more than $\epsilon_0$; -- a required current, ramp-rate, reactive-power, voltage, or controller output - has no limiter input on its selected limits; or -- $s_{\mathrm{pf}}=1$, $|P_0^\mathrm{meas}|\le\epsilon_0$, and - $|Q_0^\mathrm{ref}|>\epsilon_0$. - -Every check resolves before any storage is written, so a rejected -initialization leaves state, command nodes, and external signals unchanged. - ### Output Initialization ```math diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.cpp b/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.cpp index 3b6cacabf..64f9f08b7 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.cpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.cpp @@ -12,6 +12,9 @@ namespace GridKit { namespace Converter { + /** + * @brief Report that a separate Jacobian is unavailable in the plain build. + */ template int Reecb::evaluateJacobian() { diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp b/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp index b5c994d33..9cdf2dc3b 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp @@ -27,98 +27,62 @@ namespace GridKit namespace Converter { - /// Internal variables of a `Reecb`. + /// Internal variables of a `Reecb` enum class ReecbInternalVariables : size_t { - VMEAS, ///< Filtered terminal voltage - PMEAS, ///< Filtered electrical power - XPIQ, ///< Reactive-power PI state - XPIV, ///< Voltage PI state - QV, ///< Reactive-current command lag state - PORD, ///< Filtered active-power order - VT, ///< Terminal voltage magnitude - VMEASSAFE, ///< Safe filtered terminal voltage - SDIP, ///< Voltage inside-band control gate - VERR, ///< Deadbanded voltage error - IQV, ///< Reactive-current injection candidate - QREF, ///< Selected reactive-power reference - EQ, ///< Reactive-power control error - VPIQ, ///< Reactive-power PI output - EPIV, ///< Voltage-control PI error - FPORD, ///< Active-power order derivative before ramp limiting - RPORD, ///< Ramp-rate-limited active-power derivative - IQCIRC, ///< Reactive-current limit from current circle - IPCIRC, ///< Active-current limit from current circle - IQMAX, ///< Final reactive-current upper limit - IPMAX, ///< Final active-current upper limit - IQBASE, ///< Base reactive-current command - IQRAW, ///< Raw reactive-current command - IQCMD, ///< Reactive-current command output - IPCMD, ///< Active-current command output + VMEAS, ///< \f$V^\mathrm{meas}\f$ Filtered terminal voltage + PMEAS, ///< \f$P^\mathrm{meas}\f$ Filtered active power on component base + XPIQ, ///< \f$x_Q^\mathrm{PI}\f$ Reactive-power PI state + XPIV, ///< \f$x_V^\mathrm{PI}\f$ Voltage-control PI state + QV, ///< \f$Q_V\f$ Reactive-current command lag state + PORD, ///< \f$P^\mathrm{ord}\f$ Filtered active-power order + VT, ///< \f$V_T\f$ Terminal voltage magnitude + VMEASSAFE, ///< \f$V_\mathrm{safe}^\mathrm{meas}\f$ Safe filtered voltage + SDIP, ///< \f$s_\mathrm{dip}\f$ Voltage inside-band control gate + VERR, ///< \f$e_V^\mathrm{db}\f$ Deadbanded voltage error + IQV, ///< \f$I_q^\mathrm{inj}\f$ Reactive-current injection candidate + QREF, ///< \f$Q^\mathrm{ref}\f$ Selected reactive-power reference + EQ, ///< \f$e_Q\f$ Reactive-power control error + VPIQ, ///< \f$V_Q^\mathrm{PI}\f$ Reactive-power PI output + EPIV, ///< \f$e_V^\mathrm{PI}\f$ Voltage-control PI error + FPORD, ///< \f$f_P^\mathrm{ord}\f$ Active-power derivative target + RPORD, ///< \f$r_P^\mathrm{ord}\f$ Ramp-limited active-power derivative + IQCIRC, ///< \f$I_q^\mathrm{circ}\f$ Reactive-current circle limit + IPCIRC, ///< \f$I_p^\mathrm{circ}\f$ Active-current circle limit + IQMAX, ///< \f$I_q^\max\f$ Reactive-current upper limit + IPMAX, ///< \f$I_p^\max\f$ Active-current upper limit + IQBASE, ///< \f$I_q^\mathrm{base}\f$ Base reactive-current command + IQRAW, ///< \f$I_q^\mathrm{raw}\f$ Raw reactive-current command + IQCMD, ///< \f$I_q^\mathrm{cmd}\f$ Command output on system base + IPCMD, ///< \f$I_p^\mathrm{cmd}\f$ Command output on system base MAXIMUM, }; - /// External variables of a `Reecb`. + /// External variables of a `Reecb` enum class ReecbExternalVariables : size_t { - PE, ///< Electrical active-power signal - QGEN, ///< Reactive-power signal - QEXT, ///< External reactive-power command - PFAREF, ///< Power-factor angle reference - PREF, ///< External active-power reference + PE, ///< \f$P_e\f$ Active-power feedback on system base + QGEN, ///< \f$Q^\mathrm{gen}\f$ Reactive-power feedback on system base + QEXT, ///< \f$Q^\mathrm{ext}\f$ Reactive-power command on system base + PFAREF, ///< \f$\phi^\mathrm{ref}\f$ Power-factor angle reference in radians + PREF, ///< \f$P^\mathrm{ref}\f$ Active-power reference on system base MAXIMUM, }; - /// Indices into the REECB state, derivative, and residual vectors. - struct ReecbIdx - { - static constexpr size_t VMEAS = static_cast(ReecbInternalVariables::VMEAS); - static constexpr size_t PMEAS = static_cast(ReecbInternalVariables::PMEAS); - static constexpr size_t XPIQ = static_cast(ReecbInternalVariables::XPIQ); - static constexpr size_t XPIV = static_cast(ReecbInternalVariables::XPIV); - static constexpr size_t QV = static_cast(ReecbInternalVariables::QV); - static constexpr size_t PORD = static_cast(ReecbInternalVariables::PORD); - static constexpr size_t VT = static_cast(ReecbInternalVariables::VT); - static constexpr size_t VMEASSAFE = static_cast(ReecbInternalVariables::VMEASSAFE); - static constexpr size_t SDIP = static_cast(ReecbInternalVariables::SDIP); - static constexpr size_t VERR = static_cast(ReecbInternalVariables::VERR); - static constexpr size_t IQV = static_cast(ReecbInternalVariables::IQV); - static constexpr size_t QREF = static_cast(ReecbInternalVariables::QREF); - static constexpr size_t EQ = static_cast(ReecbInternalVariables::EQ); - static constexpr size_t VPIQ = static_cast(ReecbInternalVariables::VPIQ); - static constexpr size_t EPIV = static_cast(ReecbInternalVariables::EPIV); - static constexpr size_t FPORD = static_cast(ReecbInternalVariables::FPORD); - static constexpr size_t RPORD = static_cast(ReecbInternalVariables::RPORD); - static constexpr size_t IQCIRC = static_cast(ReecbInternalVariables::IQCIRC); - static constexpr size_t IPCIRC = static_cast(ReecbInternalVariables::IPCIRC); - static constexpr size_t IQMAX = static_cast(ReecbInternalVariables::IQMAX); - static constexpr size_t IPMAX = static_cast(ReecbInternalVariables::IPMAX); - static constexpr size_t IQBASE = static_cast(ReecbInternalVariables::IQBASE); - static constexpr size_t IQRAW = static_cast(ReecbInternalVariables::IQRAW); - static constexpr size_t IQCMD = static_cast(ReecbInternalVariables::IQCMD); - static constexpr size_t IPCMD = static_cast(ReecbInternalVariables::IPCMD); - static constexpr size_t MAXIMUM = static_cast(ReecbInternalVariables::MAXIMUM); - }; - - /// Indices into the REECB external-signal buffers. - struct ReecbExt - { - static constexpr size_t PE = static_cast(ReecbExternalVariables::PE); - static constexpr size_t QGEN = static_cast(ReecbExternalVariables::QGEN); - static constexpr size_t QEXT = static_cast(ReecbExternalVariables::QEXT); - static constexpr size_t PFAREF = static_cast(ReecbExternalVariables::PFAREF); - static constexpr size_t PREF = static_cast(ReecbExternalVariables::PREF); - static constexpr size_t MAXIMUM = static_cast(ReecbExternalVariables::MAXIMUM); - }; - + /** + * @brief Second-generation WECC renewable electrical-control model (REECB). + * + * @tparam scalar_type Plain real or differentiable scalar type. + * @tparam index_type Integer index type. + */ template class Reecb : public Component { + using Component::gridkit_component_id_; using Component::alpha_; - using Component::abs_tol_; using Component::allocated_; + using Component::abs_tol_; using Component::f_; - using Component::gridkit_component_id_; using Component::J_cols_buffer_; using Component::J_rows_buffer_; using Component::J_vals_buffer_; @@ -133,24 +97,26 @@ namespace GridKit using Component::yp_; public: - using ScalarT = scalar_type; - using IdxT = index_type; - using RealT = typename Component::RealT; - using BusT = BusBase; - using SignalT = SignalNode; - using ModelDataT = ReecbData; - using MonitorT = Model::VariableMonitor; + using ScalarT = scalar_type; + using IdxT = index_type; + using RealT = typename Component::RealT; + using BusT = BusBase; + using SignalT = SignalNode; + using ModelDataT = ReecbData; + using MonitorT = Model::VariableMonitor; + using InternalVariablesT = ReecbInternalVariables; + using ExternalVariablesT = ReecbExternalVariables; Reecb(BusT* bus); Reecb(BusT* bus, const ModelDataT& data); ~Reecb(); - int setGridKitComponentID(IdxT) override final; + int setGridKitComponentID(IdxT component_id) override final; int allocate() override final; int verify() const override final; int initialize() override final; int tagDifferentiable() override final; - int setAbsoluteTolerance(RealT) override final; + int setAbsoluteTolerance(RealT rel_tol) override final; int evaluateResidual() override final; int evaluateJacobian() override final; @@ -166,24 +132,23 @@ namespace GridKit const Model::VariableMonitorBase* getMonitor() const override; __attribute__((always_inline)) inline int evaluateInternalResidual( - const ScalarT*, const ScalarT*, const ScalarT*, const ScalarT*, ScalarT*); + const ScalarT* y, + const ScalarT* yp, + const ScalarT* wb, + const ScalarT* ws, + ScalarT* f); private: void initializeParameters(const ModelDataT& data); void initializeMonitor(); void setDerivedParameters(); - /// Solve the input required to produce a requested smooth-limiter output. - /// The limits may be constant Real parameters or algebraic variables. template bool solveLimiterInput(ScalarT requested_output, LowerT lower_limit, UpperT upper_limit, ScalarT& limiter_input) const; - /// Select a limiter input that zeros an anti-windup rate to initialization tolerance. - /// The limits may be constant Real parameters or algebraic variables. template ScalarT steadyAntiWindupInput(ScalarT nominal_input, ScalarT rate, LowerT lower_limit, UpperT upper_limit) const; - /// Evaluate log(1 - exp(-x)) without cancellation for positive x. RealT logOneMinusExp(RealT x) const; ScalarT toComponentBase(ScalarT value) const; @@ -199,36 +164,42 @@ namespace GridKit BusT* bus_{nullptr}; - RealT mva_base_{ZERO}; + // Input parameters + RealT mva_base_{0}; bool PfFlag_{false}; bool VFlag_{false}; bool QFlag_{false}; bool Pqflag_{false}; - RealT Trv_{static_cast(0.02)}; - RealT Tp_{ZERO}; - RealT Vref0_{ZERO}; - RealT Vdip_{static_cast(0.85)}; - RealT Vup_{static_cast(1.15)}; - RealT dbd1_{ZERO}; - RealT dbd2_{ZERO}; - RealT kqv_{static_cast(5.0)}; - RealT Iql1_{static_cast(-1.1)}; - RealT Iqh1_{static_cast(1.1)}; - RealT Qmax_{static_cast(0.436)}; - RealT Qmin_{static_cast(-0.436)}; - RealT Kqp_{ZERO}; - RealT Kqi_{static_cast(0.1)}; - RealT Vmax_{static_cast(1.1)}; - RealT Vmin_{static_cast(0.9)}; - RealT Kvp_{static_cast(18.0)}; - RealT Kvi_{static_cast(5.0)}; - RealT Tiq_{static_cast(0.02)}; - RealT Tpord_{static_cast(0.02)}; - RealT dPmax_{static_cast(99.0)}; - RealT dPmin_{static_cast(-99.0)}; - RealT Pmax_{ONE}; - RealT Pmin_{ZERO}; - RealT Imax_{static_cast(1.3)}; + RealT Trv_{0.02}; + RealT Tp_{0}; + RealT Vref0_{0}; + RealT Vdip_{0.85}; + RealT Vup_{1.15}; + RealT dbd1_{0}; + RealT dbd2_{0}; + RealT kqv_{5.0}; + RealT Iql1_{-1.1}; + RealT Iqh1_{1.1}; + RealT Qmax_{0.436}; + RealT Qmin_{-0.436}; + RealT Kqp_{0}; + RealT Kqi_{0.1}; + RealT Vmax_{1.1}; + RealT Vmin_{0.9}; + RealT Kvp_{18.0}; + RealT Kvi_{5.0}; + RealT Tiq_{0.02}; + RealT Tpord_{0.02}; + RealT dPmax_{99.0}; + RealT dPmin_{-99.0}; + RealT Pmax_{1}; + RealT Pmin_{0}; + RealT Imax_{1.3}; + + bool Vref0_given_{false}; + IdxT parameter_error_count_{0}; + + // Derived parameters RealT va_converter_base_{0}; RealT pf_on_{0}; RealT pf_off_{1}; @@ -239,18 +210,17 @@ namespace GridKit RealT p_priority_{0}; RealT q_priority_{1}; - bool Vref0_given_{false}; - IdxT parameter_error_count_{0}; - - ScalarT qext_set_{0}; + // Unattached signal setpoints ScalarT pe_set_{0}; ScalarT qgen_set_{0}; + ScalarT qext_set_{0}; ScalarT pfaref_set_{0}; ScalarT pref_set_{0}; ComponentSignals signals_; std::unique_ptr monitor_; + // Local copies of signal variables std::vector ws_; std::vector ws_indices_; }; diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp index c60ead105..7705ccac3 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp @@ -14,39 +14,40 @@ namespace GridKit { namespace Converter { - /// Parameter keys for the REECB electrical-control model. + /// Parameter keys for the REECB electrical-control model. `mva` is + /// required; every other key is optional and retains its documented default. enum class ReecbParameters { - mva, ///< REECB component power base - PfFlag, ///< Power-factor control flag - VFlag, ///< Voltage-control mode flag - QFlag, ///< Reactive-power control flag - Pqflag, ///< P/Q current-priority flag - Trv, ///< Voltage-measurement filter time constant - Tp, ///< Electrical-power measurement filter time constant - Vref0, ///< Outer-loop voltage reference - Vdip, ///< Low-voltage dip threshold - Vup, ///< High-voltage threshold - dbd1, ///< Lower voltage-error deadband threshold - dbd2, ///< Upper voltage-error deadband threshold - kqv, ///< Reactive-current injection gain - Iql1, ///< Minimum reactive-current injection limit - Iqh1, ///< Maximum reactive-current injection limit - Qmax, ///< Maximum reactive-power control limit - Qmin, ///< Minimum reactive-power control limit - Kqp, ///< Reactive-power proportional gain - Kqi, ///< Reactive-power integral gain - Vmax, ///< Maximum voltage-control limit - Vmin, ///< Minimum voltage-control limit - Kvp, ///< Voltage-control proportional gain - Kvi, ///< Voltage-control integral gain - Tiq, ///< Reactive-current command lag time constant - Tpord, ///< Active-power order filter time constant - dPmax, ///< Positive active-power ramp-rate limit - dPmin, ///< Negative active-power ramp-rate limit - Pmax, ///< Maximum active-power order limit - Pmin, ///< Minimum active-power order limit - Imax ///< Maximum converter current + mva, ///< \f$S^\mathrm{base}\f$ REECB component power base + PfFlag, ///< \f$s_\mathrm{pf}\f$ Power-factor control flag + VFlag, ///< \f$s_V\f$ Voltage-control mode flag + QFlag, ///< \f$s_Q\f$ Reactive-power control flag + Pqflag, ///< \f$s_{PQ}\f$ P/Q current-priority flag + Trv, ///< \f$T_\mathrm{rv}\f$ Voltage-measurement time constant + Tp, ///< \f$T_\mathrm{p}\f$ Active-power measurement time constant + Vref0, ///< \f$V_0^\mathrm{ref}\f$ Outer-loop voltage reference + Vdip, ///< \f$V_\mathrm{dip}\f$ Low-voltage threshold + Vup, ///< \f$V_\mathrm{up}\f$ High-voltage threshold + dbd1, ///< \f$D_1^\mathrm{db}\f$ Lower voltage-error deadband + dbd2, ///< \f$D_2^\mathrm{db}\f$ Upper voltage-error deadband + kqv, ///< \f$K_\mathrm{qv}\f$ Reactive-current injection gain + Iql1, ///< \f$I_{q,\mathrm{inj}}^\min\f$ Minimum injection current + Iqh1, ///< \f$I_{q,\mathrm{inj}}^\max\f$ Maximum injection current + Qmax, ///< \f$Q^\max\f$ Maximum reactive-power control limit + Qmin, ///< \f$Q^\min\f$ Minimum reactive-power control limit + Kqp, ///< \f$K_\mathrm{qp}\f$ Reactive-power proportional gain + Kqi, ///< \f$K_\mathrm{qi}\f$ Reactive-power integral gain + Vmax, ///< \f$V^\max\f$ Maximum voltage-control limit + Vmin, ///< \f$V^\min\f$ Minimum voltage-control limit + Kvp, ///< \f$K_\mathrm{vp}\f$ Voltage-control proportional gain + Kvi, ///< \f$K_\mathrm{vi}\f$ Voltage-control integral gain + Tiq, ///< \f$T_\mathrm{iq}\f$ Reactive-current command time constant + Tpord, ///< \f$T_\mathrm{pord}\f$ Active-power order time constant + dPmax, ///< \f$R_P^\max\f$ Positive active-power ramp-rate limit + dPmin, ///< \f$R_P^\min\f$ Negative active-power ramp-rate limit + Pmax, ///< \f$P^\max\f$ Maximum active-power order limit + Pmin, ///< \f$P^\min\f$ Minimum active-power order limit + Imax ///< \f$I^\max\f$ Maximum converter current }; /// Buses for the REECB electrical-control model. @@ -56,34 +57,43 @@ namespace GridKit SIZE }; - /// Signal inputs for the REECB electrical-control model. + /// Optional signal inputs for the REECB electrical-control model. enum class ReecbSignalInputs : size_t { - pe, ///< Electrical active-power signal ID - qgen, ///< Reactive-power signal ID - qext, ///< Optional reactive-power command signal ID - pfaref, ///< Optional power-factor angle reference signal ID - pref, ///< Optional active-power reference signal ID + pe, ///< Active-power feedback signal ID on system base + qgen, ///< Reactive-power feedback signal ID on system base + qext, ///< Reactive-power command signal ID on system base + pfaref, ///< Power-factor angle reference signal ID in radians + pref, ///< Active-power reference signal ID on system base SIZE }; - /// Signal outputs for the REECB electrical-control model. + /// Optional signal outputs for the REECB electrical-control model. enum class ReecbSignalOutputs : size_t { - iqcmd, ///< Reactive-current command output signal ID - ipcmd, ///< Active-current command output signal ID + iqcmd, ///< Reactive-current command signal ID on system base + ipcmd, ///< Active-current command signal ID on system base SIZE }; /// Variables available through the monitor interface. enum class ReecbMonitorableVariables { - iqcmd, ///< Reactive-current command output - ipcmd, ///< Active-current command output + iqcmd, ///< Reactive-current command on system base + ipcmd, ///< Active-current command on system base vmeas, ///< Filtered terminal voltage - pmeas ///< Filtered electrical power + pmeas ///< Filtered active power on component base }; + /** + * @brief Model data for the REECB controller: parameter values, the + * terminal bus, optional signals, and monitored-variable selections. + * + * @tparam real_type Real parameter value type. + * @tparam index_type Integer index type. + * + * @see Reecb + */ template struct ReecbData : public ComponentData int Reecb::evaluateJacobian() { diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbEnzyme.cpp b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbEnzyme.cpp index 7b4967903..4f9383687 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbEnzyme.cpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbEnzyme.cpp @@ -14,6 +14,11 @@ namespace GridKit { namespace Converter { + /** + * @brief Assemble the sparse component Jacobian with Enzyme. + * + * @pre evaluateResidual() has run at the current state. + */ template int Reecb::evaluateJacobian() { @@ -22,76 +27,82 @@ namespace GridKit if (J_rows_buffer_ == nullptr) { + // Reserve space for the dense blocks. Enzyme keeps only structural + // nonzeros for each differentiated block. auto size = static_cast(size_); auto bus_size = static_cast(bus_->size()); - auto signal_size = ws_.size(); + auto signal_size = static_cast(ws_.size()); auto buffer_size = 2 * size * size + size * bus_size + size * signal_size; J_rows_buffer_ = new IdxT[buffer_size]; J_cols_buffer_ = new IdxT[buffer_size]; J_vals_buffer_ = new RealT[buffer_size]; } - using ModelT = GridKit::PhasorDynamics::Converter::Reecb; + using ReecbT = GridKit::PhasorDynamics::Converter::Reecb; using Fn = GridKit::Enzyme::Sparse::MemberFunctions; nnz_ = 0; - GridKit::Enzyme::Sparse::DfDy::eval(this, - static_cast(f_.getSize()), - static_cast(y_.getSize()), - (this->getResidualIndices()).data(), - (this->getVariableIndices()).data(), - y_.getData(), - yp_.getData(), - wb_.data(), - ws_.data(), - J_rows_buffer_, - J_cols_buffer_, - J_vals_buffer_, - nnz_); + GridKit::Enzyme::Sparse::DfDy::eval(this, + static_cast(f_.getSize()), + static_cast(y_.getSize()), + (this->getResidualIndices()).data(), + (this->getVariableIndices()).data(), + y_.getData(), + yp_.getData(), + wb_.data(), + ws_.data(), + J_rows_buffer_, + J_cols_buffer_, + J_vals_buffer_, + nnz_); - GridKit::Enzyme::Sparse::DfDyp::eval(this, - static_cast(f_.getSize()), - static_cast(y_.getSize()), - (this->getResidualIndices()).data(), - (this->getVariableIndices()).data(), - y_.getData(), - yp_.getData(), - wb_.data(), - ws_.data(), - alpha_, - J_rows_buffer_, - J_cols_buffer_, - J_vals_buffer_, - nnz_); + GridKit::Enzyme::Sparse::DfDyp::eval(this, + static_cast(f_.getSize()), + static_cast(y_.getSize()), + (this->getResidualIndices()).data(), + (this->getVariableIndices()).data(), + y_.getData(), + yp_.getData(), + wb_.data(), + ws_.data(), + alpha_, + J_rows_buffer_, + J_cols_buffer_, + J_vals_buffer_, + nnz_); - GridKit::Enzyme::Sparse::DfDwb::eval(this, - static_cast(f_.getSize()), - static_cast(bus_->size()), - (this->getResidualIndices()).data(), - (bus_->getVariableIndices()).data(), - y_.getData(), - yp_.getData(), - wb_.data(), - ws_.data(), - J_rows_buffer_, - J_cols_buffer_, - J_vals_buffer_, - nnz_); + GridKit::Enzyme::Sparse::DfDwb::eval(this, + static_cast(f_.getSize()), + static_cast(bus_->size()), + (this->getResidualIndices()).data(), + (bus_->getVariableIndices()).data(), + y_.getData(), + yp_.getData(), + wb_.data(), + ws_.data(), + J_rows_buffer_, + J_cols_buffer_, + J_vals_buffer_, + nnz_); - GridKit::Enzyme::Sparse::DfDws::eval(this, - static_cast(f_.getSize()), - ws_.size(), - (this->getResidualIndices()).data(), - ws_indices_.data(), - y_.getData(), - yp_.getData(), - wb_.data(), - ws_.data(), - J_rows_buffer_, - J_cols_buffer_, - J_vals_buffer_, - nnz_); + GridKit::Enzyme::Sparse::DfDws::eval(this, + static_cast(f_.getSize()), + ws_.size(), + (this->getResidualIndices()).data(), + ws_indices_.data(), + y_.getData(), + yp_.getData(), + wb_.data(), + ws_.data(), + J_rows_buffer_, + J_cols_buffer_, + J_vals_buffer_, + nnz_); this->constructCoo(); return 0; diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp index 02a75d9e3..28a52ebef 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -26,7 +27,7 @@ namespace GridKit using Log = ::GridKit::Utilities::Logger; /** - * @brief Construct a REECB controller without parameters + * @brief Construct a REECB controller without parameters. * * The model is sized but left unconfigured. Every parameter keeps its * documented default, the required power base is absent, and no monitor @@ -39,11 +40,11 @@ namespace GridKit Reecb::Reecb(BusT* bus) : bus_(bus) { - size_ = static_cast(ReecbIdx::MAXIMUM); + size_ = static_cast(ReecbInternalVariables::MAXIMUM); } /** - * @brief Construct a REECB controller from model data + * @brief Construct a REECB controller from model data. * * @param[in] bus Terminal bus the controller measures. * @param[in] data Parameters and monitored-variable selections. @@ -55,7 +56,7 @@ namespace GridKit { initializeParameters(data); initializeMonitor(); - size_ = static_cast(ReecbIdx::MAXIMUM); + size_ = static_cast(ReecbInternalVariables::MAXIMUM); } template @@ -64,29 +65,7 @@ namespace GridKit } /** - * @brief Terminal-bus voltage, real component - * - * @return Reference to the bus variable. - */ - template - scalar_type& Reecb::Vr() - { - return bus_->Vr(); - } - - /** - * @brief Terminal-bus voltage, imaginary component - * - * @return Reference to the bus variable. - */ - template - scalar_type& Reecb::Vi() - { - return bus_->Vi(); - } - - /** - * @brief Resolve the parameter-derived constants and selector masks + * @brief Resolve the parameter-derived constants and selector masks. * * Raises each controller lag to the well-posedness floor, sizes the * component power base, and turns the four mode flags into complementary @@ -129,20 +108,82 @@ namespace GridKit va_converter_base_ = mva_base_ * static_cast(1.0e6); - pf_on_ = PfFlag_ ? ONE : ZERO; - v_on_ = VFlag_ ? ONE : ZERO; - q_on_ = QFlag_ ? ONE : ZERO; + pf_on_ = ZERO; + if (PfFlag_) + { + pf_on_ = ONE; + } + + v_on_ = ZERO; + if (VFlag_) + { + v_on_ = ONE; + } + + q_on_ = ZERO; + if (QFlag_) + { + q_on_ = ONE; + } pf_off_ = ONE - pf_on_; v_off_ = ONE - v_on_; q_off_ = ONE - q_on_; - p_priority_ = Pqflag_ ? ONE : ZERO; + p_priority_ = ZERO; + if (Pqflag_) + { + p_priority_ = ONE; + } q_priority_ = ONE - p_priority_; } /** - * @brief Evaluate log(1 - exp(-x)) without cancellation + * @brief Convert a system-base power or current to REECB component base. + * + * @param[in] value Quantity on the system base. + * @return The same quantity on the component base. + */ + template + scalar_type Reecb::toComponentBase( + scalar_type value) const + { + return value * va_system_base_ / va_converter_base_; + } + + /** + * @brief Convert a component-base power or current to the system base. + * + * @param[in] value Quantity on the component base. + * @return The same quantity on the system base. + */ + template + scalar_type Reecb::toSystemBase( + scalar_type value) const + { + return value / toComponentBase(static_cast(ONE)); + } + + /** + * @brief Access the terminal-bus real voltage component. + */ + template + scalar_type& Reecb::Vr() + { + return bus_->Vr(); + } + + /** + * @brief Access the terminal-bus imaginary voltage component. + */ + template + scalar_type& Reecb::Vi() + { + return bus_->Vi(); + } + + /** + * @brief Evaluate log(1 - exp(-x)) without cancellation. * * Both terms approach one for a small argument, so the direct form loses * precision exactly where the limiter inversions below need it. The @@ -155,18 +196,18 @@ namespace GridKit typename Reecb::RealT Reecb::logOneMinusExp(RealT x) const { - static constexpr RealT LOG_TWO = static_cast(0.6931471805599453); + static constexpr RealT log_two = std::numbers::ln2_v; - if (x < LOG_TWO) + if (x < log_two) { - return LOG_TWO - HALF * x + return log_two - HALF * x + std::log(std::sinh(HALF * x)); } return std::log1p(-std::exp(-x)); } /** - * @brief Recover the input that a smooth clamp maps to a requested output + * @brief Recover the input that a smooth clamp maps to a requested output. * * Initialization uses the same smooth CommonMath clamp as the residual, * so a steady state must be seeded with the limiter *input* rather than @@ -238,7 +279,7 @@ namespace GridKit } /** - * @brief Choose a PI input whose anti-windup derivative is stationary + * @brief Choose a PI input whose anti-windup derivative is stationary. * * An anti-windup integrator is at rest either because its rate is zero * or because the rate pushes into a limit that blocks it. A nonzero rate @@ -282,33 +323,7 @@ namespace GridKit } /** - * @brief Convert a system-base power or current to REECB component base - * - * @param[in] value Quantity on the system base. - * @return The same quantity on the component base. - */ - template - scalar_type Reecb::toComponentBase( - scalar_type value) const - { - return value * va_system_base_ / va_converter_base_; - } - - /** - * @brief Convert a component-base power or current to the system base - * - * @param[in] value Quantity on the component base. - * @return The same quantity on the system base. - */ - template - scalar_type Reecb::toSystemBase( - scalar_type value) const - { - return value / toComponentBase(static_cast(ONE)); - } - - /** - * @brief Read the parameters out of the model data + * @brief Read the parameters out of the model data. * * Only the component power base is required; every other parameter keeps * the default documented in the model README when omitted. A missing @@ -417,7 +432,7 @@ namespace GridKit } /** - * @brief Access the monitor + * @brief Access the monitor. * * @return Monitor for this model, or nullptr when the model was * constructed without data. @@ -429,7 +444,7 @@ namespace GridKit } /** - * @brief Bind the monitorable variables to their internal states + * @brief Bind the monitorable variables to their internal states. * * The two current commands are published on the system base and the two * filtered measurements on the component base, as documented in the @@ -438,24 +453,27 @@ namespace GridKit template void Reecb::initializeMonitor() { - using I = ReecbIdx; using Variable = typename ModelDataT::MonitorableVariables; + constexpr auto VMEAS = static_cast(ReecbInternalVariables::VMEAS); + constexpr auto PMEAS = static_cast(ReecbInternalVariables::PMEAS); + constexpr auto IQCMD = static_cast(ReecbInternalVariables::IQCMD); + constexpr auto IPCMD = static_cast(ReecbInternalVariables::IPCMD); + monitor_->set(Variable::iqcmd, [this] - { return y_.getData()[I::IQCMD]; }); + { return y_.getData()[IQCMD]; }); monitor_->set(Variable::ipcmd, [this] - { return y_.getData()[I::IPCMD]; }); + { return y_.getData()[IPCMD]; }); monitor_->set(Variable::vmeas, [this] - { return y_.getData()[I::VMEAS]; }); + { return y_.getData()[VMEAS]; }); monitor_->set(Variable::pmeas, [this] - { return y_.getData()[I::PMEAS]; }); + { return y_.getData()[PMEAS]; }); } /** - * @brief Set the component ID + * @brief Set the component identifier assigned by the system model. * - * @param[in] component_id Identifier assigned by the system model. - * @return int 0 on success. + * @param[in] component_id Component identifier. */ template int Reecb::setGridKitComponentID(IdxT component_id) @@ -465,7 +483,7 @@ namespace GridKit } /** - * @brief Allocate the model vectors and wire the command outputs + * @brief Allocate model storage and connect assigned output signals. * * Sizes the state, residual, bus-interface, and signal-interface * buffers, seeds the identity index maps, and points each assigned @@ -473,14 +491,10 @@ namespace GridKit * REECB storage from here on, which is how initialize() reads the seeds * an upstream model wrote. Repeated calls reuse the allocated vectors. * - * @return int 0 on success. */ template int Reecb::allocate() { - using I = ReecbIdx; - using E = ReecbExt; - if (!allocated_) { this->allocateVectors(size_); @@ -493,7 +507,7 @@ namespace GridKit wb_.assign(2, ScalarT{0}); - auto signal_size = E::MAXIMUM; + auto signal_size = static_cast(ReecbExternalVariables::MAXIMUM); ws_.assign(signal_size, ScalarT{0}); ws_indices_.assign(signal_size, INVALID_INDEX); @@ -505,18 +519,21 @@ namespace GridKit auto* y = y_.getData(); + const auto IQCMD = static_cast(ReecbInternalVariables::IQCMD); + const auto IPCMD = static_cast(ReecbInternalVariables::IPCMD); + if (signals_.template isAssigned()) { signals_.template getSignalNode()->set( - &y[I::IQCMD], - &(this->getVariableIndex(static_cast(I::IQCMD)))); + &y[IQCMD], + &(this->getVariableIndex(static_cast(ReecbInternalVariables::IQCMD)))); } if (signals_.template isAssigned()) { signals_.template getSignalNode()->set( - &y[I::IPCMD], - &(this->getVariableIndex(static_cast(I::IPCMD)))); + &y[IPCMD], + &(this->getVariableIndex(static_cast(ReecbInternalVariables::IPCMD)))); } allocated_ = true; @@ -524,14 +541,14 @@ namespace GridKit } /** - * @brief Validate the REECB configuration + * @brief Validate the REECB configuration. * * Checks parameter-loading errors, static parameter relationships, * terminal-bus association, and attached external signals. Seeded * command feasibility is operating-point dependent and is checked by * initialize(). * - * @return int Number of configuration errors; zero when valid. + * @return Number of configuration errors, zero when valid. */ template int Reecb::verify() const @@ -563,30 +580,56 @@ namespace GridKit check(Pmin_ <= Pmax_, "Pmin must be less than or equal to Pmax"); check(Imax_ >= ZERO, "Imax must be non-negative"); - // An attached port must resolve to writable signal storage. The - // enumerator is a template argument, so each port names itself once. - auto check_attached_signal = - [&](const char* name) + if (signals_.template isAttached()) { - if (signals_.template isAttached() - && !signals_.template isLinked()) + if (!signals_.template isLinked()) { - Log::error() << "Reecb: " << name << " signal attached with no linked source\n"; + Log::error() << "Reecb: pe signal attached with no linked source\n"; ret += 1; } - }; + } - check_attached_signal.template operator()("pe"); - check_attached_signal.template operator()("qgen"); - check_attached_signal.template operator()("qext"); - check_attached_signal.template operator()("pfaref"); - check_attached_signal.template operator()("pref"); + if (signals_.template isAttached()) + { + if (!signals_.template isLinked()) + { + Log::error() << "Reecb: qgen signal attached with no linked source\n"; + ret += 1; + } + } + + if (signals_.template isAttached()) + { + if (!signals_.template isLinked()) + { + Log::error() << "Reecb: qext signal attached with no linked source\n"; + ret += 1; + } + } + + if (signals_.template isAttached()) + { + if (!signals_.template isLinked()) + { + Log::error() << "Reecb: pfaref signal attached with no linked source\n"; + ret += 1; + } + } + + if (signals_.template isAttached()) + { + if (!signals_.template isLinked()) + { + Log::error() << "Reecb: pref signal attached with no linked source\n"; + ret += 1; + } + } return ret; } /** - * @brief Initialize REECB from seeded current-command ports + * @brief Initialize REECB from seeded current-command ports. * * Reads the assigned system-base `ipcmd` and `iqcmd` nodes, resolves a * component-base steady state that preserves those seeds, and initializes @@ -597,21 +640,45 @@ namespace GridKit * @pre verify() has reported no configuration errors. * @pre The terminal bus and assigned command nodes have been initialized. * - * @return int 0 on success; nonzero when the commands are outside the - * current circle, the selected control path cannot represent - * them, or an initial reference is undefined. + * @return Zero on success; nonzero when the commands are outside the + * current circle, the selected control path cannot represent them, + * or an initial reference is undefined. */ template int Reecb::initialize() { - using I = ReecbIdx; + const auto VMEAS = static_cast(ReecbInternalVariables::VMEAS); + const auto PMEAS = static_cast(ReecbInternalVariables::PMEAS); + const auto XPIQ = static_cast(ReecbInternalVariables::XPIQ); + const auto XPIV = static_cast(ReecbInternalVariables::XPIV); + const auto QV = static_cast(ReecbInternalVariables::QV); + const auto PORD = static_cast(ReecbInternalVariables::PORD); + const auto VT = static_cast(ReecbInternalVariables::VT); + const auto VMEASSAFE = static_cast(ReecbInternalVariables::VMEASSAFE); + const auto SDIP = static_cast(ReecbInternalVariables::SDIP); + const auto VERR = static_cast(ReecbInternalVariables::VERR); + const auto IQV = static_cast(ReecbInternalVariables::IQV); + const auto QREF = static_cast(ReecbInternalVariables::QREF); + const auto EQ = static_cast(ReecbInternalVariables::EQ); + const auto VPIQ = static_cast(ReecbInternalVariables::VPIQ); + const auto EPIV = static_cast(ReecbInternalVariables::EPIV); + const auto FPORD = static_cast(ReecbInternalVariables::FPORD); + const auto RPORD = static_cast(ReecbInternalVariables::RPORD); + const auto IQCIRC = static_cast(ReecbInternalVariables::IQCIRC); + const auto IPCIRC = static_cast(ReecbInternalVariables::IPCIRC); + const auto IQMAX = static_cast(ReecbInternalVariables::IQMAX); + const auto IPMAX = static_cast(ReecbInternalVariables::IPMAX); + const auto IQBASE = static_cast(ReecbInternalVariables::IQBASE); + const auto IQRAW = static_cast(ReecbInternalVariables::IQRAW); + const auto IQCMD = static_cast(ReecbInternalVariables::IQCMD); + const auto IPCMD = static_cast(ReecbInternalVariables::IPCMD); auto* y = y_.getData(); // Assigned command nodes alias these entries after allocate(). Their // system-base seeds remain untouched throughout initialization. - const ScalarT ipcmd0_system = y[I::IPCMD]; - const ScalarT iqcmd0_system = y[I::IQCMD]; + const ScalarT ipcmd0_system = y[IPCMD]; + const ScalarT iqcmd0_system = y[IQCMD]; const ScalarT ipcmd0 = toComponentBase(ipcmd0_system); const ScalarT iqcmd0 = toComponentBase(iqcmd0_system); const RealT ipcmd0_value = static_cast(ipcmd0); @@ -624,7 +691,12 @@ namespace GridKit const ScalarT vmeas_safe0 = Math::max(vmeas0, VMEAS_MINIMUM); const ScalarT pmeas0 = ipcmd0 * vmeas_safe0; const ScalarT qgen0 = iqcmd0 * vmeas_safe0; - const RealT vref0 = Vref0_given_ ? Vref0_ : static_cast(vt0); + + RealT vref0 = static_cast(vt0); + if (Vref0_given_) + { + vref0 = Vref0_; + } if (!std::isfinite(ipcmd0_value) || !std::isfinite(iqcmd0_value) || !std::isfinite(static_cast(vt0))) { @@ -789,29 +861,29 @@ namespace GridKit const ScalarT qext0_system = toSystemBase(reactive.qref); const ScalarT pref0_system = toSystemBase(pref0); - y[I::VMEAS] = vmeas0; - y[I::PMEAS] = pmeas0; - y[I::XPIQ] = reactive.xpiq; - y[I::XPIV] = reactive.xpiv; - y[I::QV] = reactive.qv; - y[I::PORD] = pord0; - y[I::VT] = vt0; - y[I::VMEASSAFE] = vmeas_safe0; - y[I::SDIP] = sdip0; - y[I::VERR] = verr0; - y[I::IQV] = iqv0; - y[I::QREF] = reactive.qref; - y[I::EQ] = reactive.eq; - y[I::VPIQ] = reactive.vpiq; - y[I::EPIV] = reactive.epiv; - y[I::FPORD] = fpord0; - y[I::RPORD] = rpord0; - y[I::IQCIRC] = iqcirc0; - y[I::IPCIRC] = ipcirc0; - y[I::IQMAX] = iqmax0; - y[I::IPMAX] = ipmax0; - y[I::IQBASE] = reactive.iqbase; - y[I::IQRAW] = iqraw0; + y[VMEAS] = vmeas0; + y[PMEAS] = pmeas0; + y[XPIQ] = reactive.xpiq; + y[XPIV] = reactive.xpiv; + y[QV] = reactive.qv; + y[PORD] = pord0; + y[VT] = vt0; + y[VMEASSAFE] = vmeas_safe0; + y[SDIP] = sdip0; + y[VERR] = verr0; + y[IQV] = iqv0; + y[QREF] = reactive.qref; + y[EQ] = reactive.eq; + y[VPIQ] = reactive.vpiq; + y[EPIV] = reactive.epiv; + y[FPORD] = fpord0; + y[RPORD] = rpord0; + y[IQCIRC] = iqcirc0; + y[IPCIRC] = ipcirc0; + y[IQMAX] = iqmax0; + y[IPMAX] = ipmax0; + y[IQBASE] = reactive.iqbase; + y[IQRAW] = iqraw0; if (!Vref0_given_) { @@ -851,38 +923,34 @@ namespace GridKit } /** - * @brief Identify the differential variables + * @brief Identify the differential variables. * * The two measurement filters, the two PI states, the reactive-current * lag, and the active-power order carry derivatives; every other * internal variable is algebraic. * - * @return int 0 on success. */ template int Reecb::tagDifferentiable() { - using I = ReecbIdx; - std::fill(tag_.begin(), tag_.end(), false); - tag_[I::VMEAS] = true; - tag_[I::PMEAS] = true; - tag_[I::XPIQ] = true; - tag_[I::XPIV] = true; - tag_[I::QV] = true; - tag_[I::PORD] = true; + tag_[static_cast(ReecbInternalVariables::VMEAS)] = true; + tag_[static_cast(ReecbInternalVariables::PMEAS)] = true; + tag_[static_cast(ReecbInternalVariables::XPIQ)] = true; + tag_[static_cast(ReecbInternalVariables::XPIV)] = true; + tag_[static_cast(ReecbInternalVariables::QV)] = true; + tag_[static_cast(ReecbInternalVariables::PORD)] = true; return 0; } /** - * @brief Compute the absolute tolerance for each variable in the model + * @brief Compute the absolute tolerance for each variable in the model. * * All REECB variables are per-unit voltages, powers, or currents of the * same order, so they share the relative tolerance as their absolute * floor. * * @param[in] rel_tol Solver relative tolerance. - * @return int 0 on success. */ template int Reecb::setAbsoluteTolerance(RealT rel_tol) @@ -892,7 +960,7 @@ namespace GridKit } /** - * @brief Internal residual + * @brief Evaluate the internal residual. * * Evaluates the six controller states and the nineteen algebraic rows * documented in the model README. The body is kept free of branches and @@ -905,7 +973,7 @@ namespace GridKit * @param[in] wb Terminal-bus voltage components. * @param[in] ws External signal values on system base. * @param[out] f Internal residuals. - * @return int 0 on success. + * @return Zero on success. */ template __attribute__((always_inline)) inline int @@ -916,128 +984,161 @@ namespace GridKit const ScalarT* ws, ScalarT* f) { - using I = ReecbIdx; - using E = ReecbExt; - - const ScalarT vmeas = y[I::VMEAS]; - const ScalarT pmeas = y[I::PMEAS]; - const ScalarT xpiq = y[I::XPIQ]; - const ScalarT xpiv = y[I::XPIV]; - const ScalarT qv = y[I::QV]; - const ScalarT pord = y[I::PORD]; - const ScalarT vt = y[I::VT]; - const ScalarT vmeas_safe = y[I::VMEASSAFE]; - const ScalarT sdip = y[I::SDIP]; - const ScalarT verr = y[I::VERR]; - const ScalarT iqv = y[I::IQV]; - const ScalarT qref = y[I::QREF]; - const ScalarT eq = y[I::EQ]; - const ScalarT vpiq = y[I::VPIQ]; - const ScalarT epiv = y[I::EPIV]; - const ScalarT fpord = y[I::FPORD]; - const ScalarT rpord = y[I::RPORD]; - const ScalarT iqcirc = y[I::IQCIRC]; - const ScalarT ipcirc = y[I::IPCIRC]; - const ScalarT iqmax = y[I::IQMAX]; - const ScalarT ipmax = y[I::IPMAX]; - const ScalarT iqbase = y[I::IQBASE]; - const ScalarT iqraw = y[I::IQRAW]; - const ScalarT iqcmd_system = y[I::IQCMD]; - const ScalarT ipcmd_system = y[I::IPCMD]; - - const ScalarT vmeas_dot = yp[I::VMEAS]; - const ScalarT pmeas_dot = yp[I::PMEAS]; - const ScalarT xpiq_dot = yp[I::XPIQ]; - const ScalarT xpiv_dot = yp[I::XPIV]; - const ScalarT qv_dot = yp[I::QV]; - const ScalarT pord_dot = yp[I::PORD]; + const auto VMEAS = static_cast(ReecbInternalVariables::VMEAS); + const auto PMEAS = static_cast(ReecbInternalVariables::PMEAS); + const auto XPIQ = static_cast(ReecbInternalVariables::XPIQ); + const auto XPIV = static_cast(ReecbInternalVariables::XPIV); + const auto QV = static_cast(ReecbInternalVariables::QV); + const auto PORD = static_cast(ReecbInternalVariables::PORD); + const auto VT = static_cast(ReecbInternalVariables::VT); + const auto VMEASSAFE = static_cast(ReecbInternalVariables::VMEASSAFE); + const auto SDIP = static_cast(ReecbInternalVariables::SDIP); + const auto VERR = static_cast(ReecbInternalVariables::VERR); + const auto IQV = static_cast(ReecbInternalVariables::IQV); + const auto QREF = static_cast(ReecbInternalVariables::QREF); + const auto EQ = static_cast(ReecbInternalVariables::EQ); + const auto VPIQ = static_cast(ReecbInternalVariables::VPIQ); + const auto EPIV = static_cast(ReecbInternalVariables::EPIV); + const auto FPORD = static_cast(ReecbInternalVariables::FPORD); + const auto RPORD = static_cast(ReecbInternalVariables::RPORD); + const auto IQCIRC = static_cast(ReecbInternalVariables::IQCIRC); + const auto IPCIRC = static_cast(ReecbInternalVariables::IPCIRC); + const auto IQMAX = static_cast(ReecbInternalVariables::IQMAX); + const auto IPMAX = static_cast(ReecbInternalVariables::IPMAX); + const auto IQBASE = static_cast(ReecbInternalVariables::IQBASE); + const auto IQRAW = static_cast(ReecbInternalVariables::IQRAW); + const auto IQCMD = static_cast(ReecbInternalVariables::IQCMD); + const auto IPCMD = static_cast(ReecbInternalVariables::IPCMD); + + const auto PE = static_cast(ReecbExternalVariables::PE); + const auto QGEN = static_cast(ReecbExternalVariables::QGEN); + const auto QEXT = static_cast(ReecbExternalVariables::QEXT); + const auto PFAREF = static_cast(ReecbExternalVariables::PFAREF); + const auto PREF = static_cast(ReecbExternalVariables::PREF); + + const ScalarT vmeas = y[VMEAS]; + const ScalarT pmeas = y[PMEAS]; + const ScalarT xpiq = y[XPIQ]; + const ScalarT xpiv = y[XPIV]; + const ScalarT qv = y[QV]; + const ScalarT pord = y[PORD]; + const ScalarT vt = y[VT]; + const ScalarT vmeas_safe = y[VMEASSAFE]; + const ScalarT sdip = y[SDIP]; + const ScalarT verr = y[VERR]; + const ScalarT iqv = y[IQV]; + const ScalarT qref = y[QREF]; + const ScalarT eq = y[EQ]; + const ScalarT vpiq = y[VPIQ]; + const ScalarT epiv = y[EPIV]; + const ScalarT fpord = y[FPORD]; + const ScalarT rpord = y[RPORD]; + const ScalarT iqcirc = y[IQCIRC]; + const ScalarT ipcirc = y[IPCIRC]; + const ScalarT iqmax = y[IQMAX]; + const ScalarT ipmax = y[IPMAX]; + const ScalarT iqbase = y[IQBASE]; + const ScalarT iqraw = y[IQRAW]; + const ScalarT iqcmd_system = y[IQCMD]; + const ScalarT ipcmd_system = y[IPCMD]; + + const ScalarT vmeas_dot = yp[VMEAS]; + const ScalarT pmeas_dot = yp[PMEAS]; + const ScalarT xpiq_dot = yp[XPIQ]; + const ScalarT xpiv_dot = yp[XPIV]; + const ScalarT qv_dot = yp[QV]; + const ScalarT pord_dot = yp[PORD]; const ScalarT vr = wb[0]; const ScalarT vi = wb[1]; - const ScalarT pe = toComponentBase(ws[E::PE]); - const ScalarT qgen = toComponentBase(ws[E::QGEN]); - const ScalarT qext = toComponentBase(ws[E::QEXT]); - const ScalarT pfaref = ws[E::PFAREF]; - const ScalarT pref = toComponentBase(ws[E::PREF]); + const ScalarT pe = toComponentBase(ws[PE]); + const ScalarT qgen = toComponentBase(ws[QGEN]); + const ScalarT qext = toComponentBase(ws[QEXT]); + const ScalarT pfaref = ws[PFAREF]; + const ScalarT pref = toComponentBase(ws[PREF]); const ScalarT iqcmd = toComponentBase(iqcmd_system); const ScalarT ipcmd = toComponentBase(ipcmd_system); - f[I::VMEAS] = -vmeas_dot + (vt - vmeas) / Trv_; - f[I::PMEAS] = -pmeas_dot + (pe - pmeas) / Tp_; - f[I::XPIQ] = -xpiq_dot + sdip * Math::antiwindup(Kqp_ * eq + xpiq, Kqi_ * eq, Vmin_, Vmax_); - f[I::XPIV] = -xpiv_dot + sdip * Math::antiwindup(Kvp_ * epiv + xpiv, Kvi_ * epiv, -iqmax, iqmax); - f[I::QV] = -qv_dot + sdip * (qref / vmeas_safe - qv) / Tiq_; - f[I::PORD] = -pord_dot + sdip * Math::antiwindup(pord, rpord, Pmin_, Pmax_); - f[I::VT] = -vt * vt + vr * vr + vi * vi; - f[I::VMEASSAFE] = -vmeas_safe + Math::max(vmeas, VMEAS_MINIMUM); - f[I::SDIP] = -sdip + Math::inside(vt, Vdip_, Vup_); - f[I::VERR] = -verr + Math::deadband2(Vref0_ - vmeas, dbd1_, dbd2_); - f[I::IQV] = -iqv + Math::clamp(kqv_ * verr, Iql1_, Iqh1_); - f[I::QREF] = -qref + pf_on_ * pmeas * std::tan(pfaref) + pf_off_ * qext; - f[I::EQ] = -eq + Math::clamp(qref, Qmin_, Qmax_) - qgen; - f[I::VPIQ] = -vpiq + Math::clamp(Kqp_ * eq + xpiq, Vmin_, Vmax_); - f[I::EPIV] = -epiv + v_on_ * vpiq + v_off_ * qref - vmeas; - f[I::FPORD] = -fpord + (pref - pord) / Tpord_; - f[I::RPORD] = -rpord + Math::clamp(fpord, dPmin_, dPmax_); - f[I::IQCIRC] = -iqcirc * iqcirc + Imax_ * Imax_ - p_priority_ * ipcmd * ipcmd; - f[I::IPCIRC] = -ipcirc * ipcirc + Imax_ * Imax_ - q_priority_ * iqcmd * iqcmd; - f[I::IQMAX] = -iqmax + q_priority_ * Imax_ + p_priority_ * iqcirc; - f[I::IPMAX] = -ipmax + p_priority_ * Imax_ + q_priority_ * ipcirc; - f[I::IQBASE] = -iqbase + Math::clamp(Kvp_ * epiv + xpiv, -iqmax, iqmax); - f[I::IQRAW] = -iqraw + q_on_ * iqbase + q_off_ * qv + (ONE - sdip) * iqv; - f[I::IQCMD] = -iqcmd_system + toSystemBase(Math::clamp(iqraw, -iqmax, iqmax)); - f[I::IPCMD] = -ipcmd_system + toSystemBase(Math::clamp(pord / vmeas_safe, ZERO, ipmax)); + f[VMEAS] = -vmeas_dot + (vt - vmeas) / Trv_; + f[PMEAS] = -pmeas_dot + (pe - pmeas) / Tp_; + f[XPIQ] = -xpiq_dot + sdip * Math::antiwindup(Kqp_ * eq + xpiq, Kqi_ * eq, Vmin_, Vmax_); + f[XPIV] = -xpiv_dot + sdip * Math::antiwindup(Kvp_ * epiv + xpiv, Kvi_ * epiv, -iqmax, iqmax); + f[QV] = -qv_dot + sdip * (qref / vmeas_safe - qv) / Tiq_; + f[PORD] = -pord_dot + sdip * Math::antiwindup(pord, rpord, Pmin_, Pmax_); + f[VT] = -vt * vt + vr * vr + vi * vi; + f[VMEASSAFE] = -vmeas_safe + Math::max(vmeas, VMEAS_MINIMUM); + f[SDIP] = -sdip + Math::inside(vt, Vdip_, Vup_); + f[VERR] = -verr + Math::deadband2(Vref0_ - vmeas, dbd1_, dbd2_); + f[IQV] = -iqv + Math::clamp(kqv_ * verr, Iql1_, Iqh1_); + f[QREF] = -qref + pf_on_ * pmeas * std::tan(pfaref) + pf_off_ * qext; + f[EQ] = -eq + Math::clamp(qref, Qmin_, Qmax_) - qgen; + f[VPIQ] = -vpiq + Math::clamp(Kqp_ * eq + xpiq, Vmin_, Vmax_); + f[EPIV] = -epiv + v_on_ * vpiq + v_off_ * qref - vmeas; + f[FPORD] = -fpord + (pref - pord) / Tpord_; + f[RPORD] = -rpord + Math::clamp(fpord, dPmin_, dPmax_); + f[IQCIRC] = -iqcirc * iqcirc + Imax_ * Imax_ - p_priority_ * ipcmd * ipcmd; + f[IPCIRC] = -ipcirc * ipcirc + Imax_ * Imax_ - q_priority_ * iqcmd * iqcmd; + f[IQMAX] = -iqmax + q_priority_ * Imax_ + p_priority_ * iqcirc; + f[IPMAX] = -ipmax + p_priority_ * Imax_ + q_priority_ * ipcirc; + f[IQBASE] = -iqbase + Math::clamp(Kvp_ * epiv + xpiv, -iqmax, iqmax); + f[IQRAW] = -iqraw + q_on_ * iqbase + q_off_ * qv + (ONE - sdip) * iqv; + f[IQCMD] = -iqcmd + Math::clamp(iqraw, -iqmax, iqmax); + f[IPCMD] = -ipcmd + Math::clamp(pord / vmeas_safe, ZERO, ipmax); return 0; } /** - * @brief Residuals of system equations + * @brief Evaluate the model residuals. * * Refreshes the bus and signal interface buffers and evaluates the * internal residual. REECB injects no current, so there is no bus * residual. An unattached input port falls back to the value latched by * initialize(). * - * @return int 0 on success. + * @return Zero on success. */ template int Reecb::evaluateResidual() { - using E = ReecbExt; - - ws_[E::PE] = pe_set_; - ws_[E::QGEN] = qgen_set_; - ws_[E::QEXT] = qext_set_; - ws_[E::PFAREF] = pfaref_set_; - ws_[E::PREF] = pref_set_; + const auto PE = static_cast(ReecbExternalVariables::PE); + const auto QGEN = static_cast(ReecbExternalVariables::QGEN); + const auto QEXT = static_cast(ReecbExternalVariables::QEXT); + const auto PFAREF = static_cast(ReecbExternalVariables::PFAREF); + const auto PREF = static_cast(ReecbExternalVariables::PREF); + + ws_[PE] = pe_set_; + ws_[QGEN] = qgen_set_; + ws_[QEXT] = qext_set_; + ws_[PFAREF] = pfaref_set_; + ws_[PREF] = pref_set_; std::fill(ws_indices_.begin(), ws_indices_.end(), INVALID_INDEX); if (signals_.template isAttached()) { - ws_[E::PE] = signals_.template readExternalVariable(); - ws_indices_[E::PE] = signals_.template readExternalVariableIndex(); + ws_[PE] = signals_.template readExternalVariable(); + ws_indices_[PE] = signals_.template readExternalVariableIndex(); } if (signals_.template isAttached()) { - ws_[E::QGEN] = signals_.template readExternalVariable(); - ws_indices_[E::QGEN] = signals_.template readExternalVariableIndex(); + ws_[QGEN] = signals_.template readExternalVariable(); + ws_indices_[QGEN] = signals_.template readExternalVariableIndex(); } if (signals_.template isAttached()) { - ws_[E::QEXT] = signals_.template readExternalVariable(); - ws_indices_[E::QEXT] = signals_.template readExternalVariableIndex(); + ws_[QEXT] = signals_.template readExternalVariable(); + ws_indices_[QEXT] = signals_.template readExternalVariableIndex(); } if (signals_.template isAttached()) { - ws_[E::PFAREF] = signals_.template readExternalVariable(); - ws_indices_[E::PFAREF] = signals_.template readExternalVariableIndex(); + ws_[PFAREF] = signals_.template readExternalVariable(); + ws_indices_[PFAREF] = signals_.template readExternalVariableIndex(); } if (signals_.template isAttached()) { - ws_[E::PREF] = signals_.template readExternalVariable(); - ws_indices_[E::PREF] = signals_.template readExternalVariableIndex(); + ws_[PREF] = signals_.template readExternalVariable(); + ws_indices_[PREF] = signals_.template readExternalVariableIndex(); } wb_[0] = Vr(); diff --git a/GridKit/Model/PhasorDynamics/INPUT_FORMAT.md b/GridKit/Model/PhasorDynamics/INPUT_FORMAT.md index 98a2b98df..ce4a114e5 100644 --- a/GridKit/Model/PhasorDynamics/INPUT_FORMAT.md +++ b/GridKit/Model/PhasorDynamics/INPUT_FORMAT.md @@ -152,7 +152,7 @@ are specified: [Gensal](SynchronousMachine/GENSAL/README.md) | 5th order salient-pole machine model [GenClassical](SynchronousMachine/GenClassical/README.md) | the classical machine model [Regca](Converter/REGCA/README.md) | WECC REGCA renewable generator/converter model - [Reecb](Controller/REECB/README.md) | the REECB renewable electrical-control model + [Reecb](Controller/REECB/README.md) | WECC REECB renewable electrical-control model [Repca](Controller/REPCA/README.md) | the REPCA renewable plant-control model [Tgov1](Governor/Tgov1/README.md) | the TGOV1 governor model [Hygov](Governor/HYGOV/README.md) | the HYGOV hydro turbine-governor model diff --git a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp index 7c940f8e9..c44453339 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp @@ -313,45 +313,45 @@ namespace GridKit if (reecbdata.signal_inputs.contains(ReecbSignalInputs::pe)) { - const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::pe); - constexpr auto PE = ReecbExternalVariables::PE; - reecb->getSignals().template attachSignalNode(getSignal(signal)); + const IdxT pe = reecbdata.signal_inputs.at(ReecbSignalInputs::pe); + constexpr auto PE = ReecbExternalVariables::PE; + reecb->getSignals().template attachSignalNode(getSignal(pe)); } if (reecbdata.signal_inputs.contains(ReecbSignalInputs::qgen)) { - const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::qgen); - constexpr auto QGEN = ReecbExternalVariables::QGEN; - reecb->getSignals().template attachSignalNode(getSignal(signal)); + const IdxT qgen = reecbdata.signal_inputs.at(ReecbSignalInputs::qgen); + constexpr auto QGEN = ReecbExternalVariables::QGEN; + reecb->getSignals().template attachSignalNode(getSignal(qgen)); } if (reecbdata.signal_inputs.contains(ReecbSignalInputs::qext)) { - const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::qext); - constexpr auto QEXT = ReecbExternalVariables::QEXT; - reecb->getSignals().template attachSignalNode(getSignal(signal)); + const IdxT qext = reecbdata.signal_inputs.at(ReecbSignalInputs::qext); + constexpr auto QEXT = ReecbExternalVariables::QEXT; + reecb->getSignals().template attachSignalNode(getSignal(qext)); } if (reecbdata.signal_inputs.contains(ReecbSignalInputs::pfaref)) { - const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::pfaref); + const IdxT pfaref = reecbdata.signal_inputs.at(ReecbSignalInputs::pfaref); constexpr auto PFAREF = ReecbExternalVariables::PFAREF; - reecb->getSignals().template attachSignalNode(getSignal(signal)); + reecb->getSignals().template attachSignalNode(getSignal(pfaref)); } if (reecbdata.signal_inputs.contains(ReecbSignalInputs::pref)) { - const IdxT signal = reecbdata.signal_inputs.at(ReecbSignalInputs::pref); - constexpr auto PREF = ReecbExternalVariables::PREF; - reecb->getSignals().template attachSignalNode(getSignal(signal)); + const IdxT pref = reecbdata.signal_inputs.at(ReecbSignalInputs::pref); + constexpr auto PREF = ReecbExternalVariables::PREF; + reecb->getSignals().template attachSignalNode(getSignal(pref)); } if (reecbdata.signal_outputs.contains(ReecbSignalOutputs::iqcmd)) { - const IdxT signal = reecbdata.signal_outputs.at(ReecbSignalOutputs::iqcmd); - constexpr auto IQCMD = ReecbInternalVariables::IQCMD; - reecb->getSignals().template assignSignalNode(getSignal(signal)); + const IdxT iqcmd = reecbdata.signal_outputs.at(ReecbSignalOutputs::iqcmd); + constexpr auto IQCMD = ReecbInternalVariables::IQCMD; + reecb->getSignals().template assignSignalNode(getSignal(iqcmd)); } if (reecbdata.signal_outputs.contains(ReecbSignalOutputs::ipcmd)) { - const IdxT signal = reecbdata.signal_outputs.at(ReecbSignalOutputs::ipcmd); - constexpr auto IPCMD = ReecbInternalVariables::IPCMD; - reecb->getSignals().template assignSignalNode(getSignal(signal)); + const IdxT ipcmd = reecbdata.signal_outputs.at(ReecbSignalOutputs::ipcmd); + constexpr auto IPCMD = ReecbInternalVariables::IPCMD; + reecb->getSignals().template assignSignalNode(getSignal(ipcmd)); } addComponent(reecb); diff --git a/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp b/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp index a01571b71..24524fa30 100644 --- a/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp @@ -36,10 +36,12 @@ namespace GridKit ConverterReecbTests() = default; ~ConverterReecbTests() = default; + // Fixed answer keys should agree to ordinary double-precision roundoff. + static constexpr RealT kBehaviorTol = 1.0e-12; + // REECB initialization solves smooth-limiter inputs near their asymptotes. - // The resulting steady residuals are O(1e-10), so behavioral comparisons - // use a tolerance one order above the initialization guard. - static constexpr RealT kBehaviorTol = 1.0e-9; + // The resulting steady residuals are O(1e-10). + static constexpr RealT kSteadyStateTol = 1.0e-9; // Enzyme and dependency tracking traverse the same smooth expressions // differently; their double-precision derivatives agree to O(1e-10). @@ -54,11 +56,11 @@ namespace GridKit PhasorDynamics::Bus bus(1.0, 0.0); PhasorDynamics::Converter::Reecb empty(&bus); - success *= (empty.size() == static_cast(I::MAXIMUM)); + success *= (empty.size() == static_cast(index(Vars::MAXIMUM))); success *= (empty.getMonitor() == nullptr); PhasorDynamics::Converter::Reecb configured(&bus, makeData()); - success *= (configured.size() == static_cast(I::MAXIMUM)); + success *= (configured.size() == static_cast(index(Vars::MAXIMUM))); success *= (configured.getMonitor() != nullptr); success *= (configured.verify() == 0); @@ -166,19 +168,19 @@ namespace GridKit success *= (fixture.evaluate() == 0); const auto* y = fixture.reecb.y().getData(); - success *= scalarMatches(y[I::VT], 1.0, "VT"); - success *= scalarMatches(y[I::VMEAS], 1.0, "VMEAS"); - success *= scalarMatches(y[I::PMEAS], 0.5, "PMEAS on component base"); - success *= scalarMatches(y[I::QREF], 0.1, "QREF on component base"); - success *= scalarMatches(y[I::PORD], 0.5, "PORD on component base"); + success *= scalarMatches(y[index(Vars::VT)], 1.0, "VT"); + success *= scalarMatches(y[index(Vars::VMEAS)], 1.0, "VMEAS"); + success *= scalarMatches(y[index(Vars::PMEAS)], 0.5, "PMEAS on component base"); + success *= scalarMatches(y[index(Vars::QREF)], 0.1, "QREF on component base"); + success *= scalarMatches(y[index(Vars::PORD)], 0.5, "PORD on component base"); success *= scalarMatches(fixture.iqcmd(), 0.05, "seeded iqcmd"); success *= scalarMatches(fixture.ipcmd(), 0.25, "seeded ipcmd"); - success *= scalarMatches(fixture.input(E::PE), 0.25, "published pe"); - success *= scalarMatches(fixture.input(E::QGEN), 0.05, "published qgen"); - success *= scalarMatches(fixture.input(E::QEXT), 0.05, "published qext"); - success *= scalarMatches(fixture.input(E::PFAREF), 0.0, "inactive pfaref fallback"); - success *= scalarMatches(fixture.input(E::PREF), 0.25, "published pref"); + success *= scalarMatches(fixture.input(Ext::PE), 0.25, "published pe"); + success *= scalarMatches(fixture.input(Ext::QGEN), 0.05, "published qgen"); + success *= scalarMatches(fixture.input(Ext::QEXT), 0.05, "published qext"); + success *= scalarMatches(fixture.input(Ext::PFAREF), 0.0, "inactive pfaref fallback"); + success *= scalarMatches(fixture.input(Ext::PREF), 0.25, "published pref"); RealT time = 0.0; Model::VariableMonitorController monitor(time); @@ -212,7 +214,7 @@ namespace GridKit for (size_t i = 0; i < static_cast(fixture.reecb.size()); ++i) { - const bool expected = i <= I::PORD; + const bool expected = i <= index(Vars::PORD); if (fixture.reecb.tag()[i] != expected) { std::cout << "REECB differentiability tag " << i << " mismatch\n"; @@ -254,8 +256,8 @@ namespace GridKit success *= allResidualsZero(scenario.reecb); success *= scalarMatches(scenario.iqcmd(), 0.05, "scenario iqcmd preservation"); success *= scalarMatches(scenario.ipcmd(), 0.2, "scenario ipcmd preservation"); - success *= scalarMatches(scenario.input(E::PE), 0.2, "scenario pe publication"); - success *= scalarMatches(scenario.input(E::QGEN), 0.05, "scenario qgen publication"); + success *= scalarMatches(scenario.input(Ext::PE), 0.2, "scenario pe publication"); + success *= scalarMatches(scenario.input(Ext::QGEN), 0.05, "scenario qgen publication"); } } } @@ -413,32 +415,32 @@ namespace GridKit // Values are pinned after an independent one-time evaluation of the // documented equations at setAnswerKeyState()/setAnswerKeyInputs(). - const std::array expected{{ - {I::VMEAS, 0.2400000000000002}, - {I::PMEAS, 0.1449999999999998}, - {I::XPIQ, 0.03399999970642038}, - {I::XPIV, 0.0}, - {I::QV, 0.1222222222222222}, - {I::PORD, 0.26}, - {I::VT, -0.02999999999999992}, - {I::VMEASSAFE, -0.01000000000000001}, - {I::SDIP, 0.2}, - {I::VERR, 2.821917786596795e-7}, - {I::IQV, -0.06999999999999998}, - {I::QREF, -0.2668756300679377}, - {I::EQ, 0.3499999999999999}, - {I::VPIQ, -0.1000000000000002}, - {I::EPIV, -0.04999999999999993}, - {I::FPORD, -0.1000000000000003}, - {I::RPORD, 0.05000000000000004}, - {I::IQCIRC, 0.3999999999999997}, - {I::IPCIRC, 0.8100000000000001}, - {I::IQMAX, 0.1000000000000001}, - {I::IPMAX, 0.2}, - {I::IQBASE, -0.3699999999999998}, - {I::IQRAW, -0.17}, - {I::IQCMD, -0.05000000000000004}, - {I::IPCMD, -0.06145833333333334}, + const std::array expected{{ + {index(Vars::VMEAS), 0.2400000000000002}, + {index(Vars::PMEAS), 0.1449999999999998}, + {index(Vars::XPIQ), 0.03399999970642038}, + {index(Vars::XPIV), 0.0}, + {index(Vars::QV), 0.1222222222222222}, + {index(Vars::PORD), 0.26}, + {index(Vars::VT), -0.02999999999999992}, + {index(Vars::VMEASSAFE), -0.01000000000000001}, + {index(Vars::SDIP), 0.2}, + {index(Vars::VERR), 2.821917786596795e-7}, + {index(Vars::IQV), -0.06999999999999998}, + {index(Vars::QREF), -0.2668756300679377}, + {index(Vars::EQ), 0.3499999999999999}, + {index(Vars::VPIQ), -0.1000000000000002}, + {index(Vars::EPIV), -0.04999999999999993}, + {index(Vars::FPORD), -0.1000000000000003}, + {index(Vars::RPORD), 0.05000000000000004}, + {index(Vars::IQCIRC), 0.3999999999999997}, + {index(Vars::IPCIRC), 0.8100000000000001}, + {index(Vars::IQMAX), 0.1000000000000001}, + {index(Vars::IPMAX), 0.2}, + {index(Vars::IQBASE), -0.3699999999999998}, + {index(Vars::IQRAW), -0.17}, + {index(Vars::IQCMD), -0.1000000000000001}, + {index(Vars::IPCMD), -0.1229166666666667}, }}; success *= (static_cast(fixture.reecb.getResidual().getSize()) == expected.size()); @@ -484,29 +486,29 @@ namespace GridKit fixture.attachAllInputs(); success *= fixture.initialize(0.1, 0.2); - fixture.input(E::PFAREF) = 0.2; - fixture.input(E::QEXT) = 0.2; // 0.4 on the 50 MVA component base. + fixture.input(Ext::PFAREF) = 0.2; + fixture.input(Ext::QEXT) = 0.2; // 0.4 on the 50 MVA component base. setState(fixture.reecb, - {{I::PMEAS, 0.55}, - {I::VMEAS, 0.95}, - {I::VPIQ, 1.0}, - {I::QREF, test_case.qref}, - {I::IQBASE, 0.2}, - {I::QV, 0.3}, - {I::SDIP, 0.9}, - {I::IQV, 0.1}, - {I::EPIV, test_case.epiv}, - {I::IQRAW, test_case.iqraw}}); + {{index(Vars::PMEAS), 0.55}, + {index(Vars::VMEAS), 0.95}, + {index(Vars::VPIQ), 1.0}, + {index(Vars::QREF), test_case.qref}, + {index(Vars::IQBASE), 0.2}, + {index(Vars::QV), 0.3}, + {index(Vars::SDIP), 0.9}, + {index(Vars::IQV), 0.1}, + {index(Vars::EPIV), test_case.epiv}, + {index(Vars::IQRAW), test_case.iqraw}}); success *= (fixture.evaluate() == 0); success *= residualsMatch(fixture.reecb, - {{I::QREF, 0.0}, {I::EPIV, 0.0}, {I::IQRAW, 0.0}}, + {{index(Vars::QREF), 0.0}, {index(Vars::EPIV), 0.0}, {index(Vars::IQRAW), 0.0}}, test_case.label); } Fixture limit_fixture(makeDynamicData()); limit_fixture.attachAllInputs(); - success *= limit_fixture.initialize(0.1, 0.2); - limit_fixture.input(E::QGEN) = 0.0; + success *= limit_fixture.initialize(0.1, 0.2); + limit_fixture.input(Ext::QGEN) = 0.0; // A Q reference driven past each limit; EQ is the clamped reference // less the zeroed qgen feedback. @@ -515,10 +517,10 @@ namespace GridKit {-2.0, -0.7}, }}) { - setState(limit_fixture.reecb, {{I::QREF, qref}, {I::EQ, 0.0}}); + setState(limit_fixture.reecb, {{index(Vars::QREF), qref}, {index(Vars::EQ), 0.0}}); success *= (limit_fixture.evaluate() == 0); success *= residualsMatch(limit_fixture.reecb, - {{I::EQ, expected_eq}}, + {{index(Vars::EQ), expected_eq}}, "reactive-power limit"); } @@ -528,10 +530,10 @@ namespace GridKit {-4.0, 0.7}, }}) { - setState(limit_fixture.reecb, {{I::EQ, eq}, {I::XPIQ, 0.0}, {I::VPIQ, 0.0}}); + setState(limit_fixture.reecb, {{index(Vars::EQ), eq}, {index(Vars::XPIQ), 0.0}, {index(Vars::VPIQ), 0.0}}); success *= (limit_fixture.evaluate() == 0); success *= residualsMatch(limit_fixture.reecb, - {{I::VPIQ, expected_vpiq}}, + {{index(Vars::VPIQ), expected_vpiq}}, "reactive-power PI voltage limit"); } @@ -559,15 +561,16 @@ namespace GridKit for (const auto& test_case : voltage_cases) { setState(voltage_fixture.reecb, - {{I::VT, test_case.voltage}, - {I::VMEAS, test_case.vmeas}, - {I::SDIP, test_case.expected_sdip_rhs}, - {I::VERR, test_case.expected_verr_rhs}, - {I::IQV, test_case.expected_iqv_rhs}}); + {{index(Vars::VT), test_case.voltage}, + {index(Vars::VMEAS), test_case.vmeas}, + {index(Vars::SDIP), test_case.expected_sdip_rhs}, + {index(Vars::VERR), test_case.expected_verr_rhs}, + {index(Vars::IQV), test_case.expected_iqv_rhs}}); success *= (voltage_fixture.evaluate() == 0); success *= residualsMatch(voltage_fixture.reecb, - {{I::SDIP, 0.0}, {I::VERR, 0.0}, {I::IQV, 0.0}}, - "voltage band, deadband, and injection limit"); + {{index(Vars::SDIP), 0.0}, {index(Vars::VERR), 0.0}, {index(Vars::IQV), 0.0}}, + "voltage band, deadband, and injection limit", + kSteadyStateTol); } // The QV lag and both PI anti-windup rows are evaluated at three @@ -594,25 +597,25 @@ namespace GridKit for (const auto& test_case : antiwindup_cases) { setState(controller_fixture.reecb, - {{I::SDIP, 1.0}, - {I::XPIQ, test_case.xpiq}, - {I::EQ, test_case.eq}, - {I::XPIV, test_case.xpiv}, - {I::EPIV, test_case.epiv}, - {I::IQMAX, 1.0}}); + {{index(Vars::SDIP), 1.0}, + {index(Vars::XPIQ), test_case.xpiq}, + {index(Vars::EQ), test_case.eq}, + {index(Vars::XPIV), test_case.xpiv}, + {index(Vars::EPIV), test_case.epiv}, + {index(Vars::IQMAX), 1.0}}); success *= (controller_fixture.evaluate() == 0); success *= residualsMatch(controller_fixture.reecb, - {{I::XPIQ, test_case.expected_xpiq}, - {I::XPIV, test_case.expected_xpiv}}, + {{index(Vars::XPIQ), test_case.expected_xpiq}, + {index(Vars::XPIV), test_case.expected_xpiv}}, "anti-windup"); } setState(controller_fixture.reecb, - {{I::SDIP, 1.0}, {I::QREF, 0.4}, {I::VMEASSAFE, 1.0}, {I::QV, 0.2}}); - setDerivative(controller_fixture.reecb, {{I::QV, 0.1}}); + {{index(Vars::SDIP), 1.0}, {index(Vars::QREF), 0.4}, {index(Vars::VMEASSAFE), 1.0}, {index(Vars::QV), 0.2}}); + setDerivative(controller_fixture.reecb, {{index(Vars::QV), 0.1}}); success *= (controller_fixture.evaluate() == 0); success *= residualsMatch(controller_fixture.reecb, - {{I::QV, 0.5666666666666667}}, + {{index(Vars::QV), 0.5666666666666667}}, "reactive-current lag"); return success.report(__func__); @@ -655,18 +658,18 @@ namespace GridKit for (const auto& test_case : cases) { - fixture.input(E::PREF) = test_case.pref_system; + fixture.input(Ext::PREF) = test_case.pref_system; setState(fixture.reecb, - {{I::PORD, test_case.pord}, - {I::FPORD, test_case.fpord}, - {I::RPORD, test_case.rpord}, - {I::SDIP, 1.0}}); - setDerivative(fixture.reecb, {{I::PORD, 0.0}}); + {{index(Vars::PORD), test_case.pord}, + {index(Vars::FPORD), test_case.fpord}, + {index(Vars::RPORD), test_case.rpord}, + {index(Vars::SDIP), 1.0}}); + setDerivative(fixture.reecb, {{index(Vars::PORD), 0.0}}); success *= (fixture.evaluate() == 0); success *= residualsMatch(fixture.reecb, - {{I::FPORD, test_case.expected_fpord}, - {I::RPORD, test_case.expected_rpord}, - {I::PORD, test_case.expected_pord}}, + {{index(Vars::FPORD), test_case.expected_fpord}, + {index(Vars::RPORD), test_case.expected_rpord}, + {index(Vars::PORD), test_case.expected_pord}}, "active-power order"); } @@ -685,29 +688,29 @@ namespace GridKit for (const auto& test_case : lower_bound_cases) { setState(fixture.reecb, - {{I::PORD, -0.2}, {I::RPORD, test_case.rate}, {I::SDIP, 1.0}}); - setDerivative(fixture.reecb, {{I::PORD, 0.0}}); + {{index(Vars::PORD), -0.2}, {index(Vars::RPORD), test_case.rate}, {index(Vars::SDIP), 1.0}}); + setDerivative(fixture.reecb, {{index(Vars::PORD), 0.0}}); success *= (fixture.evaluate() == 0); success *= residualsMatch(fixture.reecb, - {{I::PORD, test_case.expected_residual}}, + {{index(Vars::PORD), test_case.expected_residual}}, test_case.label); } // PE is 0.3 on system base and 0.6 on the 50 MVA component base. - fixture.input(E::PE) = 0.3; + fixture.input(Ext::PE) = 0.3; setState(fixture.reecb, - {{I::PMEAS, 0.5}, - {I::VMEAS, 0.005}, - {I::VMEASSAFE, 0.01}, - {I::PORD, 0.004}, - {I::IPMAX, 1.0}, - {I::IPCMD, 0.2}}); - setDerivative(fixture.reecb, {{I::PMEAS, 0.1}}); + {{index(Vars::PMEAS), 0.5}, + {index(Vars::VMEAS), 0.005}, + {index(Vars::VMEASSAFE), 0.01}, + {index(Vars::PORD), 0.004}, + {index(Vars::IPMAX), 1.0}, + {index(Vars::IPCMD), 0.2}}); + setDerivative(fixture.reecb, {{index(Vars::PMEAS), 0.1}}); success *= (fixture.evaluate() == 0); success *= residualsMatch(fixture.reecb, - {{I::PMEAS, 0.3}, - {I::VMEASSAFE, 0.00109701028057513}, - {I::IPCMD, 0.0}}, + {{index(Vars::PMEAS), 0.3}, + {index(Vars::VMEASSAFE), 0.00109701028057513}, + {index(Vars::IPCMD), 0.0}}, "safe-voltage active-power path"); return success.report(__func__); @@ -747,13 +750,13 @@ namespace GridKit success *= (fixture.evaluate() == 0); success *= stateMatches(fixture.reecb, - {{I::IQCIRC, test_case.iqcirc}, - {I::IPCIRC, test_case.ipcirc}, - {I::IQMAX, test_case.iqmax}, - {I::IPMAX, test_case.ipmax}}, + {{index(Vars::IQCIRC), test_case.iqcirc}, + {index(Vars::IPCIRC), test_case.ipcirc}, + {index(Vars::IQMAX), test_case.iqmax}, + {index(Vars::IPMAX), test_case.ipmax}}, test_case.label); success *= residualsMatch(fixture.reecb, - {{I::IQCMD, 0.0}, {I::IPCMD, 0.0}}, + {{index(Vars::IQCMD), 0.0}, {index(Vars::IPCMD), 0.0}}, test_case.label); success *= scalarMatches(fixture.iqcmd(), test_case.iqcmd, "priority iqcmd preservation"); success *= scalarMatches(fixture.ipcmd(), test_case.ipcmd, "priority ipcmd preservation"); @@ -764,27 +767,146 @@ namespace GridKit } #ifdef GRIDKIT_ENABLE_ENZYME - /// A single rich state and all five external inputs drive both - /// sensitivity paths; every Enzyme CSR row must match dependency tracking. + /// Representative selector modes drive both sensitivity paths. Every + /// Enzyme CSR row must match dependency tracking, and fixed derivatives + /// independently pin the four selector contracts. TestOutcome jacobian() { TestStatus success = true; - const auto data = makeDynamicData(); + struct SelectorCase + { + const char* label; + bool pf; + bool voltage; + bool reactive; + bool p_priority; + }; - const auto dependency_jacobian = dependencyTrackingJacobian(data, success); - const auto enzyme_jacobian = enzymeJacobian(data, success); + // Start with every selector off, then activate one at a time. This keeps + // each expected structural change attributable to exactly one flag. + const std::array cases{{ + {"all selectors off", false, false, false, false}, + {"power-factor control", true, false, false, false}, + {"voltage control", false, true, false, false}, + {"reactive-current control", false, false, true, false}, + {"active-current priority", false, false, false, true}, + }}; - success *= (dependency_jacobian.size() == enzyme_jacobian.size()); - const auto rows = std::min(dependency_jacobian.size(), enzyme_jacobian.size()); - for (size_t row = 0; row < rows; ++row) + for (const auto& test_case : cases) { - if (!isEqual(dependency_jacobian[row], enzyme_jacobian[row], kJacobianTol)) + auto data = makeDynamicData(); + data.parameters[Params::PfFlag] = test_case.pf; + data.parameters[Params::VFlag] = test_case.voltage; + data.parameters[Params::QFlag] = test_case.reactive; + data.parameters[Params::Pqflag] = test_case.p_priority; + + const auto dependency_jacobian = dependencyTrackingJacobian(data, success); + const auto enzyme_jacobian = enzymeJacobian(data, success); + + success *= (dependency_jacobian.size() == index(Vars::MAXIMUM)); + success *= (dependency_jacobian.size() == enzyme_jacobian.size()); + const auto rows = std::min(dependency_jacobian.size(), enzyme_jacobian.size()); + for (size_t row = 0; row < rows; ++row) { - std::cout << "REECB Jacobian row " << row - << " mismatch between dependency tracking and Enzyme\n"; - success = false; + if (!isEqual(dependency_jacobian[row], enzyme_jacobian[row], kJacobianTol)) + { + std::cout << "REECB Jacobian row " << row + << " mismatch between dependency tracking and Enzyme for " + << test_case.label << '\n'; + success = false; + } + } + + if (dependency_jacobian.size() != index(Vars::MAXIMUM)) + { + continue; + } + + // Fixed derivatives at setAnswerKeyState()/setAnswerKeyInputs(). The + // component/system power-base ratio is two in makeDynamicData(). + RealT qext_derivative = 2.0; + RealT pfaref_derivative = 0.0; + if (test_case.pf) + { + qext_derivative = 0.0; + pfaref_derivative = 0.5625630197756407; } + + RealT vpiq_derivative = 0.0; + RealT qref_derivative = 1.0; + if (test_case.voltage) + { + vpiq_derivative = 1.0; + qref_derivative = 0.0; + } + + RealT iqbase_derivative = 0.0; + RealT qv_derivative = 1.0; + if (test_case.reactive) + { + iqbase_derivative = 1.0; + qv_derivative = 0.0; + } + + RealT iqcirc_ipcmd_derivative = 0.0; + RealT ipcirc_iqcmd_derivative = -2.0; + if (test_case.p_priority) + { + iqcirc_ipcmd_derivative = -3.2; + ipcirc_iqcmd_derivative = 0.0; + } + + success *= dependencyMatches(dependency_jacobian, + Vars::QREF, + Ext::QEXT, + qext_derivative, + test_case.label); + success *= dependencyMatches(dependency_jacobian, + Vars::QREF, + Ext::PFAREF, + pfaref_derivative, + test_case.label); + success *= dependencyMatches(dependency_jacobian, + Vars::EPIV, + Vars::VPIQ, + vpiq_derivative, + test_case.label); + success *= dependencyMatches(dependency_jacobian, + Vars::EPIV, + Vars::QREF, + qref_derivative, + test_case.label); + success *= dependencyMatches(dependency_jacobian, + Vars::IQRAW, + Vars::IQBASE, + iqbase_derivative, + test_case.label); + success *= dependencyMatches(dependency_jacobian, + Vars::IQRAW, + Vars::QV, + qv_derivative, + test_case.label); + success *= dependencyMatches(dependency_jacobian, + Vars::IQCIRC, + Vars::IPCMD, + iqcirc_ipcmd_derivative, + test_case.label); + success *= dependencyMatches(dependency_jacobian, + Vars::IPCIRC, + Vars::IQCMD, + ipcirc_iqcmd_derivative, + test_case.label); + success *= dependencyMatches(dependency_jacobian, + Vars::IQCMD, + Vars::IQCMD, + -2.0, + test_case.label); + success *= dependencyMatches(dependency_jacobian, + Vars::IPCMD, + Vars::IPCMD, + -2.0, + test_case.label); } return success.report(__func__); @@ -797,12 +919,20 @@ namespace GridKit using Ext = PhasorDynamics::Converter::ReecbExternalVariables; using Mon = PhasorDynamics::Converter::ReecbMonitorableVariables; using Data = PhasorDynamics::Converter::ReecbData; - using I = PhasorDynamics::Converter::ReecbIdx; - using E = PhasorDynamics::Converter::ReecbExt; - /// A vector row paired with a value: either an input to write or an - /// expected result. Rows are `ReecbIdx`/`ReecbExt` constants, so a - /// failure report locates itself without any name string to maintain. + static constexpr size_t index(Vars variable) + { + return static_cast(variable); + } + + static constexpr size_t index(Ext variable) + { + return static_cast(variable); + } + + /// A model-vector row paired with an expected value. Rows are converted + /// from `ReecbInternalVariables`, so the enum remains the single ordering + /// contract shared with the implementation and README. using Row = std::pair; using Rows = std::initializer_list; using ReecbT = PhasorDynamics::Converter::Reecb; @@ -823,9 +953,9 @@ namespace GridKit class Fixture { private: - std::array input_values_{}; - std::array input_indices_{}; - std::array, E::MAXIMUM> input_nodes_{}; + std::array input_values_{}; + std::array input_indices_{}; + std::array, index(Ext::MAXIMUM)> input_nodes_{}; PhasorDynamics::SignalNode iqcmd_node_; PhasorDynamics::SignalNode ipcmd_node_; @@ -851,7 +981,7 @@ namespace GridKit { const IdxT external_index_base = reecb.size() + bus.size(); - for (size_t port = 0; port < E::MAXIMUM; ++port) + for (size_t port = 0; port < index(Ext::MAXIMUM); ++port) { input_values_[port] = static_cast(initial_value); input_indices_[port] = external_index_base + static_cast(port); @@ -859,11 +989,11 @@ namespace GridKit } auto& signals = reecb.getSignals(); - signals.template attachSignalNode(&input_nodes_[E::PE]); - signals.template attachSignalNode(&input_nodes_[E::QGEN]); - signals.template attachSignalNode(&input_nodes_[E::QEXT]); - signals.template attachSignalNode(&input_nodes_[E::PFAREF]); - signals.template attachSignalNode(&input_nodes_[E::PREF]); + signals.template attachSignalNode(&input_nodes_[index(Ext::PE)]); + signals.template attachSignalNode(&input_nodes_[index(Ext::QGEN)]); + signals.template attachSignalNode(&input_nodes_[index(Ext::QEXT)]); + signals.template attachSignalNode(&input_nodes_[index(Ext::PFAREF)]); + signals.template attachSignalNode(&input_nodes_[index(Ext::PREF)]); } /// Seed the assigned command nodes on the system base. @@ -919,14 +1049,14 @@ namespace GridKit return ipcmd_node_.read(); } - T& input(size_t port) + T& input(Ext port) { - return input_values_[port]; + return input_values_[index(port)]; } - IdxT inputIndex(size_t port) const + IdxT inputIndex(Ext port) const { - return input_indices_[port]; + return input_indices_[index(port)]; } PhasorDynamics::Bus bus; @@ -1064,11 +1194,11 @@ namespace GridKit template void setAnswerKeyInputs(Fixture& fixture) const { - fixture.input(E::PE) = 0.3; - fixture.input(E::QGEN) = -0.1; - fixture.input(E::QEXT) = 0.2; - fixture.input(E::PFAREF) = 0.15; - fixture.input(E::PREF) = 0.35; + fixture.input(Ext::PE) = 0.3; + fixture.input(Ext::QGEN) = -0.1; + fixture.input(Ext::QEXT) = 0.2; + fixture.input(Ext::PFAREF) = 0.15; + fixture.input(Ext::PREF) = 0.35; } /// The rich state shared by the residual answer key and the Jacobian @@ -1077,14 +1207,38 @@ namespace GridKit void setAnswerKeyState(PhasorDynamics::Converter::Reecb& reecb) const { setState(reecb, - {{I::VMEAS, 0.95}, {I::PMEAS, 0.55}, {I::XPIQ, 0.10}, {I::XPIV, -0.05}, {I::QV, 0.30}, {I::PORD, 0.65}, {I::VT, 1.00}, {I::VMEASSAFE, 0.96}, {I::SDIP, 0.80}, {I::VERR, 0.04}, {I::IQV, 0.15}, {I::QREF, 0.35}, {I::EQ, 0.20}, {I::VPIQ, 0.80}, {I::EPIV, -0.10}, {I::FPORD, 0.30}, {I::RPORD, 0.25}, {I::IQCIRC, 1.10}, {I::IPCIRC, 1.20}, {I::IQMAX, 1.00}, {I::IPMAX, 1.30}, {I::IQBASE, 0.20}, {I::IQRAW, 0.40}, {I::IQCMD, 0.25}, {I::IPCMD, 0.40}}); + {{index(Vars::VMEAS), 0.95}, + {index(Vars::PMEAS), 0.55}, + {index(Vars::XPIQ), 0.10}, + {index(Vars::XPIV), -0.05}, + {index(Vars::QV), 0.30}, + {index(Vars::PORD), 0.65}, + {index(Vars::VT), 1.00}, + {index(Vars::VMEASSAFE), 0.96}, + {index(Vars::SDIP), 0.80}, + {index(Vars::VERR), 0.04}, + {index(Vars::IQV), 0.15}, + {index(Vars::QREF), 0.35}, + {index(Vars::EQ), 0.20}, + {index(Vars::VPIQ), 0.80}, + {index(Vars::EPIV), -0.10}, + {index(Vars::FPORD), 0.30}, + {index(Vars::RPORD), 0.25}, + {index(Vars::IQCIRC), 1.10}, + {index(Vars::IPCIRC), 1.20}, + {index(Vars::IQMAX), 1.00}, + {index(Vars::IPMAX), 1.30}, + {index(Vars::IQBASE), 0.20}, + {index(Vars::IQRAW), 0.40}, + {index(Vars::IQCMD), 0.25}, + {index(Vars::IPCMD), 0.40}}); setDerivative(reecb, - {{I::VMEAS, 0.01}, - {I::PMEAS, -0.02}, - {I::XPIQ, 0.03}, - {I::XPIV, -0.04}, - {I::QV, 0.05}, - {I::PORD, -0.06}}); + {{index(Vars::VMEAS), 0.01}, + {index(Vars::PMEAS), -0.02}, + {index(Vars::XPIQ), 0.03}, + {index(Vars::XPIV), -0.04}, + {index(Vars::QV), 0.05}, + {index(Vars::PORD), -0.06}}); } /// Omitting every optional parameter must give exactly the model built @@ -1209,9 +1363,13 @@ namespace GridKit success *= scalarMatches(fixture.iqcmd(), iqcmd, "rejected iqcmd preservation"); success *= scalarMatches(fixture.ipcmd(), ipcmd, "rejected ipcmd preservation"); - for (size_t port = 0; port < E::MAXIMUM; ++port) + for (size_t port = 0; port < index(Ext::MAXIMUM); ++port) { - success &= rowMatches(fixture.input(port), 77.0, "external input", port, "changed"); + success &= rowMatches(fixture.input(static_cast(port)), + 77.0, + "external input", + port, + "changed"); } success *= vectorUnchanged(fixture.reecb.y(), y_before, "state"); success *= vectorUnchanged(fixture.reecb.yp(), yp_before, "derivative"); @@ -1245,15 +1403,16 @@ namespace GridKit /// Compare one vector row against its expected value. Every row check in /// this suite reports through here, so failures share one format. Rows - /// are named by position, which is the `ReecbIdx` constant the - /// expectation was written with, leaving no name string to maintain. + /// are named by the `ReecbInternalVariables` position the expectation was + /// written with, leaving no parallel name string to maintain. static bool rowMatches(RealT actual, RealT expected, const char* what, size_t row, - const char* context) + const char* context, + RealT tolerance = kBehaviorTol) { - if (isEqual(actual, expected, kBehaviorTol)) + if (isEqual(actual, expected, tolerance)) { return true; } @@ -1269,29 +1428,45 @@ namespace GridKit const Row* rows, size_t count, const char* what, - const char* context) const + const char* context, + RealT tolerance = kBehaviorTol) const { bool success = true; const auto* values = vector.getData(); for (size_t i = 0; i < count; ++i) { - const auto& [row, expected] = rows[i]; - success &= rowMatches(static_cast(values[row]), expected, what, row, context); + const auto& [row, expected] = rows[i]; + + success &= rowMatches(static_cast(values[row]), + expected, + what, + row, + context, + tolerance); } return success; } - bool residualsMatch(const ReecbT& reecb, Rows rows, const char* context = "") const + bool residualsMatch(const ReecbT& reecb, + Rows rows, + const char* context = "", + RealT tolerance = kBehaviorTol) const { - return rowsMatch(reecb.getResidual(), rows.begin(), rows.size(), "residual", context); + return rowsMatch(reecb.getResidual(), + rows.begin(), + rows.size(), + "residual", + context, + tolerance); } template bool residualsMatch(const ReecbT& reecb, const std::array& rows, - const char* context = "") const + const char* context = "", + RealT tolerance = kBehaviorTol) const { - return rowsMatch(reecb.getResidual(), rows.data(), size, "residual", context); + return rowsMatch(reecb.getResidual(), rows.data(), size, "residual", context, tolerance); } bool stateMatches(const ReecbT& reecb, Rows rows, const char* context = "") const @@ -1299,14 +1474,6 @@ namespace GridKit return rowsMatch(reecb.y(), rows.begin(), rows.size(), "state", context); } - template - bool stateMatches(const ReecbT& reecb, - const std::array& rows, - const char* context = "") const - { - return rowsMatch(reecb.y(), rows.data(), size, "state", context); - } - /// The model sits at a steady state: every residual and every derivative /// is zero. bool allResidualsZero(const ReecbT& reecb) const @@ -1316,8 +1483,18 @@ namespace GridKit const auto* yp = reecb.yp().getData(); for (size_t row = 0; row < static_cast(reecb.getResidual().getSize()); ++row) { - success &= rowMatches(static_cast(f[row]), 0.0, "residual", row, "at rest"); - success &= rowMatches(static_cast(yp[row]), 0.0, "derivative", row, "at rest"); + success &= rowMatches(static_cast(f[row]), + 0.0, + "residual", + row, + "at rest", + kSteadyStateTol); + success &= rowMatches(static_cast(yp[row]), + 0.0, + "derivative", + row, + "at rest", + kSteadyStateTol); } return success; } @@ -1345,6 +1522,60 @@ namespace GridKit } #ifdef GRIDKIT_ENABLE_ENZYME + using DependencyMap = DependencyTracking::Variable::DependencyMap; + + static constexpr size_t externalVariableIndex(Ext variable) + { + constexpr size_t terminal_bus_size = 2; + return index(Vars::MAXIMUM) + terminal_bus_size + index(variable); + } + + bool dependencyMatches(const std::vector& jacobian, + Vars row, + size_t column, + RealT expected, + const char* context) const + { + const auto& dependencies = jacobian[index(row)]; + const auto entry = dependencies.find(column); + RealT actual = 0.0; + if (entry != dependencies.end()) + { + actual = entry->second; + } + if (isEqual(actual, expected, kJacobianTol)) + { + return true; + } + + std::cout << "REECB Jacobian derivative (" << index(row) << ", " << column + << ") mismatch for " << context << ": " << std::setprecision(16) + << actual << " != " << expected << '\n'; + return false; + } + + bool dependencyMatches(const std::vector& jacobian, + Vars row, + Vars column, + RealT expected, + const char* context) const + { + return dependencyMatches(jacobian, row, index(column), expected, context); + } + + bool dependencyMatches(const std::vector& jacobian, + Vars row, + Ext column, + RealT expected, + const char* context) const + { + return dependencyMatches(jacobian, + row, + externalVariableIndex(column), + expected, + context); + } + void numberVariables(Fixture& fixture) const { auto* y = fixture.reecb.y().getData(); @@ -1361,9 +1592,10 @@ namespace GridKit { bus_y[i].setVariableNumber(model_size + i); } - for (size_t port = 0; port < E::MAXIMUM; ++port) + for (size_t port = 0; port < index(Ext::MAXIMUM); ++port) { - fixture.input(port).setVariableNumber(fixture.inputIndex(port)); + const auto variable = static_cast(port); + fixture.input(variable).setVariableNumber(fixture.inputIndex(variable)); } fixture.reecb.y().setDataUpdated(); diff --git a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp index 0feb2a8c7..9d6112314 100644 --- a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp +++ b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp @@ -1,3 +1,5 @@ +#include +#include #include #include @@ -296,6 +298,126 @@ namespace GridKit return success.report(__func__); } + TestOutcome reecb() + { + using ConstantParams = PhasorDynamics::ConstantSignalSourceParameters; + using ConstantOutputs = PhasorDynamics::ConstantSignalSourceSignalOutputs; + using Vars = PhasorDynamics::Converter::ReecbInternalVariables; + using Ext = PhasorDynamics::Converter::ReecbExternalVariables; + using ReecbT = PhasorDynamics::Converter::Reecb; + + TestStatus success = true; + + PhasorDynamics::SystemModelData data; + data.va_base = static_cast(100.0e6); + data.bus.resize(1); + data.bus[0].bus_id = static_cast(1); + data.bus[0].bus_type = PhasorDynamics::BusData::BusType::SLACK; + data.bus[0].Vr0 = static_cast(1.0); + data.bus[0].Vi0 = static_cast(0.0); + + data.signal.resize(static_cast(ipcmd_signal_id)); + for (size_t signal = 0; signal < data.signal.size(); ++signal) + { + data.signal[signal].signal_id = pe_signal_id + static_cast(signal); + } + + data.reecb.push_back(makeReecbData()); + + typename PhasorDynamics::SystemModelData::ConstantSourceT source; + source.parameters[ConstantParams::Sr] = static_cast(0.0); + source.parameters[ConstantParams::Si] = static_cast(0.0); + source.signal_outputs[ConstantOutputs::sr] = pe_signal_id; + source.signal_outputs[ConstantOutputs::si] = qgen_signal_id; + data.constant_source.push_back(source); + + source.signal_outputs[ConstantOutputs::sr] = qext_signal_id; + source.signal_outputs[ConstantOutputs::si] = pfaref_signal_id; + data.constant_source.push_back(source); + + source.signal_outputs.erase(ConstantOutputs::si); + source.signal_outputs[ConstantOutputs::sr] = pref_signal_id; + data.constant_source.push_back(source); + + PhasorDynamics::SystemModel system(data); + + success *= system.allocate() == 0; + system.getSignal(iqcmd_signal_id)->init(static_cast(0.05)); + system.getSignal(ipcmd_signal_id)->init(static_cast(0.25)); + success *= system.verify() == 0; + for (IdxT signal_id = pe_signal_id; signal_id <= ipcmd_signal_id; ++signal_id) + { + success *= system.getSignal(signal_id)->linked(); + } + success *= system.initialize() == 0; + success *= system.tagDifferentiable() == 0; + success *= system.evaluateResidual() == 0; + success *= system.evaluateJacobian() == 0; + success *= system.size() == static_cast(Vars::MAXIMUM); + + auto* reecb = dynamic_cast(system.getComponent(static_cast(0))); + success *= reecb != nullptr; + if (reecb != nullptr) + { + auto& signals = reecb->getSignals(); + success *= signals.template readExternalVariableIndex() + == system.getSignal(pe_signal_id)->getVariableIndex(); + success *= signals.template readExternalVariableIndex() + == system.getSignal(qgen_signal_id)->getVariableIndex(); + success *= signals.template readExternalVariableIndex() + == system.getSignal(qext_signal_id)->getVariableIndex(); + success *= signals.template readExternalVariableIndex() + == system.getSignal(pfaref_signal_id)->getVariableIndex(); + success *= signals.template readExternalVariableIndex() + == system.getSignal(pref_signal_id)->getVariableIndex(); + success *= signals.template getSignalNode() + == system.getSignal(iqcmd_signal_id); + success *= signals.template getSignalNode() + == system.getSignal(ipcmd_signal_id); + + const auto* residual = reecb->getResidual().getData(); + for (size_t row = 0; row < static_cast(reecb->size()); ++row) + { + if (!isEqual(static_cast(residual[row]), + static_cast(0.0), + static_cast(1.0e-9))) + { + std::cout << "REECB SystemModel residual row " << row + << " is not at steady state: " << std::setprecision(16) + << residual[row] << '\n'; + success = false; + } + } + + const std::array(ipcmd_signal_id)> expected_signals{ + 0.25, + 0.05, + 0.05, + 0.0, + 0.25, + 0.05, + 0.25, + }; + for (size_t signal = 0; signal < expected_signals.size(); ++signal) + { + success *= isEqual( + static_cast(system.getSignal(pe_signal_id + static_cast(signal))->read()), + expected_signals[signal], + static_cast(1.0e-9)); + } + + // The component/system base ratio is two. Perturbing only the + // system-base command therefore changes its component-base residual + // by twice the perturbation. + system.getSignal(iqcmd_signal_id)->init(static_cast(0.06)); + success *= system.evaluateResidual() == 0; + success *= isEqual(static_cast(residual[static_cast(Vars::IQCMD)]), + static_cast(-0.02), + static_cast(1.0e-9)); + } + return success.report(__func__); + } + TestOutcome genrou() { TestStatus success = true; @@ -444,6 +566,36 @@ namespace GridKit data.parameters[Params::Vhvmax] = static_cast(1.2); return data; } + + static constexpr IdxT pe_signal_id = 1; + static constexpr IdxT qgen_signal_id = 2; + static constexpr IdxT qext_signal_id = 3; + static constexpr IdxT pfaref_signal_id = 4; + static constexpr IdxT pref_signal_id = 5; + static constexpr IdxT iqcmd_signal_id = 6; + static constexpr IdxT ipcmd_signal_id = 7; + + auto makeReecbData() const -> PhasorDynamics::Converter::ReecbData + { + using Params = PhasorDynamics::Converter::ReecbParameters; + using Buses = PhasorDynamics::Converter::ReecbBuses; + using Inputs = PhasorDynamics::Converter::ReecbSignalInputs; + using Outputs = PhasorDynamics::Converter::ReecbSignalOutputs; + + PhasorDynamics::Converter::ReecbData data; + data.device_class = "Reecb"; + data.disambiguation_string = "reecb_test"; + data.buses[Buses::bus] = static_cast(1); + data.parameters[Params::mva] = static_cast(50.0); + data.signal_inputs[Inputs::pe] = pe_signal_id; + data.signal_inputs[Inputs::qgen] = qgen_signal_id; + data.signal_inputs[Inputs::qext] = qext_signal_id; + data.signal_inputs[Inputs::pfaref] = pfaref_signal_id; + data.signal_inputs[Inputs::pref] = pref_signal_id; + data.signal_outputs[Outputs::iqcmd] = iqcmd_signal_id; + data.signal_outputs[Outputs::ipcmd] = ipcmd_signal_id; + return data; + } }; } // namespace Testing diff --git a/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp b/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp index 65ec715a8..7fe1dcbe0 100644 --- a/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp @@ -17,6 +17,7 @@ int main() result += test.loadZIP(); result += test.regca(); result += test.repca(); + result += test.reecb(); result += test.genrou(); result += test.genClassical(); result += test.tgov1(); From 08d82a9de9d86fbbf75360b197d1ddbad97044d7 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Thu, 30 Jul 2026 12:46:13 -0500 Subject: [PATCH 04/16] One correction and moslty cleanup --- .../PhasorDynamics/Converter/REECB/README.md | 28 ++++++++------- .../PhasorDynamics/Converter/REECB/Reecb.hpp | 6 ++-- .../Converter/REECB/ReecbImpl.hpp | 35 +++++++++---------- .../PhasorDynamics/ConverterReecbTests.hpp | 24 +++++++------ 4 files changed, 49 insertions(+), 44 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/README.md b/GridKit/Model/PhasorDynamics/Converter/REECB/README.md index ccc8e4170..251cabfe5 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/README.md +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/README.md @@ -35,7 +35,7 @@ $V_{\mathrm{dip}}$ | [p.u.] | `Vdip` | Low-voltage threshol $V_{\mathrm{up}}$ | [p.u.] | `Vup` | High-voltage threshold for the voltage-band gate | 1.15 | $D_1^\mathrm{db}$ | [p.u.] | `dbd1` | Lower deadband threshold for voltage-error response | 0.0 | $D_2^\mathrm{db}$ | [p.u.] | `dbd2` | Upper deadband threshold for voltage-error response | 0.0 | -$K_{\mathrm{qv}}$ | [p.u.] | `kqv` | Reactive-current injection gain outside the voltage band | 5.0 | +$K_{\mathrm{qv}}$ | [p.u.] | `kqv` | Reactive-current injection gain | 5.0 | $I_{q,\mathrm{inj}}^{\min}$ | [p.u.] | `Iql1` | Minimum reactive-current injection limit | -1.1 | $I_{q,\mathrm{inj}}^{\max}$ | [p.u.] | `Iqh1` | Maximum reactive-current injection limit | 1.1 | $Q^{\max}$ | [p.u.] | `Qmax` | Maximum reactive-power control limit | 0.436 | @@ -104,6 +104,8 @@ Every equation below uses the raised time constants: &= 1 - s_V \\ s_Q^\mathrm{off} &= 1 - s_Q \\ + s_{PQ}^\mathrm{off} + &= 1 - s_{PQ} \\ k_\mathrm{base} &= \dfrac{S^\mathrm{sys}}{S^\mathrm{base}} \end{aligned} @@ -151,7 +153,7 @@ $V_T$ | [p.u.] | Terminal voltage magnitude $V_{\mathrm{safe}}^\mathrm{meas}$ | [p.u.] | Safe filtered terminal voltage for divider blocks | Lower bounded by 0.01 $s_{\mathrm{dip}}$ | [-] | Smooth voltage inside-band control gate | Approximately 1 inside the voltage band $e_V^\mathrm{db}$ | [p.u.] | Deadbanded voltage error | -$I_q^\mathrm{inj}$ | [p.u.] | Reactive-current injection candidate | Component base +$I_q^\mathrm{inj}$ | [p.u.] | Reactive-current injection | Component base $Q^\mathrm{ref}$ | [p.u.] | Selected reactive-power reference | $e_Q$ | [p.u.] | Reactive-power control error | $V_Q^\mathrm{PI}$ | [p.u.] | Reactive-power control PI output | @@ -280,9 +282,9 @@ target and smooth approximation. + s_V^\mathrm{off}Q^\mathrm{ref} - V^\mathrm{meas} \\ 0 &= - -f_P^\mathrm{ord} - + \dfrac{1}{T_{\mathrm{pord}}} - \left(k_\mathrm{base}P^\mathrm{ref} - P^\mathrm{ord}\right) \\ + -T_{\mathrm{pord}}f_P^\mathrm{ord} + + k_\mathrm{base}P^\mathrm{ref} + - P^\mathrm{ord} \\ 0 &= -r_P^\mathrm{ord} + \text{clamp} @@ -294,15 +296,15 @@ target and smooth approximation. 0 &= -\left(I_p^\mathrm{circ}\right)^2 + \left(I^{\max}\right)^2 - - \left(1-s_{PQ}\right)\left(k_\mathrm{base}I_q^\mathrm{cmd}\right)^2 \\ + - s_{PQ}^\mathrm{off}\left(k_\mathrm{base}I_q^\mathrm{cmd}\right)^2 \\ 0 &= -I_q^{\max} - + \left(1-s_{PQ}\right)I^{\max} + + s_{PQ}^\mathrm{off}I^{\max} + s_{PQ}I_q^\mathrm{circ} \\ 0 &= -I_p^{\max} + s_{PQ}I^{\max} - + \left(1-s_{PQ}\right)I_p^\mathrm{circ} \\ + + s_{PQ}^\mathrm{off}I_p^\mathrm{circ} \\ 0 &= -I_q^\mathrm{base} + \text{clamp} @@ -312,7 +314,7 @@ target and smooth approximation. -I_q^\mathrm{raw} + s_Q I_q^\mathrm{base} + s_Q^\mathrm{off}Q_V - + \left(1-s_{\mathrm{dip}}\right)I_q^\mathrm{inj} \\ + + I_q^\mathrm{inj} \\ 0 &= -k_\mathrm{base} I_q^\mathrm{cmd} + \text{clamp} @@ -412,9 +414,9 @@ Subscript $0$ denotes initial values; all internal derivatives start at zero: I^{\max} & s_{PQ}=1 \end{cases} \\ I_{q,0}^{\max} - &= (1-s_{PQ})I^{\max}+s_{PQ}I_{q,0}^\mathrm{circ} \\ + &= s_{PQ}^\mathrm{off}I^{\max}+s_{PQ}I_{q,0}^\mathrm{circ} \\ I_{p,0}^{\max} - &= s_{PQ}I^{\max}+(1-s_{PQ})I_{p,0}^\mathrm{circ} + &= s_{PQ}I^{\max}+s_{PQ}^\mathrm{off}I_{p,0}^\mathrm{circ} \end{aligned} ``` @@ -439,7 +441,7 @@ Subscript $0$ denotes initial values; all internal derivatives start at zero: -I_{q,0}^{\max},I_{q,0}^{\max}\right) \\ I_{q,0}^\mathrm{control} &= I_{q,0}^\mathrm{raw} - -(1-s_{\mathrm{dip},0})I_{q,0}^\mathrm{inj} \\ + -I_{q,0}^\mathrm{inj} \\ u_{p,0} &= \text{clamp}^{-1} \left(I_p^\mathrm{seed};\,0,I_{p,0}^{\max}\right) \\ @@ -525,7 +527,7 @@ Subscript $0$ denotes initial values; all internal derivatives start at zero: &= -I_{q,0}^\mathrm{raw} +s_Q I_{q,0}^\mathrm{base} +s_Q^\mathrm{off}Q_{V,0} - +(1-s_{\mathrm{dip},0})I_{q,0}^\mathrm{inj} + +I_{q,0}^\mathrm{inj} \end{aligned} ``` diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp b/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp index 9cdf2dc3b..9089a74c3 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp @@ -40,7 +40,7 @@ namespace GridKit VMEASSAFE, ///< \f$V_\mathrm{safe}^\mathrm{meas}\f$ Safe filtered voltage SDIP, ///< \f$s_\mathrm{dip}\f$ Voltage inside-band control gate VERR, ///< \f$e_V^\mathrm{db}\f$ Deadbanded voltage error - IQV, ///< \f$I_q^\mathrm{inj}\f$ Reactive-current injection candidate + IQV, ///< \f$I_q^\mathrm{inj}\f$ Reactive-current injection QREF, ///< \f$Q^\mathrm{ref}\f$ Selected reactive-power reference EQ, ///< \f$e_Q\f$ Reactive-power control error VPIQ, ///< \f$V_Q^\mathrm{PI}\f$ Reactive-power PI output @@ -207,8 +207,8 @@ namespace GridKit RealT v_off_{1}; RealT q_on_{0}; RealT q_off_{1}; - RealT p_priority_{0}; - RealT q_priority_{1}; + RealT pq_on_{0}; + RealT pq_off_{1}; // Unattached signal setpoints ScalarT pe_set_{0}; diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp index 28a52ebef..a3c237788 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp @@ -130,12 +130,12 @@ namespace GridKit v_off_ = ONE - v_on_; q_off_ = ONE - q_on_; - p_priority_ = ZERO; + pq_on_ = ZERO; if (Pqflag_) { - p_priority_ = ONE; + pq_on_ = ONE; } - q_priority_ = ONE - p_priority_; + pq_off_ = ONE - pq_on_; } /** @@ -724,8 +724,8 @@ namespace GridKit return 1; } - const RealT iqcirc_squared0 = current_limit_squared0 - p_priority_ * ipcmd0_value * ipcmd0_value; - const RealT ipcirc_squared0 = current_limit_squared0 - q_priority_ * iqcmd0_value * iqcmd0_value; + const RealT iqcirc_squared0 = current_limit_squared0 - pq_on_ * ipcmd0_value * ipcmd0_value; + const RealT ipcirc_squared0 = current_limit_squared0 - pq_off_ * iqcmd0_value * iqcmd0_value; if (iqcirc_squared0 < -INITIALIZATION_TOLERANCE || ipcirc_squared0 < -INITIALIZATION_TOLERANCE) { Log::error() << "Reecb: initial current commands violate the selected priority circle\n"; @@ -734,13 +734,12 @@ namespace GridKit const ScalarT iqcirc0 = static_cast(std::sqrt(std::max(iqcirc_squared0, ZERO))); const ScalarT ipcirc0 = static_cast(std::sqrt(std::max(ipcirc_squared0, ZERO))); - const ScalarT iqmax0 = q_priority_ * static_cast(Imax_) + p_priority_ * iqcirc0; - const ScalarT ipmax0 = p_priority_ * static_cast(Imax_) + q_priority_ * ipcirc0; + const ScalarT iqmax0 = pq_off_ * static_cast(Imax_) + pq_on_ * iqcirc0; + const ScalarT ipmax0 = pq_on_ * static_cast(Imax_) + pq_off_ * ipcirc0; - const ScalarT sdip0 = Math::inside(vt0, Vdip_, Vup_); - const ScalarT verr0 = Math::deadband2(static_cast(vref0) - vmeas0, dbd1_, dbd2_); - const ScalarT iqv0 = Math::clamp(kqv_ * verr0, Iql1_, Iqh1_); - const ScalarT iqinj0 = (ONE - sdip0) * iqv0; + const ScalarT sdip0 = Math::inside(vt0, Vdip_, Vup_); + const ScalarT verr0 = Math::deadband2(static_cast(vref0) - vmeas0, dbd1_, dbd2_); + const ScalarT iqv0 = Math::clamp(kqv_ * verr0, Iql1_, Iqh1_); ScalarT iqraw0{}; if (!solveLimiterInput(iqcmd0, -iqmax0, iqmax0, iqraw0)) @@ -766,7 +765,7 @@ namespace GridKit const ScalarT rpord0 = Math::clamp(fpord0, dPmin_, dPmax_); const ScalarT pref0 = pord0 + Tpord_ * fpord0; - const ScalarT iq_control0 = iqraw0 - iqinj0; + const ScalarT iq_control0 = iqraw0 - iqv0; struct ReactiveSeed { @@ -1075,14 +1074,14 @@ namespace GridKit f[EQ] = -eq + Math::clamp(qref, Qmin_, Qmax_) - qgen; f[VPIQ] = -vpiq + Math::clamp(Kqp_ * eq + xpiq, Vmin_, Vmax_); f[EPIV] = -epiv + v_on_ * vpiq + v_off_ * qref - vmeas; - f[FPORD] = -fpord + (pref - pord) / Tpord_; + f[FPORD] = -Tpord_ * fpord + pref - pord; f[RPORD] = -rpord + Math::clamp(fpord, dPmin_, dPmax_); - f[IQCIRC] = -iqcirc * iqcirc + Imax_ * Imax_ - p_priority_ * ipcmd * ipcmd; - f[IPCIRC] = -ipcirc * ipcirc + Imax_ * Imax_ - q_priority_ * iqcmd * iqcmd; - f[IQMAX] = -iqmax + q_priority_ * Imax_ + p_priority_ * iqcirc; - f[IPMAX] = -ipmax + p_priority_ * Imax_ + q_priority_ * ipcirc; + f[IQCIRC] = -iqcirc * iqcirc + Imax_ * Imax_ - pq_on_ * ipcmd * ipcmd; + f[IPCIRC] = -ipcirc * ipcirc + Imax_ * Imax_ - pq_off_ * iqcmd * iqcmd; + f[IQMAX] = -iqmax + pq_off_ * Imax_ + pq_on_ * iqcirc; + f[IPMAX] = -ipmax + pq_on_ * Imax_ + pq_off_ * ipcirc; f[IQBASE] = -iqbase + Math::clamp(Kvp_ * epiv + xpiv, -iqmax, iqmax); - f[IQRAW] = -iqraw + q_on_ * iqbase + q_off_ * qv + (ONE - sdip) * iqv; + f[IQRAW] = -iqraw + q_on_ * iqbase + q_off_ * qv + iqv; f[IQCMD] = -iqcmd + Math::clamp(iqraw, -iqmax, iqmax); f[IPCMD] = -ipcmd + Math::clamp(pord / vmeas_safe, ZERO, ipmax); diff --git a/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp b/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp index 24524fa30..d08dd56b9 100644 --- a/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp @@ -239,6 +239,7 @@ namespace GridKit scenario_data.parameters[Params::VFlag] = v_flag; scenario_data.parameters[Params::QFlag] = q_flag; scenario_data.parameters[Params::Pqflag] = p_priority; + scenario_data.parameters[Params::kqv] = 0.0; Fixture scenario(scenario_data); scenario.attachAllInputs(99.0); @@ -263,14 +264,17 @@ namespace GridKit } } - // Both voltage-band exits exercise the initialization compensation - // that removes Iq injection from the selected reactive-control path. - for (const RealT terminal_voltage : {static_cast(0.6), static_cast(1.3)}) + // An in-band point and both voltage-band exits exercise the direct + // Iq-injection compensation during initialization. + for (const RealT terminal_voltage : + {static_cast(0.6), static_cast(1.1), static_cast(1.3)}) { auto voltage_data = makeDynamicData(); - voltage_data.parameters[Params::QFlag] = false; + voltage_data.parameters[Params::QFlag] = true; voltage_data.parameters[Params::VFlag] = true; voltage_data.parameters[Params::Vref0] = 1.0; + voltage_data.parameters[Params::Vmin] = 0.5; + voltage_data.parameters[Params::Vmax] = 1.4; Fixture voltage_scenario(voltage_data, terminal_voltage); voltage_scenario.attachAllInputs(); @@ -431,14 +435,14 @@ namespace GridKit {index(Vars::EQ), 0.3499999999999999}, {index(Vars::VPIQ), -0.1000000000000002}, {index(Vars::EPIV), -0.04999999999999993}, - {index(Vars::FPORD), -0.1000000000000003}, + {index(Vars::FPORD), -0.02500000000000002}, {index(Vars::RPORD), 0.05000000000000004}, {index(Vars::IQCIRC), 0.3999999999999997}, {index(Vars::IPCIRC), 0.8100000000000001}, {index(Vars::IQMAX), 0.1000000000000001}, {index(Vars::IPMAX), 0.2}, {index(Vars::IQBASE), -0.3699999999999998}, - {index(Vars::IQRAW), -0.17}, + {index(Vars::IQRAW), -0.05000000000000002}, {index(Vars::IQCMD), -0.1000000000000001}, {index(Vars::IPCMD), -0.1229166666666667}, }}; @@ -469,10 +473,10 @@ namespace GridKit // Toggle exactly one selector at a time so an accidental swap between // PfFlag, VFlag, and QFlag cannot satisfy the same answer key. const std::array cases{{ - {"all-off selectors", false, false, false, 0.4, -0.55, 0.31}, - {"PfFlag-only selectors", true, false, false, 0.11149051952976989, -0.8385094804702301, 0.31}, - {"VFlag-only selectors", false, true, false, 0.4, 0.05, 0.31}, - {"QFlag-only selectors", false, false, true, 0.4, -0.55, 0.21}, + {"all-off selectors", false, false, false, 0.4, -0.55, 0.4}, + {"PfFlag-only selectors", true, false, false, 0.11149051952976989, -0.8385094804702301, 0.4}, + {"VFlag-only selectors", false, true, false, 0.4, 0.05, 0.4}, + {"QFlag-only selectors", false, false, true, 0.4, -0.55, 0.3}, }}; for (const auto& test_case : cases) From 1dd4301c5f58fc408dfca2695ed1854338f3ba0c Mon Sep 17 00:00:00 2001 From: lukelowry Date: Sun, 2 Aug 2026 20:16:58 -0500 Subject: [PATCH 05/16] Consistant and tested implementation --- GridKit/CommonMath.hpp | 2 + .../Model/PhasorDynamics/Converter/README.md | 5 +- .../PhasorDynamics/Converter/REECB/README.md | 821 +++--- .../PhasorDynamics/Converter/REECB/Reecb.cpp | 6 +- .../PhasorDynamics/Converter/REECB/Reecb.hpp | 111 +- .../Converter/REECB/ReecbData.hpp | 102 +- .../REECB/ReecbDependencyTracking.cpp | 9 +- .../Converter/REECB/ReecbEnzyme.cpp | 149 +- .../Converter/REECB/ReecbImpl.hpp | 1636 ++++++------ .../Model/PhasorDynamics/SystemModelImpl.hpp | 15 +- .../PhasorDynamics/CMakeLists.txt | 9 + .../PhasorDynamics/ReecbIntegrationTests.hpp | 461 ++++ .../runReecbIntegrationTests.cpp | 16 + .../Math/SmoothnessIndicatorTests.hpp | 20 + .../PhasorDynamics/ConverterReecbTests.hpp | 2326 +++++++++-------- .../SystemSingleComponentTests.hpp | 208 +- .../PhasorDynamics/runConverterReecbTests.cpp | 11 +- .../runSystemSingleComponentTests.cpp | 1 + 18 files changed, 3289 insertions(+), 2619 deletions(-) create mode 100644 tests/IntegrationTests/PhasorDynamics/ReecbIntegrationTests.hpp create mode 100644 tests/IntegrationTests/PhasorDynamics/runReecbIntegrationTests.cpp diff --git a/GridKit/CommonMath.hpp b/GridKit/CommonMath.hpp index f3e10d6d3..675a61a73 100644 --- a/GridKit/CommonMath.hpp +++ b/GridKit/CommonMath.hpp @@ -348,6 +348,7 @@ namespace GridKit * @param[in] limit_max - Maximum limit * @return Scalar value in [0, 1]: 1 when dynamics should pass through, * 0 when integration should be blocked. + * @pre `limit_min <= limit_max`; equal bounds are supported. * * @note The limit types intentionally may differ from the scalar type so * that constant Real limits and algebraic-variable limits both work. @@ -388,6 +389,7 @@ namespace GridKit * @param[in] limit_min - Minimum limit * @param[in] limit_max - Maximum limit * @return Smooth anti-windup limited derivative + * @pre `limit_min <= limit_max`; equal bounds are supported. * * @note The limit types intentionally may differ from the scalar type so * that constant Real limits and algebraic-variable limits both work. diff --git a/GridKit/Model/PhasorDynamics/Converter/README.md b/GridKit/Model/PhasorDynamics/Converter/README.md index 30b760ecb..66a88f463 100644 --- a/GridKit/Model/PhasorDynamics/Converter/README.md +++ b/GridKit/Model/PhasorDynamics/Converter/README.md @@ -2,8 +2,9 @@ ## Introduction -Converter models represent inverter-coupled resources in the phasor dynamics model. They provide the network interface between renewable-energy control -models and the bus equations, typically through commanded active and reactive current components. +Converter models represent inverter-coupled resources in the phasor dynamics model. Generator/converter models provide the network interface between +renewable-energy control models and the bus equations, while electrical-control models produce the commanded active and reactive current components +that drive them. ## Types diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/README.md b/GridKit/Model/PhasorDynamics/Converter/REECB/README.md index 251cabfe5..39146d83c 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/README.md +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/README.md @@ -1,16 +1,18 @@ # **Renewable Energy Electrical Control Model (REECB)** -REECB is a WECC renewable electrical-control model for inverter-coupled -resources. +REECB is a WECC renewable electrical-control model with power-factor, +reactive-power, voltage, and active-power command paths for an +inverter-coupled resource. ## Notes -- REECB is a control model only. It measures the terminal bus and publishes - current commands; it injects no current into the network. -- When used with REPCA active-power control, connect REPCA `pext` to REECB `pref`. -- Internal power/current states and limiter quantities are on component base. -- Power/current signal ports and the `iqcmd`/`ipcmd` outputs are on system base; - `pmeas` is monitored on component base. +- Current commands and power signals are on system base. +- Internal control states and the reactive-power, active-power, and current + limits are on REECB component base. +- REECB uses `mva` as its component power base. +- In direct-voltage mode ($s_Q=1$, $s_V=0$) `qext` carries a terminal-voltage + reference instead of a system-base reactive power. +- REECB contributes no bus current injection. ## Block Diagram @@ -22,92 +24,82 @@ Figure 1: REECB electrical-control model. Figure courtesy of the ## Model Parameters Symbol | Units | JSON | Description | Typical Value | Note -------------------------------------|----------|----------|---------------------------------------------------------|---------------|------ -$S^\mathrm{base}$ | [MVA] | `mva` | REECB component power base | 100.0 | Required positive value; block name: `MVABase` -$s_{\mathrm{pf}}$ | [binary] | `PfFlag` | Power-factor control flag | 0 | Block name: `PfFlag`; 1 = power-factor control, 0 = Q control -$s_V$ | [binary] | `VFlag` | Voltage-control mode flag | 0 | Block name: `VFlag`; 1 = Q control, 0 = voltage control -$s_Q$ | [binary] | `QFlag` | Reactive-power control flag | 0 | Block name: `QFlag`; 1 = voltage/Q control, 0 = constant pf or Q control -$s_{PQ}$ | [binary] | `Pqflag` | P/Q priority flag for converter current limit | 0 | Block name: `Pqflag`; 0 = Q priority, 1 = P priority -$T_{\mathrm{rv}}$ | [sec] | `Trv` | Voltage-measurement filter time constant | 0.02 | State 1; raised to the minimum-time floor -$T_{\mathrm{p}}$ | [sec] | `Tp` | Electrical-power measurement filter time constant | 0.0 | State 2; raised to the minimum-time floor -$V_0^\mathrm{ref}$ | [p.u.] | `Vref0` | Outer-loop voltage reference | $V_{T,0}$ | Initialized from terminal voltage if omitted -$V_{\mathrm{dip}}$ | [p.u.] | `Vdip` | Low-voltage threshold for the voltage-band gate | 0.85 | -$V_{\mathrm{up}}$ | [p.u.] | `Vup` | High-voltage threshold for the voltage-band gate | 1.15 | +------------------------------------|----------|----------|---------------------------------------------------------|---------------|----- +$S^\mathrm{base}$ | [MVA] | `mva` | REECB component power base | 100.0 | System power base when omitted +$s_\mathrm{pf}$ | [binary] | `PfFlag` | Power-factor control selector | 0 | 1 = power-factor control, 0 = reactive-power control +$s_V$ | [binary] | `VFlag` | Voltage-reference selector under $s_Q=1$ | 0 | 1 = cascaded Q-PI voltage command, 0 = direct external voltage reference +$s_Q$ | [binary] | `QFlag` | Reactive-path selector | 0 | 1 = Volt/VAr PI control, 0 = reactive-current lag +$s_{PQ}$ | [binary] | `Pqflag` | Converter current-priority selector | 0 | 1 = P priority, 0 = Q priority +$T_\mathrm{rv}$ | [sec] | `Trv` | Voltage-measurement filter time constant | 0.02 | State 1 in Fig. 1 +$T_\mathrm{p}$ | [sec] | `Tp` | Electrical-power measurement filter time constant | 0.0 | State 2 in Fig. 1 +$V^\mathrm{ref}$ | [p.u.] | `Vref0` | Reactive-current-injection voltage reference | $V_T$ | Initialized from terminal voltage when omitted +$V_\mathrm{dip}$ | [p.u.] | `Vdip` | Low-voltage threshold for the voltage-band gate | 0.85 | +$V_\mathrm{up}$ | [p.u.] | `Vup` | High-voltage threshold for the voltage-band gate | 1.15 | $D_1^\mathrm{db}$ | [p.u.] | `dbd1` | Lower deadband threshold for voltage-error response | 0.0 | $D_2^\mathrm{db}$ | [p.u.] | `dbd2` | Upper deadband threshold for voltage-error response | 0.0 | -$K_{\mathrm{qv}}$ | [p.u.] | `kqv` | Reactive-current injection gain | 5.0 | -$I_{q,\mathrm{inj}}^{\min}$ | [p.u.] | `Iql1` | Minimum reactive-current injection limit | -1.1 | -$I_{q,\mathrm{inj}}^{\max}$ | [p.u.] | `Iqh1` | Maximum reactive-current injection limit | 1.1 | -$Q^{\max}$ | [p.u.] | `Qmax` | Maximum reactive-power control limit | 0.436 | -$Q^{\min}$ | [p.u.] | `Qmin` | Minimum reactive-power control limit | -0.436 | -$K_{\mathrm{qp}}$ | [p.u.] | `Kqp` | Reactive-power control proportional gain | 0.0 | -$K_{\mathrm{qi}}$ | [p.u./s] | `Kqi` | Reactive-power control integral gain | 0.1 | -$V^{\max}$ | [p.u.] | `Vmax` | Maximum voltage-control limit | 1.1 | -$V^{\min}$ | [p.u.] | `Vmin` | Minimum voltage-control limit | 0.9 | -$K_{\mathrm{vp}}$ | [p.u.] | `Kvp` | Voltage-control proportional gain | 18.0 | -$K_{\mathrm{vi}}$ | [p.u./s] | `Kvi` | Voltage-control integral gain | 5.0 | -$T_{\mathrm{iq}}$ | [sec] | `Tiq` | Reactive-current command lag time constant | 0.02 | State 5; raised to the minimum-time floor -$T_{\mathrm{pord}}$ | [sec] | `Tpord` | Active-power order filter time constant | 0.02 | State 6; raised to the minimum-time floor +$K_\mathrm{qv}$ | [p.u.] | `kqv` | Reactive-current injection gain | 5.0 | +$I_{q,\mathrm{inj}}^{\min}$ | [p.u.] | `Iql1` | Minimum reactive-current injection | -1.1 | +$I_{q,\mathrm{inj}}^{\max}$ | [p.u.] | `Iqh1` | Maximum reactive-current injection | 1.1 | +$Q^{\max}$ | [p.u.] | `Qmax` | Maximum reactive-power control output | 0.436 | +$Q^{\min}$ | [p.u.] | `Qmin` | Minimum reactive-power control output | -0.436 | +$K_\mathrm{qp}$ | [p.u.] | `Kqp` | Reactive-power controller proportional gain | 0.0 | +$K_\mathrm{qi}$ | [p.u./s] | `Kqi` | Reactive-power controller integral gain | 0.1 | +$V^{\max}$ | [p.u.] | `Vmax` | Maximum voltage-control output | 1.1 | +$V^{\min}$ | [p.u.] | `Vmin` | Minimum voltage-control output | 0.9 | +$K_\mathrm{vp}$ | [p.u.] | `Kvp` | Voltage controller proportional gain | 18.0 | +$K_\mathrm{vi}$ | [p.u./s] | `Kvi` | Voltage controller integral gain | 5.0 | +$T_\mathrm{iq}$ | [sec] | `Tiq` | Reactive-current command lag time constant | 0.02 | State 5 in Fig. 1 +$T_\mathrm{pord}$ | [sec] | `Tpord` | Active-power order filter time constant | 0.02 | State 6 in Fig. 1 $R_P^{\max}$ | [p.u./s] | `dPmax` | Positive active-power order ramp-rate limit | 99.0 | $R_P^{\min}$ | [p.u./s] | `dPmin` | Negative active-power order ramp-rate limit | -99.0 | -$P^{\max}$ | [p.u.] | `Pmax` | Maximum active-power order limit | 1.0 | -$P^{\min}$ | [p.u.] | `Pmin` | Minimum active-power order limit | 0.0 | -$I^{\max}$ | [p.u.] | `Imax` | Maximum total converter current | 1.3 | +$P^{\max}$ | [p.u.] | `Pmax` | Maximum active-power order | 1.0 | +$P^{\min}$ | [p.u.] | `Pmin` | Minimum active-power order | 0.0 | +$I^{\max}$ | [p.u.] | `Imax` | Maximum converter current | 1.3 | -Only `mva` is required. Every omitted control parameter uses the Typical Value -shown above; when `Vref0` is omitted, it is initialized from terminal voltage. +All parameters are optional. An omitted parameter starts from its Typical +Value; the time-constant floor below is then applied. ### Parameter Validation -Invalid REECB parameter sets are rejected by the following checks. Nonnegative -time constants below $\epsilon_T=10^{-3}\ \mathrm{s}$ are raised to -$\epsilon_T$ and logged as a warning. +Invalid REECB parameter sets are rejected by the following checks: ```math \begin{aligned} - S^\mathrm{base} &> 0 \\ - s_{\mathrm{pf}}, s_V, s_Q, s_{PQ} - &\in \{0,1\} \\ - T_{\mathrm{rv}}, T_{\mathrm{p}}, T_{\mathrm{iq}}, T_{\mathrm{pord}} - &\ge 0 \\ - V_{\mathrm{dip}} - &< V_{\mathrm{up}} \\ - D_1^\mathrm{db} - &\le 0 \le D_2^\mathrm{db} \\ - I_{q,\mathrm{inj}}^{\min} - &\le I_{q,\mathrm{inj}}^{\max} \\ - Q^{\min} - &\le Q^{\max} \\ - V^{\min} - &\le V^{\max} \\ - R_P^{\min} - &< 0 < R_P^{\max} \\ - P^{\min} - &\le P^{\max} \\ - I^{\max} - &\ge 0 + S^\mathrm{base} &> 0,\quad \text{when provided} \\ + s_\mathrm{pf},s_V,s_Q,s_{PQ} &\in\{0,1\} \\ + T_\mathrm{rv},T_\mathrm{p},T_\mathrm{iq},T_\mathrm{pord} &\ge 0 \\ + V_\mathrm{dip} &< V_\mathrm{up} \\ + D_1^\mathrm{db} &\le 0 \le D_2^\mathrm{db} \\ + I_{q,\mathrm{inj}}^{\min} &\le I_{q,\mathrm{inj}}^{\max} \\ + Q^{\min} &\le Q^{\max} \\ + V^{\min} &\le V^{\max} \\ + R_P^{\min} &< 0 < R_P^{\max} \\ + P^{\min} &\le P^{\max} \\ + I^{\max} &> 0 \\ + s_\mathrm{pf}\,s_Q\,(1-s_V) &= 0. \end{aligned} ``` +The last condition rejects power-factor control combined with the +direct-voltage reference, which has no meaningful reactive target. + ### Model Derived Parameters -Every equation below uses the raised time constants: +Let $\epsilon_T=10^{-3}\ \mathrm{s}$. A time constant below $\epsilon_T$ is +raised to that floor in place, so every equation below uses the raised value: ```math \begin{aligned} T_x - &\leftarrow \text{max}\left(T_x,\epsilon_T\right), - \quad x\in\{\mathrm{rv},\mathrm{p},\mathrm{iq},\mathrm{pord}\} \\ - s_{\mathrm{pf}}^\mathrm{off} - &= 1 - s_{\mathrm{pf}} \\ - s_V^\mathrm{off} - &= 1 - s_V \\ - s_Q^\mathrm{off} - &= 1 - s_Q \\ - s_{PQ}^\mathrm{off} - &= 1 - s_{PQ} \\ - k_\mathrm{base} - &= \dfrac{S^\mathrm{sys}}{S^\mathrm{base}} + &\leftarrow \max\!\left(T_x,\epsilon_T\right), + && x\in\{\mathrm{rv},\mathrm{p},\mathrm{iq},\mathrm{pord}\} \\ + s_\mathrm{pf}^\mathrm{off} &= 1 - s_\mathrm{pf} \\ + s_Q^\mathrm{off} &= 1 - s_Q \\ + s_Q^\mathrm{PI} &= s_Q s_V \\ + s_V^\mathrm{ref} &= s_Q\left(1-s_V\right) \\ + s_Q^\mathrm{ref} &= 1 - s_V^\mathrm{ref} \\ + s_{PQ}^\mathrm{off} &= 1 - s_{PQ} \\ + k_\mathrm{base} &= \dfrac{S^\mathrm{sys}}{S^\mathrm{base}}. \end{aligned} ``` @@ -115,17 +107,18 @@ Multiplying by $k_\mathrm{base}$ converts system base to component base. ## Model Ports -Name | Port | Init | Base | Description ----------|--------|---------|-------------|------ -`bus` | Bus | Known | - | Terminal-bus voltage -`pe` | Input | Unknown | System | Electrical active-power feedback -`qgen` | Input | Unknown | System | Reactive-power feedback -`qext` | Input | Unknown | System | External reactive-power command -`pfaref` | Input | Unknown | [rad] | Power-factor angle reference -`pref` | Input | Unknown | System | External active-power reference -`iqcmd` | Output | Known | System | Reactive-current command -`ipcmd` | Output | Known | System | Active-current command - +Name | Port | Init | Description +---------|--------|---------|------------ +`bus` | Bus | Known | Terminal-bus voltage +`pe` | Input | Known | Active-power feedback +`qgen` | Input | Known | Reactive-power feedback +`qext` | Input | Unknown | Volt/VAr reference +`pfaref` | Input | Unknown | Power-factor angle reference +`pref` | Input | Unknown | Active-power reference +`iqcmd` | Output | Known | Reactive-current command +`ipcmd` | Output | Known | Active-current command + +`bus` is required; signal ports are optional and must be linked when attached. `Known` ports are seeded before `initialize()` and preserved by it. `Unknown` inputs are resolved during initialization and written to attached signal storage, or retained as constant inputs when the port is unattached. @@ -137,442 +130,420 @@ storage, or retained as constant inputs when the port is unattached. #### Differential Symbol | Units | Description | Note -------------------------|--------|-------------------------------------|------ +------------------------|--------|-------------------------------------|----- $V^\mathrm{meas}$ | [p.u.] | Filtered terminal voltage | State 1 in Fig. 1 -$P^\mathrm{meas}$ | [p.u.] | Filtered electrical power | State 2 in Fig. 1 +$P^\mathrm{meas}$ | [p.u.] | Filtered electrical power | State 2 in Fig. 1; component base $x_Q^\mathrm{PI}$ | [p.u.] | Reactive-power PI controller state | State 3 in Fig. 1 -$x_V^\mathrm{PI}$ | [p.u.] | Voltage PI controller state | State 4 in Fig. 1 -$Q_V$ | [p.u.] | Reactive-current command lag state | State 5 in Fig. 1 -$P^\mathrm{ord}$ | [p.u.] | Filtered active-power order | State 6 in Fig. 1 +$x_V^\mathrm{PI}$ | [p.u.] | Voltage-control PI controller state | State 4 in Fig. 1; component-base current +$Q_V$ | [p.u.] | Reactive-current command lag state | State 5 in Fig. 1; component base +$P^\mathrm{ord}$ | [p.u.] | Filtered active-power order | State 6 in Fig. 1; component base #### Algebraic -Symbol | Units | Description | Note -------------------------------------|----------|-------------------------------------|------ -$V_T$ | [p.u.] | Terminal voltage magnitude | -$V_{\mathrm{safe}}^\mathrm{meas}$ | [p.u.] | Safe filtered terminal voltage for divider blocks | Lower bounded by 0.01 -$s_{\mathrm{dip}}$ | [-] | Smooth voltage inside-band control gate | Approximately 1 inside the voltage band -$e_V^\mathrm{db}$ | [p.u.] | Deadbanded voltage error | -$I_q^\mathrm{inj}$ | [p.u.] | Reactive-current injection | Component base -$Q^\mathrm{ref}$ | [p.u.] | Selected reactive-power reference | -$e_Q$ | [p.u.] | Reactive-power control error | -$V_Q^\mathrm{PI}$ | [p.u.] | Reactive-power control PI output | -$e_V^\mathrm{PI}$ | [p.u.] | Voltage-control PI error | -$f_P^\mathrm{ord}$ | [p.u./s] | Active-power order derivative target | Before ramp-rate limit -$r_P^\mathrm{ord}$ | [p.u./s] | Ramp-rate-limited active-power order derivative target | -$I_q^\mathrm{circ}$ | [p.u.] | Reactive-current limit from converter current circle | -$I_p^\mathrm{circ}$ | [p.u.] | Active-current limit from converter current circle | -$I_q^{\max}$ | [p.u.] | Final reactive-current upper limit | Component base -$I_p^{\max}$ | [p.u.] | Final active-current upper limit | Component base -$I_q^\mathrm{base}$ | [p.u.] | Base reactive-current command | Component base -$I_q^\mathrm{raw}$ | [p.u.] | Raw reactive-current command before final limit | Component base -$I_q^\mathrm{cmd}$ | [p.u.] | Reactive-current command output | System base -$I_p^\mathrm{cmd}$ | [p.u.] | Active-current command output | System base +Symbol | Units | Description | Note +---------------------|--------|-----------------------------------------------|----- +$V_T$ | [p.u.] | Terminal voltage magnitude | +$I_L^{\max}$ | [p.u.] | Current available to the low-priority command | Component base +$I_q^\mathrm{cmd}$ | [p.u.] | Reactive-current command output | System base +$I_p^\mathrm{cmd}$ | [p.u.] | Active-current command output | System base ### External Variables #### Differential + None. #### Algebraic -Symbol | Units | Init | Description | Note --------------------------------------|--------|---------|-----------------------------------|------ -$V_{\mathrm{r}}$ | [p.u.] | Known | Terminal voltage, real component | Bus input -$V_{\mathrm{i}}$ | [p.u.] | Known | Terminal voltage, imaginary component | Bus input -$P_e$ | [p.u.] | Unknown | Electrical active-power feedback | Signal port `pe`; system base -$Q^\mathrm{gen}$ | [p.u.] | Unknown | Reactive-power feedback | Signal port `qgen`; system base -$Q^\mathrm{ext}$ | [p.u.] | Unknown | External reactive-power command | Optional signal port `qext`; system base -$\phi^\mathrm{ref}$ | [rad] | Unknown | Power-factor angle reference | Optional signal port `pfaref` -$P^\mathrm{ref}$ | [p.u.] | Unknown | External active-power reference | Optional signal port `pref`; system base +Symbol | Units | Init | Description | Note +-----------------------|--------|---------|------------------------------------------|----- +$V_\mathrm{r}$ | [p.u.] | Known | Terminal voltage, real component | Bus input +$V_\mathrm{i}$ | [p.u.] | Known | Terminal voltage, imaginary component | Bus input +$P_e$ | [p.u.] | Known | Electrical active-power feedback | Optional signal port `pe`; system base +$Q^\mathrm{gen}$ | [p.u.] | Known | Reactive-power feedback | Optional signal port `qgen`; system base +$Q^\mathrm{ext}$ | [p.u.] | Unknown | External Volt/VAr reference | Optional signal port `qext`; terminal voltage in direct-voltage mode +$\phi^\mathrm{ref}$ | [rad] | Unknown | Power-factor angle reference | Optional signal port `pfaref` +$P^\mathrm{ref}$ | [p.u.] | Unknown | External active-power reference | Optional signal port `pref`; system base ## Model Equations +For readability, define: + +```math +\begin{aligned} + V_\mathrm{safe}^\mathrm{meas} + &= \text{max}\!\left(V^\mathrm{meas},0.01\right) \\ + s_\mathrm{dip} + &= \text{inside}\!\left(V_T;\,V_\mathrm{dip},V_\mathrm{up}\right) \\ + e_V^\mathrm{db} + &= \text{deadband2}\!\left( + V^\mathrm{ref}-V^\mathrm{meas};\,D_1^\mathrm{db},D_2^\mathrm{db} + \right) \\ + I_q^\mathrm{inj} + &= \text{clamp}\!\left( + K_\mathrm{qv}e_V^\mathrm{db};\, + I_{q,\mathrm{inj}}^{\min},I_{q,\mathrm{inj}}^{\max} + \right) \\ + Q^\mathrm{ref} + &= s_Q^\mathrm{ref}\left( + s_\mathrm{pf}P^\mathrm{meas}\tan\!\left(\phi^\mathrm{ref}\right) + +s_\mathrm{pf}^\mathrm{off}k_\mathrm{base}Q^\mathrm{ext} + \right) \\ + e_Q + &= \text{clamp}\!\left(Q^\mathrm{ref};\,Q^{\min},Q^{\max}\right) + -k_\mathrm{base}Q^\mathrm{gen} \\ + V_Q^\mathrm{PI} + &= \text{clamp}\!\left( + K_\mathrm{qp}e_Q+x_Q^\mathrm{PI};\,V^{\min},V^{\max} + \right) \\ + e_V^\mathrm{PI} + &= s_Q^\mathrm{PI}V_Q^\mathrm{PI}+s_V^\mathrm{ref}Q^\mathrm{ext} + -s_QV^\mathrm{meas} \\ + f_P^\mathrm{ord} + &= \dfrac{1}{T_\mathrm{pord}} + \left(k_\mathrm{base}P^\mathrm{ref}-P^\mathrm{ord}\right) \\ + r_P^\mathrm{ord} + &= \text{clamp}\!\left(f_P^\mathrm{ord};\,R_P^{\min},R_P^{\max}\right) \\ + I_q^{\max} + &= s_{PQ}\left|I_L^{\max}\right|+s_{PQ}^\mathrm{off}I^{\max} \\ + I_p^{\max} + &= s_{PQ}I^{\max}+s_{PQ}^\mathrm{off}\left|I_L^{\max}\right| \\ + I_q^\mathrm{base} + &= \text{clamp}\!\left( + K_\mathrm{vp}e_V^\mathrm{PI}+x_V^\mathrm{PI};\,-I_q^{\max},I_q^{\max} + \right) \\ + I_q^\mathrm{raw} + &= s_QI_q^\mathrm{base}+s_Q^\mathrm{off}Q_V+I_q^\mathrm{inj}. +\end{aligned} +``` + +CommonMath defines the [`antiwindup`](../../../../CommonMath.md#antiwindup) and +[smooth limiter](../../../../CommonMath.md#derived-functions) functions used in +these equations. + ### Differential Equations ```math \begin{aligned} 0 &= -\dot{V}^\mathrm{meas} - + \dfrac{1}{T_{\mathrm{rv}}} - \left(V_T - V^\mathrm{meas}\right) \\ + + \dfrac{1}{T_\mathrm{rv}} + \left(V_T-V^\mathrm{meas}\right) \\ 0 &= -\dot{P}^\mathrm{meas} - + \dfrac{1}{T_{\mathrm{p}}} - \left(k_\mathrm{base}P_e - P^\mathrm{meas}\right) \\ + + \dfrac{1}{T_\mathrm{p}} + \left(k_\mathrm{base}P_e-P^\mathrm{meas}\right) \\ 0 &= -\dot{x}_Q^\mathrm{PI} - + s_{\mathrm{dip}}\, - \text{antiwindup} - \left( - K_{\mathrm{qp}}e_Q + x_Q^\mathrm{PI},\, - K_{\mathrm{qi}}e_Q;\, - V^{\min}, V^{\max} + + s_Q^\mathrm{PI}s_\mathrm{dip}\, + \text{antiwindup}\!\left( + K_\mathrm{qp}e_Q+x_Q^\mathrm{PI}, + K_\mathrm{qi}e_Q;\, + V^{\min},V^{\max} \right) \\ 0 &= -\dot{x}_V^\mathrm{PI} - + s_{\mathrm{dip}}\, - \text{antiwindup} - \left( - K_{\mathrm{vp}}e_V^\mathrm{PI} + x_V^\mathrm{PI},\, - K_{\mathrm{vi}}e_V^\mathrm{PI};\, - -I_q^{\max}, I_q^{\max} + + s_Qs_\mathrm{dip}\, + \text{antiwindup}\!\left( + K_\mathrm{vp}e_V^\mathrm{PI}+x_V^\mathrm{PI}, + K_\mathrm{vi}e_V^\mathrm{PI};\, + -I_q^{\max},I_q^{\max} \right) \\ 0 &= -\dot{Q}_V - + \dfrac{s_{\mathrm{dip}}}{T_{\mathrm{iq}}} - \left( - \dfrac{Q^\mathrm{ref}}{V_{\mathrm{safe}}^\mathrm{meas}} - - Q_V - \right) \\ + + \dfrac{1}{T_\mathrm{iq}}s_Q^\mathrm{off}s_\mathrm{dip} + \left(\dfrac{Q^\mathrm{ref}}{V_\mathrm{safe}^\mathrm{meas}}-Q_V\right) \\ 0 &= -\dot{P}^\mathrm{ord} - + s_{\mathrm{dip}}\, - \text{antiwindup} - \left(P^\mathrm{ord}, r_P^\mathrm{ord};\, P^{\min}, P^{\max}\right) + + s_\mathrm{dip}\, + \text{antiwindup}\!\left( + P^\mathrm{ord},r_P^\mathrm{ord};\,P^{\min},P^{\max} + \right). \end{aligned} ``` -CommonMath defines the [`antiwindup`](../../../../CommonMath.md#antiwindup) -target and smooth approximation. - ### Algebraic Equations ```math \begin{aligned} - 0 &= - -V_T^2 - + V_{\mathrm{r}}^2 - + V_{\mathrm{i}}^2 \\ - 0 &= - -V_{\mathrm{safe}}^\mathrm{meas} - + \text{max} - \left(V^\mathrm{meas}, 0.01\right) \\ - 0 &= - -s_{\mathrm{dip}} - + \text{inside} - \left(V_T;\, V_{\mathrm{dip}}, V_{\mathrm{up}}\right) \\ - 0 &= - -e_V^\mathrm{db} - + \text{deadband2} - \left(V_0^\mathrm{ref} - V^\mathrm{meas};\, - D_1^\mathrm{db}, D_2^\mathrm{db}\right) \\ - 0 &= - -I_q^\mathrm{inj} - + \text{clamp} - \left(K_{\mathrm{qv}}e_V^\mathrm{db};\, - I_{q,\mathrm{inj}}^{\min}, I_{q,\mathrm{inj}}^{\max}\right) \\ - 0 &= - -Q^\mathrm{ref} - + s_{\mathrm{pf}}P^\mathrm{meas}\tan\!\left(\phi^\mathrm{ref}\right) - + s_{\mathrm{pf}}^\mathrm{off}k_\mathrm{base}Q^\mathrm{ext} \\ - 0 &= - -e_Q - + \text{clamp} - \left(Q^\mathrm{ref};\, Q^{\min}, Q^{\max}\right) - - k_\mathrm{base}Q^\mathrm{gen} \\ - 0 &= - -V_Q^\mathrm{PI} - + \text{clamp} - \left(K_{\mathrm{qp}}e_Q + x_Q^\mathrm{PI};\, - V^{\min}, V^{\max}\right) \\ - 0 &= - -e_V^\mathrm{PI} - + s_V V_Q^\mathrm{PI} - + s_V^\mathrm{off}Q^\mathrm{ref} - - V^\mathrm{meas} \\ - 0 &= - -T_{\mathrm{pord}}f_P^\mathrm{ord} - + k_\mathrm{base}P^\mathrm{ref} - - P^\mathrm{ord} \\ - 0 &= - -r_P^\mathrm{ord} - + \text{clamp} - \left(f_P^\mathrm{ord};\, R_P^{\min}, R_P^{\max}\right) \\ - 0 &= - -\left(I_q^\mathrm{circ}\right)^2 - + \left(I^{\max}\right)^2 - - s_{PQ}\left(k_\mathrm{base}I_p^\mathrm{cmd}\right)^2 \\ - 0 &= - -\left(I_p^\mathrm{circ}\right)^2 - + \left(I^{\max}\right)^2 - - s_{PQ}^\mathrm{off}\left(k_\mathrm{base}I_q^\mathrm{cmd}\right)^2 \\ - 0 &= - -I_q^{\max} - + s_{PQ}^\mathrm{off}I^{\max} - + s_{PQ}I_q^\mathrm{circ} \\ - 0 &= - -I_p^{\max} - + s_{PQ}I^{\max} - + s_{PQ}^\mathrm{off}I_p^\mathrm{circ} \\ - 0 &= - -I_q^\mathrm{base} - + \text{clamp} - \left(K_{\mathrm{vp}}e_V^\mathrm{PI} + x_V^\mathrm{PI};\, - -I_q^{\max}, I_q^{\max}\right) \\ - 0 &= - -I_q^\mathrm{raw} - + s_Q I_q^\mathrm{base} - + s_Q^\mathrm{off}Q_V - + I_q^\mathrm{inj} \\ - 0 &= - -k_\mathrm{base} I_q^\mathrm{cmd} - + \text{clamp} - \left(I_q^\mathrm{raw};\, - -I_q^{\max}, I_q^{\max}\right) \\ - 0 &= - -k_\mathrm{base} I_p^\mathrm{cmd} - + \text{clamp} - \left( - \dfrac{P^\mathrm{ord}}{V_{\mathrm{safe}}^\mathrm{meas}};\, - 0,\, - I_p^{\max} - \right) + 0 &= -V_T^2+V_\mathrm{r}^2+V_\mathrm{i}^2 \\ + 0 &= -I_L^{\max}\left|I_L^{\max}\right|+\left(I^{\max}\right)^2 + -s_{PQ}\left(k_\mathrm{base}I_p^\mathrm{cmd}\right)^2 + -s_{PQ}^\mathrm{off}\left(k_\mathrm{base}I_q^\mathrm{cmd}\right)^2 \\ + 0 &= -k_\mathrm{base}I_q^\mathrm{cmd} + +\text{clamp}\!\left(I_q^\mathrm{raw};\,-I_q^{\max},I_q^{\max}\right) \\ + 0 &= -k_\mathrm{base}I_p^\mathrm{cmd} + +\text{clamp}\!\left( + \dfrac{P^\mathrm{ord}}{V_\mathrm{safe}^\mathrm{meas}};\,0,I_p^{\max} + \right). \end{aligned} ``` -CommonMath defines helper targets and smooth approximations for -[max, clamp, deadband2, and inside](../../../../CommonMath.md#derived-functions). +The signed-square continuation selects the unique positive physical root. Its +magnitude keeps both limiter ranges ordered for negative nonlinear +iterates; positive-root residual values and Jacobians are unchanged. +Initialization excludes the zero-capacity point, where the magnitude derivative +is undefined. ## Initialization -### Input Initialization +REECB reconstructs a steady operating point. Arbitrary-state restart is unsupported. -The upstream source model seeds `ipcmd` and `iqcmd` before REECB initializes. -REECB snapshots them on component base first: +### Input Initialization ```math \begin{aligned} - V_{\mathrm{r}}, V_{\mathrm{i}} + V_\mathrm{r},V_\mathrm{i} &\leftarrow \text{terminal-bus voltage} \\ - I_p^\mathrm{seed} - &\leftarrow k_\mathrm{base}I_p^\mathrm{cmd} \\ - I_q^\mathrm{seed} - &\leftarrow k_\mathrm{base}I_q^\mathrm{cmd} + I_q^\mathrm{cmd},I_p^\mathrm{cmd} + &\leftarrow \text{owned current-command variables} \\ + P_e + &\leftarrow \text{attached active-power feedback},\quad \text{if attached} \\ + Q^\mathrm{gen} + &\leftarrow \text{attached reactive-power feedback},\quad \text{if attached}. \end{aligned} ``` -Initialization never replaces the system-base values held in -$I_p^\mathrm{cmd}$ and $I_q^\mathrm{cmd}$. - ### Internal Initialization -The residual limits with the smooth CommonMath -[`clamp`](../../../../CommonMath.md#clamp), so a steady state is seeded with -the limiter *input*, not its output. With initialization tolerance -$\epsilon_0=10^{-10}$, $\text{clamp}^{-1}(z;\ell,u)$ is the input producing -output $z$, and $u_0^\mathrm{aw}(a,f;\ell,u)$ the input holding an anti-windup -path stationary: $a$ when $|f|\le\epsilon_0$, else just past the limit $f$ -drives toward. The inverse clamp rejects a requested output outside -$[\ell,u]$; the anti-windup initializer always returns a stationary input. - -Initialization rejects an operating point when any of the following holds: +Initialization resolves the steady-state quantities in dependency order; all +internal derivatives start at zero. Let +$\epsilon_0=100\,\epsilon_\mathrm{machine}$ cover roundoff from smooth-clamp +inversions and base round trips, and let +$I_p=k_\mathrm{base}I_p^\mathrm{cmd}$ and +$I_q=k_\mathrm{base}I_q^\mathrm{cmd}$ be the component-base initial commands. +$\text{unclamp}(z;\ell,u)$ is the initialization-only inverse of the smooth +clamp for $\ell\epsilon_0$. +```math +\begin{aligned} + V_T + &\leftarrow \sqrt{V_\mathrm{r}^2+V_\mathrm{i}^2} \\ + V^\mathrm{ref} + &\leftarrow V_T,\quad \text{if omitted} \\ + V^\mathrm{meas} &\leftarrow V_T \\ + V_\mathrm{safe}^\mathrm{meas} + &\leftarrow \text{max}\!\left(V^\mathrm{meas},0.01\right) \\ + P_e + &\leftarrow V_\mathrm{safe}^\mathrm{meas}I_p^\mathrm{cmd}, + \quad \text{if unattached} \\ + Q^\mathrm{gen} + &\leftarrow V_\mathrm{safe}^\mathrm{meas}I_q^\mathrm{cmd}, + \quad \text{if unattached} \\ + P^\mathrm{meas} &\leftarrow k_\mathrm{base}P_e \\ + e_V^\mathrm{db} + &\leftarrow \text{deadband2}\!\left( + V^\mathrm{ref}-V^\mathrm{meas};\,D_1^\mathrm{db},D_2^\mathrm{db} + \right) \\ + I_q^\mathrm{inj} + &\leftarrow \text{clamp}\!\left( + K_\mathrm{qv}e_V^\mathrm{db};\, + I_{q,\mathrm{inj}}^{\min},I_{q,\mathrm{inj}}^{\max} + \right) \\ + I_L^{\max} + &\leftarrow \sqrt{ + \left(I^{\max}\right)^2-s_{PQ}I_p^2-s_{PQ}^\mathrm{off}I_q^2 + } \\ + I_q^{\max} + &\leftarrow s_{PQ}I_L^{\max}+s_{PQ}^\mathrm{off}I^{\max} \\ + I_p^{\max} + &\leftarrow s_{PQ}I^{\max}+s_{PQ}^\mathrm{off}I_L^{\max}. +\end{aligned} +``` -Every check resolves before any storage is written, so a rejected -initialization leaves state, command nodes, and external signals unchanged. +$Q^\mathrm{target}$ is the initialization-only component-base reactive-power +reference required by the enabled steady-state control path. -Subscript $0$ denotes initial values; all internal derivatives start at zero: +```math +\begin{aligned} + I_q^\mathrm{raw} + &\leftarrow \text{unclamp}\!\left( + I_q;\,-I_q^{\max},I_q^{\max} + \right) \\ + I_q^\mathrm{ctrl} + &\leftarrow I_q^\mathrm{raw}-I_q^\mathrm{inj} \\ + P^\mathrm{ord} + &\leftarrow V_\mathrm{safe}^\mathrm{meas} + \text{unclamp}\!\left( + I_p;\,0,I_p^{\max} + \right) \\ + f_P^\mathrm{ord} + &\leftarrow \text{unclamp}\!\left(0;\,R_P^{\min},R_P^{\max}\right) \\ + Q^\mathrm{target} + &\leftarrow + \begin{cases} + V_\mathrm{safe}^\mathrm{meas}I_q^\mathrm{ctrl} + & s_Q=0 \\ + \text{unclamp}\!\left( + k_\mathrm{base}Q^\mathrm{gen};\,Q^{\min},Q^{\max} + \right) + & s_Q s_V=1\ \land\ Q^{\min}\epsilon_0 \\ + 0 & \text{otherwise} \end{cases} \\ - I_{p,0}^\mathrm{circ} - &= + Q^\mathrm{ext} + &\leftarrow \begin{cases} - \sqrt{(I^{\max})^2-(I_q^\mathrm{seed})^2} - & s_{PQ}=0 \\ - I^{\max} & s_{PQ}=1 + V^\mathrm{meas} & s_V^\mathrm{ref}=1 \\ + P^\mathrm{meas}\tan\!\left(\phi^\mathrm{ref}\right)/k_\mathrm{base} + & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=1 \\ + Q^\mathrm{target}/k_\mathrm{base} + & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=0 \end{cases} \\ - I_{q,0}^{\max} - &= s_{PQ}^\mathrm{off}I^{\max}+s_{PQ}I_{q,0}^\mathrm{circ} \\ - I_{p,0}^{\max} - &= s_{PQ}I^{\max}+s_{PQ}^\mathrm{off}I_{p,0}^\mathrm{circ} + Q^\mathrm{ref} + &\leftarrow s_Q^\mathrm{ref}\left( + s_\mathrm{pf}P^\mathrm{meas}\tan\!\left(\phi^\mathrm{ref}\right) + +s_\mathrm{pf}^\mathrm{off}k_\mathrm{base}Q^\mathrm{ext} + \right). \end{aligned} ``` +For $s_Q=0$, the selected reactive-reference path must reproduce the recovered +controller current: + ```math -\begin{aligned} - s_{\mathrm{dip},0} - &= \text{inside} - \left(V_{T,0};\, V_{\mathrm{dip}}, V_{\mathrm{up}}\right) \\ - e_{V,0}^\mathrm{db} - &= - \text{deadband2} - \left(V_0^\mathrm{ref} - V_0^\mathrm{meas};\, - D_1^\mathrm{db}, D_2^\mathrm{db}\right) \\ - I_{q,0}^\mathrm{inj} - &= - \text{clamp} - \left(K_{\mathrm{qv}}e_{V,0}^\mathrm{db};\, - I_{q,\mathrm{inj}}^{\min}, I_{q,\mathrm{inj}}^{\max}\right) \\ - I_{q,0}^\mathrm{raw} - &= \text{clamp}^{-1} - \left(I_q^\mathrm{seed};\, - -I_{q,0}^{\max},I_{q,0}^{\max}\right) \\ - I_{q,0}^\mathrm{control} - &= I_{q,0}^\mathrm{raw} - -I_{q,0}^\mathrm{inj} \\ - u_{p,0} - &= \text{clamp}^{-1} - \left(I_p^\mathrm{seed};\,0,I_{p,0}^{\max}\right) \\ - P_0^\mathrm{ord} - &= V_{\mathrm{safe},0}^\mathrm{meas}u_{p,0} \\ - f_{P,0}^\mathrm{ord} - &= \text{clamp}^{-1} - \left(0;\,R_P^{\min},R_P^{\max}\right) \\ - r_{P,0}^\mathrm{ord} - &= \text{clamp} - \left(f_{P,0}^\mathrm{ord};\,R_P^{\min},R_P^{\max}\right) -\end{aligned} +\left| + \dfrac{Q^\mathrm{ref}}{V_\mathrm{safe}^\mathrm{meas}} + -I_q^\mathrm{ctrl} +\right| +\le\epsilon_0. ``` ```math \begin{aligned} - Q_0^\mathrm{ref} - &= + e_Q + &\leftarrow \text{clamp}\!\left(Q^\mathrm{ref};\,Q^{\min},Q^{\max}\right) + -k_\mathrm{base}Q^\mathrm{gen} \\ + x_Q^\mathrm{PI} + &\leftarrow \begin{cases} - \text{clamp}^{-1} - \left(k_\mathrm{base}Q_0^\mathrm{gen};\, - Q^{\min},Q^{\max}\right) - & s_Q=1\ \land\ s_V=1 \\ - V_0^\mathrm{meas} - & s_Q=1\ \land\ s_V=0 \\ - V_{\mathrm{safe},0}^\mathrm{meas}I_{q,0}^\mathrm{control} - & s_Q=0 + \text{unclamp}\!\left( + V^\mathrm{meas};\,V^{\min},V^{\max} + \right)-K_\mathrm{qp}e_Q + & s_Q s_V=1\ \land\ V^{\min}\epsilon_0$ or + $\left|s_QK_\mathrm{vi}e_V^\mathrm{PI}\right|>\epsilon_0$; or +- any candidate quantity is nonfinite. + +The recovered order is retained unchanged to preserve the initial +active-current command, and collapsed Q or V bounds bypass the inverse while +still requiring the corresponding integral equilibrium. Every check resolves +before any storage is written, so a rejected initialization leaves state, +derivatives, latches, parameter storage, and attached signals unchanged. ### Output Initialization ```math \begin{aligned} - P_{e,0} - &\leftarrow V_{\mathrm{safe},0}^\mathrm{meas}I_p^\mathrm{cmd} \\ - Q_0^\mathrm{gen} - &\leftarrow V_{\mathrm{safe},0}^\mathrm{meas}I_q^\mathrm{cmd} \\ - Q_0^\mathrm{ext} - &\leftarrow \dfrac{Q_0^\mathrm{ref}}{k_\mathrm{base}} \\ - \phi_0^\mathrm{ref} + \phi^\mathrm{ref} &\leftarrow \begin{cases} - \tan^{-1}\!\left(Q_0^\mathrm{ref}/P_0^\mathrm{meas}\right) - & s_{\mathrm{pf}}=1 - \ \land\ |P_0^\mathrm{meas}|>\epsilon_0 \\ - 0 - & s_{\mathrm{pf}}=0 - \ \lor\ - \left(|P_0^\mathrm{meas}|\le\epsilon_0 - \ \land\ |Q_0^\mathrm{ref}|\le\epsilon_0\right) + \arctan\!\left(Q^\mathrm{target}/P^\mathrm{meas}\right) + & s_\mathrm{pf}=1\ \land\ |P^\mathrm{meas}|>\epsilon_0 \\ + 0 & \text{otherwise} \end{cases} \\ - P_0^\mathrm{ref} + Q^\mathrm{ext} &\leftarrow - \dfrac{P_0^\mathrm{ord} - +T_{\mathrm{pord}}f_{P,0}^\mathrm{ord}} + \begin{cases} + V^\mathrm{meas} & s_V^\mathrm{ref}=1 \\ + P^\mathrm{meas}\tan\!\left(\phi^\mathrm{ref}\right)/k_\mathrm{base} + & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=1 \\ + Q^\mathrm{target}/k_\mathrm{base} + & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=0 + \end{cases} \\ + P^\mathrm{ref} + &\leftarrow + \dfrac{P^\mathrm{ord}+T_\mathrm{pord}f_P^\mathrm{ord}} {k_\mathrm{base}} \end{aligned} ``` -These expressions are on system base. $Q_0^\mathrm{ext}$ is published even when -$s_{\mathrm{pf}}=1$, though the power-factor path does not consume it. +REECB writes the resolved references to attached signal inputs; unattached +ports retain them as constant inputs. ## Monitorable Outputs -Output | Units | Description | Note -----------------|--------|-------------------------------------|------ -`iqcmd` | [p.u.] | Reactive-current command output | $I_q^\mathrm{cmd}$ (system base) -`ipcmd` | [p.u.] | Active-current command output | $I_p^\mathrm{cmd}$ (system base) -`vmeas` | [p.u.] | Filtered terminal voltage | $V^\mathrm{meas}$ -`pmeas` | [p.u.] | Filtered electrical power | $P^\mathrm{meas}$ (component base) +Output | Units | Description | Note +--------|--------|---------------------------------|----- +`iqcmd` | [p.u.] | Reactive-current command output | $I_q^\mathrm{cmd}$ (system base) +`ipcmd` | [p.u.] | Active-current command output | $I_p^\mathrm{cmd}$ (system base) +`vmeas` | [p.u.] | Filtered terminal voltage | $V^\mathrm{meas}$ +`pmeas` | [p.u.] | Filtered electrical power | $P^\mathrm{meas}$ (component base) + +## Appendix A: `unclamp` + +For $\ell int Reecb::evaluateJacobian() { - Log::misc() << "Evaluate Jacobian for Reecb..." << std::endl; - Log::misc() << "Jacobian evaluation is not implemented!" << std::endl; + Log::misc() << "Evaluate Jacobian for Reecb...\n"; + Log::misc() << "Jacobian evaluation is not implemented!\n"; return 0; } diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp b/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp index 9089a74c3..514245064 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp @@ -22,55 +22,38 @@ namespace GridKit template class BusBase; - template - class SignalNode; - namespace Converter { - /// Internal variables of a `Reecb` + /// Internal variables and residual rows of a `Reecb`. enum class ReecbInternalVariables : size_t { - VMEAS, ///< \f$V^\mathrm{meas}\f$ Filtered terminal voltage - PMEAS, ///< \f$P^\mathrm{meas}\f$ Filtered active power on component base - XPIQ, ///< \f$x_Q^\mathrm{PI}\f$ Reactive-power PI state - XPIV, ///< \f$x_V^\mathrm{PI}\f$ Voltage-control PI state - QV, ///< \f$Q_V\f$ Reactive-current command lag state - PORD, ///< \f$P^\mathrm{ord}\f$ Filtered active-power order - VT, ///< \f$V_T\f$ Terminal voltage magnitude - VMEASSAFE, ///< \f$V_\mathrm{safe}^\mathrm{meas}\f$ Safe filtered voltage - SDIP, ///< \f$s_\mathrm{dip}\f$ Voltage inside-band control gate - VERR, ///< \f$e_V^\mathrm{db}\f$ Deadbanded voltage error - IQV, ///< \f$I_q^\mathrm{inj}\f$ Reactive-current injection - QREF, ///< \f$Q^\mathrm{ref}\f$ Selected reactive-power reference - EQ, ///< \f$e_Q\f$ Reactive-power control error - VPIQ, ///< \f$V_Q^\mathrm{PI}\f$ Reactive-power PI output - EPIV, ///< \f$e_V^\mathrm{PI}\f$ Voltage-control PI error - FPORD, ///< \f$f_P^\mathrm{ord}\f$ Active-power derivative target - RPORD, ///< \f$r_P^\mathrm{ord}\f$ Ramp-limited active-power derivative - IQCIRC, ///< \f$I_q^\mathrm{circ}\f$ Reactive-current circle limit - IPCIRC, ///< \f$I_p^\mathrm{circ}\f$ Active-current circle limit - IQMAX, ///< \f$I_q^\max\f$ Reactive-current upper limit - IPMAX, ///< \f$I_p^\max\f$ Active-current upper limit - IQBASE, ///< \f$I_q^\mathrm{base}\f$ Base reactive-current command - IQRAW, ///< \f$I_q^\mathrm{raw}\f$ Raw reactive-current command - IQCMD, ///< \f$I_q^\mathrm{cmd}\f$ Command output on system base - IPCMD, ///< \f$I_p^\mathrm{cmd}\f$ Command output on system base - MAXIMUM, + VMEAS, ///< \f$V^\mathrm{meas}\f$ Differential filtered terminal voltage [p.u.] + PMEAS, ///< \f$P^\mathrm{meas}\f$ Differential filtered electrical power on component base [p.u.] + XPIQ, ///< \f$x_Q^\mathrm{PI}\f$ Differential reactive-power PI state [p.u.] + XPIV, ///< \f$x_V^\mathrm{PI}\f$ Differential voltage-control PI state on component base [p.u.] + QV, ///< \f$Q_V\f$ Differential reactive-current command lag state on component base [p.u.] + PORD, ///< \f$P^\mathrm{ord}\f$ Differential filtered active-power order on component base [p.u.] + VT, ///< \f$V_T\f$ Algebraic terminal-voltage magnitude [p.u.] + ILMAX, ///< \f$I_L^\max\f$ Algebraic current limit available to the low-priority command on component base [p.u.] + IQCMD, ///< \f$I_q^\mathrm{cmd}\f$ Algebraic reactive-current command output on system base [p.u.] + IPCMD, ///< \f$I_p^\mathrm{cmd}\f$ Algebraic active-current command output on system base [p.u.] + MAXIMUM ///< Number of REECB internal variables and residual rows }; - /// External variables of a `Reecb` + /// External signal variables read or initialized by a `Reecb`. enum class ReecbExternalVariables : size_t { - PE, ///< \f$P_e\f$ Active-power feedback on system base - QGEN, ///< \f$Q^\mathrm{gen}\f$ Reactive-power feedback on system base - QEXT, ///< \f$Q^\mathrm{ext}\f$ Reactive-power command on system base - PFAREF, ///< \f$\phi^\mathrm{ref}\f$ Power-factor angle reference in radians - PREF, ///< \f$P^\mathrm{ref}\f$ Active-power reference on system base - MAXIMUM, + PE, ///< \f$P_e\f$ Optional Known active-power feedback input on system base [p.u.] + QGEN, ///< \f$Q^\mathrm{gen}\f$ Optional Known reactive-power feedback input on system base [p.u.] + QEXT, ///< \f$Q^\mathrm{ext}\f$ Optional Unknown Volt/VAr reference input: system-base reactive power [p.u.], or the terminal-voltage reference [p.u.] when \f$s_Q=1\f$ and \f$s_V=0\f$ + PFAREF, ///< \f$\phi^\mathrm{ref}\f$ Optional Unknown power-factor angle-reference input [rad] + PREF, ///< \f$P^\mathrm{ref}\f$ Optional Unknown active-power reference input on system base [p.u.] + MAXIMUM ///< Number of REECB external signal variables }; /** - * @brief Second-generation WECC renewable electrical-control model (REECB). + * @brief WECC renewable electrical controller with reactive-power, + * voltage, and active-power command paths (REECB). * * @tparam scalar_type Plain real or differentiable scalar type. * @tparam index_type Integer index type. @@ -101,7 +84,6 @@ namespace GridKit using IdxT = index_type; using RealT = typename Component::RealT; using BusT = BusBase; - using SignalT = SignalNode; using ModelDataT = ReecbData; using MonitorT = Model::VariableMonitor; using InternalVariablesT = ReecbInternalVariables; @@ -139,28 +121,45 @@ namespace GridKit ScalarT* f); private: - void initializeParameters(const ModelDataT& data); - void initializeMonitor(); - void setDerivedParameters(); + static constexpr size_t index(ReecbInternalVariables variable) + { + return static_cast(variable); + } - template - bool solveLimiterInput(ScalarT requested_output, LowerT lower_limit, UpperT upper_limit, ScalarT& limiter_input) const; + static constexpr size_t index(ReecbExternalVariables variable) + { + return static_cast(variable); + } - template - ScalarT steadyAntiWindupInput(ScalarT nominal_input, ScalarT rate, LowerT lower_limit, UpperT upper_limit) const; + static void checkConfiguration(bool condition, const char* message, int& errors); + void loadRealParameter(const ModelDataT& data, + ReecbParameters parameter, + RealT& target, + const char* name); + void loadSwitchParameter(const ModelDataT& data, + ReecbParameters parameter, + bool& target, + const char* name); + bool floorTimeConstant(RealT& value, const char* name); + void initializeParameters(const ModelDataT& data); + void initializeMonitor(); + void setDerivedParameters(); RealT logOneMinusExp(RealT x) const; + RealT unclamp(RealT output, RealT lower, RealT upper) const; + RealT componentPowerBase() const; + + template + __attribute__((always_inline)) inline ValueT toComponentBase(ValueT value) const; - ScalarT toComponentBase(ScalarT value) const; - ScalarT toSystemBase(ScalarT value) const; + template + ValueT toSystemBase(ValueT value) const; ScalarT& Vr(); ScalarT& Vi(); - static constexpr RealT TIME_CONSTANT_MINIMUM = static_cast(1.0e-3); - static constexpr RealT VMEAS_MINIMUM = static_cast(0.01); - static constexpr RealT INITIALIZATION_TOLERANCE = static_cast(1.0e-10); - static constexpr RealT INITIALIZATION_LIMIT_OFFSET = static_cast(0.1); + static constexpr RealT TIME_CONSTANT_MINIMUM = static_cast(1.0e-3); + static constexpr RealT VMEAS_MINIMUM = static_cast(0.01); BusT* bus_{nullptr}; @@ -196,17 +195,19 @@ namespace GridKit RealT Pmin_{0}; RealT Imax_{1.3}; + bool mva_given_{false}; bool Vref0_given_{false}; IdxT parameter_error_count_{0}; // Derived parameters - RealT va_converter_base_{0}; + RealT va_component_base_{0}; RealT pf_on_{0}; RealT pf_off_{1}; - RealT v_on_{0}; - RealT v_off_{1}; RealT q_on_{0}; RealT q_off_{1}; + RealT q_pi_on_{0}; + RealT v_ref_on_{0}; + RealT q_ref_on_{1}; RealT pq_on_{0}; RealT pq_off_{1}; diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp index 7705ccac3..bf0ecf4d4 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp @@ -14,83 +14,81 @@ namespace GridKit { namespace Converter { - /// Parameter keys for the REECB electrical-control model. `mva` is - /// required; every other key is optional and retains its documented default. + /// Parameters for REECB. enum class ReecbParameters { - mva, ///< \f$S^\mathrm{base}\f$ REECB component power base - PfFlag, ///< \f$s_\mathrm{pf}\f$ Power-factor control flag - VFlag, ///< \f$s_V\f$ Voltage-control mode flag - QFlag, ///< \f$s_Q\f$ Reactive-power control flag - Pqflag, ///< \f$s_{PQ}\f$ P/Q current-priority flag - Trv, ///< \f$T_\mathrm{rv}\f$ Voltage-measurement time constant - Tp, ///< \f$T_\mathrm{p}\f$ Active-power measurement time constant - Vref0, ///< \f$V_0^\mathrm{ref}\f$ Outer-loop voltage reference - Vdip, ///< \f$V_\mathrm{dip}\f$ Low-voltage threshold - Vup, ///< \f$V_\mathrm{up}\f$ High-voltage threshold - dbd1, ///< \f$D_1^\mathrm{db}\f$ Lower voltage-error deadband - dbd2, ///< \f$D_2^\mathrm{db}\f$ Upper voltage-error deadband - kqv, ///< \f$K_\mathrm{qv}\f$ Reactive-current injection gain - Iql1, ///< \f$I_{q,\mathrm{inj}}^\min\f$ Minimum injection current - Iqh1, ///< \f$I_{q,\mathrm{inj}}^\max\f$ Maximum injection current - Qmax, ///< \f$Q^\max\f$ Maximum reactive-power control limit - Qmin, ///< \f$Q^\min\f$ Minimum reactive-power control limit - Kqp, ///< \f$K_\mathrm{qp}\f$ Reactive-power proportional gain - Kqi, ///< \f$K_\mathrm{qi}\f$ Reactive-power integral gain - Vmax, ///< \f$V^\max\f$ Maximum voltage-control limit - Vmin, ///< \f$V^\min\f$ Minimum voltage-control limit - Kvp, ///< \f$K_\mathrm{vp}\f$ Voltage-control proportional gain - Kvi, ///< \f$K_\mathrm{vi}\f$ Voltage-control integral gain - Tiq, ///< \f$T_\mathrm{iq}\f$ Reactive-current command time constant - Tpord, ///< \f$T_\mathrm{pord}\f$ Active-power order time constant - dPmax, ///< \f$R_P^\max\f$ Positive active-power ramp-rate limit - dPmin, ///< \f$R_P^\min\f$ Negative active-power ramp-rate limit - Pmax, ///< \f$P^\max\f$ Maximum active-power order limit - Pmin, ///< \f$P^\min\f$ Minimum active-power order limit - Imax ///< \f$I^\max\f$ Maximum converter current + mva, ///< \f$S^\mathrm{base}\f$ Component power base [MVA] + PfFlag, ///< \f$s_\mathrm{pf}\f$ Power-factor control selector: 1 = power-factor control, 0 = reactive-power control [binary] + VFlag, ///< \f$s_V\f$ Voltage-reference selector under \f$s_Q=1\f$: 1 = cascaded Q-PI voltage command, 0 = direct external voltage reference [binary] + QFlag, ///< \f$s_Q\f$ Reactive-path selector: 1 = Volt/VAr PI control, 0 = reactive-current lag [binary] + Pqflag, ///< \f$s_{PQ}\f$ Converter current-priority selector: 1 = P priority, 0 = Q priority [binary] + Trv, ///< \f$T_\mathrm{rv}\f$ Voltage-measurement filter time constant [sec] + Tp, ///< \f$T_\mathrm{p}\f$ Electrical-power measurement filter time constant [sec] + Vref0, ///< \f$V^\mathrm{ref}\f$ Reactive-current-injection voltage reference [p.u.] + Vdip, ///< \f$V_\mathrm{dip}\f$ Low-voltage threshold for the voltage-band gate [p.u.] + Vup, ///< \f$V_\mathrm{up}\f$ High-voltage threshold for the voltage-band gate [p.u.] + dbd1, ///< \f$D_1^\mathrm{db}\f$ Lower voltage-error deadband threshold [p.u.] + dbd2, ///< \f$D_2^\mathrm{db}\f$ Upper voltage-error deadband threshold [p.u.] + kqv, ///< \f$K_\mathrm{qv}\f$ Reactive-current injection gain [p.u.] + Iql1, ///< \f$I_{q,\mathrm{inj}}^\min\f$ Minimum reactive-current injection on component base [p.u.] + Iqh1, ///< \f$I_{q,\mathrm{inj}}^\max\f$ Maximum reactive-current injection on component base [p.u.] + Qmax, ///< \f$Q^\max\f$ Maximum reactive-power control output on component base [p.u.] + Qmin, ///< \f$Q^\min\f$ Minimum reactive-power control output on component base [p.u.] + Kqp, ///< \f$K_\mathrm{qp}\f$ Reactive-power proportional gain [p.u.] + Kqi, ///< \f$K_\mathrm{qi}\f$ Reactive-power integral gain [p.u./s] + Vmax, ///< \f$V^\max\f$ Maximum voltage-control output [p.u.] + Vmin, ///< \f$V^\min\f$ Minimum voltage-control output [p.u.] + Kvp, ///< \f$K_\mathrm{vp}\f$ Voltage-control proportional gain [p.u.] + Kvi, ///< \f$K_\mathrm{vi}\f$ Voltage-control integral gain [p.u./s] + Tiq, ///< \f$T_\mathrm{iq}\f$ Reactive-current command lag time constant [sec] + Tpord, ///< \f$T_\mathrm{pord}\f$ Active-power order filter time constant [sec] + dPmax, ///< \f$R_P^\max\f$ Positive active-power ramp-rate limit on component base [p.u./s] + dPmin, ///< \f$R_P^\min\f$ Negative active-power ramp-rate limit on component base [p.u./s] + Pmax, ///< \f$P^\max\f$ Maximum active-power order limit on component base [p.u.] + Pmin, ///< \f$P^\min\f$ Minimum active-power order limit on component base [p.u.] + Imax ///< \f$I^\max\f$ Maximum converter current on component base [p.u.] }; /// Buses for the REECB electrical-control model. enum class ReecbBuses : size_t { - bus, ///< Terminal bus ID - SIZE + bus, ///< \f$V_\mathrm{r},V_\mathrm{i}\f$ Required Known terminal-bus voltage [p.u.] + SIZE ///< Number of REECB bus ports }; - /// Optional signal inputs for the REECB electrical-control model. + /// Signal inputs for the REECB electrical-control model. enum class ReecbSignalInputs : size_t { - pe, ///< Active-power feedback signal ID on system base - qgen, ///< Reactive-power feedback signal ID on system base - qext, ///< Reactive-power command signal ID on system base - pfaref, ///< Power-factor angle reference signal ID in radians - pref, ///< Active-power reference signal ID on system base - SIZE + pe, ///< \f$P_e\f$ Optional Known active-power feedback input on system base [p.u.] + qgen, ///< \f$Q^\mathrm{gen}\f$ Optional Known reactive-power feedback input on system base [p.u.] + qext, ///< \f$Q^\mathrm{ext}\f$ Optional Unknown Volt/VAr reference input: system-base reactive power [p.u.], or the terminal-voltage reference [p.u.] when \f$s_Q=1\f$ and \f$s_V=0\f$ + pfaref, ///< \f$\phi^\mathrm{ref}\f$ Optional Unknown power-factor angle-reference input [rad] + pref, ///< \f$P^\mathrm{ref}\f$ Optional Unknown active-power reference input on system base [p.u.] + SIZE ///< Number of REECB signal-input ports }; - /// Optional signal outputs for the REECB electrical-control model. + /// Signal outputs for the REECB electrical-control model. enum class ReecbSignalOutputs : size_t { - iqcmd, ///< Reactive-current command signal ID on system base - ipcmd, ///< Active-current command signal ID on system base - SIZE + iqcmd, ///< \f$I_q^\mathrm{cmd}\f$ Optional Known reactive-current command output on system base [p.u.] + ipcmd, ///< \f$I_p^\mathrm{cmd}\f$ Optional Known active-current command output on system base [p.u.] + SIZE ///< Number of REECB signal-output ports }; /// Variables available through the monitor interface. enum class ReecbMonitorableVariables { - iqcmd, ///< Reactive-current command on system base - ipcmd, ///< Active-current command on system base - vmeas, ///< Filtered terminal voltage - pmeas ///< Filtered active power on component base + iqcmd, ///< \f$I_q^\mathrm{cmd}\f$ Reactive-current command output on system base [p.u.] + ipcmd, ///< \f$I_p^\mathrm{cmd}\f$ Active-current command output on system base [p.u.] + vmeas, ///< \f$V^\mathrm{meas}\f$ Filtered terminal voltage [p.u.] + pmeas ///< \f$P^\mathrm{meas}\f$ Filtered electrical power on component base [p.u.] }; /** - * @brief Model data for the REECB controller: parameter values, the - * terminal bus, optional signals, and monitored-variable selections. + * @brief Model data for REECB parameters, bus and signal ports, and monitored variables. * * @tparam real_type Real parameter value type. - * @tparam index_type Integer index type. + * @tparam index_type Integer index and serialized selector type. * * @see Reecb */ diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbDependencyTracking.cpp b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbDependencyTracking.cpp index 394d38102..1a24a6a93 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbDependencyTracking.cpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbDependencyTracking.cpp @@ -13,15 +13,14 @@ namespace GridKit namespace Converter { /** - * @brief Report that dependency tracking does not assemble a separate Jacobian. - * - * Dependency tracking recovers the sparsity pattern from the residual. + * @brief Report that DependencyTracking exposes structure through the + * residual rather than a separately assembled Jacobian. */ template int Reecb::evaluateJacobian() { - Log::misc() << "Evaluate Jacobian for Reecb..." << std::endl; - Log::misc() << "Jacobian evaluation is not implemented!" << std::endl; + Log::misc() << "Evaluate Jacobian for Reecb...\n"; + Log::misc() << "Jacobian evaluation is not implemented!\n"; return 0; } diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbEnzyme.cpp b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbEnzyme.cpp index 4f9383687..6df458e96 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbEnzyme.cpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbEnzyme.cpp @@ -15,94 +15,101 @@ namespace GridKit namespace Converter { /** - * @brief Assemble the sparse component Jacobian with Enzyme. + * @brief Assemble the sparse REECB Jacobian with Enzyme. * - * @pre evaluateResidual() has run at the current state. + * Differentiates the internal residual with respect to state, derivative, + * terminal-bus, and linked signal variables, then constructs the model + * COO matrix. + * + * @pre allocate() has sized the model and Jacobian index maps. + * @pre evaluateResidual() has refreshed the current bus/signal values and + * signal indices. + * @pre The containing solver has set the current integration coefficient + * and global variable/residual indices. */ template int Reecb::evaluateJacobian() { - Log::misc() << "Evaluate Jacobian for Reecb..." << std::endl; - Log::misc() << "Jacobian evaluation is experimental!" << std::endl; + Log::misc() << "Evaluate Jacobian for Reecb...\n"; + Log::misc() << "Jacobian evaluation is experimental!\n"; if (J_rows_buffer_ == nullptr) { - // Reserve space for the dense blocks. Enzyme keeps only structural - // nonzeros for each differentiated block. - auto size = static_cast(size_); - auto bus_size = static_cast(bus_->size()); - auto signal_size = static_cast(ws_.size()); - auto buffer_size = 2 * size * size + size * bus_size + size * signal_size; - J_rows_buffer_ = new IdxT[buffer_size]; - J_cols_buffer_ = new IdxT[buffer_size]; - J_vals_buffer_ = new RealT[buffer_size]; + const auto size = static_cast(size_); + const auto bus_size = static_cast(bus_->size()); + const auto signal_size = ws_.size(); + const auto buffer_size = 2 * size * size + size * bus_size + size * signal_size; + + J_rows_buffer_ = new IdxT[buffer_size]; + J_cols_buffer_ = new IdxT[buffer_size]; + J_vals_buffer_ = new RealT[buffer_size]; } - using ReecbT = GridKit::PhasorDynamics::Converter::Reecb; + using ModelT = GridKit::PhasorDynamics::Converter::Reecb; using Fn = GridKit::Enzyme::Sparse::MemberFunctions; nnz_ = 0; - GridKit::Enzyme::Sparse::DfDy::eval(this, - static_cast(f_.getSize()), - static_cast(y_.getSize()), - (this->getResidualIndices()).data(), - (this->getVariableIndices()).data(), - y_.getData(), - yp_.getData(), - wb_.data(), - ws_.data(), - J_rows_buffer_, - J_cols_buffer_, - J_vals_buffer_, - nnz_); + GridKit::Enzyme::Sparse::DfDy::eval( + this, + static_cast(f_.getSize()), + static_cast(y_.getSize()), + this->getResidualIndices().data(), + this->getVariableIndices().data(), + y_.getData(), + yp_.getData(), + wb_.data(), + ws_.data(), + J_rows_buffer_, + J_cols_buffer_, + J_vals_buffer_, + nnz_); - GridKit::Enzyme::Sparse::DfDyp::eval(this, - static_cast(f_.getSize()), - static_cast(y_.getSize()), - (this->getResidualIndices()).data(), - (this->getVariableIndices()).data(), - y_.getData(), - yp_.getData(), - wb_.data(), - ws_.data(), - alpha_, - J_rows_buffer_, - J_cols_buffer_, - J_vals_buffer_, - nnz_); + GridKit::Enzyme::Sparse::DfDyp::eval( + this, + static_cast(f_.getSize()), + static_cast(y_.getSize()), + this->getResidualIndices().data(), + this->getVariableIndices().data(), + y_.getData(), + yp_.getData(), + wb_.data(), + ws_.data(), + alpha_, + J_rows_buffer_, + J_cols_buffer_, + J_vals_buffer_, + nnz_); - GridKit::Enzyme::Sparse::DfDwb::eval(this, - static_cast(f_.getSize()), - static_cast(bus_->size()), - (this->getResidualIndices()).data(), - (bus_->getVariableIndices()).data(), - y_.getData(), - yp_.getData(), - wb_.data(), - ws_.data(), - J_rows_buffer_, - J_cols_buffer_, - J_vals_buffer_, - nnz_); + GridKit::Enzyme::Sparse::DfDwb::eval( + this, + static_cast(f_.getSize()), + static_cast(bus_->size()), + this->getResidualIndices().data(), + bus_->getVariableIndices().data(), + y_.getData(), + yp_.getData(), + wb_.data(), + ws_.data(), + J_rows_buffer_, + J_cols_buffer_, + J_vals_buffer_, + nnz_); - GridKit::Enzyme::Sparse::DfDws::eval(this, - static_cast(f_.getSize()), - ws_.size(), - (this->getResidualIndices()).data(), - ws_indices_.data(), - y_.getData(), - yp_.getData(), - wb_.data(), - ws_.data(), - J_rows_buffer_, - J_cols_buffer_, - J_vals_buffer_, - nnz_); + GridKit::Enzyme::Sparse::DfDws::eval( + this, + static_cast(f_.getSize()), + ws_.size(), + this->getResidualIndices().data(), + ws_indices_.data(), + y_.getData(), + yp_.getData(), + wb_.data(), + ws_.data(), + J_rows_buffer_, + J_cols_buffer_, + J_vals_buffer_, + nnz_); this->constructCoo(); return 0; diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp index a3c237788..eecec04d4 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -24,30 +25,30 @@ namespace GridKit { namespace Converter { + /// Logger used for REECB diagnostics. using Log = ::GridKit::Utilities::Logger; /** - * @brief Construct a REECB controller without parameters. + * @brief Construct REECB with its documented parameter defaults * - * The model is sized but left unconfigured. Every parameter keeps its - * documented default, the required power base is absent, and no monitor - * is created, so verify() reports configuration errors until the data - * constructor is used instead. + * The terminal bus is retained, the model is sized, and no monitor or + * signal connection is created. * - * @param[in] bus Terminal bus the controller measures. + * @param[in] bus Terminal bus measured by the controller. */ template Reecb::Reecb(BusT* bus) : bus_(bus) { - size_ = static_cast(ReecbInternalVariables::MAXIMUM); + size_ = static_cast(index(ReecbInternalVariables::MAXIMUM)); + setDerivedParameters(); } /** - * @brief Construct a REECB controller from model data. + * @brief Construct REECB from model data * - * @param[in] bus Terminal bus the controller measures. - * @param[in] data Parameters and monitored-variable selections. + * @param[in] bus Terminal bus measured by the controller. + * @param[in] data Model parameters and monitor selections. */ template Reecb::Reecb(BusT* bus, const ModelDataT& data) @@ -56,424 +57,21 @@ namespace GridKit { initializeParameters(data); initializeMonitor(); - size_ = static_cast(ReecbInternalVariables::MAXIMUM); - } - - template - Reecb::~Reecb() - { - } - - /** - * @brief Resolve the parameter-derived constants and selector masks. - * - * Raises each controller lag to the well-posedness floor, sizes the - * component power base, and turns the four mode flags into complementary - * multiplicative masks. The masks let the residual select control paths - * without parameter-dependent control flow, which keeps its structure - * fixed for sparse automatic differentiation. - */ - template - void Reecb::setDerivedParameters() - { - // The lags are raised to the floor in place, so a negative value is - // rejected here while the value as read is still available. verify() - // reports the count. - auto check_non_negative = [&](RealT value, const char* name) - { - if (value < ZERO) - { - Log::error() << "Reecb: " << name << " must be non-negative\n"; - ++parameter_error_count_; - } - }; - - check_non_negative(Trv_, "Trv"); - check_non_negative(Tp_, "Tp"); - check_non_negative(Tiq_, "Tiq"); - check_non_negative(Tpord_, "Tpord"); - - if (Trv_ < TIME_CONSTANT_MINIMUM || Tp_ < TIME_CONSTANT_MINIMUM - || Tiq_ < TIME_CONSTANT_MINIMUM || Tpord_ < TIME_CONSTANT_MINIMUM) - { - Log::warning() << "Reecb: Trv, Tp, Tiq, and Tpord below " - << TIME_CONSTANT_MINIMUM - << " s are raised to that floor to keep the controller lags well posed\n"; - } - - Trv_ = std::max(Trv_, TIME_CONSTANT_MINIMUM); - Tp_ = std::max(Tp_, TIME_CONSTANT_MINIMUM); - Tiq_ = std::max(Tiq_, TIME_CONSTANT_MINIMUM); - Tpord_ = std::max(Tpord_, TIME_CONSTANT_MINIMUM); - - va_converter_base_ = mva_base_ * static_cast(1.0e6); - - pf_on_ = ZERO; - if (PfFlag_) - { - pf_on_ = ONE; - } - - v_on_ = ZERO; - if (VFlag_) - { - v_on_ = ONE; - } - - q_on_ = ZERO; - if (QFlag_) - { - q_on_ = ONE; - } - - pf_off_ = ONE - pf_on_; - v_off_ = ONE - v_on_; - q_off_ = ONE - q_on_; - - pq_on_ = ZERO; - if (Pqflag_) - { - pq_on_ = ONE; - } - pq_off_ = ONE - pq_on_; - } - - /** - * @brief Convert a system-base power or current to REECB component base. - * - * @param[in] value Quantity on the system base. - * @return The same quantity on the component base. - */ - template - scalar_type Reecb::toComponentBase( - scalar_type value) const - { - return value * va_system_base_ / va_converter_base_; - } - - /** - * @brief Convert a component-base power or current to the system base. - * - * @param[in] value Quantity on the component base. - * @return The same quantity on the system base. - */ - template - scalar_type Reecb::toSystemBase( - scalar_type value) const - { - return value / toComponentBase(static_cast(ONE)); - } - - /** - * @brief Access the terminal-bus real voltage component. - */ - template - scalar_type& Reecb::Vr() - { - return bus_->Vr(); - } - - /** - * @brief Access the terminal-bus imaginary voltage component. - */ - template - scalar_type& Reecb::Vi() - { - return bus_->Vi(); - } - - /** - * @brief Evaluate log(1 - exp(-x)) without cancellation. - * - * Both terms approach one for a small argument, so the direct form loses - * precision exactly where the limiter inversions below need it. The - * hyperbolic identity is used under log 2 and log1p above it. - * - * @param[in] x Strictly positive argument. - * @return The logarithm, always negative. - */ - template - typename Reecb::RealT - Reecb::logOneMinusExp(RealT x) const - { - static constexpr RealT log_two = std::numbers::ln2_v; - - if (x < log_two) - { - return log_two - HALF * x - + std::log(std::sinh(HALF * x)); - } - return std::log1p(-std::exp(-x)); - } - - /** - * @brief Recover the input that a smooth clamp maps to a requested output. - * - * Initialization uses the same smooth CommonMath clamp as the residual, - * so a steady state must be seeded with the limiter *input* rather than - * its output. The smooth clamp is asymptotic at both limits, so a - * requested output within the initialization tolerance of a limit is - * represented by a finite offset past it instead of by the true infinite - * preimage. - * - * @tparam LowerT Type of the lower limiter bound. - * @tparam UpperT Type of the upper limiter bound. - * - * @param[in] requested_output Output the limiter must reproduce. - * @param[in] lower_limit Lower limiter bound. - * @param[in] upper_limit Upper limiter bound. - * @param[out] limiter_input Input producing the requested output. - * @return false when the request lies outside the limits, or when the - * limits coincide at a different value; the output is then unset. - * - * @note The limit types intentionally may differ from the scalar type so - * that constant Real limits and algebraic-variable limits both work. - */ - template - template - bool Reecb::solveLimiterInput( - ScalarT requested_output, - LowerT lower_limit, - UpperT upper_limit, - ScalarT& limiter_input) const - { - const RealT output_value = static_cast(requested_output); - const RealT lower_value = static_cast(lower_limit); - const RealT upper_value = static_cast(upper_limit); - - if (lower_value > upper_value - || output_value < lower_value - INITIALIZATION_TOLERANCE - || output_value > upper_value + INITIALIZATION_TOLERANCE) - { - return false; - } - - const RealT width = upper_value - lower_value; - if (width <= INITIALIZATION_TOLERANCE) - { - limiter_input = static_cast(lower_value); - return std::abs(output_value - lower_value) <= INITIALIZATION_TOLERANCE; - } - - const RealT distance_from_lower = output_value - lower_value; - const RealT distance_from_upper = upper_value - output_value; - if (distance_from_lower <= INITIALIZATION_TOLERANCE) - { - limiter_input = static_cast(lower_value - INITIALIZATION_LIMIT_OFFSET); - return true; - } - if (distance_from_upper <= INITIALIZATION_TOLERANCE) - { - limiter_input = static_cast(upper_value + INITIALIZATION_LIMIT_OFFSET); - return true; - } - - const RealT scaled_lower_distance = Math::MU * distance_from_lower; - const RealT scaled_upper_distance = Math::MU * distance_from_upper; - const RealT correction = (scaled_lower_distance - + logOneMinusExp(scaled_lower_distance) - - logOneMinusExp(scaled_upper_distance)) - / Math::MU; - limiter_input = static_cast(lower_value + correction); - return true; - } - - /** - * @brief Choose a PI input whose anti-windup derivative is stationary. - * - * An anti-windup integrator is at rest either because its rate is zero - * or because the rate pushes into a limit that blocks it. A nonzero rate - * is therefore parked just past the limit it drives toward. - * - * The zero-rate branch deliberately returns the nominal input without - * clamping it: an inactive PI history may legitimately sit outside its - * own output limits, and callers that need a representable output ask - * solveLimiterInput() for one explicitly. - * - * @tparam LowerT Type of the lower limiter bound. - * @tparam UpperT Type of the upper limiter bound. - * - * @param[in] nominal_input Input to keep when the rate is already zero. - * @param[in] rate Anti-windup integrator rate. - * @param[in] lower_limit Lower limiter bound. - * @param[in] upper_limit Upper limiter bound. - * @return A stationary integrator input. - * - * @note The limit types intentionally may differ from the scalar type so - * that constant Real limits and algebraic-variable limits both work. - */ - template - template - scalar_type Reecb::steadyAntiWindupInput( - ScalarT nominal_input, - ScalarT rate, - LowerT lower_limit, - UpperT upper_limit) const - { - const RealT rate_value = static_cast(rate); - if (std::abs(rate_value) <= INITIALIZATION_TOLERANCE) - { - return nominal_input; - } - if (rate_value > ZERO) - { - return upper_limit + static_cast(INITIALIZATION_LIMIT_OFFSET); - } - return lower_limit - static_cast(INITIALIZATION_LIMIT_OFFSET); - } - - /** - * @brief Read the parameters out of the model data. - * - * Only the component power base is required; every other parameter keeps - * the default documented in the model README when omitted. A missing - * required key, a non-numeric value, or a switch outside {0, 1} is - * counted and reported by verify() rather than throwing. Integer JSON - * values are accepted for real parameters. - * - * @param[in] data Parameters and monitored-variable selections. - */ - template - void Reecb::initializeParameters(const ModelDataT& data) - { - using Params = typename ModelDataT::Parameters; - - parameter_error_count_ = 0; - Vref0_given_ = false; - - auto load_real = [&](auto key, RealT& target, const char* name) - { - if (!data.parameters.contains(key)) - { - return; - } - - const auto& value = data.parameters.at(key); - if (const auto* real_value = std::get_if(&value)) - { - target = *real_value; - } - else if (const auto* index_value = std::get_if(&value)) - { - target = static_cast(*index_value); - } - else - { - Log::error() << "Reecb: parameter '" << name << "' must be numeric\n"; - ++parameter_error_count_; - } - }; - - auto load_switch = [&](auto key, bool& target, const char* name) - { - if (!data.parameters.contains(key)) - { - return; - } - - const auto& value = data.parameters.at(key); - if (const auto* bool_value = std::get_if(&value)) - { - target = *bool_value; - } - else if (const auto* index_value = std::get_if(&value); - index_value && (*index_value == 0 || *index_value == 1)) - { - target = (*index_value == 1); - } - else - { - Log::error() << "Reecb: parameter '" << name - << "' must be bool or integer 0/1\n"; - ++parameter_error_count_; - } - }; - - if (!data.parameters.contains(Params::mva)) - { - Log::error() << "Reecb: missing required parameter 'mva'\n"; - ++parameter_error_count_; - } - load_real(Params::mva, mva_base_, "mva"); - load_switch(Params::PfFlag, PfFlag_, "PfFlag"); - load_switch(Params::VFlag, VFlag_, "VFlag"); - load_switch(Params::QFlag, QFlag_, "QFlag"); - load_switch(Params::Pqflag, Pqflag_, "Pqflag"); - load_real(Params::Trv, Trv_, "Trv"); - load_real(Params::Tp, Tp_, "Tp"); - if (data.parameters.contains(Params::Vref0)) - { - load_real(Params::Vref0, Vref0_, "Vref0"); - Vref0_given_ = true; - } - load_real(Params::Vdip, Vdip_, "Vdip"); - load_real(Params::Vup, Vup_, "Vup"); - load_real(Params::dbd1, dbd1_, "dbd1"); - load_real(Params::dbd2, dbd2_, "dbd2"); - load_real(Params::kqv, kqv_, "kqv"); - load_real(Params::Iql1, Iql1_, "Iql1"); - load_real(Params::Iqh1, Iqh1_, "Iqh1"); - load_real(Params::Qmax, Qmax_, "Qmax"); - load_real(Params::Qmin, Qmin_, "Qmin"); - load_real(Params::Kqp, Kqp_, "Kqp"); - load_real(Params::Kqi, Kqi_, "Kqi"); - load_real(Params::Vmax, Vmax_, "Vmax"); - load_real(Params::Vmin, Vmin_, "Vmin"); - load_real(Params::Kvp, Kvp_, "Kvp"); - load_real(Params::Kvi, Kvi_, "Kvi"); - load_real(Params::Tiq, Tiq_, "Tiq"); - load_real(Params::Tpord, Tpord_, "Tpord"); - load_real(Params::dPmax, dPmax_, "dPmax"); - load_real(Params::dPmin, dPmin_, "dPmin"); - load_real(Params::Pmax, Pmax_, "Pmax"); - load_real(Params::Pmin, Pmin_, "Pmin"); - load_real(Params::Imax, Imax_, "Imax"); - setDerivedParameters(); - } - - /** - * @brief Access the monitor. - * - * @return Monitor for this model, or nullptr when the model was - * constructed without data. - */ - template - const Model::VariableMonitorBase* Reecb::getMonitor() const - { - return monitor_.get(); + size_ = static_cast(index(ReecbInternalVariables::MAXIMUM)); } /** - * @brief Bind the monitorable variables to their internal states. - * - * The two current commands are published on the system base and the two - * filtered measurements on the component base, as documented in the - * model README. + * @brief Destroy the electrical controller and its optional variable monitor. */ template - void Reecb::initializeMonitor() + Reecb::~Reecb() { - using Variable = typename ModelDataT::MonitorableVariables; - - constexpr auto VMEAS = static_cast(ReecbInternalVariables::VMEAS); - constexpr auto PMEAS = static_cast(ReecbInternalVariables::PMEAS); - constexpr auto IQCMD = static_cast(ReecbInternalVariables::IQCMD); - constexpr auto IPCMD = static_cast(ReecbInternalVariables::IPCMD); - - monitor_->set(Variable::iqcmd, [this] - { return y_.getData()[IQCMD]; }); - monitor_->set(Variable::ipcmd, [this] - { return y_.getData()[IPCMD]; }); - monitor_->set(Variable::vmeas, [this] - { return y_.getData()[VMEAS]; }); - monitor_->set(Variable::pmeas, [this] - { return y_.getData()[PMEAS]; }); } /** - * @brief Set the component identifier assigned by the system model. + * @brief Set the component ID * - * @param[in] component_id Component identifier. + * @param[in] component_id Identifier assigned by the system model. */ template int Reecb::setGridKitComponentID(IdxT component_id) @@ -483,23 +81,24 @@ namespace GridKit } /** - * @brief Allocate model storage and connect assigned output signals. - * - * Sizes the state, residual, bus-interface, and signal-interface - * buffers, seeds the identity index maps, and points each assigned - * command node at the internal state it publishes. Those nodes alias - * REECB storage from here on, which is how initialize() reads the seeds - * an upstream model wrote. Repeated calls reuse the allocated vectors. + * @brief Allocate model vectors and wire assigned current-command outputs * + * Sizes the state, residual, bus, and signal-interface buffers, initializes + * identity index maps, and points assigned command nodes at the internal + * system-base states that REECB publishes. Repeated allocation reuses the + * existing model vectors and signal links. */ template int Reecb::allocate() { + using I = ReecbInternalVariables; + using E = ReecbExternalVariables; + if (!allocated_) { this->allocateVectors(size_); } - auto size = static_cast(size_); + const auto size = static_cast(size_); tag_.assign(size, false); variable_indices_.resize(size); @@ -507,7 +106,7 @@ namespace GridKit wb_.assign(2, ScalarT{0}); - auto signal_size = static_cast(ReecbExternalVariables::MAXIMUM); + const auto signal_size = index(E::MAXIMUM); ws_.assign(signal_size, ScalarT{0}); ws_indices_.assign(signal_size, INVALID_INDEX); @@ -519,21 +118,18 @@ namespace GridKit auto* y = y_.getData(); - const auto IQCMD = static_cast(ReecbInternalVariables::IQCMD); - const auto IPCMD = static_cast(ReecbInternalVariables::IPCMD); - - if (signals_.template isAssigned()) + if (signals_.template isAssigned()) { - signals_.template getSignalNode()->set( - &y[IQCMD], - &(this->getVariableIndex(static_cast(ReecbInternalVariables::IQCMD)))); + signals_.template getSignalNode()->set( + &y[index(I::IQCMD)], + &(this->getVariableIndex(static_cast(index(I::IQCMD))))); } - if (signals_.template isAssigned()) + if (signals_.template isAssigned()) { - signals_.template getSignalNode()->set( - &y[IPCMD], - &(this->getVariableIndex(static_cast(ReecbInternalVariables::IPCMD)))); + signals_.template getSignalNode()->set( + &y[index(I::IPCMD)], + &(this->getVariableIndex(static_cast(index(I::IPCMD))))); } allocated_ = true; @@ -541,379 +137,376 @@ namespace GridKit } /** - * @brief Validate the REECB configuration. + * @brief Validate the REECB configuration * - * Checks parameter-loading errors, static parameter relationships, - * terminal-bus association, and attached external signals. Seeded - * command feasibility is operating-point dependent and is checked by + * Checks parameter-loading errors, finiteness and static relationships, + * system/component bases and conversion ratios, the terminal bus, and + * attached optional signals. Operating-point feasibility is checked by * initialize(). * - * @return Number of configuration errors, zero when valid. + * @return Number of configuration errors; zero when valid. */ template int Reecb::verify() const { int ret = static_cast(parameter_error_count_); - auto check = [&](bool condition, const char* message) + checkConfiguration(bus_ != nullptr, "terminal bus is required", ret); + + const RealT component_power_base = componentPowerBase(); + const bool valid_component_base = std::isfinite(component_power_base) && component_power_base > ZERO; + const bool valid_system_base = std::isfinite(va_system_base_) && va_system_base_ > ZERO; + checkConfiguration(valid_component_base, "component power base must be finite and positive", ret); + checkConfiguration(valid_system_base, "system power base must be finite and positive", ret); + if (valid_component_base && valid_system_base) { - if (!condition) - { - Log::error() << "Reecb: " << message << '\n'; - ret += 1; - } - }; + const RealT system_to_component = va_system_base_ / component_power_base; + const RealT component_to_system = component_power_base / va_system_base_; + checkConfiguration( + std::isfinite(system_to_component) + && system_to_component > ZERO + && std::isfinite(component_to_system) + && component_to_system > ZERO, + "system/component power-base conversion ratios must be finite and positive", + ret); + } - if (bus_ == nullptr) + checkConfiguration(std::isfinite(Trv_), "Trv must be finite", ret); + checkConfiguration(std::isfinite(Tp_), "Tp must be finite", ret); + checkConfiguration(std::isfinite(Vref0_), "Vref0 must be finite", ret); + + const bool finite_voltage_thresholds = std::isfinite(Vdip_) && std::isfinite(Vup_); + checkConfiguration(finite_voltage_thresholds, "Vdip and Vup must be finite", ret); + if (finite_voltage_thresholds) { - Log::error() << "Reecb: bus pointer is null\n"; - ret += 1; + checkConfiguration(Vdip_ < Vup_, "Vdip must be less than Vup", ret); } - check(mva_base_ > ZERO, "mva must be positive"); - check(Vdip_ < Vup_, "Vdip must be less than Vup"); - check(dbd1_ <= ZERO && ZERO <= dbd2_, "dbd1 <= 0 <= dbd2 is required"); - check(Iql1_ <= Iqh1_, "Iql1 must be less than or equal to Iqh1"); - check(Qmin_ <= Qmax_, "Qmin must be less than or equal to Qmax"); - check(Vmin_ <= Vmax_, "Vmin must be less than or equal to Vmax"); - check(dPmin_ < ZERO && ZERO < dPmax_, "dPmin < 0 < dPmax is required"); - check(Pmin_ <= Pmax_, "Pmin must be less than or equal to Pmax"); - check(Imax_ >= ZERO, "Imax must be non-negative"); - - if (signals_.template isAttached()) + const bool finite_voltage_deadband = std::isfinite(dbd1_) && std::isfinite(dbd2_); + checkConfiguration(finite_voltage_deadband, "dbd1 and dbd2 must be finite", ret); + if (finite_voltage_deadband) { - if (!signals_.template isLinked()) - { - Log::error() << "Reecb: pe signal attached with no linked source\n"; - ret += 1; - } + checkConfiguration(dbd1_ <= ZERO && ZERO <= dbd2_, "dbd1 <= 0 <= dbd2 is required", ret); } - if (signals_.template isAttached()) + checkConfiguration(std::isfinite(kqv_), "kqv must be finite", ret); + + const bool finite_injection_limits = std::isfinite(Iql1_) && std::isfinite(Iqh1_); + checkConfiguration(finite_injection_limits, "Iql1 and Iqh1 must be finite", ret); + if (finite_injection_limits) { - if (!signals_.template isLinked()) - { - Log::error() << "Reecb: qgen signal attached with no linked source\n"; - ret += 1; - } + checkConfiguration(Iql1_ <= Iqh1_, "Iql1 must be less than or equal to Iqh1", ret); } - if (signals_.template isAttached()) + const bool finite_reactive_limits = std::isfinite(Qmin_) && std::isfinite(Qmax_); + checkConfiguration(finite_reactive_limits, "Qmin and Qmax must be finite", ret); + if (finite_reactive_limits) { - if (!signals_.template isLinked()) - { - Log::error() << "Reecb: qext signal attached with no linked source\n"; - ret += 1; - } + checkConfiguration(Qmin_ <= Qmax_, "Qmin must be less than or equal to Qmax", ret); + } + + checkConfiguration(std::isfinite(Kqp_), "Kqp must be finite", ret); + checkConfiguration(std::isfinite(Kqi_), "Kqi must be finite", ret); + + const bool finite_voltage_limits = std::isfinite(Vmin_) && std::isfinite(Vmax_); + checkConfiguration(finite_voltage_limits, "Vmin and Vmax must be finite", ret); + if (finite_voltage_limits) + { + checkConfiguration(Vmin_ <= Vmax_, "Vmin must be less than or equal to Vmax", ret); } - if (signals_.template isAttached()) + checkConfiguration(std::isfinite(Kvp_), "Kvp must be finite", ret); + checkConfiguration(std::isfinite(Kvi_), "Kvi must be finite", ret); + checkConfiguration(std::isfinite(Tiq_), "Tiq must be finite", ret); + checkConfiguration(std::isfinite(Tpord_), "Tpord must be finite", ret); + + const bool finite_ramp_limits = std::isfinite(dPmin_) && std::isfinite(dPmax_); + checkConfiguration(finite_ramp_limits, "dPmin and dPmax must be finite", ret); + if (finite_ramp_limits) { - if (!signals_.template isLinked()) - { - Log::error() << "Reecb: pfaref signal attached with no linked source\n"; - ret += 1; - } + checkConfiguration(dPmin_ < ZERO && ZERO < dPmax_, "dPmin < 0 < dPmax is required", ret); + } + + const bool finite_active_limits = std::isfinite(Pmin_) && std::isfinite(Pmax_); + checkConfiguration(finite_active_limits, "Pmin and Pmax must be finite", ret); + if (finite_active_limits) + { + checkConfiguration(Pmin_ <= Pmax_, "Pmin must be less than or equal to Pmax", ret); } - if (signals_.template isAttached()) + checkConfiguration(std::isfinite(Imax_) && Imax_ > ZERO, "Imax must be finite and positive", ret); + + checkConfiguration(!(PfFlag_ && QFlag_ && !VFlag_), + "power-factor control cannot drive the direct-voltage reference (PfFlag = 1 with QFlag = 1, VFlag = 0)", + ret); + + auto check_optional_signal = [&](const char* name) { - if (!signals_.template isLinked()) + if (signals_.template isAttached() && !signals_.template isLinked()) { - Log::error() << "Reecb: pref signal attached with no linked source\n"; + Log::error() << "Reecb: " << name << " signal attached with no linked source\n"; ret += 1; } - } + }; + + check_optional_signal.template operator()("pe"); + check_optional_signal.template operator()("qgen"); + check_optional_signal.template operator()("qext"); + check_optional_signal.template operator()("pfaref"); + check_optional_signal.template operator()("pref"); return ret; } /** - * @brief Initialize REECB from seeded current-command ports. + * @brief Initialize REECB from the initial current commands and feedback * - * Reads the assigned system-base `ipcmd` and `iqcmd` nodes, resolves a - * component-base steady state that preserves those seeds, and initializes - * attached feedback/reference signals. All operating-point checks are - * completed before model or signal storage is modified. + * Preserves the system-base command states, consumes attached initialized + * active/reactive-power feedback or reconstructs unattached feedback, + * resolves a component-base steady state, and publishes the unknown + * optional reference signals. * * @pre allocate() has completed. - * @pre verify() has reported no configuration errors. - * @pre The terminal bus and assigned command nodes have been initialized. + * @pre verify() reports a valid parameter and port configuration. + * @pre The terminal bus and current-command states are initialized. + * + * @post On failure no state, derivative, latch, parameter, or signal + * storage is modified. * - * @return Zero on success; nonzero when the commands are outside the - * current circle, the selected control path cannot represent them, - * or an initial reference is undefined. + * @return 0 on success; nonzero when allocation, configuration, initial- + * value, current-circle, limiter, or steady-state checks fail. */ template int Reecb::initialize() { - const auto VMEAS = static_cast(ReecbInternalVariables::VMEAS); - const auto PMEAS = static_cast(ReecbInternalVariables::PMEAS); - const auto XPIQ = static_cast(ReecbInternalVariables::XPIQ); - const auto XPIV = static_cast(ReecbInternalVariables::XPIV); - const auto QV = static_cast(ReecbInternalVariables::QV); - const auto PORD = static_cast(ReecbInternalVariables::PORD); - const auto VT = static_cast(ReecbInternalVariables::VT); - const auto VMEASSAFE = static_cast(ReecbInternalVariables::VMEASSAFE); - const auto SDIP = static_cast(ReecbInternalVariables::SDIP); - const auto VERR = static_cast(ReecbInternalVariables::VERR); - const auto IQV = static_cast(ReecbInternalVariables::IQV); - const auto QREF = static_cast(ReecbInternalVariables::QREF); - const auto EQ = static_cast(ReecbInternalVariables::EQ); - const auto VPIQ = static_cast(ReecbInternalVariables::VPIQ); - const auto EPIV = static_cast(ReecbInternalVariables::EPIV); - const auto FPORD = static_cast(ReecbInternalVariables::FPORD); - const auto RPORD = static_cast(ReecbInternalVariables::RPORD); - const auto IQCIRC = static_cast(ReecbInternalVariables::IQCIRC); - const auto IPCIRC = static_cast(ReecbInternalVariables::IPCIRC); - const auto IQMAX = static_cast(ReecbInternalVariables::IQMAX); - const auto IPMAX = static_cast(ReecbInternalVariables::IPMAX); - const auto IQBASE = static_cast(ReecbInternalVariables::IQBASE); - const auto IQRAW = static_cast(ReecbInternalVariables::IQRAW); - const auto IQCMD = static_cast(ReecbInternalVariables::IQCMD); - const auto IPCMD = static_cast(ReecbInternalVariables::IPCMD); + using I = ReecbInternalVariables; + using E = ReecbExternalVariables; + + if (!allocated_) + { + Log::error() << "Reecb: allocate must complete before initialize\n"; + return 1; + } + + if (verify() > 0) + { + Log::error() << "Reecb: cannot initialize with invalid configuration\n"; + return 1; + } auto* y = y_.getData(); - // Assigned command nodes alias these entries after allocate(). Their - // system-base seeds remain untouched throughout initialization. - const ScalarT ipcmd0_system = y[IPCMD]; - const ScalarT iqcmd0_system = y[IQCMD]; - const ScalarT ipcmd0 = toComponentBase(ipcmd0_system); - const ScalarT iqcmd0 = toComponentBase(iqcmd0_system); - const RealT ipcmd0_value = static_cast(ipcmd0); - const RealT iqcmd0_value = static_cast(iqcmd0); - - const ScalarT vr = Vr(); - const ScalarT vi = Vi(); - const ScalarT vt0 = std::sqrt(vr * vr + vi * vi); - const ScalarT vmeas0 = vt0; - const ScalarT vmeas_safe0 = Math::max(vmeas0, VMEAS_MINIMUM); - const ScalarT pmeas0 = ipcmd0 * vmeas_safe0; - const ScalarT qgen0 = iqcmd0 * vmeas_safe0; - - RealT vref0 = static_cast(vt0); + // Covers roundoff from inverse clamps and component/system-base round trips. + const RealT tol = static_cast(100) * std::numeric_limits::epsilon(); + const RealT ipcmd0_system = static_cast(y[index(I::IPCMD)]); + const RealT iqcmd0_system = static_cast(y[index(I::IQCMD)]); + const RealT ipcmd0 = toComponentBase(ipcmd0_system); + const RealT iqcmd0 = toComponentBase(iqcmd0_system); + const RealT vr0 = static_cast(Vr()); + const RealT vi0 = static_cast(Vi()); + const RealT vt0 = std::sqrt(vr0 * vr0 + vi0 * vi0); + const RealT vmeas0 = vt0; + const RealT vmeas_safe0 = Math::max(vmeas0, VMEAS_MINIMUM); + const RealT active_order0 = ipcmd0 * vmeas_safe0; + RealT pe0_system = toSystemBase(active_order0); + RealT qgen0_system = toSystemBase(iqcmd0 * vmeas_safe0); + + if (signals_.template isAttached()) + { + pe0_system = static_cast(signals_.template readExternalVariable()); + } + if (signals_.template isAttached()) + { + qgen0_system = static_cast(signals_.template readExternalVariable()); + } + + const RealT pmeas0 = toComponentBase(pe0_system); + const RealT qgen0 = toComponentBase(qgen0_system); + RealT vref0 = vt0; if (Vref0_given_) { vref0 = Vref0_; } - if (!std::isfinite(ipcmd0_value) || !std::isfinite(iqcmd0_value) || !std::isfinite(static_cast(vt0))) + if (!std::isfinite(vr0) || !std::isfinite(vi0) || !std::isfinite(vt0) || !std::isfinite(vmeas_safe0) + || !std::isfinite(ipcmd0) || !std::isfinite(iqcmd0) || !std::isfinite(pmeas0) + || !std::isfinite(qgen0) || !std::isfinite(vref0)) { - Log::error() << "Reecb: initial bus voltage and current commands must be finite\n"; + Log::error() << "Reecb: initial bus, command, and feedback values must be finite\n"; return 1; } - if (ipcmd0_value < ZERO) + if (vt0 <= ZERO) { - Log::error() << "Reecb: initial active-current command must be non-negative\n"; + Log::error() << "Reecb: initial terminal-voltage magnitude must be positive\n"; return 1; } - const RealT current_squared0 = ipcmd0_value * ipcmd0_value + iqcmd0_value * iqcmd0_value; - const RealT current_limit_squared0 = Imax_ * Imax_; - if (current_squared0 > current_limit_squared0 + INITIALIZATION_TOLERANCE) + const RealT verr0 = Math::deadband2(vref0 - vmeas0, dbd1_, dbd2_); + const RealT iqv0 = Math::clamp(kqv_ * verr0, Iql1_, Iqh1_); + const RealT ilmax_squared = Imax_ * Imax_ - pq_on_ * ipcmd0 * ipcmd0 - pq_off_ * iqcmd0 * iqcmd0; + + if (!std::isfinite(ilmax_squared) || ilmax_squared <= ZERO) { - Log::error() << "Reecb: initial current commands exceed the Imax circle\n"; + Log::error() << "Reecb: initial operating point leaves no low-priority current capacity\n"; return 1; } - const RealT pmeas0_value = static_cast(pmeas0); - if (pmeas0_value < Pmin_ - INITIALIZATION_TOLERANCE || pmeas0_value > Pmax_ + INITIALIZATION_TOLERANCE) + const RealT ilmax0 = std::sqrt(ilmax_squared); + const RealT iqmax0 = pq_on_ * ilmax0 + pq_off_ * Imax_; + const RealT ipmax0 = pq_on_ * Imax_ + pq_off_ * ilmax0; + + if (ipcmd0 <= ZERO || ipcmd0 >= ipmax0 || iqcmd0 <= -iqmax0 || iqcmd0 >= iqmax0) { - Log::error() << "Reecb: initial active power is outside Pmin/Pmax\n"; + Log::error() << "Reecb: initial current commands must lie strictly inside their limiter ranges\n"; return 1; } - const RealT iqcirc_squared0 = current_limit_squared0 - pq_on_ * ipcmd0_value * ipcmd0_value; - const RealT ipcirc_squared0 = current_limit_squared0 - pq_off_ * iqcmd0_value * iqcmd0_value; - if (iqcirc_squared0 < -INITIALIZATION_TOLERANCE || ipcirc_squared0 < -INITIALIZATION_TOLERANCE) + const RealT pord0 = vmeas_safe0 * unclamp(ipcmd0, ZERO, ipmax0); + if (pord0 < Pmin_ - tol || pord0 > Pmax_ + tol) { - Log::error() << "Reecb: initial current commands violate the selected priority circle\n"; + Log::error() << "Reecb: recovered active-power order is outside Pmin/Pmax\n"; return 1; } - const ScalarT iqcirc0 = static_cast(std::sqrt(std::max(iqcirc_squared0, ZERO))); - const ScalarT ipcirc0 = static_cast(std::sqrt(std::max(ipcirc_squared0, ZERO))); - const ScalarT iqmax0 = pq_off_ * static_cast(Imax_) + pq_on_ * iqcirc0; - const ScalarT ipmax0 = pq_on_ * static_cast(Imax_) + pq_off_ * ipcirc0; + const RealT fpord0 = unclamp(ZERO, dPmin_, dPmax_); + const RealT pref0 = pord0 + Tpord_ * fpord0; + const RealT iqraw0 = unclamp(iqcmd0, -iqmax0, iqmax0); + const RealT iqctl0 = iqraw0 - iqv0; - const ScalarT sdip0 = Math::inside(vt0, Vdip_, Vup_); - const ScalarT verr0 = Math::deadband2(static_cast(vref0) - vmeas0, dbd1_, dbd2_); - const ScalarT iqv0 = Math::clamp(kqv_ * verr0, Iql1_, Iqh1_); + // Unconstrained reactive targets stay zero; the masked equilibrium + // checks below reject any choice the enabled integrators cannot hold. + RealT qtarget0 = ZERO; - ScalarT iqraw0{}; - if (!solveLimiterInput(iqcmd0, -iqmax0, iqmax0, iqraw0)) + if (!QFlag_) { - Log::error() << "Reecb: initial reactive-current command is outside the available current limit\n"; - return 1; + qtarget0 = iqctl0 * vmeas_safe0; } - - ScalarT ip_limiter_input0{}; - if (!solveLimiterInput(ipcmd0, ZERO, ipmax0, ip_limiter_input0)) + else if (VFlag_ && Qmin_ < qgen0 && qgen0 < Qmax_) { - Log::error() << "Reecb: initial active-current command is outside the available current limit\n"; - return 1; + qtarget0 = unclamp(qgen0, Qmin_, Qmax_); } - const ScalarT pord0 = ip_limiter_input0 * vmeas_safe0; - ScalarT fpord0{}; - if (!solveLimiterInput(static_cast(ZERO), dPmin_, dPmax_, fpord0)) - { - Log::error() << "Reecb: zero initial active-power ramp is outside dPmin/dPmax\n"; - return 1; - } - const ScalarT rpord0 = Math::clamp(fpord0, dPmin_, dPmax_); - const ScalarT pref0 = pord0 + Tpord_ * fpord0; + // The Volt/VAr channel publishes a terminal-voltage reference in + // direct-voltage mode and a system-base reactive power otherwise. + RealT qref0 = ZERO; + RealT qext0_port = ZERO; + RealT pfaref0 = ZERO; - const ScalarT iq_control0 = iqraw0 - iqv0; - - struct ReactiveSeed + if (QFlag_ && !VFlag_) { - ScalarT qv{}; - ScalarT qref{}; - ScalarT eq{}; - ScalarT vpiq{}; - ScalarT epiv{}; - ScalarT xpiq{}; - ScalarT iqbase{}; - ScalarT xpiv{}; - } reactive; - - if (!QFlag_) - { - reactive.qv = iq_control0; - reactive.qref = reactive.qv * vmeas_safe0; + // The V PI holds the raw reference at the measurement exactly; a + // zero Kvi keeps the same physical setpoint. + qext0_port = vmeas0; } - else if (!VFlag_) + else if (PfFlag_) { - reactive.qref = vmeas0; - reactive.qv = reactive.qref / vmeas_safe0; + if (std::abs(pmeas0) > tol) + { + pfaref0 = std::atan(qtarget0 / pmeas0); + qref0 = pmeas0 * std::tan(pfaref0); + } + qext0_port = toSystemBase(qref0); } else { - if (!solveLimiterInput(qgen0, Qmin_, Qmax_, reactive.qref)) - { - Log::error() << "Reecb: initial reactive power is outside Qmin/Qmax\n"; - return 1; - } - - reactive.qv = reactive.qref / vmeas_safe0; + qext0_port = toSystemBase(qtarget0); + qref0 = toComponentBase(qext0_port); } - reactive.eq = Math::clamp(reactive.qref, Qmin_, Qmax_) - qgen0; + // The masked product mirrors the residual integrator rate. + const RealT eq0 = Math::clamp(qref0, Qmin_, Qmax_) - qgen0; + if (std::abs(q_pi_on_ * Kqi_ * eq0) > tol) + { + Log::error() << "Reecb: reactive-power integral path is not at equilibrium\n"; + return 1; + } - ScalarT vpiq_input0{}; + // The Q-PI state reproduces the measured voltage through the inverse + // clamp on the V limits + RealT xpiq0 = ZERO; if (QFlag_ && VFlag_) { - if (!solveLimiterInput(vmeas0, Vmin_, Vmax_, vpiq_input0)) + xpiq0 = -Kqp_ * eq0; + if (Vmin_ < vmeas0 && vmeas0 < Vmax_) { - Log::error() << "Reecb: initial voltage is outside Vmin/Vmax\n"; - return 1; + xpiq0 += unclamp(vmeas0, Vmin_, Vmax_); } } - else + + const RealT vpiq0 = Math::clamp(Kqp_ * eq0 + xpiq0, Vmin_, Vmax_); + const RealT epiv0 = q_pi_on_ * vpiq0 + v_ref_on_ * qext0_port - q_on_ * vmeas0; + if (std::abs(q_on_ * Kvi_ * epiv0) > tol) { - const ScalarT vpiq_nominal0 = v_on_ * vmeas0 + v_off_ * reactive.qref; - vpiq_input0 = steadyAntiWindupInput(vpiq_nominal0, Kqi_ * reactive.eq, Vmin_, Vmax_); + Log::error() << "Reecb: voltage-control integral path is not at equilibrium\n"; + return 1; } - reactive.vpiq = Math::clamp(vpiq_input0, Vmin_, Vmax_); - reactive.epiv = v_on_ * reactive.vpiq + v_off_ * reactive.qref - vmeas0; - reactive.xpiq = vpiq_input0 - Kqp_ * reactive.eq; + RealT qv0 = ZERO; + RealT xpiv0 = ZERO; - ScalarT iqbase_input0{}; if (QFlag_) { - if (!solveLimiterInput(iq_control0, -iqmax0, iqmax0, iqbase_input0)) + if (iqctl0 <= -iqmax0 || iqctl0 >= iqmax0) { - Log::error() << "Reecb: initial reactive-current command is outside the voltage-controller current limit\n"; + Log::error() << "Reecb: initial voltage-controller current is outside its limiter range\n"; return 1; } + xpiv0 = unclamp(iqctl0, -iqmax0, iqmax0) - Kvp_ * epiv0; } else { - iqbase_input0 = steadyAntiWindupInput(static_cast(ZERO), Kvi_ * reactive.epiv, -iqmax0, iqmax0); + qv0 = qref0 / vmeas_safe0; + if (std::abs(qv0 - iqctl0) > tol) + { + Log::error() << "Reecb: reactive-reference path cannot reproduce the initial reactive-current command\n"; + return 1; + } } - reactive.iqbase = Math::clamp(iqbase_input0, -iqmax0, iqmax0); - reactive.xpiv = iqbase_input0 - Kvp_ * reactive.epiv; - - ScalarT pfaref0 = static_cast(ZERO); - if (PfFlag_) + const RealT pref0_system = toSystemBase(pref0); + if (!std::isfinite(verr0) || !std::isfinite(iqv0) || !std::isfinite(ilmax0) + || !std::isfinite(iqmax0) || !std::isfinite(ipmax0) + || !std::isfinite(pord0) || !std::isfinite(fpord0) || !std::isfinite(pref0) + || !std::isfinite(iqraw0) || !std::isfinite(iqctl0) || !std::isfinite(qref0) + || !std::isfinite(qext0_port) || !std::isfinite(pfaref0) || !std::isfinite(eq0) + || !std::isfinite(xpiq0) || !std::isfinite(vpiq0) || !std::isfinite(epiv0) + || !std::isfinite(qv0) || !std::isfinite(xpiv0) || !std::isfinite(pref0_system)) { - if (std::abs(pmeas0_value) <= INITIALIZATION_TOLERANCE) - { - if (std::abs(static_cast(reactive.qref)) > INITIALIZATION_TOLERANCE) - { - Log::error() << "Reecb: power-factor control cannot represent nonzero Qref at zero active power\n"; - return 1; - } - } - else - { - pfaref0 = static_cast(std::atan(static_cast(reactive.qref / pmeas0))); - } + Log::error() << "Reecb: initialization produced a nonfinite value\n"; + return 1; } - const ScalarT pe0_system = toSystemBase(pmeas0); - const ScalarT qgen0_system = toSystemBase(qgen0); - const ScalarT qext0_system = toSystemBase(reactive.qref); - const ScalarT pref0_system = toSystemBase(pref0); - - y[VMEAS] = vmeas0; - y[PMEAS] = pmeas0; - y[XPIQ] = reactive.xpiq; - y[XPIV] = reactive.xpiv; - y[QV] = reactive.qv; - y[PORD] = pord0; - y[VT] = vt0; - y[VMEASSAFE] = vmeas_safe0; - y[SDIP] = sdip0; - y[VERR] = verr0; - y[IQV] = iqv0; - y[QREF] = reactive.qref; - y[EQ] = reactive.eq; - y[VPIQ] = reactive.vpiq; - y[EPIV] = reactive.epiv; - y[FPORD] = fpord0; - y[RPORD] = rpord0; - y[IQCIRC] = iqcirc0; - y[IPCIRC] = ipcirc0; - y[IQMAX] = iqmax0; - y[IPMAX] = ipmax0; - y[IQBASE] = reactive.iqbase; - y[IQRAW] = iqraw0; + y[index(I::VMEAS)] = vmeas0; + y[index(I::PMEAS)] = pmeas0; + y[index(I::XPIQ)] = xpiq0; + y[index(I::XPIV)] = xpiv0; + y[index(I::QV)] = qv0; + y[index(I::PORD)] = pord0; + y[index(I::VT)] = vt0; + y[index(I::ILMAX)] = ilmax0; if (!Vref0_given_) { Vref0_ = vref0; } - pe_set_ = pe0_system; - qgen_set_ = qgen0_system; - qext_set_ = qext0_system; - pfaref_set_ = pfaref0; - pref_set_ = pref0_system; + pe_set_ = static_cast(pe0_system); + qgen_set_ = static_cast(qgen0_system); + qext_set_ = static_cast(qext0_port); + pfaref_set_ = static_cast(pfaref0); + pref_set_ = static_cast(pref0_system); - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - signals_.template writeExternalVariable(pe_set_); + signals_.template writeExternalVariable(qext_set_); } - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - signals_.template writeExternalVariable(qgen_set_); + signals_.template writeExternalVariable(pfaref_set_); } - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - signals_.template writeExternalVariable(qext_set_); - } - if (signals_.template isAttached()) - { - signals_.template writeExternalVariable(pfaref_set_); - } - if (signals_.template isAttached()) - { - signals_.template writeExternalVariable(pref_set_); + signals_.template writeExternalVariable(pref_set_); } y_.setDataUpdated(); @@ -922,34 +515,30 @@ namespace GridKit } /** - * @brief Identify the differential variables. - * - * The two measurement filters, the two PI states, the reactive-current - * lag, and the active-power order carry derivatives; every other - * internal variable is algebraic. + * @brief Identify the differential variables * + * The two measurement filters, two PI states, reactive-current lag, and + * active-power order carry derivatives; all other rows are algebraic. */ template int Reecb::tagDifferentiable() { + using I = ReecbInternalVariables; + std::fill(tag_.begin(), tag_.end(), false); - tag_[static_cast(ReecbInternalVariables::VMEAS)] = true; - tag_[static_cast(ReecbInternalVariables::PMEAS)] = true; - tag_[static_cast(ReecbInternalVariables::XPIQ)] = true; - tag_[static_cast(ReecbInternalVariables::XPIV)] = true; - tag_[static_cast(ReecbInternalVariables::QV)] = true; - tag_[static_cast(ReecbInternalVariables::PORD)] = true; + tag_[index(I::VMEAS)] = true; + tag_[index(I::PMEAS)] = true; + tag_[index(I::XPIQ)] = true; + tag_[index(I::XPIV)] = true; + tag_[index(I::QV)] = true; + tag_[index(I::PORD)] = true; return 0; } /** - * @brief Compute the absolute tolerance for each variable in the model. - * - * All REECB variables are per-unit voltages, powers, or currents of the - * same order, so they share the relative tolerance as their absolute - * floor. + * @brief Set one absolute tolerance for every REECB variable * - * @param[in] rel_tol Solver relative tolerance. + * @param[in] rel_tol Solver relative tolerance used as the absolute floor. */ template int Reecb::setAbsoluteTolerance(RealT rel_tol) @@ -959,20 +548,85 @@ namespace GridKit } /** - * @brief Evaluate the internal residual. + * @brief Evaluate the model residuals + * + * Starts from latched values, refreshes attached signals and their indices, + * refreshes terminal-bus voltage, and evaluates the internal residual. + * REECB contributes no bus residual. + */ + template + int Reecb::evaluateResidual() + { + using E = ReecbExternalVariables; + + ws_[index(E::PE)] = pe_set_; + ws_[index(E::QGEN)] = qgen_set_; + ws_[index(E::QEXT)] = qext_set_; + ws_[index(E::PFAREF)] = pfaref_set_; + ws_[index(E::PREF)] = pref_set_; + std::fill(ws_indices_.begin(), ws_indices_.end(), INVALID_INDEX); + + if (signals_.template isAttached()) + { + ws_[index(E::PE)] = signals_.template readExternalVariable(); + ws_indices_[index(E::PE)] = signals_.template readExternalVariableIndex(); + } + if (signals_.template isAttached()) + { + ws_[index(E::QGEN)] = signals_.template readExternalVariable(); + ws_indices_[index(E::QGEN)] = signals_.template readExternalVariableIndex(); + } + if (signals_.template isAttached()) + { + ws_[index(E::QEXT)] = signals_.template readExternalVariable(); + ws_indices_[index(E::QEXT)] = signals_.template readExternalVariableIndex(); + } + if (signals_.template isAttached()) + { + ws_[index(E::PFAREF)] = signals_.template readExternalVariable(); + ws_indices_[index(E::PFAREF)] = signals_.template readExternalVariableIndex(); + } + if (signals_.template isAttached()) + { + ws_[index(E::PREF)] = signals_.template readExternalVariable(); + ws_indices_[index(E::PREF)] = signals_.template readExternalVariableIndex(); + } + + wb_[0] = Vr(); + wb_[1] = Vi(); + + evaluateInternalResidual(y_.getData(), yp_.getData(), wb_.data(), ws_.data(), f_.getData()); + f_.setDataUpdated(); + return 0; + } + + /** + * @brief Access the optional variable monitor * - * Evaluates the six controller states and the nineteen algebraic rows - * documented in the model README. The body is kept free of branches and - * loops so that sparse automatic differentiation resolves a fixed - * structure; the four mode selections enter as the multiplicative masks - * set by setDerivedParameters(). + * @return Monitor, or nullptr when constructed without model data. + */ + template + const Model::VariableMonitorBase* Reecb::getMonitor() const + { + return monitor_.get(); + } + + /** + * @brief Evaluate the REECB internal residual + * + * The branch-free equation body preserves a fixed dependency structure; + * parameter-selected paths enter through selector masks resolved by + * setDerivedParameters(). The `ILMAX` row uses a signed-square + * continuation, while its magnitude supplies the limiter bounds, so a + * negative nonlinear iterate does not invert either range. * * @param[in] y Internal variables. * @param[in] yp Internal variable derivatives. * @param[in] wb Terminal-bus voltage components. - * @param[in] ws External signal values on system base. + * @param[in] ws External signal values in their documented port units and bases. * @param[out] f Internal residuals. - * @return Zero on success. + * @pre Jacobian evaluation requires `y[ILMAX] != 0`; initialization + * rejects the zero-capacity point. */ template __attribute__((always_inline)) inline int @@ -983,173 +637,453 @@ namespace GridKit const ScalarT* ws, ScalarT* f) { - const auto VMEAS = static_cast(ReecbInternalVariables::VMEAS); - const auto PMEAS = static_cast(ReecbInternalVariables::PMEAS); - const auto XPIQ = static_cast(ReecbInternalVariables::XPIQ); - const auto XPIV = static_cast(ReecbInternalVariables::XPIV); - const auto QV = static_cast(ReecbInternalVariables::QV); - const auto PORD = static_cast(ReecbInternalVariables::PORD); - const auto VT = static_cast(ReecbInternalVariables::VT); - const auto VMEASSAFE = static_cast(ReecbInternalVariables::VMEASSAFE); - const auto SDIP = static_cast(ReecbInternalVariables::SDIP); - const auto VERR = static_cast(ReecbInternalVariables::VERR); - const auto IQV = static_cast(ReecbInternalVariables::IQV); - const auto QREF = static_cast(ReecbInternalVariables::QREF); - const auto EQ = static_cast(ReecbInternalVariables::EQ); - const auto VPIQ = static_cast(ReecbInternalVariables::VPIQ); - const auto EPIV = static_cast(ReecbInternalVariables::EPIV); - const auto FPORD = static_cast(ReecbInternalVariables::FPORD); - const auto RPORD = static_cast(ReecbInternalVariables::RPORD); - const auto IQCIRC = static_cast(ReecbInternalVariables::IQCIRC); - const auto IPCIRC = static_cast(ReecbInternalVariables::IPCIRC); - const auto IQMAX = static_cast(ReecbInternalVariables::IQMAX); - const auto IPMAX = static_cast(ReecbInternalVariables::IPMAX); - const auto IQBASE = static_cast(ReecbInternalVariables::IQBASE); - const auto IQRAW = static_cast(ReecbInternalVariables::IQRAW); - const auto IQCMD = static_cast(ReecbInternalVariables::IQCMD); - const auto IPCMD = static_cast(ReecbInternalVariables::IPCMD); - - const auto PE = static_cast(ReecbExternalVariables::PE); - const auto QGEN = static_cast(ReecbExternalVariables::QGEN); - const auto QEXT = static_cast(ReecbExternalVariables::QEXT); - const auto PFAREF = static_cast(ReecbExternalVariables::PFAREF); - const auto PREF = static_cast(ReecbExternalVariables::PREF); - - const ScalarT vmeas = y[VMEAS]; - const ScalarT pmeas = y[PMEAS]; - const ScalarT xpiq = y[XPIQ]; - const ScalarT xpiv = y[XPIV]; - const ScalarT qv = y[QV]; - const ScalarT pord = y[PORD]; - const ScalarT vt = y[VT]; - const ScalarT vmeas_safe = y[VMEASSAFE]; - const ScalarT sdip = y[SDIP]; - const ScalarT verr = y[VERR]; - const ScalarT iqv = y[IQV]; - const ScalarT qref = y[QREF]; - const ScalarT eq = y[EQ]; - const ScalarT vpiq = y[VPIQ]; - const ScalarT epiv = y[EPIV]; - const ScalarT fpord = y[FPORD]; - const ScalarT rpord = y[RPORD]; - const ScalarT iqcirc = y[IQCIRC]; - const ScalarT ipcirc = y[IPCIRC]; - const ScalarT iqmax = y[IQMAX]; - const ScalarT ipmax = y[IPMAX]; - const ScalarT iqbase = y[IQBASE]; - const ScalarT iqraw = y[IQRAW]; - const ScalarT iqcmd_system = y[IQCMD]; - const ScalarT ipcmd_system = y[IPCMD]; - - const ScalarT vmeas_dot = yp[VMEAS]; - const ScalarT pmeas_dot = yp[PMEAS]; - const ScalarT xpiq_dot = yp[XPIQ]; - const ScalarT xpiv_dot = yp[XPIV]; - const ScalarT qv_dot = yp[QV]; - const ScalarT pord_dot = yp[PORD]; + using I = ReecbInternalVariables; + using E = ReecbExternalVariables; + + const ScalarT vmeas = y[index(I::VMEAS)]; + const ScalarT pmeas = y[index(I::PMEAS)]; + const ScalarT xpiq = y[index(I::XPIQ)]; + const ScalarT xpiv = y[index(I::XPIV)]; + const ScalarT qv = y[index(I::QV)]; + const ScalarT pord = y[index(I::PORD)]; + const ScalarT vt = y[index(I::VT)]; + const ScalarT ilmax = y[index(I::ILMAX)]; + const ScalarT iqcmd_system = y[index(I::IQCMD)]; + const ScalarT ipcmd_system = y[index(I::IPCMD)]; + + const ScalarT vmeas_dot = yp[index(I::VMEAS)]; + const ScalarT pmeas_dot = yp[index(I::PMEAS)]; + const ScalarT xpiq_dot = yp[index(I::XPIQ)]; + const ScalarT xpiv_dot = yp[index(I::XPIV)]; + const ScalarT qv_dot = yp[index(I::QV)]; + const ScalarT pord_dot = yp[index(I::PORD)]; const ScalarT vr = wb[0]; const ScalarT vi = wb[1]; - const ScalarT pe = toComponentBase(ws[PE]); - const ScalarT qgen = toComponentBase(ws[QGEN]); - const ScalarT qext = toComponentBase(ws[QEXT]); - const ScalarT pfaref = ws[PFAREF]; - const ScalarT pref = toComponentBase(ws[PREF]); + const ScalarT pe = toComponentBase(ws[index(E::PE)]); + const ScalarT qgen = toComponentBase(ws[index(E::QGEN)]); + const ScalarT extref = ws[index(E::QEXT)]; + const ScalarT pfaref = ws[index(E::PFAREF)]; + const ScalarT pref = toComponentBase(ws[index(E::PREF)]); const ScalarT iqcmd = toComponentBase(iqcmd_system); const ScalarT ipcmd = toComponentBase(ipcmd_system); - f[VMEAS] = -vmeas_dot + (vt - vmeas) / Trv_; - f[PMEAS] = -pmeas_dot + (pe - pmeas) / Tp_; - f[XPIQ] = -xpiq_dot + sdip * Math::antiwindup(Kqp_ * eq + xpiq, Kqi_ * eq, Vmin_, Vmax_); - f[XPIV] = -xpiv_dot + sdip * Math::antiwindup(Kvp_ * epiv + xpiv, Kvi_ * epiv, -iqmax, iqmax); - f[QV] = -qv_dot + sdip * (qref / vmeas_safe - qv) / Tiq_; - f[PORD] = -pord_dot + sdip * Math::antiwindup(pord, rpord, Pmin_, Pmax_); - f[VT] = -vt * vt + vr * vr + vi * vi; - f[VMEASSAFE] = -vmeas_safe + Math::max(vmeas, VMEAS_MINIMUM); - f[SDIP] = -sdip + Math::inside(vt, Vdip_, Vup_); - f[VERR] = -verr + Math::deadband2(Vref0_ - vmeas, dbd1_, dbd2_); - f[IQV] = -iqv + Math::clamp(kqv_ * verr, Iql1_, Iqh1_); - f[QREF] = -qref + pf_on_ * pmeas * std::tan(pfaref) + pf_off_ * qext; - f[EQ] = -eq + Math::clamp(qref, Qmin_, Qmax_) - qgen; - f[VPIQ] = -vpiq + Math::clamp(Kqp_ * eq + xpiq, Vmin_, Vmax_); - f[EPIV] = -epiv + v_on_ * vpiq + v_off_ * qref - vmeas; - f[FPORD] = -Tpord_ * fpord + pref - pord; - f[RPORD] = -rpord + Math::clamp(fpord, dPmin_, dPmax_); - f[IQCIRC] = -iqcirc * iqcirc + Imax_ * Imax_ - pq_on_ * ipcmd * ipcmd; - f[IPCIRC] = -ipcirc * ipcirc + Imax_ * Imax_ - pq_off_ * iqcmd * iqcmd; - f[IQMAX] = -iqmax + pq_off_ * Imax_ + pq_on_ * iqcirc; - f[IPMAX] = -ipmax + pq_on_ * Imax_ + pq_off_ * ipcirc; - f[IQBASE] = -iqbase + Math::clamp(Kvp_ * epiv + xpiv, -iqmax, iqmax); - f[IQRAW] = -iqraw + q_on_ * iqbase + q_off_ * qv + iqv; - f[IQCMD] = -iqcmd + Math::clamp(iqraw, -iqmax, iqmax); - f[IPCMD] = -ipcmd + Math::clamp(pord / vmeas_safe, ZERO, ipmax); + const ScalarT vmeas_safe = Math::max(vmeas, VMEAS_MINIMUM); + const ScalarT sdip = Math::inside(vt, Vdip_, Vup_); + const ScalarT verr = Math::deadband2(Vref0_ - vmeas, dbd1_, dbd2_); + const ScalarT iqv = Math::clamp(kqv_ * verr, Iql1_, Iqh1_); + // The Volt/VAr channel is a system-base reactive power unless + // direct-voltage mode selects it as a terminal-voltage reference, + // which takes no power-base conversion. + const ScalarT qref = q_ref_on_ * (pf_on_ * pmeas * std::tan(pfaref) + pf_off_ * toComponentBase(extref)); + const ScalarT eq = Math::clamp(qref, Qmin_, Qmax_) - qgen; + const ScalarT vpiq = Math::clamp(Kqp_ * eq + xpiq, Vmin_, Vmax_); + const ScalarT epiv = q_pi_on_ * vpiq + v_ref_on_ * extref - q_on_ * vmeas; + const ScalarT fpord = (pref - pord) / Tpord_; + const ScalarT rpord = Math::clamp(fpord, dPmin_, dPmax_); + const ScalarT ilcap = std::sqrt(ilmax * ilmax); + const ScalarT iqmax = pq_on_ * ilcap + pq_off_ * Imax_; + const ScalarT ipmax = pq_on_ * Imax_ + pq_off_ * ilcap; + const ScalarT iqbase = Math::clamp(Kvp_ * epiv + xpiv, -iqmax, iqmax); + const ScalarT iqraw = q_on_ * iqbase + q_off_ * qv + iqv; + + f[index(I::VMEAS)] = -vmeas_dot + (vt - vmeas) / Trv_; + f[index(I::PMEAS)] = -pmeas_dot + (pe - pmeas) / Tp_; + f[index(I::XPIQ)] = -xpiq_dot + q_pi_on_ * sdip * Math::antiwindup(Kqp_ * eq + xpiq, Kqi_ * eq, Vmin_, Vmax_); + f[index(I::XPIV)] = -xpiv_dot + q_on_ * sdip * Math::antiwindup(Kvp_ * epiv + xpiv, Kvi_ * epiv, -iqmax, iqmax); + f[index(I::QV)] = -qv_dot + q_off_ * sdip * (qref / vmeas_safe - qv) / Tiq_; + f[index(I::PORD)] = -pord_dot + sdip * Math::antiwindup(pord, rpord, Pmin_, Pmax_); + f[index(I::VT)] = -vt * vt + vr * vr + vi * vi; + f[index(I::ILMAX)] = -ilmax * ilcap + Imax_ * Imax_ - pq_on_ * ipcmd * ipcmd - pq_off_ * iqcmd * iqcmd; + f[index(I::IQCMD)] = -iqcmd + Math::clamp(iqraw, -iqmax, iqmax); + f[index(I::IPCMD)] = -ipcmd + Math::clamp(pord / vmeas_safe, ZERO, ipmax); return 0; } + // + // Private methods + // + /** - * @brief Evaluate the model residuals. + * @brief Record one failed configuration condition * - * Refreshes the bus and signal interface buffers and evaluates the - * internal residual. REECB injects no current, so there is no bus - * residual. An unattached input port falls back to the value latched by - * initialize(). + * @param[in] condition Required condition. + * @param[in] message Error message when `condition` is false. + * @param[in,out] errors Accumulated configuration-error count. + */ + template + void Reecb::checkConfiguration( + bool condition, const char* message, int& errors) + { + if (!condition) + { + Log::error() << "Reecb: " << message << '\n'; + errors += 1; + } + } + + /** + * @brief Load one real-valued parameter + * + * Real and integer serialized values are accepted. Any other stored type + * records a loading error while preserving the existing value. * - * @return Zero on success. + * @param[in] data Model parameter data. + * @param[in] parameter Parameter key to load. + * @param[in,out] target Stored parameter value. + * @param[in] name Serialized parameter name for diagnostics. */ template - int Reecb::evaluateResidual() + void Reecb::loadRealParameter( + const ModelDataT& data, + ReecbParameters parameter, + RealT& target, + const char* name) { - const auto PE = static_cast(ReecbExternalVariables::PE); - const auto QGEN = static_cast(ReecbExternalVariables::QGEN); - const auto QEXT = static_cast(ReecbExternalVariables::QEXT); - const auto PFAREF = static_cast(ReecbExternalVariables::PFAREF); - const auto PREF = static_cast(ReecbExternalVariables::PREF); - - ws_[PE] = pe_set_; - ws_[QGEN] = qgen_set_; - ws_[QEXT] = qext_set_; - ws_[PFAREF] = pfaref_set_; - ws_[PREF] = pref_set_; - std::fill(ws_indices_.begin(), ws_indices_.end(), INVALID_INDEX); + if (!data.parameters.contains(parameter)) + { + return; + } - if (signals_.template isAttached()) + const auto& value = data.parameters.at(parameter); + if (const auto* real_value = std::get_if(&value)) + { + target = *real_value; + } + else if (const auto* index_value = std::get_if(&value)) { - ws_[PE] = signals_.template readExternalVariable(); - ws_indices_[PE] = signals_.template readExternalVariableIndex(); + target = static_cast(*index_value); } - if (signals_.template isAttached()) + else + { + Log::error() << "Reecb: parameter '" << name << "' must be numeric\n"; + ++parameter_error_count_; + } + } + + /** + * @brief Load one binary selector + * + * Boolean values and integer values equal to zero or one are accepted. + * Any other value or stored type records a loading error while + * preserving the existing default. + * + * @param[in] data Model parameter data. + * @param[in] parameter Parameter key to load. + * @param[in,out] target Stored selector value. + * @param[in] name Serialized parameter name for diagnostics. + */ + template + void Reecb::loadSwitchParameter( + const ModelDataT& data, + ReecbParameters parameter, + bool& target, + const char* name) + { + if (!data.parameters.contains(parameter)) { - ws_[QGEN] = signals_.template readExternalVariable(); - ws_indices_[QGEN] = signals_.template readExternalVariableIndex(); + return; } - if (signals_.template isAttached()) + + const auto& value = data.parameters.at(parameter); + if (const auto* bool_value = std::get_if(&value)) { - ws_[QEXT] = signals_.template readExternalVariable(); - ws_indices_[QEXT] = signals_.template readExternalVariableIndex(); + target = *bool_value; } - if (signals_.template isAttached()) + else if (const auto* index_value = std::get_if(&value); + index_value && (*index_value == 0 || *index_value == 1)) { - ws_[PFAREF] = signals_.template readExternalVariable(); - ws_indices_[PFAREF] = signals_.template readExternalVariableIndex(); + target = (*index_value == 1); } - if (signals_.template isAttached()) + else { - ws_[PREF] = signals_.template readExternalVariable(); - ws_indices_[PREF] = signals_.template readExternalVariableIndex(); + Log::error() << "Reecb: parameter '" << name << "' must be bool or 0/1\n"; + ++parameter_error_count_; } + } - wb_[0] = Vr(); - wb_[1] = Vi(); + /** + * @brief Validate and floor one explicit controller lag + * + * Nonfinite and negative values record errors before replacement so + * verify() retains the evidence. A valid value below 1 ms is raised and + * reported through the return value, preserving an explicit Hessenberg + * residual and avoiding division by zero. + * + * @param[in,out] value Time constant to validate and floor. + * @param[in] name Parameter name for diagnostics. + * @return true only when a valid nonnegative value was raised. + */ + template + bool Reecb::floorTimeConstant( + RealT& value, const char* name) + { + if (!std::isfinite(value)) + { + Log::error() << "Reecb: " << name << " must be finite\n"; + ++parameter_error_count_; + value = TIME_CONSTANT_MINIMUM; + return false; + } + if (value < ZERO) + { + Log::error() << "Reecb: " << name << " must be non-negative\n"; + ++parameter_error_count_; + value = TIME_CONSTANT_MINIMUM; + return false; + } - const auto* y = y_.getData(); - const auto* yp = yp_.getData(); - auto* f = f_.getData(); + const bool raised = value < TIME_CONSTANT_MINIMUM; + value = std::max(value, TIME_CONSTANT_MINIMUM); + return raised; + } - evaluateInternalResidual(y, yp, wb_.data(), ws_.data(), f); - f_.setDataUpdated(); - return 0; + /** + * @brief Read parameters from model data + * + * Omitted parameters retain their documented defaults. Loading errors + * are counted for verify() rather than thrown. + * + * @param[in] data Parameters and monitored-variable selections. + */ + template + void Reecb::initializeParameters(const ModelDataT& data) + { + using Params = typename ModelDataT::Parameters; + + parameter_error_count_ = 0; + mva_given_ = data.parameters.contains(Params::mva); + Vref0_given_ = false; + + loadRealParameter(data, Params::mva, mva_base_, "mva"); + loadSwitchParameter(data, Params::PfFlag, PfFlag_, "PfFlag"); + loadSwitchParameter(data, Params::VFlag, VFlag_, "VFlag"); + loadSwitchParameter(data, Params::QFlag, QFlag_, "QFlag"); + loadSwitchParameter(data, Params::Pqflag, Pqflag_, "Pqflag"); + loadRealParameter(data, Params::Trv, Trv_, "Trv"); + loadRealParameter(data, Params::Tp, Tp_, "Tp"); + if (data.parameters.contains(Params::Vref0)) + { + loadRealParameter(data, Params::Vref0, Vref0_, "Vref0"); + Vref0_given_ = true; + } + loadRealParameter(data, Params::Vdip, Vdip_, "Vdip"); + loadRealParameter(data, Params::Vup, Vup_, "Vup"); + loadRealParameter(data, Params::dbd1, dbd1_, "dbd1"); + loadRealParameter(data, Params::dbd2, dbd2_, "dbd2"); + loadRealParameter(data, Params::kqv, kqv_, "kqv"); + loadRealParameter(data, Params::Iql1, Iql1_, "Iql1"); + loadRealParameter(data, Params::Iqh1, Iqh1_, "Iqh1"); + loadRealParameter(data, Params::Qmax, Qmax_, "Qmax"); + loadRealParameter(data, Params::Qmin, Qmin_, "Qmin"); + loadRealParameter(data, Params::Kqp, Kqp_, "Kqp"); + loadRealParameter(data, Params::Kqi, Kqi_, "Kqi"); + loadRealParameter(data, Params::Vmax, Vmax_, "Vmax"); + loadRealParameter(data, Params::Vmin, Vmin_, "Vmin"); + loadRealParameter(data, Params::Kvp, Kvp_, "Kvp"); + loadRealParameter(data, Params::Kvi, Kvi_, "Kvi"); + loadRealParameter(data, Params::Tiq, Tiq_, "Tiq"); + loadRealParameter(data, Params::Tpord, Tpord_, "Tpord"); + loadRealParameter(data, Params::dPmax, dPmax_, "dPmax"); + loadRealParameter(data, Params::dPmin, dPmin_, "dPmin"); + loadRealParameter(data, Params::Pmax, Pmax_, "Pmax"); + loadRealParameter(data, Params::Pmin, Pmin_, "Pmin"); + loadRealParameter(data, Params::Imax, Imax_, "Imax"); + + setDerivedParameters(); + } + + /** + * @brief Bind monitor selections to REECB internal states + */ + template + void Reecb::initializeMonitor() + { + using I = ReecbInternalVariables; + using Variable = typename ModelDataT::MonitorableVariables; + + monitor_->set(Variable::iqcmd, [this] + { return y_.getData()[index(I::IQCMD)]; }); + monitor_->set(Variable::ipcmd, [this] + { return y_.getData()[index(I::IPCMD)]; }); + monitor_->set(Variable::vmeas, [this] + { return y_.getData()[index(I::VMEAS)]; }); + monitor_->set(Variable::pmeas, [this] + { return y_.getData()[index(I::PMEAS)]; }); + } + + /** + * @brief Resolve parameter-derived constants and selector masks + * + * Raises explicit controller lags in place, converts any supplied component + * rating, and resolves selector masks. Invalid lag inputs are + * recorded before replacement so verify() retains each error. + */ + template + void Reecb::setDerivedParameters() + { + bool floor_warning = false; + + floor_warning |= floorTimeConstant(Trv_, "Trv"); + floor_warning |= floorTimeConstant(Tp_, "Tp"); + floor_warning |= floorTimeConstant(Tiq_, "Tiq"); + floor_warning |= floorTimeConstant(Tpord_, "Tpord"); + + if (floor_warning) + { + Log::warning() << "Reecb: any of Trv, Tp, Tiq, or Tpord below " + << TIME_CONSTANT_MINIMUM + << " s is raised to that floor to keep the controller lags well posed\n"; + } + + va_component_base_ = mva_base_ * static_cast(1.0e6); + + pf_on_ = ZERO; + if (PfFlag_) + { + pf_on_ = ONE; + } + pf_off_ = ONE - pf_on_; + + q_on_ = ZERO; + if (QFlag_) + { + q_on_ = ONE; + } + q_off_ = ONE - q_on_; + + q_pi_on_ = ZERO; + if (QFlag_ && VFlag_) + { + q_pi_on_ = ONE; + } + + v_ref_on_ = ZERO; + if (QFlag_ && !VFlag_) + { + v_ref_on_ = ONE; + } + q_ref_on_ = ONE - v_ref_on_; + + pq_on_ = ZERO; + if (Pqflag_) + { + pq_on_ = ONE; + } + pq_off_ = ONE - pq_on_; + } + + /** + * @brief Evaluate log(1 - exp(-x)) accurately for positive x + * + * The small-x hyperbolic form avoids cancellation; the large-x form uses + * log1p. The algebraically equivalent branches agree in value and first + * derivative at x = log(2). + * + * @param[in] x Strictly positive argument. + * @return Numerically stable value of log(1 - exp(-x)). + * @pre `x > 0`. + * @warning This function contains conditional branching and as such can + * be used in initialization methods but not in residual evaluation. + */ + template + typename Reecb::RealT + Reecb::logOneMinusExp(RealT x) const + { + static constexpr RealT log_two = std::numbers::ln2_v; + + if (x < log_two) + { + return log_two - HALF * x + std::log(std::sinh(HALF * x)); + } + return std::log1p(-std::exp(-x)); + } + + /** + * @brief Recover the input that produces an interior smooth-clamp output + * + * @param[in] output Requested output strictly between the limits. + * @param[in] lower Lower smooth-clamp limit. + * @param[in] upper Upper smooth-clamp limit. + * @return Exact inverse of `Math::clamp` to floating-point roundoff. + * @pre `lower < output < upper`. + * @warning This function contains conditional branching and as such can + * be used in initialization methods but not in residual evaluation. + */ + template + typename Reecb::RealT + Reecb::unclamp(RealT output, RealT lower, RealT upper) const + { + const RealT a = Math::MU * (output - lower); + const RealT b = Math::MU * (upper - output); + return lower + (a + logOneMinusExp(a) - logOneMinusExp(b)) / Math::MU; + } + + /** + * @brief Resolve the REECB component power base + * + * @return The supplied component base, or the system base when `mva` is omitted. + */ + template + typename Reecb::RealT + Reecb::componentPowerBase() const + { + if (mva_given_) + { + return va_component_base_; + } + return va_system_base_; + } + + /** + * @brief Convert a system-base power or current to REECB component base + * + * @param[in] value Quantity on the system base. + * @return The same quantity on the REECB component base. + */ + template + template + __attribute__((always_inline)) inline ValueT + Reecb::toComponentBase(ValueT value) const + { + return value * (va_system_base_ / componentPowerBase()); + } + + /** + * @brief Convert a component-base power or current to system base + * + * @param[in] value Quantity on the REECB component base. + * @return The same quantity on the system base. + */ + template + template + ValueT Reecb::toSystemBase(ValueT value) const + { + return value * (componentPowerBase() / va_system_base_); + } + + /** + * @brief Access the terminal-bus real voltage component + * + * @return Mutable reference to the bus real voltage state. + */ + template + scalar_type& Reecb::Vr() + { + return bus_->Vr(); + } + + /** + * @brief Access the terminal-bus imaginary voltage component + * + * @return Mutable reference to the bus imaginary voltage state. + */ + template + scalar_type& Reecb::Vi() + { + return bus_->Vi(); } } // namespace Converter } // namespace PhasorDynamics diff --git a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp index c44453339..c60326b1a 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp @@ -294,22 +294,17 @@ namespace GridKit addComponent(gen); } - // Add REECB electrical controllers - // - // Added after the machines and converters that drive them: a source - // publishes its resolved current commands into the assigned iqcmd/ipcmd - // nodes during its own initialize(), and REECB reads those seeds when it - // initializes. Components initialize in insertion order, so REECB must - // come after anything that seeds it. + // Add REECB after its current-command and feedback producers because + // components initialize in insertion order. for (const auto& reecbdata : data.reecb) { - IdxT bus_index = 0; + BusT* bus = nullptr; if (reecbdata.buses.contains(ReecbBuses::bus)) { - bus_index = reecbdata.buses.at(ReecbBuses::bus); + bus = getBus(reecbdata.buses.at(ReecbBuses::bus)); } - auto* reecb = new Reecb(getBus(bus_index), reecbdata); + auto* reecb = new Reecb(bus, reecbdata); if (reecbdata.signal_inputs.contains(ReecbSignalInputs::pe)) { diff --git a/tests/IntegrationTests/PhasorDynamics/CMakeLists.txt b/tests/IntegrationTests/PhasorDynamics/CMakeLists.txt index 34b7b5053..533f82b59 100644 --- a/tests/IntegrationTests/PhasorDynamics/CMakeLists.txt +++ b/tests/IntegrationTests/PhasorDynamics/CMakeLists.txt @@ -9,3 +9,12 @@ add_test( WORKING_DIRECTORY ${CMAKE_BINARY_DIR}/examples/PhasorDynamics/Tiny) install(TARGETS test_pd_integration) + +add_executable(test_phasor_reecb_integration runReecbIntegrationTests.cpp) +target_link_libraries( + test_phasor_reecb_integration + PRIVATE GridKit::phasor_dynamics_systemmodel GridKit::solvers_dyn GridKit::testing) + +add_test(NAME PhasorDynamicsReecbIntegrationTest COMMAND test_phasor_reecb_integration) + +install(TARGETS test_phasor_reecb_integration) diff --git a/tests/IntegrationTests/PhasorDynamics/ReecbIntegrationTests.hpp b/tests/IntegrationTests/PhasorDynamics/ReecbIntegrationTests.hpp new file mode 100644 index 000000000..a44129f01 --- /dev/null +++ b/tests/IntegrationTests/PhasorDynamics/ReecbIntegrationTests.hpp @@ -0,0 +1,461 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace GridKit +{ + namespace Testing + { + /// Verify REECB signal attachment to REGCA, the coupled steady state, and + /// time-domain recovery of the closed loop. + template + class ReecbIntegrationTests + { + public: + using RealT = real_type; + using IdxT = index_type; + + /// Full command and branch-power feedback loop between REECB and REGCA. + TestOutcome regca() + { + return checkConnection(makeClosedLoopCase(), true, __func__); + } + + /// Command-only wiring; REECB reconstructs feedback from its commands. + TestOutcome regcaReconstructedFeedback() + { + return checkConnection(makeCommandOnlyCase(), false, __func__); + } + + /// Perturbation probes confirm response direction, rate, gating, and + /// priority coupling across the closed REGCA/REECB loop. + TestOutcome regcaLoopResponse() + { + using I = PhasorDynamics::Converter::ReecbInternalVariables; + using R = PhasorDynamics::Converter::RegcaInternalVariables; + + TestStatus success = true; + SystemT system(makeClosedLoopCase()); + + success *= system.allocate() == 0; + success *= system.initialize() == 0; + + auto* converter = dynamic_cast(system.getComponent(kConverterComponentId)); + auto* controller = dynamic_cast(system.getComponent(kControllerComponentId)); + + if (converter == nullptr || controller == nullptr) + { + success = false; + return success.report(__func__); + } + + const auto reecb = [&](I variable) + { return controller->getVariableIndex(static_cast(variable)); }; + const auto regca = [&](R variable) + { return converter->getVariableIndex(static_cast(variable)); }; + + const RealT d = kProbeDelta; + + success *= runProbes( + system, + { + {"converter reactive current chases the command", {reecb(I::IQCMD), d}, {}, regca(R::IQ), d / kTg}, + {"converter active current chases the command", {reecb(I::IPCMD), d}, {}, regca(R::IP), d / kTg}, + {"power measurement tracks the branch feedback", {regca(R::PBR), d}, {}, reecb(I::PMEAS), d / kTp}, + {"voltage measurement tracks the terminal magnitude", {reecb(I::VT), d}, {}, reecb(I::VMEAS), d / kTrv}, + {"controller restores its published reactive command", {reecb(I::IQCMD), d}, {}, reecb(I::IQCMD), -d}, + {"reactive command consumes low-priority headroom", {reecb(I::IQCMD), d}, {}, reecb(I::ILMAX), -(TWO * kInitialReactivePower + d) * d}, + {"active command follows the power order", {reecb(I::PORD), d}, {}, reecb(I::IPCMD), d}, + {"volt-var loop integrates a voltage rise downward", {reecb(I::VMEAS), d}, {}, reecb(I::XPIV), -kKvi * d}, + {"voltage dip freezes the volt-var integrator", {reecb(I::VT), -kDipDepth}, {reecb(I::VMEAS), d}, reecb(I::XPIV), ZERO}, + }); + + // The probes restore every state they touch: the system must still be + // at the initialized equilibrium. + success *= system.evaluateResidual() == 0; + success *= allNearZero(system.getResidual()); + + return success.report(__func__); + } + + /// Displaced measurement and current lag states integrate back to the + /// initialized equilibrium, closing the REGCA/REECB loop in time domain. + TestOutcome regcaLoopRecovery() + { + using I = PhasorDynamics::Converter::ReecbInternalVariables; + using R = PhasorDynamics::Converter::RegcaInternalVariables; + + TestStatus success = true; + SystemT system(makeClosedLoopCase()); + + success *= system.allocate() == 0; + success *= system.initialize() == 0; + + auto* converter = dynamic_cast(system.getComponent(kConverterComponentId)); + auto* controller = dynamic_cast(system.getComponent(kControllerComponentId)); + + if (converter == nullptr || controller == nullptr) + { + success = false; + return success.report(__func__); + } + + const auto* equilibrium_values = system.y().getData(); + const std::vector equilibrium( + equilibrium_values, + equilibrium_values + static_cast(system.y().getSize())); + + // The displacements stay strictly inside every limiter, deadband, and + // voltage band, so the return path is a smooth interior trajectory. + auto* y = system.y().getData(); + y[controller->getVariableIndex(static_cast(I::VMEAS))] += kRecoveryDelta; + y[controller->getVariableIndex(static_cast(I::PMEAS))] -= kRecoveryDelta; + y[converter->getVariableIndex(static_cast(R::IQ))] += kRecoveryDelta; + y[converter->getVariableIndex(static_cast(R::IP))] -= kRecoveryDelta; + system.y().setDataUpdated(); + + AnalysisManager::Sundials::Ida ida(&system); + success *= ida.configureSimulation() == 0; + success *= ida.initializeSimulation(ZERO) == 0; + // The step callback keeps the model state current so the final point + // can be compared against the stored equilibrium. + success *= ida.runSimulation(kRecoveryHorizon, kRecoveryMonitorStep, [](RealT) {}) == 0; + + const auto* final_values = system.y().getData(); + for (size_t entry = 0; entry < equilibrium.size(); ++entry) + { + const RealT deviation = std::abs(static_cast(final_values[entry]) - equilibrium[entry]); + if (deviation > kRecoveryTolerance) + { + std::cout << "State " << entry << " remains " << deviation + << " from its equilibrium after recovery\n"; + success = false; + } + } + + return success.report(__func__); + } + + private: + using SystemDataT = PhasorDynamics::SystemModelData; + using SystemT = PhasorDynamics::SystemModel; + using RegcaT = PhasorDynamics::Converter::Regca; + using ReecbT = PhasorDynamics::Converter::Reecb; + + static constexpr IdxT kBusId = static_cast(23); + static constexpr IdxT kIpcmdSignalId = static_cast(201); + static constexpr IdxT kIqcmdSignalId = static_cast(202); + static constexpr IdxT kPbranchSignalId = static_cast(203); + static constexpr IdxT kQbranchSignalId = static_cast(204); + static constexpr IdxT kConverterComponentId = static_cast(0); + static constexpr IdxT kControllerComponentId = static_cast(1); + + static constexpr RealT kSystemBaseVa = static_cast(100.0e6); + static constexpr RealT kConverterBaseMva = static_cast(100.0); + static constexpr RealT kInitialActivePower = static_cast(0.4); + static constexpr RealT kInitialReactivePower = static_cast(0.05); + + // Case parameters shared with the probe-response expectations. + static constexpr RealT kTg = static_cast(0.02); + static constexpr RealT kTp = static_cast(0.02); + static constexpr RealT kTrv = static_cast(0.02); + static constexpr RealT kKvi = static_cast(5.0); + + // Probes stay clear of every smoothing transition, so responses are + // exact; the dip lands the terminal voltage well below Vdip. + static constexpr RealT kProbeDelta = static_cast(1.0e-3); + static constexpr RealT kDipDepth = static_cast(0.5); + + // The slowest closed-loop mode of the case pairs the reactive and + // voltage integrators with a time constant near three seconds; the + // horizon leaves the displaced trajectory well inside the tolerance. + static constexpr RealT kRecoveryDelta = static_cast(2.0e-3); + static constexpr RealT kRecoveryHorizon = static_cast(25.0); + static constexpr RealT kRecoveryMonitorStep = static_cast(1.0 / 60.0); + static constexpr RealT kRecoveryTolerance = static_cast(1.0e-6); + + static constexpr RealT kTol = + static_cast(100.0) * std::numeric_limits::epsilon(); + + TestOutcome checkConnection(const SystemDataT& data, + bool feedback_attached, + const char* test_name) + { + using PhasorDynamics::Converter::ReecbExternalVariables; + using PhasorDynamics::Converter::ReecbInternalVariables; + using PhasorDynamics::Converter::RegcaExternalVariables; + using PhasorDynamics::Converter::RegcaInternalVariables; + + TestStatus success = true; + SystemT system(data); + + success *= system.allocate() == 0; + + auto* converter = dynamic_cast(system.getComponent(kConverterComponentId)); + auto* controller = dynamic_cast(system.getComponent(kControllerComponentId)); + auto* ipcmd = system.getSignal(kIpcmdSignalId); + auto* iqcmd = system.getSignal(kIqcmdSignalId); + auto* pbranch = feedback_attached ? system.getSignal(kPbranchSignalId) : nullptr; + auto* qbranch = feedback_attached ? system.getSignal(kQbranchSignalId) : nullptr; + + if (converter == nullptr || controller == nullptr || ipcmd == nullptr || iqcmd == nullptr + || (feedback_attached && (pbranch == nullptr || qbranch == nullptr))) + { + success = false; + return success.report(test_name); + } + + bool signals_linked = ipcmd->linked() && iqcmd->linked(); + if (feedback_attached) + { + signals_linked = signals_linked && pbranch->linked() && qbranch->linked(); + } + success *= signals_linked; + if (!signals_linked) + { + return success.report(test_name); + } + + auto& converter_signals = converter->getSignals(); + auto& controller_signals = controller->getSignals(); + + bool ports_connected = + controller_signals.template isAssigned() + && controller_signals.template isAssigned() + && converter_signals.template isAttached() + && converter_signals.template isAttached(); + if (feedback_attached) + { + ports_connected = ports_connected + && converter_signals.template isAssigned() + && converter_signals.template isAssigned() + && controller_signals.template isAttached() + && controller_signals.template isAttached(); + } + else + { + ports_connected = ports_connected + && !controller_signals.template isAttached() + && !controller_signals.template isAttached(); + } + success *= ports_connected; + if (!ports_connected) + { + return success.report(test_name); + } + + // Optional references stay unattached and latch their initialized setpoints. + success *= !controller_signals.template isAttached(); + success *= !controller_signals.template isAttached(); + success *= !controller_signals.template isAttached(); + + // Each linked signal is one shared global unknown: the node, the + // publishing state, and the subscribing port agree on its index. + const IdxT ipcmd_index = ipcmd->getVariableIndex(); + const IdxT iqcmd_index = iqcmd->getVariableIndex(); + + success *= ipcmd_index != iqcmd_index; + success *= ipcmd_index + == controller->getVariableIndex( + static_cast(ReecbInternalVariables::IPCMD)); + success *= ipcmd_index + == converter_signals.template readExternalVariableIndex< + RegcaExternalVariables::IPCMD>(); + success *= iqcmd_index + == controller->getVariableIndex( + static_cast(ReecbInternalVariables::IQCMD)); + success *= iqcmd_index + == converter_signals.template readExternalVariableIndex< + RegcaExternalVariables::IQCMD>(); + + if (feedback_attached) + { + const IdxT pbranch_index = pbranch->getVariableIndex(); + const IdxT qbranch_index = qbranch->getVariableIndex(); + + success *= pbranch_index != qbranch_index; + success *= pbranch_index != ipcmd_index; + success *= pbranch_index != iqcmd_index; + success *= qbranch_index != ipcmd_index; + success *= qbranch_index != iqcmd_index; + success *= pbranch_index + == converter->getVariableIndex( + static_cast(RegcaInternalVariables::PBR)); + success *= pbranch_index + == controller_signals.template readExternalVariableIndex< + ReecbExternalVariables::PE>(); + success *= qbranch_index + == converter->getVariableIndex( + static_cast(RegcaInternalVariables::QBR)); + success *= qbranch_index + == controller_signals.template readExternalVariableIndex< + ReecbExternalVariables::QGEN>(); + } + + // The published REGCA operating point initializes the coupled pair to + // an exact steady state. + success *= system.initialize() == 0; + success *= system.evaluateResidual() == 0; + success *= allNearZero(system.yp()); + success *= allNearZero(system.getResidual()); + + return success.report(test_name); + } + + /// One state write applied before a probe evaluation. + struct Write + { + IdxT state{INVALID_INDEX}; + RealT delta{}; + }; + + /// One perturbation and the exact residual response it must produce. + struct Probe + { + const char* label; + Write first; + Write second{}; + IdxT row{}; + RealT expected{}; + }; + + /// Apply each probe to the initialized system, check the responding + /// residual row against its exact expectation, and restore the state. + TestStatus runProbes(SystemT& system, std::initializer_list probes) + { + TestStatus success = true; + auto* y = system.y().getData(); + + for (const auto& probe : probes) + { + const RealT first_base = static_cast(y[probe.first.state]); + RealT second_base = ZERO; + + y[probe.first.state] = first_base + probe.first.delta; + if (probe.second.state != INVALID_INDEX) + { + second_base = static_cast(y[probe.second.state]); + y[probe.second.state] = second_base + probe.second.delta; + } + system.y().setDataUpdated(); + success *= system.evaluateResidual() == 0; + + const RealT response = static_cast(system.getResidual().getData()[probe.row]); + if (!isEqual(response, probe.expected, kTol)) + { + std::cout << "Probe '" << probe.label << "' expected " << probe.expected + << " but produced " << response << '\n'; + success = false; + } + + y[probe.first.state] = first_base; + if (probe.second.state != INVALID_INDEX) + { + y[probe.second.state] = second_base; + } + } + system.y().setDataUpdated(); + + return success; + } + + static SystemDataT makeCommandOnlyCase() + { + using namespace PhasorDynamics; + using namespace PhasorDynamics::Converter; + + SystemDataT data; + data.va_base = kSystemBaseVa; + + auto& bus = data.bus.emplace_back(); + bus.bus_id = kBusId; + bus.bus_type = BusData::BusType::SLACK; + bus.Vr0 = ONE; + bus.Vi0 = ZERO; + + data.signal = {{"Active Current Command", kIpcmdSignalId}, + {"Reactive Current Command", kIqcmdSignalId}}; + + auto& converter = data.regca.emplace_back(); + converter.buses[RegcaBuses::bus] = kBusId; + converter.signal_inputs[RegcaSignalInputs::ipcmd] = kIpcmdSignalId; + converter.signal_inputs[RegcaSignalInputs::iqcmd] = kIqcmdSignalId; + converter.parameters[RegcaParameters::p0] = kInitialActivePower; + converter.parameters[RegcaParameters::q0] = kInitialReactivePower; + converter.parameters[RegcaParameters::mva] = kConverterBaseMva; + converter.parameters[RegcaParameters::Tg] = kTg; + converter.parameters[RegcaParameters::TM] = static_cast(0.02); + converter.parameters[RegcaParameters::Rqmax] = static_cast(999.0); + converter.parameters[RegcaParameters::Rqmin] = static_cast(-999.0); + converter.parameters[RegcaParameters::Rpmax] = static_cast(999.0); + converter.parameters[RegcaParameters::sL] = true; + converter.parameters[RegcaParameters::IL1] = static_cast(1.1); + converter.parameters[RegcaParameters::VL0] = static_cast(0.4); + converter.parameters[RegcaParameters::VL1] = static_cast(0.9); + converter.parameters[RegcaParameters::VA0] = static_cast(0.4); + converter.parameters[RegcaParameters::VA1] = static_cast(0.9); + converter.parameters[RegcaParameters::Vhvmax] = static_cast(1.2); + + auto& controller = data.reecb.emplace_back(); + controller.buses[ReecbBuses::bus] = kBusId; + controller.signal_outputs[ReecbSignalOutputs::ipcmd] = kIpcmdSignalId; + controller.signal_outputs[ReecbSignalOutputs::iqcmd] = kIqcmdSignalId; + controller.parameters[ReecbParameters::mva] = kConverterBaseMva; + controller.parameters[ReecbParameters::Trv] = kTrv; + controller.parameters[ReecbParameters::Tp] = kTp; + controller.parameters[ReecbParameters::Kvi] = kKvi; + controller.parameters[ReecbParameters::QFlag] = true; + controller.parameters[ReecbParameters::VFlag] = true; + + return data; + } + + static SystemDataT makeClosedLoopCase() + { + using namespace PhasorDynamics; + using namespace PhasorDynamics::Converter; + + auto data = makeCommandOnlyCase(); + + data.signal.push_back({"Branch Active Power", kPbranchSignalId}); + data.signal.push_back({"Branch Reactive Power", kQbranchSignalId}); + + auto& converter = data.regca.front(); + converter.signal_outputs[RegcaSignalOutputs::pbranch] = kPbranchSignalId; + converter.signal_outputs[RegcaSignalOutputs::qbranch] = kQbranchSignalId; + + auto& controller = data.reecb.front(); + controller.signal_inputs[ReecbSignalInputs::pe] = kPbranchSignalId; + controller.signal_inputs[ReecbSignalInputs::qgen] = kQbranchSignalId; + + return data; + } + + template + static bool allNearZero(const VectorT& vector) + { + const auto* values = vector.getData(); + for (IdxT entry = 0; entry < vector.getSize(); ++entry) + { + if (!isEqual(values[entry], ZERO, kTol)) + { + return false; + } + } + return true; + } + }; + } // namespace Testing +} // namespace GridKit diff --git a/tests/IntegrationTests/PhasorDynamics/runReecbIntegrationTests.cpp b/tests/IntegrationTests/PhasorDynamics/runReecbIntegrationTests.cpp new file mode 100644 index 000000000..47e0f34a5 --- /dev/null +++ b/tests/IntegrationTests/PhasorDynamics/runReecbIntegrationTests.cpp @@ -0,0 +1,16 @@ +#include + +#include "ReecbIntegrationTests.hpp" + +int main() +{ + GridKit::Testing::TestingResults result; + GridKit::Testing::ReecbIntegrationTests test; + + result += test.regca(); + result += test.regcaReconstructedFeedback(); + result += test.regcaLoopResponse(); + result += test.regcaLoopRecovery(); + + return result.summary(); +} diff --git a/tests/UnitTests/Math/SmoothnessIndicatorTests.hpp b/tests/UnitTests/Math/SmoothnessIndicatorTests.hpp index 70f1b8371..77cb80085 100644 --- a/tests/UnitTests/Math/SmoothnessIndicatorTests.hpp +++ b/tests/UnitTests/Math/SmoothnessIndicatorTests.hpp @@ -390,6 +390,26 @@ namespace GridKit } } + const RealT real_lower{-0.05}; + const Variable mixed_upper{0.05, 4}; + const auto mixed_gate = Math::indicator(state, rate, real_lower, mixed_upper); + static_assert(std::is_same::type, + Variable>::value, + "A real lower bound and dynamic upper bound should retain the scalar type."); + success *= mixed_gate.getDependencies().contains(4); + + const Variable equal_lower{0.0, 5}; + const Variable equal_upper{0.0, 6}; + const auto equal_gate = Math::indicator(state, rate, equal_lower, equal_upper); + const auto equal_limited = + Math::antiwindup(state, rate, equal_lower, equal_upper); + success *= within(equal_gate.getValue(), 0.75, kRoundoffTolerance); + success *= within(equal_limited.getValue(), 0.75 * rate.getValue(), kRoundoffTolerance); + success *= equal_gate.getDependencies().contains(5); + success *= equal_gate.getDependencies().contains(6); + success *= equal_limited.getDependencies().contains(5); + success *= equal_limited.getDependencies().contains(6); + return success.report(__func__); } }; diff --git a/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp b/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp index d08dd56b9..6c8e39f09 100644 --- a/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp @@ -2,12 +2,17 @@ #include #include +#include #include #include +#include #include +#include +#include #include #include +#include #include #include #include @@ -36,124 +41,137 @@ namespace GridKit ConverterReecbTests() = default; ~ConverterReecbTests() = default; - // Fixed answer keys should agree to ordinary double-precision roundoff. - static constexpr RealT kBehaviorTol = 1.0e-12; + // Covers accumulated roundoff from smooth inverses, base round trips, and AD evaluation. + static constexpr RealT kTol = static_cast(100) * std::numeric_limits::epsilon(); - // REECB initialization solves smooth-limiter inputs near their asymptotes. - // The resulting steady residuals are O(1e-10). - static constexpr RealT kSteadyStateTol = 1.0e-9; - - // Enzyme and dependency tracking traverse the same smooth expressions - // differently; their double-precision derivatives agree to O(1e-10). - static constexpr RealT kJacobianTol = 1.0e-9; + // A smooth clamp or deadband evaluated exactly at a limit sits log(2)/mu + // inside it; boundary expectations below are stated with this offset. + static RealT clampEdgeOffset() + { + return std::log(static_cast(2)) / Math::MU; + } - /// Construction and every verify() error class, including parameter - /// types, parameter relationships, bus ownership, and signal linkage. + /// Construction, row layout, differential tags, parameters, buses, and signal-link validation use direct contract checks. TestOutcome validation() { TestStatus success = true; + noteExpectedLogs("Testing invalid REECB configurations. Logged errors and time-constant warnings are expected."); - PhasorDynamics::Bus bus(1.0, 0.0); - + PhasorDynamics::Bus bus(1.0, 0.0); PhasorDynamics::Converter::Reecb empty(&bus); - success *= (empty.size() == static_cast(index(Vars::MAXIMUM))); + success *= (empty.size() == static_cast(10)); success *= (empty.getMonitor() == nullptr); + const std::array row_order{{ + Vars::VMEAS, + Vars::PMEAS, + Vars::XPIQ, + Vars::XPIV, + Vars::QV, + Vars::PORD, + Vars::VT, + Vars::ILMAX, + Vars::IQCMD, + Vars::IPCMD, + }}; + for (size_t row = 0; row < row_order.size(); ++row) + { + success *= (index(row_order[row]) == row); + } + PhasorDynamics::Converter::Reecb configured(&bus, makeData()); - success *= (configured.size() == static_cast(index(Vars::MAXIMUM))); + configured.setSystemBase(60.0, 100.0e6); + success *= (configured.size() == static_cast(Vars::MAXIMUM)); success *= (configured.getMonitor() != nullptr); success *= (configured.verify() == 0); + success *= (configured.initialize() != 0); + success *= (configured.allocate() == 0); + success *= (configured.tagDifferentiable() == 0); - noteExpectedLogs("Testing REECB defaults and invalid configurations. " - "Logged errors and time-constant warnings are expected."); - - auto minimal_data = makeMinimalData(); - minimal_data.parameters[Params::mva] = 100.0; - PhasorDynamics::Converter::Reecb minimal(&bus, minimal_data); - success *= (minimal.verify() == 0); - success *= defaultsMatchDocumentedValues(); - - success *= (empty.verify() > 0); + for (size_t row = 0; row < index(Vars::MAXIMUM); ++row) + { + const bool expected = row <= index(Vars::PORD); + if (configured.tag()[row] != expected) + { + std::cout << "REECB differential tag " << row << " mismatch\n"; + success = false; + } + } - PhasorDynamics::Converter::Reecb missing_mva(&bus, makeMinimalData()); - success *= (missing_mva.verify() > 0); + const RealT nan = std::numeric_limits::quiet_NaN(); + for (const Params parameter : {Params::mva, Params::Trv, Params::Tp, Params::Vref0, Params::Vdip, Params::Vup, Params::dbd1, Params::dbd2, Params::kqv, Params::Iql1, Params::Iqh1, Params::Qmax, Params::Qmin, Params::Kqp, Params::Kqi, Params::Vmax, Params::Vmin, Params::Kvp, Params::Kvi, Params::Tiq, Params::Tpord, Params::dPmax, Params::dPmin, Params::Pmax, Params::Pmin, Params::Imax}) + { + success *= invalidParameterCase(bus, parameter, nan); + } success *= invalidParameterCase(bus, Params::mva, 0.0); success *= invalidParameterCase(bus, Params::Trv, -0.1); - success *= invalidParameterCase(bus, Params::Tp, -0.1); - success *= invalidParameterCase(bus, Params::Tiq, -0.1); - success *= invalidParameterCase(bus, Params::Tpord, -0.1); success *= invalidParameterCase(bus, Params::Vdip, 1.2); success *= invalidParameterCase(bus, Params::dbd1, 0.1); - success *= invalidParameterCase(bus, Params::dbd2, -0.1); success *= invalidParameterCase(bus, Params::Iql1, 2.0); success *= invalidParameterCase(bus, Params::Qmin, 2.0); success *= invalidParameterCase(bus, Params::Vmin, 2.0); success *= invalidParameterCase(bus, Params::dPmin, 0.0); success *= invalidParameterCase(bus, Params::dPmax, 0.0); success *= invalidParameterCase(bus, Params::Pmin, 2.0); - success *= invalidParameterCase(bus, Params::Imax, -0.1); + success *= invalidParameterCase(bus, Params::Imax, 0.0); + success *= invalidParameterCase(bus, Params::Trv, std::numeric_limits::infinity()); + success *= invalidParameterCase(bus, Params::Imax, -std::numeric_limits::infinity()); + success *= invalidParameterCase(bus, Params::mva, true); + // Selectors accept only bool and integer 0/1 encodings. for (const Params flag : {Params::PfFlag, Params::VFlag, Params::QFlag, Params::Pqflag}) { - auto bad_integer = makeData(); - bad_integer.parameters[flag] = static_cast(2); - PhasorDynamics::Converter::Reecb bad_integer_model(&bus, bad_integer); - success *= (bad_integer_model.verify() > 0); - - // Real-valued 0/1 is intentionally rejected: switches are JSON - // booleans or integer 0/1, matching REGCA's parameter contract. - auto bad_real = makeData(); - bad_real.parameters[flag] = static_cast(1.0); - PhasorDynamics::Converter::Reecb bad_real_model(&bus, bad_real); - success *= (bad_real_model.verify() > 0); + success *= !invalidParameterCase(bus, flag, static_cast(0)); + success *= !invalidParameterCase(bus, flag, static_cast(1)); + success *= invalidParameterCase(bus, flag, static_cast(0.0)); + success *= invalidParameterCase(bus, flag, static_cast(1.0)); + success *= invalidParameterCase(bus, flag, static_cast(2)); + success *= invalidParameterCase(bus, flag, static_cast(0.5)); } - auto integer_switches = makeData(); - integer_switches.parameters[Params::PfFlag] = static_cast(0); - integer_switches.parameters[Params::VFlag] = static_cast(1); - integer_switches.parameters[Params::QFlag] = static_cast(0); - integer_switches.parameters[Params::Pqflag] = static_cast(1); - PhasorDynamics::Converter::Reecb integer_switch_model( - &bus, - integer_switches); - success *= (integer_switch_model.verify() == 0); - - auto bad_numeric_type = makeData(); - bad_numeric_type.parameters[Params::mva] = true; - PhasorDynamics::Converter::Reecb bad_numeric_model( - &bus, - bad_numeric_type); - success *= (bad_numeric_model.verify() > 0); - PhasorDynamics::Converter::Reecb busless(nullptr, makeData()); + busless.setSystemBase(60.0, 100.0e6); success *= (busless.verify() > 0); - success *= unlinkedSignalRejected(bus); success *= unlinkedSignalRejected(bus); success *= unlinkedSignalRejected(bus); success *= unlinkedSignalRejected(bus); success *= unlinkedSignalRejected(bus); - // All four zero time constants use the documented numerical floor and - // still admit a consistent steady-state initialization. auto zero_time = makeData(); zero_time.parameters[Params::Trv] = 0.0; zero_time.parameters[Params::Tp] = 0.0; zero_time.parameters[Params::Tiq] = 0.0; zero_time.parameters[Params::Tpord] = 0.0; - - Fixture fixture(zero_time); - success *= fixture.initialize(0.2, 0.6); - success *= (fixture.evaluate() == 0); - success *= allResidualsZero(fixture.reecb); + Fixture floored(zero_time); + success *= floored.initialize(0.0, 0.2); + success *= (floored.evaluate() == 0); + success *= allResidualsZero(floored.reecb); + + auto* floored_y = floored.reecb.y().getData(); + floored_y[index(Vars::VMEAS)] = 0.999; + floored_y[index(Vars::PMEAS)] = 0.199; + floored_y[index(Vars::QV)] = 0.001; + floored_y[index(Vars::PORD)] = 0.1995; + floored.reecb.y().setDataUpdated(); + success *= (floored.evaluate() == 0); + success *= scalarMatches(floored.reecb.getResidual().getData()[index(Vars::VMEAS)], 1.0, "Trv 1 ms floor"); + success *= scalarMatches(floored.reecb.getResidual().getData()[index(Vars::PMEAS)], 1.0, "Tp 1 ms floor"); + success *= scalarMatches(floored.reecb.getResidual().getData()[index(Vars::QV)], -1.0, "Tiq 1 ms floor"); + success *= scalarMatches(floored.reecb.getResidual().getData()[index(Vars::PORD)], 0.5, "Tpord 1 ms floor"); + + Data default_data; + Fixture defaulted(default_data); + success *= defaulted.initialize(0.1, 0.2); + success *= (defaulted.evaluate() == 0); + success *= allResidualsZero(defaulted.reecb); + success *= scalarMatches(defaulted.reecb.y().getData()[index(Vars::ILMAX)], std::sqrt(1.68), "omitted parameter defaults"); return success.report(__func__); } - /// A nonidentity power-base initialization with every port attached. - /// Assigned command nodes are seeded after allocate() and must remain - /// unchanged while REECB initializes and publishes its feedback signals. + /// Nonidentity-base initialization checks known-input preservation, unknown publication, output aliases, latches, and monitor values. TestOutcome initializationAndSignals() { TestStatus success = true; @@ -163,766 +181,1048 @@ namespace GridKit Fixture fixture(data, 0.8, 0.6); fixture.attachAllInputs(99.0); - success *= fixture.initialize(0.05, 0.25); - success *= (fixture.reecb.tagDifferentiable() == 0); - success *= (fixture.evaluate() == 0); + fixture.input(Ext::PE) = 0.3; + fixture.input(Ext::QGEN) = -0.05; + success *= fixture.initialize(0.05, 0.25); + success *= (fixture.evaluate() == 0); const auto* y = fixture.reecb.y().getData(); - success *= scalarMatches(y[index(Vars::VT)], 1.0, "VT"); success *= scalarMatches(y[index(Vars::VMEAS)], 1.0, "VMEAS"); - success *= scalarMatches(y[index(Vars::PMEAS)], 0.5, "PMEAS on component base"); - success *= scalarMatches(y[index(Vars::QREF)], 0.1, "QREF on component base"); - success *= scalarMatches(y[index(Vars::PORD)], 0.5, "PORD on component base"); - success *= scalarMatches(fixture.iqcmd(), 0.05, "seeded iqcmd"); - success *= scalarMatches(fixture.ipcmd(), 0.25, "seeded ipcmd"); - - success *= scalarMatches(fixture.input(Ext::PE), 0.25, "published pe"); - success *= scalarMatches(fixture.input(Ext::QGEN), 0.05, "published qgen"); - success *= scalarMatches(fixture.input(Ext::QEXT), 0.05, "published qext"); - success *= scalarMatches(fixture.input(Ext::PFAREF), 0.0, "inactive pfaref fallback"); - success *= scalarMatches(fixture.input(Ext::PREF), 0.25, "published pref"); + success *= scalarMatches(y[index(Vars::PMEAS)], 0.6, "PMEAS"); + success *= scalarMatches(y[index(Vars::PORD)], 0.5, "PORD"); + success *= scalarMatches(y[index(Vars::VT)], 1.0, "VT"); + success *= scalarMatches(y[index(Vars::ILMAX)], 1.9364916731037085, "ILMAX"); + success *= (fixture.iqcmd() == 0.05); + success *= (fixture.ipcmd() == 0.25); + success *= (fixture.input(Ext::PE) == 0.3); + success *= (fixture.input(Ext::QGEN) == -0.05); + success *= scalarMatches(fixture.input(Ext::QEXT), 0.05, "published QEXT"); + success *= scalarMatches(fixture.input(Ext::PFAREF), 0.0, "published PFAREF"); + success *= scalarMatches(fixture.input(Ext::PREF), 0.25, "published PREF"); + success *= allResidualsZero(fixture.reecb); RealT time = 0.0; Model::VariableMonitorController monitor(time); monitor.addMonitor(fixture.reecb.getMonitor()); - std::stringstream monitor_output; - monitor.addSink({Model::VariableMonitorFormat::CSV}, monitor_output); + std::stringstream output; + monitor.addSink({Model::VariableMonitorFormat::CSV}, output); monitor.start(); monitor.print(); monitor.stop(); - std::string monitor_header; - std::string monitor_values; - std::getline(monitor_output, monitor_header); - std::getline(monitor_output, monitor_values); - success *= (monitor_header == "t,Reecb_reecb_test_iqcmd,Reecb_reecb_test_ipcmd," - "Reecb_reecb_test_vmeas,Reecb_reecb_test_pmeas"); - const auto monitored = Tokenizer(monitor_values, ',')(); + std::string header; + std::string values; + std::getline(output, header); + std::getline(output, values); + success *= (header == "t,Reecb_reecb_test_iqcmd,Reecb_reecb_test_ipcmd,Reecb_reecb_test_vmeas,Reecb_reecb_test_pmeas"); + const auto monitored = Tokenizer(values, ',')(); if (monitored.size() == 5) { - success *= scalarMatches(monitored[1], 0.05, "monitored iqcmd"); - success *= scalarMatches(monitored[2], 0.25, "monitored ipcmd"); - success *= scalarMatches(monitored[3], 1.0, "monitored vmeas"); - success *= scalarMatches(monitored[4], 0.5, "monitored pmeas"); + success *= scalarMatches(monitored[1], 0.05, "monitored IQCMD"); + success *= scalarMatches(monitored[2], 0.25, "monitored IPCMD"); + success *= scalarMatches(monitored[3], 1.0, "monitored VMEAS"); + success *= scalarMatches(monitored[4], 0.6, "monitored PMEAS"); } else { - std::cout << "REECB monitor emitted " << monitored.size() - << " values instead of 5\n"; + std::cout << "REECB monitor emitted " << monitored.size() << " values instead of 5\n"; success = false; } - for (size_t i = 0; i < static_cast(fixture.reecb.size()); ++i) - { - const bool expected = i <= index(Vars::PORD); - if (fixture.reecb.tag()[i] != expected) - { - std::cout << "REECB differentiability tag " << i << " mismatch\n"; - success = false; - } - } - success *= allResidualsZero(fixture.reecb); + Fixture latched(data, 1.0, 0.0, 100.0e6, false); + success *= latched.initialize(0.05, 0.25); + success *= (latched.evaluate() == 0); + success *= allResidualsZero(latched.reecb); + + auto system_base_data = makeData(); + system_base_data.parameters.erase(Params::mva); + Fixture system_base(system_base_data, 1.0, 0.0, 80.0e6); + success *= system_base.initialize(0.05, 0.25); + success *= (system_base.evaluate() == 0); + success *= allResidualsZero(system_base.reecb); + success *= scalarMatches(system_base.reecb.y().getData()[index(Vars::PMEAS)], 0.25, "omitted mva PMEAS base"); + success *= scalarMatches(system_base.reecb.y().getData()[index(Vars::PORD)], 0.25, "omitted mva PORD base"); + success *= scalarMatches(system_base.reecb.y().getData()[index(Vars::ILMAX)], std::sqrt(3.9375), "omitted mva ILMAX base"); - // Every flag combination must preserve the seeded commands and produce - // a zero-derivative, zero-residual state. The nonzero PI gains ensure - // that active and parked anti-windup paths are both initialized. - for (const bool pf_flag : {false, true}) - { - for (const bool v_flag : {false, true}) - { - for (const bool q_flag : {false, true}) - { - for (const bool p_priority : {false, true}) - { - auto scenario_data = makeDynamicData(); - scenario_data.parameters[Params::PfFlag] = pf_flag; - scenario_data.parameters[Params::VFlag] = v_flag; - scenario_data.parameters[Params::QFlag] = q_flag; - scenario_data.parameters[Params::Pqflag] = p_priority; - scenario_data.parameters[Params::kqv] = 0.0; - - Fixture scenario(scenario_data); - scenario.attachAllInputs(99.0); - if (!scenario.initialize(0.05, 0.2)) - { - std::cout << "REECB initialization scenario failed: PfFlag=" << pf_flag - << ", VFlag=" << v_flag - << ", QFlag=" << q_flag - << ", Pqflag=" << p_priority << '\n'; - success = false; - continue; - } + return success.report(__func__); + } - success *= (scenario.evaluate() == 0); - success *= allResidualsZero(scenario.reecb); - success *= scalarMatches(scenario.iqcmd(), 0.05, "scenario iqcmd preservation"); - success *= scalarMatches(scenario.ipcmd(), 0.2, "scenario ipcmd preservation"); - success *= scalarMatches(scenario.input(Ext::PE), 0.2, "scenario pe publication"); - success *= scalarMatches(scenario.input(Ext::QGEN), 0.05, "scenario qgen publication"); - } - } - } - } + /// Strict inverse-limiter domains, collapsed limits, zero gains, and failure atomicity are checked at accepted and rejected points. + TestOutcome initializationDomain() + { + TestStatus success = true; - // An in-band point and both voltage-band exits exercise the direct - // Iq-injection compensation during initialization. - for (const RealT terminal_voltage : - {static_cast(0.6), static_cast(1.1), static_cast(1.3)}) - { - auto voltage_data = makeDynamicData(); - voltage_data.parameters[Params::QFlag] = true; - voltage_data.parameters[Params::VFlag] = true; - voltage_data.parameters[Params::Vref0] = 1.0; - voltage_data.parameters[Params::Vmin] = 0.5; - voltage_data.parameters[Params::Vmax] = 1.4; - - Fixture voltage_scenario(voltage_data, terminal_voltage); - voltage_scenario.attachAllInputs(); - success *= voltage_scenario.initialize(0.05, 0.2); - success *= (voltage_scenario.evaluate() == 0); - success *= allResidualsZero(voltage_scenario.reecb); - success *= scalarMatches(voltage_scenario.iqcmd(), 0.05, "voltage-band iqcmd preservation"); - success *= scalarMatches(voltage_scenario.ipcmd(), 0.2, "voltage-band ipcmd preservation"); - } + noteExpectedLogs("Testing inadmissible REECB initialization points. Logged errors are expected."); + + success *= initializationRejectedAtomically(makeData(), 0.0, 0.0, 1.0, "zero active-current endpoint"); + success *= initializationRejectedAtomically(makeData(), 0.0, 2.0, 1.0, "zero ILMAX at active-current endpoint"); + + const RealT iq_endpoint = std::sqrt(4.0 - 0.2 * 0.2); + success *= initializationRejectedAtomically(makeData(), iq_endpoint, 0.2, 1.0, "reactive-current endpoint"); + success *= initializationRejectedAtomically(makeData(), -iq_endpoint, 0.2, 1.0, "negative reactive-current endpoint"); + + auto q_priority = makeData(); + q_priority.parameters[Params::Pqflag] = false; + success *= initializationRejectedAtomically(q_priority, 0.2, iq_endpoint, 1.0, "Q-priority active-current endpoint"); + + auto pord_limit = makeData(); + pord_limit.parameters[Params::Pmax] = 0.25; + success *= initializationRejectedAtomically(pord_limit, 0.0, 0.5, 1.0, "recovered PORD above Pmax"); + success *= initializationRejectedAtomically(makeData(), 0.0, 1.0e-6, 1.0, "recovered PORD below Pmin"); + + auto q_endpoint = makeData(); + q_endpoint.parameters[Params::QFlag] = true; + q_endpoint.parameters[Params::VFlag] = true; + q_endpoint.parameters[Params::Kqi] = 0.4; + success *= initializationRejectedAtomically(q_endpoint, 0.0, 0.2, 1.0, "QGEN at Qmax", 0.2, 1.0); + success *= initializationRejectedAtomically(q_endpoint, 0.0, 0.2, 1.0, "QGEN at Qmin", 0.2, -1.0); + + auto v_endpoint = q_endpoint; + v_endpoint.parameters[Params::Kqi] = 0.0; + v_endpoint.parameters[Params::Kvi] = 0.5; + success *= initializationRejectedAtomically(v_endpoint, 0.0, 0.2, 1.2, "voltage at Vmax", 0.24, 0.0); + + // At Vmin the saturated Q-PI output equals the measured voltage, so + // this boundary point is a consistent equilibrium and initializes. + Fixture v_boundary(v_endpoint, 0.8); + v_boundary.attachAllInputs(); + v_boundary.input(Ext::PE) = 0.16; + success *= v_boundary.initialize(0.0, 0.2); + success *= (v_boundary.evaluate() == 0); + success *= allResidualsZero(v_boundary.reecb); + + auto zero_power = makeData(); + zero_power.parameters[Params::PfFlag] = true; + success *= initializationRejectedAtomically(zero_power, 0.1, 0.2, 1.0, "power-factor target at zero active power", 0.0, 0.1); + + auto pf_resolution = makeData(); + pf_resolution.parameters[Params::PfFlag] = true; + success *= initializationRejectedAtomically( + pf_resolution, 0.1, 0.2, 0.01, "unrepresentable power-factor reference", 1.0e-8); + + success *= initializationRejectedAtomically(makeData(), 0.0, 0.2, 0.0, "zero terminal voltage"); + success *= initializationRejectedAtomically(makeData(), 0.0, 0.2, 1.0, "nonfinite PE", std::numeric_limits::infinity(), 0.0); + + auto collapsed = makeData(); + collapsed.parameters[Params::QFlag] = true; + collapsed.parameters[Params::VFlag] = true; + collapsed.parameters[Params::Kqi] = 0.4; + collapsed.parameters[Params::Kvi] = 0.5; + collapsed.parameters[Params::Qmin] = 0.0; + collapsed.parameters[Params::Qmax] = 0.0; + collapsed.parameters[Params::Vmin] = 1.0; + collapsed.parameters[Params::Vmax] = 1.0; + Fixture collapsed_fixture(collapsed); + collapsed_fixture.attachAllInputs(); + collapsed_fixture.input(Ext::PE) = 0.2; + success *= collapsed_fixture.initialize(0.0, 0.2); + success *= (collapsed_fixture.evaluate() == 0); + success *= allResidualsZero(collapsed_fixture.reecb); + + auto collapsed_q = collapsed; + collapsed_q.parameters[Params::Vmin] = 0.8; + collapsed_q.parameters[Params::Vmax] = 1.2; + collapsed_q.parameters[Params::Kvi] = 0.0; + success *= initializationRejectedAtomically(collapsed_q, 0.0, 0.2, 1.0, "collapsed Q limit away from equilibrium", 0.2, 0.1); + + auto collapsed_v = collapsed; + collapsed_v.parameters[Params::Qmin] = -1.0; + collapsed_v.parameters[Params::Qmax] = 1.0; + collapsed_v.parameters[Params::Kqi] = 0.0; + collapsed_v.parameters[Params::Vmin] = 1.1; + collapsed_v.parameters[Params::Vmax] = 1.1; + success *= initializationRejectedAtomically(collapsed_v, 0.0, 0.2, 1.0, "collapsed V limit away from equilibrium", 0.2, 0.0); + + auto zero_gains = makeData(); + zero_gains.parameters[Params::QFlag] = true; + zero_gains.parameters[Params::VFlag] = true; + zero_gains.parameters[Params::Kqi] = 0.0; + zero_gains.parameters[Params::Kvi] = 0.0; + Fixture unconstrained(zero_gains); + unconstrained.attachAllInputs(); + unconstrained.input(Ext::PE) = 0.2; + unconstrained.input(Ext::QGEN) = 4.0; + success *= unconstrained.initialize(0.0, 0.2); + success *= (unconstrained.evaluate() == 0); + success *= allResidualsZero(unconstrained.reecb); + + auto unattached_data = makeData(); + unattached_data.parameters[Params::QFlag] = true; + unattached_data.parameters[Params::VFlag] = true; + unattached_data.parameters[Params::Kqi] = 0.4; + unattached_data.parameters[Params::Kvi] = 0.5; + unattached_data.parameters[Params::kqv] = 1.0; + unattached_data.parameters.erase(Params::Vref0); + Fixture unattached(unattached_data, 1.2); + success *= unattached.prepare(0.05, 0.2); + setControlState(unattached.reecb); + success *= (unattached.evaluate() == 0); + const auto residual_before = snapshot(unattached.reecb.getResidual()); + success *= (unattached.reecb.initialize() != 0); + success *= (unattached.evaluate() == 0); + success *= vectorUnchanged(unattached.reecb.getResidual(), residual_before, "unattached residual"); return success.report(__func__); } - /// Current, power, and selected-controller initialization domains. - /// Every rejection is atomic; zero-current and exact limiter boundaries - /// remain admissible. - TestOutcome initializationDomain() + /// Fixed near-endpoint commands check that the private smooth inverse reproduces each requested command without an artificial offset. + TestOutcome initializationExactness() { TestStatus success = true; - noteExpectedLogs("Testing inadmissible REECB current, power, and controller " - "initialization points. Logged errors are expected."); + auto data = makeData(); + data.parameters[Params::Pmin] = -1.0; + data.parameters[Params::Pmax] = 3.0; - struct RejectionCase + struct ExactnessCase { - const char* label; - RealT iqcmd; RealT ipcmd; - RealT imax; - RealT pmin; - RealT pmax; + RealT pord; + const char* label; }; - const std::array rejected{{ - {"negative active-current command", 0.0, -0.1, 1.0, 0.0, 1.0}, - {"command vector outside Imax", 0.8, 0.8, 1.0, 0.0, 1.0}, - {"active-power seed above Pmax", 0.0, 0.8, 1.0, 0.0, 0.5}, - {"active-power seed below Pmin", 0.0, 0.2, 1.0, 0.3, 1.0}, + const std::array cases{{ + {1.0e-6, -0.034728131800926182, "near lower active-current limit"}, + {0.2, 0.2, "interior active-current command"}, + {1.999999, 2.0347281318012689, "near upper active-current limit"}, }}; - for (const auto& test_case : rejected) + for (const auto& test_case : cases) { - auto data = makeData(); - data.parameters[Params::Imax] = test_case.imax; - data.parameters[Params::Pmin] = test_case.pmin; - data.parameters[Params::Pmax] = test_case.pmax; - success *= initializationRejectedAtomically( - data, test_case.iqcmd, test_case.ipcmd, 1.0, test_case.label); + Fixture fixture(data); + success *= fixture.initialize(0.0, test_case.ipcmd); + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.y().getData()[index(Vars::PORD)], test_case.pord, test_case.label); + success *= (fixture.ipcmd() == test_case.ipcmd); + success *= allResidualsZero(fixture.reecb); } - // With reactive-current control selected, the voltage-controller - // limiter input must reproduce the compensated reactive-current target. - auto controller_data = makeData(); - controller_data.parameters[Params::QFlag] = true; - controller_data.parameters[Params::Imax] = 1.0; - controller_data.parameters[Params::Vref0] = 1.0; - controller_data.parameters[Params::kqv] = 10.0; - controller_data.parameters[Params::Iql1] = -1.0; - controller_data.parameters[Params::Iqh1] = 1.0; - success *= initializationRejectedAtomically( - controller_data, - -0.5, - 0.0, - 0.6, - "reactive-current target outside the selected voltage-controller limits"); - - // VFlag requires limiter inputs for terminal voltage through Vmin/Vmax - // and initial reactive power through Qmin/Qmax. - auto voltage_data = makeData(); - voltage_data.parameters[Params::QFlag] = true; - voltage_data.parameters[Params::VFlag] = true; - voltage_data.parameters[Params::Vmax] = 0.9; - success *= initializationRejectedAtomically( - voltage_data, - 0.1, - 0.2, - 1.0, - "terminal voltage outside selected Vmin/Vmax"); - - auto reactive_power_data = makeData(); - reactive_power_data.parameters[Params::QFlag] = true; - reactive_power_data.parameters[Params::VFlag] = true; - reactive_power_data.parameters[Params::Qmin] = -0.1; - reactive_power_data.parameters[Params::Qmax] = 0.1; - success *= initializationRejectedAtomically( - reactive_power_data, - 0.2, - 0.2, - 1.0, - "initial reactive power outside selected Qmin/Qmax"); - - // Power-factor control cannot infer an angle for nonzero Q at zero - // active power. This late rejection proves initialization atomicity. - auto power_factor_data = makeData(); - power_factor_data.parameters[Params::PfFlag] = true; - success *= initializationRejectedAtomically( - power_factor_data, - 0.2, - 0.0, - 1.0, - "nonzero power-factor reactive reference at zero active power"); - - // Zero current and both exact current-circle boundaries stay admissible. - struct AdmissibleCase - { - RealT iqcmd; - RealT ipcmd; - RealT imax; - }; + const std::array pord_boundaries{{ + {clampEdgeOffset(), 0.0, "PORD at Pmin"}, + {1.0, 1.0, "PORD at Pmax"}, + }}; - for (const auto& accepted : std::array{{ - {0.0, 0.0, 1.0}, - {0.0, 1.0, 1.0}, - {1.0, 0.0, 1.0}, - }}) + for (const auto& test_case : pord_boundaries) { - auto data = makeData(); - data.parameters[Params::Imax] = accepted.imax; - data.parameters[Params::Pmax] = 1.0; - - Fixture fixture(data); - success *= fixture.initialize(accepted.iqcmd, accepted.ipcmd); + Fixture fixture(makeData()); + success *= fixture.initialize(0.0, test_case.ipcmd); success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.y().getData()[index(Vars::PORD)], test_case.pord, test_case.label); success *= allResidualsZero(fixture.reecb); } + const RealT pmax = 0.2 - 0.5 * kTol; + auto near_pmax = makeData(); + near_pmax.parameters[Params::Pmax] = pmax; + Fixture exact(near_pmax); + success *= exact.initialize(0.0, 0.2); + success *= (exact.evaluate() == 0); + const RealT recovered_pord = static_cast(exact.reecb.y().getData()[index(Vars::PORD)]); + success *= (recovered_pord > pmax); + success *= (recovered_pord < pmax + kTol); + success *= allResidualsZero(exact.reecb); + + const RealT iqmax = std::sqrt(3.96); + Fixture reactive(data); + success *= reactive.initialize(iqmax - 1.0e-6, 0.2); + success *= (reactive.evaluate() == 0); + success *= scalarMatches(reactive.reecb.y().getData()[index(Vars::QV)], 2.0247030060145095, "near upper reactive-current limit"); + success *= allResidualsZero(reactive.reecb); + return success.report(__func__); } - /// A fixed numerical answer key for all 25 REECB residual rows. The - /// expected values are literals, not a second implementation of REECB. + /// One independently calculated literal answer key checks all ten residual rows at a rich non-equilibrium state. TestOutcome residualEquations() { TestStatus success = true; - Fixture fixture(makeDynamicData(), kStateVr, kStateVi); + Fixture fixture(makeDynamicData(), 0.9, 0.4); fixture.attachAllInputs(); - success *= fixture.initialize(0.1, 0.2); setAnswerKeyInputs(fixture); + success *= fixture.prepare(0.25, 0.4); setAnswerKeyState(fixture.reecb); success *= (fixture.evaluate() == 0); - // Values are pinned after an independent one-time evaluation of the - // documented equations at setAnswerKeyState()/setAnswerKeyInputs(). - const std::array expected{{ - {index(Vars::VMEAS), 0.2400000000000002}, - {index(Vars::PMEAS), 0.1449999999999998}, - {index(Vars::XPIQ), 0.03399999970642038}, - {index(Vars::XPIV), 0.0}, - {index(Vars::QV), 0.1222222222222222}, - {index(Vars::PORD), 0.26}, - {index(Vars::VT), -0.02999999999999992}, - {index(Vars::VMEASSAFE), -0.01000000000000001}, - {index(Vars::SDIP), 0.2}, - {index(Vars::VERR), 2.821917786596795e-7}, - {index(Vars::IQV), -0.06999999999999998}, - {index(Vars::QREF), -0.2668756300679377}, - {index(Vars::EQ), 0.3499999999999999}, - {index(Vars::VPIQ), -0.1000000000000002}, - {index(Vars::EPIV), -0.04999999999999993}, - {index(Vars::FPORD), -0.02500000000000002}, - {index(Vars::RPORD), 0.05000000000000004}, - {index(Vars::IQCIRC), 0.3999999999999997}, - {index(Vars::IPCIRC), 0.8100000000000001}, - {index(Vars::IQMAX), 0.1000000000000001}, - {index(Vars::IPMAX), 0.2}, - {index(Vars::IQBASE), -0.3699999999999998}, - {index(Vars::IQRAW), -0.05000000000000002}, - {index(Vars::IQCMD), -0.1000000000000001}, - {index(Vars::IPCMD), -0.1229166666666667}, + const std::array expected{{ + {Vars::VMEAS, "VMEAS", 0.24000000000000021}, // -VMEAS' + (VT - VMEAS) / Trv + {Vars::PMEAS, "PMEAS", 0.14499999999999982}, // -PMEAS' + (kbase PE - PMEAS) / Tp + {Vars::XPIQ, "XPIQ", 0.083249747972647115}, // -XPIQ' + sQPI sdip antiwindup(Kqp eq + XPIQ, Kqi eq; Vmin, Vmax) + {Vars::XPIV, "XPIV", -0.095000000000000001}, // -XPIV' + sQ sdip antiwindup(Kvp epiv + XPIV, Kvi epiv; -Iqmax, Iqmax) + {Vars::QV, "QV", -0.050000000000000003}, // -QV' + sQoff sdip (qref / vsafe - QV) / Tiq + {Vars::PORD, "PORD", 0.25999999999999973}, // -PORD' + sdip antiwindup(PORD, rpord; Pmin, Pmax) + {Vars::VT, "VT", -0.029999999999999916}, // -VT^2 + Vr^2 + Vi^2 + {Vars::ILMAX, "ILMAX", 0.16999999999999993}, // -ILMAX |ILMAX| + Imax^2 - sPQ (kbase IPCMD)^2 - sPQoff (kbase IQCMD)^2 + {Vars::IQCMD, "IQCMD", -0.76999943561644268}, // -kbase IQCMD + clamp(iqraw; -Iqmax, Iqmax) + {Vars::IPCMD, "IPCMD", -0.11578947368421055}, // -kbase IPCMD + clamp(PORD / vsafe; 0, Ipmax) }}; - success *= (static_cast(fixture.reecb.getResidual().getSize()) == expected.size()); - success *= residualsMatch(fixture.reecb, expected); + const auto* residual = fixture.reecb.getResidual().getData(); + for (const auto& answer : expected) + { + success *= scalarMatches(residual[index(answer.row)], answer.value, answer.name); + } return success.report(__func__); } - /// Flag selection, voltage/deadband behavior, injection limiting, - /// reactive lag, and upper/lower/restoring anti-windup behavior. - TestOutcome reactiveControl() + /// Every valid selector combination initializes attached and unattached signals to a + /// zero-residual state; power-factor control with the direct-voltage reference is rejected. + TestOutcome selectorConfigurations() { TestStatus success = true; - struct FlagCase + noteExpectedLogs("Testing REECB selector configurations. " + "Rejection of the power-factor direct-voltage combinations is expected."); + + for (const bool pf : {false, true}) { - const char* label; - bool pf; - bool voltage; - bool reactive; - RealT qref; - RealT epiv; - RealT iqraw; - }; + for (const bool voltage : {false, true}) + { + for (const bool reactive : {false, true}) + { + for (const bool p_priority : {false, true}) + { + auto data = makeData(); + data.parameters[Params::PfFlag] = pf; + data.parameters[Params::VFlag] = voltage; + data.parameters[Params::QFlag] = reactive; + data.parameters[Params::Pqflag] = p_priority; + data.parameters[Params::Kqi] = reactive && voltage ? 0.4 : 0.0; + data.parameters[Params::Kvi] = reactive ? 0.5 : 0.0; + + for (const bool attached : {false, true}) + { + Fixture fixture(data); + if (attached) + { + fixture.attachAllInputs(7.0); + fixture.input(Ext::PE) = 0.2; + fixture.input(Ext::QGEN) = 0.1; + } + + if (pf && reactive && !voltage) + { + success *= (fixture.reecb.verify() > 0); + success *= !fixture.initialize(0.1, 0.2); + continue; + } + + success *= fixture.initialize(0.1, 0.2); + success *= (fixture.evaluate() == 0); + success *= allResidualsZero(fixture.reecb); + success *= (fixture.iqcmd() == 0.1); + success *= (fixture.ipcmd() == 0.2); + + const auto* y = fixture.reecb.y().getData(); + const RealT expected_ilmax = p_priority ? std::sqrt(3.96) : std::sqrt(3.99); + success *= scalarMatches(y[index(Vars::ILMAX)], expected_ilmax, "selector ILMAX"); + + if (reactive) + { + success *= std::abs(y[index(Vars::XPIV)]) > kTol; + if (voltage) + { + success *= std::abs(y[index(Vars::XPIQ)]) > kTol; + } + } + else + { + success *= std::abs(y[index(Vars::QV)]) > kTol; + } + + if (attached) + { + success *= (fixture.input(Ext::PE) == 0.2); + success *= (fixture.input(Ext::QGEN) == 0.1); + const RealT qref = reactive && !voltage ? 1.0 : 0.1; + const RealT expected_pfaref = pf ? 0.4636476090008061 : 0.0; + success *= scalarMatches(fixture.input(Ext::QEXT), qref, "selector QEXT publication"); + success *= scalarMatches(fixture.input(Ext::PFAREF), expected_pfaref, "selector PFAREF publication"); + success *= scalarMatches(fixture.input(Ext::PREF), 0.2, "selector PREF publication"); + } + } + } + } + } + } - // Toggle exactly one selector at a time so an accidental swap between - // PfFlag, VFlag, and QFlag cannot satisfy the same answer key. - const std::array cases{{ - {"all-off selectors", false, false, false, 0.4, -0.55, 0.4}, - {"PfFlag-only selectors", true, false, false, 0.11149051952976989, -0.8385094804702301, 0.4}, - {"VFlag-only selectors", false, true, false, 0.4, 0.05, 0.4}, - {"QFlag-only selectors", false, false, true, 0.4, -0.55, 0.3}, - }}; + return success.report(__func__); + } + + /// Direct-voltage mode consumes and publishes the Volt/VAr reference without + /// power-base conversion; reactive modes convert on the nonidentity base. + TestOutcome voltVarReferenceBase() + { + TestStatus success = true; - for (const auto& test_case : cases) { - auto data = makeDynamicData(); - data.parameters[Params::PfFlag] = test_case.pf; - data.parameters[Params::VFlag] = test_case.voltage; - data.parameters[Params::QFlag] = test_case.reactive; + // 50 MVA component on the 100 MVA system selects direct-voltage mode. + auto data = makeData(); + data.parameters[Params::mva] = 50.0; + data.parameters[Params::QFlag] = true; + data.parameters[Params::VFlag] = false; + data.parameters[Params::Kvi] = 0.5; + + Fixture fixture(data); + fixture.attachAllInputs(); + success *= fixture.initialize(0.1, 0.2); + success *= scalarMatches(fixture.input(Ext::QEXT), 1.0, "published voltage reference"); + success *= (fixture.evaluate() == 0); + success *= allResidualsZero(fixture.reecb); + + // A raised external voltage reference enters the V-PI rate raw. + fixture.input(Ext::QEXT) = 1.02; + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIV)], 0.01, "unconverted voltage-reference rate"); + } + + { + // The reactive-current lag keeps the power-base conversion. + auto data = makeData(); + data.parameters[Params::mva] = 50.0; Fixture fixture(data); fixture.attachAllInputs(); success *= fixture.initialize(0.1, 0.2); + success *= scalarMatches(fixture.input(Ext::QEXT), 0.1, "published system-base reactive power"); + success *= (fixture.evaluate() == 0); + success *= allResidualsZero(fixture.reecb); + + fixture.input(Ext::QEXT) = 0.11; + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::QV)], 1.0, "converted reactive-reference rate"); + } + + return success.report(__func__); + } + + /// Literal selector, voltage-gate, limiter, and anti-windup cases check the reactive-control paths. + TestOutcome reactiveControl() + { + TestStatus success = true; + + { + auto data = makeDynamicData(); + data.parameters[Params::PfFlag] = false; + data.parameters[Params::QFlag] = false; + data.parameters[Params::kqv] = 0.0; + Fixture fixture(data); + fixture.attachAllInputs(); + fixture.input(Ext::QEXT) = 0.4; + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + fixture.reecb.y().getData()[index(Vars::QV)] = 0.1; + fixture.reecb.y().setDataUpdated(); + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::QV)], 2.3333333333333335, "constant-Q lag"); + } - fixture.input(Ext::PFAREF) = 0.2; - fixture.input(Ext::QEXT) = 0.2; // 0.4 on the 50 MVA component base. - setState(fixture.reecb, - {{index(Vars::PMEAS), 0.55}, - {index(Vars::VMEAS), 0.95}, - {index(Vars::VPIQ), 1.0}, - {index(Vars::QREF), test_case.qref}, - {index(Vars::IQBASE), 0.2}, - {index(Vars::QV), 0.3}, - {index(Vars::SDIP), 0.9}, - {index(Vars::IQV), 0.1}, - {index(Vars::EPIV), test_case.epiv}, - {index(Vars::IQRAW), test_case.iqraw}}); + { + auto data = makeDynamicData(); + data.parameters[Params::PfFlag] = false; + data.parameters[Params::QFlag] = true; + data.parameters[Params::VFlag] = true; + data.parameters[Params::kqv] = 0.0; + Fixture fixture(data); + fixture.attachAllInputs(); + fixture.input(Ext::QEXT) = 0.1; + fixture.input(Ext::QGEN) = -0.05; + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + fixture.reecb.y().getData()[index(Vars::XPIQ)] = 1.0; + fixture.reecb.y().setDataUpdated(); success *= (fixture.evaluate() == 0); - success *= residualsMatch(fixture.reecb, - {{index(Vars::QREF), 0.0}, {index(Vars::EPIV), 0.0}, {index(Vars::IQRAW), 0.0}}, - test_case.label); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIQ)], 0.11999999999996271, "Q-control integral rate"); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::QV)], 0.0, "bypassed Q lag"); } - Fixture limit_fixture(makeDynamicData()); - limit_fixture.attachAllInputs(); - success *= limit_fixture.initialize(0.1, 0.2); - limit_fixture.input(Ext::QGEN) = 0.0; - - // A Q reference driven past each limit; EQ is the clamped reference - // less the zeroed qgen feedback. - for (const auto& [qref, expected_eq] : std::array{{ - {2.0, 0.8}, - {-2.0, -0.7}, - }}) - { - setState(limit_fixture.reecb, {{index(Vars::QREF), qref}, {index(Vars::EQ), 0.0}}); - success *= (limit_fixture.evaluate() == 0); - success *= residualsMatch(limit_fixture.reecb, - {{index(Vars::EQ), expected_eq}}, - "reactive-power limit"); + { + auto data = makeDynamicData(); + data.parameters[Params::PfFlag] = false; + data.parameters[Params::QFlag] = true; + data.parameters[Params::VFlag] = false; + data.parameters[Params::kqv] = 0.0; + Fixture fixture(data); + fixture.attachAllInputs(); + fixture.input(Ext::QEXT) = 1.05; + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIQ)], 0.0, "bypassed Q-control integrator"); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIV)], 0.025, "voltage-control integral rate"); } - // The same sweep through the reactive-power PI output limits. - for (const auto& [eq, expected_vpiq] : std::array{{ - {4.0, 1.3}, - {-4.0, 0.7}, - }}) - { - setState(limit_fixture.reecb, {{index(Vars::EQ), eq}, {index(Vars::XPIQ), 0.0}, {index(Vars::VPIQ), 0.0}}); - success *= (limit_fixture.evaluate() == 0); - success *= residualsMatch(limit_fixture.reecb, - {{index(Vars::VPIQ), expected_vpiq}}, - "reactive-power PI voltage limit"); + { + auto data = makeDynamicData(); + data.parameters[Params::PfFlag] = false; + data.parameters[Params::QFlag] = false; + data.parameters[Params::kqv] = 0.0; + Fixture fixture(data); + fixture.attachAllInputs(); + fixture.input(Ext::QEXT) = 0.4; + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + auto* y = fixture.reecb.y().getData(); + y[index(Vars::QV)] = 0.1; + y[index(Vars::VT)] = 0.5; + fixture.reecb.y().setDataUpdated(); + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::QV)], 0.0, "constant-Q voltage-band gate"); + } + + { + auto data = makeDynamicData(); + data.parameters[Params::PfFlag] = false; + data.parameters[Params::QFlag] = true; + data.parameters[Params::VFlag] = true; + data.parameters[Params::kqv] = 0.0; + data.parameters[Params::Qmin] = -2.0; + data.parameters[Params::Qmax] = 2.0; + data.parameters[Params::Kqp] = 0.0; + Fixture fixture(data); + fixture.attachAllInputs(); + fixture.input(Ext::QEXT) = 0.5; + fixture.input(Ext::QGEN) = 0.0; + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + auto* y = fixture.reecb.y().getData(); + y[index(Vars::XPIQ)] = 1.0; + y[index(Vars::VT)] = 0.5; + fixture.reecb.y().setDataUpdated(); + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIQ)], 0.0, "Q-integrator voltage-band gate"); + } + + { + auto data = makeDynamicData(); + data.parameters[Params::PfFlag] = false; + data.parameters[Params::QFlag] = true; + data.parameters[Params::VFlag] = false; + data.parameters[Params::kqv] = 0.0; + data.parameters[Params::Kvp] = 0.0; + Fixture fixture(data); + fixture.attachAllInputs(); + fixture.input(Ext::QEXT) = 1.1; + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + auto* y = fixture.reecb.y().getData(); + y[index(Vars::XPIV)] = 0.0; + y[index(Vars::VT)] = 0.5; + fixture.reecb.y().setDataUpdated(); + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIV)], 0.0, "V-integrator voltage-band gate"); } - // Each row pins the smooth voltage-band/deadband/injection behavior - // without calling CommonMath in the expected-value path. - struct VoltageCase + struct ReactiveCase { - RealT voltage; - RealT vmeas; - RealT expected_sdip_rhs; - RealT expected_verr_rhs; - RealT expected_iqv_rhs; + RealT input; + RealT state; + RealT expected; + const char* label; }; - const std::array voltage_cases{{ - {0.6, 0.6, 0.0, 0.37, 0.5}, - {1.0, 1.0, 1.0, -3.104066702683552e-5, -6.208133405367104e-5}, - {1.3, 1.3, 0.0, -0.28, -0.4}, + const std::array q_limit_cases{{ + {-1.0, 1.0, -0.27999999999999997, "Q reference below Qmin"}, + {-0.7, 1.0, 0.4 * (-0.7 + clampEdgeOffset()), "Q reference at Qmin"}, + {0.1, 1.0, 0.039999999999999994, "Q reference inside limits"}, + {0.8, 1.0, 0.4 * (0.8 - clampEdgeOffset()), "Q reference at Qmax"}, + {1.0, 1.0, 0.32000000000000006, "Q reference above Qmax"}, }}; - auto voltage_data = makeDynamicData(); - voltage_data.parameters[Params::Vref0] = 1.0; - Fixture voltage_fixture(voltage_data); - success *= voltage_fixture.initialize(0.0, 0.2); - for (const auto& test_case : voltage_cases) - { - setState(voltage_fixture.reecb, - {{index(Vars::VT), test_case.voltage}, - {index(Vars::VMEAS), test_case.vmeas}, - {index(Vars::SDIP), test_case.expected_sdip_rhs}, - {index(Vars::VERR), test_case.expected_verr_rhs}, - {index(Vars::IQV), test_case.expected_iqv_rhs}}); - success *= (voltage_fixture.evaluate() == 0); - success *= residualsMatch(voltage_fixture.reecb, - {{index(Vars::SDIP), 0.0}, {index(Vars::VERR), 0.0}, {index(Vars::IQV), 0.0}}, - "voltage band, deadband, and injection limit", - kSteadyStateTol); + for (const auto& test_case : q_limit_cases) + { + auto data = makeDynamicData(); + data.parameters[Params::PfFlag] = false; + data.parameters[Params::QFlag] = true; + data.parameters[Params::VFlag] = true; + data.parameters[Params::kqv] = 0.0; + data.parameters[Params::Kqp] = 0.0; + Fixture fixture(data); + fixture.attachAllInputs(); + fixture.input(Ext::QEXT) = test_case.input / 2.0; + fixture.input(Ext::QGEN) = 0.0; + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + fixture.reecb.y().getData()[index(Vars::XPIQ)] = test_case.state; + fixture.reecb.y().setDataUpdated(); + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIQ)], test_case.expected, test_case.label); } - // The QV lag and both PI anti-windup rows are evaluated at three - // controller directions: upper saturation, lower saturation, and a - // restoring direction. The expected derivatives are fixed literals. - struct AntiWindupCase - { - RealT xpiq; - RealT eq; - RealT xpiv; - RealT epiv; - RealT expected_xpiq; - RealT expected_xpiv; - }; + const std::array q_windup_cases{{ + {1.0, 2.0, 0.0, "outward Q-integrator rate above Vmax"}, + {1.0, 1.3, 0.2, "outward Q-integrator rate at Vmax"}, + {-1.0, 2.0, -0.4, "restoring Q-integrator rate above Vmax"}, + {-1.0, 0.0, 0.0, "outward Q-integrator rate below Vmin"}, + {-1.0, 0.7, -0.2, "outward Q-integrator rate at Vmin"}, + {1.0, 0.0, 0.4, "restoring Q-integrator rate below Vmin"}, + }}; + + for (const auto& test_case : q_windup_cases) + { + auto data = makeDynamicData(); + data.parameters[Params::PfFlag] = false; + data.parameters[Params::QFlag] = true; + data.parameters[Params::VFlag] = true; + data.parameters[Params::kqv] = 0.0; + data.parameters[Params::Qmin] = -2.0; + data.parameters[Params::Qmax] = 2.0; + data.parameters[Params::Kqp] = 0.0; + Fixture fixture(data); + fixture.attachAllInputs(); + fixture.input(Ext::QEXT) = test_case.input / 2.0; + fixture.input(Ext::QGEN) = 0.0; + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + fixture.reecb.y().getData()[index(Vars::XPIQ)] = test_case.state; + fixture.reecb.y().setDataUpdated(); + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIQ)], test_case.expected, test_case.label); + } - const std::array antiwindup_cases{{ - {2.0, 0.5, 2.0, 0.5, 0.0, 0.0}, - {-2.0, -0.5, -2.0, -0.5, 0.0, 0.0}, - {2.0, -0.5, 2.0, -0.5, -0.2, -0.25}, + const std::array v_limit_cases{{ + {0.0, 0.0, -0.30000000000000004, "V-PI input below Vmin"}, + {0.7, 0.0, -0.3 + clampEdgeOffset(), "V-PI input at Vmin"}, + {1.0, 0.0, 0.0, "V-PI input inside limits"}, + {1.3, 0.0, 0.3 - clampEdgeOffset(), "V-PI input at Vmax"}, + {2.0, 0.0, 0.30000000000000004, "V-PI input above Vmax"}, }}; - Fixture controller_fixture(makeDynamicData()); - success *= controller_fixture.initialize(0.0, 0.2); - for (const auto& test_case : antiwindup_cases) - { - setState(controller_fixture.reecb, - {{index(Vars::SDIP), 1.0}, - {index(Vars::XPIQ), test_case.xpiq}, - {index(Vars::EQ), test_case.eq}, - {index(Vars::XPIV), test_case.xpiv}, - {index(Vars::EPIV), test_case.epiv}, - {index(Vars::IQMAX), 1.0}}); - success *= (controller_fixture.evaluate() == 0); - success *= residualsMatch(controller_fixture.reecb, - {{index(Vars::XPIQ), test_case.expected_xpiq}, - {index(Vars::XPIV), test_case.expected_xpiv}}, - "anti-windup"); + for (const auto& test_case : v_limit_cases) + { + auto data = makeDynamicData(); + data.parameters[Params::PfFlag] = false; + data.parameters[Params::QFlag] = true; + data.parameters[Params::VFlag] = true; + data.parameters[Params::kqv] = 0.0; + data.parameters[Params::Kqp] = 0.0; + data.parameters[Params::Kvi] = 1.0; + data.parameters[Params::Kvp] = 0.0; + Fixture fixture(data); + fixture.attachAllInputs(); + fixture.input(Ext::QEXT) = 0.0; + fixture.input(Ext::QGEN) = 0.0; + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + auto* y = fixture.reecb.y().getData(); + y[index(Vars::XPIQ)] = test_case.input; + y[index(Vars::XPIV)] = test_case.state; + fixture.reecb.y().setDataUpdated(); + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIV)], test_case.expected, test_case.label); } - setState(controller_fixture.reecb, - {{index(Vars::SDIP), 1.0}, {index(Vars::QREF), 0.4}, {index(Vars::VMEASSAFE), 1.0}, {index(Vars::QV), 0.2}}); - setDerivative(controller_fixture.reecb, {{index(Vars::QV), 0.1}}); - success *= (controller_fixture.evaluate() == 0); - success *= residualsMatch(controller_fixture.reecb, - {{index(Vars::QV), 0.5666666666666667}}, - "reactive-current lag"); + const std::array v_windup_cases{{ + {0.5, 2.0, 0.0, "outward V-integrator rate above Iqmax"}, + {0.5, 1.4, 0.25, "outward V-integrator rate at Iqmax"}, + {-0.5, 2.0, -0.5, "restoring V-integrator rate above Iqmax"}, + {-0.5, -2.0, 0.0, "outward V-integrator rate below negative Iqmax"}, + {-0.5, -1.4, -0.25, "outward V-integrator rate at negative Iqmax"}, + {0.5, -2.0, 0.5, "restoring V-integrator rate below negative Iqmax"}, + }}; + + for (const auto& test_case : v_windup_cases) + { + auto data = makeDynamicData(); + data.parameters[Params::PfFlag] = false; + data.parameters[Params::QFlag] = true; + data.parameters[Params::VFlag] = false; + data.parameters[Params::kqv] = 0.0; + data.parameters[Params::Kvp] = 0.0; + Fixture fixture(data); + fixture.attachAllInputs(); + fixture.input(Ext::QEXT) = test_case.input > 0.0 ? 2.0 : 0.0; + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + fixture.reecb.y().getData()[index(Vars::XPIV)] = test_case.state; + fixture.reecb.y().setDataUpdated(); + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIV)], test_case.expected, test_case.label); + } + + // With QFlag = 0 and a zero lag state the reactive command is the + // injection alone, so the IQCMD row reads iqv = clamp(kqv deadband2( + // Vref0 - VMEAS; dbd1, dbd2); Iql1, Iqh1) directly. The wide deadband + // and limit gaps keep every smoothing tail below kTol except at the + // tested edges, which sit exactly one clampEdgeOffset() inside. + const std::array injection_cases{{ + {1.75, 0.0, -0.4, "injection saturated at Iql1"}, + {1.2, 0.0, -clampEdgeOffset(), "injection at lower deadband breakpoint"}, + {1.0, 0.0, 0.0, "injection inside deadband"}, + {0.75, 0.0, clampEdgeOffset(), "injection at upper deadband breakpoint"}, + {0.55, 0.0, 0.2, "injection passthrough above deadband"}, + {0.25, 0.0, 0.5 - clampEdgeOffset(), "injection at Iqh1 edge"}, + {0.1, 0.0, 0.5, "injection saturated at Iqh1"}, + }}; + + for (const auto& test_case : injection_cases) + { + auto data = makeData(); + data.parameters[Params::kqv] = 1.0; + data.parameters[Params::dbd1] = -0.2; + data.parameters[Params::dbd2] = 0.25; + data.parameters[Params::Iql1] = -0.4; + data.parameters[Params::Iqh1] = 0.5; + Fixture fixture(data); + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + fixture.reecb.y().getData()[index(Vars::VMEAS)] = test_case.input; + fixture.reecb.y().setDataUpdated(); + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::IQCMD)], test_case.expected, test_case.label); + } return success.report(__func__); } - /// Active-power measurement/order filters, both ramp limits, power - /// bounds, and the safe-voltage current conversion. - TestOutcome activePowerControl() + /// Literal ramp, voltage-gate, anti-windup, command-limit, current-circle, and signed-continuation cases check the active/current paths. + TestOutcome activeCurrentControl() { TestStatus success = true; - auto data = makeDynamicData(); - data.parameters[Params::dPmax] = 0.4; - data.parameters[Params::dPmin] = -0.3; - data.parameters[Params::Pmax] = 1.0; - data.parameters[Params::Pmin] = 0.1; - data.parameters[Params::Tpord] = 0.5; - data.parameters[Params::Tp] = 0.25; - - Fixture fixture(data); - fixture.attachAllInputs(); - success *= fixture.initialize(0.0, 0.2); - - struct RampCase + struct ScalarCase { - RealT pord; - RealT pref_system; - RealT fpord; - RealT rpord; - RealT expected_fpord; - RealT expected_rpord; - RealT expected_pord; + RealT input; + RealT expected; + const char* label; }; - const std::array cases{{ - {0.5, 0.5, 1.0, 0.4, 0.0, 0.0, 0.4}, - {0.7, 0.1, -1.0, -0.3, 0.0, 0.0, -0.3}, - {1.2, 0.5, -0.4, -0.3, 0.0, 0.0, -0.3}, + const std::array rate_cases{{ + {-1.0, -0.5, "lower PORD ramp saturation"}, + {-0.5, -0.5 + clampEdgeOffset(), "lower PORD ramp boundary"}, + {0.2, 0.19999999999999996, "interior PORD rate"}, + {0.6, 0.6 - clampEdgeOffset(), "upper PORD ramp boundary"}, + {1.0, 0.6, "upper PORD ramp saturation"}, }}; - for (const auto& test_case : cases) + for (const auto& test_case : rate_cases) + { + Fixture fixture(makeDynamicData()); + fixture.attachAllInputs(); + fixture.input(Ext::PREF) = (0.65 + 0.25 * test_case.input) / 2.0; + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + fixture.reecb.y().getData()[index(Vars::PORD)] = 0.65; + fixture.reecb.y().setDataUpdated(); + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::PORD)], test_case.expected, test_case.label); + } + + const std::array gate_cases{{ + {0.5, 0.0, "PORD below voltage band"}, + {0.7, 0.1, "PORD at lower voltage threshold"}, + {1.0, 0.2, "PORD inside voltage band"}, + {1.2, 0.1, "PORD at upper voltage threshold"}, + {1.4, 0.0, "PORD above voltage band"}, + }}; + + for (const auto& test_case : gate_cases) { - fixture.input(Ext::PREF) = test_case.pref_system; - setState(fixture.reecb, - {{index(Vars::PORD), test_case.pord}, - {index(Vars::FPORD), test_case.fpord}, - {index(Vars::RPORD), test_case.rpord}, - {index(Vars::SDIP), 1.0}}); - setDerivative(fixture.reecb, {{index(Vars::PORD), 0.0}}); + Fixture fixture(makeDynamicData()); + fixture.attachAllInputs(); + fixture.input(Ext::PREF) = 0.35; + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + auto* y = fixture.reecb.y().getData(); + y[index(Vars::PORD)] = 0.65; + y[index(Vars::VT)] = test_case.input; + fixture.reecb.y().setDataUpdated(); success *= (fixture.evaluate() == 0); - success *= residualsMatch(fixture.reecb, - {{index(Vars::FPORD), test_case.expected_fpord}, - {index(Vars::RPORD), test_case.expected_rpord}, - {index(Vars::PORD), test_case.expected_pord}}, - "active-power order"); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::PORD)], test_case.expected, test_case.label); } - struct LowerPowerBoundCase + struct WindupCase { - RealT rate; - RealT expected_residual; + RealT pord; + RealT raw_rate; + RealT expected; const char* label; }; - const std::array lower_bound_cases{{ - {-0.3, 0.0, "Pmin blocks an outward active-power rate"}, - {0.4, 0.4, "Pmin admits a restoring active-power rate"}, + const std::array windup_cases{{ + {2.0, 1.0, 0.0, "outward rate above Pmax"}, + {1.4, 1.0, 0.3, "outward rate at Pmax"}, + {2.0, -1.0, -0.5, "restoring rate above Pmax"}, + {-1.0, -1.0, 0.0, "outward rate below Pmin"}, + {0.1, -1.0, -0.25, "outward rate at Pmin"}, + {-1.0, 1.0, 0.6, "restoring rate below Pmin"}, }}; - for (const auto& test_case : lower_bound_cases) + for (const auto& test_case : windup_cases) { - setState(fixture.reecb, - {{index(Vars::PORD), -0.2}, {index(Vars::RPORD), test_case.rate}, {index(Vars::SDIP), 1.0}}); - setDerivative(fixture.reecb, {{index(Vars::PORD), 0.0}}); + Fixture fixture(makeDynamicData()); + fixture.attachAllInputs(); + fixture.input(Ext::PREF) = (test_case.pord + 0.25 * test_case.raw_rate) / 2.0; + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + fixture.reecb.y().getData()[index(Vars::PORD)] = test_case.pord; + fixture.reecb.y().setDataUpdated(); success *= (fixture.evaluate() == 0); - success *= residualsMatch(fixture.reecb, - {{index(Vars::PORD), test_case.expected_residual}}, - test_case.label); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::PORD)], test_case.expected, test_case.label); } - // PE is 0.3 on system base and 0.6 on the 50 MVA component base. - fixture.input(Ext::PE) = 0.3; - setState(fixture.reecb, - {{index(Vars::PMEAS), 0.5}, - {index(Vars::VMEAS), 0.005}, - {index(Vars::VMEASSAFE), 0.01}, - {index(Vars::PORD), 0.004}, - {index(Vars::IPMAX), 1.0}, - {index(Vars::IPCMD), 0.2}}); - setDerivative(fixture.reecb, {{index(Vars::PMEAS), 0.1}}); - success *= (fixture.evaluate() == 0); - success *= residualsMatch(fixture.reecb, - {{index(Vars::PMEAS), 0.3}, - {index(Vars::VMEASSAFE), 0.00109701028057513}, - {index(Vars::IPCMD), 0.0}}, - "safe-voltage active-power path"); + const std::array iq_limit_cases{{ + {-1.0, -0.4, "IQCMD below low-priority limit"}, + {-0.4, -0.4 + clampEdgeOffset(), "IQCMD at negative low-priority limit"}, + {0.0, 0.0, "IQCMD inside low-priority limits"}, + {0.4, 0.4 - clampEdgeOffset(), "IQCMD at positive low-priority limit"}, + {1.0, 0.4, "IQCMD above low-priority limit"}, + }}; - return success.report(__func__); - } + for (const auto& test_case : iq_limit_cases) + { + auto data = makeData(); + data.parameters[Params::QFlag] = false; + data.parameters[Params::Pqflag] = true; + Fixture fixture(data); + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + auto* y = fixture.reecb.y().getData(); + y[index(Vars::ILMAX)] = 0.4; + y[index(Vars::IQCMD)] = 0.0; + y[index(Vars::QV)] = test_case.input; + fixture.reecb.y().setDataUpdated(); + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::IQCMD)], test_case.expected, test_case.label); + } - /// P- and Q-priority at the current-circle boundary. In both cases the - /// low-priority command exactly binds the remaining-current root. - TestOutcome currentPriority() - { - TestStatus success = true; + const std::array ip_limit_cases{{ + {-1.0, 0.0, "IPCMD below zero"}, + {0.0, clampEdgeOffset(), "IPCMD at zero"}, + {0.2, 0.2, "IPCMD inside low-priority limits"}, + {0.4, 0.4 - clampEdgeOffset(), "IPCMD at low-priority limit"}, + {1.0, 0.4, "IPCMD above low-priority limit"}, + }}; - struct PriorityCase + for (const auto& test_case : ip_limit_cases) { - const char* label; - bool p_priority; - RealT iqcmd; - RealT ipcmd; - RealT iqcirc; - RealT ipcirc; - RealT iqmax; - RealT ipmax; - }; + auto data = makeData(); + data.parameters[Params::Pqflag] = false; + Fixture fixture(data); + success *= fixture.prepare(0.2, 0.0); + setControlState(fixture.reecb); + auto* y = fixture.reecb.y().getData(); + y[index(Vars::ILMAX)] = 0.4; + y[index(Vars::IPCMD)] = 0.0; + y[index(Vars::PORD)] = test_case.input; + fixture.reecb.y().setDataUpdated(); + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::IPCMD)], test_case.expected, test_case.label); + } - const std::array cases{{ - {"Q-priority", false, 0.8, 0.6, 1.0, 0.6, 1.0, 0.6}, - {"P-priority", true, 0.6, 0.8, 0.6, 1.0, 0.6, 1.0}, - }}; + for (const bool p_priority : {false, true}) + { + auto data = makeDynamicData(); + data.parameters[Params::Pqflag] = p_priority; + Fixture fixture(data); + fixture.attachAllInputs(); + success *= fixture.prepare(0.25, 0.4); + setAnswerKeyState(fixture.reecb); + success *= (fixture.evaluate() == 0); + const RealT expected = p_priority ? 0.16999999999999993 : 0.56; + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::ILMAX)], expected, "priority-circle residual"); + } - for (const auto& test_case : cases) + { + auto data = makeData(); + data.parameters[Params::Imax] = 1.0; + Fixture fixture(data); + const RealT ipcmd = std::sqrt(1.0 - 1.0e-12); + success *= fixture.prepare(0.0, ipcmd); + setControlState(fixture.reecb); + auto* y = fixture.reecb.y().getData(); + y[index(Vars::ILMAX)] = 1.0e-6; + y[index(Vars::IPCMD)] = ipcmd; + fixture.reecb.y().setDataUpdated(); + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::ILMAX)], 0.0, "near-zero ILMAX residual"); + } + + for (const bool p_priority : {false, true}) { auto data = makeData(); - data.parameters[Params::Pqflag] = test_case.p_priority; + data.parameters[Params::Pqflag] = p_priority; data.parameters[Params::Imax] = 1.0; + const RealT iqcmd = p_priority ? 0.2 : 1.1; + const RealT ipcmd = p_priority ? 1.1 : 0.2; Fixture fixture(data); - success *= fixture.initialize(test_case.iqcmd, test_case.ipcmd); + success *= fixture.prepare(iqcmd, ipcmd); + setControlState(fixture.reecb); + + auto* y = fixture.reecb.y().getData(); + y[index(Vars::ILMAX)] = -std::sqrt(0.21); + y[p_priority ? index(Vars::QV) : index(Vars::PORD)] = 0.3; + fixture.reecb.y().setDataUpdated(); + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::ILMAX)], 0.0, "negative ILMAX continuation"); + const auto low_priority_row = p_priority ? Vars::IQCMD : Vars::IPCMD; + const RealT negative_limit_value = fixture.reecb.getResidual().getData()[index(low_priority_row)]; + success *= scalarMatches(negative_limit_value, 0.1, "negative ILMAX command bound"); + for (size_t row = 0; row < index(Vars::MAXIMUM); ++row) + { + success *= std::isfinite(fixture.reecb.getResidual().getData()[row]); + } + + y[index(Vars::ILMAX)] = std::sqrt(0.21); + fixture.reecb.y().setDataUpdated(); success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(low_priority_row)], negative_limit_value, "signed ILMAX bound parity"); - success *= stateMatches(fixture.reecb, - {{index(Vars::IQCIRC), test_case.iqcirc}, - {index(Vars::IPCIRC), test_case.ipcirc}, - {index(Vars::IQMAX), test_case.iqmax}, - {index(Vars::IPMAX), test_case.ipmax}}, - test_case.label); - success *= residualsMatch(fixture.reecb, - {{index(Vars::IQCMD), 0.0}, {index(Vars::IPCMD), 0.0}}, - test_case.label); - success *= scalarMatches(fixture.iqcmd(), test_case.iqcmd, "priority iqcmd preservation"); - success *= scalarMatches(fixture.ipcmd(), test_case.ipcmd, "priority ipcmd preservation"); - success *= allResidualsZero(fixture.reecb); + y[index(Vars::ILMAX)] = 0.0; + fixture.reecb.y().setDataUpdated(); + success *= (fixture.evaluate() == 0); + success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::ILMAX)], -0.21, "zero ILMAX continuation"); + for (size_t row = 0; row < index(Vars::MAXIMUM); ++row) + { + success *= std::isfinite(fixture.reecb.getResidual().getData()[row]); + } } return success.report(__func__); } -#ifdef GRIDKIT_ENABLE_ENZYME - /// Representative selector modes drive both sensitivity paths. Every - /// Enzyme CSR row must match dependency tracking, and fixed derivatives - /// independently pin the four selector contracts. + /// Fixed positive and negative DependencyTracking coefficients provide the oracle before each configuration is compared with Enzyme. TestOutcome jacobian() { TestStatus success = true; - struct SelectorCase + constexpr RealT alpha = 0.7; + for (const bool pf : {false, true}) { - const char* label; - bool pf; - bool voltage; - bool reactive; - bool p_priority; - }; - - // Start with every selector off, then activate one at a time. This keeps - // each expected structural change attributable to exactly one flag. - const std::array cases{{ - {"all selectors off", false, false, false, false}, - {"power-factor control", true, false, false, false}, - {"voltage control", false, true, false, false}, - {"reactive-current control", false, false, true, false}, - {"active-current priority", false, false, false, true}, - }}; - - for (const auto& test_case : cases) - { - auto data = makeDynamicData(); - data.parameters[Params::PfFlag] = test_case.pf; - data.parameters[Params::VFlag] = test_case.voltage; - data.parameters[Params::QFlag] = test_case.reactive; - data.parameters[Params::Pqflag] = test_case.p_priority; - - const auto dependency_jacobian = dependencyTrackingJacobian(data, success); - const auto enzyme_jacobian = enzymeJacobian(data, success); - - success *= (dependency_jacobian.size() == index(Vars::MAXIMUM)); - success *= (dependency_jacobian.size() == enzyme_jacobian.size()); - const auto rows = std::min(dependency_jacobian.size(), enzyme_jacobian.size()); - for (size_t row = 0; row < rows; ++row) + for (const bool voltage : {false, true}) { - if (!isEqual(dependency_jacobian[row], enzyme_jacobian[row], kJacobianTol)) + for (const bool reactive : {false, true}) { - std::cout << "REECB Jacobian row " << row - << " mismatch between dependency tracking and Enzyme for " - << test_case.label << '\n'; - success = false; - } - } + for (const bool p_priority : {false, true}) + { + if (pf && reactive && !voltage) + { + continue; + } - if (dependency_jacobian.size() != index(Vars::MAXIMUM)) - { - continue; - } + auto data = makeJacobianData(); + data.parameters[Params::PfFlag] = pf; + data.parameters[Params::VFlag] = voltage; + data.parameters[Params::QFlag] = reactive; + data.parameters[Params::Pqflag] = p_priority; + + const auto dependency = dependencyTrackingJacobian(data, alpha, success); + const auto bus_vr = index(Vars::MAXIMUM); + const auto bus_vi = bus_vr + 1; + const auto pe_column = bus_vi + 1 + index(Ext::PE); + const auto qgen_column = bus_vi + 1 + index(Ext::QGEN); + const auto qext_column = bus_vi + 1 + index(Ext::QEXT); + const auto pfaref_column = bus_vi + 1 + index(Ext::PFAREF); + const auto pref_column = bus_vi + 1 + index(Ext::PREF); + + success *= derivativeMatches(dependency, Vars::VMEAS, Vars::VMEAS, -5.7, "VMEAS diagonal"); + success *= derivativeMatches(dependency, Vars::VMEAS, Vars::VT, 5.0, "VMEAS-VT"); + success *= derivativeMatches(dependency, Vars::PMEAS, Vars::PMEAS, -3.2, "PMEAS diagonal"); + success *= derivativeMatches(dependency, Vars::PMEAS, pe_column, 5.0, "PMEAS-PE"); + success *= derivativeMatches(dependency, Vars::XPIQ, Vars::XPIQ, -alpha, "XPIQ diagonal"); + success *= derivativeMatches(dependency, Vars::XPIV, Vars::XPIV, -alpha, "XPIV diagonal"); + success *= derivativeMatches(dependency, Vars::QV, Vars::QV, -alpha - (reactive ? 0.0 : 1.0 / 0.3), "QV diagonal"); // Tiq = 0.3 + success *= derivativeMatches(dependency, Vars::PORD, Vars::PORD, -4.7, "PORD diagonal"); + success *= derivativeMatches(dependency, Vars::PORD, pref_column, 8.0, "PORD-PREF"); + success *= derivativeMatches(dependency, Vars::VT, Vars::VT, -2.0, "VT diagonal"); + success *= derivativeMatches(dependency, Vars::VT, bus_vr, 1.8, "VT-Vr"); + success *= derivativeMatches(dependency, Vars::VT, bus_vi, 0.8, "VT-Vi"); + success *= derivativeMatches(dependency, Vars::ILMAX, Vars::ILMAX, -2.4, "ILMAX diagonal"); + success *= derivativeMatches(dependency, Vars::IQCMD, Vars::IQCMD, -2.0, "IQCMD diagonal"); + success *= derivativeMatches(dependency, Vars::IPCMD, Vars::IPCMD, -2.0, "IPCMD diagonal"); + success *= derivativeMatches(dependency, Vars::IPCMD, Vars::PORD, 1.0, "IPCMD-PORD"); + success *= derivativeMatches(dependency, Vars::IPCMD, Vars::VMEAS, -0.5, "IPCMD-VMEAS"); + success *= derivativeMatches(dependency, Vars::IQCMD, Vars::XPIV, reactive ? 1.0 : 0.0, "IQCMD-XPIV selector path"); + success *= derivativeMatches(dependency, Vars::IQCMD, Vars::QV, reactive ? 0.0 : 1.0, "IQCMD-QV selector path"); + success *= derivativeMatches(dependency, Vars::XPIQ, qgen_column, reactive && voltage ? -0.8 : 0.0, "XPIQ-QGEN selector path"); + + // The direct-voltage coefficient carries no power-base factor, + // while the cascaded path converts the reference to component base. + RealT xpiv_qext = 0.0; + if (reactive) + { + xpiv_qext = voltage ? (pf ? 0.0 : 0.6) : 0.5; + } + success *= derivativeMatches(dependency, Vars::XPIV, qext_column, xpiv_qext, "XPIV-QEXT selector path"); + success *= derivativeMatches(dependency, Vars::QV, qext_column, !reactive && !pf ? 20.0 / 3.0 : 0.0, "QV-QEXT selector path"); + success *= derivativeMatches(dependency, Vars::QV, pfaref_column, !reactive && pf ? 25.0 / 3.0 : 0.0, "QV-PFAREF selector path"); - // Fixed derivatives at setAnswerKeyState()/setAnswerKeyInputs(). The - // component/system power-base ratio is two in makeDynamicData(). - RealT qext_derivative = 2.0; - RealT pfaref_derivative = 0.0; - if (test_case.pf) - { - qext_derivative = 0.0; - pfaref_derivative = 0.5625630197756407; - } + if (p_priority) + { + success *= derivativeMatches(dependency, Vars::ILMAX, Vars::IPCMD, -1.6, "P-priority current-circle column"); + success *= derivativeMatches(dependency, Vars::ILMAX, Vars::IQCMD, 0.0, "P-priority absent current-circle column"); + } + else + { + success *= derivativeMatches(dependency, Vars::ILMAX, Vars::IQCMD, -0.8, "Q-priority current-circle column"); + success *= derivativeMatches(dependency, Vars::ILMAX, Vars::IPCMD, 0.0, "Q-priority absent current-circle column"); + } - RealT vpiq_derivative = 0.0; - RealT qref_derivative = 1.0; - if (test_case.voltage) - { - vpiq_derivative = 1.0; - qref_derivative = 0.0; +#ifdef GRIDKIT_ENABLE_ENZYME + const auto enzyme = enzymeJacobian(data, alpha, success); + success *= jacobiansMatch(dependency, enzyme, index(Vars::MAXIMUM) + 2 + index(Ext::MAXIMUM)); +#endif + } + } } + } - RealT iqbase_derivative = 0.0; - RealT qv_derivative = 1.0; - if (test_case.reactive) - { - iqbase_derivative = 1.0; - qv_derivative = 0.0; - } + for (const bool p_priority : {false, true}) + { + auto data = makeJacobianData(); + data.parameters[Params::Pqflag] = p_priority; - RealT iqcirc_ipcmd_derivative = 0.0; - RealT ipcirc_iqcmd_derivative = -2.0; - if (test_case.p_priority) - { - iqcirc_ipcmd_derivative = -3.2; - ipcirc_iqcmd_derivative = 0.0; - } + const auto dependency = dependencyTrackingJacobian(data, alpha, success, -1.2); + success *= derivativeMatches(dependency, Vars::ILMAX, Vars::ILMAX, -2.4, "negative ILMAX continuation"); +#ifdef GRIDKIT_ENABLE_ENZYME + const auto enzyme = enzymeJacobian(data, alpha, success, -1.2); + success *= jacobiansMatch(dependency, enzyme, index(Vars::MAXIMUM) + 2 + index(Ext::MAXIMUM)); +#endif + } - success *= dependencyMatches(dependency_jacobian, - Vars::QREF, - Ext::QEXT, - qext_derivative, - test_case.label); - success *= dependencyMatches(dependency_jacobian, - Vars::QREF, - Ext::PFAREF, - pfaref_derivative, - test_case.label); - success *= dependencyMatches(dependency_jacobian, - Vars::EPIV, - Vars::VPIQ, - vpiq_derivative, - test_case.label); - success *= dependencyMatches(dependency_jacobian, - Vars::EPIV, - Vars::QREF, - qref_derivative, - test_case.label); - success *= dependencyMatches(dependency_jacobian, - Vars::IQRAW, - Vars::IQBASE, - iqbase_derivative, - test_case.label); - success *= dependencyMatches(dependency_jacobian, - Vars::IQRAW, - Vars::QV, - qv_derivative, - test_case.label); - success *= dependencyMatches(dependency_jacobian, - Vars::IQCIRC, - Vars::IPCMD, - iqcirc_ipcmd_derivative, - test_case.label); - success *= dependencyMatches(dependency_jacobian, - Vars::IPCIRC, - Vars::IQCMD, - ipcirc_iqcmd_derivative, - test_case.label); - success *= dependencyMatches(dependency_jacobian, - Vars::IQCMD, - Vars::IQCMD, - -2.0, - test_case.label); - success *= dependencyMatches(dependency_jacobian, - Vars::IPCMD, - Vars::IPCMD, - -2.0, - test_case.label); + // The selector sweep zeroes kqv because the answer-key deadband tails + // sit above kTol there. This configuration exercises the injection + // derivative on its own: with QFlag = 0 the only VMEAS dependence of + // the IQCMD row is iqv, evaluated on the deadband passthrough side + // strictly inside the injection limits, where the chain collapses to + // d(iqv)/d(VMEAS) = -kqv. + { + auto data = makeJacobianData(); + data.parameters[Params::QFlag] = false; + data.parameters[Params::kqv] = 1.0; + data.parameters[Params::dbd1] = -0.2; + data.parameters[Params::dbd2] = 0.25; + data.parameters[Params::Vref0] = 1.5; + + const auto dependency = dependencyTrackingJacobian(data, alpha, success); + success *= derivativeMatches(dependency, Vars::IQCMD, Vars::VMEAS, -1.0, "IQCMD-VMEAS injection path"); + success *= derivativeMatches(dependency, Vars::IQCMD, Vars::QV, 1.0, "IQCMD-QV alongside injection"); +#ifdef GRIDKIT_ENABLE_ENZYME + const auto enzyme = enzymeJacobian(data, alpha, success); + success *= jacobiansMatch(dependency, enzyme, index(Vars::MAXIMUM) + 2 + index(Ext::MAXIMUM)); +#endif } return success.report(__func__); } -#endif private: - using Params = PhasorDynamics::Converter::ReecbParameters; - using Vars = PhasorDynamics::Converter::ReecbInternalVariables; - using Ext = PhasorDynamics::Converter::ReecbExternalVariables; - using Mon = PhasorDynamics::Converter::ReecbMonitorableVariables; - using Data = PhasorDynamics::Converter::ReecbData; + using Params = PhasorDynamics::Converter::ReecbParameters; + using Vars = PhasorDynamics::Converter::ReecbInternalVariables; + using Ext = PhasorDynamics::Converter::ReecbExternalVariables; + using Mon = PhasorDynamics::Converter::ReecbMonitorableVariables; + using Data = PhasorDynamics::Converter::ReecbData; + using ReecbT = PhasorDynamics::Converter::Reecb; + using DependencyMap = DependencyTracking::Variable::DependencyMap; + + struct ExpectedResidual + { + Vars row; + const char* name; + RealT value; + }; static constexpr size_t index(Vars variable) { @@ -934,25 +1234,6 @@ namespace GridKit return static_cast(variable); } - /// A model-vector row paired with an expected value. Rows are converted - /// from `ReecbInternalVariables`, so the enum remains the single ordering - /// contract shared with the implementation and README. - using Row = std::pair; - using Rows = std::initializer_list; - using ReecbT = PhasorDynamics::Converter::Reecb; - - /// A driven input value paired with the result it should produce. Kept - /// distinct from `Row`, whose first member is a vector position. - struct DrivenCase - { - RealT input; - RealT expected; - }; - - /// Owns the terminal bus, REECB, assigned command nodes, and attached - /// input nodes. Signal storage is declared before the model so every - /// referenced node outlives REECB. Copying would invalidate the model and - /// signal-node pointers. template class Fixture { @@ -960,35 +1241,31 @@ namespace GridKit std::array input_values_{}; std::array input_indices_{}; std::array, index(Ext::MAXIMUM)> input_nodes_{}; - - PhasorDynamics::SignalNode iqcmd_node_; - PhasorDynamics::SignalNode ipcmd_node_; + PhasorDynamics::SignalNode iqcmd_node_; + PhasorDynamics::SignalNode ipcmd_node_; + bool commands_assigned_{true}; public: - explicit Fixture(const Data& data, - RealT vr = 1.0, - RealT vi = 0.0, - RealT system_va_base = 100.0e6) - : bus(static_cast(vr), static_cast(vi)), - reecb(&bus, data) + explicit Fixture(const Data& data, RealT vr = 1.0, RealT vi = 0.0, RealT system_va_base = 100.0e6, bool assign_commands = true) + : commands_assigned_(assign_commands), bus(static_cast(vr), static_cast(vi)), reecb(&bus, data) { reecb.setSystemBase(60.0, system_va_base); - reecb.getSignals().template assignSignalNode(&iqcmd_node_); - reecb.getSignals().template assignSignalNode(&ipcmd_node_); + if (commands_assigned_) + { + reecb.getSignals().template assignSignalNode(&iqcmd_node_); + reecb.getSignals().template assignSignalNode(&ipcmd_node_); + } } Fixture(const Fixture&) = delete; Fixture& operator=(const Fixture&) = delete; - /// Attach fixture-owned storage to every external input. - void attachAllInputs(RealT initial_value = 0.0) + void attachAllInputs(RealT value = 0.0) { - const IdxT external_index_base = reecb.size() + bus.size(); - for (size_t port = 0; port < index(Ext::MAXIMUM); ++port) { - input_values_[port] = static_cast(initial_value); - input_indices_[port] = external_index_base + static_cast(port); + input_values_[port] = static_cast(value); + input_indices_[port] = reecb.size() + bus.size() + static_cast(port); input_nodes_[port].set(&input_values_[port], &input_indices_[port]); } @@ -1000,42 +1277,47 @@ namespace GridKit signals.template attachSignalNode(&input_nodes_[index(Ext::PREF)]); } - /// Seed the assigned command nodes on the system base. - void seedCommands(RealT iqcmd, RealT ipcmd) + void setCommands(RealT iqcmd, RealT ipcmd) { - iqcmd_node_.init(static_cast(iqcmd)); - ipcmd_node_.init(static_cast(ipcmd)); + if (commands_assigned_) + { + iqcmd_node_.init(static_cast(iqcmd)); + ipcmd_node_.init(static_cast(ipcmd)); + } + else + { + auto* y = reecb.y().getData(); + y[index(Vars::IQCMD)] = static_cast(iqcmd); + y[index(Vars::IPCMD)] = static_cast(ipcmd); + reecb.y().setDataUpdated(); + } } - /// Everything REECB initialization requires: allocation, verification, - /// an initialized terminal bus, and initialized command nodes. bool prepare(RealT iqcmd, RealT ipcmd) { - const bool success = (bus.allocate() == 0) && (reecb.allocate() == 0) - && (reecb.verify() == 0) && (bus.initialize() == 0); - if (!success) + const bool ready = (bus.allocate() == 0) && (reecb.allocate() == 0) + && (reecb.verify() == 0) && (bus.initialize() == 0); + if (!ready) { std::cout << "REECB fixture preparation failed\n"; return false; } - - seedCommands(iqcmd, ipcmd); + setCommands(iqcmd, ipcmd); return true; } - /// prepare() plus successful REECB initialization. bool initialize(RealT iqcmd, RealT ipcmd) { if (!prepare(iqcmd, ipcmd)) { return false; } - if (reecb.initialize() != 0) + if (reecb.initialize() == 0) { - std::cout << "REECB initialization failed\n"; - return false; + return true; } - return true; + std::cout << "REECB initialization failed\n"; + return false; } int evaluate() @@ -1045,32 +1327,29 @@ namespace GridKit T iqcmd() const { - return iqcmd_node_.read(); + return commands_assigned_ ? iqcmd_node_.read() : reecb.y().getData()[index(Vars::IQCMD)]; } T ipcmd() const { - return ipcmd_node_.read(); + return commands_assigned_ ? ipcmd_node_.read() : reecb.y().getData()[index(Vars::IPCMD)]; } - T& input(Ext port) + T& input(Ext variable) { - return input_values_[index(port)]; + return input_values_[index(variable)]; } - IdxT inputIndex(Ext port) const + IdxT inputIndex(Ext variable) const { - return input_indices_[index(port)]; + return input_indices_[index(variable)]; } PhasorDynamics::Bus bus; PhasorDynamics::Converter::Reecb reecb; }; - static constexpr RealT kStateVr = 0.9; - static constexpr RealT kStateVi = 0.4; - - Data makeMinimalData() const + Data makeData() const { Data data; data.device_class = "Reecb"; @@ -1079,51 +1358,6 @@ namespace GridKit data.monitored_variables.insert(Mon::ipcmd); data.monitored_variables.insert(Mon::vmeas); data.monitored_variables.insert(Mon::pmeas); - return data; - } - - Data makeExplicitDefaultData() const - { - auto data = makeMinimalData(); - - // These are the documented defaults. Vref0 is the terminal-voltage - // fallback for the probe bus used by defaultsMatchDocumentedValues(). - data.parameters[Params::mva] = 100.0; - data.parameters[Params::PfFlag] = false; - data.parameters[Params::VFlag] = false; - data.parameters[Params::QFlag] = false; - data.parameters[Params::Pqflag] = false; - data.parameters[Params::Trv] = 0.02; - data.parameters[Params::Tp] = 0.0; - data.parameters[Params::Vref0] = 0.9848857801796105; - data.parameters[Params::Vdip] = 0.85; - data.parameters[Params::Vup] = 1.15; - data.parameters[Params::dbd1] = 0.0; - data.parameters[Params::dbd2] = 0.0; - data.parameters[Params::kqv] = 5.0; - data.parameters[Params::Iql1] = -1.1; - data.parameters[Params::Iqh1] = 1.1; - data.parameters[Params::Qmax] = 0.436; - data.parameters[Params::Qmin] = -0.436; - data.parameters[Params::Kqp] = 0.0; - data.parameters[Params::Kqi] = 0.1; - data.parameters[Params::Vmax] = 1.1; - data.parameters[Params::Vmin] = 0.9; - data.parameters[Params::Kvp] = 18.0; - data.parameters[Params::Kvi] = 5.0; - data.parameters[Params::Tiq] = 0.02; - data.parameters[Params::Tpord] = 0.02; - data.parameters[Params::dPmax] = 99.0; - data.parameters[Params::dPmin] = -99.0; - data.parameters[Params::Pmax] = 1.0; - data.parameters[Params::Pmin] = 0.0; - data.parameters[Params::Imax] = 1.3; - return data; - } - - Data makeData() const - { - auto data = makeMinimalData(); data.parameters[Params::mva] = 100.0; data.parameters[Params::PfFlag] = false; @@ -1169,8 +1403,6 @@ namespace GridKit data.parameters[Params::Trv] = 0.2; data.parameters[Params::Tp] = 0.4; data.parameters[Params::Vref0] = 1.02; - data.parameters[Params::Vdip] = 0.7; - data.parameters[Params::Vup] = 1.2; data.parameters[Params::dbd1] = -0.02; data.parameters[Params::dbd2] = 0.03; data.parameters[Params::kqv] = 2.0; @@ -1194,7 +1426,16 @@ namespace GridKit return data; } - /// The external inputs the residual answer key is evaluated against. + Data makeJacobianData() const + { + auto data = makeDynamicData(); + data.parameters[Params::Vref0] = std::sqrt(0.97); + data.parameters[Params::kqv] = 0.0; + data.parameters[Params::Qmin] = -2.0; + data.parameters[Params::Qmax] = 2.0; + return data; + } + template void setAnswerKeyInputs(Fixture& fixture) const { @@ -1205,140 +1446,74 @@ namespace GridKit fixture.input(Ext::PREF) = 0.35; } - /// The rich state shared by the residual answer key and the Jacobian - /// comparison. Every row is distinct so a swapped index cannot pass. template void setAnswerKeyState(PhasorDynamics::Converter::Reecb& reecb) const { - setState(reecb, - {{index(Vars::VMEAS), 0.95}, - {index(Vars::PMEAS), 0.55}, - {index(Vars::XPIQ), 0.10}, - {index(Vars::XPIV), -0.05}, - {index(Vars::QV), 0.30}, - {index(Vars::PORD), 0.65}, - {index(Vars::VT), 1.00}, - {index(Vars::VMEASSAFE), 0.96}, - {index(Vars::SDIP), 0.80}, - {index(Vars::VERR), 0.04}, - {index(Vars::IQV), 0.15}, - {index(Vars::QREF), 0.35}, - {index(Vars::EQ), 0.20}, - {index(Vars::VPIQ), 0.80}, - {index(Vars::EPIV), -0.10}, - {index(Vars::FPORD), 0.30}, - {index(Vars::RPORD), 0.25}, - {index(Vars::IQCIRC), 1.10}, - {index(Vars::IPCIRC), 1.20}, - {index(Vars::IQMAX), 1.00}, - {index(Vars::IPMAX), 1.30}, - {index(Vars::IQBASE), 0.20}, - {index(Vars::IQRAW), 0.40}, - {index(Vars::IQCMD), 0.25}, - {index(Vars::IPCMD), 0.40}}); - setDerivative(reecb, - {{index(Vars::VMEAS), 0.01}, - {index(Vars::PMEAS), -0.02}, - {index(Vars::XPIQ), 0.03}, - {index(Vars::XPIV), -0.04}, - {index(Vars::QV), 0.05}, - {index(Vars::PORD), -0.06}}); + auto* y = reecb.y().getData(); + y[index(Vars::VMEAS)] = 0.95; + y[index(Vars::PMEAS)] = 0.55; + y[index(Vars::XPIQ)] = 0.10; + y[index(Vars::XPIV)] = -0.05; + y[index(Vars::QV)] = 0.30; + y[index(Vars::PORD)] = 0.65; + y[index(Vars::VT)] = 1.00; + y[index(Vars::ILMAX)] = 1.20; + y[index(Vars::IQCMD)] = 0.25; + y[index(Vars::IPCMD)] = 0.40; + + auto* yp = reecb.yp().getData(); + yp[index(Vars::VMEAS)] = 0.01; + yp[index(Vars::PMEAS)] = -0.02; + yp[index(Vars::XPIQ)] = 0.03; + yp[index(Vars::XPIV)] = -0.03; + yp[index(Vars::QV)] = 0.05; + yp[index(Vars::PORD)] = -0.06; + reecb.y().setDataUpdated(); + reecb.yp().setDataUpdated(); } - /// Omitting every optional parameter must give exactly the model built - /// from the defaults the README documents, at rest and under load. - bool defaultsMatchDocumentedValues() const + template + void setControlState(PhasorDynamics::Converter::Reecb& reecb) const { - auto implicit_data = makeMinimalData(); - implicit_data.parameters[Params::mva] = 100.0; - - Fixture implicit_defaults(implicit_data, 0.9, 0.4); - Fixture explicit_defaults(makeExplicitDefaultData(), 0.9, 0.4); - implicit_defaults.attachAllInputs(); - explicit_defaults.attachAllInputs(); - - bool success = implicit_defaults.initialize(0.1, 0.2) - && explicit_defaults.initialize(0.1, 0.2); - if (!success) - { - std::cout << "REECB documented-default comparison failed to initialize\n"; - return false; - } - - success *= (implicit_defaults.evaluate() == 0); - success *= (explicit_defaults.evaluate() == 0); - success *= vectorUnchanged(implicit_defaults.reecb.y(), - copyVector(explicit_defaults.reecb.y()), - "documented-default state"); - success *= vectorUnchanged(implicit_defaults.reecb.yp(), - copyVector(explicit_defaults.reecb.yp()), - "documented-default derivative"); - success *= vectorUnchanged(implicit_defaults.reecb.getResidual(), - copyVector(explicit_defaults.reecb.getResidual()), - "documented-default residual"); - - setAnswerKeyInputs(implicit_defaults); - setAnswerKeyInputs(explicit_defaults); - setAnswerKeyState(implicit_defaults.reecb); - setAnswerKeyState(explicit_defaults.reecb); - success *= (implicit_defaults.evaluate() == 0); - success *= (explicit_defaults.evaluate() == 0); - success *= vectorUnchanged(implicit_defaults.reecb.getResidual(), - copyVector(explicit_defaults.reecb.getResidual()), - "documented-default dynamic residual"); - return success; + auto* y = reecb.y().getData(); + y[index(Vars::VMEAS)] = 1.0; + y[index(Vars::PMEAS)] = 0.0; + y[index(Vars::XPIQ)] = 0.0; + y[index(Vars::XPIV)] = 0.0; + y[index(Vars::QV)] = 0.0; + y[index(Vars::PORD)] = 0.5; + y[index(Vars::VT)] = 1.0; + y[index(Vars::ILMAX)] = 1.4; + reecb.yp().setToConst(static_cast(0.0)); + reecb.y().setDataUpdated(); } - bool invalidParameterCase(PhasorDynamics::Bus& bus, - Params parameter, - RealT value) const + template + bool invalidParameterCase(PhasorDynamics::Bus& bus, Params parameter, ValueT value) const { auto data = makeData(); data.parameters[parameter] = value; PhasorDynamics::Converter::Reecb model(&bus, data); + model.setSystemBase(60.0, 100.0e6); return model.verify() > 0; } template bool unlinkedSignalRejected(PhasorDynamics::Bus& bus) const { - PhasorDynamics::SignalNode unlinked_node; + PhasorDynamics::SignalNode node; PhasorDynamics::Converter::Reecb model(&bus, makeData()); - model.getSignals().template attachSignalNode(&unlinked_node); + model.setSystemBase(60.0, 100.0e6); + model.getSignals().template attachSignalNode(&node); return model.verify() > 0; } - template - std::vector copyVector(const VectorT& vector) const - { - const auto* values = vector.getData(); - return std::vector(values, - values + static_cast(vector.getSize())); - } - - /// Every row of a vector still holds its snapshot value. - template - bool vectorUnchanged(const VectorT& vector, - const std::vector& snapshot, - const char* what) const + bool initializationRejectedAtomically(const Data& data, RealT iqcmd, RealT ipcmd, RealT voltage, const char* label, RealT pe = std::numeric_limits::quiet_NaN(), RealT qgen = std::numeric_limits::quiet_NaN()) const { - bool success = true; - const auto* values = vector.getData(); - for (size_t i = 0; i < snapshot.size(); ++i) - { - success &= rowMatches(static_cast(values[i]), snapshot[i], what, i, "changed"); - } - return success; - } - - bool initializationRejectedAtomically(const Data& data, - RealT iqcmd, - RealT ipcmd, - RealT terminal_voltage, - const char* label) const - { - Fixture fixture(data, terminal_voltage); - fixture.attachAllInputs(77.0); + Fixture fixture(data, voltage); + fixture.attachAllInputs(17.0); + fixture.input(Ext::PE) = std::isnan(pe) ? ipcmd * voltage : pe; + fixture.input(Ext::QGEN) = std::isnan(qgen) ? iqcmd * voltage : qgen; if (!fixture.prepare(iqcmd, ipcmd)) { return false; @@ -1346,313 +1521,236 @@ namespace GridKit auto* y = fixture.reecb.y().getData(); auto* yp = fixture.reecb.yp().getData(); - for (size_t i = 0; i < static_cast(fixture.reecb.y().getSize()); ++i) + for (size_t row = 0; row < index(Vars::MAXIMUM); ++row) { - y[i] = 0.125 + 0.01 * static_cast(i); - yp[i] = -0.25 - 0.01 * static_cast(i); + y[row] = 0.125 + 0.01 * static_cast(row); + yp[row] = -0.25 - 0.01 * static_cast(row); } - fixture.seedCommands(iqcmd, ipcmd); + fixture.setCommands(iqcmd, ipcmd); fixture.reecb.y().setDataUpdated(); fixture.reecb.yp().setDataUpdated(); - const auto y_before = copyVector(fixture.reecb.y()); - const auto yp_before = copyVector(fixture.reecb.yp()); - - bool success = true; - if (fixture.reecb.initialize() == 0) - { - std::cout << "Expected initialization rejection: " << label << "\n"; - success = false; - } - - success *= scalarMatches(fixture.iqcmd(), iqcmd, "rejected iqcmd preservation"); - success *= scalarMatches(fixture.ipcmd(), ipcmd, "rejected ipcmd preservation"); + const auto y_before = snapshot(fixture.reecb.y()); + const auto yp_before = snapshot(fixture.reecb.yp()); + const auto bus_before = snapshot(fixture.bus.y()); + std::array input_before{}; for (size_t port = 0; port < index(Ext::MAXIMUM); ++port) { - success &= rowMatches(fixture.input(static_cast(port)), - 77.0, - "external input", - port, - "changed"); + input_before[port] = fixture.input(static_cast(port)); } - success *= vectorUnchanged(fixture.reecb.y(), y_before, "state"); - success *= vectorUnchanged(fixture.reecb.yp(), yp_before, "derivative"); - return success; - } - /// Write state rows and publish the update, folding in the - /// setDataUpdated() that a hand-written write block has to remember. - template - void setState(PhasorDynamics::Converter::Reecb& reecb, Rows rows) const - { - auto* y = reecb.y().getData(); - for (const auto& [row, value] : rows) + if (fixture.reecb.initialize() == 0) { - y[row] = static_cast(value); + std::cout << "Expected REECB initialization rejection: " << label << '\n'; + return false; } - reecb.y().setDataUpdated(); - } - /// setState() for the derivative vector. - template - void setDerivative(PhasorDynamics::Converter::Reecb& reecb, Rows rows) const - { - auto* yp = reecb.yp().getData(); - for (const auto& [row, value] : rows) + bool unchanged = vectorUnchanged(fixture.reecb.y(), y_before, "state") + && vectorUnchanged(fixture.reecb.yp(), yp_before, "derivative") + && vectorUnchanged(fixture.bus.y(), bus_before, "bus"); + for (size_t port = 0; port < index(Ext::MAXIMUM); ++port) { - yp[row] = static_cast(value); + unchanged &= (fixture.input(static_cast(port)) == input_before[port]); } - reecb.yp().setDataUpdated(); + return unchanged; } - /// Compare one vector row against its expected value. Every row check in - /// this suite reports through here, so failures share one format. Rows - /// are named by the `ReecbInternalVariables` position the expectation was - /// written with, leaving no parallel name string to maintain. - static bool rowMatches(RealT actual, - RealT expected, - const char* what, - size_t row, - const char* context, - RealT tolerance = kBehaviorTol) + template + std::vector snapshot(const VectorT& vector) const { - if (isEqual(actual, expected, tolerance)) - { - return true; - } - std::cout << "REECB " << what << " row " << row << ' ' << context - << " mismatch: " << std::setprecision(16) << actual - << " != " << expected << '\n'; - return false; + const auto* values = vector.getData(); + return std::vector(values, values + static_cast(vector.getSize())); } - /// Check selected rows of a model vector against expected values. template - bool rowsMatch(const VectorT& vector, - const Row* rows, - size_t count, - const char* what, - const char* context, - RealT tolerance = kBehaviorTol) const + bool vectorUnchanged(const VectorT& vector, const std::vector& expected, const char* label) const { bool success = true; const auto* values = vector.getData(); - for (size_t i = 0; i < count; ++i) + for (size_t row = 0; row < expected.size(); ++row) { - const auto& [row, expected] = rows[i]; - - success &= rowMatches(static_cast(values[row]), - expected, - what, - row, - context, - tolerance); + if (values[row] != expected[row]) + { + std::cout << "REECB " << label << " row " << row << " changed during rejected initialization\n"; + success = false; + } } return success; } - bool residualsMatch(const ReecbT& reecb, - Rows rows, - const char* context = "", - RealT tolerance = kBehaviorTol) const - { - return rowsMatch(reecb.getResidual(), - rows.begin(), - rows.size(), - "residual", - context, - tolerance); - } - - template - bool residualsMatch(const ReecbT& reecb, - const std::array& rows, - const char* context = "", - RealT tolerance = kBehaviorTol) const - { - return rowsMatch(reecb.getResidual(), rows.data(), size, "residual", context, tolerance); - } - - bool stateMatches(const ReecbT& reecb, Rows rows, const char* context = "") const - { - return rowsMatch(reecb.y(), rows.begin(), rows.size(), "state", context); - } - - /// The model sits at a steady state: every residual and every derivative - /// is zero. bool allResidualsZero(const ReecbT& reecb) const { bool success = true; const auto* f = reecb.getResidual().getData(); const auto* yp = reecb.yp().getData(); - for (size_t row = 0; row < static_cast(reecb.getResidual().getSize()); ++row) - { - success &= rowMatches(static_cast(f[row]), - 0.0, - "residual", - row, - "at rest", - kSteadyStateTol); - success &= rowMatches(static_cast(yp[row]), - 0.0, - "derivative", - row, - "at rest", - kSteadyStateTol); + for (size_t row = 0; row < index(Vars::MAXIMUM); ++row) + { + success &= scalarMatches(f[row], 0.0, "steady residual"); + success &= scalarMatches(yp[row], 0.0, "steady derivative"); } return success; } - bool scalarMatches(ScalarT actual, - ScalarT expected, - const char* label, - ScalarT tolerance = kBehaviorTol) const + bool scalarMatches(RealT actual, RealT expected, const char* label) const { - if (isEqual(actual, expected, tolerance)) + if (isEqual(actual, expected, kTol)) { return true; } - std::cout << label << " mismatch: " << std::setprecision(16) << actual - << " != " << expected << "\n"; + std::cout << "REECB " << label << " mismatch: " + << std::setprecision(std::numeric_limits::max_digits10) + << actual << " != " << expected << '\n'; return false; } void noteExpectedLogs(const char* message) const { - const auto previous_verbosity = Log::verbosity(); + const auto verbosity = Log::verbosity(); Log::setVerbosity(Log::Verbosity::EVERYTHING); - Log::misc() << message << "\n"; - Log::setVerbosity(previous_verbosity); - } - -#ifdef GRIDKIT_ENABLE_ENZYME - using DependencyMap = DependencyTracking::Variable::DependencyMap; - - static constexpr size_t externalVariableIndex(Ext variable) - { - constexpr size_t terminal_bus_size = 2; - return index(Vars::MAXIMUM) + terminal_bus_size + index(variable); + Log::misc() << message << '\n'; + Log::setVerbosity(verbosity); } - bool dependencyMatches(const std::vector& jacobian, - Vars row, - size_t column, - RealT expected, - const char* context) const - { - const auto& dependencies = jacobian[index(row)]; - const auto entry = dependencies.find(column); - RealT actual = 0.0; - if (entry != dependencies.end()) - { - actual = entry->second; - } - if (isEqual(actual, expected, kJacobianTol)) - { - return true; - } - - std::cout << "REECB Jacobian derivative (" << index(row) << ", " << column - << ") mismatch for " << context << ": " << std::setprecision(16) - << actual << " != " << expected << '\n'; - return false; - } - - bool dependencyMatches(const std::vector& jacobian, - Vars row, - Vars column, - RealT expected, - const char* context) const - { - return dependencyMatches(jacobian, row, index(column), expected, context); - } - - bool dependencyMatches(const std::vector& jacobian, - Vars row, - Ext column, - RealT expected, - const char* context) const + template + void setJacobianState(Fixture& fixture, RealT ilmax = 1.2) const { - return dependencyMatches(jacobian, - row, - externalVariableIndex(column), - expected, - context); + fixture.input(Ext::PE) = 0.25; + fixture.input(Ext::QGEN) = 0.5; + fixture.input(Ext::QEXT) = 0.5; + fixture.input(Ext::PFAREF) = std::atan(2.0); + fixture.input(Ext::PREF) = 0.25; + + auto* y = fixture.reecb.y().getData(); + y[index(Vars::VMEAS)] = 1.0; + y[index(Vars::PMEAS)] = 0.5; + y[index(Vars::XPIQ)] = 1.0; + y[index(Vars::XPIV)] = 0.0; + y[index(Vars::QV)] = 0.0; + y[index(Vars::PORD)] = 0.5; + y[index(Vars::VT)] = 1.0; + y[index(Vars::ILMAX)] = ilmax; + y[index(Vars::IQCMD)] = 0.1; + y[index(Vars::IPCMD)] = 0.2; + fixture.reecb.yp().setToConst(static_cast(0.0)); + fixture.reecb.y().setDataUpdated(); } - void numberVariables(Fixture& fixture) const + void numberVariables(Fixture& fixture, RealT alpha) const { auto* y = fixture.reecb.y().getData(); auto* yp = fixture.reecb.yp().getData(); auto* bus_y = fixture.bus.y().getData(); - - const auto model_size = static_cast(fixture.reecb.size()); - for (size_t i = 0; i < model_size; ++i) + for (size_t row = 0; row < index(Vars::MAXIMUM); ++row) { - y[i].setVariableNumber(i); - yp[i].setVariableNumber(i); + y[row].setVariableNumber(row); + yp[row].setVariableNumber(row); + yp[row].scaleDependencies(alpha); } - for (size_t i = 0; i < static_cast(fixture.bus.size()); ++i) + for (size_t row = 0; row < static_cast(fixture.bus.size()); ++row) { - bus_y[i].setVariableNumber(model_size + i); + bus_y[row].setVariableNumber(index(Vars::MAXIMUM) + row); } for (size_t port = 0; port < index(Ext::MAXIMUM); ++port) { - const auto variable = static_cast(port); - fixture.input(variable).setVariableNumber(fixture.inputIndex(variable)); + fixture.input(static_cast(port)).setVariableNumber(fixture.inputIndex(static_cast(port))); } - fixture.reecb.y().setDataUpdated(); fixture.reecb.yp().setDataUpdated(); fixture.bus.y().setDataUpdated(); } - std::vector dependencyTrackingJacobian( - const Data& data, - TestStatus& success) const + std::vector dependencyTrackingJacobian(const Data& data, RealT alpha, TestStatus& success, RealT ilmax = 1.2) const { using DepVar = DependencyTracking::Variable; - - Fixture fixture(data, kStateVr, kStateVi); + Fixture fixture(data, 0.9, 0.4); fixture.attachAllInputs(); - success *= fixture.initialize(0.1, 0.2); - setAnswerKeyInputs(fixture); - setAnswerKeyState(fixture.reecb); - numberVariables(fixture); + fixture.input(Ext::PE) = 0.2; + success *= fixture.initialize(0.0, 0.2); + setJacobianState(fixture, ilmax); + numberVariables(fixture, alpha); success *= (fixture.evaluate() == 0); - const auto model_size = static_cast(fixture.reecb.size()); - std::vector rows(model_size); - const auto* f = fixture.reecb.getResidual().getData(); - for (size_t i = 0; i < model_size; ++i) + std::vector jacobian(index(Vars::MAXIMUM)); + const auto* residual = fixture.reecb.getResidual().getData(); + for (size_t row = 0; row < jacobian.size(); ++row) { - rows[i] = f[i].getDependencies(); + jacobian[row] = residual[row].getDependencies(); } - return rows; + return jacobian; } - std::vector enzymeJacobian( - const Data& data, - TestStatus& success) const +#ifdef GRIDKIT_ENABLE_ENZYME + std::vector enzymeJacobian(const Data& data, RealT alpha, TestStatus& success, RealT ilmax = 1.2) const { - Fixture fixture(data, kStateVr, kStateVi); + Fixture fixture(data, 0.9, 0.4); fixture.attachAllInputs(); - success *= fixture.initialize(0.1, 0.2); - - for (IdxT i = 0; i < fixture.bus.size(); ++i) + fixture.input(Ext::PE) = 0.2; + success *= fixture.initialize(0.0, 0.2); + for (IdxT row = 0; row < fixture.bus.size(); ++row) { - fixture.bus.setVariableIndex(i, fixture.reecb.size() + i); + fixture.bus.setVariableIndex(row, fixture.reecb.size() + row); } - - setAnswerKeyInputs(fixture); - setAnswerKeyState(fixture.reecb); - fixture.reecb.updateTime(0.0, 1.0); + setJacobianState(fixture, ilmax); + fixture.reecb.updateTime(0.0, alpha); success *= (fixture.evaluate() == 0); success *= (fixture.reecb.evaluateJacobian() == 0); success *= (fixture.reecb.constructCsr() == 0); return MapFromCsr(fixture.reecb.getCsrJacobian()); } #endif + + static RealT derivative(const std::vector& jacobian, size_t row, size_t column) + { + const auto entry = jacobian[row].find(column); + return entry == jacobian[row].end() ? 0.0 : entry->second; + } + + bool derivativeMatches(const std::vector& jacobian, Vars row, Vars column, RealT expected, const char* label) const + { + return derivativeMatches(jacobian, row, index(column), expected, label); + } + + bool derivativeMatches(const std::vector& jacobian, Vars row, size_t column, RealT expected, const char* label) const + { + const RealT actual = derivative(jacobian, index(row), column); + if (isEqual(actual, expected, kTol)) + { + return true; + } + std::cout << "REECB Jacobian " << label << " mismatch: " + << std::setprecision(std::numeric_limits::max_digits10) + << actual << " != " << expected << '\n'; + return false; + } + +#ifdef GRIDKIT_ENABLE_ENZYME + bool jacobiansMatch(const std::vector& dependency, const std::vector& enzyme, size_t columns) const + { + if (dependency.size() != enzyme.size()) + { + std::cout << "REECB Jacobian row-count mismatch\n"; + return false; + } + + bool success = true; + for (size_t row = 0; row < dependency.size(); ++row) + { + for (size_t column = 0; column < columns; ++column) + { + const RealT expected = derivative(dependency, row, column); + const RealT actual = derivative(enzyme, row, column); + if (!isEqual(actual, expected, kTol)) + { + std::cout << "REECB Jacobian (" << row << ", " << column << ") backend mismatch: " + << std::setprecision(std::numeric_limits::max_digits10) + << actual << " != " << expected << '\n'; + success = false; + } + } + } + return success; + } +#endif }; } // namespace Testing } // namespace GridKit diff --git a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp index 9d6112314..712995625 100644 --- a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp +++ b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp @@ -1,6 +1,9 @@ #include +#include #include #include +#include +#include #include #include @@ -12,8 +15,7 @@ namespace GridKit { namespace Testing { - /// Smoke test for components (single component connected to an infinite bus) - /// through the system model with the minimal constructors + /// Component smoke tests and focused integration tests through SystemModel. template class SystemSingleComponentTests { @@ -308,6 +310,35 @@ namespace GridKit TestStatus success = true; + // A missing required bus must remain missing so REECB verification can + // reject it; SystemModel must not silently substitute bus zero. + { + std::cout << "Testing missing REECB bus mapping; logged errors are expected.\n"; + auto missing_bus_model = makeReecbData(); + missing_bus_model.buses.clear(); + missing_bus_model.signal_inputs.clear(); + missing_bus_model.signal_outputs.clear(); + + PhasorDynamics::SystemModelData missing_bus_data; + missing_bus_data.bus.resize(1); + missing_bus_data.bus[0].bus_id = static_cast(0); + missing_bus_data.bus[0].bus_type = + PhasorDynamics::BusData::BusType::SLACK; + missing_bus_data.reecb.push_back(missing_bus_model); + + bool rejected = false; + try + { + PhasorDynamics::SystemModel missing_bus_system(missing_bus_data); + missing_bus_system.allocate(); + } + catch (const std::runtime_error&) + { + rejected = true; + } + success *= rejected; + } + PhasorDynamics::SystemModelData data; data.va_base = static_cast(100.0e6); data.bus.resize(1); @@ -325,8 +356,8 @@ namespace GridKit data.reecb.push_back(makeReecbData()); typename PhasorDynamics::SystemModelData::ConstantSourceT source; - source.parameters[ConstantParams::Sr] = static_cast(0.0); - source.parameters[ConstantParams::Si] = static_cast(0.0); + source.parameters[ConstantParams::Sr] = static_cast(0.25); + source.parameters[ConstantParams::Si] = static_cast(0.05); source.signal_outputs[ConstantOutputs::sr] = pe_signal_id; source.signal_outputs[ConstantOutputs::si] = qgen_signal_id; data.constant_source.push_back(source); @@ -375,28 +406,19 @@ namespace GridKit success *= signals.template getSignalNode() == system.getSignal(ipcmd_signal_id); - const auto* residual = reecb->getResidual().getData(); - for (size_t row = 0; row < static_cast(reecb->size()); ++row) - { - if (!isEqual(static_cast(residual[row]), - static_cast(0.0), - static_cast(1.0e-9))) - { - std::cout << "REECB SystemModel residual row " << row - << " is not at steady state: " << std::setprecision(16) - << residual[row] << '\n'; - success = false; - } - } + success *= residualsAreZero(*reecb, "REECB SystemModel"); + // The constant sources drive qext and pfaref with 0.25/0.05, but + // REECB overwrites attached unknown references during initialize(), + // so the published values below are its resolved setpoints. const std::array(ipcmd_signal_id)> expected_signals{ - 0.25, - 0.05, - 0.05, - 0.0, - 0.25, - 0.05, - 0.25, + 0.25, // pe from the constant source + 0.05, // qgen from the constant source + 0.05, // qext resolved by REECB + 0.0, // pfaref resolved by REECB + 0.25, // pref resolved by REECB + 0.05, // iqcmd owned by REECB + 0.25, // ipcmd owned by REECB }; for (size_t signal = 0; signal < expected_signals.size(); ++signal) { @@ -409,6 +431,7 @@ namespace GridKit // The component/system base ratio is two. Perturbing only the // system-base command therefore changes its component-base residual // by twice the perturbation. + const auto* residual = reecb->getResidual().getData(); system.getSignal(iqcmd_signal_id)->init(static_cast(0.06)); success *= system.evaluateResidual() == 0; success *= isEqual(static_cast(residual[static_cast(Vars::IQCMD)]), @@ -418,6 +441,121 @@ namespace GridKit return success.report(__func__); } + /// REGCA initializes the shared current commands and actual power + /// feedback before REECB consumes them, with mixed component/system + /// bases at off-nominal terminal voltages. Signal wiring identity for + /// the pair is covered by ReecbIntegrationTests. + TestOutcome regcaReecb() + { + using ConstantParams = PhasorDynamics::ConstantSignalSourceParameters; + using ConstantOutputs = PhasorDynamics::ConstantSignalSourceSignalOutputs; + using RegcaParams = PhasorDynamics::Converter::RegcaParameters; + using RegcaInputs = PhasorDynamics::Converter::RegcaSignalInputs; + using RegcaOutputs = PhasorDynamics::Converter::RegcaSignalOutputs; + using RegcaVars = PhasorDynamics::Converter::RegcaInternalVariables; + using ReecbParams = PhasorDynamics::Converter::ReecbParameters; + using ReecbVars = PhasorDynamics::Converter::ReecbInternalVariables; + using RegcaT = PhasorDynamics::Converter::Regca; + using ReecbT = PhasorDynamics::Converter::Reecb; + + TestStatus success = true; + + for (const RealT terminal_voltage : + {static_cast(0.9), static_cast(1.19)}) + { + PhasorDynamics::SystemModelData data; + data.va_base = static_cast(100.0e6); + data.bus.resize(1); + data.bus[0].bus_id = static_cast(1); + data.bus[0].bus_type = PhasorDynamics::BusData::BusType::SLACK; + data.bus[0].Vr0 = terminal_voltage; + data.bus[0].Vi0 = static_cast(0.0); + data.signal.resize(static_cast(ipcmd_signal_id)); + for (size_t signal = 0; signal < data.signal.size(); ++signal) + { + data.signal[signal].signal_id = pe_signal_id + static_cast(signal); + } + + auto regca_data = makeRegcaData(); + regca_data.parameters[RegcaParams::p0] = static_cast(0.25); + regca_data.parameters[RegcaParams::q0] = static_cast(0.05); + regca_data.parameters[RegcaParams::mva] = static_cast(50.0); + regca_data.signal_inputs[RegcaInputs::ipcmd] = ipcmd_signal_id; + regca_data.signal_inputs[RegcaInputs::iqcmd] = iqcmd_signal_id; + regca_data.signal_outputs[RegcaOutputs::pbranch] = pe_signal_id; + regca_data.signal_outputs[RegcaOutputs::qbranch] = qgen_signal_id; + data.regca.push_back(regca_data); + + auto reecb_data = makeReecbData(); + reecb_data.parameters[ReecbParams::QFlag] = true; + reecb_data.parameters[ReecbParams::VFlag] = true; + reecb_data.parameters[ReecbParams::Vmin] = static_cast(0.5); + reecb_data.parameters[ReecbParams::Vmax] = static_cast(1.4); + data.reecb.push_back(reecb_data); + + typename PhasorDynamics::SystemModelData::ConstantSourceT source; + source.parameters[ConstantParams::Sr] = static_cast(0.0); + source.parameters[ConstantParams::Si] = static_cast(0.0); + source.signal_outputs[ConstantOutputs::sr] = qext_signal_id; + source.signal_outputs[ConstantOutputs::si] = pfaref_signal_id; + data.constant_source.push_back(source); + source.signal_outputs.erase(ConstantOutputs::si); + source.signal_outputs[ConstantOutputs::sr] = pref_signal_id; + data.constant_source.push_back(source); + + PhasorDynamics::SystemModel system(data); + success *= system.allocate() == 0; + success *= system.verify() == 0; + for (IdxT signal_id = pe_signal_id; signal_id <= ipcmd_signal_id; ++signal_id) + { + success *= system.getSignal(signal_id)->linked(); + } + success *= system.initialize() == 0; + success *= system.tagDifferentiable() == 0; + success *= system.evaluateResidual() == 0; + success *= system.evaluateJacobian() == 0; + success *= system.size() + == static_cast(RegcaVars::MAXIMUM) + + static_cast(ReecbVars::MAXIMUM); + + auto* regca = dynamic_cast(system.getComponent(static_cast(0))); + auto* reecb = dynamic_cast(system.getComponent(static_cast(1))); + success *= regca != nullptr; + success *= reecb != nullptr; + if (regca == nullptr || reecb == nullptr) + { + continue; + } + + const RealT pe = static_cast(system.getSignal(pe_signal_id)->read()); + const RealT qgen = static_cast(system.getSignal(qgen_signal_id)->read()); + const RealT ipcmd = static_cast(system.getSignal(ipcmd_signal_id)->read()); + const RealT iqcmd = static_cast(system.getSignal(iqcmd_signal_id)->read()); + success *= isEqual(pe, static_cast(0.25), static_cast(1.0e-12)); + success *= isEqual(qgen, static_cast(0.05), static_cast(1.0e-12)); + success *= isEqual( + static_cast(reecb->y().getData()[static_cast(ReecbVars::PMEAS)]), + static_cast(0.5), + static_cast(1.0e-12)); + + if (terminal_voltage < static_cast(1.0)) + { + success *= std::abs(pe - ipcmd * terminal_voltage) + > static_cast(1.0e-6); + } + else + { + success *= std::abs(qgen - iqcmd * terminal_voltage) + > static_cast(1.0e-6); + } + + success *= residualsAreZero(*regca, "REGCA integrated"); + success *= residualsAreZero(*reecb, "REECB integrated"); + } + + return success.report(__func__); + } + TestOutcome genrou() { TestStatus success = true; @@ -540,6 +678,26 @@ namespace GridKit } private: + template + bool residualsAreZero(ComponentT& component, const char* name) const + { + const auto* residual = component.getResidual().getData(); + bool match = true; + for (size_t row = 0; row < static_cast(component.size()); ++row) + { + if (!isEqual(static_cast(residual[row]), + static_cast(0.0), + static_cast(1.0e-9))) + { + std::cout << name << " residual row " << row << " is not at steady state: " + << std::setprecision(std::numeric_limits::max_digits10) + << residual[row] << '\n'; + match = false; + } + } + return match; + } + auto makeRegcaData() -> PhasorDynamics::Converter::RegcaData { using Params = PhasorDynamics::Converter::RegcaParameters; @@ -575,7 +733,7 @@ namespace GridKit static constexpr IdxT iqcmd_signal_id = 6; static constexpr IdxT ipcmd_signal_id = 7; - auto makeReecbData() const -> PhasorDynamics::Converter::ReecbData + auto makeReecbData() -> PhasorDynamics::Converter::ReecbData { using Params = PhasorDynamics::Converter::ReecbParameters; using Buses = PhasorDynamics::Converter::ReecbBuses; diff --git a/tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp b/tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp index 8897d76a5..0af27f80e 100644 --- a/tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp @@ -2,20 +2,19 @@ int main() { - GridKit::Testing::TestingResults result; - + GridKit::Testing::TestingResults result; GridKit::Testing::ConverterReecbTests test; result += test.validation(); result += test.initializationAndSignals(); result += test.initializationDomain(); + result += test.initializationExactness(); result += test.residualEquations(); + result += test.selectorConfigurations(); + result += test.voltVarReferenceBase(); result += test.reactiveControl(); - result += test.activePowerControl(); - result += test.currentPriority(); -#ifdef GRIDKIT_ENABLE_ENZYME + result += test.activeCurrentControl(); result += test.jacobian(); -#endif return result.summary(); } diff --git a/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp b/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp index 7fe1dcbe0..c80c43190 100644 --- a/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp @@ -18,6 +18,7 @@ int main() result += test.regca(); result += test.repca(); result += test.reecb(); + result += test.regcaReecb(); result += test.genrou(); result += test.genClassical(); result += test.tgov1(); From 06f262600f39f0e184a213937156983edfc0da9b Mon Sep 17 00:00:00 2001 From: lukelowry Date: Mon, 3 Aug 2026 00:02:47 -0500 Subject: [PATCH 06/16] flags are boolean type --- .../PhasorDynamics/Converter/REECB/README.md | 68 +++++++++---------- .../PhasorDynamics/Converter/REECB/Reecb.hpp | 8 +-- .../Converter/REECB/ReecbData.hpp | 10 +-- .../Converter/REECB/ReecbImpl.hpp | 26 +++---- .../PhasorDynamics/ConverterReecbTests.hpp | 30 +++++--- tests/UnitTests/Utilities/CaseFormatTests.hpp | 4 +- 6 files changed, 77 insertions(+), 69 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/README.md b/GridKit/Model/PhasorDynamics/Converter/REECB/README.md index 39146d83c..b9ccb9379 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/README.md +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/README.md @@ -23,41 +23,42 @@ Figure 1: REECB electrical-control model. Figure courtesy of the ## Model Parameters -Symbol | Units | JSON | Description | Typical Value | Note -------------------------------------|----------|----------|---------------------------------------------------------|---------------|----- -$S^\mathrm{base}$ | [MVA] | `mva` | REECB component power base | 100.0 | System power base when omitted -$s_\mathrm{pf}$ | [binary] | `PfFlag` | Power-factor control selector | 0 | 1 = power-factor control, 0 = reactive-power control -$s_V$ | [binary] | `VFlag` | Voltage-reference selector under $s_Q=1$ | 0 | 1 = cascaded Q-PI voltage command, 0 = direct external voltage reference -$s_Q$ | [binary] | `QFlag` | Reactive-path selector | 0 | 1 = Volt/VAr PI control, 0 = reactive-current lag -$s_{PQ}$ | [binary] | `Pqflag` | Converter current-priority selector | 0 | 1 = P priority, 0 = Q priority -$T_\mathrm{rv}$ | [sec] | `Trv` | Voltage-measurement filter time constant | 0.02 | State 1 in Fig. 1 -$T_\mathrm{p}$ | [sec] | `Tp` | Electrical-power measurement filter time constant | 0.0 | State 2 in Fig. 1 -$V^\mathrm{ref}$ | [p.u.] | `Vref0` | Reactive-current-injection voltage reference | $V_T$ | Initialized from terminal voltage when omitted -$V_\mathrm{dip}$ | [p.u.] | `Vdip` | Low-voltage threshold for the voltage-band gate | 0.85 | -$V_\mathrm{up}$ | [p.u.] | `Vup` | High-voltage threshold for the voltage-band gate | 1.15 | -$D_1^\mathrm{db}$ | [p.u.] | `dbd1` | Lower deadband threshold for voltage-error response | 0.0 | -$D_2^\mathrm{db}$ | [p.u.] | `dbd2` | Upper deadband threshold for voltage-error response | 0.0 | -$K_\mathrm{qv}$ | [p.u.] | `kqv` | Reactive-current injection gain | 5.0 | -$I_{q,\mathrm{inj}}^{\min}$ | [p.u.] | `Iql1` | Minimum reactive-current injection | -1.1 | -$I_{q,\mathrm{inj}}^{\max}$ | [p.u.] | `Iqh1` | Maximum reactive-current injection | 1.1 | -$Q^{\max}$ | [p.u.] | `Qmax` | Maximum reactive-power control output | 0.436 | -$Q^{\min}$ | [p.u.] | `Qmin` | Minimum reactive-power control output | -0.436 | -$K_\mathrm{qp}$ | [p.u.] | `Kqp` | Reactive-power controller proportional gain | 0.0 | -$K_\mathrm{qi}$ | [p.u./s] | `Kqi` | Reactive-power controller integral gain | 0.1 | -$V^{\max}$ | [p.u.] | `Vmax` | Maximum voltage-control output | 1.1 | -$V^{\min}$ | [p.u.] | `Vmin` | Minimum voltage-control output | 0.9 | -$K_\mathrm{vp}$ | [p.u.] | `Kvp` | Voltage controller proportional gain | 18.0 | -$K_\mathrm{vi}$ | [p.u./s] | `Kvi` | Voltage controller integral gain | 5.0 | -$T_\mathrm{iq}$ | [sec] | `Tiq` | Reactive-current command lag time constant | 0.02 | State 5 in Fig. 1 -$T_\mathrm{pord}$ | [sec] | `Tpord` | Active-power order filter time constant | 0.02 | State 6 in Fig. 1 -$R_P^{\max}$ | [p.u./s] | `dPmax` | Positive active-power order ramp-rate limit | 99.0 | -$R_P^{\min}$ | [p.u./s] | `dPmin` | Negative active-power order ramp-rate limit | -99.0 | -$P^{\max}$ | [p.u.] | `Pmax` | Maximum active-power order | 1.0 | -$P^{\min}$ | [p.u.] | `Pmin` | Minimum active-power order | 0.0 | -$I^{\max}$ | [p.u.] | `Imax` | Maximum converter current | 1.3 | +Symbol | Units | JSON | Description | Typical Value | Note +------------------------------------|-----------|----------|---------------------------------------------------------|---------------|----- +$S^\mathrm{base}$ | [MVA] | `mva` | REECB component power base | 100.0 | System power base when omitted +$s_\mathrm{pf}$ | [boolean] | `PfFlag` | Power-factor control selector | `false` | `true` = power-factor control, `false` = reactive-power control +$s_V$ | [boolean] | `VFlag` | Voltage-reference selector under $s_Q=1$ | `false` | `true` = cascaded Q-PI voltage command, `false` = direct external voltage reference +$s_Q$ | [boolean] | `QFlag` | Reactive-path selector | `false` | `true` = Volt/VAr PI control, `false` = reactive-current lag +$s_{PQ}$ | [boolean] | `Pqflag` | Converter current-priority selector | `false` | `true` = P priority, `false` = Q priority +$T_\mathrm{rv}$ | [sec] | `Trv` | Voltage-measurement filter time constant | 0.02 | State 1 in Fig. 1 +$T_\mathrm{p}$ | [sec] | `Tp` | Electrical-power measurement filter time constant | 0.0 | State 2 in Fig. 1 +$V^\mathrm{ref}$ | [p.u.] | `Vref0` | Reactive-current-injection voltage reference | $V_T$ | Initialized from terminal voltage when omitted +$V_\mathrm{dip}$ | [p.u.] | `Vdip` | Low-voltage threshold for the voltage-band gate | 0.85 | +$V_\mathrm{up}$ | [p.u.] | `Vup` | High-voltage threshold for the voltage-band gate | 1.15 | +$D_1^\mathrm{db}$ | [p.u.] | `dbd1` | Lower deadband threshold for voltage-error response | 0.0 | +$D_2^\mathrm{db}$ | [p.u.] | `dbd2` | Upper deadband threshold for voltage-error response | 0.0 | +$K_\mathrm{qv}$ | [p.u.] | `kqv` | Reactive-current injection gain | 5.0 | +$I_{q,\mathrm{inj}}^{\min}$ | [p.u.] | `Iql1` | Minimum reactive-current injection | -1.1 | +$I_{q,\mathrm{inj}}^{\max}$ | [p.u.] | `Iqh1` | Maximum reactive-current injection | 1.1 | +$Q^{\max}$ | [p.u.] | `Qmax` | Maximum reactive-power control output | 0.436 | +$Q^{\min}$ | [p.u.] | `Qmin` | Minimum reactive-power control output | -0.436 | +$K_\mathrm{qp}$ | [p.u.] | `Kqp` | Reactive-power controller proportional gain | 0.0 | +$K_\mathrm{qi}$ | [p.u./s] | `Kqi` | Reactive-power controller integral gain | 0.1 | +$V^{\max}$ | [p.u.] | `Vmax` | Maximum voltage-control output | 1.1 | +$V^{\min}$ | [p.u.] | `Vmin` | Minimum voltage-control output | 0.9 | +$K_\mathrm{vp}$ | [p.u.] | `Kvp` | Voltage controller proportional gain | 18.0 | +$K_\mathrm{vi}$ | [p.u./s] | `Kvi` | Voltage controller integral gain | 5.0 | +$T_\mathrm{iq}$ | [sec] | `Tiq` | Reactive-current command lag time constant | 0.02 | State 5 in Fig. 1 +$T_\mathrm{pord}$ | [sec] | `Tpord` | Active-power order filter time constant | 0.02 | State 6 in Fig. 1 +$R_P^{\max}$ | [p.u./s] | `dPmax` | Positive active-power order ramp-rate limit | 99.0 | +$R_P^{\min}$ | [p.u./s] | `dPmin` | Negative active-power order ramp-rate limit | -99.0 | +$P^{\max}$ | [p.u.] | `Pmax` | Maximum active-power order | 1.0 | +$P^{\min}$ | [p.u.] | `Pmin` | Minimum active-power order | 0.0 | +$I^{\max}$ | [p.u.] | `Imax` | Maximum converter current | 1.3 | All parameters are optional. An omitted parameter starts from its Typical -Value; the time-constant floor below is then applied. +Value; the time-constant floor below is then applied. Real-valued parameters +accept real or integer JSON values; selectors require Boolean JSON values. ### Parameter Validation @@ -66,7 +67,6 @@ Invalid REECB parameter sets are rejected by the following checks: ```math \begin{aligned} S^\mathrm{base} &> 0,\quad \text{when provided} \\ - s_\mathrm{pf},s_V,s_Q,s_{PQ} &\in\{0,1\} \\ T_\mathrm{rv},T_\mathrm{p},T_\mathrm{iq},T_\mathrm{pord} &\ge 0 \\ V_\mathrm{dip} &< V_\mathrm{up} \\ D_1^\mathrm{db} &\le 0 \le D_2^\mathrm{db} \\ diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp b/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp index 514245064..e8a593215 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp @@ -136,10 +136,10 @@ namespace GridKit ReecbParameters parameter, RealT& target, const char* name); - void loadSwitchParameter(const ModelDataT& data, - ReecbParameters parameter, - bool& target, - const char* name); + void loadBooleanParameter(const ModelDataT& data, + ReecbParameters parameter, + bool& target, + const char* name); bool floorTimeConstant(RealT& value, const char* name); void initializeParameters(const ModelDataT& data); void initializeMonitor(); diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp index bf0ecf4d4..1487fc154 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp @@ -18,10 +18,10 @@ namespace GridKit enum class ReecbParameters { mva, ///< \f$S^\mathrm{base}\f$ Component power base [MVA] - PfFlag, ///< \f$s_\mathrm{pf}\f$ Power-factor control selector: 1 = power-factor control, 0 = reactive-power control [binary] - VFlag, ///< \f$s_V\f$ Voltage-reference selector under \f$s_Q=1\f$: 1 = cascaded Q-PI voltage command, 0 = direct external voltage reference [binary] - QFlag, ///< \f$s_Q\f$ Reactive-path selector: 1 = Volt/VAr PI control, 0 = reactive-current lag [binary] - Pqflag, ///< \f$s_{PQ}\f$ Converter current-priority selector: 1 = P priority, 0 = Q priority [binary] + PfFlag, ///< \f$s_\mathrm{pf}\f$ Power-factor control selector: true = power-factor control, false = reactive-power control [boolean] + VFlag, ///< \f$s_V\f$ Voltage-reference selector under \f$s_Q=1\f$: true = cascaded Q-PI voltage command, false = direct external voltage reference [boolean] + QFlag, ///< \f$s_Q\f$ Reactive-path selector: true = Volt/VAr PI control, false = reactive-current lag [boolean] + Pqflag, ///< \f$s_{PQ}\f$ Converter current-priority selector: true = P priority, false = Q priority [boolean] Trv, ///< \f$T_\mathrm{rv}\f$ Voltage-measurement filter time constant [sec] Tp, ///< \f$T_\mathrm{p}\f$ Electrical-power measurement filter time constant [sec] Vref0, ///< \f$V^\mathrm{ref}\f$ Reactive-current-injection voltage reference [p.u.] @@ -88,7 +88,7 @@ namespace GridKit * @brief Model data for REECB parameters, bus and signal ports, and monitored variables. * * @tparam real_type Real parameter value type. - * @tparam index_type Integer index and serialized selector type. + * @tparam index_type Integer index type. * * @see Reecb */ diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp index eecec04d4..2f1bddb38 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp @@ -764,19 +764,18 @@ namespace GridKit } /** - * @brief Load one binary selector + * @brief Load one optional Boolean parameter * - * Boolean values and integer values equal to zero or one are accepted. - * Any other value or stored type records a loading error while - * preserving the existing default. + * Any non-Boolean stored type records a loading error while preserving + * the existing default. * * @param[in] data Model parameter data. * @param[in] parameter Parameter key to load. - * @param[in,out] target Stored selector value. + * @param[in,out] target Stored Boolean value. * @param[in] name Serialized parameter name for diagnostics. */ template - void Reecb::loadSwitchParameter( + void Reecb::loadBooleanParameter( const ModelDataT& data, ReecbParameters parameter, bool& target, @@ -792,14 +791,9 @@ namespace GridKit { target = *bool_value; } - else if (const auto* index_value = std::get_if(&value); - index_value && (*index_value == 0 || *index_value == 1)) - { - target = (*index_value == 1); - } else { - Log::error() << "Reecb: parameter '" << name << "' must be bool or 0/1\n"; + Log::error() << "Reecb: parameter '" << name << "' must be boolean\n"; ++parameter_error_count_; } } @@ -858,10 +852,10 @@ namespace GridKit Vref0_given_ = false; loadRealParameter(data, Params::mva, mva_base_, "mva"); - loadSwitchParameter(data, Params::PfFlag, PfFlag_, "PfFlag"); - loadSwitchParameter(data, Params::VFlag, VFlag_, "VFlag"); - loadSwitchParameter(data, Params::QFlag, QFlag_, "QFlag"); - loadSwitchParameter(data, Params::Pqflag, Pqflag_, "Pqflag"); + loadBooleanParameter(data, Params::PfFlag, PfFlag_, "PfFlag"); + loadBooleanParameter(data, Params::VFlag, VFlag_, "VFlag"); + loadBooleanParameter(data, Params::QFlag, QFlag_, "QFlag"); + loadBooleanParameter(data, Params::Pqflag, Pqflag_, "Pqflag"); loadRealParameter(data, Params::Trv, Trv_, "Trv"); loadRealParameter(data, Params::Tp, Tp_, "Tp"); if (data.parameters.contains(Params::Vref0)) diff --git a/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp b/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp index 6c8e39f09..3e17f21be 100644 --- a/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp @@ -98,7 +98,8 @@ namespace GridKit } } - const RealT nan = std::numeric_limits::quiet_NaN(); + const RealT nan = std::numeric_limits::quiet_NaN(); + const RealT infinity = std::numeric_limits::infinity(); for (const Params parameter : {Params::mva, Params::Trv, Params::Tp, Params::Vref0, Params::Vdip, Params::Vup, Params::dbd1, Params::dbd2, Params::kqv, Params::Iql1, Params::Iqh1, Params::Qmax, Params::Qmin, Params::Kqp, Params::Kqi, Params::Vmax, Params::Vmin, Params::Kvp, Params::Kvi, Params::Tiq, Params::Tpord, Params::dPmax, Params::dPmin, Params::Pmax, Params::Pmin, Params::Imax}) { success *= invalidParameterCase(bus, parameter, nan); @@ -119,15 +120,28 @@ namespace GridKit success *= invalidParameterCase(bus, Params::Imax, -std::numeric_limits::infinity()); success *= invalidParameterCase(bus, Params::mva, true); - // Selectors accept only bool and integer 0/1 encodings. for (const Params flag : {Params::PfFlag, Params::VFlag, Params::QFlag, Params::Pqflag}) { - success *= !invalidParameterCase(bus, flag, static_cast(0)); - success *= !invalidParameterCase(bus, flag, static_cast(1)); - success *= invalidParameterCase(bus, flag, static_cast(0.0)); - success *= invalidParameterCase(bus, flag, static_cast(1.0)); - success *= invalidParameterCase(bus, flag, static_cast(2)); - success *= invalidParameterCase(bus, flag, static_cast(0.5)); + for (const bool value : {false, true}) + { + success *= !invalidParameterCase(bus, flag, value); + } + + for (const IdxT value : {static_cast(0), + static_cast(1), + static_cast(2)}) + { + success *= invalidParameterCase(bus, flag, value); + } + + for (const RealT value : {static_cast(0.0), + static_cast(0.5), + static_cast(1.0), + nan, + infinity}) + { + success *= invalidParameterCase(bus, flag, value); + } } PhasorDynamics::Converter::Reecb busless(nullptr, makeData()); diff --git a/tests/UnitTests/Utilities/CaseFormatTests.hpp b/tests/UnitTests/Utilities/CaseFormatTests.hpp index ad99de106..3fc958765 100644 --- a/tests/UnitTests/Utilities/CaseFormatTests.hpp +++ b/tests/UnitTests/Utilities/CaseFormatTests.hpp @@ -76,7 +76,7 @@ namespace GridKit { "class": "Gensal", "ports": {"bus":1}, "id": "2", "params": {"p0":1.0, "q0":0.05013, "H":3.0, "D":0.0, "Ra":0.0, "Tdop":7.0, "Tdopp":0.04, "Tqopp":0.05, "Xd":2.1, "Xdp":0.2, "Xdpp":0.18, "Xq":0.5, "Xl":0.15, "S10":0.0, "S12":0.0}, "mon": ["delta", "omega"] }, { "class": "Regca", "ports": {"bus":1}, "id": "CV1", "params": {"p0":0.0, "q0":0.0, "mva":100, "Tg":0.02, "TM":0.02, "Rqmax":999.0, "Rqmin":-999.0, "Rpmax":999.0, "sL":true, "IL1":1.1, "VL0":0.4, "VL1":0.9, "VA0":0.4, "VA1":0.9, "Vhvmax":1.2}, "mon": ["ir", "ii", "p", "q"] }, - { "class": "Reecb", "ports": {"bus":1}, "id": "REE1", "params": {"mva":50.0, "Pqflag":1}, "mon": ["iqcmd", "pmeas"] }, + { "class": "Reecb", "ports": {"bus":1}, "id": "REE1", "params": {"mva":50.0, "Pqflag":true}, "mon": ["iqcmd", "pmeas"] }, { "class": "BusFault", "ports": {"bus":1}, "id": "1", "params": {"state0": false, "R":0.0, "X":1e-3} } ] })"; @@ -181,7 +181,7 @@ namespace GridKit success *= result.regca[0].monitored_variables.contains(RegcaData::MonitorableVariables::p); success *= result.regca[0].monitored_variables.contains(RegcaData::MonitorableVariables::q); success *= std::get(result.reecb[0].parameters[ReecbData::Parameters::mva]) == 50.0; - success *= std::get(result.reecb[0].parameters[ReecbData::Parameters::Pqflag]) == 1; + success *= std::get(result.reecb[0].parameters[ReecbData::Parameters::Pqflag]); success *= result.reecb[0].buses[ReecbData::Buses::bus] == 1; success *= result.reecb[0].disambiguation_string == "REE1"; success *= result.reecb[0].monitored_variables.contains( From 92a63b2deca96e0941d3b12e93ebb7d0f81db475 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Mon, 3 Aug 2026 12:42:27 -0500 Subject: [PATCH 07/16] test style consistancy --- tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp b/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp index 3e17f21be..42625c708 100644 --- a/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp @@ -1599,8 +1599,14 @@ namespace GridKit const auto* yp = reecb.yp().getData(); for (size_t row = 0; row < index(Vars::MAXIMUM); ++row) { - success &= scalarMatches(f[row], 0.0, "steady residual"); - success &= scalarMatches(yp[row], 0.0, "steady derivative"); + if (!scalarMatches(f[row], 0.0, "steady residual")) + { + success = false; + } + if (!scalarMatches(yp[row], 0.0, "steady derivative")) + { + success = false; + } } return success; } From 8626dd11cc08146c52b80dbbeeb12a74e1ab95a2 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Wed, 5 Aug 2026 06:32:27 -0500 Subject: [PATCH 08/16] Style unification with others --- GridKit/CommonMath.hpp | 28 +- .../PhasorDynamics/Converter/REECB/README.md | 299 +- .../PhasorDynamics/Converter/REECB/Reecb.hpp | 35 + .../Converter/REECB/ReecbImpl.hpp | 143 +- .../PhasorDynamics/CMakeLists.txt | 9 - .../PhasorDynamics/PDIntegrationTests.hpp | 131 + .../PhasorDynamics/ReecbIntegrationTests.hpp | 461 --- .../PhasorDynamics/runPDIntegrationTests.cpp | 1 + .../runReecbIntegrationTests.cpp | 16 - .../Math/SmoothnessIndicatorTests.hpp | 67 - .../Math/runSmoothnessIndicatorTests.cpp | 1 - .../ComponentConnectionTests.hpp | 94 +- .../PhasorDynamics/ConverterReecbTests.hpp | 2479 ++++++++++------- .../SystemSingleComponentTests.hpp | 327 +-- .../runComponentConnectionTests.cpp | 1 + .../PhasorDynamics/runConverterReecbTests.cpp | 2 + .../runSystemSingleComponentTests.cpp | 1 - tests/UnitTests/Utilities/CaseFormatTests.hpp | 67 +- 18 files changed, 2006 insertions(+), 2156 deletions(-) delete mode 100644 tests/IntegrationTests/PhasorDynamics/ReecbIntegrationTests.hpp delete mode 100644 tests/IntegrationTests/PhasorDynamics/runReecbIntegrationTests.cpp diff --git a/GridKit/CommonMath.hpp b/GridKit/CommonMath.hpp index 675a61a73..8f9f8c5cf 100644 --- a/GridKit/CommonMath.hpp +++ b/GridKit/CommonMath.hpp @@ -339,8 +339,7 @@ namespace GridKit * @brief Smooth anti-windup indicator for a limited state variable * * @tparam ScalarT - Scalar data type - * @tparam LowerT - data type of the lower limit - * @tparam UpperT - data type of the upper limit + * @tparam RealT - Real data type (see GridKit::ScalarTraits::RealT) * * @param[in] x - State variable * @param[in] f - Pre-limit derivative of the state variable @@ -348,20 +347,14 @@ namespace GridKit * @param[in] limit_max - Maximum limit * @return Scalar value in [0, 1]: 1 when dynamics should pass through, * 0 when integration should be blocked. - * @pre `limit_min <= limit_max`; equal bounds are supported. - * - * @note The limit types intentionally may differ from the scalar type so - * that constant Real limits and algebraic-variable limits both work. */ - template + template __attribute__((always_inline)) inline ScalarT indicator( const ScalarT x, const ScalarT f, - const LowerT limit_min, - const UpperT limit_max) + const RealT limit_min, + const RealT limit_max) { - using RealT = typename GridKit::ScalarTraits::RealT; - assert(limit_min <= limit_max); ScalarT above_min = above(x, limit_min); @@ -381,25 +374,20 @@ namespace GridKit * and blocks motion that would push further into saturation. * * @tparam ScalarT - Scalar data type - * @tparam LowerT - data type of the lower limit - * @tparam UpperT - data type of the upper limit + * @tparam RealT - Real data type (see GridKit::ScalarTraits::RealT) * * @param[in] x - Limited state or limited output signal * @param[in] f - Pre-limit derivative * @param[in] limit_min - Minimum limit * @param[in] limit_max - Maximum limit * @return Smooth anti-windup limited derivative - * @pre `limit_min <= limit_max`; equal bounds are supported. - * - * @note The limit types intentionally may differ from the scalar type so - * that constant Real limits and algebraic-variable limits both work. */ - template + template __attribute__((always_inline)) inline ScalarT antiwindup( const ScalarT x, const ScalarT f, - const LowerT limit_min, - const UpperT limit_max) + const RealT limit_min, + const RealT limit_max) { return indicator(x, f, limit_min, limit_max) * f; } diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/README.md b/GridKit/Model/PhasorDynamics/Converter/REECB/README.md index b9ccb9379..c66cf3461 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/README.md +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/README.md @@ -90,13 +90,11 @@ raised to that floor in place, so every equation below uses the raised value: ```math \begin{aligned} - T_x - &\leftarrow \max\!\left(T_x,\epsilon_T\right), - && x\in\{\mathrm{rv},\mathrm{p},\mathrm{iq},\mathrm{pord}\} \\ + T_x &\leftarrow \text{max}(T_x,\epsilon_T), && x\in\{\mathrm{rv},\mathrm{p},\mathrm{iq},\mathrm{pord}\} \\ s_\mathrm{pf}^\mathrm{off} &= 1 - s_\mathrm{pf} \\ s_Q^\mathrm{off} &= 1 - s_Q \\ s_Q^\mathrm{PI} &= s_Q s_V \\ - s_V^\mathrm{ref} &= s_Q\left(1-s_V\right) \\ + s_V^\mathrm{ref} &= s_Q(1-s_V) \\ s_Q^\mathrm{ref} &= 1 - s_V^\mathrm{ref} \\ s_{PQ}^\mathrm{off} &= 1 - s_{PQ} \\ k_\mathrm{base} &= \dfrac{S^\mathrm{sys}}{S^\mathrm{base}}. @@ -144,8 +142,8 @@ Symbol | Units | Description | ---------------------|--------|-----------------------------------------------|----- $V_T$ | [p.u.] | Terminal voltage magnitude | $I_L^{\max}$ | [p.u.] | Current available to the low-priority command | Component base -$I_q^\mathrm{cmd}$ | [p.u.] | Reactive-current command output | System base -$I_p^\mathrm{cmd}$ | [p.u.] | Active-current command output | System base +$I_q^\mathrm{cmd}$ | [p.u.] | Reactive-current command output | System base +$I_p^\mathrm{cmd}$ | [p.u.] | Active-current command output | System base ### External Variables @@ -171,49 +169,20 @@ For readability, define: ```math \begin{aligned} - V_\mathrm{safe}^\mathrm{meas} - &= \text{max}\!\left(V^\mathrm{meas},0.01\right) \\ - s_\mathrm{dip} - &= \text{inside}\!\left(V_T;\,V_\mathrm{dip},V_\mathrm{up}\right) \\ - e_V^\mathrm{db} - &= \text{deadband2}\!\left( - V^\mathrm{ref}-V^\mathrm{meas};\,D_1^\mathrm{db},D_2^\mathrm{db} - \right) \\ - I_q^\mathrm{inj} - &= \text{clamp}\!\left( - K_\mathrm{qv}e_V^\mathrm{db};\, - I_{q,\mathrm{inj}}^{\min},I_{q,\mathrm{inj}}^{\max} - \right) \\ - Q^\mathrm{ref} - &= s_Q^\mathrm{ref}\left( - s_\mathrm{pf}P^\mathrm{meas}\tan\!\left(\phi^\mathrm{ref}\right) - +s_\mathrm{pf}^\mathrm{off}k_\mathrm{base}Q^\mathrm{ext} - \right) \\ - e_Q - &= \text{clamp}\!\left(Q^\mathrm{ref};\,Q^{\min},Q^{\max}\right) - -k_\mathrm{base}Q^\mathrm{gen} \\ - V_Q^\mathrm{PI} - &= \text{clamp}\!\left( - K_\mathrm{qp}e_Q+x_Q^\mathrm{PI};\,V^{\min},V^{\max} - \right) \\ - e_V^\mathrm{PI} - &= s_Q^\mathrm{PI}V_Q^\mathrm{PI}+s_V^\mathrm{ref}Q^\mathrm{ext} - -s_QV^\mathrm{meas} \\ - f_P^\mathrm{ord} - &= \dfrac{1}{T_\mathrm{pord}} - \left(k_\mathrm{base}P^\mathrm{ref}-P^\mathrm{ord}\right) \\ - r_P^\mathrm{ord} - &= \text{clamp}\!\left(f_P^\mathrm{ord};\,R_P^{\min},R_P^{\max}\right) \\ - I_q^{\max} - &= s_{PQ}\left|I_L^{\max}\right|+s_{PQ}^\mathrm{off}I^{\max} \\ - I_p^{\max} - &= s_{PQ}I^{\max}+s_{PQ}^\mathrm{off}\left|I_L^{\max}\right| \\ - I_q^\mathrm{base} - &= \text{clamp}\!\left( - K_\mathrm{vp}e_V^\mathrm{PI}+x_V^\mathrm{PI};\,-I_q^{\max},I_q^{\max} - \right) \\ - I_q^\mathrm{raw} - &= s_QI_q^\mathrm{base}+s_Q^\mathrm{off}Q_V+I_q^\mathrm{inj}. + V_\mathrm{safe}^\mathrm{meas} &= \text{max}(V^\mathrm{meas},0.01) \\ + s_\mathrm{dip} &= \text{inside}(V_T;\,V_\mathrm{dip},V_\mathrm{up}) \\ + e_V^\mathrm{db} &= \text{deadband2}(V^\mathrm{ref}-V^\mathrm{meas};\,D_1^\mathrm{db},D_2^\mathrm{db}) \\ + I_q^\mathrm{inj} &= \text{clamp}(K_\mathrm{qv}e_V^\mathrm{db};\,I_{q,\mathrm{inj}}^{\min},I_{q,\mathrm{inj}}^{\max}) \\ + Q^\mathrm{ref} &= s_Q^\mathrm{ref}(s_\mathrm{pf}P^\mathrm{meas}\tan(\phi^\mathrm{ref})+s_\mathrm{pf}^\mathrm{off}k_\mathrm{base}Q^\mathrm{ext}) \\ + e_Q &= \text{clamp}(Q^\mathrm{ref};\,Q^{\min},Q^{\max})-k_\mathrm{base}Q^\mathrm{gen} \\ + V_Q^\mathrm{PI} &= \text{clamp}(K_\mathrm{qp}e_Q+x_Q^\mathrm{PI};\,V^{\min},V^{\max}) \\ + e_V^\mathrm{PI} &= s_Q^\mathrm{PI}V_Q^\mathrm{PI}+s_V^\mathrm{ref}Q^\mathrm{ext}-s_QV^\mathrm{meas} \\ + f_P^\mathrm{ord} &= \dfrac{1}{T_\mathrm{pord}}(k_\mathrm{base}P^\mathrm{ref}-P^\mathrm{ord}) \\ + r_P^\mathrm{ord} &= \text{clamp}(f_P^\mathrm{ord};\,R_P^{\min},R_P^{\max}) \\ + I_q^{\max} &= s_{PQ}|I_L^{\max}|+s_{PQ}^\mathrm{off}I^{\max} \\ + I_p^{\max} &= s_{PQ}I^{\max}+s_{PQ}^\mathrm{off}|I_L^{\max}| \\ + I_q^\mathrm{base} &= \text{clamp}(K_\mathrm{vp}e_V^\mathrm{PI}+x_V^\mathrm{PI};\,-I_q^{\max},I_q^{\max}) \\ + I_q^\mathrm{raw} &= s_QI_q^\mathrm{base}+s_Q^\mathrm{off}Q_V+I_q^\mathrm{inj}. \end{aligned} ``` @@ -225,40 +194,12 @@ these equations. ```math \begin{aligned} - 0 &= - -\dot{V}^\mathrm{meas} - + \dfrac{1}{T_\mathrm{rv}} - \left(V_T-V^\mathrm{meas}\right) \\ - 0 &= - -\dot{P}^\mathrm{meas} - + \dfrac{1}{T_\mathrm{p}} - \left(k_\mathrm{base}P_e-P^\mathrm{meas}\right) \\ - 0 &= - -\dot{x}_Q^\mathrm{PI} - + s_Q^\mathrm{PI}s_\mathrm{dip}\, - \text{antiwindup}\!\left( - K_\mathrm{qp}e_Q+x_Q^\mathrm{PI}, - K_\mathrm{qi}e_Q;\, - V^{\min},V^{\max} - \right) \\ - 0 &= - -\dot{x}_V^\mathrm{PI} - + s_Qs_\mathrm{dip}\, - \text{antiwindup}\!\left( - K_\mathrm{vp}e_V^\mathrm{PI}+x_V^\mathrm{PI}, - K_\mathrm{vi}e_V^\mathrm{PI};\, - -I_q^{\max},I_q^{\max} - \right) \\ - 0 &= - -\dot{Q}_V - + \dfrac{1}{T_\mathrm{iq}}s_Q^\mathrm{off}s_\mathrm{dip} - \left(\dfrac{Q^\mathrm{ref}}{V_\mathrm{safe}^\mathrm{meas}}-Q_V\right) \\ - 0 &= - -\dot{P}^\mathrm{ord} - + s_\mathrm{dip}\, - \text{antiwindup}\!\left( - P^\mathrm{ord},r_P^\mathrm{ord};\,P^{\min},P^{\max} - \right). + 0 &= -\dot{V}^\mathrm{meas} + \dfrac{1}{T_\mathrm{rv}}(V_T-V^\mathrm{meas}) \\ + 0 &= -\dot{P}^\mathrm{meas} + \dfrac{1}{T_\mathrm{p}}(k_\mathrm{base}P_e-P^\mathrm{meas}) \\ + 0 &= -\dot{x}_Q^\mathrm{PI} + s_Q^\mathrm{PI}s_\mathrm{dip}\,\text{antiwindup}(K_\mathrm{qp}e_Q+x_Q^\mathrm{PI},K_\mathrm{qi}e_Q;\,V^{\min},V^{\max}) \\ + 0 &= -\dot{x}_V^\mathrm{PI} + s_Qs_\mathrm{dip}\,\text{antiwindup}(K_\mathrm{vp}e_V^\mathrm{PI}+x_V^\mathrm{PI},K_\mathrm{vi}e_V^\mathrm{PI};\,-I_q^{\max},I_q^{\max}) \\ + 0 &= -\dot{Q}_V + \dfrac{1}{T_\mathrm{iq}}s_Q^\mathrm{off}s_\mathrm{dip}\left(\dfrac{Q^\mathrm{ref}}{V_\mathrm{safe}^\mathrm{meas}}-Q_V\right) \\ + 0 &= -\dot{P}^\mathrm{ord} + s_\mathrm{dip}\,\text{antiwindup}(P^\mathrm{ord},r_P^\mathrm{ord};\,P^{\min},P^{\max}). \end{aligned} ``` @@ -267,15 +208,9 @@ these equations. ```math \begin{aligned} 0 &= -V_T^2+V_\mathrm{r}^2+V_\mathrm{i}^2 \\ - 0 &= -I_L^{\max}\left|I_L^{\max}\right|+\left(I^{\max}\right)^2 - -s_{PQ}\left(k_\mathrm{base}I_p^\mathrm{cmd}\right)^2 - -s_{PQ}^\mathrm{off}\left(k_\mathrm{base}I_q^\mathrm{cmd}\right)^2 \\ - 0 &= -k_\mathrm{base}I_q^\mathrm{cmd} - +\text{clamp}\!\left(I_q^\mathrm{raw};\,-I_q^{\max},I_q^{\max}\right) \\ - 0 &= -k_\mathrm{base}I_p^\mathrm{cmd} - +\text{clamp}\!\left( - \dfrac{P^\mathrm{ord}}{V_\mathrm{safe}^\mathrm{meas}};\,0,I_p^{\max} - \right). + 0 &= -I_L^{\max}|I_L^{\max}|+(I^{\max})^2-s_{PQ}(k_\mathrm{base}I_p^\mathrm{cmd})^2-s_{PQ}^\mathrm{off}(k_\mathrm{base}I_q^\mathrm{cmd})^2 \\ + 0 &= -k_\mathrm{base}I_q^\mathrm{cmd}+\text{clamp}(I_q^\mathrm{raw};\,-I_q^{\max},I_q^{\max}) \\ + 0 &= -k_\mathrm{base}I_p^\mathrm{cmd}+\text{clamp}\left(\dfrac{P^\mathrm{ord}}{V_\mathrm{safe}^\mathrm{meas}};\,0,I_p^{\max}\right). \end{aligned} ``` @@ -293,14 +228,10 @@ REECB reconstructs a steady operating point. Arbitrary-state restart is unsuppor ```math \begin{aligned} - V_\mathrm{r},V_\mathrm{i} - &\leftarrow \text{terminal-bus voltage} \\ - I_q^\mathrm{cmd},I_p^\mathrm{cmd} - &\leftarrow \text{owned current-command variables} \\ - P_e - &\leftarrow \text{attached active-power feedback},\quad \text{if attached} \\ - Q^\mathrm{gen} - &\leftarrow \text{attached reactive-power feedback},\quad \text{if attached}. + V_\mathrm{r},V_\mathrm{i} &\leftarrow \text{terminal-bus voltage} \\ + I_q^\mathrm{cmd},I_p^\mathrm{cmd} &\leftarrow \text{owned current-command variables} \\ + P_e &\leftarrow \text{attached active-power feedback},\quad \text{if attached} \\ + Q^\mathrm{gen} &\leftarrow \text{attached reactive-power feedback},\quad \text{if attached}. \end{aligned} ``` @@ -317,37 +248,18 @@ clamp for $\ell\epsilon_0 \\ + \arctan(Q^\mathrm{target}/P^\mathrm{meas}) & s_\mathrm{pf}=1\ \land\ |P^\mathrm{meas}|>\epsilon_0 \\ 0 & \text{otherwise} \end{cases} \\ - Q^\mathrm{ext} - &\leftarrow + Q^\mathrm{ext} &\leftarrow \begin{cases} V^\mathrm{meas} & s_V^\mathrm{ref}=1 \\ - P^\mathrm{meas}\tan\!\left(\phi^\mathrm{ref}\right)/k_\mathrm{base} - & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=1 \\ - Q^\mathrm{target}/k_\mathrm{base} - & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=0 + P^\mathrm{meas}\tan(\phi^\mathrm{ref})/k_\mathrm{base} & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=1 \\ + Q^\mathrm{target}/k_\mathrm{base} & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=0 \end{cases} \\ - Q^\mathrm{ref} - &\leftarrow s_Q^\mathrm{ref}\left( - s_\mathrm{pf}P^\mathrm{meas}\tan\!\left(\phi^\mathrm{ref}\right) - +s_\mathrm{pf}^\mathrm{off}k_\mathrm{base}Q^\mathrm{ext} - \right). + Q^\mathrm{ref} &\leftarrow s_Q^\mathrm{ref}(s_\mathrm{pf}P^\mathrm{meas}\tan(\phi^\mathrm{ref})+s_\mathrm{pf}^\mathrm{off}k_\mathrm{base}Q^\mathrm{ext}). \end{aligned} ``` @@ -413,46 +302,26 @@ For $s_Q=0$, the selected reactive-reference path must reproduce the recovered controller current: ```math -\left| - \dfrac{Q^\mathrm{ref}}{V_\mathrm{safe}^\mathrm{meas}} - -I_q^\mathrm{ctrl} -\right| -\le\epsilon_0. +\left|\dfrac{Q^\mathrm{ref}}{V_\mathrm{safe}^\mathrm{meas}}-I_q^\mathrm{ctrl}\right|\le\epsilon_0. ``` ```math \begin{aligned} - e_Q - &\leftarrow \text{clamp}\!\left(Q^\mathrm{ref};\,Q^{\min},Q^{\max}\right) - -k_\mathrm{base}Q^\mathrm{gen} \\ - x_Q^\mathrm{PI} - &\leftarrow + e_Q &\leftarrow \text{clamp}(Q^\mathrm{ref};\,Q^{\min},Q^{\max})-k_\mathrm{base}Q^\mathrm{gen} \\ + x_Q^\mathrm{PI} &\leftarrow \begin{cases} - \text{unclamp}\!\left( - V^\mathrm{meas};\,V^{\min},V^{\max} - \right)-K_\mathrm{qp}e_Q - & s_Q s_V=1\ \land\ V^{\min}\epsilon_0$ or - $\left|s_QK_\mathrm{vi}e_V^\mathrm{PI}\right|>\epsilon_0$; or + $|s_Q^\mathrm{PI}K_\mathrm{qi}e_Q|>\epsilon_0$ or + $|s_QK_\mathrm{vi}e_V^\mathrm{PI}|>\epsilon_0$; or - any candidate quantity is nonfinite. The recovered order is retained unchanged to preserve the initial @@ -484,26 +353,18 @@ derivatives, latches, parameter storage, and attached signals unchanged. ```math \begin{aligned} - \phi^\mathrm{ref} - &\leftarrow + \phi^\mathrm{ref} &\leftarrow \begin{cases} - \arctan\!\left(Q^\mathrm{target}/P^\mathrm{meas}\right) - & s_\mathrm{pf}=1\ \land\ |P^\mathrm{meas}|>\epsilon_0 \\ + \arctan(Q^\mathrm{target}/P^\mathrm{meas}) & s_\mathrm{pf}=1\ \land\ |P^\mathrm{meas}|>\epsilon_0 \\ 0 & \text{otherwise} \end{cases} \\ - Q^\mathrm{ext} - &\leftarrow + Q^\mathrm{ext} &\leftarrow \begin{cases} V^\mathrm{meas} & s_V^\mathrm{ref}=1 \\ - P^\mathrm{meas}\tan\!\left(\phi^\mathrm{ref}\right)/k_\mathrm{base} - & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=1 \\ - Q^\mathrm{target}/k_\mathrm{base} - & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=0 + P^\mathrm{meas}\tan(\phi^\mathrm{ref})/k_\mathrm{base} & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=1 \\ + Q^\mathrm{target}/k_\mathrm{base} & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=0 \end{cases} \\ - P^\mathrm{ref} - &\leftarrow - \dfrac{P^\mathrm{ord}+T_\mathrm{pord}f_P^\mathrm{ord}} - {k_\mathrm{base}} + P^\mathrm{ref} &\leftarrow \dfrac{P^\mathrm{ord}+T_\mathrm{pord}f_P^\mathrm{ord}}{k_\mathrm{base}} \end{aligned} ``` @@ -524,23 +385,19 @@ Output | Units | Description | Note For $\ell +#include #include #include +#include #include #include #include @@ -155,12 +157,45 @@ namespace GridKit template ValueT toSystemBase(ValueT value) const; + /** + * @brief Smooth anti-windup derivative within a moving symmetric band. + * + * Math::antiwindup over [-band, band] with a band edge that is an + * algebraic quantity, so differentiation carries the band's own + * contributions through the gate. + * + * @param[in] x Limited PI state. + * @param[in] f Pre-limit derivative of x. + * @param[in] band Nonnegative symmetric band edge. + * @return Anti-windup-limited derivative. + * + * @todo Fold moving-limit support into Math::antiwindup in CommonMath. + */ + static __attribute__((always_inline)) inline ScalarT awband( + const ScalarT x, + const ScalarT f, + const ScalarT band) + { + const ScalarT above_min = Math::sigmoid(x + band); + const ScalarT below_max = Math::sigmoid(band - x); + + return (above_min * below_max // + + (ONE - below_max) * Math::sigmoid(-f) // + + (ONE - above_min) * Math::sigmoid(f)) + * f; + } + ScalarT& Vr(); ScalarT& Vi(); static constexpr RealT TIME_CONSTANT_MINIMUM = static_cast(1.0e-3); static constexpr RealT VMEAS_MINIMUM = static_cast(0.01); + /// Accepted reconstruction error where an initialization reference + /// round-trips through a transcendental function. + static constexpr RealT INITIALIZATION_TOLERANCE = + static_cast(100.0) * std::numeric_limits::epsilon(); + BusT* bus_{nullptr}; // Input parameters diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp index 2f1bddb38..96248f7e9 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp @@ -262,19 +262,20 @@ namespace GridKit * @brief Initialize REECB from the initial current commands and feedback * * Preserves the system-base command states, consumes attached initialized - * active/reactive-power feedback or reconstructs unattached feedback, - * resolves a component-base steady state, and publishes the unknown - * optional reference signals. + * active/reactive-power feedback or reconstructs unattached feedback, and + * constructs the remaining states and reference setpoints by forward + * evaluation of the residual expressions, so an admissible operating + * point starts at a steady state. * * @pre allocate() has completed. * @pre verify() reports a valid parameter and port configuration. * @pre The terminal bus and current-command states are initialized. * - * @post On failure no state, derivative, latch, parameter, or signal - * storage is modified. + * @post On failure no state, derivative, parameter, or signal storage is + * modified. * - * @return 0 on success; nonzero when allocation, configuration, initial- - * value, current-circle, limiter, or steady-state checks fail. + * @return 0 on success; nonzero when an allocation, configuration, + * initial-value, current-circle, or limiter-interior check fails. */ template int Reecb::initialize() @@ -296,8 +297,6 @@ namespace GridKit auto* y = y_.getData(); - // Covers roundoff from inverse clamps and component/system-base round trips. - const RealT tol = static_cast(100) * std::numeric_limits::epsilon(); const RealT ipcmd0_system = static_cast(y[index(I::IPCMD)]); const RealT iqcmd0_system = static_cast(y[index(I::IQCMD)]); const RealT ipcmd0 = toComponentBase(ipcmd0_system); @@ -307,9 +306,9 @@ namespace GridKit const RealT vt0 = std::sqrt(vr0 * vr0 + vi0 * vi0); const RealT vmeas0 = vt0; const RealT vmeas_safe0 = Math::max(vmeas0, VMEAS_MINIMUM); - const RealT active_order0 = ipcmd0 * vmeas_safe0; - RealT pe0_system = toSystemBase(active_order0); - RealT qgen0_system = toSystemBase(iqcmd0 * vmeas_safe0); + + RealT pe0_system = toSystemBase(ipcmd0 * vmeas_safe0); + RealT qgen0_system = toSystemBase(iqcmd0 * vmeas_safe0); if (signals_.template isAttached()) { @@ -322,7 +321,7 @@ namespace GridKit const RealT pmeas0 = toComponentBase(pe0_system); const RealT qgen0 = toComponentBase(qgen0_system); - RealT vref0 = vt0; + RealT vref0 = vmeas0; if (Vref0_given_) { vref0 = Vref0_; @@ -341,6 +340,7 @@ namespace GridKit return 1; } + // Mirrors of the residual limiter chain at the initial point. const RealT verr0 = Math::deadband2(vref0 - vmeas0, dbd1_, dbd2_); const RealT iqv0 = Math::clamp(kqv_ * verr0, Iql1_, Iqh1_); const RealT ilmax_squared = Imax_ * Imax_ - pq_on_ * ipcmd0 * ipcmd0 - pq_off_ * iqcmd0 * iqcmd0; @@ -361,22 +361,47 @@ namespace GridKit return 1; } - const RealT pord0 = vmeas_safe0 * unclamp(ipcmd0, ZERO, ipmax0); - if (pord0 < Pmin_ - tol || pord0 > Pmax_ + tol) + // The algebraic command rows reproduce their limiter outputs through + // the smooth-clamp inverse. A command no input can produce leaves the + // inverse nonfinite, which the finiteness test below rejects. + const RealT ipraw0 = unclamp(ipcmd0, ZERO, ipmax0); + const RealT iqraw0 = unclamp(iqcmd0, -iqmax0, iqmax0); + const RealT iqctl0 = iqraw0 - iqv0; + const RealT pord_raw = vmeas_safe0 * ipraw0; + + if (pord_raw < Pmin_ || pord_raw > Pmax_) { Log::error() << "Reecb: recovered active-power order is outside Pmin/Pmax\n"; return 1; } - const RealT fpord0 = unclamp(ZERO, dPmin_, dPmax_); - const RealT pref0 = pord0 + Tpord_ * fpord0; - const RealT iqraw0 = unclamp(iqcmd0, -iqmax0, iqmax0); - const RealT iqctl0 = iqraw0 - iqv0; + // Round-tripping the published reference reproduces the residual's + // component-base reference, holding the order rate at exactly zero. + const RealT pref0_system = toSystemBase(pord_raw); + const RealT pord0 = toComponentBase(pref0_system); - // Unconstrained reactive targets stay zero; the masked equilibrium - // checks below reject any choice the enabled integrators cannot hold. - RealT qtarget0 = ZERO; + // An integrating path holds its feedback only where the clamp can + // reproduce it: strictly inside the limits, or collapsed onto it. + auto reproducible = [](RealT value, RealT lower, RealT upper) + { + return (lower < value && value < upper) || (lower == upper && value == lower); + }; + + if (q_pi_on_ * Kqi_ != ZERO && !reproducible(qgen0, Qmin_, Qmax_)) + { + Log::error() << "Reecb: reactive-power integral path is not at equilibrium\n"; + return 1; + } + if (q_pi_on_ * Kvi_ != ZERO && !reproducible(vmeas0, Vmin_, Vmax_)) + { + Log::error() << "Reecb: voltage-control integral path is not at equilibrium\n"; + return 1; + } + // The reactive channel reproduces the power feedback when the reactive + // PI is enabled, and the reactive-current command otherwise. Collapsed + // limits already pin the clamp output onto the feedback. + RealT qtarget0 = ZERO; if (!QFlag_) { qtarget0 = iqctl0 * vmeas_safe0; @@ -386,24 +411,34 @@ namespace GridKit qtarget0 = unclamp(qgen0, Qmin_, Qmax_); } - // The Volt/VAr channel publishes a terminal-voltage reference in - // direct-voltage mode and a system-base reactive power otherwise. RealT qref0 = ZERO; RealT qext0_port = ZERO; RealT pfaref0 = ZERO; if (QFlag_ && !VFlag_) { - // The V PI holds the raw reference at the measurement exactly; a - // zero Kvi keeps the same physical setpoint. + // Direct-voltage mode publishes the measurement as its reference. qext0_port = vmeas0; } else if (PfFlag_) { - if (std::abs(pmeas0) > tol) + if (pmeas0 == ZERO && qtarget0 != ZERO) + { + Log::error() << "Reecb: power-factor mode cannot reproduce the reactive target at zero active power\n"; + return 1; + } + if (pmeas0 != ZERO) { pfaref0 = std::atan(qtarget0 / pmeas0); - qref0 = pmeas0 * std::tan(pfaref0); + } + qref0 = pmeas0 * std::tan(pfaref0); + + // Angle resolution collapses toward the tangent pole, so the + // published angle must still carry its own target back. + if (std::abs(qref0 - qtarget0) > std::abs(qtarget0) * INITIALIZATION_TOLERANCE) + { + Log::error() << "Reecb: power-factor angle cannot reproduce the reactive target\n"; + return 1; } qext0_port = toSystemBase(qref0); } @@ -413,33 +448,18 @@ namespace GridKit qref0 = toComponentBase(qext0_port); } - // The masked product mirrors the residual integrator rate. const RealT eq0 = Math::clamp(qref0, Qmin_, Qmax_) - qgen0; - if (std::abs(q_pi_on_ * Kqi_ * eq0) > tol) - { - Log::error() << "Reecb: reactive-power integral path is not at equilibrium\n"; - return 1; - } - // The Q-PI state reproduces the measured voltage through the inverse - // clamp on the V limits + // The Q-PI order carries the measurement the voltage channel + // subtracts; collapsed limits pin the clamp output there already. RealT xpiq0 = ZERO; - if (QFlag_ && VFlag_) + if (QFlag_ && VFlag_ && Vmin_ < vmeas0 && vmeas0 < Vmax_) { - xpiq0 = -Kqp_ * eq0; - if (Vmin_ < vmeas0 && vmeas0 < Vmax_) - { - xpiq0 += unclamp(vmeas0, Vmin_, Vmax_); - } + xpiq0 = unclamp(vmeas0, Vmin_, Vmax_) - Kqp_ * eq0; } const RealT vpiq0 = Math::clamp(Kqp_ * eq0 + xpiq0, Vmin_, Vmax_); const RealT epiv0 = q_pi_on_ * vpiq0 + v_ref_on_ * qext0_port - q_on_ * vmeas0; - if (std::abs(q_on_ * Kvi_ * epiv0) > tol) - { - Log::error() << "Reecb: voltage-control integral path is not at equilibrium\n"; - return 1; - } RealT qv0 = ZERO; RealT xpiv0 = ZERO; @@ -455,22 +475,16 @@ namespace GridKit } else { + // The lag state carries the same quotient the QV row forms. qv0 = qref0 / vmeas_safe0; - if (std::abs(qv0 - iqctl0) > tol) - { - Log::error() << "Reecb: reactive-reference path cannot reproduce the initial reactive-current command\n"; - return 1; - } } - const RealT pref0_system = toSystemBase(pref0); if (!std::isfinite(verr0) || !std::isfinite(iqv0) || !std::isfinite(ilmax0) - || !std::isfinite(iqmax0) || !std::isfinite(ipmax0) - || !std::isfinite(pord0) || !std::isfinite(fpord0) || !std::isfinite(pref0) - || !std::isfinite(iqraw0) || !std::isfinite(iqctl0) || !std::isfinite(qref0) - || !std::isfinite(qext0_port) || !std::isfinite(pfaref0) || !std::isfinite(eq0) - || !std::isfinite(xpiq0) || !std::isfinite(vpiq0) || !std::isfinite(epiv0) - || !std::isfinite(qv0) || !std::isfinite(xpiv0) || !std::isfinite(pref0_system)) + || !std::isfinite(iqmax0) || !std::isfinite(ipmax0) || !std::isfinite(ipraw0) + || !std::isfinite(iqraw0) || !std::isfinite(pord0) || !std::isfinite(pref0_system) + || !std::isfinite(qref0) || !std::isfinite(qext0_port) || !std::isfinite(pfaref0) + || !std::isfinite(eq0) || !std::isfinite(xpiq0) || !std::isfinite(epiv0) + || !std::isfinite(xpiv0) || !std::isfinite(qv0)) { Log::error() << "Reecb: initialization produced a nonfinite value\n"; return 1; @@ -691,7 +705,7 @@ namespace GridKit f[index(I::VMEAS)] = -vmeas_dot + (vt - vmeas) / Trv_; f[index(I::PMEAS)] = -pmeas_dot + (pe - pmeas) / Tp_; f[index(I::XPIQ)] = -xpiq_dot + q_pi_on_ * sdip * Math::antiwindup(Kqp_ * eq + xpiq, Kqi_ * eq, Vmin_, Vmax_); - f[index(I::XPIV)] = -xpiv_dot + q_on_ * sdip * Math::antiwindup(Kvp_ * epiv + xpiv, Kvi_ * epiv, -iqmax, iqmax); + f[index(I::XPIV)] = -xpiv_dot + q_on_ * sdip * awband(Kvp_ * epiv + xpiv, Kvi_ * epiv, iqmax); f[index(I::QV)] = -qv_dot + q_off_ * sdip * (qref / vmeas_safe - qv) / Tiq_; f[index(I::PORD)] = -pord_dot + sdip * Math::antiwindup(pord, rpord, Pmin_, Pmax_); f[index(I::VT)] = -vt * vt + vr * vr + vi * vi; @@ -996,13 +1010,14 @@ namespace GridKit } /** - * @brief Recover the input that produces an interior smooth-clamp output + * @brief Recover the input that produces a requested smooth-clamp output * - * @param[in] output Requested output strictly between the limits. + * @param[in] output Requested output. * @param[in] lower Lower smooth-clamp limit. * @param[in] upper Upper smooth-clamp limit. - * @return Exact inverse of `Math::clamp` to floating-point roundoff. - * @pre `lower < output < upper`. + * @return Inverse of `Math::clamp`, or a nonfinite value when no input + * produces `output`. + * @pre `lower <= upper`. * @warning This function contains conditional branching and as such can * be used in initialization methods but not in residual evaluation. */ diff --git a/tests/IntegrationTests/PhasorDynamics/CMakeLists.txt b/tests/IntegrationTests/PhasorDynamics/CMakeLists.txt index 533f82b59..34b7b5053 100644 --- a/tests/IntegrationTests/PhasorDynamics/CMakeLists.txt +++ b/tests/IntegrationTests/PhasorDynamics/CMakeLists.txt @@ -9,12 +9,3 @@ add_test( WORKING_DIRECTORY ${CMAKE_BINARY_DIR}/examples/PhasorDynamics/Tiny) install(TARGETS test_pd_integration) - -add_executable(test_phasor_reecb_integration runReecbIntegrationTests.cpp) -target_link_libraries( - test_phasor_reecb_integration - PRIVATE GridKit::phasor_dynamics_systemmodel GridKit::solvers_dyn GridKit::testing) - -add_test(NAME PhasorDynamicsReecbIntegrationTest COMMAND test_phasor_reecb_integration) - -install(TARGETS test_phasor_reecb_integration) diff --git a/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp b/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp index 5f58c410d..85ee9f151 100644 --- a/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp +++ b/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp @@ -4,6 +4,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -716,6 +720,133 @@ namespace GridKit auto success = compare(set_data, file_data); return success.report(__func__); } + + /// Displaced REECB measurement and REGCA current-lag states integrate + /// back to the initialized equilibrium of the closed command and power + /// feedback loop. + TestOutcome regcaReecbRecovery() + { + using namespace GridKit::PhasorDynamics::Converter; + using ReecbVar = ReecbInternalVariables; + using RegcaVar = RegcaInternalVariables; + + TestStatus success = true; + + SystemModelDataT data; + data.va_base = static_cast(100.0e6); + + auto& bus = data.bus.emplace_back(); + bus.bus_id = kReecbBusId; + bus.bus_type = BusDataT::BusType::SLACK; + bus.Vr0 = ONE; + bus.Vi0 = ZERO; + + data.signal = {{"Active Current Command", kIpcmdSignalId}, + {"Reactive Current Command", kIqcmdSignalId}, + {"Branch Active Power", kPbranchSignalId}, + {"Branch Reactive Power", kQbranchSignalId}}; + + auto& converter = data.regca.emplace_back(); + converter.buses[RegcaBuses::bus] = kReecbBusId; + converter.signal_inputs[RegcaSignalInputs::ipcmd] = kIpcmdSignalId; + converter.signal_inputs[RegcaSignalInputs::iqcmd] = kIqcmdSignalId; + converter.signal_outputs[RegcaSignalOutputs::pbranch] = kPbranchSignalId; + converter.signal_outputs[RegcaSignalOutputs::qbranch] = kQbranchSignalId; + converter.parameters[RegcaParameters::p0] = static_cast(0.4); + converter.parameters[RegcaParameters::q0] = static_cast(0.05); + converter.parameters[RegcaParameters::mva] = static_cast(100.0); + converter.parameters[RegcaParameters::Tg] = static_cast(0.02); + converter.parameters[RegcaParameters::TM] = static_cast(0.02); + converter.parameters[RegcaParameters::Rqmax] = static_cast(999.0); + converter.parameters[RegcaParameters::Rqmin] = static_cast(-999.0); + converter.parameters[RegcaParameters::Rpmax] = static_cast(999.0); + converter.parameters[RegcaParameters::sL] = true; + converter.parameters[RegcaParameters::IL1] = static_cast(1.1); + converter.parameters[RegcaParameters::VL0] = static_cast(0.4); + converter.parameters[RegcaParameters::VL1] = static_cast(0.9); + converter.parameters[RegcaParameters::VA0] = static_cast(0.4); + converter.parameters[RegcaParameters::VA1] = static_cast(0.9); + converter.parameters[RegcaParameters::Vhvmax] = static_cast(1.2); + + auto& controller = data.reecb.emplace_back(); + controller.buses[ReecbBuses::bus] = kReecbBusId; + controller.signal_inputs[ReecbSignalInputs::pe] = kPbranchSignalId; + controller.signal_inputs[ReecbSignalInputs::qgen] = kQbranchSignalId; + controller.signal_outputs[ReecbSignalOutputs::ipcmd] = kIpcmdSignalId; + controller.signal_outputs[ReecbSignalOutputs::iqcmd] = kIqcmdSignalId; + controller.parameters[ReecbParameters::mva] = static_cast(100.0); + controller.parameters[ReecbParameters::Trv] = static_cast(0.02); + controller.parameters[ReecbParameters::Tp] = static_cast(0.02); + controller.parameters[ReecbParameters::Kvi] = static_cast(5.0); + controller.parameters[ReecbParameters::QFlag] = true; + controller.parameters[ReecbParameters::VFlag] = true; + + SystemModel system(data); + success *= system.allocate() == 0; + success *= system.initialize() == 0; + + auto* regca = + dynamic_cast*>(system.getComponent(kConverterComponentId)); + auto* reecb = + dynamic_cast*>(system.getComponent(kControllerComponentId)); + if (regca == nullptr || reecb == nullptr) + { + success = false; + return success.report(__func__); + } + + const auto* equilibrium_values = system.y().getData(); + const std::vector equilibrium( + equilibrium_values, + equilibrium_values + static_cast(system.y().getSize())); + + // The displacements stay strictly inside every limiter, deadband, and + // voltage band, so the return path is a smooth interior trajectory. + auto* y = system.y().getData(); + y[reecb->getVariableIndex(static_cast(ReecbVar::VMEAS))] += kRecoveryDelta; + y[reecb->getVariableIndex(static_cast(ReecbVar::PMEAS))] -= kRecoveryDelta; + y[regca->getVariableIndex(static_cast(RegcaVar::IQ))] += kRecoveryDelta; + y[regca->getVariableIndex(static_cast(RegcaVar::IP))] -= kRecoveryDelta; + system.y().setDataUpdated(); + + AnalysisManager::Sundials::Ida ida(&system); + success *= ida.configureSimulation() == 0; + success *= ida.initializeSimulation(ZERO) == 0; + // The step callback keeps the model state current so the final point + // can be compared against the stored equilibrium. + success *= ida.runSimulation(kRecoveryHorizon, kRecoveryMonitorStep, [](RealT) {}) == 0; + + const auto* final_values = system.y().getData(); + for (size_t entry = 0; entry < equilibrium.size(); ++entry) + { + const RealT deviation = final_values[entry] - equilibrium[entry]; + if (!isEqual(deviation, ZERO, kRecoveryTolerance)) + { + std::cout << "State " << entry << " remains " << deviation + << " from its equilibrium after recovery\n"; + success = false; + } + } + + return success.report(__func__); + } + + private: + static constexpr IdxT kReecbBusId = static_cast(23); + static constexpr IdxT kIpcmdSignalId = static_cast(201); + static constexpr IdxT kIqcmdSignalId = static_cast(202); + static constexpr IdxT kPbranchSignalId = static_cast(203); + static constexpr IdxT kQbranchSignalId = static_cast(204); + static constexpr IdxT kConverterComponentId = static_cast(0); + static constexpr IdxT kControllerComponentId = static_cast(1); + + // The slowest closed-loop mode pairs the reactive and voltage + // integrators near three seconds, so the horizon settles well inside + // the tolerance. + static constexpr RealT kRecoveryDelta = static_cast(2.0e-3); + static constexpr RealT kRecoveryHorizon = static_cast(25.0); + static constexpr RealT kRecoveryMonitorStep = static_cast(1.0 / 60.0); + static constexpr RealT kRecoveryTolerance = static_cast(1.0e-6); }; } // namespace Testing } // namespace GridKit diff --git a/tests/IntegrationTests/PhasorDynamics/ReecbIntegrationTests.hpp b/tests/IntegrationTests/PhasorDynamics/ReecbIntegrationTests.hpp deleted file mode 100644 index a44129f01..000000000 --- a/tests/IntegrationTests/PhasorDynamics/ReecbIntegrationTests.hpp +++ /dev/null @@ -1,461 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace GridKit -{ - namespace Testing - { - /// Verify REECB signal attachment to REGCA, the coupled steady state, and - /// time-domain recovery of the closed loop. - template - class ReecbIntegrationTests - { - public: - using RealT = real_type; - using IdxT = index_type; - - /// Full command and branch-power feedback loop between REECB and REGCA. - TestOutcome regca() - { - return checkConnection(makeClosedLoopCase(), true, __func__); - } - - /// Command-only wiring; REECB reconstructs feedback from its commands. - TestOutcome regcaReconstructedFeedback() - { - return checkConnection(makeCommandOnlyCase(), false, __func__); - } - - /// Perturbation probes confirm response direction, rate, gating, and - /// priority coupling across the closed REGCA/REECB loop. - TestOutcome regcaLoopResponse() - { - using I = PhasorDynamics::Converter::ReecbInternalVariables; - using R = PhasorDynamics::Converter::RegcaInternalVariables; - - TestStatus success = true; - SystemT system(makeClosedLoopCase()); - - success *= system.allocate() == 0; - success *= system.initialize() == 0; - - auto* converter = dynamic_cast(system.getComponent(kConverterComponentId)); - auto* controller = dynamic_cast(system.getComponent(kControllerComponentId)); - - if (converter == nullptr || controller == nullptr) - { - success = false; - return success.report(__func__); - } - - const auto reecb = [&](I variable) - { return controller->getVariableIndex(static_cast(variable)); }; - const auto regca = [&](R variable) - { return converter->getVariableIndex(static_cast(variable)); }; - - const RealT d = kProbeDelta; - - success *= runProbes( - system, - { - {"converter reactive current chases the command", {reecb(I::IQCMD), d}, {}, regca(R::IQ), d / kTg}, - {"converter active current chases the command", {reecb(I::IPCMD), d}, {}, regca(R::IP), d / kTg}, - {"power measurement tracks the branch feedback", {regca(R::PBR), d}, {}, reecb(I::PMEAS), d / kTp}, - {"voltage measurement tracks the terminal magnitude", {reecb(I::VT), d}, {}, reecb(I::VMEAS), d / kTrv}, - {"controller restores its published reactive command", {reecb(I::IQCMD), d}, {}, reecb(I::IQCMD), -d}, - {"reactive command consumes low-priority headroom", {reecb(I::IQCMD), d}, {}, reecb(I::ILMAX), -(TWO * kInitialReactivePower + d) * d}, - {"active command follows the power order", {reecb(I::PORD), d}, {}, reecb(I::IPCMD), d}, - {"volt-var loop integrates a voltage rise downward", {reecb(I::VMEAS), d}, {}, reecb(I::XPIV), -kKvi * d}, - {"voltage dip freezes the volt-var integrator", {reecb(I::VT), -kDipDepth}, {reecb(I::VMEAS), d}, reecb(I::XPIV), ZERO}, - }); - - // The probes restore every state they touch: the system must still be - // at the initialized equilibrium. - success *= system.evaluateResidual() == 0; - success *= allNearZero(system.getResidual()); - - return success.report(__func__); - } - - /// Displaced measurement and current lag states integrate back to the - /// initialized equilibrium, closing the REGCA/REECB loop in time domain. - TestOutcome regcaLoopRecovery() - { - using I = PhasorDynamics::Converter::ReecbInternalVariables; - using R = PhasorDynamics::Converter::RegcaInternalVariables; - - TestStatus success = true; - SystemT system(makeClosedLoopCase()); - - success *= system.allocate() == 0; - success *= system.initialize() == 0; - - auto* converter = dynamic_cast(system.getComponent(kConverterComponentId)); - auto* controller = dynamic_cast(system.getComponent(kControllerComponentId)); - - if (converter == nullptr || controller == nullptr) - { - success = false; - return success.report(__func__); - } - - const auto* equilibrium_values = system.y().getData(); - const std::vector equilibrium( - equilibrium_values, - equilibrium_values + static_cast(system.y().getSize())); - - // The displacements stay strictly inside every limiter, deadband, and - // voltage band, so the return path is a smooth interior trajectory. - auto* y = system.y().getData(); - y[controller->getVariableIndex(static_cast(I::VMEAS))] += kRecoveryDelta; - y[controller->getVariableIndex(static_cast(I::PMEAS))] -= kRecoveryDelta; - y[converter->getVariableIndex(static_cast(R::IQ))] += kRecoveryDelta; - y[converter->getVariableIndex(static_cast(R::IP))] -= kRecoveryDelta; - system.y().setDataUpdated(); - - AnalysisManager::Sundials::Ida ida(&system); - success *= ida.configureSimulation() == 0; - success *= ida.initializeSimulation(ZERO) == 0; - // The step callback keeps the model state current so the final point - // can be compared against the stored equilibrium. - success *= ida.runSimulation(kRecoveryHorizon, kRecoveryMonitorStep, [](RealT) {}) == 0; - - const auto* final_values = system.y().getData(); - for (size_t entry = 0; entry < equilibrium.size(); ++entry) - { - const RealT deviation = std::abs(static_cast(final_values[entry]) - equilibrium[entry]); - if (deviation > kRecoveryTolerance) - { - std::cout << "State " << entry << " remains " << deviation - << " from its equilibrium after recovery\n"; - success = false; - } - } - - return success.report(__func__); - } - - private: - using SystemDataT = PhasorDynamics::SystemModelData; - using SystemT = PhasorDynamics::SystemModel; - using RegcaT = PhasorDynamics::Converter::Regca; - using ReecbT = PhasorDynamics::Converter::Reecb; - - static constexpr IdxT kBusId = static_cast(23); - static constexpr IdxT kIpcmdSignalId = static_cast(201); - static constexpr IdxT kIqcmdSignalId = static_cast(202); - static constexpr IdxT kPbranchSignalId = static_cast(203); - static constexpr IdxT kQbranchSignalId = static_cast(204); - static constexpr IdxT kConverterComponentId = static_cast(0); - static constexpr IdxT kControllerComponentId = static_cast(1); - - static constexpr RealT kSystemBaseVa = static_cast(100.0e6); - static constexpr RealT kConverterBaseMva = static_cast(100.0); - static constexpr RealT kInitialActivePower = static_cast(0.4); - static constexpr RealT kInitialReactivePower = static_cast(0.05); - - // Case parameters shared with the probe-response expectations. - static constexpr RealT kTg = static_cast(0.02); - static constexpr RealT kTp = static_cast(0.02); - static constexpr RealT kTrv = static_cast(0.02); - static constexpr RealT kKvi = static_cast(5.0); - - // Probes stay clear of every smoothing transition, so responses are - // exact; the dip lands the terminal voltage well below Vdip. - static constexpr RealT kProbeDelta = static_cast(1.0e-3); - static constexpr RealT kDipDepth = static_cast(0.5); - - // The slowest closed-loop mode of the case pairs the reactive and - // voltage integrators with a time constant near three seconds; the - // horizon leaves the displaced trajectory well inside the tolerance. - static constexpr RealT kRecoveryDelta = static_cast(2.0e-3); - static constexpr RealT kRecoveryHorizon = static_cast(25.0); - static constexpr RealT kRecoveryMonitorStep = static_cast(1.0 / 60.0); - static constexpr RealT kRecoveryTolerance = static_cast(1.0e-6); - - static constexpr RealT kTol = - static_cast(100.0) * std::numeric_limits::epsilon(); - - TestOutcome checkConnection(const SystemDataT& data, - bool feedback_attached, - const char* test_name) - { - using PhasorDynamics::Converter::ReecbExternalVariables; - using PhasorDynamics::Converter::ReecbInternalVariables; - using PhasorDynamics::Converter::RegcaExternalVariables; - using PhasorDynamics::Converter::RegcaInternalVariables; - - TestStatus success = true; - SystemT system(data); - - success *= system.allocate() == 0; - - auto* converter = dynamic_cast(system.getComponent(kConverterComponentId)); - auto* controller = dynamic_cast(system.getComponent(kControllerComponentId)); - auto* ipcmd = system.getSignal(kIpcmdSignalId); - auto* iqcmd = system.getSignal(kIqcmdSignalId); - auto* pbranch = feedback_attached ? system.getSignal(kPbranchSignalId) : nullptr; - auto* qbranch = feedback_attached ? system.getSignal(kQbranchSignalId) : nullptr; - - if (converter == nullptr || controller == nullptr || ipcmd == nullptr || iqcmd == nullptr - || (feedback_attached && (pbranch == nullptr || qbranch == nullptr))) - { - success = false; - return success.report(test_name); - } - - bool signals_linked = ipcmd->linked() && iqcmd->linked(); - if (feedback_attached) - { - signals_linked = signals_linked && pbranch->linked() && qbranch->linked(); - } - success *= signals_linked; - if (!signals_linked) - { - return success.report(test_name); - } - - auto& converter_signals = converter->getSignals(); - auto& controller_signals = controller->getSignals(); - - bool ports_connected = - controller_signals.template isAssigned() - && controller_signals.template isAssigned() - && converter_signals.template isAttached() - && converter_signals.template isAttached(); - if (feedback_attached) - { - ports_connected = ports_connected - && converter_signals.template isAssigned() - && converter_signals.template isAssigned() - && controller_signals.template isAttached() - && controller_signals.template isAttached(); - } - else - { - ports_connected = ports_connected - && !controller_signals.template isAttached() - && !controller_signals.template isAttached(); - } - success *= ports_connected; - if (!ports_connected) - { - return success.report(test_name); - } - - // Optional references stay unattached and latch their initialized setpoints. - success *= !controller_signals.template isAttached(); - success *= !controller_signals.template isAttached(); - success *= !controller_signals.template isAttached(); - - // Each linked signal is one shared global unknown: the node, the - // publishing state, and the subscribing port agree on its index. - const IdxT ipcmd_index = ipcmd->getVariableIndex(); - const IdxT iqcmd_index = iqcmd->getVariableIndex(); - - success *= ipcmd_index != iqcmd_index; - success *= ipcmd_index - == controller->getVariableIndex( - static_cast(ReecbInternalVariables::IPCMD)); - success *= ipcmd_index - == converter_signals.template readExternalVariableIndex< - RegcaExternalVariables::IPCMD>(); - success *= iqcmd_index - == controller->getVariableIndex( - static_cast(ReecbInternalVariables::IQCMD)); - success *= iqcmd_index - == converter_signals.template readExternalVariableIndex< - RegcaExternalVariables::IQCMD>(); - - if (feedback_attached) - { - const IdxT pbranch_index = pbranch->getVariableIndex(); - const IdxT qbranch_index = qbranch->getVariableIndex(); - - success *= pbranch_index != qbranch_index; - success *= pbranch_index != ipcmd_index; - success *= pbranch_index != iqcmd_index; - success *= qbranch_index != ipcmd_index; - success *= qbranch_index != iqcmd_index; - success *= pbranch_index - == converter->getVariableIndex( - static_cast(RegcaInternalVariables::PBR)); - success *= pbranch_index - == controller_signals.template readExternalVariableIndex< - ReecbExternalVariables::PE>(); - success *= qbranch_index - == converter->getVariableIndex( - static_cast(RegcaInternalVariables::QBR)); - success *= qbranch_index - == controller_signals.template readExternalVariableIndex< - ReecbExternalVariables::QGEN>(); - } - - // The published REGCA operating point initializes the coupled pair to - // an exact steady state. - success *= system.initialize() == 0; - success *= system.evaluateResidual() == 0; - success *= allNearZero(system.yp()); - success *= allNearZero(system.getResidual()); - - return success.report(test_name); - } - - /// One state write applied before a probe evaluation. - struct Write - { - IdxT state{INVALID_INDEX}; - RealT delta{}; - }; - - /// One perturbation and the exact residual response it must produce. - struct Probe - { - const char* label; - Write first; - Write second{}; - IdxT row{}; - RealT expected{}; - }; - - /// Apply each probe to the initialized system, check the responding - /// residual row against its exact expectation, and restore the state. - TestStatus runProbes(SystemT& system, std::initializer_list probes) - { - TestStatus success = true; - auto* y = system.y().getData(); - - for (const auto& probe : probes) - { - const RealT first_base = static_cast(y[probe.first.state]); - RealT second_base = ZERO; - - y[probe.first.state] = first_base + probe.first.delta; - if (probe.second.state != INVALID_INDEX) - { - second_base = static_cast(y[probe.second.state]); - y[probe.second.state] = second_base + probe.second.delta; - } - system.y().setDataUpdated(); - success *= system.evaluateResidual() == 0; - - const RealT response = static_cast(system.getResidual().getData()[probe.row]); - if (!isEqual(response, probe.expected, kTol)) - { - std::cout << "Probe '" << probe.label << "' expected " << probe.expected - << " but produced " << response << '\n'; - success = false; - } - - y[probe.first.state] = first_base; - if (probe.second.state != INVALID_INDEX) - { - y[probe.second.state] = second_base; - } - } - system.y().setDataUpdated(); - - return success; - } - - static SystemDataT makeCommandOnlyCase() - { - using namespace PhasorDynamics; - using namespace PhasorDynamics::Converter; - - SystemDataT data; - data.va_base = kSystemBaseVa; - - auto& bus = data.bus.emplace_back(); - bus.bus_id = kBusId; - bus.bus_type = BusData::BusType::SLACK; - bus.Vr0 = ONE; - bus.Vi0 = ZERO; - - data.signal = {{"Active Current Command", kIpcmdSignalId}, - {"Reactive Current Command", kIqcmdSignalId}}; - - auto& converter = data.regca.emplace_back(); - converter.buses[RegcaBuses::bus] = kBusId; - converter.signal_inputs[RegcaSignalInputs::ipcmd] = kIpcmdSignalId; - converter.signal_inputs[RegcaSignalInputs::iqcmd] = kIqcmdSignalId; - converter.parameters[RegcaParameters::p0] = kInitialActivePower; - converter.parameters[RegcaParameters::q0] = kInitialReactivePower; - converter.parameters[RegcaParameters::mva] = kConverterBaseMva; - converter.parameters[RegcaParameters::Tg] = kTg; - converter.parameters[RegcaParameters::TM] = static_cast(0.02); - converter.parameters[RegcaParameters::Rqmax] = static_cast(999.0); - converter.parameters[RegcaParameters::Rqmin] = static_cast(-999.0); - converter.parameters[RegcaParameters::Rpmax] = static_cast(999.0); - converter.parameters[RegcaParameters::sL] = true; - converter.parameters[RegcaParameters::IL1] = static_cast(1.1); - converter.parameters[RegcaParameters::VL0] = static_cast(0.4); - converter.parameters[RegcaParameters::VL1] = static_cast(0.9); - converter.parameters[RegcaParameters::VA0] = static_cast(0.4); - converter.parameters[RegcaParameters::VA1] = static_cast(0.9); - converter.parameters[RegcaParameters::Vhvmax] = static_cast(1.2); - - auto& controller = data.reecb.emplace_back(); - controller.buses[ReecbBuses::bus] = kBusId; - controller.signal_outputs[ReecbSignalOutputs::ipcmd] = kIpcmdSignalId; - controller.signal_outputs[ReecbSignalOutputs::iqcmd] = kIqcmdSignalId; - controller.parameters[ReecbParameters::mva] = kConverterBaseMva; - controller.parameters[ReecbParameters::Trv] = kTrv; - controller.parameters[ReecbParameters::Tp] = kTp; - controller.parameters[ReecbParameters::Kvi] = kKvi; - controller.parameters[ReecbParameters::QFlag] = true; - controller.parameters[ReecbParameters::VFlag] = true; - - return data; - } - - static SystemDataT makeClosedLoopCase() - { - using namespace PhasorDynamics; - using namespace PhasorDynamics::Converter; - - auto data = makeCommandOnlyCase(); - - data.signal.push_back({"Branch Active Power", kPbranchSignalId}); - data.signal.push_back({"Branch Reactive Power", kQbranchSignalId}); - - auto& converter = data.regca.front(); - converter.signal_outputs[RegcaSignalOutputs::pbranch] = kPbranchSignalId; - converter.signal_outputs[RegcaSignalOutputs::qbranch] = kQbranchSignalId; - - auto& controller = data.reecb.front(); - controller.signal_inputs[ReecbSignalInputs::pe] = kPbranchSignalId; - controller.signal_inputs[ReecbSignalInputs::qgen] = kQbranchSignalId; - - return data; - } - - template - static bool allNearZero(const VectorT& vector) - { - const auto* values = vector.getData(); - for (IdxT entry = 0; entry < vector.getSize(); ++entry) - { - if (!isEqual(values[entry], ZERO, kTol)) - { - return false; - } - } - return true; - } - }; - } // namespace Testing -} // namespace GridKit diff --git a/tests/IntegrationTests/PhasorDynamics/runPDIntegrationTests.cpp b/tests/IntegrationTests/PhasorDynamics/runPDIntegrationTests.cpp index 1f4c1fe36..714074c9b 100644 --- a/tests/IntegrationTests/PhasorDynamics/runPDIntegrationTests.cpp +++ b/tests/IntegrationTests/PhasorDynamics/runPDIntegrationTests.cpp @@ -12,6 +12,7 @@ int main() result += test.twoBusTgov1(); result += test.threeBusBasic(); result += test.threeBusClassical(); + result += test.regcaReecbRecovery(); return result.summary(); } diff --git a/tests/IntegrationTests/PhasorDynamics/runReecbIntegrationTests.cpp b/tests/IntegrationTests/PhasorDynamics/runReecbIntegrationTests.cpp deleted file mode 100644 index 47e0f34a5..000000000 --- a/tests/IntegrationTests/PhasorDynamics/runReecbIntegrationTests.cpp +++ /dev/null @@ -1,16 +0,0 @@ -#include - -#include "ReecbIntegrationTests.hpp" - -int main() -{ - GridKit::Testing::TestingResults result; - GridKit::Testing::ReecbIntegrationTests test; - - result += test.regca(); - result += test.regcaReconstructedFeedback(); - result += test.regcaLoopResponse(); - result += test.regcaLoopRecovery(); - - return result.summary(); -} diff --git a/tests/UnitTests/Math/SmoothnessIndicatorTests.hpp b/tests/UnitTests/Math/SmoothnessIndicatorTests.hpp index 77cb80085..caaae8ebc 100644 --- a/tests/UnitTests/Math/SmoothnessIndicatorTests.hpp +++ b/tests/UnitTests/Math/SmoothnessIndicatorTests.hpp @@ -345,73 +345,6 @@ namespace GridKit return success.report(__func__); } - - TestOutcome dynamicAntiWindupBounds() - { - TestStatus success = true; - - using Variable = GridKit::DependencyTracking::Variable; - - const Variable state{0.0, 0}; - const Variable rate{0.03, 1}; - const Variable lower{-0.05, 2}; - const Variable upper{0.05, 3}; - - const auto gate = Math::indicator(state, rate, lower, upper); - const auto limited = Math::antiwindup(state, rate, lower, upper); - - static_assert(std::is_same::type, - Variable>::value, - "Dynamic-bound indicator should retain dependency tracking."); - static_assert(std::is_same::type, - Variable>::value, - "Dynamic-bound antiwindup should retain dependency tracking."); - - success *= (gate.getValue() > kNearOne); - success *= within(limited.getValue(), rate.getValue(), kSmoothTolerance); - const auto& gate_dependencies = gate.getDependencies(); - const auto& limited_dependencies = limited.getDependencies(); - for (size_t variable = 0; variable < 4; ++variable) - { - success *= gate_dependencies.contains(variable); - success *= limited_dependencies.contains(variable); - } - for (const size_t bound : {size_t{2}, size_t{3}}) - { - const auto gate_bound = gate_dependencies.find(bound); - const auto limited_bound = limited_dependencies.find(bound); - if (gate_bound != gate_dependencies.end()) - { - success *= std::abs(gate_bound->second) > 0.0; - } - if (limited_bound != limited_dependencies.end()) - { - success *= std::abs(limited_bound->second) > 0.0; - } - } - - const RealT real_lower{-0.05}; - const Variable mixed_upper{0.05, 4}; - const auto mixed_gate = Math::indicator(state, rate, real_lower, mixed_upper); - static_assert(std::is_same::type, - Variable>::value, - "A real lower bound and dynamic upper bound should retain the scalar type."); - success *= mixed_gate.getDependencies().contains(4); - - const Variable equal_lower{0.0, 5}; - const Variable equal_upper{0.0, 6}; - const auto equal_gate = Math::indicator(state, rate, equal_lower, equal_upper); - const auto equal_limited = - Math::antiwindup(state, rate, equal_lower, equal_upper); - success *= within(equal_gate.getValue(), 0.75, kRoundoffTolerance); - success *= within(equal_limited.getValue(), 0.75 * rate.getValue(), kRoundoffTolerance); - success *= equal_gate.getDependencies().contains(5); - success *= equal_gate.getDependencies().contains(6); - success *= equal_limited.getDependencies().contains(5); - success *= equal_limited.getDependencies().contains(6); - - return success.report(__func__); - } }; } // namespace Testing diff --git a/tests/UnitTests/Math/runSmoothnessIndicatorTests.cpp b/tests/UnitTests/Math/runSmoothnessIndicatorTests.cpp index 3b6a14a12..aecdc7ae8 100644 --- a/tests/UnitTests/Math/runSmoothnessIndicatorTests.cpp +++ b/tests/UnitTests/Math/runSmoothnessIndicatorTests.cpp @@ -16,7 +16,6 @@ int main() result += test.minMax(); result += test.antiWindupIndicator(); result += test.antiWindup(); - result += test.dynamicAntiWindupBounds(); return result.summary(); } diff --git a/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp b/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp index a849c5c4e..b180abefb 100644 --- a/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include #include @@ -136,7 +138,7 @@ namespace GridKit return success.report(__func__); } - /// REGCA initializes first and publishes its branch current and power ++ /// REGCA initializes first and publishes its branch current and power /// to the four shared nodes. REPCA then initializes around those /// measurements and must hold a steady state without a frequency input. TestOutcome regcaRepca() @@ -228,6 +230,96 @@ namespace GridKit return success.report(__func__); } + + /// REGCA initializes first and publishes the current commands it + /// resolves to the shared nodes, alongside its branch powers. REECB + /// then initializes around all four published values and must leave + /// them unchanged at a steady state. + TestOutcome regcaReecb() + { + using ConverterExternal = PhasorDynamics::Converter::RegcaExternalVariables; + using ConverterInternal = PhasorDynamics::Converter::RegcaInternalVariables; + using ConverterParams = PhasorDynamics::Converter::RegcaParameters; + using ControllerExternal = PhasorDynamics::Converter::ReecbExternalVariables; + using ControllerInternal = PhasorDynamics::Converter::ReecbInternalVariables; + using ControllerParams = PhasorDynamics::Converter::ReecbParameters; + + TestStatus success = true; + + PhasorDynamics::SystemModel system; + PhasorDynamics::BusInfinite bus( + static_cast(1.0), + static_cast(0.0)); + PhasorDynamics::SignalNode ipcmd; + PhasorDynamics::SignalNode iqcmd; + PhasorDynamics::SignalNode pe; + PhasorDynamics::SignalNode qgen; + + // The operating point is exactly representable, so the pair rests at + // an exact steady state. + PhasorDynamics::Converter::RegcaData converter_data; + converter_data.parameters[ConverterParams::p0] = static_cast(0.375); + converter_data.parameters[ConverterParams::q0] = static_cast(0.0625); + converter_data.parameters[ConverterParams::mva] = static_cast(100.0); + converter_data.parameters[ConverterParams::Tg] = static_cast(0.02); + converter_data.parameters[ConverterParams::TM] = static_cast(0.02); + converter_data.parameters[ConverterParams::Rqmax] = static_cast(999.0); + converter_data.parameters[ConverterParams::Rqmin] = static_cast(-999.0); + converter_data.parameters[ConverterParams::Rpmax] = static_cast(999.0); + converter_data.parameters[ConverterParams::sL] = true; + converter_data.parameters[ConverterParams::IL1] = static_cast(1.1); + converter_data.parameters[ConverterParams::VL0] = static_cast(0.25); + converter_data.parameters[ConverterParams::VL1] = static_cast(0.75); + converter_data.parameters[ConverterParams::VA0] = static_cast(0.25); + converter_data.parameters[ConverterParams::VA1] = static_cast(0.75); + converter_data.parameters[ConverterParams::Vhvmax] = static_cast(1.5); + + PhasorDynamics::Converter::Regca converter(&bus, converter_data); + + PhasorDynamics::Converter::ReecbData controller_data; + controller_data.parameters[ControllerParams::mva] = static_cast(100.0); + controller_data.parameters[ControllerParams::Tp] = static_cast(0.02); + controller_data.parameters[ControllerParams::QFlag] = true; + controller_data.parameters[ControllerParams::VFlag] = true; + controller_data.parameters[ControllerParams::Pqflag] = true; + controller_data.parameters[ControllerParams::Imax] = static_cast(0.625); + controller_data.parameters[ControllerParams::Vmin] = static_cast(0.5); + controller_data.parameters[ControllerParams::Vmax] = static_cast(1.5); + + PhasorDynamics::Converter::Reecb controller(&bus, controller_data); + + controller.getSignals().template assignSignalNode(&ipcmd); + controller.getSignals().template assignSignalNode(&iqcmd); + converter.getSignals().template attachSignalNode(&ipcmd); + converter.getSignals().template attachSignalNode(&iqcmd); + converter.getSignals().template assignSignalNode(&pe); + converter.getSignals().template assignSignalNode(&qgen); + controller.getSignals().template attachSignalNode(&pe); + controller.getSignals().template attachSignalNode(&qgen); + + system.addBus(&bus); + system.addComponent(&converter); + system.addComponent(&controller); + + success *= system.allocate() == 0; + success *= ipcmd.linked() && iqcmd.linked() && pe.linked() && qgen.linked(); + success *= system.initialize() == 0; + success *= system.evaluateResidual() == 0; + + // At unit terminal voltage the shared nodes carry the scheduled powers. + success *= isEqual(ipcmd.read(), static_cast(0.375), kTol); + success *= isEqual(iqcmd.read(), static_cast(0.0625), kTol); + success *= isEqual(pe.read(), static_cast(0.375), kTol); + success *= isEqual(qgen.read(), static_cast(0.0625), kTol); + + const auto* residual = controller.getResidual().getData(); + for (IdxT row = 0; row < controller.size(); ++row) + { + success *= isEqual(residual[row], static_cast(0.0), kTol); + } + + return success.report(__func__); + } }; } // namespace Testing diff --git a/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp b/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp index 42625c708..9eb954d38 100644 --- a/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp @@ -1,18 +1,17 @@ #pragma once -#include #include -#include +#include #include #include #include +#include #include #include #include #include #include -#include #include #include #include @@ -41,28 +40,29 @@ namespace GridKit ConverterReecbTests() = default; ~ConverterReecbTests() = default; - // Covers accumulated roundoff from smooth inverses, base round trips, and AD evaluation. - static constexpr RealT kTol = static_cast(100) * std::numeric_limits::epsilon(); + // The tolerance only absorbs floating-point roundoff. + static constexpr RealT kTol = std::numeric_limits::epsilon(); - // A smooth clamp or deadband evaluated exactly at a limit sits log(2)/mu - // inside it; boundary expectations below are stated with this offset. - static RealT clampEdgeOffset() - { - return std::log(static_cast(2)) / Math::MU; - } + // Probes that sit inside a smooth transition carry its MU-dependent tail + // rather than roundoff alone. + static constexpr RealT kTolSmooth = static_cast(100.0) * kTol; - /// Construction, row layout, differential tags, parameters, buses, and signal-link validation use direct contract checks. + /// Validate construction, row layout, defaults, parameters, buses, + /// signal links, and the time-constant floor. TestOutcome validation() { TestStatus success = true; - noteExpectedLogs("Testing invalid REECB configurations. Logged errors and time-constant warnings are expected."); - PhasorDynamics::Bus bus(1.0, 0.0); + noteExpectedLogs("Testing REECB defaults, parameter floors, and invalid " + "configurations. Logged errors and warnings are expected."); + + PhasorDynamics::Bus bus(1.0, 0.0); + PhasorDynamics::Converter::Reecb empty(&bus); - success *= (empty.size() == static_cast(10)); + success *= (empty.size() == static_cast(index(Vars::MAXIMUM))); success *= (empty.getMonitor() == nullptr); - const std::array row_order{{ + const std::array row_order{{ Vars::VMEAS, Vars::PMEAS, Vars::XPIQ, @@ -79,59 +79,103 @@ namespace GridKit success *= (index(row_order[row]) == row); } - PhasorDynamics::Converter::Reecb configured(&bus, makeData()); - configured.setSystemBase(60.0, 100.0e6); - success *= (configured.size() == static_cast(Vars::MAXIMUM)); - success *= (configured.getMonitor() != nullptr); - success *= (configured.verify() == 0); - success *= (configured.initialize() != 0); - success *= (configured.allocate() == 0); - success *= (configured.tagDifferentiable() == 0); + Fixture configured(makeData()); + success *= (configured.reecb.size() == static_cast(index(Vars::MAXIMUM))); + success *= (configured.reecb.getMonitor() != nullptr); + success *= (configured.reecb.verify() == 0); + success *= (configured.reecb.initialize() != 0); + success *= (configured.reecb.allocate() == 0); + success *= (configured.reecb.tagDifferentiable() == 0); + success *= (static_cast(configured.reecb.getResidual().getSize()) + == index(Vars::MAXIMUM)); for (size_t row = 0; row < index(Vars::MAXIMUM); ++row) { const bool expected = row <= index(Vars::PORD); - if (configured.tag()[row] != expected) + if (configured.reecb.tag()[row] != expected) { - std::cout << "REECB differential tag " << row << " mismatch\n"; + std::cout << "REECB differentiability tag " << row << " mismatch\n"; success = false; } } + Fixture documented_defaults(makeMinimalData()); + success *= (documented_defaults.reecb.verify() == 0); + success *= defaultsMatchDocumentedValues(); + + // Integer JSON values are accepted for real parameters; booleans are + // not numeric. + auto integer_numeric = makeData(); + integer_numeric.parameters[Params::mva] = static_cast(100); + Fixture integer_parameter(integer_numeric); + success *= (integer_parameter.reecb.verify() == 0); + success *= invalidParameterCase(Params::mva, true); + const RealT nan = std::numeric_limits::quiet_NaN(); const RealT infinity = std::numeric_limits::infinity(); - for (const Params parameter : {Params::mva, Params::Trv, Params::Tp, Params::Vref0, Params::Vdip, Params::Vup, Params::dbd1, Params::dbd2, Params::kqv, Params::Iql1, Params::Iqh1, Params::Qmax, Params::Qmin, Params::Kqp, Params::Kqi, Params::Vmax, Params::Vmin, Params::Kvp, Params::Kvi, Params::Tiq, Params::Tpord, Params::dPmax, Params::dPmin, Params::Pmax, Params::Pmin, Params::Imax}) - { - success *= invalidParameterCase(bus, parameter, nan); + for (const Params parameter : {Params::mva, + Params::Trv, + Params::Tp, + Params::Vref0, + Params::Vdip, + Params::Vup, + Params::dbd1, + Params::dbd2, + Params::kqv, + Params::Iql1, + Params::Iqh1, + Params::Qmax, + Params::Qmin, + Params::Kqp, + Params::Kqi, + Params::Vmax, + Params::Vmin, + Params::Kvp, + Params::Kvi, + Params::Tiq, + Params::Tpord, + Params::dPmax, + Params::dPmin, + Params::Pmax, + Params::Pmin, + Params::Imax}) + { + success *= invalidParameterCase(parameter, nan); + success *= invalidParameterCase(parameter, infinity); + success *= invalidParameterCase(parameter, -infinity); } - success *= invalidParameterCase(bus, Params::mva, 0.0); - success *= invalidParameterCase(bus, Params::Trv, -0.1); - success *= invalidParameterCase(bus, Params::Vdip, 1.2); - success *= invalidParameterCase(bus, Params::dbd1, 0.1); - success *= invalidParameterCase(bus, Params::Iql1, 2.0); - success *= invalidParameterCase(bus, Params::Qmin, 2.0); - success *= invalidParameterCase(bus, Params::Vmin, 2.0); - success *= invalidParameterCase(bus, Params::dPmin, 0.0); - success *= invalidParameterCase(bus, Params::dPmax, 0.0); - success *= invalidParameterCase(bus, Params::Pmin, 2.0); - success *= invalidParameterCase(bus, Params::Imax, 0.0); - success *= invalidParameterCase(bus, Params::Trv, std::numeric_limits::infinity()); - success *= invalidParameterCase(bus, Params::Imax, -std::numeric_limits::infinity()); - success *= invalidParameterCase(bus, Params::mva, true); - - for (const Params flag : {Params::PfFlag, Params::VFlag, Params::QFlag, Params::Pqflag}) + success *= invalidParameterCase(Params::mva, 0.0); + success *= invalidParameterCase(Params::Trv, -0.1); + success *= invalidParameterCase(Params::Vdip, 2.0); + success *= invalidParameterCase(Params::dbd1, 0.1); + success *= invalidParameterCase(Params::dbd2, -0.1); + success *= invalidParameterCase(Params::Iql1, 2.0); + success *= invalidParameterCase(Params::Qmin, 3.0); + success *= invalidParameterCase(Params::Vmin, 2.0); + success *= invalidParameterCase(Params::dPmin, 0.0); + success *= invalidParameterCase(Params::dPmax, 0.0); + success *= invalidParameterCase(Params::Pmin, 3.0); + success *= invalidParameterCase(Params::Imax, 0.0); + + for (const Params flag : {Params::PfFlag, + Params::VFlag, + Params::QFlag, + Params::Pqflag}) { for (const bool value : {false, true}) { - success *= !invalidParameterCase(bus, flag, value); + auto data = makeData(); + data.parameters[flag] = value; + Fixture model(data); + success *= (model.reecb.verify() == 0); } for (const IdxT value : {static_cast(0), static_cast(1), static_cast(2)}) { - success *= invalidParameterCase(bus, flag, value); + success *= invalidParameterCase(flag, value); } for (const RealT value : {static_cast(0.0), @@ -140,348 +184,364 @@ namespace GridKit nan, infinity}) { - success *= invalidParameterCase(bus, flag, value); + success *= invalidParameterCase(flag, value); } } PhasorDynamics::Converter::Reecb busless(nullptr, makeData()); - busless.setSystemBase(60.0, 100.0e6); + busless.setSystemBase(kNominalFrequency, kSystemBaseVa); success *= (busless.verify() > 0); - success *= unlinkedSignalRejected(bus); - success *= unlinkedSignalRejected(bus); - success *= unlinkedSignalRejected(bus); - success *= unlinkedSignalRejected(bus); - success *= unlinkedSignalRejected(bus); - - auto zero_time = makeData(); - zero_time.parameters[Params::Trv] = 0.0; - zero_time.parameters[Params::Tp] = 0.0; - zero_time.parameters[Params::Tiq] = 0.0; - zero_time.parameters[Params::Tpord] = 0.0; - Fixture floored(zero_time); - success *= floored.initialize(0.0, 0.2); + + success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); + + auto floor_data = makeData(); + floor_data.parameters[Params::Trv] = 0.0; + floor_data.parameters[Params::Tp] = 0.0; + floor_data.parameters[Params::Tiq] = 0.0; + floor_data.parameters[Params::Tpord] = 0.0; + + Fixture floored(floor_data); + success *= floored.initialize(kInitialIqcmd, kInitialIpcmd); + success *= (floored.evaluate() == 0); + success *= allResidualsAtRest(floored.reecb); + + // Each floored lag turns a half-unit state offset into a rate of 500, + // and saturates the active-power ramp limiter. + setState(floored.reecb, {{Vars::VMEAS, 0.5}}); success *= (floored.evaluate() == 0); - success *= allResidualsZero(floored.reecb); - - auto* floored_y = floored.reecb.y().getData(); - floored_y[index(Vars::VMEAS)] = 0.999; - floored_y[index(Vars::PMEAS)] = 0.199; - floored_y[index(Vars::QV)] = 0.001; - floored_y[index(Vars::PORD)] = 0.1995; - floored.reecb.y().setDataUpdated(); + success *= residualsMatch(floored.reecb, + {{Vars::VMEAS, 500.0}}, + "floored voltage filter"); + + setState(floored.reecb, + {{Vars::VMEAS, 1.0}, + {Vars::PMEAS, 1.0}, + {Vars::QV, 1.0}, + {Vars::PORD, 1.0}}); success *= (floored.evaluate() == 0); - success *= scalarMatches(floored.reecb.getResidual().getData()[index(Vars::VMEAS)], 1.0, "Trv 1 ms floor"); - success *= scalarMatches(floored.reecb.getResidual().getData()[index(Vars::PMEAS)], 1.0, "Tp 1 ms floor"); - success *= scalarMatches(floored.reecb.getResidual().getData()[index(Vars::QV)], -1.0, "Tiq 1 ms floor"); - success *= scalarMatches(floored.reecb.getResidual().getData()[index(Vars::PORD)], 0.5, "Tpord 1 ms floor"); - - Data default_data; - Fixture defaulted(default_data); - success *= defaulted.initialize(0.1, 0.2); - success *= (defaulted.evaluate() == 0); - success *= allResidualsZero(defaulted.reecb); - success *= scalarMatches(defaulted.reecb.y().getData()[index(Vars::ILMAX)], std::sqrt(1.68), "omitted parameter defaults"); + success *= residualsMatch(floored.reecb, + {{Vars::PMEAS, 500.0}, + {Vars::QV, 500.0}, + {Vars::PORD, 1.0}}, + "floored time constants"); return success.report(__func__); } - /// Nonidentity-base initialization checks known-input preservation, unknown publication, output aliases, latches, and monitor values. + /// Check initialization state, known-input preservation, unknown-reference + /// publication, command aliases, latches, monitors, and the power base. TestOutcome initializationAndSignals() { TestStatus success = true; - auto data = makeData(); - data.parameters[Params::mva] = 50.0; - - Fixture fixture(data, 0.8, 0.6); + Fixture fixture(makeData(), 0.8, 0.6); fixture.attachAllInputs(99.0); - fixture.input(Ext::PE) = 0.3; - fixture.input(Ext::QGEN) = -0.05; - success *= fixture.initialize(0.05, 0.25); + fixture.input(Ext::PE) = kInitialIpcmd; + fixture.input(Ext::QGEN) = kInitialIqcmd; + success *= fixture.initialize(kInitialIqcmd, kInitialIpcmd); success *= (fixture.evaluate() == 0); - const auto* y = fixture.reecb.y().getData(); - success *= scalarMatches(y[index(Vars::VMEAS)], 1.0, "VMEAS"); - success *= scalarMatches(y[index(Vars::PMEAS)], 0.6, "PMEAS"); - success *= scalarMatches(y[index(Vars::PORD)], 0.5, "PORD"); - success *= scalarMatches(y[index(Vars::VT)], 1.0, "VT"); - success *= scalarMatches(y[index(Vars::ILMAX)], 1.9364916731037085, "ILMAX"); - success *= (fixture.iqcmd() == 0.05); - success *= (fixture.ipcmd() == 0.25); - success *= (fixture.input(Ext::PE) == 0.3); - success *= (fixture.input(Ext::QGEN) == -0.05); - success *= scalarMatches(fixture.input(Ext::QEXT), 0.05, "published QEXT"); - success *= scalarMatches(fixture.input(Ext::PFAREF), 0.0, "published PFAREF"); - success *= scalarMatches(fixture.input(Ext::PREF), 0.25, "published PREF"); - success *= allResidualsZero(fixture.reecb); - - RealT time = 0.0; - Model::VariableMonitorController monitor(time); - monitor.addMonitor(fixture.reecb.getMonitor()); - std::stringstream output; - monitor.addSink({Model::VariableMonitorFormat::CSV}, output); - monitor.start(); - monitor.print(); - monitor.stop(); - - std::string header; - std::string values; - std::getline(output, header); - std::getline(output, values); - success *= (header == "t,Reecb_reecb_test_iqcmd,Reecb_reecb_test_ipcmd,Reecb_reecb_test_vmeas,Reecb_reecb_test_pmeas"); - const auto monitored = Tokenizer(values, ',')(); - if (monitored.size() == 5) - { - success *= scalarMatches(monitored[1], 0.05, "monitored IQCMD"); - success *= scalarMatches(monitored[2], 0.25, "monitored IPCMD"); - success *= scalarMatches(monitored[3], 1.0, "monitored VMEAS"); - success *= scalarMatches(monitored[4], 0.6, "monitored PMEAS"); - } - else + success *= stateMatches(fixture.reecb, + {{Vars::VMEAS, 1.0}, + {Vars::PMEAS, 1.5}, + {Vars::XPIQ, 0.0}, + {Vars::XPIV, 0.0}, + {Vars::QV, 1.5}, + {Vars::PORD, 1.5}, + {Vars::VT, 1.0}, + {Vars::ILMAX, 2.0}}, + "initialization"); + + success *= scalarPreserved(fixture.iqcmd(), kInitialIqcmd, "preserved iqcmd"); + success *= scalarPreserved(fixture.ipcmd(), kInitialIpcmd, "preserved ipcmd"); + success *= scalarPreserved(fixture.input(Ext::PE), kInitialIpcmd, "preserved pe"); + success *= scalarPreserved(fixture.input(Ext::QGEN), kInitialIqcmd, "preserved qgen"); + success *= scalarMatches(fixture.input(Ext::QEXT), 0.75, "published qext"); + success *= scalarMatches(fixture.input(Ext::PFAREF), 0.0, "published pfaref"); + success *= scalarMatches(fixture.input(Ext::PREF), 0.75, "published pref"); + success *= allResidualsAtRest(fixture.reecb); + + success *= monitorMatches(fixture.reecb, + {{kInitialIqcmd, kInitialIpcmd, 1.0, 1.5}}, + "initialization"); + + constexpr RealT absolute_tolerance = 2.5e-7; + success *= (fixture.reecb.setAbsoluteTolerance(absolute_tolerance) == 0); + const auto* tolerances = fixture.reecb.absoluteTolerance().getData(); + for (size_t row = 0; row < index(Vars::MAXIMUM); ++row) { - std::cout << "REECB monitor emitted " << monitored.size() << " values instead of 5\n"; - success = false; + success *= valueUnchanged(tolerances[row], absolute_tolerance, "absolute tolerance", row); } - Fixture latched(data, 1.0, 0.0, 100.0e6, false); - success *= latched.initialize(0.05, 0.25); + // Unassigned command outputs keep the commands in the model vector. + Fixture latched(makeData(), 1.0, 0.0, kSystemBaseVa, false); + success *= latched.initialize(kInitialIqcmd, kInitialIpcmd); success *= (latched.evaluate() == 0); - success *= allResidualsZero(latched.reecb); + success *= scalarPreserved(latched.iqcmd(), kInitialIqcmd, "unassigned iqcmd"); + success *= scalarPreserved(latched.ipcmd(), kInitialIpcmd, "unassigned ipcmd"); + success *= allResidualsAtRest(latched.reecb); + // An omitted component rating falls back to the system power base, so + // the same commands land on a different measured power. auto system_base_data = makeData(); system_base_data.parameters.erase(Params::mva); - Fixture system_base(system_base_data, 1.0, 0.0, 80.0e6); - success *= system_base.initialize(0.05, 0.25); - success *= (system_base.evaluate() == 0); - success *= allResidualsZero(system_base.reecb); - success *= scalarMatches(system_base.reecb.y().getData()[index(Vars::PMEAS)], 0.25, "omitted mva PMEAS base"); - success *= scalarMatches(system_base.reecb.y().getData()[index(Vars::PORD)], 0.25, "omitted mva PORD base"); - success *= scalarMatches(system_base.reecb.y().getData()[index(Vars::ILMAX)], std::sqrt(3.9375), "omitted mva ILMAX base"); + Fixture system_base(system_base_data, 1.0, 0.0, kSystemBaseVa); + system_base.attachAllInputs(); + system_base.input(Ext::PE) = 0.75; + success *= system_base.initialize(kInitialIqcmd, 1.5); + success *= (system_base.evaluate() == 0); + success *= stateMatches(system_base.reecb, + {{Vars::PMEAS, 0.75}, + {Vars::PORD, 1.5}, + {Vars::ILMAX, 2.0}}, + "omitted component rating"); + success *= allResidualsAtRest(system_base.reecb); return success.report(__func__); } - /// Strict inverse-limiter domains, collapsed limits, zero gains, and failure atomicity are checked at accepted and rejected points. + /// Check initialization rejection, atomicity, and the admissible points + /// next to each rejected one. TestOutcome initializationDomain() { TestStatus success = true; - noteExpectedLogs("Testing inadmissible REECB initialization points. Logged errors are expected."); + noteExpectedLogs("Testing inadmissible REECB initialization points. " + "Logged errors are expected."); + + const auto data = makeData(); - success *= initializationRejectedAtomically(makeData(), 0.0, 0.0, 1.0, "zero active-current endpoint"); - success *= initializationRejectedAtomically(makeData(), 0.0, 2.0, 1.0, "zero ILMAX at active-current endpoint"); + // The active-current command must stay strictly inside its limiter, + // and the current circle must leave low-priority capacity. + success *= initializationRejectedAtomically(data, 0.75, 0.0, "zero active-current command"); + success *= initializationRejectedAtomically(data, 0.75, 1.25, "active-current command at its limit"); + success *= initializationRejectedAtomically(data, 0.75, 1.5, "active-current command beyond the current circle"); - const RealT iq_endpoint = std::sqrt(4.0 - 0.2 * 0.2); - success *= initializationRejectedAtomically(makeData(), iq_endpoint, 0.2, 1.0, "reactive-current endpoint"); - success *= initializationRejectedAtomically(makeData(), -iq_endpoint, 0.2, 1.0, "negative reactive-current endpoint"); + // The reactive-current command endpoints are the low-priority limit. + success *= initializationRejectedAtomically(data, 1.0, 0.75, "reactive-current command at its limit"); + success *= initializationRejectedAtomically(data, -1.0, 0.75, "negative reactive-current command at its limit"); - auto q_priority = makeData(); + auto q_priority = data; q_priority.parameters[Params::Pqflag] = false; - success *= initializationRejectedAtomically(q_priority, 0.2, iq_endpoint, 1.0, "Q-priority active-current endpoint"); - - auto pord_limit = makeData(); - pord_limit.parameters[Params::Pmax] = 0.25; - success *= initializationRejectedAtomically(pord_limit, 0.0, 0.5, 1.0, "recovered PORD above Pmax"); - success *= initializationRejectedAtomically(makeData(), 0.0, 1.0e-6, 1.0, "recovered PORD below Pmin"); - - auto q_endpoint = makeData(); - q_endpoint.parameters[Params::QFlag] = true; - q_endpoint.parameters[Params::VFlag] = true; - q_endpoint.parameters[Params::Kqi] = 0.4; - success *= initializationRejectedAtomically(q_endpoint, 0.0, 0.2, 1.0, "QGEN at Qmax", 0.2, 1.0); - success *= initializationRejectedAtomically(q_endpoint, 0.0, 0.2, 1.0, "QGEN at Qmin", 0.2, -1.0); - - auto v_endpoint = q_endpoint; - v_endpoint.parameters[Params::Kqi] = 0.0; - v_endpoint.parameters[Params::Kvi] = 0.5; - success *= initializationRejectedAtomically(v_endpoint, 0.0, 0.2, 1.2, "voltage at Vmax", 0.24, 0.0); - - // At Vmin the saturated Q-PI output equals the measured voltage, so - // this boundary point is a consistent equilibrium and initializes. - Fixture v_boundary(v_endpoint, 0.8); - v_boundary.attachAllInputs(); - v_boundary.input(Ext::PE) = 0.16; - success *= v_boundary.initialize(0.0, 0.2); - success *= (v_boundary.evaluate() == 0); - success *= allResidualsZero(v_boundary.reecb); - - auto zero_power = makeData(); - zero_power.parameters[Params::PfFlag] = true; - success *= initializationRejectedAtomically(zero_power, 0.1, 0.2, 1.0, "power-factor target at zero active power", 0.0, 0.1); - - auto pf_resolution = makeData(); - pf_resolution.parameters[Params::PfFlag] = true; - success *= initializationRejectedAtomically( - pf_resolution, 0.1, 0.2, 0.01, "unrepresentable power-factor reference", 1.0e-8); - - success *= initializationRejectedAtomically(makeData(), 0.0, 0.2, 0.0, "zero terminal voltage"); - success *= initializationRejectedAtomically(makeData(), 0.0, 0.2, 1.0, "nonfinite PE", std::numeric_limits::infinity(), 0.0); - - auto collapsed = makeData(); - collapsed.parameters[Params::QFlag] = true; - collapsed.parameters[Params::VFlag] = true; - collapsed.parameters[Params::Kqi] = 0.4; - collapsed.parameters[Params::Kvi] = 0.5; - collapsed.parameters[Params::Qmin] = 0.0; - collapsed.parameters[Params::Qmax] = 0.0; - collapsed.parameters[Params::Vmin] = 1.0; - collapsed.parameters[Params::Vmax] = 1.0; - Fixture collapsed_fixture(collapsed); - collapsed_fixture.attachAllInputs(); - collapsed_fixture.input(Ext::PE) = 0.2; - success *= collapsed_fixture.initialize(0.0, 0.2); - success *= (collapsed_fixture.evaluate() == 0); - success *= allResidualsZero(collapsed_fixture.reecb); - - auto collapsed_q = collapsed; - collapsed_q.parameters[Params::Vmin] = 0.8; - collapsed_q.parameters[Params::Vmax] = 1.2; - collapsed_q.parameters[Params::Kvi] = 0.0; - success *= initializationRejectedAtomically(collapsed_q, 0.0, 0.2, 1.0, "collapsed Q limit away from equilibrium", 0.2, 0.1); - - auto collapsed_v = collapsed; - collapsed_v.parameters[Params::Qmin] = -1.0; - collapsed_v.parameters[Params::Qmax] = 1.0; - collapsed_v.parameters[Params::Kqi] = 0.0; - collapsed_v.parameters[Params::Vmin] = 1.1; - collapsed_v.parameters[Params::Vmax] = 1.1; - success *= initializationRejectedAtomically(collapsed_v, 0.0, 0.2, 1.0, "collapsed V limit away from equilibrium", 0.2, 0.0); - - auto zero_gains = makeData(); - zero_gains.parameters[Params::QFlag] = true; - zero_gains.parameters[Params::VFlag] = true; - zero_gains.parameters[Params::Kqi] = 0.0; - zero_gains.parameters[Params::Kvi] = 0.0; + success *= initializationRejectedAtomically(q_priority, 0.75, 1.0, "Q-priority active-current command at its limit"); + + auto pord_above = data; + pord_above.parameters[Params::Pmax] = 1.0; + success *= initializationRejectedAtomically(pord_above, 0.75, 0.75, "recovered active-power order above Pmax"); + + auto pord_below = data; + pord_below.parameters[Params::Pmin] = 2.0; + success *= initializationRejectedAtomically(pord_below, 0.75, 0.75, "recovered active-power order below Pmin"); + + // The reactive-power integrator cannot hold a command outside the + // reactive-power limits. + auto reactive_pi = data; + reactive_pi.parameters[Params::QFlag] = true; + reactive_pi.parameters[Params::VFlag] = true; + reactive_pi.parameters[Params::Kqi] = 0.4; + success *= initializationRejectedAtomically(reactive_pi, 0.75, 0.75, "reactive feedback at Qmax", 0.75, 1.0); + success *= initializationRejectedAtomically(reactive_pi, 0.75, 0.75, "reactive feedback at Qmin", 0.75, -1.0); + + // The voltage-control integrator cannot hold a measured voltage the + // saturated Q-PI output does not reproduce. + auto voltage_pi = reactive_pi; + voltage_pi.parameters[Params::Kqi] = 0.0; + voltage_pi.parameters[Params::Kvi] = 0.5; + success *= initializationRejectedAtomically(voltage_pi, 0.75, 0.75, "measured voltage above Vmax", 0.96, 0.8, 1.6); + + // On a voltage limit the smooth Q-PI output only approaches the + // measurement, so an integrating voltage path has no equilibrium + // there. Collapsed limits pin it exactly and are admitted below. + success *= initializationRejectedAtomically(voltage_pi, 0.75, 0.75, "measured voltage at Vmin", 0.3, 0.3, 0.5); + + // Power-factor control needs a representable angle, so a vanishing or + // near-vanishing active power is rejected. + auto power_factor = data; + power_factor.parameters[Params::PfFlag] = true; + success *= initializationRejectedAtomically(power_factor, 0.75, 0.75, "power-factor target at zero active power", 0.0, 0.75); + success *= initializationRejectedAtomically(power_factor, 0.75, 0.75, "unrepresentable power-factor reference", 1.0e-8, 0.75); + + success *= initializationRejectedAtomically(data, 0.75, 0.75, "zero terminal voltage", 0.75, 0.75, 0.0); + success *= initializationRejectedAtomically(data, 0.75, 0.75, "nonfinite active-power feedback", std::numeric_limits::infinity(), 0.75); + + // Collapsed reactive and voltage limits admit only the equilibrium + // they pin, and reject every other operating point. + auto collapsed = reactive_pi; + collapsed.parameters[Params::Kvi] = 0.5; + collapsed.parameters[Params::Qmin] = 1.2; + collapsed.parameters[Params::Qmax] = 1.2; + collapsed.parameters[Params::Vmin] = 1.0; + collapsed.parameters[Params::Vmax] = 1.0; + Fixture collapsed_limits(collapsed); + collapsed_limits.attachAllInputs(); + collapsed_limits.input(Ext::PE) = 0.6; + collapsed_limits.input(Ext::QGEN) = 0.6; + success *= collapsed_limits.initialize(0.75, 0.75); + success *= (collapsed_limits.evaluate() == 0); + success *= allResidualsAtRest(collapsed_limits.reecb); + + auto collapsed_reactive = collapsed; + collapsed_reactive.parameters[Params::Vmin] = 0.5; + collapsed_reactive.parameters[Params::Vmax] = 1.5; + collapsed_reactive.parameters[Params::Kvi] = 0.0; + success *= initializationRejectedAtomically(collapsed_reactive, 0.75, 0.75, "collapsed reactive limit away from equilibrium", 0.75, 0.3); + + auto collapsed_voltage = collapsed; + collapsed_voltage.parameters[Params::Qmin] = -2.0; + collapsed_voltage.parameters[Params::Qmax] = 2.0; + collapsed_voltage.parameters[Params::Kqi] = 0.0; + collapsed_voltage.parameters[Params::Vmin] = 1.4; + collapsed_voltage.parameters[Params::Vmax] = 1.4; + success *= initializationRejectedAtomically(collapsed_voltage, 0.75, 0.75, "collapsed voltage limit away from equilibrium", 0.75, 0.75); + + // Zero integral gains leave both controllers unconstrained, so any + // reactive feedback initializes. + auto zero_gains = reactive_pi; + zero_gains.parameters[Params::Kqi] = 0.0; + zero_gains.parameters[Params::Kvi] = 0.0; Fixture unconstrained(zero_gains); unconstrained.attachAllInputs(); - unconstrained.input(Ext::PE) = 0.2; + unconstrained.input(Ext::PE) = 0.75; unconstrained.input(Ext::QGEN) = 4.0; - success *= unconstrained.initialize(0.0, 0.2); + success *= unconstrained.initialize(0.75, 0.75); success *= (unconstrained.evaluate() == 0); - success *= allResidualsZero(unconstrained.reecb); - - auto unattached_data = makeData(); - unattached_data.parameters[Params::QFlag] = true; - unattached_data.parameters[Params::VFlag] = true; - unattached_data.parameters[Params::Kqi] = 0.4; - unattached_data.parameters[Params::Kvi] = 0.5; - unattached_data.parameters[Params::kqv] = 1.0; - unattached_data.parameters.erase(Params::Vref0); - Fixture unattached(unattached_data, 1.2); - success *= unattached.prepare(0.05, 0.2); - setControlState(unattached.reecb); - success *= (unattached.evaluate() == 0); - const auto residual_before = snapshot(unattached.reecb.getResidual()); - success *= (unattached.reecb.initialize() != 0); - success *= (unattached.evaluate() == 0); - success *= vectorUnchanged(unattached.reecb.getResidual(), residual_before, "unattached residual"); + success *= allResidualsAtRest(unconstrained.reecb); + + // An invalid configuration is rejected before any state is written. + auto invalid_data = data; + invalid_data.parameters[Params::Imax] = 0.0; + Fixture invalid_fixture(invalid_data); + invalid_fixture.attachAllInputs(); + success *= (invalid_fixture.reecb.allocate() == 0); + poisonState(invalid_fixture, 0.75, 0.75); + const auto invalid_y = copyVector(invalid_fixture.reecb.y()); + const auto invalid_yp = copyVector(invalid_fixture.reecb.yp()); + if (invalid_fixture.reecb.initialize() == 0) + { + std::cout << "Expected REECB initialization rejection: invalid configuration\n"; + success = false; + } + success *= vectorUnchanged(invalid_fixture.reecb.y(), invalid_y, "state"); + success *= vectorUnchanged(invalid_fixture.reecb.yp(), invalid_yp, "derivative"); return success.report(__func__); } - /// Fixed near-endpoint commands check that the private smooth inverse reproduces each requested command without an artificial offset. + /// The private smooth-limiter inverse reproduces every requested command, + /// including commands pressed against a limit. TestOutcome initializationExactness() { TestStatus success = true; - auto data = makeData(); - data.parameters[Params::Pmin] = -1.0; - data.parameters[Params::Pmax] = 3.0; - struct ExactnessCase { RealT ipcmd; - RealT pord; const char* label; }; - const std::array cases{{ - {1.0e-6, -0.034728131800926182, "near lower active-current limit"}, - {0.2, 0.2, "interior active-current command"}, - {1.999999, 2.0347281318012689, "near upper active-current limit"}, + const std::array active_cases{{ + {1.0e-6, "near the lower active-current limit"}, + {0.75, "interior active-current command"}, + {1.249999, "near the upper active-current limit"}, }}; - for (const auto& test_case : cases) + // The recovered order limits are widened so the reconstruction, not + // the order limit, decides admissibility at the command endpoints. + auto exactness_data = makeData(); + exactness_data.parameters[Params::Pmin] = -1.0; + exactness_data.parameters[Params::Pmax] = 3.0; + + for (const auto& test_case : active_cases) { - Fixture fixture(data); + Fixture fixture(exactness_data); success *= fixture.initialize(0.0, test_case.ipcmd); success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.y().getData()[index(Vars::PORD)], test_case.pord, test_case.label); - success *= (fixture.ipcmd() == test_case.ipcmd); - success *= allResidualsZero(fixture.reecb); + success *= scalarPreserved(fixture.ipcmd(), test_case.ipcmd, test_case.label); + success *= allResidualsAtRest(fixture.reecb, kTolSmooth); } - const std::array pord_boundaries{{ - {clampEdgeOffset(), 0.0, "PORD at Pmin"}, - {1.0, 1.0, "PORD at Pmax"}, - }}; - - for (const auto& test_case : pord_boundaries) - { - Fixture fixture(makeData()); - success *= fixture.initialize(0.0, test_case.ipcmd); - success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.y().getData()[index(Vars::PORD)], test_case.pord, test_case.label); - success *= allResidualsZero(fixture.reecb); - } - - const RealT pmax = 0.2 - 0.5 * kTol; - auto near_pmax = makeData(); - near_pmax.parameters[Params::Pmax] = pmax; - Fixture exact(near_pmax); - success *= exact.initialize(0.0, 0.2); - success *= (exact.evaluate() == 0); - const RealT recovered_pord = static_cast(exact.reecb.y().getData()[index(Vars::PORD)]); - success *= (recovered_pord > pmax); - success *= (recovered_pord < pmax + kTol); - success *= allResidualsZero(exact.reecb); - - const RealT iqmax = std::sqrt(3.96); - Fixture reactive(data); - success *= reactive.initialize(iqmax - 1.0e-6, 0.2); - success *= (reactive.evaluate() == 0); - success *= scalarMatches(reactive.reecb.y().getData()[index(Vars::QV)], 2.0247030060145095, "near upper reactive-current limit"); - success *= allResidualsZero(reactive.reecb); + // An interior command recovers the ideal active-power order exactly. + Fixture interior(makeData()); + success *= interior.initialize(0.0, 0.75); + success *= (interior.evaluate() == 0); + success *= stateMatches(interior.reecb, + {{Vars::PORD, 1.5}}, + "interior active-power order"); + + // A command whose recovered order lands exactly on the order limit is + // admitted rather than rejected. + auto limit_data = makeData(); + limit_data.parameters[Params::Pmax] = 1.5; + Fixture at_limit(limit_data); + success *= at_limit.initialize(0.0, 0.75); + success *= (at_limit.evaluate() == 0); + success *= stateMatches(at_limit.reecb, {{Vars::PORD, 1.5}}, "order at Pmax"); + success *= allResidualsAtRest(at_limit.reecb); + + // The reactive command shares the inverse, at both signs. + for (const RealT iqcmd : {static_cast(0.999999), static_cast(-0.999999)}) + { + Fixture reactive(exactness_data); + success *= reactive.initialize(iqcmd, 0.75); + success *= (reactive.evaluate() == 0); + success *= scalarPreserved(reactive.iqcmd(), iqcmd, "near-limit reactive command"); + success *= allResidualsAtRest(reactive.reecb, kTolSmooth); + } return success.report(__func__); } - /// One independently calculated literal answer key checks all ten residual rows at a rich non-equilibrium state. + /// Check every residual row against an independent numerical answer key. + /// The expected values are literals, not a second implementation of REECB. TestOutcome residualEquations() { TestStatus success = true; - Fixture fixture(makeDynamicData(), 0.9, 0.4); + Fixture fixture(makeResidualData(), kStateVr, kStateVi); fixture.attachAllInputs(); setAnswerKeyInputs(fixture); - success *= fixture.prepare(0.25, 0.4); + success *= fixture.prepare(0.25, 0.35); setAnswerKeyState(fixture.reecb); success *= (fixture.evaluate() == 0); - const std::array expected{{ - {Vars::VMEAS, "VMEAS", 0.24000000000000021}, // -VMEAS' + (VT - VMEAS) / Trv - {Vars::PMEAS, "PMEAS", 0.14499999999999982}, // -PMEAS' + (kbase PE - PMEAS) / Tp - {Vars::XPIQ, "XPIQ", 0.083249747972647115}, // -XPIQ' + sQPI sdip antiwindup(Kqp eq + XPIQ, Kqi eq; Vmin, Vmax) - {Vars::XPIV, "XPIV", -0.095000000000000001}, // -XPIV' + sQ sdip antiwindup(Kvp epiv + XPIV, Kvi epiv; -Iqmax, Iqmax) - {Vars::QV, "QV", -0.050000000000000003}, // -QV' + sQoff sdip (qref / vsafe - QV) / Tiq - {Vars::PORD, "PORD", 0.25999999999999973}, // -PORD' + sdip antiwindup(PORD, rpord; Pmin, Pmax) - {Vars::VT, "VT", -0.029999999999999916}, // -VT^2 + Vr^2 + Vi^2 - {Vars::ILMAX, "ILMAX", 0.16999999999999993}, // -ILMAX |ILMAX| + Imax^2 - sPQ (kbase IPCMD)^2 - sPQoff (kbase IQCMD)^2 - {Vars::IQCMD, "IQCMD", -0.76999943561644268}, // -kbase IQCMD + clamp(iqraw; -Iqmax, Iqmax) - {Vars::IPCMD, "IPCMD", -0.11578947368421055}, // -kbase IPCMD + clamp(PORD / vsafe; 0, Ipmax) + const std::array expected{{ + {Vars::VMEAS, "VMEAS", 0.99}, + {Vars::PMEAS, "PMEAS", 0.145}, + {Vars::XPIQ, "XPIQ", 0.21}, + {Vars::XPIV, "XPIV", 0.13}, + {Vars::QV, "QV", -0.05}, + {Vars::PORD, "PORD", 0.26}, + {Vars::VT, "VT", -0.03}, + {Vars::ILMAX, "ILMAX", 0.32}, + {Vars::IQCMD, "IQCMD", 0.19}, + {Vars::IPCMD, "IPCMD", 0.05}, }}; - const auto* residual = fixture.reecb.getResidual().getData(); - for (const auto& answer : expected) + success *= (static_cast(fixture.reecb.getResidual().getSize()) == expected.size()); + const auto* residual = fixture.reecb.getResidual().getData(); + for (size_t row = 0; row < expected.size(); ++row) { - success *= scalarMatches(residual[index(answer.row)], answer.value, answer.name); + if (index(expected[row].variable) != row) + { + std::cout << "REECB residual key position " << row << " names row " + << expected[row].name << '\n'; + success = false; + } + success *= scalarMatches(residual[index(expected[row].variable)], + expected[row].value, + expected[row].name); } return success.report(__func__); } - /// Every valid selector combination initializes attached and unattached signals to a - /// zero-residual state; power-factor control with the direct-voltage reference is rejected. + /// Every valid selector combination initializes attached and unattached + /// signals to a zero-residual state; power-factor control with the + /// direct-voltage reference is rejected. TestOutcome selectorConfigurations() { TestStatus success = true; @@ -511,49 +571,48 @@ namespace GridKit if (attached) { fixture.attachAllInputs(7.0); - fixture.input(Ext::PE) = 0.2; - fixture.input(Ext::QGEN) = 0.1; + fixture.input(Ext::PE) = 0.75; + fixture.input(Ext::QGEN) = 0.75; } if (pf && reactive && !voltage) { success *= (fixture.reecb.verify() > 0); - success *= !fixture.initialize(0.1, 0.2); + success *= !fixture.initialize(0.75, 0.75); continue; } - success *= fixture.initialize(0.1, 0.2); + success *= fixture.initialize(0.75, 0.75); success *= (fixture.evaluate() == 0); - success *= allResidualsZero(fixture.reecb); - success *= (fixture.iqcmd() == 0.1); - success *= (fixture.ipcmd() == 0.2); - - const auto* y = fixture.reecb.y().getData(); - const RealT expected_ilmax = p_priority ? std::sqrt(3.96) : std::sqrt(3.99); - success *= scalarMatches(y[index(Vars::ILMAX)], expected_ilmax, "selector ILMAX"); + success *= allResidualsAtRest(fixture.reecb); + success *= scalarPreserved(fixture.iqcmd(), 0.75, "selector iqcmd"); + success *= scalarPreserved(fixture.ipcmd(), 0.75, "selector ipcmd"); + success *= stateMatches(fixture.reecb, {{Vars::ILMAX, 2.0}}, "selector ILMAX"); + // Exactly one reactive path carries the operating point. + const auto* y = fixture.reecb.y().getData(); if (reactive) { - success *= std::abs(y[index(Vars::XPIV)]) > kTol; + success *= (y[index(Vars::XPIV)] != ZERO); if (voltage) { - success *= std::abs(y[index(Vars::XPIQ)]) > kTol; + success *= (y[index(Vars::XPIQ)] != ZERO); } } else { - success *= std::abs(y[index(Vars::QV)]) > kTol; + success *= (y[index(Vars::QV)] != ZERO); } if (attached) { - success *= (fixture.input(Ext::PE) == 0.2); - success *= (fixture.input(Ext::QGEN) == 0.1); - const RealT qref = reactive && !voltage ? 1.0 : 0.1; - const RealT expected_pfaref = pf ? 0.4636476090008061 : 0.0; - success *= scalarMatches(fixture.input(Ext::QEXT), qref, "selector QEXT publication"); - success *= scalarMatches(fixture.input(Ext::PFAREF), expected_pfaref, "selector PFAREF publication"); - success *= scalarMatches(fixture.input(Ext::PREF), 0.2, "selector PREF publication"); + success *= scalarPreserved(fixture.input(Ext::PE), 0.75, "selector pe"); + success *= scalarPreserved(fixture.input(Ext::QGEN), 0.75, "selector qgen"); + const RealT expected_qext = reactive && !voltage ? 1.0 : 0.75; + const RealT expected_pfaref = pf ? kQuarterTurn : 0.0; + success *= scalarMatches(fixture.input(Ext::QEXT), expected_qext, "published qext"); + success *= scalarMatches(fixture.input(Ext::PFAREF), expected_pfaref, "published pfaref"); + success *= scalarMatches(fixture.input(Ext::PREF), 0.75, "published pref"); } } } @@ -564,545 +623,468 @@ namespace GridKit return success.report(__func__); } - /// Direct-voltage mode consumes and publishes the Volt/VAr reference without - /// power-base conversion; reactive modes convert on the nonidentity base. + /// Direct-voltage mode consumes and publishes the Volt/VAr reference + /// without power-base conversion; the reactive modes convert it. TestOutcome voltVarReferenceBase() { TestStatus success = true; { - // 50 MVA component on the 100 MVA system selects direct-voltage mode. auto data = makeData(); - data.parameters[Params::mva] = 50.0; data.parameters[Params::QFlag] = true; data.parameters[Params::VFlag] = false; data.parameters[Params::Kvi] = 0.5; Fixture fixture(data); fixture.attachAllInputs(); - success *= fixture.initialize(0.1, 0.2); + success *= fixture.initialize(0.75, 0.75); success *= scalarMatches(fixture.input(Ext::QEXT), 1.0, "published voltage reference"); success *= (fixture.evaluate() == 0); - success *= allResidualsZero(fixture.reecb); + success *= allResidualsAtRest(fixture.reecb); // A raised external voltage reference enters the V-PI rate raw. - fixture.input(Ext::QEXT) = 1.02; + fixture.input(Ext::QEXT) = 1.2; success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIV)], 0.01, "unconverted voltage-reference rate"); + success *= residualsMatch(fixture.reecb, + {{Vars::XPIV, 0.1}}, + "unconverted voltage-reference rate"); } { - // The reactive-current lag keeps the power-base conversion. - auto data = makeData(); - data.parameters[Params::mva] = 50.0; - - Fixture fixture(data); + Fixture fixture(makeData()); fixture.attachAllInputs(); - success *= fixture.initialize(0.1, 0.2); - success *= scalarMatches(fixture.input(Ext::QEXT), 0.1, "published system-base reactive power"); + success *= fixture.initialize(0.75, 0.75); + success *= scalarMatches(fixture.input(Ext::QEXT), 0.75, "published system-base reactive power"); success *= (fixture.evaluate() == 0); - success *= allResidualsZero(fixture.reecb); + success *= allResidualsAtRest(fixture.reecb); - fixture.input(Ext::QEXT) = 0.11; + // The reactive-current lag keeps the power-base conversion, so the + // same raise produces twice the component-base rate. + fixture.input(Ext::QEXT) = 0.85; success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::QV)], 1.0, "converted reactive-reference rate"); + success *= residualsMatch(fixture.reecb, + {{Vars::QV, 10.0}}, + "converted reactive-reference rate"); } return success.report(__func__); } - /// Literal selector, voltage-gate, limiter, and anti-windup cases check the reactive-control paths. + /// Check the reactive selector paths, the voltage-band gate, the + /// reactive limits, both anti-windup gates, and the injection curve. TestOutcome reactiveControl() { TestStatus success = true; { - auto data = makeDynamicData(); - data.parameters[Params::PfFlag] = false; - data.parameters[Params::QFlag] = false; - data.parameters[Params::kqv] = 0.0; + // The constant-reactive path drives the current-command lag. + auto data = makeResidualData(); + data.parameters[Params::QFlag] = false; + data.parameters[Params::kqv] = 0.0; Fixture fixture(data); fixture.attachAllInputs(); fixture.input(Ext::QEXT) = 0.4; success *= fixture.prepare(0.0, 0.2); setControlState(fixture.reecb); - fixture.reecb.y().getData()[index(Vars::QV)] = 0.1; - fixture.reecb.y().setDataUpdated(); + setState(fixture.reecb, {{Vars::QV, 0.1}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, {{Vars::QV, 1.4}}, "constant-reactive lag"); + + setState(fixture.reecb, {{Vars::VT, 0.0}}); success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::QV)], 2.3333333333333335, "constant-Q lag"); + success *= residualsMatch(fixture.reecb, {{Vars::QV, 0.0}}, "gated constant-reactive lag"); } { - auto data = makeDynamicData(); - data.parameters[Params::PfFlag] = false; - data.parameters[Params::QFlag] = true; - data.parameters[Params::VFlag] = true; - data.parameters[Params::kqv] = 0.0; + // Cascaded Volt/VAr control runs the reactive PI and bypasses the lag. + auto data = makeResidualData(); + data.parameters[Params::QFlag] = true; + data.parameters[Params::VFlag] = true; + data.parameters[Params::kqv] = 0.0; Fixture fixture(data); fixture.attachAllInputs(); fixture.input(Ext::QEXT) = 0.1; fixture.input(Ext::QGEN) = -0.05; success *= fixture.prepare(0.0, 0.2); setControlState(fixture.reecb); - fixture.reecb.y().getData()[index(Vars::XPIQ)] = 1.0; - fixture.reecb.y().setDataUpdated(); + setState(fixture.reecb, {{Vars::XPIQ, 0.82}}); success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIQ)], 0.11999999999996271, "Q-control integral rate"); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::QV)], 0.0, "bypassed Q lag"); - } + success *= residualsMatch(fixture.reecb, + {{Vars::XPIQ, 0.12}, {Vars::QV, 0.0}}, + "reactive-power integral rate"); - { - auto data = makeDynamicData(); - data.parameters[Params::PfFlag] = false; - data.parameters[Params::QFlag] = true; - data.parameters[Params::VFlag] = false; - data.parameters[Params::kqv] = 0.0; - Fixture fixture(data); - fixture.attachAllInputs(); - fixture.input(Ext::QEXT) = 1.05; - success *= fixture.prepare(0.0, 0.2); - setControlState(fixture.reecb); + setState(fixture.reecb, {{Vars::VT, 0.0}}); success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIQ)], 0.0, "bypassed Q-control integrator"); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIV)], 0.025, "voltage-control integral rate"); + success *= residualsMatch(fixture.reecb, {{Vars::XPIQ, 0.0}}, "gated reactive-power integrator"); } { - auto data = makeDynamicData(); - data.parameters[Params::PfFlag] = false; - data.parameters[Params::QFlag] = false; - data.parameters[Params::kqv] = 0.0; + // The direct-voltage reference bypasses the reactive PI. + auto data = makeResidualData(); + data.parameters[Params::QFlag] = true; + data.parameters[Params::VFlag] = false; + data.parameters[Params::kqv] = 0.0; Fixture fixture(data); fixture.attachAllInputs(); - fixture.input(Ext::QEXT) = 0.4; + fixture.input(Ext::QEXT) = 1.05; success *= fixture.prepare(0.0, 0.2); setControlState(fixture.reecb); - auto* y = fixture.reecb.y().getData(); - y[index(Vars::QV)] = 0.1; - y[index(Vars::VT)] = 0.5; - fixture.reecb.y().setDataUpdated(); success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::QV)], 0.0, "constant-Q voltage-band gate"); - } + success *= residualsMatch(fixture.reecb, + {{Vars::XPIQ, 0.0}, {Vars::XPIV, 0.025}}, + "voltage-control integral rate"); - { - auto data = makeDynamicData(); - data.parameters[Params::PfFlag] = false; - data.parameters[Params::QFlag] = true; - data.parameters[Params::VFlag] = true; - data.parameters[Params::kqv] = 0.0; - data.parameters[Params::Qmin] = -2.0; - data.parameters[Params::Qmax] = 2.0; - data.parameters[Params::Kqp] = 0.0; - Fixture fixture(data); - fixture.attachAllInputs(); - fixture.input(Ext::QEXT) = 0.5; - fixture.input(Ext::QGEN) = 0.0; - success *= fixture.prepare(0.0, 0.2); - setControlState(fixture.reecb); - auto* y = fixture.reecb.y().getData(); - y[index(Vars::XPIQ)] = 1.0; - y[index(Vars::VT)] = 0.5; - fixture.reecb.y().setDataUpdated(); + setState(fixture.reecb, {{Vars::VT, 2.0}}); success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIQ)], 0.0, "Q-integrator voltage-band gate"); + success *= residualsMatch(fixture.reecb, {{Vars::XPIV, 0.0}}, "gated voltage-control integrator"); } { - auto data = makeDynamicData(); - data.parameters[Params::PfFlag] = false; - data.parameters[Params::QFlag] = true; - data.parameters[Params::VFlag] = false; - data.parameters[Params::kqv] = 0.0; - data.parameters[Params::Kvp] = 0.0; - Fixture fixture(data); - fixture.attachAllInputs(); - fixture.input(Ext::QEXT) = 1.1; - success *= fixture.prepare(0.0, 0.2); - setControlState(fixture.reecb); - auto* y = fixture.reecb.y().getData(); - y[index(Vars::XPIV)] = 0.0; - y[index(Vars::VT)] = 0.5; - fixture.reecb.y().setDataUpdated(); - success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIV)], 0.0, "V-integrator voltage-band gate"); + // The reactive-power reference is limited before the error forms. + auto data = makeResidualData(); + data.parameters[Params::QFlag] = true; + data.parameters[Params::VFlag] = true; + data.parameters[Params::kqv] = 0.0; + data.parameters[Params::Kqp] = 0.0; + + const std::array reference_cases{{ + {-0.6, -0.28}, + {0.05, 0.04}, + {0.6, 0.32}, + }}; + for (const auto& test_case : reference_cases) + { + Fixture fixture(data); + fixture.attachAllInputs(); + fixture.input(Ext::QEXT) = test_case.input; + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + setState(fixture.reecb, {{Vars::XPIQ, 1.0}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, + {{Vars::XPIQ, test_case.expected}}, + "reactive-power reference limit"); + } } - struct ReactiveCase - { - RealT input; - RealT state; - RealT expected; - const char* label; - }; - - const std::array q_limit_cases{{ - {-1.0, 1.0, -0.27999999999999997, "Q reference below Qmin"}, - {-0.7, 1.0, 0.4 * (-0.7 + clampEdgeOffset()), "Q reference at Qmin"}, - {0.1, 1.0, 0.039999999999999994, "Q reference inside limits"}, - {0.8, 1.0, 0.4 * (0.8 - clampEdgeOffset()), "Q reference at Qmax"}, - {1.0, 1.0, 0.32000000000000006, "Q reference above Qmax"}, - }}; - - for (const auto& test_case : q_limit_cases) { - auto data = makeDynamicData(); - data.parameters[Params::PfFlag] = false; - data.parameters[Params::QFlag] = true; - data.parameters[Params::VFlag] = true; - data.parameters[Params::kqv] = 0.0; - data.parameters[Params::Kqp] = 0.0; - Fixture fixture(data); - fixture.attachAllInputs(); - fixture.input(Ext::QEXT) = test_case.input / 2.0; - fixture.input(Ext::QGEN) = 0.0; - success *= fixture.prepare(0.0, 0.2); - setControlState(fixture.reecb); - fixture.reecb.y().getData()[index(Vars::XPIQ)] = test_case.state; - fixture.reecb.y().setDataUpdated(); - success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIQ)], test_case.expected, test_case.label); + // Saturated probes sit beyond their limit by a margin, so a blocked + // gate contributes nothing and an admitted gate passes the full rate. + auto data = makeResidualData(); + data.parameters[Params::QFlag] = true; + data.parameters[Params::VFlag] = true; + data.parameters[Params::kqv] = 0.0; + data.parameters[Params::Kqp] = 0.0; + data.parameters[Params::Qmin] = -3.0; + data.parameters[Params::Qmax] = 3.0; + + const std::array antiwindup_cases{{ + {2.2, 1.0, 0.0}, + {2.2, -1.0, -0.8}, + {-0.2, -1.0, 0.0}, + {-0.2, 1.0, 0.8}, + {1.0, 1.0, 0.8}, + }}; + for (const auto& test_case : antiwindup_cases) + { + Fixture fixture(data); + fixture.attachAllInputs(); + fixture.input(Ext::QEXT) = test_case.reference; + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + setState(fixture.reecb, {{Vars::XPIQ, test_case.state}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, + {{Vars::XPIQ, test_case.expected}}, + "reactive-power antiwindup"); + } } - const std::array q_windup_cases{{ - {1.0, 2.0, 0.0, "outward Q-integrator rate above Vmax"}, - {1.0, 1.3, 0.2, "outward Q-integrator rate at Vmax"}, - {-1.0, 2.0, -0.4, "restoring Q-integrator rate above Vmax"}, - {-1.0, 0.0, 0.0, "outward Q-integrator rate below Vmin"}, - {-1.0, 0.7, -0.2, "outward Q-integrator rate at Vmin"}, - {1.0, 0.0, 0.4, "restoring Q-integrator rate below Vmin"}, - }}; - - for (const auto& test_case : q_windup_cases) { - auto data = makeDynamicData(); - data.parameters[Params::PfFlag] = false; - data.parameters[Params::QFlag] = true; - data.parameters[Params::VFlag] = true; - data.parameters[Params::kqv] = 0.0; - data.parameters[Params::Qmin] = -2.0; - data.parameters[Params::Qmax] = 2.0; - data.parameters[Params::Kqp] = 0.0; - Fixture fixture(data); - fixture.attachAllInputs(); - fixture.input(Ext::QEXT) = test_case.input / 2.0; - fixture.input(Ext::QGEN) = 0.0; - success *= fixture.prepare(0.0, 0.2); - setControlState(fixture.reecb); - fixture.reecb.y().getData()[index(Vars::XPIQ)] = test_case.state; - fixture.reecb.y().setDataUpdated(); - success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIQ)], test_case.expected, test_case.label); + // The voltage-control integrator saturates on the reactive-current + // limit carried by the current circle. + auto data = makeResidualData(); + data.parameters[Params::QFlag] = true; + data.parameters[Params::VFlag] = false; + data.parameters[Params::kqv] = 0.0; + data.parameters[Params::Kvp] = 0.0; + data.parameters[Params::Kvi] = 1.0; + + const std::array antiwindup_cases{{ + {1.0, 1.6, 0.0}, + {1.0, 0.4, -0.6}, + {-1.0, 0.4, 0.0}, + {-1.0, 1.6, 0.6}, + {0.0, 1.6, 0.6}, + }}; + for (const auto& test_case : antiwindup_cases) + { + Fixture fixture(data); + fixture.attachAllInputs(); + fixture.input(Ext::QEXT) = test_case.reference; + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + setState(fixture.reecb, + {{Vars::XPIV, test_case.state}, {Vars::ILMAX, 0.5}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, + {{Vars::XPIV, test_case.expected}}, + "voltage-control antiwindup"); + } } - const std::array v_limit_cases{{ - {0.0, 0.0, -0.30000000000000004, "V-PI input below Vmin"}, - {0.7, 0.0, -0.3 + clampEdgeOffset(), "V-PI input at Vmin"}, - {1.0, 0.0, 0.0, "V-PI input inside limits"}, - {1.3, 0.0, 0.3 - clampEdgeOffset(), "V-PI input at Vmax"}, - {2.0, 0.0, 0.30000000000000004, "V-PI input above Vmax"}, - }}; - - for (const auto& test_case : v_limit_cases) { - auto data = makeDynamicData(); - data.parameters[Params::PfFlag] = false; - data.parameters[Params::QFlag] = true; - data.parameters[Params::VFlag] = true; - data.parameters[Params::kqv] = 0.0; - data.parameters[Params::Kqp] = 0.0; - data.parameters[Params::Kvi] = 1.0; - data.parameters[Params::Kvp] = 0.0; - Fixture fixture(data); - fixture.attachAllInputs(); - fixture.input(Ext::QEXT) = 0.0; - fixture.input(Ext::QGEN) = 0.0; - success *= fixture.prepare(0.0, 0.2); - setControlState(fixture.reecb); - auto* y = fixture.reecb.y().getData(); - y[index(Vars::XPIQ)] = test_case.input; - y[index(Vars::XPIV)] = test_case.state; - fixture.reecb.y().setDataUpdated(); - success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIV)], test_case.expected, test_case.label); + // With the reactive lag at zero the command row reads the injection + // curve directly: a deadbanded voltage error scaled and limited. + auto data = makeResidualData(); + data.parameters[Params::QFlag] = false; + data.parameters[Params::kqv] = 1.0; + data.parameters[Params::dbd1] = -0.6; + data.parameters[Params::dbd2] = 0.6; + data.parameters[Params::Iql1] = -1.2; + data.parameters[Params::Iqh1] = 1.5; + data.parameters[Params::Vref0] = 3.0; + + const std::array injection_cases{{ + {5.5, -1.2}, + {4.2, -0.6}, + {3.0, 0.0}, + {1.8, 0.6}, + {0.4, 1.5}, + }}; + for (const auto& test_case : injection_cases) + { + Fixture fixture(data); + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + setState(fixture.reecb, + {{Vars::VMEAS, test_case.input}, + {Vars::IQCMD, 0.0}, + {Vars::ILMAX, 3.0}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, + {{Vars::IQCMD, test_case.expected}}, + "reactive-current injection"); + } } - const std::array v_windup_cases{{ - {0.5, 2.0, 0.0, "outward V-integrator rate above Iqmax"}, - {0.5, 1.4, 0.25, "outward V-integrator rate at Iqmax"}, - {-0.5, 2.0, -0.5, "restoring V-integrator rate above Iqmax"}, - {-0.5, -2.0, 0.0, "outward V-integrator rate below negative Iqmax"}, - {-0.5, -1.4, -0.25, "outward V-integrator rate at negative Iqmax"}, - {0.5, -2.0, 0.5, "restoring V-integrator rate below negative Iqmax"}, - }}; - - for (const auto& test_case : v_windup_cases) { - auto data = makeDynamicData(); - data.parameters[Params::PfFlag] = false; - data.parameters[Params::QFlag] = true; - data.parameters[Params::VFlag] = false; + // Power-factor control resolves the reactive reference from the + // measured active power and the commanded angle. + auto data = makeResidualData(); + data.parameters[Params::PfFlag] = true; + data.parameters[Params::QFlag] = false; data.parameters[Params::kqv] = 0.0; - data.parameters[Params::Kvp] = 0.0; Fixture fixture(data); fixture.attachAllInputs(); - fixture.input(Ext::QEXT) = test_case.input > 0.0 ? 2.0 : 0.0; - success *= fixture.prepare(0.0, 0.2); - setControlState(fixture.reecb); - fixture.reecb.y().getData()[index(Vars::XPIV)] = test_case.state; - fixture.reecb.y().setDataUpdated(); - success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::XPIV)], test_case.expected, test_case.label); - } - - // With QFlag = 0 and a zero lag state the reactive command is the - // injection alone, so the IQCMD row reads iqv = clamp(kqv deadband2( - // Vref0 - VMEAS; dbd1, dbd2); Iql1, Iqh1) directly. The wide deadband - // and limit gaps keep every smoothing tail below kTol except at the - // tested edges, which sit exactly one clampEdgeOffset() inside. - const std::array injection_cases{{ - {1.75, 0.0, -0.4, "injection saturated at Iql1"}, - {1.2, 0.0, -clampEdgeOffset(), "injection at lower deadband breakpoint"}, - {1.0, 0.0, 0.0, "injection inside deadband"}, - {0.75, 0.0, clampEdgeOffset(), "injection at upper deadband breakpoint"}, - {0.55, 0.0, 0.2, "injection passthrough above deadband"}, - {0.25, 0.0, 0.5 - clampEdgeOffset(), "injection at Iqh1 edge"}, - {0.1, 0.0, 0.5, "injection saturated at Iqh1"}, - }}; - - for (const auto& test_case : injection_cases) - { - auto data = makeData(); - data.parameters[Params::kqv] = 1.0; - data.parameters[Params::dbd1] = -0.2; - data.parameters[Params::dbd2] = 0.25; - data.parameters[Params::Iql1] = -0.4; - data.parameters[Params::Iqh1] = 0.5; - Fixture fixture(data); - success *= fixture.prepare(0.0, 0.2); + fixture.input(Ext::PFAREF) = kHalfSlopeAngle; + success *= fixture.prepare(0.0, 0.2); setControlState(fixture.reecb); - fixture.reecb.y().getData()[index(Vars::VMEAS)] = test_case.input; - fixture.reecb.y().setDataUpdated(); + setState(fixture.reecb, {{Vars::PMEAS, 0.6}, {Vars::QV, 0.1}}); success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::IQCMD)], test_case.expected, test_case.label); + success *= residualsMatch(fixture.reecb, + {{Vars::QV, 0.4}}, + "power-factor reference"); } return success.report(__func__); } - /// Literal ramp, voltage-gate, anti-windup, command-limit, current-circle, and signed-continuation cases check the active/current paths. + /// Check the active-power ramp, its voltage gate and anti-windup, both + /// command limits, the priority circle, and the signed continuation. TestOutcome activeCurrentControl() { TestStatus success = true; - struct ScalarCase { - RealT input; - RealT expected; - const char* label; - }; - - const std::array rate_cases{{ - {-1.0, -0.5, "lower PORD ramp saturation"}, - {-0.5, -0.5 + clampEdgeOffset(), "lower PORD ramp boundary"}, - {0.2, 0.19999999999999996, "interior PORD rate"}, - {0.6, 0.6 - clampEdgeOffset(), "upper PORD ramp boundary"}, - {1.0, 0.6, "upper PORD ramp saturation"}, - }}; - - for (const auto& test_case : rate_cases) - { - Fixture fixture(makeDynamicData()); - fixture.attachAllInputs(); - fixture.input(Ext::PREF) = (0.65 + 0.25 * test_case.input) / 2.0; - success *= fixture.prepare(0.0, 0.2); - setControlState(fixture.reecb); - fixture.reecb.y().getData()[index(Vars::PORD)] = 0.65; - fixture.reecb.y().setDataUpdated(); - success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::PORD)], test_case.expected, test_case.label); + // The ramp-rate limiter bounds the active-power order rate. + const std::array rate_cases{{ + {-1.0, -0.5}, + {0.2, 0.2}, + {1.0, 0.6}, + }}; + for (const auto& test_case : rate_cases) + { + Fixture fixture(makeResidualData()); + fixture.attachAllInputs(); + fixture.input(Ext::PREF) = rampReference(0.5, test_case.input); + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, + {{Vars::PORD, test_case.expected}}, + "active-power ramp limit"); + } } - const std::array gate_cases{{ - {0.5, 0.0, "PORD below voltage band"}, - {0.7, 0.1, "PORD at lower voltage threshold"}, - {1.0, 0.2, "PORD inside voltage band"}, - {1.2, 0.1, "PORD at upper voltage threshold"}, - {1.4, 0.0, "PORD above voltage band"}, - }}; - - for (const auto& test_case : gate_cases) { - Fixture fixture(makeDynamicData()); - fixture.attachAllInputs(); - fixture.input(Ext::PREF) = 0.35; - success *= fixture.prepare(0.0, 0.2); - setControlState(fixture.reecb); - auto* y = fixture.reecb.y().getData(); - y[index(Vars::PORD)] = 0.65; - y[index(Vars::VT)] = test_case.input; - fixture.reecb.y().setDataUpdated(); - success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::PORD)], test_case.expected, test_case.label); + // The voltage band gates the active-power order outside it. + const std::array gate_cases{{ + {0.0, 0.0}, + {1.0, 0.2}, + {2.0, 0.0}, + }}; + for (const auto& test_case : gate_cases) + { + Fixture fixture(makeResidualData()); + fixture.attachAllInputs(); + fixture.input(Ext::PREF) = rampReference(0.5, 0.2); + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + setState(fixture.reecb, {{Vars::VT, test_case.input}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, + {{Vars::PORD, test_case.expected}}, + "active-power voltage gate"); + } } - struct WindupCase - { - RealT pord; - RealT raw_rate; - RealT expected; - const char* label; - }; - - const std::array windup_cases{{ - {2.0, 1.0, 0.0, "outward rate above Pmax"}, - {1.4, 1.0, 0.3, "outward rate at Pmax"}, - {2.0, -1.0, -0.5, "restoring rate above Pmax"}, - {-1.0, -1.0, 0.0, "outward rate below Pmin"}, - {0.1, -1.0, -0.25, "outward rate at Pmin"}, - {-1.0, 1.0, 0.6, "restoring rate below Pmin"}, - }}; - - for (const auto& test_case : windup_cases) { - Fixture fixture(makeDynamicData()); - fixture.attachAllInputs(); - fixture.input(Ext::PREF) = (test_case.pord + 0.25 * test_case.raw_rate) / 2.0; - success *= fixture.prepare(0.0, 0.2); - setControlState(fixture.reecb); - fixture.reecb.y().getData()[index(Vars::PORD)] = test_case.pord; - fixture.reecb.y().setDataUpdated(); - success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::PORD)], test_case.expected, test_case.label); + // Saturated probes sit beyond their limit by a margin, so a blocked + // gate contributes nothing and an admitted gate passes the full rate. + const std::array antiwindup_cases{{ + {2.0, 1.0, 0.0}, + {2.0, -1.0, -0.5}, + {-0.4, -1.0, 0.0}, + {-0.4, 1.0, 0.6}, + {0.5, 1.0, 0.6}, + }}; + for (const auto& test_case : antiwindup_cases) + { + Fixture fixture(makeResidualData()); + fixture.attachAllInputs(); + fixture.input(Ext::PREF) = rampReference(test_case.state, test_case.reference); + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + setState(fixture.reecb, {{Vars::PORD, test_case.state}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, + {{Vars::PORD, test_case.expected}}, + "active-power antiwindup"); + } } - const std::array iq_limit_cases{{ - {-1.0, -0.4, "IQCMD below low-priority limit"}, - {-0.4, -0.4 + clampEdgeOffset(), "IQCMD at negative low-priority limit"}, - {0.0, 0.0, "IQCMD inside low-priority limits"}, - {0.4, 0.4 - clampEdgeOffset(), "IQCMD at positive low-priority limit"}, - {1.0, 0.4, "IQCMD above low-priority limit"}, - }}; - - for (const auto& test_case : iq_limit_cases) { - auto data = makeData(); - data.parameters[Params::QFlag] = false; - data.parameters[Params::Pqflag] = true; - Fixture fixture(data); - success *= fixture.prepare(0.0, 0.2); - setControlState(fixture.reecb); - auto* y = fixture.reecb.y().getData(); - y[index(Vars::ILMAX)] = 0.4; - y[index(Vars::IQCMD)] = 0.0; - y[index(Vars::QV)] = test_case.input; - fixture.reecb.y().setDataUpdated(); - success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::IQCMD)], test_case.expected, test_case.label); + // P priority leaves the reactive command on the residual capacity. + const std::array reactive_limit_cases{{ + {-3.0, -2.0}, + {-1.0, -1.0}, + {0.0, 0.0}, + {1.0, 1.0}, + {3.0, 2.0}, + }}; + for (const auto& test_case : reactive_limit_cases) + { + Fixture fixture(makeData()); + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + setState(fixture.reecb, + {{Vars::ILMAX, 2.0}, + {Vars::IQCMD, 0.0}, + {Vars::QV, test_case.input}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, + {{Vars::IQCMD, test_case.expected}}, + "reactive-command limit"); + } } - const std::array ip_limit_cases{{ - {-1.0, 0.0, "IPCMD below zero"}, - {0.0, clampEdgeOffset(), "IPCMD at zero"}, - {0.2, 0.2, "IPCMD inside low-priority limits"}, - {0.4, 0.4 - clampEdgeOffset(), "IPCMD at low-priority limit"}, - {1.0, 0.4, "IPCMD above low-priority limit"}, - }}; - - for (const auto& test_case : ip_limit_cases) { + // Q priority leaves the active command on the residual capacity, and + // the active command is one-sided. auto data = makeData(); data.parameters[Params::Pqflag] = false; - Fixture fixture(data); - success *= fixture.prepare(0.2, 0.0); - setControlState(fixture.reecb); - auto* y = fixture.reecb.y().getData(); - y[index(Vars::ILMAX)] = 0.4; - y[index(Vars::IPCMD)] = 0.0; - y[index(Vars::PORD)] = test_case.input; - fixture.reecb.y().setDataUpdated(); - success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::IPCMD)], test_case.expected, test_case.label); - } - for (const bool p_priority : {false, true}) - { - auto data = makeDynamicData(); - data.parameters[Params::Pqflag] = p_priority; - Fixture fixture(data); - fixture.attachAllInputs(); - success *= fixture.prepare(0.25, 0.4); - setAnswerKeyState(fixture.reecb); - success *= (fixture.evaluate() == 0); - const RealT expected = p_priority ? 0.16999999999999993 : 0.56; - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::ILMAX)], expected, "priority-circle residual"); + const std::array active_limit_cases{{ + {-1.0, 0.0}, + {0.6, 0.6}, + {1.0, 1.0}, + {3.0, 2.0}, + }}; + for (const auto& test_case : active_limit_cases) + { + Fixture fixture(data); + success *= fixture.prepare(0.2, 0.0); + setControlState(fixture.reecb); + setState(fixture.reecb, + {{Vars::ILMAX, 2.0}, + {Vars::IPCMD, 0.0}, + {Vars::PORD, test_case.input}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, + {{Vars::IPCMD, test_case.expected}}, + "active-command limit"); + } } { - auto data = makeData(); - data.parameters[Params::Imax] = 1.0; - Fixture fixture(data); - const RealT ipcmd = std::sqrt(1.0 - 1.0e-12); - success *= fixture.prepare(0.0, ipcmd); - setControlState(fixture.reecb); - auto* y = fixture.reecb.y().getData(); - y[index(Vars::ILMAX)] = 1.0e-6; - y[index(Vars::IPCMD)] = ipcmd; - fixture.reecb.y().setDataUpdated(); - success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::ILMAX)], 0.0, "near-zero ILMAX residual"); + // The priority selector chooses which command consumes the circle. + for (const auto& [p_priority, expected] : std::array, 2>{{ + {true, 0.32}, + {false, 0.56}, + }}) + { + auto data = makeResidualData(); + data.parameters[Params::Pqflag] = p_priority; + Fixture fixture(data, kStateVr, kStateVi); + fixture.attachAllInputs(); + setAnswerKeyInputs(fixture); + success *= fixture.prepare(0.25, 0.35); + setAnswerKeyState(fixture.reecb); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, + {{Vars::ILMAX, expected}}, + p_priority ? "P-priority current circle" + : "Q-priority current circle"); + } } - for (const bool p_priority : {false, true}) { + // The signed-square continuation keeps a negative capacity iterate + // finite, and its magnitude still bounds the low-priority command. auto data = makeData(); - data.parameters[Params::Pqflag] = p_priority; data.parameters[Params::Imax] = 1.0; + data.parameters[Params::Pqflag] = true; - const RealT iqcmd = p_priority ? 0.2 : 1.1; - const RealT ipcmd = p_priority ? 1.1 : 0.2; - Fixture fixture(data); - success *= fixture.prepare(iqcmd, ipcmd); - setControlState(fixture.reecb); - - auto* y = fixture.reecb.y().getData(); - y[index(Vars::ILMAX)] = -std::sqrt(0.21); - y[p_priority ? index(Vars::QV) : index(Vars::PORD)] = 0.3; - fixture.reecb.y().setDataUpdated(); - success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::ILMAX)], 0.0, "negative ILMAX continuation"); - const auto low_priority_row = p_priority ? Vars::IQCMD : Vars::IPCMD; - const RealT negative_limit_value = fixture.reecb.getResidual().getData()[index(low_priority_row)]; - success *= scalarMatches(negative_limit_value, 0.1, "negative ILMAX command bound"); - for (size_t row = 0; row < index(Vars::MAXIMUM); ++row) - { - success *= std::isfinite(fixture.reecb.getResidual().getData()[row]); - } - - y[index(Vars::ILMAX)] = std::sqrt(0.21); - fixture.reecb.y().setDataUpdated(); - success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(low_priority_row)], negative_limit_value, "signed ILMAX bound parity"); - - y[index(Vars::ILMAX)] = 0.0; - fixture.reecb.y().setDataUpdated(); - success *= (fixture.evaluate() == 0); - success *= scalarMatches(fixture.reecb.getResidual().getData()[index(Vars::ILMAX)], -0.21, "zero ILMAX continuation"); - for (size_t row = 0; row < index(Vars::MAXIMUM); ++row) + const std::array continuation_cases{{ + {-0.5, 1.0}, + {0.5, 0.5}, + {0.0, 0.75}, + }}; + for (const auto& test_case : continuation_cases) { - success *= std::isfinite(fixture.reecb.getResidual().getData()[row]); + Fixture fixture(data); + success *= fixture.prepare(0.0, 0.25); + setControlState(fixture.reecb); + setState(fixture.reecb, + {{Vars::ILMAX, test_case.input}, + {Vars::IPCMD, 0.25}, + {Vars::IQCMD, 0.0}, + {Vars::QV, 1.0}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, + {{Vars::ILMAX, test_case.expected}}, + "signed capacity continuation"); + + const RealT expected_command = + test_case.input == ZERO ? 0.0 : 0.5; + success *= residualsMatch(fixture.reecb, + {{Vars::IQCMD, expected_command}}, + "capacity magnitude bound"); + success *= allResidualsFinite(fixture.reecb); } } return success.report(__func__); } - /// Fixed positive and negative DependencyTracking coefficients provide the oracle before each configuration is compared with Enzyme. +#ifdef GRIDKIT_ENABLE_ENZYME + /// Fixed dependency-tracking coefficients pin every selector path before + /// each configuration is compared against the Enzyme CSR rows. TestOutcome jacobian() { TestStatus success = true; - constexpr RealT alpha = 0.7; for (const bool pf : {false, true}) { for (const bool voltage : {false, true}) @@ -1122,35 +1104,28 @@ namespace GridKit data.parameters[Params::QFlag] = reactive; data.parameters[Params::Pqflag] = p_priority; - const auto dependency = dependencyTrackingJacobian(data, alpha, success); - const auto bus_vr = index(Vars::MAXIMUM); - const auto bus_vi = bus_vr + 1; - const auto pe_column = bus_vi + 1 + index(Ext::PE); - const auto qgen_column = bus_vi + 1 + index(Ext::QGEN); - const auto qext_column = bus_vi + 1 + index(Ext::QEXT); - const auto pfaref_column = bus_vi + 1 + index(Ext::PFAREF); - const auto pref_column = bus_vi + 1 + index(Ext::PREF); + const auto dependency = dependencyTrackingJacobian(data, kNonunitAlpha, success); success *= derivativeMatches(dependency, Vars::VMEAS, Vars::VMEAS, -5.7, "VMEAS diagonal"); success *= derivativeMatches(dependency, Vars::VMEAS, Vars::VT, 5.0, "VMEAS-VT"); success *= derivativeMatches(dependency, Vars::PMEAS, Vars::PMEAS, -3.2, "PMEAS diagonal"); - success *= derivativeMatches(dependency, Vars::PMEAS, pe_column, 5.0, "PMEAS-PE"); - success *= derivativeMatches(dependency, Vars::XPIQ, Vars::XPIQ, -alpha, "XPIQ diagonal"); - success *= derivativeMatches(dependency, Vars::XPIV, Vars::XPIV, -alpha, "XPIV diagonal"); - success *= derivativeMatches(dependency, Vars::QV, Vars::QV, -alpha - (reactive ? 0.0 : 1.0 / 0.3), "QV diagonal"); // Tiq = 0.3 + success *= derivativeMatches(dependency, Vars::PMEAS, kPeColumn, 5.0, "PMEAS-PE"); + success *= derivativeMatches(dependency, Vars::XPIQ, Vars::XPIQ, -kNonunitAlpha, "XPIQ diagonal"); + success *= derivativeMatches(dependency, Vars::XPIV, Vars::XPIV, -kNonunitAlpha, "XPIV diagonal"); + success *= derivativeMatches(dependency, Vars::QV, Vars::QV, -kNonunitAlpha - (reactive ? 0.0 : 2.0), "QV diagonal"); success *= derivativeMatches(dependency, Vars::PORD, Vars::PORD, -4.7, "PORD diagonal"); - success *= derivativeMatches(dependency, Vars::PORD, pref_column, 8.0, "PORD-PREF"); + success *= derivativeMatches(dependency, Vars::PORD, kPrefColumn, 8.0, "PORD-PREF"); success *= derivativeMatches(dependency, Vars::VT, Vars::VT, -2.0, "VT diagonal"); - success *= derivativeMatches(dependency, Vars::VT, bus_vr, 1.8, "VT-Vr"); - success *= derivativeMatches(dependency, Vars::VT, bus_vi, 0.8, "VT-Vi"); - success *= derivativeMatches(dependency, Vars::ILMAX, Vars::ILMAX, -2.4, "ILMAX diagonal"); + success *= derivativeMatches(dependency, Vars::VT, kBusVrColumn, 1.8, "VT-Vr"); + success *= derivativeMatches(dependency, Vars::VT, kBusViColumn, 0.8, "VT-Vi"); + success *= derivativeMatches(dependency, Vars::ILMAX, Vars::ILMAX, -4.0, "ILMAX diagonal"); success *= derivativeMatches(dependency, Vars::IQCMD, Vars::IQCMD, -2.0, "IQCMD diagonal"); success *= derivativeMatches(dependency, Vars::IPCMD, Vars::IPCMD, -2.0, "IPCMD diagonal"); success *= derivativeMatches(dependency, Vars::IPCMD, Vars::PORD, 1.0, "IPCMD-PORD"); success *= derivativeMatches(dependency, Vars::IPCMD, Vars::VMEAS, -0.5, "IPCMD-VMEAS"); success *= derivativeMatches(dependency, Vars::IQCMD, Vars::XPIV, reactive ? 1.0 : 0.0, "IQCMD-XPIV selector path"); success *= derivativeMatches(dependency, Vars::IQCMD, Vars::QV, reactive ? 0.0 : 1.0, "IQCMD-QV selector path"); - success *= derivativeMatches(dependency, Vars::XPIQ, qgen_column, reactive && voltage ? -0.8 : 0.0, "XPIQ-QGEN selector path"); + success *= derivativeMatches(dependency, Vars::XPIQ, kQgenColumn, reactive && voltage ? -0.8 : 0.0, "XPIQ-QGEN selector path"); // The direct-voltage coefficient carries no power-base factor, // while the cascaded path converts the reference to component base. @@ -1159,9 +1134,9 @@ namespace GridKit { xpiv_qext = voltage ? (pf ? 0.0 : 0.6) : 0.5; } - success *= derivativeMatches(dependency, Vars::XPIV, qext_column, xpiv_qext, "XPIV-QEXT selector path"); - success *= derivativeMatches(dependency, Vars::QV, qext_column, !reactive && !pf ? 20.0 / 3.0 : 0.0, "QV-QEXT selector path"); - success *= derivativeMatches(dependency, Vars::QV, pfaref_column, !reactive && pf ? 25.0 / 3.0 : 0.0, "QV-PFAREF selector path"); + success *= derivativeMatches(dependency, Vars::XPIV, kQextColumn, xpiv_qext, "XPIV-QEXT selector path"); + success *= derivativeMatches(dependency, Vars::QV, kQextColumn, !reactive && !pf ? 4.0 : 0.0, "QV-QEXT selector path"); + success *= derivativeMatches(dependency, Vars::QV, kPfarefColumn, !reactive && pf ? 1.0 : 0.0, "QV-PFAREF selector path"); if (p_priority) { @@ -1174,69 +1149,56 @@ namespace GridKit success *= derivativeMatches(dependency, Vars::ILMAX, Vars::IPCMD, 0.0, "Q-priority absent current-circle column"); } -#ifdef GRIDKIT_ENABLE_ENZYME - const auto enzyme = enzymeJacobian(data, alpha, success); - success *= jacobiansMatch(dependency, enzyme, index(Vars::MAXIMUM) + 2 + index(Ext::MAXIMUM)); -#endif + success *= jacobiansMatch(dependency, + enzymeJacobian(data, kNonunitAlpha, success)); } } } } + // A negative capacity iterate keeps the signed-square derivative. for (const bool p_priority : {false, true}) { auto data = makeJacobianData(); data.parameters[Params::Pqflag] = p_priority; - const auto dependency = dependencyTrackingJacobian(data, alpha, success, -1.2); - success *= derivativeMatches(dependency, Vars::ILMAX, Vars::ILMAX, -2.4, "negative ILMAX continuation"); -#ifdef GRIDKIT_ENABLE_ENZYME - const auto enzyme = enzymeJacobian(data, alpha, success, -1.2); - success *= jacobiansMatch(dependency, enzyme, index(Vars::MAXIMUM) + 2 + index(Ext::MAXIMUM)); -#endif + const auto dependency = dependencyTrackingJacobian(data, kNonunitAlpha, success, -2.0); + success *= derivativeMatches(dependency, Vars::ILMAX, Vars::ILMAX, -4.0, "negative capacity continuation"); + success *= jacobiansMatch(dependency, + enzymeJacobian(data, kNonunitAlpha, success, -2.0)); } - // The selector sweep zeroes kqv because the answer-key deadband tails - // sit above kTol there. This configuration exercises the injection - // derivative on its own: with QFlag = 0 the only VMEAS dependence of - // the IQCMD row is iqv, evaluated on the deadband passthrough side - // strictly inside the injection limits, where the chain collapses to - // d(iqv)/d(VMEAS) = -kqv. + // The selector sweep zeroes the injection gain, so this configuration + // exercises the injection derivative on its own. { auto data = makeJacobianData(); data.parameters[Params::QFlag] = false; data.parameters[Params::kqv] = 1.0; - data.parameters[Params::dbd1] = -0.2; - data.parameters[Params::dbd2] = 0.25; - data.parameters[Params::Vref0] = 1.5; + data.parameters[Params::dbd1] = -0.6; + data.parameters[Params::dbd2] = 0.6; + data.parameters[Params::Iql1] = -1.2; + data.parameters[Params::Iqh1] = 1.5; + data.parameters[Params::Vref0] = 2.2; - const auto dependency = dependencyTrackingJacobian(data, alpha, success); + const auto dependency = dependencyTrackingJacobian(data, kNonunitAlpha, success); success *= derivativeMatches(dependency, Vars::IQCMD, Vars::VMEAS, -1.0, "IQCMD-VMEAS injection path"); success *= derivativeMatches(dependency, Vars::IQCMD, Vars::QV, 1.0, "IQCMD-QV alongside injection"); -#ifdef GRIDKIT_ENABLE_ENZYME - const auto enzyme = enzymeJacobian(data, alpha, success); - success *= jacobiansMatch(dependency, enzyme, index(Vars::MAXIMUM) + 2 + index(Ext::MAXIMUM)); -#endif + success *= jacobiansMatch(dependency, + enzymeJacobian(data, kNonunitAlpha, success)); } return success.report(__func__); } +#endif private: - using Params = PhasorDynamics::Converter::ReecbParameters; - using Vars = PhasorDynamics::Converter::ReecbInternalVariables; - using Ext = PhasorDynamics::Converter::ReecbExternalVariables; - using Mon = PhasorDynamics::Converter::ReecbMonitorableVariables; - using Data = PhasorDynamics::Converter::ReecbData; - using ReecbT = PhasorDynamics::Converter::Reecb; - using DependencyMap = DependencyTracking::Variable::DependencyMap; - - struct ExpectedResidual - { - Vars row; - const char* name; - RealT value; - }; + using Params = PhasorDynamics::Converter::ReecbParameters; + using Vars = PhasorDynamics::Converter::ReecbInternalVariables; + using Ext = PhasorDynamics::Converter::ReecbExternalVariables; + using Mon = PhasorDynamics::Converter::ReecbMonitorableVariables; + using Data = PhasorDynamics::Converter::ReecbData; + using ReecbT = PhasorDynamics::Converter::Reecb; + using JacobianRow = DependencyTracking::Variable::DependencyMap; static constexpr size_t index(Vars variable) { @@ -1248,6 +1210,44 @@ namespace GridKit return static_cast(variable); } + struct Row + { + constexpr Row(Vars row, RealT expected_value) + : variable(row), + value(expected_value) + { + } + + Vars variable; + RealT value; + }; + + struct ExpectedResidual + { + Vars variable; + const char* name; + RealT value; + }; + + struct DrivenCase + { + RealT input; + RealT expected; + }; + + struct AntiWindupCase + { + RealT state; + RealT reference; + RealT expected; + }; + + using Rows = std::initializer_list; + + /// Owns the terminal bus, REECB, the assigned command nodes, and the + /// attached input nodes. Signal storage precedes the model so every + /// referenced node outlives REECB; copying would invalidate the model + /// and node pointers. template class Fixture { @@ -1255,15 +1255,22 @@ namespace GridKit std::array input_values_{}; std::array input_indices_{}; std::array, index(Ext::MAXIMUM)> input_nodes_{}; - PhasorDynamics::SignalNode iqcmd_node_; - PhasorDynamics::SignalNode ipcmd_node_; - bool commands_assigned_{true}; + + PhasorDynamics::SignalNode iqcmd_node_; + PhasorDynamics::SignalNode ipcmd_node_; + bool commands_assigned_{true}; public: - explicit Fixture(const Data& data, RealT vr = 1.0, RealT vi = 0.0, RealT system_va_base = 100.0e6, bool assign_commands = true) - : commands_assigned_(assign_commands), bus(static_cast(vr), static_cast(vi)), reecb(&bus, data) - { - reecb.setSystemBase(60.0, system_va_base); + explicit Fixture(const Data& data, + RealT vr = 1.0, + RealT vi = 0.0, + RealT system_va_base = kSystemBaseVa, + bool assign_commands = true) + : commands_assigned_(assign_commands), + bus(static_cast(vr), static_cast(vi)), + reecb(&bus, data) + { + reecb.setSystemBase(kNominalFrequency, system_va_base); if (commands_assigned_) { reecb.getSignals().template assignSignalNode(&iqcmd_node_); @@ -1274,12 +1281,13 @@ namespace GridKit Fixture(const Fixture&) = delete; Fixture& operator=(const Fixture&) = delete; - void attachAllInputs(RealT value = 0.0) + void attachAllInputs(RealT initial_value = 0.0) { + const IdxT external_index_base = reecb.size() + bus.size(); for (size_t port = 0; port < index(Ext::MAXIMUM); ++port) { - input_values_[port] = static_cast(value); - input_indices_[port] = reecb.size() + bus.size() + static_cast(port); + input_values_[port] = static_cast(initial_value); + input_indices_[port] = external_index_base + static_cast(port); input_nodes_[port].set(&input_values_[port], &input_indices_[port]); } @@ -1297,16 +1305,15 @@ namespace GridKit { iqcmd_node_.init(static_cast(iqcmd)); ipcmd_node_.init(static_cast(ipcmd)); + return; } - else - { - auto* y = reecb.y().getData(); - y[index(Vars::IQCMD)] = static_cast(iqcmd); - y[index(Vars::IPCMD)] = static_cast(ipcmd); - reecb.y().setDataUpdated(); - } + auto* y = reecb.y().getData(); + y[index(Vars::IQCMD)] = static_cast(iqcmd); + y[index(Vars::IPCMD)] = static_cast(ipcmd); + reecb.y().setDataUpdated(); } + /// Arrange the allocation, verification, bus, and command prerequisites. bool prepare(RealT iqcmd, RealT ipcmd) { const bool ready = (bus.allocate() == 0) && (reecb.allocate() == 0) @@ -1326,12 +1333,12 @@ namespace GridKit { return false; } - if (reecb.initialize() == 0) + if (reecb.initialize() != 0) { - return true; + std::cout << "REECB initialization failed\n"; + return false; } - std::cout << "REECB initialization failed\n"; - return false; + return true; } int evaluate() @@ -1341,29 +1348,54 @@ namespace GridKit T iqcmd() const { - return commands_assigned_ ? iqcmd_node_.read() : reecb.y().getData()[index(Vars::IQCMD)]; + return reecb.y().getData()[index(Vars::IQCMD)]; } T ipcmd() const { - return commands_assigned_ ? ipcmd_node_.read() : reecb.y().getData()[index(Vars::IPCMD)]; + return reecb.y().getData()[index(Vars::IPCMD)]; } - T& input(Ext variable) + T& input(Ext port) { - return input_values_[index(variable)]; + return input_values_[index(port)]; } - IdxT inputIndex(Ext variable) const + IdxT inputIndex(Ext port) const { - return input_indices_[index(variable)]; + return input_indices_[index(port)]; } PhasorDynamics::Bus bus; PhasorDynamics::Converter::Reecb reecb; }; - Data makeData() const + static constexpr RealT kSystemBaseVa = static_cast(100.0e6); + static constexpr RealT kNominalFrequency = static_cast(60.0); + static constexpr RealT kStateVr = 0.9; + static constexpr RealT kStateVi = 0.4; + // The commands, the current circle, and the terminal voltage are all + // exactly representable, so every smooth limiter the initial point + // clears reproduces it bitwise and the model rests at an exact zero. + static constexpr RealT kInitialIqcmd = 0.75; + static constexpr RealT kInitialIpcmd = 0.75; + static constexpr RealT kNonunitAlpha = 0.7; + + // Angles whose tangents are the exact slopes the probes below assume. + static constexpr RealT kQuarterTurn = std::numbers::pi_v / FOUR; + static constexpr RealT kHalfSlopeAngle = 0.46364760900080612; + + static constexpr size_t kBusVrColumn = index(Vars::MAXIMUM); + static constexpr size_t kBusViColumn = kBusVrColumn + 1; + static constexpr size_t kExternalColumnBase = kBusViColumn + 1; + static constexpr size_t kPeColumn = kExternalColumnBase + index(Ext::PE); + static constexpr size_t kQgenColumn = kExternalColumnBase + index(Ext::QGEN); + static constexpr size_t kQextColumn = kExternalColumnBase + index(Ext::QEXT); + static constexpr size_t kPfarefColumn = kExternalColumnBase + index(Ext::PFAREF); + static constexpr size_t kPrefColumn = kExternalColumnBase + index(Ext::PREF); + static constexpr size_t kColumnCount = kExternalColumnBase + index(Ext::MAXIMUM); + + Data makeMinimalData() const { Data data; data.device_class = "Reecb"; @@ -1372,53 +1404,101 @@ namespace GridKit data.monitored_variables.insert(Mon::ipcmd); data.monitored_variables.insert(Mon::vmeas); data.monitored_variables.insert(Mon::pmeas); + return data; + } + + Data makeExplicitDefaultData() const + { + auto data = makeMinimalData(); + // These are the documented defaults; Vref0 has no fixed default and is + // resolved from the terminal voltage. data.parameters[Params::mva] = 100.0; data.parameters[Params::PfFlag] = false; - data.parameters[Params::VFlag] = true; + data.parameters[Params::VFlag] = false; data.parameters[Params::QFlag] = false; - data.parameters[Params::Pqflag] = true; + data.parameters[Params::Pqflag] = false; data.parameters[Params::Trv] = 0.02; - data.parameters[Params::Tp] = 0.02; + data.parameters[Params::Tp] = 0.0; + data.parameters[Params::Vdip] = 0.85; + data.parameters[Params::Vup] = 1.15; + data.parameters[Params::dbd1] = 0.0; + data.parameters[Params::dbd2] = 0.0; + data.parameters[Params::kqv] = 5.0; + data.parameters[Params::Iql1] = -1.1; + data.parameters[Params::Iqh1] = 1.1; + data.parameters[Params::Qmax] = 0.436; + data.parameters[Params::Qmin] = -0.436; + data.parameters[Params::Kqp] = 0.0; + data.parameters[Params::Kqi] = 0.1; + data.parameters[Params::Vmax] = 1.1; + data.parameters[Params::Vmin] = 0.9; + data.parameters[Params::Kvp] = 18.0; + data.parameters[Params::Kvi] = 5.0; + data.parameters[Params::Tiq] = 0.02; + data.parameters[Params::Tpord] = 0.02; + data.parameters[Params::dPmax] = 99.0; + data.parameters[Params::dPmin] = -99.0; + data.parameters[Params::Pmax] = 1.0; + data.parameters[Params::Pmin] = 0.0; + data.parameters[Params::Imax] = 1.3; + return data; + } + + /// The routine fixture: a half-size component base, wide bands, and + /// limits far enough from the canonical commands that every smooth + /// transition is saturated. + Data makeData() const + { + auto data = makeMinimalData(); + + data.parameters[Params::mva] = 50.0; + data.parameters[Params::PfFlag] = false; + data.parameters[Params::VFlag] = true; + data.parameters[Params::QFlag] = false; + data.parameters[Params::Pqflag] = true; + data.parameters[Params::Trv] = 0.02; + data.parameters[Params::Tp] = 0.02; data.parameters[Params::Vref0] = 1.0; - data.parameters[Params::Vdip] = 0.7; - data.parameters[Params::Vup] = 1.2; - data.parameters[Params::dbd1] = -0.01; - data.parameters[Params::dbd2] = 0.01; + data.parameters[Params::Vdip] = 0.5; + data.parameters[Params::Vup] = 1.5; + data.parameters[Params::dbd1] = -0.2; + data.parameters[Params::dbd2] = 0.2; data.parameters[Params::kqv] = 0.0; data.parameters[Params::Iql1] = -1.0; data.parameters[Params::Iqh1] = 1.0; - data.parameters[Params::Qmax] = 1.0; - data.parameters[Params::Qmin] = -1.0; + data.parameters[Params::Qmax] = 2.0; + data.parameters[Params::Qmin] = -2.0; data.parameters[Params::Kqp] = 1.0; data.parameters[Params::Kqi] = 0.0; - data.parameters[Params::Vmax] = 1.2; - data.parameters[Params::Vmin] = 0.8; + data.parameters[Params::Vmax] = 1.5; + data.parameters[Params::Vmin] = 0.5; data.parameters[Params::Kvp] = 1.0; data.parameters[Params::Kvi] = 0.0; data.parameters[Params::Tiq] = 0.02; data.parameters[Params::Tpord] = 0.02; data.parameters[Params::dPmax] = 1.0; data.parameters[Params::dPmin] = -1.0; - data.parameters[Params::Pmax] = 1.0; + data.parameters[Params::Pmax] = 2.0; data.parameters[Params::Pmin] = 0.0; - data.parameters[Params::Imax] = 2.0; + data.parameters[Params::Imax] = 2.5; return data; } - Data makeDynamicData() const + /// Distinct nonzero values for every parameter. The bands are wide + /// enough, and the lag reciprocals exact enough, for probe states to + /// clear every smooth transition on an exact decimal. + Data makeResidualData() const { - auto data = makeData(); - data.parameters[Params::mva] = 50.0; - data.parameters[Params::PfFlag] = true; + auto data = makeData(); + + data.parameters[Params::PfFlag] = false; data.parameters[Params::VFlag] = true; data.parameters[Params::QFlag] = true; data.parameters[Params::Pqflag] = true; data.parameters[Params::Trv] = 0.2; data.parameters[Params::Tp] = 0.4; - data.parameters[Params::Vref0] = 1.02; - data.parameters[Params::dbd1] = -0.02; - data.parameters[Params::dbd2] = 0.03; + data.parameters[Params::Vref0] = 1.5; data.parameters[Params::kqv] = 2.0; data.parameters[Params::Iql1] = -0.4; data.parameters[Params::Iqh1] = 0.5; @@ -1426,11 +1506,11 @@ namespace GridKit data.parameters[Params::Qmin] = -0.7; data.parameters[Params::Kqp] = 0.6; data.parameters[Params::Kqi] = 0.4; - data.parameters[Params::Vmax] = 1.3; - data.parameters[Params::Vmin] = 0.7; + data.parameters[Params::Vmax] = 1.6; + data.parameters[Params::Vmin] = 0.4; data.parameters[Params::Kvp] = 1.2; data.parameters[Params::Kvi] = 0.5; - data.parameters[Params::Tiq] = 0.3; + data.parameters[Params::Tiq] = 0.5; data.parameters[Params::Tpord] = 0.25; data.parameters[Params::dPmax] = 0.6; data.parameters[Params::dPmin] = -0.5; @@ -1440,99 +1520,199 @@ namespace GridKit return data; } + /// The residual parameters with the injection disabled and the reactive + /// limits widened, so the sensitivity probe sits interior everywhere. Data makeJacobianData() const { - auto data = makeDynamicData(); - data.parameters[Params::Vref0] = std::sqrt(0.97); + auto data = makeResidualData(); + data.parameters[Params::Vref0] = 1.0; data.parameters[Params::kqv] = 0.0; data.parameters[Params::Qmin] = -2.0; data.parameters[Params::Qmax] = 2.0; + data.parameters[Params::Imax] = 2.5; return data; } + /// The active-power reference that produces a requested pre-limit ramp + /// rate at a given order, on the residual-parameter time constant. + static constexpr RealT rampReference(RealT pord, RealT raw_rate) + { + return (pord + 0.25 * raw_rate) / 2.0; + } + template void setAnswerKeyInputs(Fixture& fixture) const { - fixture.input(Ext::PE) = 0.3; - fixture.input(Ext::QGEN) = -0.1; - fixture.input(Ext::QEXT) = 0.2; - fixture.input(Ext::PFAREF) = 0.15; - fixture.input(Ext::PREF) = 0.35; + fixture.input(Ext::PE) = static_cast(0.3); + fixture.input(Ext::QGEN) = static_cast(-0.1); + fixture.input(Ext::QEXT) = static_cast(0.2); + fixture.input(Ext::PFAREF) = static_cast(0.15); + fixture.input(Ext::PREF) = static_cast(0.325); } + /// The rich state shared by the residual answer key and the priority + /// circle. Every smooth-transition argument keeps a saturation margin, + /// so each row carries its ideal value. template void setAnswerKeyState(PhasorDynamics::Converter::Reecb& reecb) const { - auto* y = reecb.y().getData(); - y[index(Vars::VMEAS)] = 0.95; - y[index(Vars::PMEAS)] = 0.55; - y[index(Vars::XPIQ)] = 0.10; - y[index(Vars::XPIV)] = -0.05; - y[index(Vars::QV)] = 0.30; - y[index(Vars::PORD)] = 0.65; - y[index(Vars::VT)] = 1.00; - y[index(Vars::ILMAX)] = 1.20; - y[index(Vars::IQCMD)] = 0.25; - y[index(Vars::IPCMD)] = 0.40; - - auto* yp = reecb.yp().getData(); - yp[index(Vars::VMEAS)] = 0.01; - yp[index(Vars::PMEAS)] = -0.02; - yp[index(Vars::XPIQ)] = 0.03; - yp[index(Vars::XPIV)] = -0.03; - yp[index(Vars::QV)] = 0.05; - yp[index(Vars::PORD)] = -0.06; - reecb.y().setDataUpdated(); - reecb.yp().setDataUpdated(); + setState(reecb, + {{Vars::VMEAS, 0.80}, + {Vars::PMEAS, 0.55}, + {Vars::XPIQ, 0.64}, + {Vars::XPIV, -0.05}, + {Vars::QV, 0.30}, + {Vars::PORD, 0.60}, + {Vars::VT, 1.00}, + {Vars::ILMAX, 1.20}, + {Vars::IQCMD, 0.25}, + {Vars::IPCMD, 0.35}}); + setDerivative(reecb, + {{Vars::VMEAS, 0.01}, + {Vars::PMEAS, -0.02}, + {Vars::XPIQ, 0.03}, + {Vars::XPIV, -0.03}, + {Vars::QV, 0.05}, + {Vars::PORD, -0.06}}); } + /// A neutral driven state for the control probes: unit voltage, cleared + /// controller states, and a rested derivative. template void setControlState(PhasorDynamics::Converter::Reecb& reecb) const { - auto* y = reecb.y().getData(); - y[index(Vars::VMEAS)] = 1.0; - y[index(Vars::PMEAS)] = 0.0; - y[index(Vars::XPIQ)] = 0.0; - y[index(Vars::XPIV)] = 0.0; - y[index(Vars::QV)] = 0.0; - y[index(Vars::PORD)] = 0.5; - y[index(Vars::VT)] = 1.0; - y[index(Vars::ILMAX)] = 1.4; - reecb.yp().setToConst(static_cast(0.0)); - reecb.y().setDataUpdated(); + reecb.yp().setToConst(static_cast(ZERO)); + setState(reecb, + {{Vars::VMEAS, 1.0}, + {Vars::PMEAS, 0.6}, + {Vars::XPIQ, 0.0}, + {Vars::XPIV, 0.0}, + {Vars::QV, 0.0}, + {Vars::PORD, 0.5}, + {Vars::VT, 1.0}, + {Vars::ILMAX, 1.4}, + {Vars::IQCMD, 0.1}, + {Vars::IPCMD, 0.2}}); + reecb.yp().setDataUpdated(); + } + + /// The sensitivity probe: one interior operating point that keeps every + /// smooth transition saturated in every selector combination. + template + void setJacobianState(Fixture& fixture, RealT ilmax) const + { + fixture.input(Ext::PE) = static_cast(0.25); + fixture.input(Ext::QGEN) = static_cast(0.5); + fixture.input(Ext::QEXT) = static_cast(0.0); + fixture.input(Ext::PFAREF) = static_cast(0.0); + fixture.input(Ext::PREF) = static_cast(0.25); + + fixture.reecb.yp().setToConst(static_cast(ZERO)); + setState(fixture.reecb, + {{Vars::VMEAS, 1.0}, + {Vars::PMEAS, 0.5}, + {Vars::XPIQ, 1.6}, + {Vars::XPIV, 0.0}, + {Vars::QV, 0.0}, + {Vars::PORD, 0.5}, + {Vars::VT, 1.0}, + {Vars::ILMAX, ilmax}, + {Vars::IQCMD, 0.1}, + {Vars::IPCMD, 0.2}}); + fixture.reecb.yp().setDataUpdated(); + } + + /// Omitting every parameter must give exactly the model built from the + /// defaults the README documents, at rest and under load. + bool defaultsMatchDocumentedValues() const + { + Fixture implicit_defaults(makeMinimalData(), kStateVr, kStateVi); + Fixture explicit_defaults(makeExplicitDefaultData(), kStateVr, kStateVi); + implicit_defaults.attachAllInputs(); + explicit_defaults.attachAllInputs(); + + bool success = implicit_defaults.initialize(0.1, 0.2) + && explicit_defaults.initialize(0.1, 0.2); + if (!success) + { + std::cout << "REECB documented-default comparison failed to initialize\n"; + return false; + } + + if (implicit_defaults.evaluate() != 0 || explicit_defaults.evaluate() != 0) + { + success = false; + } + if (!vectorsMatch(implicit_defaults.reecb.y(), + explicit_defaults.reecb.y(), + "documented-default state")) + { + success = false; + } + if (!vectorsMatch(implicit_defaults.reecb.yp(), + explicit_defaults.reecb.yp(), + "documented-default derivative")) + { + success = false; + } + if (!vectorsMatch(implicit_defaults.reecb.getResidual(), + explicit_defaults.reecb.getResidual(), + "documented-default residual")) + { + success = false; + } + for (size_t port = 0; port < index(Ext::MAXIMUM); ++port) + { + const auto variable = static_cast(port); + if (!rowMatches(implicit_defaults.input(variable), + explicit_defaults.input(variable), + "documented-default signal", + port, + "")) + { + success = false; + } + } + + setAnswerKeyInputs(implicit_defaults); + setAnswerKeyInputs(explicit_defaults); + setAnswerKeyState(implicit_defaults.reecb); + setAnswerKeyState(explicit_defaults.reecb); + if (implicit_defaults.evaluate() != 0 || explicit_defaults.evaluate() != 0) + { + success = false; + } + if (!vectorsMatch(implicit_defaults.reecb.getResidual(), + explicit_defaults.reecb.getResidual(), + "documented-default dynamic residual")) + { + success = false; + } + return success; } template - bool invalidParameterCase(PhasorDynamics::Bus& bus, Params parameter, ValueT value) const + bool invalidParameterCase(Params parameter, ValueT value) const { auto data = makeData(); data.parameters[parameter] = value; - PhasorDynamics::Converter::Reecb model(&bus, data); - model.setSystemBase(60.0, 100.0e6); - return model.verify() > 0; + Fixture fixture(data); + return fixture.reecb.verify() > 0; } template - bool unlinkedSignalRejected(PhasorDynamics::Bus& bus) const + bool unlinkedSignalRejected() const { - PhasorDynamics::SignalNode node; - PhasorDynamics::Converter::Reecb model(&bus, makeData()); - model.setSystemBase(60.0, 100.0e6); - model.getSignals().template attachSignalNode(&node); - return model.verify() > 0; + PhasorDynamics::SignalNode unlinked_node; + Fixture fixture(makeData()); + fixture.reecb.getSignals().template attachSignalNode(&unlinked_node); + return fixture.reecb.verify() > 0; } - bool initializationRejectedAtomically(const Data& data, RealT iqcmd, RealT ipcmd, RealT voltage, const char* label, RealT pe = std::numeric_limits::quiet_NaN(), RealT qgen = std::numeric_limits::quiet_NaN()) const + /// Fill state and derivative with a recognizable ramp, restoring the + /// aliased commands, so any write by a rejected initialization shows. + void poisonState(Fixture& fixture, RealT iqcmd, RealT ipcmd) const { - Fixture fixture(data, voltage); - fixture.attachAllInputs(17.0); - fixture.input(Ext::PE) = std::isnan(pe) ? ipcmd * voltage : pe; - fixture.input(Ext::QGEN) = std::isnan(qgen) ? iqcmd * voltage : qgen; - if (!fixture.prepare(iqcmd, ipcmd)) - { - return false; - } - auto* y = fixture.reecb.y().getData(); auto* yp = fixture.reecb.yp().getData(); for (size_t row = 0; row < index(Vars::MAXIMUM); ++row) @@ -1543,67 +1723,269 @@ namespace GridKit fixture.setCommands(iqcmd, ipcmd); fixture.reecb.y().setDataUpdated(); fixture.reecb.yp().setDataUpdated(); + } - const auto y_before = snapshot(fixture.reecb.y()); - const auto yp_before = snapshot(fixture.reecb.yp()); - const auto bus_before = snapshot(fixture.bus.y()); - std::array input_before{}; + bool initializationRejectedAtomically(const Data& data, + RealT iqcmd, + RealT ipcmd, + const char* label, + RealT pe = 0.6, + RealT qgen = 0.6, + RealT voltage = 1.0) const + { + Fixture fixture(data, voltage); + fixture.attachAllInputs(77.0); + fixture.input(Ext::PE) = pe; + fixture.input(Ext::QGEN) = qgen; + if (!fixture.prepare(iqcmd, ipcmd)) + { + return false; + } + + poisonState(fixture, iqcmd, ipcmd); + + const auto y_before = copyVector(fixture.reecb.y()); + const auto yp_before = copyVector(fixture.reecb.yp()); + const auto bus_before = copyVector(fixture.bus.y()); + std::array inputs_before{}; for (size_t port = 0; port < index(Ext::MAXIMUM); ++port) { - input_before[port] = fixture.input(static_cast(port)); + inputs_before[port] = fixture.input(static_cast(port)); } + bool success = true; if (fixture.reecb.initialize() == 0) { std::cout << "Expected REECB initialization rejection: " << label << '\n'; - return false; + success = false; } - bool unchanged = vectorUnchanged(fixture.reecb.y(), y_before, "state") - && vectorUnchanged(fixture.reecb.yp(), yp_before, "derivative") - && vectorUnchanged(fixture.bus.y(), bus_before, "bus"); + if (!scalarPreserved(fixture.iqcmd(), iqcmd, "rejected iqcmd preservation")) + { + success = false; + } + if (!scalarPreserved(fixture.ipcmd(), ipcmd, "rejected ipcmd preservation")) + { + success = false; + } + if (!vectorUnchanged(fixture.reecb.y(), y_before, "state")) + { + success = false; + } + if (!vectorUnchanged(fixture.reecb.yp(), yp_before, "derivative")) + { + success = false; + } + if (!vectorUnchanged(fixture.bus.y(), bus_before, "bus state")) + { + success = false; + } for (size_t port = 0; port < index(Ext::MAXIMUM); ++port) { - unchanged &= (fixture.input(static_cast(port)) == input_before[port]); + if (!valueUnchanged(fixture.input(static_cast(port)), + inputs_before[port], + "external signal", + port)) + { + success = false; + } } - return unchanged; + return success; } - template - std::vector snapshot(const VectorT& vector) const + template + void setState(PhasorDynamics::Converter::Reecb& reecb, Rows rows) const { - const auto* values = vector.getData(); - return std::vector(values, values + static_cast(vector.getSize())); + auto* y = reecb.y().getData(); + for (const auto& [variable, value] : rows) + { + y[index(variable)] = static_cast(value); + } + reecb.y().setDataUpdated(); + } + + template + void setDerivative(PhasorDynamics::Converter::Reecb& reecb, Rows rows) const + { + auto* yp = reecb.yp().getData(); + for (const auto& [variable, value] : rows) + { + yp[index(variable)] = static_cast(value); + } + reecb.yp().setDataUpdated(); + } + + static const char* variableName(Vars variable) + { + static constexpr std::array names{{ + "VMEAS", + "PMEAS", + "XPIQ", + "XPIV", + "QV", + "PORD", + "VT", + "ILMAX", + "IQCMD", + "IPCMD", + }}; + return names[index(variable)]; + } + + static bool variableMatches(RealT actual, + RealT expected, + const char* what, + Vars variable, + const char* context, + RealT tolerance = kTol) + { + if (isEqual(actual, expected, tolerance)) + { + return true; + } + std::cout << "REECB " << what << ' ' << variableName(variable); + if (context[0] != '\0') + { + std::cout << ' ' << context; + } + std::cout << " mismatch: " + << std::setprecision(std::numeric_limits::max_digits10) + << actual << " != " << expected << '\n'; + return false; + } + + static bool rowMatches(RealT actual, + RealT expected, + const char* what, + size_t row, + const char* context, + RealT tolerance = kTol) + { + if (isEqual(actual, expected, tolerance)) + { + return true; + } + std::cout << "REECB " << what << " row " << row; + if (context[0] != '\0') + { + std::cout << ' ' << context; + } + std::cout << " mismatch: " + << std::setprecision(std::numeric_limits::max_digits10) + << actual << " != " << expected << '\n'; + return false; + } + + bool scalarMatches(RealT actual, + RealT expected, + const char* label, + RealT tolerance = kTol) const + { + if (isEqual(actual, expected, tolerance)) + { + return true; + } + std::cout << "REECB " << label << " mismatch: " + << std::setprecision(std::numeric_limits::max_digits10) + << actual << " != " << expected << '\n'; + return false; + } + + /// A value retains exactly what its owner supplied, including signed + /// infinities and NaN. + static bool preserved(RealT actual, RealT expected) + { + if (expected != expected) + { + return actual != actual; + } + return actual == expected; + } + + bool scalarPreserved(RealT actual, RealT expected, const char* label) const + { + if (preserved(actual, expected)) + { + return true; + } + std::cout << "REECB " << label << " changed: " + << std::setprecision(std::numeric_limits::max_digits10) + << actual << " != " << expected << '\n'; + return false; + } + + static bool valueUnchanged(RealT actual, + RealT expected, + const char* what, + size_t row) + { + if (preserved(actual, expected)) + { + return true; + } + std::cout << "REECB " << what << ' ' << row << " changed: " + << std::setprecision(std::numeric_limits::max_digits10) + << actual << " != " << expected << '\n'; + return false; + } + + static bool finite(RealT value) + { + return value == value + && value < std::numeric_limits::infinity() + && value > -std::numeric_limits::infinity(); } template - bool vectorUnchanged(const VectorT& vector, const std::vector& expected, const char* label) const + bool rowsMatch(const VectorT& vector, + const Row* rows, + size_t count, + const char* what, + const char* context) const { bool success = true; const auto* values = vector.getData(); - for (size_t row = 0; row < expected.size(); ++row) - { - if (values[row] != expected[row]) + for (size_t i = 0; i < count; ++i) + { + const auto& [variable, expected] = rows[i]; + if (!variableMatches(static_cast(values[index(variable)]), + expected, + what, + variable, + context)) { - std::cout << "REECB " << label << " row " << row << " changed during rejected initialization\n"; success = false; } } return success; } - bool allResidualsZero(const ReecbT& reecb) const + bool residualsMatch(const ReecbT& reecb, Rows rows, const char* context = "") const + { + return rowsMatch(reecb.getResidual(), rows.begin(), rows.size(), "residual", context); + } + + bool stateMatches(const ReecbT& reecb, Rows rows, const char* context = "") const + { + return rowsMatch(reecb.y(), rows.begin(), rows.size(), "state", context); + } + + /// The model sits at a steady state: every residual and every derivative + /// is zero. Probes placed inside a smooth transition carry that + /// transition's own MU-dependent tail and pass @p tolerance explicitly. + bool allResidualsAtRest(const ReecbT& reecb, RealT tolerance = kTol) const { bool success = true; const auto* f = reecb.getResidual().getData(); const auto* yp = reecb.yp().getData(); for (size_t row = 0; row < index(Vars::MAXIMUM); ++row) { - if (!scalarMatches(f[row], 0.0, "steady residual")) + const auto variable = static_cast(row); + if (!variableMatches(f[row], 0.0, "residual", variable, "at rest", tolerance)) { success = false; } - if (!scalarMatches(yp[row], 0.0, "steady derivative")) + if (!valueUnchanged(yp[row], 0.0, "derivative", row)) { success = false; } @@ -1611,48 +1993,123 @@ namespace GridKit return success; } - bool scalarMatches(RealT actual, RealT expected, const char* label) const + bool allResidualsFinite(const ReecbT& reecb) const { - if (isEqual(actual, expected, kTol)) + bool success = true; + const auto* f = reecb.getResidual().getData(); + for (size_t row = 0; row < index(Vars::MAXIMUM); ++row) { - return true; + if (!finite(f[row])) + { + std::cout << "REECB residual " << variableName(static_cast(row)) + << " is not finite\n"; + success = false; + } } - std::cout << "REECB " << label << " mismatch: " - << std::setprecision(std::numeric_limits::max_digits10) - << actual << " != " << expected << '\n'; - return false; + return success; } - void noteExpectedLogs(const char* message) const + bool monitorMatches(const ReecbT& reecb, + const std::array& expected, + const char* context) const { - const auto verbosity = Log::verbosity(); - Log::setVerbosity(Log::Verbosity::EVERYTHING); - Log::misc() << message << '\n'; - Log::setVerbosity(verbosity); + RealT time = 0.0; + Model::VariableMonitorController monitor(time); + monitor.addMonitor(reecb.getMonitor()); + std::stringstream output; + monitor.addSink({Model::VariableMonitorFormat::CSV}, output); + monitor.start(); + monitor.print(); + monitor.stop(); + + std::string header; + std::string values_line; + std::getline(output, header); + std::getline(output, values_line); + + bool success = header == "t,Reecb_reecb_test_iqcmd,Reecb_reecb_test_ipcmd," + "Reecb_reecb_test_vmeas,Reecb_reecb_test_pmeas"; + + const auto values = Tokenizer(values_line, ',')(); + if (values.size() != expected.size() + 1) + { + std::cout << "REECB monitor emitted " << values.size() + << " values instead of " << expected.size() + 1 << '\n'; + return false; + } + + for (size_t i = 0; i < expected.size(); ++i) + { + if (!rowMatches(values[i + 1], expected[i], "monitor", i, context)) + { + success = false; + } + } + return success; } - template - void setJacobianState(Fixture& fixture, RealT ilmax = 1.2) const + template + std::vector copyVector(const VectorT& vector) const { - fixture.input(Ext::PE) = 0.25; - fixture.input(Ext::QGEN) = 0.5; - fixture.input(Ext::QEXT) = 0.5; - fixture.input(Ext::PFAREF) = std::atan(2.0); - fixture.input(Ext::PREF) = 0.25; - - auto* y = fixture.reecb.y().getData(); - y[index(Vars::VMEAS)] = 1.0; - y[index(Vars::PMEAS)] = 0.5; - y[index(Vars::XPIQ)] = 1.0; - y[index(Vars::XPIV)] = 0.0; - y[index(Vars::QV)] = 0.0; - y[index(Vars::PORD)] = 0.5; - y[index(Vars::VT)] = 1.0; - y[index(Vars::ILMAX)] = ilmax; - y[index(Vars::IQCMD)] = 0.1; - y[index(Vars::IPCMD)] = 0.2; - fixture.reecb.yp().setToConst(static_cast(0.0)); - fixture.reecb.y().setDataUpdated(); + const auto* values = vector.getData(); + std::vector snapshot(static_cast(vector.getSize())); + for (size_t row = 0; row < snapshot.size(); ++row) + { + snapshot[row] = static_cast(values[row]); + } + return snapshot; + } + + template + bool vectorUnchanged(const VectorT& vector, + const std::vector& snapshot, + const char* what) const + { + bool success = true; + const auto* values = vector.getData(); + for (size_t row = 0; row < snapshot.size(); ++row) + { + if (!valueUnchanged(static_cast(values[row]), snapshot[row], what, row)) + { + success = false; + } + } + return success; + } + + template + bool vectorsMatch(const LeftVectorT& left, + const RightVectorT& right, + const char* what) const + { + if (left.getSize() != right.getSize()) + { + std::cout << "REECB " << what << " size mismatch\n"; + return false; + } + bool success = true; + const auto* left_values = left.getData(); + const auto* right_values = right.getData(); + for (size_t row = 0; row < static_cast(left.getSize()); ++row) + { + if (!rowMatches(static_cast(left_values[row]), + static_cast(right_values[row]), + what, + row, + "")) + { + success = false; + } + } + return success; + } + + void noteExpectedLogs(const char* message) const + { + const auto previous_verbosity = Log::verbosity(); + Log::setVerbosity(Log::Verbosity::EVERYTHING); + Log::misc() << message << '\n'; + Log::setVerbosity(previous_verbosity); } void numberVariables(Fixture& fixture, RealT alpha) const @@ -1660,6 +2117,7 @@ namespace GridKit auto* y = fixture.reecb.y().getData(); auto* yp = fixture.reecb.yp().getData(); auto* bus_y = fixture.bus.y().getData(); + for (size_t row = 0; row < index(Vars::MAXIMUM); ++row) { y[row].setVariableNumber(row); @@ -1668,69 +2126,66 @@ namespace GridKit } for (size_t row = 0; row < static_cast(fixture.bus.size()); ++row) { - bus_y[row].setVariableNumber(index(Vars::MAXIMUM) + row); + bus_y[row].setVariableNumber(kBusVrColumn + row); } for (size_t port = 0; port < index(Ext::MAXIMUM); ++port) { - fixture.input(static_cast(port)).setVariableNumber(fixture.inputIndex(static_cast(port))); + const auto variable = static_cast(port); + fixture.input(variable).setVariableNumber(fixture.inputIndex(variable)); } + fixture.reecb.y().setDataUpdated(); fixture.reecb.yp().setDataUpdated(); fixture.bus.y().setDataUpdated(); } - std::vector dependencyTrackingJacobian(const Data& data, RealT alpha, TestStatus& success, RealT ilmax = 1.2) const + std::vector dependencyTrackingJacobian(const Data& data, + RealT alpha, + TestStatus& success, + RealT ilmax = 2.0) const { using DepVar = DependencyTracking::Variable; - Fixture fixture(data, 0.9, 0.4); + + Fixture fixture(data, kStateVr, kStateVi); fixture.attachAllInputs(); - fixture.input(Ext::PE) = 0.2; - success *= fixture.initialize(0.0, 0.2); + success *= fixture.prepare(0.0, 0.2); setJacobianState(fixture, ilmax); numberVariables(fixture, alpha); success *= (fixture.evaluate() == 0); - std::vector jacobian(index(Vars::MAXIMUM)); - const auto* residual = fixture.reecb.getResidual().getData(); - for (size_t row = 0; row < jacobian.size(); ++row) + std::vector rows(index(Vars::MAXIMUM)); + const auto* f = fixture.reecb.getResidual().getData(); + for (size_t row = 0; row < rows.size(); ++row) { - jacobian[row] = residual[row].getDependencies(); + rows[row] = f[row].getDependencies(); } - return jacobian; + return rows; } -#ifdef GRIDKIT_ENABLE_ENZYME - std::vector enzymeJacobian(const Data& data, RealT alpha, TestStatus& success, RealT ilmax = 1.2) const + static RealT derivative(const std::vector& jacobian, size_t row, size_t column) { - Fixture fixture(data, 0.9, 0.4); - fixture.attachAllInputs(); - fixture.input(Ext::PE) = 0.2; - success *= fixture.initialize(0.0, 0.2); - for (IdxT row = 0; row < fixture.bus.size(); ++row) + const auto entry = jacobian[row].find(column); + if (entry == jacobian[row].end()) { - fixture.bus.setVariableIndex(row, fixture.reecb.size() + row); + return 0.0; } - setJacobianState(fixture, ilmax); - fixture.reecb.updateTime(0.0, alpha); - success *= (fixture.evaluate() == 0); - success *= (fixture.reecb.evaluateJacobian() == 0); - success *= (fixture.reecb.constructCsr() == 0); - return MapFromCsr(fixture.reecb.getCsrJacobian()); - } -#endif - - static RealT derivative(const std::vector& jacobian, size_t row, size_t column) - { - const auto entry = jacobian[row].find(column); - return entry == jacobian[row].end() ? 0.0 : entry->second; + return entry->second; } - bool derivativeMatches(const std::vector& jacobian, Vars row, Vars column, RealT expected, const char* label) const + bool derivativeMatches(const std::vector& jacobian, + Vars row, + Vars column, + RealT expected, + const char* label) const { return derivativeMatches(jacobian, row, index(column), expected, label); } - bool derivativeMatches(const std::vector& jacobian, Vars row, size_t column, RealT expected, const char* label) const + bool derivativeMatches(const std::vector& jacobian, + Vars row, + size_t column, + RealT expected, + const char* label) const { const RealT actual = derivative(jacobian, index(row), column); if (isEqual(actual, expected, kTol)) @@ -1744,7 +2199,30 @@ namespace GridKit } #ifdef GRIDKIT_ENABLE_ENZYME - bool jacobiansMatch(const std::vector& dependency, const std::vector& enzyme, size_t columns) const + std::vector enzymeJacobian(const Data& data, + RealT alpha, + TestStatus& success, + RealT ilmax = 2.0) const + { + Fixture fixture(data, kStateVr, kStateVi); + fixture.attachAllInputs(); + success *= fixture.prepare(0.0, 0.2); + + for (IdxT row = 0; row < fixture.bus.size(); ++row) + { + fixture.bus.setVariableIndex(row, fixture.reecb.size() + row); + } + + setJacobianState(fixture, ilmax); + fixture.reecb.updateTime(0.0, alpha); + success *= (fixture.evaluate() == 0); + success *= (fixture.reecb.evaluateJacobian() == 0); + success *= (fixture.reecb.constructCsr() == 0); + return MapFromCsr(fixture.reecb.getCsrJacobian()); + } + + bool jacobiansMatch(const std::vector& dependency, + const std::vector& enzyme) const { if (dependency.size() != enzyme.size()) { @@ -1755,13 +2233,14 @@ namespace GridKit bool success = true; for (size_t row = 0; row < dependency.size(); ++row) { - for (size_t column = 0; column < columns; ++column) + for (size_t column = 0; column < kColumnCount; ++column) { const RealT expected = derivative(dependency, row, column); const RealT actual = derivative(enzyme, row, column); if (!isEqual(actual, expected, kTol)) { - std::cout << "REECB Jacobian (" << row << ", " << column << ") backend mismatch: " + std::cout << "REECB Jacobian (" << row << ", " << column + << ") backend mismatch: " << std::setprecision(std::numeric_limits::max_digits10) << actual << " != " << expected << '\n'; success = false; diff --git a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp index 712995625..4b899f1bf 100644 --- a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp +++ b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp @@ -1,9 +1,5 @@ -#include -#include #include #include -#include -#include #include #include @@ -15,7 +11,8 @@ namespace GridKit { namespace Testing { - /// Component smoke tests and focused integration tests through SystemModel. + /// Smoke test for components (single component connected to an infinite bus) + /// through the system model with the minimal constructors template class SystemSingleComponentTests { @@ -300,258 +297,72 @@ namespace GridKit return success.report(__func__); } + /// REECB through the production path: model data to system construction + /// to required bus and current-command signal wiring. Initialization + /// reconstructs the operating point from the seeded commands, so both + /// must be published before the system initializes. TestOutcome reecb() { - using ConstantParams = PhasorDynamics::ConstantSignalSourceParameters; - using ConstantOutputs = PhasorDynamics::ConstantSignalSourceSignalOutputs; - using Vars = PhasorDynamics::Converter::ReecbInternalVariables; - using Ext = PhasorDynamics::Converter::ReecbExternalVariables; - using ReecbT = PhasorDynamics::Converter::Reecb; + using Data = PhasorDynamics::Converter::ReecbData; + using Buses = typename Data::Buses; + using Outputs = typename Data::SignalOutputs; + using Params = typename Data::Parameters; + using Vars = PhasorDynamics::Converter::ReecbInternalVariables; - TestStatus success = true; + constexpr IdxT bus_id = static_cast(1); + constexpr IdxT iqcmd_id = static_cast(1); + constexpr IdxT ipcmd_id = static_cast(2); - // A missing required bus must remain missing so REECB verification can - // reject it; SystemModel must not silently substitute bus zero. - { - std::cout << "Testing missing REECB bus mapping; logged errors are expected.\n"; - auto missing_bus_model = makeReecbData(); - missing_bus_model.buses.clear(); - missing_bus_model.signal_inputs.clear(); - missing_bus_model.signal_outputs.clear(); - - PhasorDynamics::SystemModelData missing_bus_data; - missing_bus_data.bus.resize(1); - missing_bus_data.bus[0].bus_id = static_cast(0); - missing_bus_data.bus[0].bus_type = - PhasorDynamics::BusData::BusType::SLACK; - missing_bus_data.reecb.push_back(missing_bus_model); - - bool rejected = false; - try - { - PhasorDynamics::SystemModel missing_bus_system(missing_bus_data); - missing_bus_system.allocate(); - } - catch (const std::runtime_error&) - { - rejected = true; - } - success *= rejected; - } + TestStatus success = true; PhasorDynamics::SystemModelData data; data.va_base = static_cast(100.0e6); data.bus.resize(1); - data.bus[0].bus_id = static_cast(1); + data.bus[0].bus_id = bus_id; data.bus[0].bus_type = PhasorDynamics::BusData::BusType::SLACK; data.bus[0].Vr0 = static_cast(1.0); data.bus[0].Vi0 = static_cast(0.0); - data.signal.resize(static_cast(ipcmd_signal_id)); - for (size_t signal = 0; signal < data.signal.size(); ++signal) - { - data.signal[signal].signal_id = pe_signal_id + static_cast(signal); - } - - data.reecb.push_back(makeReecbData()); - - typename PhasorDynamics::SystemModelData::ConstantSourceT source; - source.parameters[ConstantParams::Sr] = static_cast(0.25); - source.parameters[ConstantParams::Si] = static_cast(0.05); - source.signal_outputs[ConstantOutputs::sr] = pe_signal_id; - source.signal_outputs[ConstantOutputs::si] = qgen_signal_id; - data.constant_source.push_back(source); - - source.signal_outputs[ConstantOutputs::sr] = qext_signal_id; - source.signal_outputs[ConstantOutputs::si] = pfaref_signal_id; - data.constant_source.push_back(source); - - source.signal_outputs.erase(ConstantOutputs::si); - source.signal_outputs[ConstantOutputs::sr] = pref_signal_id; - data.constant_source.push_back(source); + data.signal.resize(2); + data.signal[0].signal_id = iqcmd_id; + data.signal[0].name = "Reactive Current Command"; + data.signal[1].signal_id = ipcmd_id; + data.signal[1].name = "Active Current Command"; + + Data reecb_data; + reecb_data.device_class = "Reecb"; + reecb_data.disambiguation_string = "reecb_system"; + reecb_data.buses[Buses::bus] = bus_id; + reecb_data.parameters[Params::mva] = static_cast(50.0); + reecb_data.signal_outputs[Outputs::iqcmd] = iqcmd_id; + reecb_data.signal_outputs[Outputs::ipcmd] = ipcmd_id; + data.reecb.push_back(reecb_data); PhasorDynamics::SystemModel system(data); success *= system.allocate() == 0; - system.getSignal(iqcmd_signal_id)->init(static_cast(0.05)); - system.getSignal(ipcmd_signal_id)->init(static_cast(0.25)); - success *= system.verify() == 0; - for (IdxT signal_id = pe_signal_id; signal_id <= ipcmd_signal_id; ++signal_id) - { - success *= system.getSignal(signal_id)->linked(); - } + system.getSignal(iqcmd_id)->init(static_cast(0.05)); + system.getSignal(ipcmd_id)->init(static_cast(0.25)); success *= system.initialize() == 0; success *= system.tagDifferentiable() == 0; success *= system.evaluateResidual() == 0; success *= system.evaluateJacobian() == 0; success *= system.size() == static_cast(Vars::MAXIMUM); - auto* reecb = dynamic_cast(system.getComponent(static_cast(0))); - success *= reecb != nullptr; - if (reecb != nullptr) - { - auto& signals = reecb->getSignals(); - success *= signals.template readExternalVariableIndex() - == system.getSignal(pe_signal_id)->getVariableIndex(); - success *= signals.template readExternalVariableIndex() - == system.getSignal(qgen_signal_id)->getVariableIndex(); - success *= signals.template readExternalVariableIndex() - == system.getSignal(qext_signal_id)->getVariableIndex(); - success *= signals.template readExternalVariableIndex() - == system.getSignal(pfaref_signal_id)->getVariableIndex(); - success *= signals.template readExternalVariableIndex() - == system.getSignal(pref_signal_id)->getVariableIndex(); - success *= signals.template getSignalNode() - == system.getSignal(iqcmd_signal_id); - success *= signals.template getSignalNode() - == system.getSignal(ipcmd_signal_id); - - success *= residualsAreZero(*reecb, "REECB SystemModel"); - - // The constant sources drive qext and pfaref with 0.25/0.05, but - // REECB overwrites attached unknown references during initialize(), - // so the published values below are its resolved setpoints. - const std::array(ipcmd_signal_id)> expected_signals{ - 0.25, // pe from the constant source - 0.05, // qgen from the constant source - 0.05, // qext resolved by REECB - 0.0, // pfaref resolved by REECB - 0.25, // pref resolved by REECB - 0.05, // iqcmd owned by REECB - 0.25, // ipcmd owned by REECB - }; - for (size_t signal = 0; signal < expected_signals.size(); ++signal) - { - success *= isEqual( - static_cast(system.getSignal(pe_signal_id + static_cast(signal))->read()), - expected_signals[signal], - static_cast(1.0e-9)); - } - - // The component/system base ratio is two. Perturbing only the - // system-base command therefore changes its component-base residual - // by twice the perturbation. - const auto* residual = reecb->getResidual().getData(); - system.getSignal(iqcmd_signal_id)->init(static_cast(0.06)); - success *= system.evaluateResidual() == 0; - success *= isEqual(static_cast(residual[static_cast(Vars::IQCMD)]), - static_cast(-0.02), - static_cast(1.0e-9)); - } - return success.report(__func__); - } + auto* iqcmd = system.getSignal(iqcmd_id); + auto* ipcmd = system.getSignal(ipcmd_id); + success *= iqcmd->linked(); + success *= ipcmd->linked(); + success *= iqcmd->getVariableIndex() == static_cast(Vars::IQCMD); + success *= ipcmd->getVariableIndex() == static_cast(Vars::IPCMD); - /// REGCA initializes the shared current commands and actual power - /// feedback before REECB consumes them, with mixed component/system - /// bases at off-nominal terminal voltages. Signal wiring identity for - /// the pair is covered by ReecbIntegrationTests. - TestOutcome regcaReecb() - { - using ConstantParams = PhasorDynamics::ConstantSignalSourceParameters; - using ConstantOutputs = PhasorDynamics::ConstantSignalSourceSignalOutputs; - using RegcaParams = PhasorDynamics::Converter::RegcaParameters; - using RegcaInputs = PhasorDynamics::Converter::RegcaSignalInputs; - using RegcaOutputs = PhasorDynamics::Converter::RegcaSignalOutputs; - using RegcaVars = PhasorDynamics::Converter::RegcaInternalVariables; - using ReecbParams = PhasorDynamics::Converter::ReecbParameters; - using ReecbVars = PhasorDynamics::Converter::ReecbInternalVariables; - using RegcaT = PhasorDynamics::Converter::Regca; - using ReecbT = PhasorDynamics::Converter::Reecb; - - TestStatus success = true; + auto missing_bus_data = data; + missing_bus_data.bus[0].bus_id = static_cast(0); + missing_bus_data.reecb[0].buses.clear(); - for (const RealT terminal_voltage : - {static_cast(0.9), static_cast(1.19)}) - { - PhasorDynamics::SystemModelData data; - data.va_base = static_cast(100.0e6); - data.bus.resize(1); - data.bus[0].bus_id = static_cast(1); - data.bus[0].bus_type = PhasorDynamics::BusData::BusType::SLACK; - data.bus[0].Vr0 = terminal_voltage; - data.bus[0].Vi0 = static_cast(0.0); - data.signal.resize(static_cast(ipcmd_signal_id)); - for (size_t signal = 0; signal < data.signal.size(); ++signal) - { - data.signal[signal].signal_id = pe_signal_id + static_cast(signal); - } - - auto regca_data = makeRegcaData(); - regca_data.parameters[RegcaParams::p0] = static_cast(0.25); - regca_data.parameters[RegcaParams::q0] = static_cast(0.05); - regca_data.parameters[RegcaParams::mva] = static_cast(50.0); - regca_data.signal_inputs[RegcaInputs::ipcmd] = ipcmd_signal_id; - regca_data.signal_inputs[RegcaInputs::iqcmd] = iqcmd_signal_id; - regca_data.signal_outputs[RegcaOutputs::pbranch] = pe_signal_id; - regca_data.signal_outputs[RegcaOutputs::qbranch] = qgen_signal_id; - data.regca.push_back(regca_data); - - auto reecb_data = makeReecbData(); - reecb_data.parameters[ReecbParams::QFlag] = true; - reecb_data.parameters[ReecbParams::VFlag] = true; - reecb_data.parameters[ReecbParams::Vmin] = static_cast(0.5); - reecb_data.parameters[ReecbParams::Vmax] = static_cast(1.4); - data.reecb.push_back(reecb_data); - - typename PhasorDynamics::SystemModelData::ConstantSourceT source; - source.parameters[ConstantParams::Sr] = static_cast(0.0); - source.parameters[ConstantParams::Si] = static_cast(0.0); - source.signal_outputs[ConstantOutputs::sr] = qext_signal_id; - source.signal_outputs[ConstantOutputs::si] = pfaref_signal_id; - data.constant_source.push_back(source); - source.signal_outputs.erase(ConstantOutputs::si); - source.signal_outputs[ConstantOutputs::sr] = pref_signal_id; - data.constant_source.push_back(source); - - PhasorDynamics::SystemModel system(data); - success *= system.allocate() == 0; - success *= system.verify() == 0; - for (IdxT signal_id = pe_signal_id; signal_id <= ipcmd_signal_id; ++signal_id) - { - success *= system.getSignal(signal_id)->linked(); - } - success *= system.initialize() == 0; - success *= system.tagDifferentiable() == 0; - success *= system.evaluateResidual() == 0; - success *= system.evaluateJacobian() == 0; - success *= system.size() - == static_cast(RegcaVars::MAXIMUM) - + static_cast(ReecbVars::MAXIMUM); - - auto* regca = dynamic_cast(system.getComponent(static_cast(0))); - auto* reecb = dynamic_cast(system.getComponent(static_cast(1))); - success *= regca != nullptr; - success *= reecb != nullptr; - if (regca == nullptr || reecb == nullptr) - { - continue; - } - - const RealT pe = static_cast(system.getSignal(pe_signal_id)->read()); - const RealT qgen = static_cast(system.getSignal(qgen_signal_id)->read()); - const RealT ipcmd = static_cast(system.getSignal(ipcmd_signal_id)->read()); - const RealT iqcmd = static_cast(system.getSignal(iqcmd_signal_id)->read()); - success *= isEqual(pe, static_cast(0.25), static_cast(1.0e-12)); - success *= isEqual(qgen, static_cast(0.05), static_cast(1.0e-12)); - success *= isEqual( - static_cast(reecb->y().getData()[static_cast(ReecbVars::PMEAS)]), - static_cast(0.5), - static_cast(1.0e-12)); - - if (terminal_voltage < static_cast(1.0)) - { - success *= std::abs(pe - ipcmd * terminal_voltage) - > static_cast(1.0e-6); - } - else - { - success *= std::abs(qgen - iqcmd * terminal_voltage) - > static_cast(1.0e-6); - } - - success *= residualsAreZero(*regca, "REGCA integrated"); - success *= residualsAreZero(*reecb, "REECB integrated"); - } + PhasorDynamics::SystemModel missing_bus_system(missing_bus_data); + std::cout << "Testing expected REECB missing-bus configuration error.\n"; + success *= missing_bus_system.verify() > 0; return success.report(__func__); } @@ -678,26 +489,6 @@ namespace GridKit } private: - template - bool residualsAreZero(ComponentT& component, const char* name) const - { - const auto* residual = component.getResidual().getData(); - bool match = true; - for (size_t row = 0; row < static_cast(component.size()); ++row) - { - if (!isEqual(static_cast(residual[row]), - static_cast(0.0), - static_cast(1.0e-9))) - { - std::cout << name << " residual row " << row << " is not at steady state: " - << std::setprecision(std::numeric_limits::max_digits10) - << residual[row] << '\n'; - match = false; - } - } - return match; - } - auto makeRegcaData() -> PhasorDynamics::Converter::RegcaData { using Params = PhasorDynamics::Converter::RegcaParameters; @@ -724,36 +515,6 @@ namespace GridKit data.parameters[Params::Vhvmax] = static_cast(1.2); return data; } - - static constexpr IdxT pe_signal_id = 1; - static constexpr IdxT qgen_signal_id = 2; - static constexpr IdxT qext_signal_id = 3; - static constexpr IdxT pfaref_signal_id = 4; - static constexpr IdxT pref_signal_id = 5; - static constexpr IdxT iqcmd_signal_id = 6; - static constexpr IdxT ipcmd_signal_id = 7; - - auto makeReecbData() -> PhasorDynamics::Converter::ReecbData - { - using Params = PhasorDynamics::Converter::ReecbParameters; - using Buses = PhasorDynamics::Converter::ReecbBuses; - using Inputs = PhasorDynamics::Converter::ReecbSignalInputs; - using Outputs = PhasorDynamics::Converter::ReecbSignalOutputs; - - PhasorDynamics::Converter::ReecbData data; - data.device_class = "Reecb"; - data.disambiguation_string = "reecb_test"; - data.buses[Buses::bus] = static_cast(1); - data.parameters[Params::mva] = static_cast(50.0); - data.signal_inputs[Inputs::pe] = pe_signal_id; - data.signal_inputs[Inputs::qgen] = qgen_signal_id; - data.signal_inputs[Inputs::qext] = qext_signal_id; - data.signal_inputs[Inputs::pfaref] = pfaref_signal_id; - data.signal_inputs[Inputs::pref] = pref_signal_id; - data.signal_outputs[Outputs::iqcmd] = iqcmd_signal_id; - data.signal_outputs[Outputs::ipcmd] = ipcmd_signal_id; - return data; - } }; } // namespace Testing diff --git a/tests/UnitTests/PhasorDynamics/runComponentConnectionTests.cpp b/tests/UnitTests/PhasorDynamics/runComponentConnectionTests.cpp index b8c7f36f5..ce2d37dbb 100644 --- a/tests/UnitTests/PhasorDynamics/runComponentConnectionTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runComponentConnectionTests.cpp @@ -10,6 +10,7 @@ int main() result += test.genrouEsdc1a(); result += test.genrouHygov(); result += test.regcaRepca(); + result += test.regcaReecb(); return result.summary(); } diff --git a/tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp b/tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp index 0af27f80e..624395065 100644 --- a/tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp @@ -14,7 +14,9 @@ int main() result += test.voltVarReferenceBase(); result += test.reactiveControl(); result += test.activeCurrentControl(); +#ifdef GRIDKIT_ENABLE_ENZYME result += test.jacobian(); +#endif return result.summary(); } diff --git a/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp b/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp index c80c43190..7fe1dcbe0 100644 --- a/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp @@ -18,7 +18,6 @@ int main() result += test.regca(); result += test.repca(); result += test.reecb(); - result += test.regcaReecb(); result += test.genrou(); result += test.genClassical(); result += test.tgov1(); diff --git a/tests/UnitTests/Utilities/CaseFormatTests.hpp b/tests/UnitTests/Utilities/CaseFormatTests.hpp index 3fc958765..e8d34d80f 100644 --- a/tests/UnitTests/Utilities/CaseFormatTests.hpp +++ b/tests/UnitTests/Utilities/CaseFormatTests.hpp @@ -76,7 +76,7 @@ namespace GridKit { "class": "Gensal", "ports": {"bus":1}, "id": "2", "params": {"p0":1.0, "q0":0.05013, "H":3.0, "D":0.0, "Ra":0.0, "Tdop":7.0, "Tdopp":0.04, "Tqopp":0.05, "Xd":2.1, "Xdp":0.2, "Xdpp":0.18, "Xq":0.5, "Xl":0.15, "S10":0.0, "S12":0.0}, "mon": ["delta", "omega"] }, { "class": "Regca", "ports": {"bus":1}, "id": "CV1", "params": {"p0":0.0, "q0":0.0, "mva":100, "Tg":0.02, "TM":0.02, "Rqmax":999.0, "Rqmin":-999.0, "Rpmax":999.0, "sL":true, "IL1":1.1, "VL0":0.4, "VL1":0.9, "VA0":0.4, "VA1":0.9, "Vhvmax":1.2}, "mon": ["ir", "ii", "p", "q"] }, - { "class": "Reecb", "ports": {"bus":1}, "id": "REE1", "params": {"mva":50.0, "Pqflag":true}, "mon": ["iqcmd", "pmeas"] }, + { "class": "Reecb", "ports": {"bus":1}, "id": "REE1", "params": {"mva":50.0, "Pqflag":true}, "mon": ["iqcmd", "ipcmd", "vmeas", "pmeas"] }, { "class": "BusFault", "ports": {"bus":1}, "id": "1", "params": {"state0": false, "R":0.0, "X":1e-3} } ] })"; @@ -186,6 +186,10 @@ namespace GridKit success *= result.reecb[0].disambiguation_string == "REE1"; success *= result.reecb[0].monitored_variables.contains( ReecbData::MonitorableVariables::iqcmd); + success *= result.reecb[0].monitored_variables.contains( + ReecbData::MonitorableVariables::ipcmd); + success *= result.reecb[0].monitored_variables.contains( + ReecbData::MonitorableVariables::vmeas); success *= result.reecb[0].monitored_variables.contains( ReecbData::MonitorableVariables::pmeas); @@ -266,7 +270,7 @@ namespace GridKit { "class": "Repca", "ports": {"bus":1, "ir":11, "ii":12, "p":13, "q":14, "freq":15, "vref":16, "pref":17, "qref":18, "freqref":19, "qext":20, "pext":21}, "id": "PC1", "params": {"mva":50, "VcompFlag":false, "RefFlag":true, "Freqflag":true, "Tfltr":0.2, "Vfrz":0.65, "Rc":0.02, "Xc":0.03, "Kc":0.4, "dbdlow":-0.02, "dbdupper":0.03, "emax":0.8, "emin":-0.7, "Kp":2.0, "Ki":3.0, "Qmax":0.9, "Qmin":-0.8, "Tft":0.2, "Tfv":1.5, "Tp":0.4, "fdbd1":-0.01, "fdbd2":0.015, "Ddn":2.0, "Dup":1.0, "femax":0.6, "femin":-0.5, "Kpg":1.7, "Kig":1.8, "Pmax":1.2, "Pmin":0.1, "Tlag":0.5}, "mon": ["qext", "pext", "vmeas", "qmeas", "pmeas"] }, { "class": "Ieeet1", "ports": {"bus":1, "speed": 1, "efd":3}, "id": "DV3", "params": {"Tr":0.0, "Ka":50.0, "Ta":0.04, "Ke":-0.06, "Te":0.6, "Kf":0.09, "Tf":1.46, "Vrmin":-1.0, "Vrmax":1.0, "E1":2.8, "E2":3.373, "Se1":0.04, "Se2":0.33, "Ispdlim":0.0}}, { "class": "SexsPti", "ports": {"bus":1, "efd":3}, "id": "DV4", "params": {"Ta":0.1, "Tb":0.5, "Te":0.8, "K":10.0, "Efdmax":5.0, "Efdmin":-5.0}}, - { "class": "Reecb", "ports": {"bus":1, "pe":22, "qgen":23, "qext":24, "pfaref":25, "pref":26, "iqcmd":27, "ipcmd":28}, "id": "EC1", "params": {"mva":100.0}}, + { "class": "Reecb", "ports": {"bus":1, "pe":22, "qgen":23, "qext":24, "pfaref":25, "pref":26, "iqcmd":27, "ipcmd":28}, "id": "EC1", "params": {"mva":100.0, "PfFlag":false, "VFlag":true, "QFlag":true, "Pqflag":true, "Trv":0.02, "Tp":0.05, "Vref0":1.0, "Vdip":0.85, "Vup":1.15, "dbd1":-0.01, "dbd2":0.01, "kqv":5.0, "Iql1":-1.1, "Iqh1":1.1, "Qmax":0.436, "Qmin":-0.436, "Kqp":0.1, "Kqi":0.2, "Vmax":1.1, "Vmin":0.9, "Kvp":18.0, "Kvi":5.0, "Tiq":0.02, "Tpord":0.02, "dPmax":99.0, "dPmin":-99.0, "Pmax":1.0, "Pmin":0.0, "Imax":1.3}, "mon": ["iqcmd", "ipcmd", "vmeas", "pmeas"]}, { "class": "BusFault", "ports": {"bus":1}, "id": "1", "params": {"state0": false, "R":0.0, "X":1e-3} } ] })"; @@ -536,16 +540,55 @@ namespace GridKit success *= result.sexspti[0].signal_outputs[Exciter::SexsPtiSignalOutputs::efd] == 3; success *= result.sexspti[0].disambiguation_string == "DV4"; - using ReecbData = Converter::ReecbData; - success *= result.reecb[0].buses[ReecbData::Buses::bus] == 1; - success *= result.reecb[0].signal_inputs[ReecbData::SignalInputs::pe] == 22; - success *= result.reecb[0].signal_inputs[ReecbData::SignalInputs::qgen] == 23; - success *= result.reecb[0].signal_inputs[ReecbData::SignalInputs::qext] == 24; - success *= result.reecb[0].signal_inputs[ReecbData::SignalInputs::pfaref] == 25; - success *= result.reecb[0].signal_inputs[ReecbData::SignalInputs::pref] == 26; - success *= result.reecb[0].signal_outputs[ReecbData::SignalOutputs::iqcmd] == 27; - success *= result.reecb[0].signal_outputs[ReecbData::SignalOutputs::ipcmd] == 28; - success *= result.reecb[0].disambiguation_string == "EC1"; + using ReecbData = Converter::ReecbData; + using ReecbParams = ReecbData::Parameters; + success *= std::get(result.reecb[0].parameters[ReecbParams::mva]) == 100.0; + success *= !std::get(result.reecb[0].parameters[ReecbParams::PfFlag]); + success *= std::get(result.reecb[0].parameters[ReecbParams::VFlag]); + success *= std::get(result.reecb[0].parameters[ReecbParams::QFlag]); + success *= std::get(result.reecb[0].parameters[ReecbParams::Pqflag]); + success *= std::get(result.reecb[0].parameters[ReecbParams::Trv]) == 0.02; + success *= std::get(result.reecb[0].parameters[ReecbParams::Tp]) == 0.05; + success *= std::get(result.reecb[0].parameters[ReecbParams::Vref0]) == 1.0; + success *= std::get(result.reecb[0].parameters[ReecbParams::Vdip]) == 0.85; + success *= std::get(result.reecb[0].parameters[ReecbParams::Vup]) == 1.15; + success *= std::get(result.reecb[0].parameters[ReecbParams::dbd1]) == -0.01; + success *= std::get(result.reecb[0].parameters[ReecbParams::dbd2]) == 0.01; + success *= std::get(result.reecb[0].parameters[ReecbParams::kqv]) == 5.0; + success *= std::get(result.reecb[0].parameters[ReecbParams::Iql1]) == -1.1; + success *= std::get(result.reecb[0].parameters[ReecbParams::Iqh1]) == 1.1; + success *= std::get(result.reecb[0].parameters[ReecbParams::Qmax]) == 0.436; + success *= std::get(result.reecb[0].parameters[ReecbParams::Qmin]) == -0.436; + success *= std::get(result.reecb[0].parameters[ReecbParams::Kqp]) == 0.1; + success *= std::get(result.reecb[0].parameters[ReecbParams::Kqi]) == 0.2; + success *= std::get(result.reecb[0].parameters[ReecbParams::Vmax]) == 1.1; + success *= std::get(result.reecb[0].parameters[ReecbParams::Vmin]) == 0.9; + success *= std::get(result.reecb[0].parameters[ReecbParams::Kvp]) == 18.0; + success *= std::get(result.reecb[0].parameters[ReecbParams::Kvi]) == 5.0; + success *= std::get(result.reecb[0].parameters[ReecbParams::Tiq]) == 0.02; + success *= std::get(result.reecb[0].parameters[ReecbParams::Tpord]) == 0.02; + success *= std::get(result.reecb[0].parameters[ReecbParams::dPmax]) == 99.0; + success *= std::get(result.reecb[0].parameters[ReecbParams::dPmin]) == -99.0; + success *= std::get(result.reecb[0].parameters[ReecbParams::Pmax]) == 1.0; + success *= std::get(result.reecb[0].parameters[ReecbParams::Pmin]) == 0.0; + success *= std::get(result.reecb[0].parameters[ReecbParams::Imax]) == 1.3; + success *= result.reecb[0].buses[ReecbData::Buses::bus] == 1; + success *= result.reecb[0].signal_inputs[ReecbData::SignalInputs::pe] == 22; + success *= result.reecb[0].signal_inputs[ReecbData::SignalInputs::qgen] == 23; + success *= result.reecb[0].signal_inputs[ReecbData::SignalInputs::qext] == 24; + success *= result.reecb[0].signal_inputs[ReecbData::SignalInputs::pfaref] == 25; + success *= result.reecb[0].signal_inputs[ReecbData::SignalInputs::pref] == 26; + success *= result.reecb[0].signal_outputs[ReecbData::SignalOutputs::iqcmd] == 27; + success *= result.reecb[0].signal_outputs[ReecbData::SignalOutputs::ipcmd] == 28; + success *= result.reecb[0].disambiguation_string == "EC1"; + success *= result.reecb[0].monitored_variables.contains( + ReecbData::MonitorableVariables::iqcmd); + success *= result.reecb[0].monitored_variables.contains( + ReecbData::MonitorableVariables::ipcmd); + success *= result.reecb[0].monitored_variables.contains( + ReecbData::MonitorableVariables::vmeas); + success *= result.reecb[0].monitored_variables.contains( + ReecbData::MonitorableVariables::pmeas); success *= std::get(result.bus_fault[0].parameters[BusFaultParameters::R]) == 0.0; success *= std::get(result.bus_fault[0].parameters[BusFaultParameters::X]) == 1e-3; From a9ddbcbee8919da66c85f154e7cf3b26d27f6d69 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Wed, 5 Aug 2026 08:27:01 -0500 Subject: [PATCH 09/16] Controller family --- .../Model/PhasorDynamics/ComponentLibrary.hpp | 2 +- .../PhasorDynamics/Controller/CMakeLists.txt | 1 + .../Model/PhasorDynamics/Controller/README.md | 1 + .../REECB/CMakeLists.txt | 10 ++--- .../{Converter => Controller}/REECB/README.md | 0 .../{Converter => Controller}/REECB/Reecb.cpp | 4 +- .../{Converter => Controller}/REECB/Reecb.hpp | 6 +-- .../REECB/ReecbData.hpp | 4 +- .../REECB/ReecbDependencyTracking.cpp | 4 +- .../REECB/ReecbEnzyme.cpp | 6 +-- .../REECB/ReecbImpl.hpp | 8 ++-- .../PhasorDynamics/Converter/CMakeLists.txt | 1 - .../Model/PhasorDynamics/Converter/README.md | 7 ++-- .../Model/PhasorDynamics/SystemModelData.hpp | 4 +- .../Model/PhasorDynamics/Controller/README.md | 1 + .../PhasorDynamics/Controller/REECB/README.md | 6 +++ .../Model/PhasorDynamics/Converter/README.md | 1 - .../PhasorDynamics/Converter/REECB/README.md | 6 --- .../PhasorDynamics/PDIntegrationTests.hpp | 5 ++- tests/UnitTests/PhasorDynamics/CMakeLists.txt | 12 +++--- .../ComponentConnectionTests.hpp | 14 +++---- ...eecbTests.hpp => ControllerReecbTests.hpp} | 38 +++++++++---------- .../SystemSingleComponentTests.hpp | 4 +- ...bTests.cpp => runControllerReecbTests.cpp} | 6 +-- tests/UnitTests/Utilities/CaseFormatTests.hpp | 6 +-- 25 files changed, 79 insertions(+), 78 deletions(-) rename GridKit/Model/PhasorDynamics/{Converter => Controller}/REECB/CMakeLists.txt (82%) rename GridKit/Model/PhasorDynamics/{Converter => Controller}/REECB/README.md (100%) rename GridKit/Model/PhasorDynamics/{Converter => Controller}/REECB/Reecb.cpp (93%) rename GridKit/Model/PhasorDynamics/{Converter => Controller}/REECB/Reecb.hpp (98%) rename GridKit/Model/PhasorDynamics/{Converter => Controller}/REECB/ReecbData.hpp (99%) rename GridKit/Model/PhasorDynamics/{Converter => Controller}/REECB/ReecbDependencyTracking.cpp (94%) rename GridKit/Model/PhasorDynamics/{Converter => Controller}/REECB/ReecbEnzyme.cpp (96%) rename GridKit/Model/PhasorDynamics/{Converter => Controller}/REECB/ReecbImpl.hpp (99%) create mode 100644 docs/GridKit/Model/PhasorDynamics/Controller/REECB/README.md delete mode 100644 docs/GridKit/Model/PhasorDynamics/Converter/REECB/README.md rename tests/UnitTests/PhasorDynamics/{ConverterReecbTests.hpp => ControllerReecbTests.hpp} (98%) rename tests/UnitTests/PhasorDynamics/{runConverterReecbTests.cpp => runControllerReecbTests.cpp} (74%) diff --git a/GridKit/Model/PhasorDynamics/ComponentLibrary.hpp b/GridKit/Model/PhasorDynamics/ComponentLibrary.hpp index 11083eee1..4d2d81aa5 100644 --- a/GridKit/Model/PhasorDynamics/ComponentLibrary.hpp +++ b/GridKit/Model/PhasorDynamics/ComponentLibrary.hpp @@ -5,8 +5,8 @@ #include #include #include +#include #include -#include #include #include #include diff --git a/GridKit/Model/PhasorDynamics/Controller/CMakeLists.txt b/GridKit/Model/PhasorDynamics/Controller/CMakeLists.txt index be3287ed4..d3ff1437e 100644 --- a/GridKit/Model/PhasorDynamics/Controller/CMakeLists.txt +++ b/GridKit/Model/PhasorDynamics/Controller/CMakeLists.txt @@ -3,4 +3,5 @@ # - Luke Lowery # ]] +add_subdirectory(REECB) add_subdirectory(REPCA) diff --git a/GridKit/Model/PhasorDynamics/Controller/README.md b/GridKit/Model/PhasorDynamics/Controller/README.md index ce5f57486..aac18d485 100644 --- a/GridKit/Model/PhasorDynamics/Controller/README.md +++ b/GridKit/Model/PhasorDynamics/Controller/README.md @@ -7,4 +7,5 @@ directly contributing to the network equations. ## Types +- Renewable Energy Electrical Control Model REECB (See [REECB](REECB/README.md)) - Renewable Energy Plant Control Model REPCA (See [REPCA](REPCA/README.md)) diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/CMakeLists.txt b/GridKit/Model/PhasorDynamics/Controller/REECB/CMakeLists.txt similarity index 82% rename from GridKit/Model/PhasorDynamics/Converter/REECB/CMakeLists.txt rename to GridKit/Model/PhasorDynamics/Controller/REECB/CMakeLists.txt index 03492a503..7bc3f9c68 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/CMakeLists.txt +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/CMakeLists.txt @@ -7,7 +7,7 @@ set(_install_headers Reecb.hpp ReecbData.hpp) if(GRIDKIT_ENABLE_ENZYME) gridkit_add_library( - phasor_dynamics_converter_reecb + phasor_dynamics_controller_reecb SOURCES ReecbEnzyme.cpp HEADERS ${_install_headers} INCLUDE_DIRECTORIES PRIVATE ${GRIDKIT_THIRD_PARTY_DIR}/magic-enum/include @@ -25,7 +25,7 @@ if(GRIDKIT_ENABLE_ENZYME) -fno-math-errno) else() gridkit_add_library( - phasor_dynamics_converter_reecb + phasor_dynamics_controller_reecb SOURCES Reecb.cpp HEADERS ${_install_headers} INCLUDE_DIRECTORIES PRIVATE ${GRIDKIT_THIRD_PARTY_DIR}/magic-enum/include @@ -37,7 +37,7 @@ else() endif() gridkit_add_library( - phasor_dynamics_converter_reecb_dependency_tracking + phasor_dynamics_controller_reecb_dependency_tracking SOURCES ReecbDependencyTracking.cpp INCLUDE_DIRECTORIES PRIVATE ${GRIDKIT_THIRD_PARTY_DIR}/magic-enum/include LINK_LIBRARIES @@ -48,7 +48,7 @@ gridkit_add_library( target_link_libraries( phasor_dynamics_components - INTERFACE GridKit::phasor_dynamics_converter_reecb) + INTERFACE GridKit::phasor_dynamics_controller_reecb) target_link_libraries( phasor_dynamics_components_dependency_tracking - INTERFACE GridKit::phasor_dynamics_converter_reecb_dependency_tracking) + INTERFACE GridKit::phasor_dynamics_controller_reecb_dependency_tracking) diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/README.md b/GridKit/Model/PhasorDynamics/Controller/REECB/README.md similarity index 100% rename from GridKit/Model/PhasorDynamics/Converter/REECB/README.md rename to GridKit/Model/PhasorDynamics/Controller/REECB/README.md diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.cpp b/GridKit/Model/PhasorDynamics/Controller/REECB/Reecb.cpp similarity index 93% rename from GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.cpp rename to GridKit/Model/PhasorDynamics/Controller/REECB/Reecb.cpp index a609eba45..9e12d4031 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.cpp +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/Reecb.cpp @@ -10,7 +10,7 @@ namespace GridKit { namespace PhasorDynamics { - namespace Converter + namespace Controller { /** * @brief Report that a separate Jacobian is unavailable in the plain-real build. @@ -25,6 +25,6 @@ namespace GridKit template class Reecb; template class Reecb; - } // namespace Converter + } // namespace Controller } // namespace PhasorDynamics } // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp b/GridKit/Model/PhasorDynamics/Controller/REECB/Reecb.hpp similarity index 98% rename from GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp rename to GridKit/Model/PhasorDynamics/Controller/REECB/Reecb.hpp index 017e2883c..218236d80 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/Reecb.hpp +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/Reecb.hpp @@ -14,7 +14,7 @@ #include #include #include -#include +#include #include namespace GridKit @@ -24,7 +24,7 @@ namespace GridKit template class BusBase; - namespace Converter + namespace Controller { /// Internal variables and residual rows of a `Reecb`. enum class ReecbInternalVariables : size_t @@ -260,6 +260,6 @@ namespace GridKit std::vector ws_; std::vector ws_indices_; }; - } // namespace Converter + } // namespace Controller } // namespace PhasorDynamics } // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbData.hpp similarity index 99% rename from GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp rename to GridKit/Model/PhasorDynamics/Controller/REECB/ReecbData.hpp index 1487fc154..1b97a41c4 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbData.hpp +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbData.hpp @@ -12,7 +12,7 @@ namespace GridKit { namespace PhasorDynamics { - namespace Converter + namespace Controller { /// Parameters for REECB. enum class ReecbParameters @@ -109,6 +109,6 @@ namespace GridKit using SignalOutputs = ReecbSignalOutputs; using MonitorableVariables = ReecbMonitorableVariables; }; - } // namespace Converter + } // namespace Controller } // namespace PhasorDynamics } // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbDependencyTracking.cpp b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbDependencyTracking.cpp similarity index 94% rename from GridKit/Model/PhasorDynamics/Converter/REECB/ReecbDependencyTracking.cpp rename to GridKit/Model/PhasorDynamics/Controller/REECB/ReecbDependencyTracking.cpp index 1a24a6a93..1b2f97f32 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbDependencyTracking.cpp +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbDependencyTracking.cpp @@ -10,7 +10,7 @@ namespace GridKit { namespace PhasorDynamics { - namespace Converter + namespace Controller { /** * @brief Report that DependencyTracking exposes structure through the @@ -26,6 +26,6 @@ namespace GridKit template class Reecb; template class Reecb; - } // namespace Converter + } // namespace Controller } // namespace PhasorDynamics } // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbEnzyme.cpp b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbEnzyme.cpp similarity index 96% rename from GridKit/Model/PhasorDynamics/Converter/REECB/ReecbEnzyme.cpp rename to GridKit/Model/PhasorDynamics/Controller/REECB/ReecbEnzyme.cpp index 6df458e96..c71231ee8 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbEnzyme.cpp +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbEnzyme.cpp @@ -12,7 +12,7 @@ namespace GridKit { namespace PhasorDynamics { - namespace Converter + namespace Controller { /** * @brief Assemble the sparse REECB Jacobian with Enzyme. @@ -45,7 +45,7 @@ namespace GridKit J_vals_buffer_ = new RealT[buffer_size]; } - using ModelT = GridKit::PhasorDynamics::Converter::Reecb; + using ModelT = GridKit::PhasorDynamics::Controller::Reecb; using Fn = GridKit::Enzyme::Sparse::MemberFunctions; nnz_ = 0; @@ -117,6 +117,6 @@ namespace GridKit template class Reecb; template class Reecb; - } // namespace Converter + } // namespace Controller } // namespace PhasorDynamics } // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp similarity index 99% rename from GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp rename to GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp index 96248f7e9..5a970b5c7 100644 --- a/GridKit/Model/PhasorDynamics/Converter/REECB/ReecbImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp @@ -13,8 +13,8 @@ #include #include -#include -#include +#include +#include #include #include #include @@ -23,7 +23,7 @@ namespace GridKit { namespace PhasorDynamics { - namespace Converter + namespace Controller { /// Logger used for REECB diagnostics. using Log = ::GridKit::Utilities::Logger; @@ -1094,6 +1094,6 @@ namespace GridKit { return bus_->Vi(); } - } // namespace Converter + } // namespace Controller } // namespace PhasorDynamics } // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Converter/CMakeLists.txt b/GridKit/Model/PhasorDynamics/Converter/CMakeLists.txt index 5a7dffab8..cafb2cc36 100644 --- a/GridKit/Model/PhasorDynamics/Converter/CMakeLists.txt +++ b/GridKit/Model/PhasorDynamics/Converter/CMakeLists.txt @@ -4,4 +4,3 @@ # ]] add_subdirectory(REGCA) -add_subdirectory(REECB) diff --git a/GridKit/Model/PhasorDynamics/Converter/README.md b/GridKit/Model/PhasorDynamics/Converter/README.md index 66a88f463..aa937a8b2 100644 --- a/GridKit/Model/PhasorDynamics/Converter/README.md +++ b/GridKit/Model/PhasorDynamics/Converter/README.md @@ -2,9 +2,9 @@ ## Introduction -Converter models represent inverter-coupled resources in the phasor dynamics model. Generator/converter models provide the network interface between -renewable-energy control models and the bus equations, while electrical-control models produce the commanded active and reactive current components -that drive them. +Converter models represent inverter-coupled resources in the phasor dynamics +model and provide the network interface between renewable-energy controller +models and the bus equations. ## Types @@ -13,4 +13,3 @@ The GridKit converter documentation includes: - Renewable Energy Generator/Converter Model REGCA (See [REGCA](REGCA/README.md)) - Renewable Energy Generator/Converter Model REGCB (See [REGCB](REGCB/README.md)) - Renewable Energy Electrical Control Model REECA (See [REECA](REECA/README.md)) -- Renewable Energy Electrical Control Model REECB (See [REECB](REECB/README.md)) diff --git a/GridKit/Model/PhasorDynamics/SystemModelData.hpp b/GridKit/Model/PhasorDynamics/SystemModelData.hpp index 7878c5006..c83d60189 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelData.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelData.hpp @@ -10,8 +10,8 @@ #include #include #include +#include #include -#include #include #include #include @@ -47,7 +47,7 @@ namespace GridKit using BusToSignalAdapterDataT = BusToSignalAdapterData; using BusFaultDataT = BusFaultData; using RegcaDataT = Converter::RegcaData; - using ReecbDataT = Converter::ReecbData; + using ReecbDataT = Controller::ReecbData; using RepcaDataT = Controller::RepcaData; using Tgov1DataT = Governor::Tgov1Data; using Esdc1aDataT = Exciter::Esdc1aData; diff --git a/docs/GridKit/Model/PhasorDynamics/Controller/README.md b/docs/GridKit/Model/PhasorDynamics/Controller/README.md index c7b0dd611..3962c8eb4 100644 --- a/docs/GridKit/Model/PhasorDynamics/Controller/README.md +++ b/docs/GridKit/Model/PhasorDynamics/Controller/README.md @@ -5,6 +5,7 @@ :titlesonly: :hidden: +REECB REPCA ``` diff --git a/docs/GridKit/Model/PhasorDynamics/Controller/REECB/README.md b/docs/GridKit/Model/PhasorDynamics/Controller/REECB/README.md new file mode 100644 index 000000000..6926035a0 --- /dev/null +++ b/docs/GridKit/Model/PhasorDynamics/Controller/REECB/README.md @@ -0,0 +1,6 @@ +# REECB + +```{include} ../../../../../../GridKit/Model/PhasorDynamics/Controller/REECB/README.md +:start-line: 1 +:relative-images: +``` diff --git a/docs/GridKit/Model/PhasorDynamics/Converter/README.md b/docs/GridKit/Model/PhasorDynamics/Converter/README.md index e868967e5..fa56e8dd8 100644 --- a/docs/GridKit/Model/PhasorDynamics/Converter/README.md +++ b/docs/GridKit/Model/PhasorDynamics/Converter/README.md @@ -8,7 +8,6 @@ REGCA REGCB REECA -REECB ``` ```{include} ../../../../../GridKit/Model/PhasorDynamics/Converter/README.md diff --git a/docs/GridKit/Model/PhasorDynamics/Converter/REECB/README.md b/docs/GridKit/Model/PhasorDynamics/Converter/REECB/README.md deleted file mode 100644 index ff972cda5..000000000 --- a/docs/GridKit/Model/PhasorDynamics/Converter/REECB/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# REECB - -```{include} ../../../../../../GridKit/Model/PhasorDynamics/Converter/REECB/README.md -:start-line: 1 -:relative-images: -``` diff --git a/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp b/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp index 85ee9f151..dc1d07129 100644 --- a/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp +++ b/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp @@ -4,8 +4,8 @@ #include #include #include -#include -#include +#include +#include #include #include #include @@ -726,6 +726,7 @@ namespace GridKit /// feedback loop. TestOutcome regcaReecbRecovery() { + using namespace GridKit::PhasorDynamics::Controller; using namespace GridKit::PhasorDynamics::Converter; using ReecbVar = ReecbInternalVariables; using RegcaVar = RegcaInternalVariables; diff --git a/tests/UnitTests/PhasorDynamics/CMakeLists.txt b/tests/UnitTests/PhasorDynamics/CMakeLists.txt index 266c14865..af3ec6f23 100644 --- a/tests/UnitTests/PhasorDynamics/CMakeLists.txt +++ b/tests/UnitTests/PhasorDynamics/CMakeLists.txt @@ -132,12 +132,12 @@ target_link_libraries( GridKit::phasor_dynamics_bus_dependency_tracking GridKit::testing) -add_executable(test_phasor_converter_reecb runConverterReecbTests.cpp) +add_executable(test_phasor_controller_reecb runControllerReecbTests.cpp) target_link_libraries( - test_phasor_converter_reecb + test_phasor_controller_reecb GridKit::definitions - GridKit::phasor_dynamics_converter_reecb - GridKit::phasor_dynamics_converter_reecb_dependency_tracking + GridKit::phasor_dynamics_controller_reecb + GridKit::phasor_dynamics_controller_reecb_dependency_tracking GridKit::phasor_dynamics_bus GridKit::phasor_dynamics_bus_dependency_tracking GridKit::testing) @@ -207,7 +207,7 @@ add_test(NAME PhasorDynamicsExciterEsdc1aTest COMMAND test_phasor_exciter_esdc1a add_test(NAME PhasorDynamicsGensalTest COMMAND test_phasor_gensal) add_test(NAME PhasorDynamicsExciterSexsPtiTest COMMAND test_phasor_exciter_sexspti) add_test(NAME PhasorDynamicsConverterRegcaTest COMMAND test_phasor_converter_regca) -add_test(NAME PhasorDynamicsConverterReecbTest COMMAND test_phasor_converter_reecb) +add_test(NAME PhasorDynamicsControllerReecbTest COMMAND test_phasor_controller_reecb) add_test(NAME PhasorDynamicsControllerRepcaTest COMMAND test_phasor_controller_repca) add_test(NAME PhasorDynamicsStabilizerIeeestTest COMMAND test_phasor_stabilizer_ieeest) add_test(NAME PhasorDynamicsGenClassicalTest COMMAND test_phasor_gen_classical) @@ -235,7 +235,7 @@ install( test_phasor_gensal test_phasor_exciter_sexspti test_phasor_converter_regca - test_phasor_converter_reecb + test_phasor_controller_reecb test_phasor_controller_repca test_phasor_stabilizer_ieeest test_phasor_gen_classical diff --git a/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp b/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp index b180abefb..4b97c33de 100644 --- a/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp @@ -4,10 +4,10 @@ #include #include +#include +#include #include #include -#include -#include #include #include #include @@ -240,9 +240,9 @@ namespace GridKit using ConverterExternal = PhasorDynamics::Converter::RegcaExternalVariables; using ConverterInternal = PhasorDynamics::Converter::RegcaInternalVariables; using ConverterParams = PhasorDynamics::Converter::RegcaParameters; - using ControllerExternal = PhasorDynamics::Converter::ReecbExternalVariables; - using ControllerInternal = PhasorDynamics::Converter::ReecbInternalVariables; - using ControllerParams = PhasorDynamics::Converter::ReecbParameters; + using ControllerExternal = PhasorDynamics::Controller::ReecbExternalVariables; + using ControllerInternal = PhasorDynamics::Controller::ReecbInternalVariables; + using ControllerParams = PhasorDynamics::Controller::ReecbParameters; TestStatus success = true; @@ -276,7 +276,7 @@ namespace GridKit PhasorDynamics::Converter::Regca converter(&bus, converter_data); - PhasorDynamics::Converter::ReecbData controller_data; + PhasorDynamics::Controller::ReecbData controller_data; controller_data.parameters[ControllerParams::mva] = static_cast(100.0); controller_data.parameters[ControllerParams::Tp] = static_cast(0.02); controller_data.parameters[ControllerParams::QFlag] = true; @@ -286,7 +286,7 @@ namespace GridKit controller_data.parameters[ControllerParams::Vmin] = static_cast(0.5); controller_data.parameters[ControllerParams::Vmax] = static_cast(1.5); - PhasorDynamics::Converter::Reecb controller(&bus, controller_data); + PhasorDynamics::Controller::Reecb controller(&bus, controller_data); controller.getSignals().template assignSignalNode(&ipcmd); controller.getSignals().template assignSignalNode(&iqcmd); diff --git a/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp b/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp similarity index 98% rename from tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp rename to tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp index 9eb954d38..f5881645d 100644 --- a/tests/UnitTests/PhasorDynamics/ConverterReecbTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp @@ -14,8 +14,8 @@ #include #include #include -#include -#include +#include +#include #include #include #include @@ -30,15 +30,15 @@ namespace GridKit using Log = ::GridKit::Utilities::Logger; template - class ConverterReecbTests + class ControllerReecbTests { public: using ScalarT = scalar_type; using IdxT = index_type; using RealT = typename PhasorDynamics::Component::RealT; - ConverterReecbTests() = default; - ~ConverterReecbTests() = default; + ControllerReecbTests() = default; + ~ControllerReecbTests() = default; // The tolerance only absorbs floating-point roundoff. static constexpr RealT kTol = std::numeric_limits::epsilon(); @@ -58,7 +58,7 @@ namespace GridKit PhasorDynamics::Bus bus(1.0, 0.0); - PhasorDynamics::Converter::Reecb empty(&bus); + PhasorDynamics::Controller::Reecb empty(&bus); success *= (empty.size() == static_cast(index(Vars::MAXIMUM))); success *= (empty.getMonitor() == nullptr); @@ -188,7 +188,7 @@ namespace GridKit } } - PhasorDynamics::Converter::Reecb busless(nullptr, makeData()); + PhasorDynamics::Controller::Reecb busless(nullptr, makeData()); busless.setSystemBase(kNominalFrequency, kSystemBaseVa); success *= (busless.verify() > 0); @@ -1192,12 +1192,12 @@ namespace GridKit #endif private: - using Params = PhasorDynamics::Converter::ReecbParameters; - using Vars = PhasorDynamics::Converter::ReecbInternalVariables; - using Ext = PhasorDynamics::Converter::ReecbExternalVariables; - using Mon = PhasorDynamics::Converter::ReecbMonitorableVariables; - using Data = PhasorDynamics::Converter::ReecbData; - using ReecbT = PhasorDynamics::Converter::Reecb; + using Params = PhasorDynamics::Controller::ReecbParameters; + using Vars = PhasorDynamics::Controller::ReecbInternalVariables; + using Ext = PhasorDynamics::Controller::ReecbExternalVariables; + using Mon = PhasorDynamics::Controller::ReecbMonitorableVariables; + using Data = PhasorDynamics::Controller::ReecbData; + using ReecbT = PhasorDynamics::Controller::Reecb; using JacobianRow = DependencyTracking::Variable::DependencyMap; static constexpr size_t index(Vars variable) @@ -1366,8 +1366,8 @@ namespace GridKit return input_indices_[index(port)]; } - PhasorDynamics::Bus bus; - PhasorDynamics::Converter::Reecb reecb; + PhasorDynamics::Bus bus; + PhasorDynamics::Controller::Reecb reecb; }; static constexpr RealT kSystemBaseVa = static_cast(100.0e6); @@ -1554,7 +1554,7 @@ namespace GridKit /// circle. Every smooth-transition argument keeps a saturation margin, /// so each row carries its ideal value. template - void setAnswerKeyState(PhasorDynamics::Converter::Reecb& reecb) const + void setAnswerKeyState(PhasorDynamics::Controller::Reecb& reecb) const { setState(reecb, {{Vars::VMEAS, 0.80}, @@ -1579,7 +1579,7 @@ namespace GridKit /// A neutral driven state for the control probes: unit voltage, cleared /// controller states, and a rested derivative. template - void setControlState(PhasorDynamics::Converter::Reecb& reecb) const + void setControlState(PhasorDynamics::Controller::Reecb& reecb) const { reecb.yp().setToConst(static_cast(ZERO)); setState(reecb, @@ -1794,7 +1794,7 @@ namespace GridKit } template - void setState(PhasorDynamics::Converter::Reecb& reecb, Rows rows) const + void setState(PhasorDynamics::Controller::Reecb& reecb, Rows rows) const { auto* y = reecb.y().getData(); for (const auto& [variable, value] : rows) @@ -1805,7 +1805,7 @@ namespace GridKit } template - void setDerivative(PhasorDynamics::Converter::Reecb& reecb, Rows rows) const + void setDerivative(PhasorDynamics::Controller::Reecb& reecb, Rows rows) const { auto* yp = reecb.yp().getData(); for (const auto& [variable, value] : rows) diff --git a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp index 4b899f1bf..7522cbfc5 100644 --- a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp +++ b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp @@ -303,11 +303,11 @@ namespace GridKit /// must be published before the system initializes. TestOutcome reecb() { - using Data = PhasorDynamics::Converter::ReecbData; + using Data = PhasorDynamics::Controller::ReecbData; using Buses = typename Data::Buses; using Outputs = typename Data::SignalOutputs; using Params = typename Data::Parameters; - using Vars = PhasorDynamics::Converter::ReecbInternalVariables; + using Vars = PhasorDynamics::Controller::ReecbInternalVariables; constexpr IdxT bus_id = static_cast(1); constexpr IdxT iqcmd_id = static_cast(1); diff --git a/tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp b/tests/UnitTests/PhasorDynamics/runControllerReecbTests.cpp similarity index 74% rename from tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp rename to tests/UnitTests/PhasorDynamics/runControllerReecbTests.cpp index 624395065..5aa0ba589 100644 --- a/tests/UnitTests/PhasorDynamics/runConverterReecbTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runControllerReecbTests.cpp @@ -1,9 +1,9 @@ -#include "ConverterReecbTests.hpp" +#include "ControllerReecbTests.hpp" int main() { - GridKit::Testing::TestingResults result; - GridKit::Testing::ConverterReecbTests test; + GridKit::Testing::TestingResults result; + GridKit::Testing::ControllerReecbTests test; result += test.validation(); result += test.initializationAndSignals(); diff --git a/tests/UnitTests/Utilities/CaseFormatTests.hpp b/tests/UnitTests/Utilities/CaseFormatTests.hpp index e8d34d80f..c0fef2641 100644 --- a/tests/UnitTests/Utilities/CaseFormatTests.hpp +++ b/tests/UnitTests/Utilities/CaseFormatTests.hpp @@ -7,8 +7,8 @@ #include #include #include +#include #include -#include #include #include #include @@ -40,7 +40,7 @@ namespace GridKit using BusData = BusData; using BusType = typename BusData::BusType; using RegcaData = Converter::RegcaData; - using ReecbData = Converter::ReecbData; + using ReecbData = Controller::ReecbData; const char data[] = R"({ @@ -540,7 +540,7 @@ namespace GridKit success *= result.sexspti[0].signal_outputs[Exciter::SexsPtiSignalOutputs::efd] == 3; success *= result.sexspti[0].disambiguation_string == "DV4"; - using ReecbData = Converter::ReecbData; + using ReecbData = Controller::ReecbData; using ReecbParams = ReecbData::Parameters; success *= std::get(result.reecb[0].parameters[ReecbParams::mva]) == 100.0; success *= !std::get(result.reecb[0].parameters[ReecbParams::PfFlag]); From 55d05815a7f1b352b3987d90f946b280523089ee Mon Sep 17 00:00:00 2001 From: lukelowry Date: Wed, 5 Aug 2026 09:19:50 -0500 Subject: [PATCH 10/16] minor syntax and polish --- .../PhasorDynamics/Controller/REECB/README.md | 28 +++++-- .../Controller/REECB/ReecbData.hpp | 2 +- .../Controller/REECB/ReecbImpl.hpp | 23 +++--- .../Model/PhasorDynamics/SystemModelImpl.hpp | 17 ++-- .../PhasorDynamics/PDIntegrationTests.hpp | 81 ++++++++++++++----- .../PhasorDynamics/ControllerReecbTests.hpp | 34 ++++++++ .../SystemSingleComponentTests.hpp | 72 +++++++++++++---- .../runSystemSingleComponentTests.cpp | 1 + 8 files changed, 197 insertions(+), 61 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Controller/REECB/README.md b/GridKit/Model/PhasorDynamics/Controller/REECB/README.md index c66cf3461..01c9fb833 100644 --- a/GridKit/Model/PhasorDynamics/Controller/REECB/README.md +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/README.md @@ -14,6 +14,15 @@ inverter-coupled resource. reference instead of a system-base reactive power. - REECB contributes no bus current injection. +> [!WARNING] +> GridKit does not yet inherit `mva` from the associated REGCA model. Set it +> explicitly to the REGCA component base; omitting it falls back to the system +> base and is correct only when those bases match.[^reecb-mva-base] + +> [!WARNING] +> GridKit does not yet apply the associated generator's baseload response +> setting to REECB. The model always uses its configured `Pmin` and `Pmax`. + ## Block Diagram ![REECB electrical-control block diagram](../../../../../docs/Figures/PhasorDynamics/REECB/diagram.png) @@ -29,7 +38,7 @@ $S^\mathrm{base}$ | [MVA] | `mva` | REECB component pow $s_\mathrm{pf}$ | [boolean] | `PfFlag` | Power-factor control selector | `false` | `true` = power-factor control, `false` = reactive-power control $s_V$ | [boolean] | `VFlag` | Voltage-reference selector under $s_Q=1$ | `false` | `true` = cascaded Q-PI voltage command, `false` = direct external voltage reference $s_Q$ | [boolean] | `QFlag` | Reactive-path selector | `false` | `true` = Volt/VAr PI control, `false` = reactive-current lag -$s_{PQ}$ | [boolean] | `Pqflag` | Converter current-priority selector | `false` | `true` = P priority, `false` = Q priority +$s_\mathrm{pq}$ | [boolean] | `Pqflag` | Converter current-priority selector | `false` | `true` = P priority, `false` = Q priority $T_\mathrm{rv}$ | [sec] | `Trv` | Voltage-measurement filter time constant | 0.02 | State 1 in Fig. 1 $T_\mathrm{p}$ | [sec] | `Tp` | Electrical-power measurement filter time constant | 0.0 | State 2 in Fig. 1 $V^\mathrm{ref}$ | [p.u.] | `Vref0` | Reactive-current-injection voltage reference | $V_T$ | Initialized from terminal voltage when omitted @@ -96,7 +105,7 @@ raised to that floor in place, so every equation below uses the raised value: s_Q^\mathrm{PI} &= s_Q s_V \\ s_V^\mathrm{ref} &= s_Q(1-s_V) \\ s_Q^\mathrm{ref} &= 1 - s_V^\mathrm{ref} \\ - s_{PQ}^\mathrm{off} &= 1 - s_{PQ} \\ + s_\mathrm{pq}^\mathrm{off} &= 1 - s_\mathrm{pq} \\ k_\mathrm{base} &= \dfrac{S^\mathrm{sys}}{S^\mathrm{base}}. \end{aligned} ``` @@ -179,8 +188,8 @@ For readability, define: e_V^\mathrm{PI} &= s_Q^\mathrm{PI}V_Q^\mathrm{PI}+s_V^\mathrm{ref}Q^\mathrm{ext}-s_QV^\mathrm{meas} \\ f_P^\mathrm{ord} &= \dfrac{1}{T_\mathrm{pord}}(k_\mathrm{base}P^\mathrm{ref}-P^\mathrm{ord}) \\ r_P^\mathrm{ord} &= \text{clamp}(f_P^\mathrm{ord};\,R_P^{\min},R_P^{\max}) \\ - I_q^{\max} &= s_{PQ}|I_L^{\max}|+s_{PQ}^\mathrm{off}I^{\max} \\ - I_p^{\max} &= s_{PQ}I^{\max}+s_{PQ}^\mathrm{off}|I_L^{\max}| \\ + I_q^{\max} &= s_\mathrm{pq}|I_L^{\max}|+s_\mathrm{pq}^\mathrm{off}I^{\max} \\ + I_p^{\max} &= s_\mathrm{pq}I^{\max}+s_\mathrm{pq}^\mathrm{off}|I_L^{\max}| \\ I_q^\mathrm{base} &= \text{clamp}(K_\mathrm{vp}e_V^\mathrm{PI}+x_V^\mathrm{PI};\,-I_q^{\max},I_q^{\max}) \\ I_q^\mathrm{raw} &= s_QI_q^\mathrm{base}+s_Q^\mathrm{off}Q_V+I_q^\mathrm{inj}. \end{aligned} @@ -208,7 +217,7 @@ these equations. ```math \begin{aligned} 0 &= -V_T^2+V_\mathrm{r}^2+V_\mathrm{i}^2 \\ - 0 &= -I_L^{\max}|I_L^{\max}|+(I^{\max})^2-s_{PQ}(k_\mathrm{base}I_p^\mathrm{cmd})^2-s_{PQ}^\mathrm{off}(k_\mathrm{base}I_q^\mathrm{cmd})^2 \\ + 0 &= -I_L^{\max}|I_L^{\max}|+(I^{\max})^2-s_\mathrm{pq}(k_\mathrm{base}I_p^\mathrm{cmd})^2-s_\mathrm{pq}^\mathrm{off}(k_\mathrm{base}I_q^\mathrm{cmd})^2 \\ 0 &= -k_\mathrm{base}I_q^\mathrm{cmd}+\text{clamp}(I_q^\mathrm{raw};\,-I_q^{\max},I_q^{\max}) \\ 0 &= -k_\mathrm{base}I_p^\mathrm{cmd}+\text{clamp}\left(\dfrac{P^\mathrm{ord}}{V_\mathrm{safe}^\mathrm{meas}};\,0,I_p^{\max}\right). \end{aligned} @@ -257,9 +266,9 @@ clamp for $\ell, ipmax0); - const RealT iqraw0 = unclamp(iqcmd0, -iqmax0, iqmax0); - const RealT iqctl0 = iqraw0 - iqv0; - const RealT pord_raw = vmeas_safe0 * ipraw0; + const RealT ipraw0 = unclamp(ipcmd0, ZERO, ipmax0); + const RealT iqraw0 = unclamp(iqcmd0, -iqmax0, iqmax0); + const RealT iqctl0 = iqraw0 - iqv0; + const RealT pord0 = vmeas_safe0 * ipraw0; - if (pord_raw < Pmin_ || pord_raw > Pmax_) + // Invert the smooth rate limiter at zero so asymmetric ramp limits + // still initialize the active-power order at rest. + const RealT fpord0 = unclamp(ZERO, dPmin_, dPmax_); + const RealT pref0_system = toSystemBase(pord0 + Tpord_ * fpord0); + + if (pord0 < Pmin_ || pord0 > Pmax_) { Log::error() << "Reecb: recovered active-power order is outside Pmin/Pmax\n"; return 1; } - // Round-tripping the published reference reproduces the residual's - // component-base reference, holding the order rate at exactly zero. - const RealT pref0_system = toSystemBase(pord_raw); - const RealT pord0 = toComponentBase(pref0_system); - // An integrating path holds its feedback only where the clamp can // reproduce it: strictly inside the limits, or collapsed onto it. auto reproducible = [](RealT value, RealT lower, RealT upper) @@ -481,7 +481,8 @@ namespace GridKit if (!std::isfinite(verr0) || !std::isfinite(iqv0) || !std::isfinite(ilmax0) || !std::isfinite(iqmax0) || !std::isfinite(ipmax0) || !std::isfinite(ipraw0) - || !std::isfinite(iqraw0) || !std::isfinite(pord0) || !std::isfinite(pref0_system) + || !std::isfinite(iqraw0) || !std::isfinite(pord0) || !std::isfinite(fpord0) + || !std::isfinite(pref0_system) || !std::isfinite(qref0) || !std::isfinite(qext0_port) || !std::isfinite(pfaref0) || !std::isfinite(eq0) || !std::isfinite(xpiq0) || !std::isfinite(epiv0) || !std::isfinite(xpiv0) || !std::isfinite(qv0)) diff --git a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp index c60326b1a..34267651a 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp @@ -831,21 +831,28 @@ namespace GridKit throw std::runtime_error("SystemModel allocation failed"); } - // Start variable monitors - initializeMonitor(); - startMonitor(); - // Perform an initial Jacobian evaluation for sparse Jacobians, such that // the dynamic solver can querry the NNZ value when it is configured. // @todo Replace with a sparsity analysis that sets the NNZ and allocates the Jacobian // without needing the Jacobian values. if (hasJacobian()) { - initialize(); + const int status = initialize(); + if (status != 0) + { + Log::error() << "System model initialization failed with status " + << status << '\n'; + throw std::runtime_error("SystemModel allocation failed"); + } evaluateResidual(); evaluateJacobian(); } + // Start variable monitors only after allocation and sparse initialization + // complete successfully. + initializeMonitor(); + startMonitor(); + allocated_ = true; return 0; } diff --git a/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp b/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp index dc1d07129..74125b0f8 100644 --- a/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp +++ b/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -721,9 +722,8 @@ namespace GridKit return success.report(__func__); } - /// Displaced REECB measurement and REGCA current-lag states integrate - /// back to the initialized equilibrium of the closed command and power - /// feedback loop. + /// A finite active-power-reference pulse moves the coupled REECB and + /// REGCA states, after which the closed loop returns to equilibrium. TestOutcome regcaReecbRecovery() { using namespace GridKit::PhasorDynamics::Controller; @@ -745,7 +745,8 @@ namespace GridKit data.signal = {{"Active Current Command", kIpcmdSignalId}, {"Reactive Current Command", kIqcmdSignalId}, {"Branch Active Power", kPbranchSignalId}, - {"Branch Reactive Power", kQbranchSignalId}}; + {"Branch Reactive Power", kQbranchSignalId}, + {"Active Power Reference", kPrefSignalId}}; auto& converter = data.regca.emplace_back(); converter.buses[RegcaBuses::bus] = kReecbBusId; @@ -773,6 +774,7 @@ namespace GridKit controller.buses[ReecbBuses::bus] = kReecbBusId; controller.signal_inputs[ReecbSignalInputs::pe] = kPbranchSignalId; controller.signal_inputs[ReecbSignalInputs::qgen] = kQbranchSignalId; + controller.signal_inputs[ReecbSignalInputs::pref] = kPrefSignalId; controller.signal_outputs[ReecbSignalOutputs::ipcmd] = kIpcmdSignalId; controller.signal_outputs[ReecbSignalOutputs::iqcmd] = kIqcmdSignalId; controller.parameters[ReecbParameters::mva] = static_cast(100.0); @@ -782,9 +784,12 @@ namespace GridKit controller.parameters[ReecbParameters::QFlag] = true; controller.parameters[ReecbParameters::VFlag] = true; + auto& reference = data.constant_source.emplace_back(); + reference.parameters[ConstantSignalSourceParameters::Sr] = ZERO; + reference.signal_outputs[ConstantSignalSourceSignalOutputs::sr] = kPrefSignalId; + SystemModel system(data); success *= system.allocate() == 0; - success *= system.initialize() == 0; auto* regca = dynamic_cast*>(system.getComponent(kConverterComponentId)); @@ -796,26 +801,58 @@ namespace GridKit return success.report(__func__); } + AnalysisManager::Sundials::Ida ida(&system); + success *= ida.configureSimulation() == 0; + success *= ida.initializeSimulation(ZERO) == 0; + + const auto pord_index = static_cast( + reecb->getVariableIndex(static_cast(ReecbVar::PORD))); + const auto ipcmd_index = static_cast( + reecb->getVariableIndex(static_cast(ReecbVar::IPCMD))); + const auto regca_ip_index = static_cast( + regca->getVariableIndex(static_cast(RegcaVar::IP))); + const auto* equilibrium_values = system.y().getData(); const std::vector equilibrium( equilibrium_values, equilibrium_values + static_cast(system.y().getSize())); - // The displacements stay strictly inside every limiter, deadband, and - // voltage band, so the return path is a smooth interior trajectory. - auto* y = system.y().getData(); - y[reecb->getVariableIndex(static_cast(ReecbVar::VMEAS))] += kRecoveryDelta; - y[reecb->getVariableIndex(static_cast(ReecbVar::PMEAS))] -= kRecoveryDelta; - y[regca->getVariableIndex(static_cast(RegcaVar::IQ))] += kRecoveryDelta; - y[regca->getVariableIndex(static_cast(RegcaVar::IP))] -= kRecoveryDelta; - system.y().setDataUpdated(); + auto* pref_signal = system.getSignal(kPrefSignalId); + const RealT pref0 = static_cast(pref_signal->read()); - AnalysisManager::Sundials::Ida ida(&system); - success *= ida.configureSimulation() == 0; + pref_signal->init(pref0 + kReferencePulse); success *= ida.initializeSimulation(ZERO) == 0; - // The step callback keeps the model state current so the final point - // can be compared against the stored equilibrium. - success *= ida.runSimulation(kRecoveryHorizon, kRecoveryMonitorStep, [](RealT) {}) == 0; + success *= ida.runSimulation(kPulseEnd, kRecoveryMonitorStep) == 0; + + const auto* pulse_values = system.y().getData(); + const RealT pord_response = pulse_values[pord_index] - equilibrium[pord_index]; + const RealT ipcmd_response = pulse_values[ipcmd_index] - equilibrium[ipcmd_index]; + const RealT regca_ip_response = pulse_values[regca_ip_index] - equilibrium[regca_ip_index]; + + if (pord_response <= kResponseTolerance) + { + std::cout << "REECB PORD responded by only " << pord_response + << " during the reference pulse\n"; + success = false; + } + if (ipcmd_response <= kResponseTolerance) + { + std::cout << "REECB IPCMD responded by only " << ipcmd_response + << " during the reference pulse\n"; + success = false; + } + if (regca_ip_response <= kResponseTolerance) + { + std::cout << "REGCA IP responded by only " << regca_ip_response + << " during the reference pulse\n"; + success = false; + } + + pref_signal->init(pref0); + success *= ida.initializeSimulation(kPulseEnd) == 0; + success *= ida.runSimulation(kPulseEnd + kRecoveryHorizon, + kRecoveryMonitorStep) + == 0; const auto* final_values = system.y().getData(); for (size_t entry = 0; entry < equilibrium.size(); ++entry) @@ -838,13 +875,13 @@ namespace GridKit static constexpr IdxT kIqcmdSignalId = static_cast(202); static constexpr IdxT kPbranchSignalId = static_cast(203); static constexpr IdxT kQbranchSignalId = static_cast(204); + static constexpr IdxT kPrefSignalId = static_cast(205); static constexpr IdxT kConverterComponentId = static_cast(0); static constexpr IdxT kControllerComponentId = static_cast(1); - // The slowest closed-loop mode pairs the reactive and voltage - // integrators near three seconds, so the horizon settles well inside - // the tolerance. - static constexpr RealT kRecoveryDelta = static_cast(2.0e-3); + static constexpr RealT kReferencePulse = static_cast(0.05); + static constexpr RealT kPulseEnd = static_cast(0.1); + static constexpr RealT kResponseTolerance = static_cast(0.01); static constexpr RealT kRecoveryHorizon = static_cast(25.0); static constexpr RealT kRecoveryMonitorStep = static_cast(1.0 / 60.0); static constexpr RealT kRecoveryTolerance = static_cast(1.0e-6); diff --git a/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp b/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp index f5881645d..5898ca752 100644 --- a/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp @@ -482,6 +482,40 @@ namespace GridKit success *= stateMatches(at_limit.reecb, {{Vars::PORD, 1.5}}, "order at Pmax"); success *= allResidualsAtRest(at_limit.reecb); + struct AsymmetricRampCase + { + RealT minimum; + RealT maximum; + const char* label; + }; + + const std::array asymmetric_ramps{{ + {-0.001, 0.1, "narrow negative ramp limit"}, + {-0.1, 0.001, "narrow positive ramp limit"}, + }}; + + for (const auto& test_case : asymmetric_ramps) + { + auto asymmetric_data = makeData(); + asymmetric_data.parameters[Params::dPmin] = test_case.minimum; + asymmetric_data.parameters[Params::dPmax] = test_case.maximum; + + Fixture asymmetric(asymmetric_data); + asymmetric.attachAllInputs(); + success *= asymmetric.initialize(0.0, 0.75); + success *= (asymmetric.evaluate() == 0); + success *= stateMatches(asymmetric.reecb, + {{Vars::PORD, 1.5}}, + test_case.label); + success *= allResidualsAtRest(asymmetric.reecb, kTolSmooth); + if (isEqual(asymmetric.input(Ext::PREF), 0.75, kTolSmooth)) + { + std::cout << test_case.label + << " did not offset the published active-power reference\n"; + success = false; + } + } + // The reactive command shares the inverse, at both signs. for (const RealT iqcmd : {static_cast(0.999999), static_cast(-0.999999)}) { diff --git a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp index 7522cbfc5..676466f7f 100644 --- a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp +++ b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp @@ -1,5 +1,6 @@ #include #include +#include #include #include @@ -297,17 +298,18 @@ namespace GridKit return success.report(__func__); } - /// REECB through the production path: model data to system construction - /// to required bus and current-command signal wiring. Initialization - /// reconstructs the operating point from the seeded commands, so both - /// must be published before the system initializes. + /// REECB through the production path, coupled to the REGCA model that + /// seeds its current-command outputs during system initialization. TestOutcome reecb() { - using Data = PhasorDynamics::Controller::ReecbData; - using Buses = typename Data::Buses; - using Outputs = typename Data::SignalOutputs; - using Params = typename Data::Parameters; - using Vars = PhasorDynamics::Controller::ReecbInternalVariables; + using Data = PhasorDynamics::Controller::ReecbData; + using Buses = typename Data::Buses; + using Outputs = typename Data::SignalOutputs; + using Params = typename Data::Parameters; + using Vars = PhasorDynamics::Controller::ReecbInternalVariables; + using RegcaInputs = PhasorDynamics::Converter::RegcaSignalInputs; + using RegcaParams = PhasorDynamics::Converter::RegcaParameters; + using RegcaVars = PhasorDynamics::Converter::RegcaInternalVariables; constexpr IdxT bus_id = static_cast(1); constexpr IdxT iqcmd_id = static_cast(1); @@ -329,6 +331,13 @@ namespace GridKit data.signal[1].signal_id = ipcmd_id; data.signal[1].name = "Active Current Command"; + auto regca_data = makeRegcaData(); + regca_data.parameters[RegcaParams::p0] = static_cast(0.25); + regca_data.parameters[RegcaParams::q0] = static_cast(0.05); + regca_data.signal_inputs[RegcaInputs::ipcmd] = ipcmd_id; + regca_data.signal_inputs[RegcaInputs::iqcmd] = iqcmd_id; + data.regca.push_back(regca_data); + Data reecb_data; reecb_data.device_class = "Reecb"; reecb_data.disambiguation_string = "reecb_system"; @@ -341,23 +350,26 @@ namespace GridKit PhasorDynamics::SystemModel system(data); success *= system.allocate() == 0; - system.getSignal(iqcmd_id)->init(static_cast(0.05)); - system.getSignal(ipcmd_id)->init(static_cast(0.25)); success *= system.initialize() == 0; success *= system.tagDifferentiable() == 0; success *= system.evaluateResidual() == 0; success *= system.evaluateJacobian() == 0; - success *= system.size() == static_cast(Vars::MAXIMUM); + success *= system.size() + == static_cast(RegcaVars::MAXIMUM) + + static_cast(Vars::MAXIMUM); auto* iqcmd = system.getSignal(iqcmd_id); auto* ipcmd = system.getSignal(ipcmd_id); success *= iqcmd->linked(); success *= ipcmd->linked(); - success *= iqcmd->getVariableIndex() == static_cast(Vars::IQCMD); - success *= ipcmd->getVariableIndex() == static_cast(Vars::IPCMD); + success *= iqcmd->getVariableIndex() + == system.getComponent(static_cast(1))->getVariableIndex(static_cast(Vars::IQCMD)); + success *= ipcmd->getVariableIndex() + == system.getComponent(static_cast(1))->getVariableIndex(static_cast(Vars::IPCMD)); auto missing_bus_data = data; missing_bus_data.bus[0].bus_id = static_cast(0); + missing_bus_data.regca.clear(); missing_bus_data.reecb[0].buses.clear(); PhasorDynamics::SystemModel missing_bus_system(missing_bus_data); @@ -367,6 +379,38 @@ namespace GridKit return success.report(__func__); } + /// System initialization reports a statically valid component whose + /// operating point cannot be initialized. + TestOutcome initializationFailure() + { + TestStatus success = true; + + PhasorDynamics::SystemModelData data; + data.bus.resize(1); + data.bus[0].bus_id = static_cast(1); + data.bus[0].bus_type = PhasorDynamics::BusData::BusType::SLACK; + data.bus[0].Vr0 = static_cast(0.8); + data.bus[0].Vi0 = ZERO; + data.regca.push_back(makeRegcaData()); + + PhasorDynamics::SystemModel system(data); + + std::cout << "Testing expected component initialization failure.\n"; + success *= system.verify() == 0; + if (system.hasJacobian()) + { + success *= throws([&]() + { system.allocate(); }); + } + else + { + success *= system.allocate() == 0; + success *= system.initialize() != 0; + } + + return success.report(__func__); + } + TestOutcome genrou() { TestStatus success = true; diff --git a/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp b/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp index 7fe1dcbe0..04a543f7d 100644 --- a/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp @@ -18,6 +18,7 @@ int main() result += test.regca(); result += test.repca(); result += test.reecb(); + result += test.initializationFailure(); result += test.genrou(); result += test.genClassical(); result += test.tgov1(); From 4ce3351088e9bed1508abe766b4babb3649c0768 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Wed, 5 Aug 2026 10:44:08 -0500 Subject: [PATCH 11/16] Extract one helper for asymmetric slew --- .../PhasorDynamics/Controller/REECB/README.md | 35 ++++++------ .../PhasorDynamics/Controller/REECB/Reecb.hpp | 22 ++++++++ .../Controller/REECB/ReecbImpl.hpp | 10 ++-- .../PhasorDynamics/ControllerReecbTests.hpp | 53 +++++++++++++++---- 4 files changed, 87 insertions(+), 33 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Controller/REECB/README.md b/GridKit/Model/PhasorDynamics/Controller/REECB/README.md index 01c9fb833..78d5f23b5 100644 --- a/GridKit/Model/PhasorDynamics/Controller/REECB/README.md +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/README.md @@ -187,7 +187,7 @@ For readability, define: V_Q^\mathrm{PI} &= \text{clamp}(K_\mathrm{qp}e_Q+x_Q^\mathrm{PI};\,V^{\min},V^{\max}) \\ e_V^\mathrm{PI} &= s_Q^\mathrm{PI}V_Q^\mathrm{PI}+s_V^\mathrm{ref}Q^\mathrm{ext}-s_QV^\mathrm{meas} \\ f_P^\mathrm{ord} &= \dfrac{1}{T_\mathrm{pord}}(k_\mathrm{base}P^\mathrm{ref}-P^\mathrm{ord}) \\ - r_P^\mathrm{ord} &= \text{clamp}(f_P^\mathrm{ord};\,R_P^{\min},R_P^{\max}) \\ + r_P^\mathrm{ord} &= \text{aslew}(f_P^\mathrm{ord};\,R_P^{\min},R_P^{\max}) \\ I_q^{\max} &= s_\mathrm{pq}|I_L^{\max}|+s_\mathrm{pq}^\mathrm{off}I^{\max} \\ I_p^{\max} &= s_\mathrm{pq}I^{\max}+s_\mathrm{pq}^\mathrm{off}|I_L^{\max}| \\ I_q^\mathrm{base} &= \text{clamp}(K_\mathrm{vp}e_V^\mathrm{PI}+x_V^\mathrm{PI};\,-I_q^{\max},I_q^{\max}) \\ @@ -197,7 +197,7 @@ For readability, define: CommonMath defines the [`antiwindup`](../../../../CommonMath.md#antiwindup) and [smooth limiter](../../../../CommonMath.md#derived-functions) functions used in -these equations. +these equations. [Appendix B](#appendix-b-aslew) defines `aslew`. ### Differential Equations @@ -280,7 +280,6 @@ reference required by the enabled steady-state control path. I_q^\mathrm{raw} &\leftarrow \text{unclamp}(I_q;\,-I_q^{\max},I_q^{\max}) \\ I_q^\mathrm{ctrl} &\leftarrow I_q^\mathrm{raw}-I_q^\mathrm{inj} \\ P^\mathrm{ord} &\leftarrow V_\mathrm{safe}^\mathrm{meas}\text{unclamp}(I_p;\,0,I_p^{\max}) \\ - f_P^\mathrm{ord} &\leftarrow \text{unclamp}(0;\,R_P^{\min},R_P^{\max}) \\ Q^\mathrm{target} &\leftarrow \begin{cases} V_\mathrm{safe}^\mathrm{meas}I_q^\mathrm{ctrl} & s_Q=0 \\ @@ -373,7 +372,7 @@ derivatives, latches, parameter storage, and attached signals unchanged. P^\mathrm{meas}\tan(\phi^\mathrm{ref})/k_\mathrm{base} & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=1 \\ Q^\mathrm{target}/k_\mathrm{base} & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=0 \end{cases} \\ - P^\mathrm{ref} &\leftarrow \dfrac{P^\mathrm{ord}+T_\mathrm{pord}f_P^\mathrm{ord}}{k_\mathrm{base}} + P^\mathrm{ref} &\leftarrow \dfrac{P^\mathrm{ord}}{k_\mathrm{base}} \end{aligned} ``` @@ -391,28 +390,30 @@ Output | Units | Description | Note ## Appendix A: `unclamp` -For $\ell #include #include #include @@ -157,6 +158,27 @@ namespace GridKit template ValueT toSystemBase(ValueT value) const; + /** + * @brief Smooth asymmetric slew-rate limiter. + * + * @param[in] f Unconstrained rate. + * @param[in] rate_min Negative rate limit. + * @param[in] rate_max Positive rate limit. + * @return Limited rate. + */ + static __attribute__((always_inline)) inline ScalarT aslew( + const ScalarT f, + const RealT rate_min, + const RealT rate_max) + { + assert(rate_min < ZERO && ZERO < rate_max); + + return f + / (ONE + + Math::ramp(f / rate_max - ONE) + + Math::ramp(f / rate_min - ONE)); + } + /** * @brief Smooth anti-windup derivative within a moving symmetric band. * diff --git a/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp index a7f7926a7..3c1c98353 100644 --- a/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp @@ -369,10 +369,7 @@ namespace GridKit const RealT iqctl0 = iqraw0 - iqv0; const RealT pord0 = vmeas_safe0 * ipraw0; - // Invert the smooth rate limiter at zero so asymmetric ramp limits - // still initialize the active-power order at rest. - const RealT fpord0 = unclamp(ZERO, dPmin_, dPmax_); - const RealT pref0_system = toSystemBase(pord0 + Tpord_ * fpord0); + const RealT pref0_system = toSystemBase(pord0); if (pord0 < Pmin_ || pord0 > Pmax_) { @@ -481,8 +478,7 @@ namespace GridKit if (!std::isfinite(verr0) || !std::isfinite(iqv0) || !std::isfinite(ilmax0) || !std::isfinite(iqmax0) || !std::isfinite(ipmax0) || !std::isfinite(ipraw0) - || !std::isfinite(iqraw0) || !std::isfinite(pord0) || !std::isfinite(fpord0) - || !std::isfinite(pref0_system) + || !std::isfinite(iqraw0) || !std::isfinite(pord0) || !std::isfinite(pref0_system) || !std::isfinite(qref0) || !std::isfinite(qext0_port) || !std::isfinite(pfaref0) || !std::isfinite(eq0) || !std::isfinite(xpiq0) || !std::isfinite(epiv0) || !std::isfinite(xpiv0) || !std::isfinite(qv0)) @@ -696,7 +692,7 @@ namespace GridKit const ScalarT vpiq = Math::clamp(Kqp_ * eq + xpiq, Vmin_, Vmax_); const ScalarT epiv = q_pi_on_ * vpiq + v_ref_on_ * extref - q_on_ * vmeas; const ScalarT fpord = (pref - pord) / Tpord_; - const ScalarT rpord = Math::clamp(fpord, dPmin_, dPmax_); + const ScalarT rpord = aslew(fpord, dPmin_, dPmax_); const ScalarT ilcap = std::sqrt(ilmax * ilmax); const ScalarT iqmax = pq_on_ * ilcap + pq_off_ * Imax_; const ScalarT ipmax = pq_on_ * Imax_ + pq_off_ * ilcap; diff --git a/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp b/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp index 5898ca752..7c727e0d3 100644 --- a/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp @@ -482,19 +482,19 @@ namespace GridKit success *= stateMatches(at_limit.reecb, {{Vars::PORD, 1.5}}, "order at Pmax"); success *= allResidualsAtRest(at_limit.reecb); - struct AsymmetricRampCase + struct AsymmetricSlewCase { RealT minimum; RealT maximum; const char* label; }; - const std::array asymmetric_ramps{{ + const std::array asymmetric_slews{{ {-0.001, 0.1, "narrow negative ramp limit"}, {-0.1, 0.001, "narrow positive ramp limit"}, }}; - for (const auto& test_case : asymmetric_ramps) + for (const auto& test_case : asymmetric_slews) { auto asymmetric_data = makeData(); asymmetric_data.parameters[Params::dPmin] = test_case.minimum; @@ -508,12 +508,9 @@ namespace GridKit {{Vars::PORD, 1.5}}, test_case.label); success *= allResidualsAtRest(asymmetric.reecb, kTolSmooth); - if (isEqual(asymmetric.input(Ext::PREF), 0.75, kTolSmooth)) - { - std::cout << test_case.label - << " did not offset the published active-power reference\n"; - success = false; - } + success *= scalarMatches(asymmetric.input(Ext::PREF), + 0.75, + test_case.label); } // The reactive command shares the inverse, at both signs. @@ -951,6 +948,42 @@ namespace GridKit } } + { + // Strongly asymmetric limits retain interior rates and bound each + // direction independently. + struct AsymmetricRateCase + { + RealT minimum; + RealT maximum; + RealT input; + RealT expected; + }; + + const std::array rate_cases{{ + {-0.001, 0.1, -0.002, -0.001}, + {-0.001, 0.1, 0.05, 0.05}, + {-0.1, 0.001, -0.05, -0.05}, + {-0.1, 0.001, 0.002, 0.001}, + }}; + + for (const auto& test_case : rate_cases) + { + auto data = makeResidualData(); + data.parameters[Params::dPmin] = test_case.minimum; + data.parameters[Params::dPmax] = test_case.maximum; + + Fixture fixture(data); + fixture.attachAllInputs(); + fixture.input(Ext::PREF) = rampReference(0.5, test_case.input); + success *= fixture.prepare(0.0, 0.2); + setControlState(fixture.reecb); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, + {{Vars::PORD, test_case.expected}}, + "asymmetric active-power ramp limit"); + } + } + { // The voltage band gates the active-power order outside it. const std::array gate_cases{{ @@ -1563,6 +1596,8 @@ namespace GridKit data.parameters[Params::kqv] = 0.0; data.parameters[Params::Qmin] = -2.0; data.parameters[Params::Qmax] = 2.0; + data.parameters[Params::dPmin] = -0.001; + data.parameters[Params::dPmax] = 0.1; data.parameters[Params::Imax] = 2.5; return data; } From 2ae9620c528ddc7aa656b34fead10153b24fb394 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Wed, 5 Aug 2026 15:21:46 -0500 Subject: [PATCH 12/16] Cleanup and use hygov style --- .../PhasorDynamics/Controller/REECB/Reecb.hpp | 78 +-- .../Controller/REECB/ReecbImpl.hpp | 320 ++++++++----- .../ComponentConnectionTests.hpp | 6 + .../PhasorDynamics/ControllerReecbTests.hpp | 448 ++++++++++-------- .../SystemSingleComponentTests.hpp | 21 +- .../runControllerReecbTests.cpp | 1 + 6 files changed, 496 insertions(+), 378 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Controller/REECB/Reecb.hpp b/GridKit/Model/PhasorDynamics/Controller/REECB/Reecb.hpp index f19f13a22..4c79b72f7 100644 --- a/GridKit/Model/PhasorDynamics/Controller/REECB/Reecb.hpp +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/Reecb.hpp @@ -6,7 +6,6 @@ #pragma once -#include #include #include #include @@ -92,6 +91,10 @@ namespace GridKit using InternalVariablesT = ReecbInternalVariables; using ExternalVariablesT = ReecbExternalVariables; + /// Tolerance for initialization reconstruction and steady-state residuals. + static constexpr RealT INITIALIZATION_TOLERANCE = + static_cast(100.0) * std::numeric_limits::epsilon(); + Reecb(BusT* bus); Reecb(BusT* bus, const ModelDataT& data); ~Reecb(); @@ -109,14 +112,11 @@ namespace GridKit -> ComponentSignals& - { - return signals_; - } + ReecbExternalVariables>&; const Model::VariableMonitorBase* getMonitor() const override; - __attribute__((always_inline)) inline int evaluateInternalResidual( + [[gnu::always_inline]] inline int evaluateInternalResidual( const ScalarT* y, const ScalarT* yp, const ScalarT* wb, @@ -124,16 +124,6 @@ namespace GridKit ScalarT* f); private: - static constexpr size_t index(ReecbInternalVariables variable) - { - return static_cast(variable); - } - - static constexpr size_t index(ReecbExternalVariables variable) - { - return static_cast(variable); - } - static void checkConfiguration(bool condition, const char* message, int& errors); void loadRealParameter(const ModelDataT& data, ReecbParameters parameter, @@ -153,59 +143,22 @@ namespace GridKit RealT componentPowerBase() const; template - __attribute__((always_inline)) inline ValueT toComponentBase(ValueT value) const; + [[gnu::always_inline]] inline ValueT toComponentBase(ValueT value) const; template ValueT toSystemBase(ValueT value) const; - /** - * @brief Smooth asymmetric slew-rate limiter. - * - * @param[in] f Unconstrained rate. - * @param[in] rate_min Negative rate limit. - * @param[in] rate_max Positive rate limit. - * @return Limited rate. - */ - static __attribute__((always_inline)) inline ScalarT aslew( + /// Smooth asymmetric slew-rate limiter. + [[gnu::always_inline]] static inline ScalarT aslew( const ScalarT f, const RealT rate_min, - const RealT rate_max) - { - assert(rate_min < ZERO && ZERO < rate_max); + const RealT rate_max); - return f - / (ONE - + Math::ramp(f / rate_max - ONE) - + Math::ramp(f / rate_min - ONE)); - } - - /** - * @brief Smooth anti-windup derivative within a moving symmetric band. - * - * Math::antiwindup over [-band, band] with a band edge that is an - * algebraic quantity, so differentiation carries the band's own - * contributions through the gate. - * - * @param[in] x Limited PI state. - * @param[in] f Pre-limit derivative of x. - * @param[in] band Nonnegative symmetric band edge. - * @return Anti-windup-limited derivative. - * - * @todo Fold moving-limit support into Math::antiwindup in CommonMath. - */ - static __attribute__((always_inline)) inline ScalarT awband( + /// Smooth anti-windup derivative within a moving symmetric band. + [[gnu::always_inline]] static inline ScalarT awband( const ScalarT x, const ScalarT f, - const ScalarT band) - { - const ScalarT above_min = Math::sigmoid(x + band); - const ScalarT below_max = Math::sigmoid(band - x); - - return (above_min * below_max // - + (ONE - below_max) * Math::sigmoid(-f) // - + (ONE - above_min) * Math::sigmoid(f)) - * f; - } + const ScalarT band); ScalarT& Vr(); ScalarT& Vi(); @@ -213,11 +166,6 @@ namespace GridKit static constexpr RealT TIME_CONSTANT_MINIMUM = static_cast(1.0e-3); static constexpr RealT VMEAS_MINIMUM = static_cast(0.01); - /// Accepted reconstruction error where an initialization reference - /// round-trips through a transcendental function. - static constexpr RealT INITIALIZATION_TOLERANCE = - static_cast(100.0) * std::numeric_limits::epsilon(); - BusT* bus_{nullptr}; // Input parameters diff --git a/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp index 3c1c98353..7ca2f7c70 100644 --- a/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp @@ -7,6 +7,7 @@ #pragma once #include +#include #include #include #include @@ -40,7 +41,7 @@ namespace GridKit Reecb::Reecb(BusT* bus) : bus_(bus) { - size_ = static_cast(index(ReecbInternalVariables::MAXIMUM)); + size_ = static_cast(ReecbInternalVariables::MAXIMUM); setDerivedParameters(); } @@ -57,7 +58,7 @@ namespace GridKit { initializeParameters(data); initializeMonitor(); - size_ = static_cast(index(ReecbInternalVariables::MAXIMUM)); + size_ = static_cast(ReecbInternalVariables::MAXIMUM); } /** @@ -91,8 +92,8 @@ namespace GridKit template int Reecb::allocate() { - using I = ReecbInternalVariables; - using E = ReecbExternalVariables; + const auto IQCMD = static_cast(ReecbInternalVariables::IQCMD); + const auto IPCMD = static_cast(ReecbInternalVariables::IPCMD); if (!allocated_) { @@ -106,7 +107,7 @@ namespace GridKit wb_.assign(2, ScalarT{0}); - const auto signal_size = index(E::MAXIMUM); + const auto signal_size = static_cast(ReecbExternalVariables::MAXIMUM); ws_.assign(signal_size, ScalarT{0}); ws_indices_.assign(signal_size, INVALID_INDEX); @@ -118,18 +119,18 @@ namespace GridKit auto* y = y_.getData(); - if (signals_.template isAssigned()) + if (signals_.template isAssigned()) { - signals_.template getSignalNode()->set( - &y[index(I::IQCMD)], - &(this->getVariableIndex(static_cast(index(I::IQCMD))))); + signals_.template getSignalNode()->set( + &y[IQCMD], + &(this->getVariableIndex(static_cast(IQCMD)))); } - if (signals_.template isAssigned()) + if (signals_.template isAssigned()) { - signals_.template getSignalNode()->set( - &y[index(I::IPCMD)], - &(this->getVariableIndex(static_cast(index(I::IPCMD))))); + signals_.template getSignalNode()->set( + &y[IPCMD], + &(this->getVariableIndex(static_cast(IPCMD)))); } allocated_ = true; @@ -280,8 +281,16 @@ namespace GridKit template int Reecb::initialize() { - using I = ReecbInternalVariables; - using E = ReecbExternalVariables; + const auto VMEAS = static_cast(ReecbInternalVariables::VMEAS); + const auto PMEAS = static_cast(ReecbInternalVariables::PMEAS); + const auto XPIQ = static_cast(ReecbInternalVariables::XPIQ); + const auto XPIV = static_cast(ReecbInternalVariables::XPIV); + const auto QV = static_cast(ReecbInternalVariables::QV); + const auto PORD = static_cast(ReecbInternalVariables::PORD); + const auto VT = static_cast(ReecbInternalVariables::VT); + const auto ILMAX = static_cast(ReecbInternalVariables::ILMAX); + const auto IQCMD = static_cast(ReecbInternalVariables::IQCMD); + const auto IPCMD = static_cast(ReecbInternalVariables::IPCMD); if (!allocated_) { @@ -297,8 +306,8 @@ namespace GridKit auto* y = y_.getData(); - const RealT ipcmd0_system = static_cast(y[index(I::IPCMD)]); - const RealT iqcmd0_system = static_cast(y[index(I::IQCMD)]); + const RealT ipcmd0_system = static_cast(y[IPCMD]); + const RealT iqcmd0_system = static_cast(y[IQCMD]); const RealT ipcmd0 = toComponentBase(ipcmd0_system); const RealT iqcmd0 = toComponentBase(iqcmd0_system); const RealT vr0 = static_cast(Vr()); @@ -310,13 +319,15 @@ namespace GridKit RealT pe0_system = toSystemBase(ipcmd0 * vmeas_safe0); RealT qgen0_system = toSystemBase(iqcmd0 * vmeas_safe0); - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - pe0_system = static_cast(signals_.template readExternalVariable()); + pe0_system = static_cast( + signals_.template readExternalVariable()); } - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - qgen0_system = static_cast(signals_.template readExternalVariable()); + qgen0_system = static_cast( + signals_.template readExternalVariable()); } const RealT pmeas0 = toComponentBase(pe0_system); @@ -487,14 +498,14 @@ namespace GridKit return 1; } - y[index(I::VMEAS)] = vmeas0; - y[index(I::PMEAS)] = pmeas0; - y[index(I::XPIQ)] = xpiq0; - y[index(I::XPIV)] = xpiv0; - y[index(I::QV)] = qv0; - y[index(I::PORD)] = pord0; - y[index(I::VT)] = vt0; - y[index(I::ILMAX)] = ilmax0; + y[VMEAS] = vmeas0; + y[PMEAS] = pmeas0; + y[XPIQ] = xpiq0; + y[XPIV] = xpiv0; + y[QV] = qv0; + y[PORD] = pord0; + y[VT] = vt0; + y[ILMAX] = ilmax0; if (!Vref0_given_) { @@ -507,17 +518,17 @@ namespace GridKit pfaref_set_ = static_cast(pfaref0); pref_set_ = static_cast(pref0_system); - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - signals_.template writeExternalVariable(qext_set_); + signals_.template writeExternalVariable(qext_set_); } - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - signals_.template writeExternalVariable(pfaref_set_); + signals_.template writeExternalVariable(pfaref_set_); } - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - signals_.template writeExternalVariable(pref_set_); + signals_.template writeExternalVariable(pref_set_); } y_.setDataUpdated(); @@ -534,15 +545,20 @@ namespace GridKit template int Reecb::tagDifferentiable() { - using I = ReecbInternalVariables; + const auto VMEAS = static_cast(ReecbInternalVariables::VMEAS); + const auto PMEAS = static_cast(ReecbInternalVariables::PMEAS); + const auto XPIQ = static_cast(ReecbInternalVariables::XPIQ); + const auto XPIV = static_cast(ReecbInternalVariables::XPIV); + const auto QV = static_cast(ReecbInternalVariables::QV); + const auto PORD = static_cast(ReecbInternalVariables::PORD); std::fill(tag_.begin(), tag_.end(), false); - tag_[index(I::VMEAS)] = true; - tag_[index(I::PMEAS)] = true; - tag_[index(I::XPIQ)] = true; - tag_[index(I::XPIV)] = true; - tag_[index(I::QV)] = true; - tag_[index(I::PORD)] = true; + tag_[VMEAS] = true; + tag_[PMEAS] = true; + tag_[XPIQ] = true; + tag_[XPIV] = true; + tag_[QV] = true; + tag_[PORD] = true; return 0; } @@ -568,39 +584,49 @@ namespace GridKit template int Reecb::evaluateResidual() { - using E = ReecbExternalVariables; - - ws_[index(E::PE)] = pe_set_; - ws_[index(E::QGEN)] = qgen_set_; - ws_[index(E::QEXT)] = qext_set_; - ws_[index(E::PFAREF)] = pfaref_set_; - ws_[index(E::PREF)] = pref_set_; + const auto PE = static_cast(ReecbExternalVariables::PE); + const auto QGEN = static_cast(ReecbExternalVariables::QGEN); + const auto QEXT = static_cast(ReecbExternalVariables::QEXT); + const auto PFAREF = static_cast(ReecbExternalVariables::PFAREF); + const auto PREF = static_cast(ReecbExternalVariables::PREF); + + ws_[PE] = pe_set_; + ws_[QGEN] = qgen_set_; + ws_[QEXT] = qext_set_; + ws_[PFAREF] = pfaref_set_; + ws_[PREF] = pref_set_; std::fill(ws_indices_.begin(), ws_indices_.end(), INVALID_INDEX); - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - ws_[index(E::PE)] = signals_.template readExternalVariable(); - ws_indices_[index(E::PE)] = signals_.template readExternalVariableIndex(); + ws_[PE] = signals_.template readExternalVariable(); + ws_indices_[PE] = + signals_.template readExternalVariableIndex(); } - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - ws_[index(E::QGEN)] = signals_.template readExternalVariable(); - ws_indices_[index(E::QGEN)] = signals_.template readExternalVariableIndex(); + ws_[QGEN] = signals_.template readExternalVariable(); + ws_indices_[QGEN] = + signals_.template readExternalVariableIndex(); } - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - ws_[index(E::QEXT)] = signals_.template readExternalVariable(); - ws_indices_[index(E::QEXT)] = signals_.template readExternalVariableIndex(); + ws_[QEXT] = signals_.template readExternalVariable(); + ws_indices_[QEXT] = + signals_.template readExternalVariableIndex(); } - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - ws_[index(E::PFAREF)] = signals_.template readExternalVariable(); - ws_indices_[index(E::PFAREF)] = signals_.template readExternalVariableIndex(); + ws_[PFAREF] = + signals_.template readExternalVariable(); + ws_indices_[PFAREF] = + signals_.template readExternalVariableIndex(); } - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - ws_[index(E::PREF)] = signals_.template readExternalVariable(); - ws_indices_[index(E::PREF)] = signals_.template readExternalVariableIndex(); + ws_[PREF] = signals_.template readExternalVariable(); + ws_indices_[PREF] = + signals_.template readExternalVariableIndex(); } wb_[0] = Vr(); @@ -611,6 +637,22 @@ namespace GridKit return 0; } + /** + * @brief Access the REECB signal interface + * + * @return Interface used to assign current-command outputs and attach + * optional feedback and reference signals. + */ + template + auto Reecb::getSignals() + -> ComponentSignals& + { + return signals_; + } + /** * @brief Access the optional variable monitor * @@ -640,7 +682,7 @@ namespace GridKit * rejects the zero-capacity point. */ template - __attribute__((always_inline)) inline int + [[gnu::always_inline]] inline int Reecb::evaluateInternalResidual( const ScalarT* y, const ScalarT* yp, @@ -648,35 +690,49 @@ namespace GridKit const ScalarT* ws, ScalarT* f) { - using I = ReecbInternalVariables; - using E = ReecbExternalVariables; - - const ScalarT vmeas = y[index(I::VMEAS)]; - const ScalarT pmeas = y[index(I::PMEAS)]; - const ScalarT xpiq = y[index(I::XPIQ)]; - const ScalarT xpiv = y[index(I::XPIV)]; - const ScalarT qv = y[index(I::QV)]; - const ScalarT pord = y[index(I::PORD)]; - const ScalarT vt = y[index(I::VT)]; - const ScalarT ilmax = y[index(I::ILMAX)]; - const ScalarT iqcmd_system = y[index(I::IQCMD)]; - const ScalarT ipcmd_system = y[index(I::IPCMD)]; - - const ScalarT vmeas_dot = yp[index(I::VMEAS)]; - const ScalarT pmeas_dot = yp[index(I::PMEAS)]; - const ScalarT xpiq_dot = yp[index(I::XPIQ)]; - const ScalarT xpiv_dot = yp[index(I::XPIV)]; - const ScalarT qv_dot = yp[index(I::QV)]; - const ScalarT pord_dot = yp[index(I::PORD)]; + const auto VMEAS = static_cast(ReecbInternalVariables::VMEAS); + const auto PMEAS = static_cast(ReecbInternalVariables::PMEAS); + const auto XPIQ = static_cast(ReecbInternalVariables::XPIQ); + const auto XPIV = static_cast(ReecbInternalVariables::XPIV); + const auto QV = static_cast(ReecbInternalVariables::QV); + const auto PORD = static_cast(ReecbInternalVariables::PORD); + const auto VT = static_cast(ReecbInternalVariables::VT); + const auto ILMAX = static_cast(ReecbInternalVariables::ILMAX); + const auto IQCMD = static_cast(ReecbInternalVariables::IQCMD); + const auto IPCMD = static_cast(ReecbInternalVariables::IPCMD); + + const auto PE = static_cast(ReecbExternalVariables::PE); + const auto QGEN = static_cast(ReecbExternalVariables::QGEN); + const auto QEXT = static_cast(ReecbExternalVariables::QEXT); + const auto PFAREF = static_cast(ReecbExternalVariables::PFAREF); + const auto PREF = static_cast(ReecbExternalVariables::PREF); + + const ScalarT vmeas = y[VMEAS]; + const ScalarT pmeas = y[PMEAS]; + const ScalarT xpiq = y[XPIQ]; + const ScalarT xpiv = y[XPIV]; + const ScalarT qv = y[QV]; + const ScalarT pord = y[PORD]; + const ScalarT vt = y[VT]; + const ScalarT ilmax = y[ILMAX]; + const ScalarT iqcmd_system = y[IQCMD]; + const ScalarT ipcmd_system = y[IPCMD]; + + const ScalarT vmeas_dot = yp[VMEAS]; + const ScalarT pmeas_dot = yp[PMEAS]; + const ScalarT xpiq_dot = yp[XPIQ]; + const ScalarT xpiv_dot = yp[XPIV]; + const ScalarT qv_dot = yp[QV]; + const ScalarT pord_dot = yp[PORD]; const ScalarT vr = wb[0]; const ScalarT vi = wb[1]; - const ScalarT pe = toComponentBase(ws[index(E::PE)]); - const ScalarT qgen = toComponentBase(ws[index(E::QGEN)]); - const ScalarT extref = ws[index(E::QEXT)]; - const ScalarT pfaref = ws[index(E::PFAREF)]; - const ScalarT pref = toComponentBase(ws[index(E::PREF)]); + const ScalarT pe = toComponentBase(ws[PE]); + const ScalarT qgen = toComponentBase(ws[QGEN]); + const ScalarT extref = ws[QEXT]; + const ScalarT pfaref = ws[PFAREF]; + const ScalarT pref = toComponentBase(ws[PREF]); const ScalarT iqcmd = toComponentBase(iqcmd_system); const ScalarT ipcmd = toComponentBase(ipcmd_system); @@ -699,16 +755,16 @@ namespace GridKit const ScalarT iqbase = Math::clamp(Kvp_ * epiv + xpiv, -iqmax, iqmax); const ScalarT iqraw = q_on_ * iqbase + q_off_ * qv + iqv; - f[index(I::VMEAS)] = -vmeas_dot + (vt - vmeas) / Trv_; - f[index(I::PMEAS)] = -pmeas_dot + (pe - pmeas) / Tp_; - f[index(I::XPIQ)] = -xpiq_dot + q_pi_on_ * sdip * Math::antiwindup(Kqp_ * eq + xpiq, Kqi_ * eq, Vmin_, Vmax_); - f[index(I::XPIV)] = -xpiv_dot + q_on_ * sdip * awband(Kvp_ * epiv + xpiv, Kvi_ * epiv, iqmax); - f[index(I::QV)] = -qv_dot + q_off_ * sdip * (qref / vmeas_safe - qv) / Tiq_; - f[index(I::PORD)] = -pord_dot + sdip * Math::antiwindup(pord, rpord, Pmin_, Pmax_); - f[index(I::VT)] = -vt * vt + vr * vr + vi * vi; - f[index(I::ILMAX)] = -ilmax * ilcap + Imax_ * Imax_ - pq_on_ * ipcmd * ipcmd - pq_off_ * iqcmd * iqcmd; - f[index(I::IQCMD)] = -iqcmd + Math::clamp(iqraw, -iqmax, iqmax); - f[index(I::IPCMD)] = -ipcmd + Math::clamp(pord / vmeas_safe, ZERO, ipmax); + f[VMEAS] = -vmeas_dot + (vt - vmeas) / Trv_; + f[PMEAS] = -pmeas_dot + (pe - pmeas) / Tp_; + f[XPIQ] = -xpiq_dot + q_pi_on_ * sdip * Math::antiwindup(Kqp_ * eq + xpiq, Kqi_ * eq, Vmin_, Vmax_); + f[XPIV] = -xpiv_dot + q_on_ * sdip * awband(Kvp_ * epiv + xpiv, Kvi_ * epiv, iqmax); + f[QV] = -qv_dot + q_off_ * sdip * (qref / vmeas_safe - qv) / Tiq_; + f[PORD] = -pord_dot + sdip * Math::antiwindup(pord, rpord, Pmin_, Pmax_); + f[VT] = -vt * vt + vr * vr + vi * vi; + f[ILMAX] = -ilmax * ilcap + Imax_ * Imax_ - pq_on_ * ipcmd * ipcmd - pq_off_ * iqcmd * iqcmd; + f[IQCMD] = -iqcmd + Math::clamp(iqraw, -iqmax, iqmax); + f[IPCMD] = -ipcmd + Math::clamp(pord / vmeas_safe, ZERO, ipmax); return 0; } @@ -717,6 +773,59 @@ namespace GridKit // Private methods // + /** + * @brief Smooth asymmetric slew-rate limiter + * + * @param[in] f Unconstrained rate. + * @param[in] rate_min Negative rate limit. + * @param[in] rate_max Positive rate limit. + * @return Limited rate. + */ + template + [[gnu::always_inline]] inline scalar_type + Reecb::aslew( + const ScalarT f, + const RealT rate_min, + const RealT rate_max) + { + assert(rate_min < ZERO && ZERO < rate_max); + + return f + / (ONE + + Math::ramp(f / rate_max - ONE) + + Math::ramp(f / rate_min - ONE)); + } + + /** + * @brief Smooth anti-windup derivative within a moving symmetric band + * + * Math::antiwindup over [-band, band] with a band edge that is an + * algebraic quantity, so differentiation carries the band's own + * contributions through the gate. + * + * @param[in] x Limited PI state. + * @param[in] f Pre-limit derivative of x. + * @param[in] band Nonnegative symmetric band edge. + * @return Anti-windup-limited derivative. + * + * @todo Fold moving-limit support into Math::antiwindup in CommonMath. + */ + template + [[gnu::always_inline]] inline scalar_type + Reecb::awband( + const ScalarT x, + const ScalarT f, + const ScalarT band) + { + const ScalarT above_min = Math::sigmoid(x + band); + const ScalarT below_max = Math::sigmoid(band - x); + + return (above_min * below_max // + + (ONE - below_max) * Math::sigmoid(-f) // + + (ONE - above_min) * Math::sigmoid(f)) + * f; + } + /** * @brief Record one failed configuration condition * @@ -906,17 +1015,16 @@ namespace GridKit template void Reecb::initializeMonitor() { - using I = ReecbInternalVariables; using Variable = typename ModelDataT::MonitorableVariables; monitor_->set(Variable::iqcmd, [this] - { return y_.getData()[index(I::IQCMD)]; }); + { return y_.getData()[static_cast(ReecbInternalVariables::IQCMD)]; }); monitor_->set(Variable::ipcmd, [this] - { return y_.getData()[index(I::IPCMD)]; }); + { return y_.getData()[static_cast(ReecbInternalVariables::IPCMD)]; }); monitor_->set(Variable::vmeas, [this] - { return y_.getData()[index(I::VMEAS)]; }); + { return y_.getData()[static_cast(ReecbInternalVariables::VMEAS)]; }); monitor_->set(Variable::pmeas, [this] - { return y_.getData()[index(I::PMEAS)]; }); + { return y_.getData()[static_cast(ReecbInternalVariables::PMEAS)]; }); } /** @@ -1051,7 +1159,7 @@ namespace GridKit */ template template - __attribute__((always_inline)) inline ValueT + [[gnu::always_inline]] inline ValueT Reecb::toComponentBase(ValueT value) const { return value * (va_system_base_ / componentPowerBase()); diff --git a/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp b/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp index 4b97c33de..d8f55a9a6 100644 --- a/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp @@ -303,6 +303,12 @@ namespace GridKit success *= system.allocate() == 0; success *= ipcmd.linked() && iqcmd.linked() && pe.linked() && qgen.linked(); + success *= ipcmd.getVariableIndex() + == controller.getVariableIndex( + static_cast(ControllerInternal::IPCMD)); + success *= iqcmd.getVariableIndex() + == controller.getVariableIndex( + static_cast(ControllerInternal::IQCMD)); success *= system.initialize() == 0; success *= system.evaluateResidual() == 0; diff --git a/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp b/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp index 7c727e0d3..7fa352ed6 100644 --- a/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -40,12 +41,8 @@ namespace GridKit ControllerReecbTests() = default; ~ControllerReecbTests() = default; - // The tolerance only absorbs floating-point roundoff. - static constexpr RealT kTol = std::numeric_limits::epsilon(); - - // Probes that sit inside a smooth transition carry its MU-dependent tail - // rather than roundoff alone. - static constexpr RealT kTolSmooth = static_cast(100.0) * kTol; + static constexpr RealT kTol = + static_cast(100.0) * std::numeric_limits::epsilon(); /// Validate construction, row layout, defaults, parameters, buses, /// signal links, and the time-constant floor. @@ -111,34 +108,37 @@ namespace GridKit success *= (integer_parameter.reecb.verify() == 0); success *= invalidParameterCase(Params::mva, true); - const RealT nan = std::numeric_limits::quiet_NaN(); - const RealT infinity = std::numeric_limits::infinity(); - for (const Params parameter : {Params::mva, - Params::Trv, - Params::Tp, - Params::Vref0, - Params::Vdip, - Params::Vup, - Params::dbd1, - Params::dbd2, - Params::kqv, - Params::Iql1, - Params::Iqh1, - Params::Qmax, - Params::Qmin, - Params::Kqp, - Params::Kqi, - Params::Vmax, - Params::Vmin, - Params::Kvp, - Params::Kvi, - Params::Tiq, - Params::Tpord, - Params::dPmax, - Params::dPmin, - Params::Pmax, - Params::Pmin, - Params::Imax}) + const RealT nan = std::numeric_limits::quiet_NaN(); + const RealT infinity = std::numeric_limits::infinity(); + const std::array real_parameters{{ + Params::mva, + Params::Trv, + Params::Tp, + Params::Vref0, + Params::Vdip, + Params::Vup, + Params::dbd1, + Params::dbd2, + Params::kqv, + Params::Iql1, + Params::Iqh1, + Params::Qmax, + Params::Qmin, + Params::Kqp, + Params::Kqi, + Params::Vmax, + Params::Vmin, + Params::Kvp, + Params::Kvi, + Params::Tiq, + Params::Tpord, + Params::dPmax, + Params::dPmin, + Params::Pmax, + Params::Pmin, + Params::Imax, + }}; + for (const Params parameter : real_parameters) { success *= invalidParameterCase(parameter, nan); success *= invalidParameterCase(parameter, infinity); @@ -158,12 +158,28 @@ namespace GridKit success *= invalidParameterCase(Params::Pmin, 3.0); success *= invalidParameterCase(Params::Imax, 0.0); - for (const Params flag : {Params::PfFlag, - Params::VFlag, - Params::QFlag, - Params::Pqflag}) + const std::array flag_parameters{{ + Params::PfFlag, + Params::VFlag, + Params::QFlag, + Params::Pqflag, + }}; + const std::array valid_flag_values{{false, true}}; + const std::array invalid_integral_flag_values{{ + static_cast(0), + static_cast(1), + static_cast(2), + }}; + const std::array invalid_real_flag_values{{ + static_cast(0.0), + static_cast(0.5), + static_cast(1.0), + nan, + infinity, + }}; + for (const Params flag : flag_parameters) { - for (const bool value : {false, true}) + for (const bool value : valid_flag_values) { auto data = makeData(); data.parameters[flag] = value; @@ -171,18 +187,12 @@ namespace GridKit success *= (model.reecb.verify() == 0); } - for (const IdxT value : {static_cast(0), - static_cast(1), - static_cast(2)}) + for (const IdxT value : invalid_integral_flag_values) { success *= invalidParameterCase(flag, value); } - for (const RealT value : {static_cast(0.0), - static_cast(0.5), - static_cast(1.0), - nan, - infinity}) + for (const RealT value : invalid_real_flag_values) { success *= invalidParameterCase(flag, value); } @@ -207,7 +217,7 @@ namespace GridKit Fixture floored(floor_data); success *= floored.initialize(kInitialIqcmd, kInitialIpcmd); success *= (floored.evaluate() == 0); - success *= allResidualsAtRest(floored.reecb); + success *= allResidualsWithinInitTolerance(floored.reecb); // Each floored lag turns a half-unit state offset into a rate of 500, // and saturates the active-power ramp limiter. @@ -263,7 +273,7 @@ namespace GridKit success *= scalarMatches(fixture.input(Ext::QEXT), 0.75, "published qext"); success *= scalarMatches(fixture.input(Ext::PFAREF), 0.0, "published pfaref"); success *= scalarMatches(fixture.input(Ext::PREF), 0.75, "published pref"); - success *= allResidualsAtRest(fixture.reecb); + success *= allResidualsWithinInitTolerance(fixture.reecb); success *= monitorMatches(fixture.reecb, {{kInitialIqcmd, kInitialIpcmd, 1.0, 1.5}}, @@ -283,7 +293,7 @@ namespace GridKit success *= (latched.evaluate() == 0); success *= scalarPreserved(latched.iqcmd(), kInitialIqcmd, "unassigned iqcmd"); success *= scalarPreserved(latched.ipcmd(), kInitialIpcmd, "unassigned ipcmd"); - success *= allResidualsAtRest(latched.reecb); + success *= allResidualsWithinInitTolerance(latched.reecb); // An omitted component rating falls back to the system power base, so // the same commands land on a different measured power. @@ -299,7 +309,7 @@ namespace GridKit {Vars::PORD, 1.5}, {Vars::ILMAX, 2.0}}, "omitted component rating"); - success *= allResidualsAtRest(system_base.reecb); + success *= allResidualsWithinInitTolerance(system_base.reecb); return success.report(__func__); } @@ -382,7 +392,7 @@ namespace GridKit collapsed_limits.input(Ext::QGEN) = 0.6; success *= collapsed_limits.initialize(0.75, 0.75); success *= (collapsed_limits.evaluate() == 0); - success *= allResidualsAtRest(collapsed_limits.reecb); + success *= allResidualsWithinInitTolerance(collapsed_limits.reecb); auto collapsed_reactive = collapsed; collapsed_reactive.parameters[Params::Vmin] = 0.5; @@ -409,7 +419,7 @@ namespace GridKit unconstrained.input(Ext::QGEN) = 4.0; success *= unconstrained.initialize(0.75, 0.75); success *= (unconstrained.evaluate() == 0); - success *= allResidualsAtRest(unconstrained.reecb); + success *= allResidualsWithinInitTolerance(unconstrained.reecb); // An invalid configuration is rejected before any state is written. auto invalid_data = data; @@ -461,7 +471,7 @@ namespace GridKit success *= fixture.initialize(0.0, test_case.ipcmd); success *= (fixture.evaluate() == 0); success *= scalarPreserved(fixture.ipcmd(), test_case.ipcmd, test_case.label); - success *= allResidualsAtRest(fixture.reecb, kTolSmooth); + success *= allResidualsWithinInitTolerance(fixture.reecb); } // An interior command recovers the ideal active-power order exactly. @@ -480,7 +490,7 @@ namespace GridKit success *= at_limit.initialize(0.0, 0.75); success *= (at_limit.evaluate() == 0); success *= stateMatches(at_limit.reecb, {{Vars::PORD, 1.5}}, "order at Pmax"); - success *= allResidualsAtRest(at_limit.reecb); + success *= allResidualsWithinInitTolerance(at_limit.reecb); struct AsymmetricSlewCase { @@ -507,20 +517,24 @@ namespace GridKit success *= stateMatches(asymmetric.reecb, {{Vars::PORD, 1.5}}, test_case.label); - success *= allResidualsAtRest(asymmetric.reecb, kTolSmooth); + success *= allResidualsWithinInitTolerance(asymmetric.reecb); success *= scalarMatches(asymmetric.input(Ext::PREF), 0.75, test_case.label); } // The reactive command shares the inverse, at both signs. - for (const RealT iqcmd : {static_cast(0.999999), static_cast(-0.999999)}) + const std::array reactive_commands{{ + static_cast(0.999999), + static_cast(-0.999999), + }}; + for (const RealT iqcmd : reactive_commands) { Fixture reactive(exactness_data); success *= reactive.initialize(iqcmd, 0.75); success *= (reactive.evaluate() == 0); success *= scalarPreserved(reactive.iqcmd(), iqcmd, "near-limit reactive command"); - success *= allResidualsAtRest(reactive.reecb, kTolSmooth); + success *= allResidualsWithinInitTolerance(reactive.reecb); } return success.report(__func__); @@ -539,33 +553,33 @@ namespace GridKit setAnswerKeyState(fixture.reecb); success *= (fixture.evaluate() == 0); - const std::array expected{{ - {Vars::VMEAS, "VMEAS", 0.99}, - {Vars::PMEAS, "PMEAS", 0.145}, - {Vars::XPIQ, "XPIQ", 0.21}, - {Vars::XPIV, "XPIV", 0.13}, - {Vars::QV, "QV", -0.05}, - {Vars::PORD, "PORD", 0.26}, - {Vars::VT, "VT", -0.03}, - {Vars::ILMAX, "ILMAX", 0.32}, - {Vars::IQCMD, "IQCMD", 0.19}, - {Vars::IPCMD, "IPCMD", 0.05}, + const std::array expected_residuals{{ + {Vars::VMEAS, 0.99}, + {Vars::PMEAS, 0.145}, + {Vars::XPIQ, 0.21}, + {Vars::XPIV, 0.13}, + {Vars::QV, -0.05}, + {Vars::PORD, 0.26}, + {Vars::VT, -0.03}, + {Vars::ILMAX, 0.32}, + {Vars::IQCMD, 0.19}, + {Vars::IPCMD, 0.05}, }}; - success *= (static_cast(fixture.reecb.getResidual().getSize()) == expected.size()); - const auto* residual = fixture.reecb.getResidual().getData(); - for (size_t row = 0; row < expected.size(); ++row) + success *= (static_cast(fixture.reecb.getResidual().getSize()) + == expected_residuals.size()); + for (size_t row = 0; row < expected_residuals.size(); ++row) { - if (index(expected[row].variable) != row) + if (index(expected_residuals[row].variable) != row) { std::cout << "REECB residual key position " << row << " names row " - << expected[row].name << '\n'; + << variableName(expected_residuals[row].variable) << '\n'; success = false; } - success *= scalarMatches(residual[index(expected[row].variable)], - expected[row].value, - expected[row].name); } + success *= residualsMatch(fixture.reecb, + expected_residuals, + "independent numerical answer key"); return success.report(__func__); } @@ -580,13 +594,14 @@ namespace GridKit noteExpectedLogs("Testing REECB selector configurations. " "Rejection of the power-factor direct-voltage combinations is expected."); - for (const bool pf : {false, true}) + const std::array selector_values{{false, true}}; + for (const bool pf : selector_values) { - for (const bool voltage : {false, true}) + for (const bool voltage : selector_values) { - for (const bool reactive : {false, true}) + for (const bool reactive : selector_values) { - for (const bool p_priority : {false, true}) + for (const bool p_priority : selector_values) { auto data = makeData(); data.parameters[Params::PfFlag] = pf; @@ -596,7 +611,7 @@ namespace GridKit data.parameters[Params::Kqi] = reactive && voltage ? 0.4 : 0.0; data.parameters[Params::Kvi] = reactive ? 0.5 : 0.0; - for (const bool attached : {false, true}) + for (const bool attached : selector_values) { Fixture fixture(data); if (attached) @@ -615,7 +630,7 @@ namespace GridKit success *= fixture.initialize(0.75, 0.75); success *= (fixture.evaluate() == 0); - success *= allResidualsAtRest(fixture.reecb); + success *= allResidualsWithinInitTolerance(fixture.reecb); success *= scalarPreserved(fixture.iqcmd(), 0.75, "selector iqcmd"); success *= scalarPreserved(fixture.ipcmd(), 0.75, "selector ipcmd"); success *= stateMatches(fixture.reecb, {{Vars::ILMAX, 2.0}}, "selector ILMAX"); @@ -640,7 +655,7 @@ namespace GridKit success *= scalarPreserved(fixture.input(Ext::PE), 0.75, "selector pe"); success *= scalarPreserved(fixture.input(Ext::QGEN), 0.75, "selector qgen"); const RealT expected_qext = reactive && !voltage ? 1.0 : 0.75; - const RealT expected_pfaref = pf ? kQuarterTurn : 0.0; + const RealT expected_pfaref = pf ? kUnitSlopeAngle : 0.0; success *= scalarMatches(fixture.input(Ext::QEXT), expected_qext, "published qext"); success *= scalarMatches(fixture.input(Ext::PFAREF), expected_pfaref, "published pfaref"); success *= scalarMatches(fixture.input(Ext::PREF), 0.75, "published pref"); @@ -671,7 +686,7 @@ namespace GridKit success *= fixture.initialize(0.75, 0.75); success *= scalarMatches(fixture.input(Ext::QEXT), 1.0, "published voltage reference"); success *= (fixture.evaluate() == 0); - success *= allResidualsAtRest(fixture.reecb); + success *= allResidualsWithinInitTolerance(fixture.reecb); // A raised external voltage reference enters the V-PI rate raw. fixture.input(Ext::QEXT) = 1.2; @@ -687,7 +702,7 @@ namespace GridKit success *= fixture.initialize(0.75, 0.75); success *= scalarMatches(fixture.input(Ext::QEXT), 0.75, "published system-base reactive power"); success *= (fixture.evaluate() == 0); - success *= allResidualsAtRest(fixture.reecb); + success *= allResidualsWithinInitTolerance(fixture.reecb); // The reactive-current lag keeps the power-base conversion, so the // same raise produces twice the component-base rate. @@ -908,7 +923,7 @@ namespace GridKit data.parameters[Params::kqv] = 0.0; Fixture fixture(data); fixture.attachAllInputs(); - fixture.input(Ext::PFAREF) = kHalfSlopeAngle; + fixture.input(Ext::PFAREF) = std::atan(HALF); success *= fixture.prepare(0.0, 0.2); setControlState(fixture.reecb); setState(fixture.reecb, {{Vars::PMEAS, 0.6}, {Vars::QV, 0.1}}); @@ -1086,10 +1101,11 @@ namespace GridKit { // The priority selector chooses which command consumes the circle. - for (const auto& [p_priority, expected] : std::array, 2>{{ - {true, 0.32}, - {false, 0.56}, - }}) + const std::array, 2> priority_cases{{ + {true, 0.32}, + {false, 0.56}, + }}; + for (const auto& [p_priority, expected] : priority_cases) { auto data = makeResidualData(); data.parameters[Params::Pqflag] = p_priority; @@ -1145,20 +1161,20 @@ namespace GridKit return success.report(__func__); } -#ifdef GRIDKIT_ENABLE_ENZYME - /// Fixed dependency-tracking coefficients pin every selector path before - /// each configuration is compared against the Enzyme CSR rows. - TestOutcome jacobian() + /// Fixed dependency-tracking coefficients pin every selector path at a + /// non-unit alpha. + TestOutcome dependencyTracking() { TestStatus success = true; - for (const bool pf : {false, true}) + const std::array selector_values{{false, true}}; + for (const bool pf : selector_values) { - for (const bool voltage : {false, true}) + for (const bool voltage : selector_values) { - for (const bool reactive : {false, true}) + for (const bool reactive : selector_values) { - for (const bool p_priority : {false, true}) + for (const bool p_priority : selector_values) { if (pf && reactive && !voltage) { @@ -1215,24 +1231,19 @@ namespace GridKit success *= derivativeMatches(dependency, Vars::ILMAX, Vars::IQCMD, -0.8, "Q-priority current-circle column"); success *= derivativeMatches(dependency, Vars::ILMAX, Vars::IPCMD, 0.0, "Q-priority absent current-circle column"); } - - success *= jacobiansMatch(dependency, - enzymeJacobian(data, kNonunitAlpha, success)); } } } } // A negative capacity iterate keeps the signed-square derivative. - for (const bool p_priority : {false, true}) + for (const bool p_priority : selector_values) { auto data = makeJacobianData(); data.parameters[Params::Pqflag] = p_priority; const auto dependency = dependencyTrackingJacobian(data, kNonunitAlpha, success, -2.0); success *= derivativeMatches(dependency, Vars::ILMAX, Vars::ILMAX, -4.0, "negative capacity continuation"); - success *= jacobiansMatch(dependency, - enzymeJacobian(data, kNonunitAlpha, success, -2.0)); } // The selector sweep zeroes the injection gain, so this configuration @@ -1250,22 +1261,79 @@ namespace GridKit const auto dependency = dependencyTrackingJacobian(data, kNonunitAlpha, success); success *= derivativeMatches(dependency, Vars::IQCMD, Vars::VMEAS, -1.0, "IQCMD-VMEAS injection path"); success *= derivativeMatches(dependency, Vars::IQCMD, Vars::QV, 1.0, "IQCMD-QV alongside injection"); - success *= jacobiansMatch(dependency, - enzymeJacobian(data, kNonunitAlpha, success)); } return success.report(__func__); } + +#ifdef GRIDKIT_ENABLE_ENZYME + /// Every selector mode and the continuation probes agree between Enzyme + /// and dependency tracking at a non-unit alpha. + TestOutcome jacobian() + { + TestStatus success = true; + + const std::array selector_values{{false, true}}; + for (const bool pf : selector_values) + { + for (const bool voltage : selector_values) + { + for (const bool reactive : selector_values) + { + for (const bool p_priority : selector_values) + { + if (pf && reactive && !voltage) + { + continue; + } + + auto data = makeJacobianData(); + data.parameters[Params::PfFlag] = pf; + data.parameters[Params::VFlag] = voltage; + data.parameters[Params::QFlag] = reactive; + data.parameters[Params::Pqflag] = p_priority; + + success *= jacobiansMatch( + dependencyTrackingJacobian(data, kNonunitAlpha, success), + enzymeJacobian(data, kNonunitAlpha, success)); + } + } + } + } + + for (const bool p_priority : selector_values) + { + auto data = makeJacobianData(); + data.parameters[Params::Pqflag] = p_priority; + + success *= jacobiansMatch( + dependencyTrackingJacobian(data, kNonunitAlpha, success, -2.0), + enzymeJacobian(data, kNonunitAlpha, success, -2.0)); + } + + auto injection_data = makeJacobianData(); + injection_data.parameters[Params::QFlag] = false; + injection_data.parameters[Params::kqv] = 1.0; + injection_data.parameters[Params::dbd1] = -0.6; + injection_data.parameters[Params::dbd2] = 0.6; + injection_data.parameters[Params::Iql1] = -1.2; + injection_data.parameters[Params::Iqh1] = 1.5; + injection_data.parameters[Params::Vref0] = 2.2; + success *= jacobiansMatch( + dependencyTrackingJacobian(injection_data, kNonunitAlpha, success), + enzymeJacobian(injection_data, kNonunitAlpha, success)); + + return success.report(__func__); + } #endif private: - using Params = PhasorDynamics::Controller::ReecbParameters; - using Vars = PhasorDynamics::Controller::ReecbInternalVariables; - using Ext = PhasorDynamics::Controller::ReecbExternalVariables; - using Mon = PhasorDynamics::Controller::ReecbMonitorableVariables; - using Data = PhasorDynamics::Controller::ReecbData; - using ReecbT = PhasorDynamics::Controller::Reecb; - using JacobianRow = DependencyTracking::Variable::DependencyMap; + using Params = PhasorDynamics::Controller::ReecbParameters; + using Vars = PhasorDynamics::Controller::ReecbInternalVariables; + using Ext = PhasorDynamics::Controller::ReecbExternalVariables; + using Mon = PhasorDynamics::Controller::ReecbMonitorableVariables; + using Data = PhasorDynamics::Controller::ReecbData; + using ReecbT = PhasorDynamics::Controller::Reecb; static constexpr size_t index(Vars variable) { @@ -1277,25 +1345,12 @@ namespace GridKit return static_cast(variable); } - struct Row + struct VariableValue { - constexpr Row(Vars row, RealT expected_value) - : variable(row), - value(expected_value) - { - } - Vars variable; RealT value; }; - struct ExpectedResidual - { - Vars variable; - const char* name; - RealT value; - }; - struct DrivenCase { RealT input; @@ -1309,8 +1364,6 @@ namespace GridKit RealT expected; }; - using Rows = std::initializer_list; - /// Owns the terminal bus, REECB, the assigned command nodes, and the /// attached input nodes. Signal storage precedes the model so every /// referenced node outlives REECB; copying would invalidate the model @@ -1448,9 +1501,7 @@ namespace GridKit static constexpr RealT kInitialIpcmd = 0.75; static constexpr RealT kNonunitAlpha = 0.7; - // Angles whose tangents are the exact slopes the probes below assume. - static constexpr RealT kQuarterTurn = std::numbers::pi_v / FOUR; - static constexpr RealT kHalfSlopeAngle = 0.46364760900080612; + static constexpr RealT kUnitSlopeAngle = std::numbers::pi_v / FOUR; static constexpr size_t kBusVrColumn = index(Vars::MAXIMUM); static constexpr size_t kBusViColumn = kBusVrColumn + 1; @@ -1863,10 +1914,11 @@ namespace GridKit } template - void setState(PhasorDynamics::Controller::Reecb& reecb, Rows rows) const + void setState(PhasorDynamics::Controller::Reecb& reecb, + std::initializer_list values) const { auto* y = reecb.y().getData(); - for (const auto& [variable, value] : rows) + for (const auto& [variable, value] : values) { y[index(variable)] = static_cast(value); } @@ -1874,10 +1926,11 @@ namespace GridKit } template - void setDerivative(PhasorDynamics::Controller::Reecb& reecb, Rows rows) const + void setDerivative(PhasorDynamics::Controller::Reecb& reecb, + std::initializer_list values) const { auto* yp = reecb.yp().getData(); - for (const auto& [variable, value] : rows) + for (const auto& [variable, value] : values) { yp[index(variable)] = static_cast(value); } @@ -1964,9 +2017,9 @@ namespace GridKit /// infinities and NaN. static bool preserved(RealT actual, RealT expected) { - if (expected != expected) + if (std::isnan(expected)) { - return actual != actual; + return std::isnan(actual); } return actual == expected; } @@ -1998,26 +2051,17 @@ namespace GridKit return false; } - static bool finite(RealT value) - { - return value == value - && value < std::numeric_limits::infinity() - && value > -std::numeric_limits::infinity(); - } - - template + template bool rowsMatch(const VectorT& vector, - const Row* rows, - size_t count, + const ValuesT& values, const char* what, const char* context) const { - bool success = true; - const auto* values = vector.getData(); - for (size_t i = 0; i < count; ++i) + bool success = true; + const auto* vector_values = vector.getData(); + for (const auto& [variable, expected] : values) { - const auto& [variable, expected] = rows[i]; - if (!variableMatches(static_cast(values[index(variable)]), + if (!variableMatches(static_cast(vector_values[index(variable)]), expected, what, variable, @@ -2029,20 +2073,37 @@ namespace GridKit return success; } - bool residualsMatch(const ReecbT& reecb, Rows rows, const char* context = "") const + bool residualsMatch(const ReecbT& reecb, + std::initializer_list values, + const char* context = "") const + { + return rowsMatch(reecb.getResidual(), values, "residual", context); + } + + template + bool residualsMatch(const ReecbT& reecb, + const std::array& values, + const char* context = "") const { - return rowsMatch(reecb.getResidual(), rows.begin(), rows.size(), "residual", context); + return rowsMatch(reecb.getResidual(), values, "residual", context); } - bool stateMatches(const ReecbT& reecb, Rows rows, const char* context = "") const + bool stateMatches(const ReecbT& reecb, + std::initializer_list values, + const char* context = "") const { - return rowsMatch(reecb.y(), rows.begin(), rows.size(), "state", context); + return rowsMatch(reecb.y(), values, "state", context); } - /// The model sits at a steady state: every residual and every derivative - /// is zero. Probes placed inside a smooth transition carry that - /// transition's own MU-dependent tail and pass @p tolerance explicitly. - bool allResidualsAtRest(const ReecbT& reecb, RealT tolerance = kTol) const + template + bool stateMatches(const ReecbT& reecb, + const std::array& values, + const char* context = "") const + { + return rowsMatch(reecb.y(), values, "state", context); + } + + bool allResidualsWithinInitTolerance(const ReecbT& reecb) const { bool success = true; const auto* f = reecb.getResidual().getData(); @@ -2050,7 +2111,12 @@ namespace GridKit for (size_t row = 0; row < index(Vars::MAXIMUM); ++row) { const auto variable = static_cast(row); - if (!variableMatches(f[row], 0.0, "residual", variable, "at rest", tolerance)) + if (!variableMatches(f[row], + 0.0, + "residual", + variable, + "at rest", + ReecbT::INITIALIZATION_TOLERANCE)) { success = false; } @@ -2068,7 +2134,7 @@ namespace GridKit const auto* f = reecb.getResidual().getData(); for (size_t row = 0; row < index(Vars::MAXIMUM); ++row) { - if (!finite(f[row])) + if (!std::isfinite(f[row])) { std::cout << "REECB residual " << variableName(static_cast(row)) << " is not finite\n"; @@ -2208,10 +2274,11 @@ namespace GridKit fixture.bus.y().setDataUpdated(); } - std::vector dependencyTrackingJacobian(const Data& data, - RealT alpha, - TestStatus& success, - RealT ilmax = 2.0) const + std::vector + dependencyTrackingJacobian(const Data& data, + RealT alpha, + TestStatus& success, + RealT ilmax = 2.0) const { using DepVar = DependencyTracking::Variable; @@ -2222,8 +2289,8 @@ namespace GridKit numberVariables(fixture, alpha); success *= (fixture.evaluate() == 0); - std::vector rows(index(Vars::MAXIMUM)); - const auto* f = fixture.reecb.getResidual().getData(); + std::vector rows(index(Vars::MAXIMUM)); + const auto* f = fixture.reecb.getResidual().getData(); for (size_t row = 0; row < rows.size(); ++row) { rows[row] = f[row].getDependencies(); @@ -2231,7 +2298,10 @@ namespace GridKit return rows; } - static RealT derivative(const std::vector& jacobian, size_t row, size_t column) + static RealT derivative( + const std::vector& jacobian, + size_t row, + size_t column) { const auto entry = jacobian[row].find(column); if (entry == jacobian[row].end()) @@ -2241,20 +2311,22 @@ namespace GridKit return entry->second; } - bool derivativeMatches(const std::vector& jacobian, - Vars row, - Vars column, - RealT expected, - const char* label) const + bool derivativeMatches( + const std::vector& jacobian, + Vars row, + Vars column, + RealT expected, + const char* label) const { return derivativeMatches(jacobian, row, index(column), expected, label); } - bool derivativeMatches(const std::vector& jacobian, - Vars row, - size_t column, - RealT expected, - const char* label) const + bool derivativeMatches( + const std::vector& jacobian, + Vars row, + size_t column, + RealT expected, + const char* label) const { const RealT actual = derivative(jacobian, index(row), column); if (isEqual(actual, expected, kTol)) @@ -2268,10 +2340,11 @@ namespace GridKit } #ifdef GRIDKIT_ENABLE_ENZYME - std::vector enzymeJacobian(const Data& data, - RealT alpha, - TestStatus& success, - RealT ilmax = 2.0) const + std::vector + enzymeJacobian(const Data& data, + RealT alpha, + TestStatus& success, + RealT ilmax = 2.0) const { Fixture fixture(data, kStateVr, kStateVi); fixture.attachAllInputs(); @@ -2290,8 +2363,9 @@ namespace GridKit return MapFromCsr(fixture.reecb.getCsrJacobian()); } - bool jacobiansMatch(const std::vector& dependency, - const std::vector& enzyme) const + bool jacobiansMatch( + const std::vector& dependency, + const std::vector& enzyme) const { if (dependency.size() != enzyme.size()) { diff --git a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp index 676466f7f..26d9cbd42 100644 --- a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp +++ b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp @@ -298,8 +298,7 @@ namespace GridKit return success.report(__func__); } - /// REECB through the production path, coupled to the REGCA model that - /// seeds its current-command outputs during system initialization. + /// REECB through the production data path, coupled to REGCA. TestOutcome reecb() { using Data = PhasorDynamics::Controller::ReecbData; @@ -358,24 +357,6 @@ namespace GridKit == static_cast(RegcaVars::MAXIMUM) + static_cast(Vars::MAXIMUM); - auto* iqcmd = system.getSignal(iqcmd_id); - auto* ipcmd = system.getSignal(ipcmd_id); - success *= iqcmd->linked(); - success *= ipcmd->linked(); - success *= iqcmd->getVariableIndex() - == system.getComponent(static_cast(1))->getVariableIndex(static_cast(Vars::IQCMD)); - success *= ipcmd->getVariableIndex() - == system.getComponent(static_cast(1))->getVariableIndex(static_cast(Vars::IPCMD)); - - auto missing_bus_data = data; - missing_bus_data.bus[0].bus_id = static_cast(0); - missing_bus_data.regca.clear(); - missing_bus_data.reecb[0].buses.clear(); - - PhasorDynamics::SystemModel missing_bus_system(missing_bus_data); - std::cout << "Testing expected REECB missing-bus configuration error.\n"; - success *= missing_bus_system.verify() > 0; - return success.report(__func__); } diff --git a/tests/UnitTests/PhasorDynamics/runControllerReecbTests.cpp b/tests/UnitTests/PhasorDynamics/runControllerReecbTests.cpp index 5aa0ba589..3750436a9 100644 --- a/tests/UnitTests/PhasorDynamics/runControllerReecbTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runControllerReecbTests.cpp @@ -14,6 +14,7 @@ int main() result += test.voltVarReferenceBase(); result += test.reactiveControl(); result += test.activeCurrentControl(); + result += test.dependencyTracking(); #ifdef GRIDKIT_ENABLE_ENZYME result += test.jacobian(); #endif From 8a2be865e59b2cfd3e95d975adc4359ab41124d3 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Thu, 6 Aug 2026 14:58:49 -0500 Subject: [PATCH 13/16] Cleanup and add some tests --- .../PhasorDynamics/Controller/REECB/README.md | 124 +++--- .../PhasorDynamics/Controller/REECB/Reecb.hpp | 28 +- .../Controller/REECB/ReecbImpl.hpp | 372 +++++++++++------ .../Model/PhasorDynamics/SystemModelImpl.hpp | 4 +- .../PhasorDynamics/ControllerReecbTests.hpp | 384 +++++++++++++----- .../SystemSingleComponentTests.hpp | 1 + 6 files changed, 590 insertions(+), 323 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Controller/REECB/README.md b/GridKit/Model/PhasorDynamics/Controller/REECB/README.md index 78d5f23b5..063146aec 100644 --- a/GridKit/Model/PhasorDynamics/Controller/REECB/README.md +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/README.md @@ -13,16 +13,14 @@ inverter-coupled resource. - In direct-voltage mode ($s_Q=1$, $s_V=0$) `qext` carries a terminal-voltage reference instead of a system-base reactive power. - REECB contributes no bus current injection. +- GridKit does not apply generator-level Governor Response Limits to `Pmin` or + `Pmax`. > [!WARNING] > GridKit does not yet inherit `mva` from the associated REGCA model. Set it > explicitly to the REGCA component base; omitting it falls back to the system > base and is correct only when those bases match.[^reecb-mva-base] -> [!WARNING] -> GridKit does not yet apply the associated generator's baseload response -> setting to REECB. The model always uses its configured `Pmin` and `Pmax`. - ## Block Diagram ![REECB electrical-control block diagram](../../../../../docs/Figures/PhasorDynamics/REECB/diagram.png) @@ -79,18 +77,17 @@ Invalid REECB parameter sets are rejected by the following checks: T_\mathrm{rv},T_\mathrm{p},T_\mathrm{iq},T_\mathrm{pord} &\ge 0 \\ V_\mathrm{dip} &< V_\mathrm{up} \\ D_1^\mathrm{db} &\le 0 \le D_2^\mathrm{db} \\ + K_\mathrm{qv},K_\mathrm{qp},K_\mathrm{qi},K_\mathrm{vp},K_\mathrm{vi} &\ge 0 \\ I_{q,\mathrm{inj}}^{\min} &\le I_{q,\mathrm{inj}}^{\max} \\ Q^{\min} &\le Q^{\max} \\ V^{\min} &\le V^{\max} \\ R_P^{\min} &< 0 < R_P^{\max} \\ P^{\min} &\le P^{\max} \\ - I^{\max} &> 0 \\ - s_\mathrm{pf}\,s_Q\,(1-s_V) &= 0. + I^{\max} &> 0. \end{aligned} ``` -The last condition rejects power-factor control combined with the -direct-voltage reference, which has no meaningful reactive target. +Enabling both `PfFlag` and `QFlag` logs an atypical-configuration warning. ### Model Derived Parameters @@ -150,7 +147,7 @@ $P^\mathrm{ord}$ | [p.u.] | Filtered active-power order | State 6 Symbol | Units | Description | Note ---------------------|--------|-----------------------------------------------|----- $V_T$ | [p.u.] | Terminal voltage magnitude | -$I_L^{\max}$ | [p.u.] | Current available to the low-priority command | Component base +$I_L^{\max}$ | [p.u.] | Current-circle continuation state | Component base $I_q^\mathrm{cmd}$ | [p.u.] | Reactive-current command output | System base $I_p^\mathrm{cmd}$ | [p.u.] | Active-current command output | System base @@ -188,8 +185,9 @@ For readability, define: e_V^\mathrm{PI} &= s_Q^\mathrm{PI}V_Q^\mathrm{PI}+s_V^\mathrm{ref}Q^\mathrm{ext}-s_QV^\mathrm{meas} \\ f_P^\mathrm{ord} &= \dfrac{1}{T_\mathrm{pord}}(k_\mathrm{base}P^\mathrm{ref}-P^\mathrm{ord}) \\ r_P^\mathrm{ord} &= \text{aslew}(f_P^\mathrm{ord};\,R_P^{\min},R_P^{\max}) \\ - I_q^{\max} &= s_\mathrm{pq}|I_L^{\max}|+s_\mathrm{pq}^\mathrm{off}I^{\max} \\ - I_p^{\max} &= s_\mathrm{pq}I^{\max}+s_\mathrm{pq}^\mathrm{off}|I_L^{\max}| \\ + N_L &= \sqrt{(I_L^{\max})^2+\epsilon_0},\qquad I_L^\mathrm{cap}=\dfrac{(I_L^{\max})^2}{N_L} \\ + I_q^{\max} &= s_\mathrm{pq}I_L^\mathrm{cap}+s_\mathrm{pq}^\mathrm{off}I^{\max} \\ + I_p^{\max} &= s_\mathrm{pq}I^{\max}+s_\mathrm{pq}^\mathrm{off}I_L^\mathrm{cap} \\ I_q^\mathrm{base} &= \text{clamp}(K_\mathrm{vp}e_V^\mathrm{PI}+x_V^\mathrm{PI};\,-I_q^{\max},I_q^{\max}) \\ I_q^\mathrm{raw} &= s_QI_q^\mathrm{base}+s_Q^\mathrm{off}Q_V+I_q^\mathrm{inj}. \end{aligned} @@ -217,17 +215,14 @@ these equations. [Appendix B](#appendix-b-aslew) defines `aslew`. ```math \begin{aligned} 0 &= -V_T^2+V_\mathrm{r}^2+V_\mathrm{i}^2 \\ - 0 &= -I_L^{\max}|I_L^{\max}|+(I^{\max})^2-s_\mathrm{pq}(k_\mathrm{base}I_p^\mathrm{cmd})^2-s_\mathrm{pq}^\mathrm{off}(k_\mathrm{base}I_q^\mathrm{cmd})^2 \\ + 0 &= -I_L^{\max}N_L+(I^{\max})^2-s_\mathrm{pq}(k_\mathrm{base}I_p^\mathrm{cmd})^2-s_\mathrm{pq}^\mathrm{off}(k_\mathrm{base}I_q^\mathrm{cmd})^2 \\ 0 &= -k_\mathrm{base}I_q^\mathrm{cmd}+\text{clamp}(I_q^\mathrm{raw};\,-I_q^{\max},I_q^{\max}) \\ 0 &= -k_\mathrm{base}I_p^\mathrm{cmd}+\text{clamp}\left(\dfrac{P^\mathrm{ord}}{V_\mathrm{safe}^\mathrm{meas}};\,0,I_p^{\max}\right). \end{aligned} ``` -The signed-square continuation selects the unique positive physical root. Its -magnitude keeps both limiter ranges ordered for negative nonlinear -iterates; positive-root residual values and Jacobians are unchanged. -Initialization excludes the zero-capacity point, where the magnitude derivative -is undefined. +Here $\epsilon_0=100\epsilon_\mathrm{machine}$ regularizes the `ILMAX` row at +zero remaining capacity. ## Initialization @@ -247,13 +242,9 @@ REECB reconstructs a steady operating point. Arbitrary-state restart is unsuppor ### Internal Initialization Initialization resolves the steady-state quantities in dependency order; all -internal derivatives start at zero. Let -$\epsilon_0=100\,\epsilon_\mathrm{machine}$ cover roundoff from smooth-clamp -inversions and base round trips, and let -$I_p=k_\mathrm{base}I_p^\mathrm{cmd}$ and -$I_q=k_\mathrm{base}I_q^\mathrm{cmd}$ be the component-base initial commands. -$\text{unclamp}(z;\ell,u)$ is the initialization-only inverse of the smooth -clamp for $\ell\epsilon_0 \\ + \arctan(Q^\mathrm{target}/P^\mathrm{meas}) & s_\mathrm{pf}=1\ \land\ P^\mathrm{meas}\ne0 \\ 0 & \text{otherwise} \end{cases} \\ Q^\mathrm{ext} &\leftarrow \begin{cases} V^\mathrm{meas} & s_V^\mathrm{ref}=1 \\ - P^\mathrm{meas}\tan(\phi^\mathrm{ref})/k_\mathrm{base} & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=1 \\ + 0 & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=1 \\ Q^\mathrm{target}/k_\mathrm{base} & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=0 \end{cases} \\ Q^\mathrm{ref} &\leftarrow s_Q^\mathrm{ref}(s_\mathrm{pf}P^\mathrm{meas}\tan(\phi^\mathrm{ref})+s_\mathrm{pf}^\mathrm{off}k_\mathrm{base}Q^\mathrm{ext}). \end{aligned} ``` -For $s_Q=0$, the selected reactive-reference path must reproduce the recovered -controller current: - -```math -\left|\dfrac{Q^\mathrm{ref}}{V_\mathrm{safe}^\mathrm{meas}}-I_q^\mathrm{ctrl}\right|\le\epsilon_0. -``` - ```math \begin{aligned} e_Q &\leftarrow \text{clamp}(Q^\mathrm{ref};\,Q^{\min},Q^{\max})-k_\mathrm{base}Q^\mathrm{gen} \\ x_Q^\mathrm{PI} &\leftarrow \begin{cases} - \text{unclamp}(V^\mathrm{meas};\,V^{\min},V^{\max})-K_\mathrm{qp}e_Q & s_Q s_V=1\ \land\ V^{\min}\epsilon_0 \\ 0 & s_Q=0 \end{cases} \\ Q_V &\leftarrow @@ -337,25 +322,8 @@ controller current: \end{aligned} ``` -Initialization rejects an operating point when any of the following holds: - -- the terminal-voltage magnitude or the current-limit radicand is not - positive; -- a current command leaves its strict interior, - $0\epsilon_0$ or - $|s_QK_\mathrm{vi}e_V^\mathrm{PI}|>\epsilon_0$; or -- any candidate quantity is nonfinite. - -The recovered order is retained unchanged to preserve the initial -active-current command, and collapsed Q or V bounds bypass the inverse while -still requiring the corresponding integral equilibrium. Every check resolves -before any storage is written, so a rejected initialization leaves state, -derivatives, latches, parameter storage, and attached signals unchanged. +Invalid or non-equilibrium operating points are rejected before any state, +limit, latch, or signal is changed. ### Output Initialization @@ -363,13 +331,13 @@ derivatives, latches, parameter storage, and attached signals unchanged. \begin{aligned} \phi^\mathrm{ref} &\leftarrow \begin{cases} - \arctan(Q^\mathrm{target}/P^\mathrm{meas}) & s_\mathrm{pf}=1\ \land\ |P^\mathrm{meas}|>\epsilon_0 \\ + \arctan(Q^\mathrm{target}/P^\mathrm{meas}) & s_\mathrm{pf}=1\ \land\ P^\mathrm{meas}\ne0 \\ 0 & \text{otherwise} \end{cases} \\ Q^\mathrm{ext} &\leftarrow \begin{cases} V^\mathrm{meas} & s_V^\mathrm{ref}=1 \\ - P^\mathrm{meas}\tan(\phi^\mathrm{ref})/k_\mathrm{base} & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=1 \\ + 0 & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=1 \\ Q^\mathrm{target}/k_\mathrm{base} & s_V^\mathrm{ref}=0\ \land\ s_\mathrm{pf}=0 \end{cases} \\ P^\mathrm{ref} &\leftarrow \dfrac{P^\mathrm{ord}}{k_\mathrm{base}} @@ -388,18 +356,34 @@ Output | Units | Description | Note `vmeas` | [p.u.] | Filtered terminal voltage | $V^\mathrm{meas}$ `pmeas` | [p.u.] | Filtered electrical power | $P^\mathrm{meas}$ (component base) -## Appendix A: `unclamp` +## Testing + +- `validation()` checks configuration and defaults. +- `initializationAndSignals()` checks initialization, signals, monitors, and power bases. +- `initializationDomain()` checks rejected inputs and limit expansion. +- `initializationExactness()` checks endpoint and current-circle initialization. +- `residualEquations()` checks the fixed residual answer key. +- `selectorConfigurations()` checks selectors and optional ports. +- `voltVarReferenceBase()` checks `qext` units. +- `reactiveControl()` checks the reactive-control paths. +- `activeCurrentControl()` checks active-current control and current priority. +- `dependencyTracking()` checks sparse dependencies. +- `jacobian()` compares the Enzyme and dependency-tracking Jacobians. +- `regcaReecb()`, `reecb()`, and `initializationFailure()` check system wiring. + +## Appendix A: `iclamp` For $\ell(100.0) * std::numeric_limits::epsilon(); @@ -124,6 +124,12 @@ namespace GridKit ScalarT* f); private: + /// Smooth asymmetric slew-rate limiter. + [[gnu::always_inline]] static inline ScalarT aslew(ScalarT rate, RealT lower, RealT upper); + + /// Smooth anti-windup derivative within a moving symmetric band. + [[gnu::always_inline]] static inline ScalarT awband(ScalarT state, ScalarT rate, ScalarT band); + static void checkConfiguration(bool condition, const char* message, int& errors); void loadRealParameter(const ModelDataT& data, ReecbParameters parameter, @@ -138,9 +144,9 @@ namespace GridKit void initializeMonitor(); void setDerivedParameters(); - RealT logOneMinusExp(RealT x) const; - RealT unclamp(RealT output, RealT lower, RealT upper) const; - RealT componentPowerBase() const; + static RealT logOneMinusExp(RealT x); + bool iclamp(RealT output, RealT lower, RealT upper, RealT& input) const; + RealT componentPowerBase() const; template [[gnu::always_inline]] inline ValueT toComponentBase(ValueT value) const; @@ -148,18 +154,6 @@ namespace GridKit template ValueT toSystemBase(ValueT value) const; - /// Smooth asymmetric slew-rate limiter. - [[gnu::always_inline]] static inline ScalarT aslew( - const ScalarT f, - const RealT rate_min, - const RealT rate_max); - - /// Smooth anti-windup derivative within a moving symmetric band. - [[gnu::always_inline]] static inline ScalarT awband( - const ScalarT x, - const ScalarT f, - const ScalarT band); - ScalarT& Vr(); ScalarT& Vi(); diff --git a/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp index 7ca2f7c70..92fb4716b 100644 --- a/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp @@ -8,9 +8,7 @@ #include #include -#include #include -#include #include #include @@ -190,7 +188,7 @@ namespace GridKit checkConfiguration(dbd1_ <= ZERO && ZERO <= dbd2_, "dbd1 <= 0 <= dbd2 is required", ret); } - checkConfiguration(std::isfinite(kqv_), "kqv must be finite", ret); + checkConfiguration(std::isfinite(kqv_) && kqv_ >= ZERO, "kqv must be finite and non-negative", ret); const bool finite_injection_limits = std::isfinite(Iql1_) && std::isfinite(Iqh1_); checkConfiguration(finite_injection_limits, "Iql1 and Iqh1 must be finite", ret); @@ -206,8 +204,8 @@ namespace GridKit checkConfiguration(Qmin_ <= Qmax_, "Qmin must be less than or equal to Qmax", ret); } - checkConfiguration(std::isfinite(Kqp_), "Kqp must be finite", ret); - checkConfiguration(std::isfinite(Kqi_), "Kqi must be finite", ret); + checkConfiguration(std::isfinite(Kqp_) && Kqp_ >= ZERO, "Kqp must be finite and non-negative", ret); + checkConfiguration(std::isfinite(Kqi_) && Kqi_ >= ZERO, "Kqi must be finite and non-negative", ret); const bool finite_voltage_limits = std::isfinite(Vmin_) && std::isfinite(Vmax_); checkConfiguration(finite_voltage_limits, "Vmin and Vmax must be finite", ret); @@ -216,8 +214,8 @@ namespace GridKit checkConfiguration(Vmin_ <= Vmax_, "Vmin must be less than or equal to Vmax", ret); } - checkConfiguration(std::isfinite(Kvp_), "Kvp must be finite", ret); - checkConfiguration(std::isfinite(Kvi_), "Kvi must be finite", ret); + checkConfiguration(std::isfinite(Kvp_) && Kvp_ >= ZERO, "Kvp must be finite and non-negative", ret); + checkConfiguration(std::isfinite(Kvi_) && Kvi_ >= ZERO, "Kvi must be finite and non-negative", ret); checkConfiguration(std::isfinite(Tiq_), "Tiq must be finite", ret); checkConfiguration(std::isfinite(Tpord_), "Tpord must be finite", ret); @@ -237,10 +235,6 @@ namespace GridKit checkConfiguration(std::isfinite(Imax_) && Imax_ > ZERO, "Imax must be finite and positive", ret); - checkConfiguration(!(PfFlag_ && QFlag_ && !VFlag_), - "power-factor control cannot drive the direct-voltage reference (PfFlag = 1 with QFlag = 1, VFlag = 0)", - ret); - auto check_optional_signal = [&](const char* name) { if (signals_.template isAttached() && !signals_.template isLinked()) @@ -263,10 +257,8 @@ namespace GridKit * @brief Initialize REECB from the initial current commands and feedback * * Preserves the system-base command states, consumes attached initialized - * active/reactive-power feedback or reconstructs unattached feedback, and - * constructs the remaining states and reference setpoints by forward - * evaluation of the residual expressions, so an admissible operating - * point starts at a steady state. + * power feedback or reconstructs unattached feedback, and constructs the + * remaining states and reference setpoints at a steady operating point. * * @pre allocate() has completed. * @pre verify() reports a valid parameter and port configuration. @@ -275,8 +267,7 @@ namespace GridKit * @post On failure no state, derivative, parameter, or signal storage is * modified. * - * @return 0 on success; nonzero when an allocation, configuration, - * initial-value, current-circle, or limiter-interior check fails. + * @return 0 on success; nonzero when allocation, configuration, initial-value, limiter inversion, or steady-state checks fail. */ template int Reecb::initialize() @@ -351,81 +342,127 @@ namespace GridKit return 1; } - // Mirrors of the residual limiter chain at the initial point. - const RealT verr0 = Math::deadband2(vref0 - vmeas0, dbd1_, dbd2_); - const RealT iqv0 = Math::clamp(kqv_ * verr0, Iql1_, Iqh1_); - const RealT ilmax_squared = Imax_ * Imax_ - pq_on_ * ipcmd0 * ipcmd0 - pq_off_ * iqcmd0 * iqcmd0; - - if (!std::isfinite(ilmax_squared) || ilmax_squared <= ZERO) + if (ipcmd0 < ZERO) { - Log::error() << "Reecb: initial operating point leaves no low-priority current capacity\n"; + Log::error() << "Reecb: initial active-current command must be non-negative\n"; return 1; } - const RealT ilmax0 = std::sqrt(ilmax_squared); - const RealT iqmax0 = pq_on_ * ilmax0 + pq_off_ * Imax_; - const RealT ipmax0 = pq_on_ * Imax_ + pq_off_ * ilmax0; + const RealT verr0 = Math::deadband2(vref0 - vmeas0, dbd1_, dbd2_); + const RealT iqv0 = Math::clamp(kqv_ * verr0, Iql1_, Iqh1_); + const RealT iqabs0 = std::abs(iqcmd0); + RealT iqneed0 = iqabs0; + if (QFlag_ && iqabs0 > ZERO) + { + iqneed0 += std::log(TWO) / Math::MU + INITIALIZATION_TOLERANCE; + } - if (ipcmd0 <= ZERO || ipcmd0 >= ipmax0 || iqcmd0 <= -iqmax0 || iqcmd0 >= iqmax0) + const RealT d = std::sqrt(INITIALIZATION_TOLERANCE); + const RealT high0 = pq_on_ * ipcmd0 + pq_off_ * iqabs0; + const RealT low0 = pq_on_ * iqneed0 + pq_off_ * ipcmd0; + RealT imax = std::max(Imax_, high0); + if (pq_off_ != ZERO) { - Log::error() << "Reecb: initial current commands must lie strictly inside their limiter ranges\n"; - return 1; + imax = std::max(imax, iqneed0); + } + if (low0 > ZERO) + { + const RealT ilreq = std::sqrt(low0) * std::sqrt(HALF * (low0 + std::hypot(low0, TWO * d))); + const RealT required = std::hypot(high0, std::sqrt(ilreq) * std::sqrt(std::hypot(ilreq, d))); + if (required >= imax) + { + imax = std::nextafter(required, std::numeric_limits::infinity()); + } } - // The algebraic command rows reproduce their limiter outputs through - // the smooth-clamp inverse. A command no input can produce leaves the - // inverse nonfinite, which the finiteness test below rejects. - const RealT ipraw0 = unclamp(ipcmd0, ZERO, ipmax0); - const RealT iqraw0 = unclamp(iqcmd0, -iqmax0, iqmax0); - const RealT iqctl0 = iqraw0 - iqv0; - const RealT pord0 = vmeas_safe0 * ipraw0; + RealT ilrhs0 = ZERO; + RealT ilmax0 = ZERO; + RealT ilnorm0 = ZERO; + RealT ilcap0 = ZERO; + for (int correction = 0; correction <= std::numeric_limits::digits && std::isfinite(imax); ++correction) + { + ilrhs0 = (imax - high0) * (imax + high0); + ilmax0 = ZERO; + if (ilrhs0 > ZERO) + { + const RealT ratio = INITIALIZATION_TOLERANCE / ilrhs0; + ilmax0 = std::sqrt(ilrhs0) * std::sqrt(TWO / (std::hypot(ratio, TWO) + ratio)); + } + ilnorm0 = std::sqrt(ilmax0 * ilmax0 + INITIALIZATION_TOLERANCE); + ilcap0 = (ilmax0 / ilnorm0) * ilmax0; + if (!(ilcap0 < low0)) + { + break; + } + const RealT next = std::nextafter(imax, std::numeric_limits::infinity()); + if (next == imax) + { + break; + } + imax = next; + } + if (ilcap0 < low0) + { + Log::error() << "Reecb: adjusted Imax cannot include the initial current commands\n"; + return 1; + } - const RealT pref0_system = toSystemBase(pord0); + const RealT iqmax0 = pq_on_ * ilcap0 + pq_off_ * imax; + const RealT ipmax0 = pq_on_ * imax + pq_off_ * ilcap0; - if (pord0 < Pmin_ || pord0 > Pmax_) + RealT ipraw0 = ZERO; + RealT iqraw0 = ZERO; + if (!iclamp(ipcmd0, ZERO, ipmax0, ipraw0) + || !iclamp(iqcmd0, -iqmax0, iqmax0, iqraw0)) { - Log::error() << "Reecb: recovered active-power order is outside Pmin/Pmax\n"; + Log::error() << "Reecb: initial current commands cannot be reproduced by their limiters\n"; return 1; } - // An integrating path holds its feedback only where the clamp can - // reproduce it: strictly inside the limits, or collapsed onto it. - auto reproducible = [](RealT value, RealT lower, RealT upper) + const RealT iqctl0 = iqraw0 - iqv0; + const RealT pord0 = vmeas_safe0 * ipraw0; + RealT qmin = q_pi_on_ != ZERO ? std::min(Qmin_, qgen0) : Qmin_; + RealT qmax = q_pi_on_ != ZERO ? std::max(Qmax_, qgen0) : Qmax_; + RealT vmin = q_pi_on_ != ZERO ? std::min(Vmin_, vmeas0) : Vmin_; + RealT vmax = q_pi_on_ != ZERO ? std::max(Vmax_, vmeas0) : Vmax_; + const RealT infinity = std::numeric_limits::infinity(); + if (q_pi_on_ != ZERO && qmin == qgen0 && qmin < qmax) { - return (lower < value && value < upper) || (lower == upper && value == lower); - }; - - if (q_pi_on_ * Kqi_ != ZERO && !reproducible(qgen0, Qmin_, Qmax_)) + qmin = std::nextafter(qmin, -infinity); + } + if (q_pi_on_ != ZERO && qmax == qgen0 && qmin < qmax) { - Log::error() << "Reecb: reactive-power integral path is not at equilibrium\n"; - return 1; + qmax = std::nextafter(qmax, infinity); } - if (q_pi_on_ * Kvi_ != ZERO && !reproducible(vmeas0, Vmin_, Vmax_)) + if (q_pi_on_ != ZERO && vmin == vmeas0 && vmin < vmax) { - Log::error() << "Reecb: voltage-control integral path is not at equilibrium\n"; - return 1; + vmin = std::nextafter(vmin, -infinity); } + if (q_pi_on_ != ZERO && vmax == vmeas0 && vmin < vmax) + { + vmax = std::nextafter(vmax, infinity); + } + const RealT pmin = std::min(Pmin_, pord0); + const RealT pmax = std::max(Pmax_, pord0); + const RealT pref0_system = toSystemBase(pord0); - // The reactive channel reproduces the power feedback when the reactive - // PI is enabled, and the reactive-current command otherwise. Collapsed - // limits already pin the clamp output onto the feedback. RealT qtarget0 = ZERO; if (!QFlag_) { qtarget0 = iqctl0 * vmeas_safe0; } - else if (VFlag_ && Qmin_ < qgen0 && qgen0 < Qmax_) + else if (VFlag_ && !iclamp(qgen0, qmin, qmax, qtarget0)) { - qtarget0 = unclamp(qgen0, Qmin_, Qmax_); + Log::error() << "Reecb: reactive-power limiter has no finite steady input\n"; + return 1; } RealT qref0 = ZERO; RealT qext0_port = ZERO; RealT pfaref0 = ZERO; - if (QFlag_ && !VFlag_) + if (v_ref_on_ != ZERO) { - // Direct-voltage mode publishes the measurement as its reference. qext0_port = vmeas0; } else if (PfFlag_) @@ -440,15 +477,11 @@ namespace GridKit pfaref0 = std::atan(qtarget0 / pmeas0); } qref0 = pmeas0 * std::tan(pfaref0); - - // Angle resolution collapses toward the tangent pole, so the - // published angle must still carry its own target back. if (std::abs(qref0 - qtarget0) > std::abs(qtarget0) * INITIALIZATION_TOLERANCE) { Log::error() << "Reecb: power-factor angle cannot reproduce the reactive target\n"; return 1; } - qext0_port = toSystemBase(qref0); } else { @@ -456,47 +489,91 @@ namespace GridKit qref0 = toComponentBase(qext0_port); } - const RealT eq0 = Math::clamp(qref0, Qmin_, Qmax_) - qgen0; - - // The Q-PI order carries the measurement the voltage channel - // subtracts; collapsed limits pin the clamp output there already. - RealT xpiq0 = ZERO; - if (QFlag_ && VFlag_ && Vmin_ < vmeas0 && vmeas0 < Vmax_) + const RealT eq0 = Math::clamp(qref0, qmin, qmax) - qgen0; + RealT xpiq0 = ZERO; + if (q_pi_on_ != ZERO) { - xpiq0 = unclamp(vmeas0, Vmin_, Vmax_) - Kqp_ * eq0; + RealT vpiq_input0 = ZERO; + if (!iclamp(vmeas0, vmin, vmax, vpiq_input0)) + { + Log::error() << "Reecb: voltage limiter has no finite steady input\n"; + return 1; + } + xpiq0 = vpiq_input0 - Kqp_ * eq0; } - const RealT vpiq0 = Math::clamp(Kqp_ * eq0 + xpiq0, Vmin_, Vmax_); + const RealT vpiq0 = Math::clamp(Kqp_ * eq0 + xpiq0, vmin, vmax); const RealT epiv0 = q_pi_on_ * vpiq0 + v_ref_on_ * qext0_port - q_on_ * vmeas0; - - RealT qv0 = ZERO; - RealT xpiv0 = ZERO; + RealT qv0 = ZERO; + RealT xpiv0 = ZERO; if (QFlag_) { - if (iqctl0 <= -iqmax0 || iqctl0 >= iqmax0) + if (iqmax0 <= INITIALIZATION_TOLERANCE) { - Log::error() << "Reecb: initial voltage-controller current is outside its limiter range\n"; - return 1; + xpiv0 = -Kvp_ * epiv0; + } + else + { + RealT iqctl_input0 = ZERO; + if (!iclamp(iqctl0, -iqmax0, iqmax0, iqctl_input0)) + { + Log::error() << "Reecb: voltage-controller current cannot be reproduced by its limiter\n"; + return 1; + } + xpiv0 = iqctl_input0 - Kvp_ * epiv0; } - xpiv0 = unclamp(iqctl0, -iqmax0, iqmax0) - Kvp_ * epiv0; } else { - // The lag state carries the same quotient the QV row forms. qv0 = qref0 / vmeas_safe0; } - if (!std::isfinite(verr0) || !std::isfinite(iqv0) || !std::isfinite(ilmax0) - || !std::isfinite(iqmax0) || !std::isfinite(ipmax0) || !std::isfinite(ipraw0) - || !std::isfinite(iqraw0) || !std::isfinite(pord0) || !std::isfinite(pref0_system) - || !std::isfinite(qref0) || !std::isfinite(qext0_port) || !std::isfinite(pfaref0) - || !std::isfinite(eq0) || !std::isfinite(xpiq0) || !std::isfinite(epiv0) - || !std::isfinite(xpiv0) || !std::isfinite(qv0)) + const RealT sdip0 = Math::inside(vt0, Vdip_, Vup_); + const RealT qrate0 = q_pi_on_ * sdip0 * Math::antiwindup(Kqp_ * eq0 + xpiq0, Kqi_ * eq0, vmin, vmax); + const ScalarT vstate0{Kvp_ * epiv0 + xpiv0}; + const ScalarT vderiv0{Kvi_ * epiv0}; + const RealT vrate0 = q_on_ * sdip0 * static_cast(awband(vstate0, vderiv0, ScalarT{iqmax0})); + const RealT iqbase0 = Math::clamp(Kvp_ * epiv0 + xpiv0, -iqmax0, iqmax0); + const RealT iqcmd_check = Math::clamp(q_on_ * iqbase0 + q_off_ * qv0 + iqv0, -iqmax0, iqmax0); + const RealT ipcmd_check = Math::clamp(pord0 / vmeas_safe0, ZERO, ipmax0); + + if (!std::isfinite(imax) || !std::isfinite(ilrhs0) || ilrhs0 < ZERO || !std::isfinite(ilmax0) + || !std::isfinite(ilcap0) || !std::isfinite(iqmax0) || !std::isfinite(ipmax0) + || !std::isfinite(ipraw0) || !std::isfinite(iqraw0) || !std::isfinite(pord0) + || !std::isfinite(pref0_system) || !std::isfinite(qtarget0) || !std::isfinite(qref0) + || !std::isfinite(qext0_port) || !std::isfinite(pfaref0) || !std::isfinite(eq0) + || !std::isfinite(xpiq0) || !std::isfinite(epiv0) || !std::isfinite(xpiv0) + || !std::isfinite(qv0) || !std::isfinite(qrate0) || !std::isfinite(vrate0) + || !std::isfinite(iqcmd_check) || !std::isfinite(ipcmd_check)) { Log::error() << "Reecb: initialization produced a nonfinite value\n"; return 1; } + if (std::abs(qrate0) > INITIALIZATION_TOLERANCE || std::abs(vrate0) > INITIALIZATION_TOLERANCE) + { + Log::error() << "Reecb: controller state rate is nonzero at initialization\n"; + return 1; + } + if (std::abs(iqcmd_check - iqcmd0) > INITIALIZATION_TOLERANCE + || std::abs(ipcmd_check - ipcmd0) > INITIALIZATION_TOLERANCE) + { + Log::error() << "Reecb: current-command limiter reconstruction is inexact\n"; + return 1; + } + + const bool q_adjusted = qmin != Qmin_ || qmax != Qmax_; + const bool v_adjusted = vmin != Vmin_ || vmax != Vmax_; + const bool p_adjusted = pmin != Pmin_ || pmax != Pmax_; + const bool imax_adjusted = imax != Imax_; + + Qmin_ = qmin; + Qmax_ = qmax; + Vmin_ = vmin; + Vmax_ = vmax; + Pmin_ = pmin; + Pmax_ = pmax; + Imax_ = imax; y[VMEAS] = vmeas0; y[PMEAS] = pmeas0; @@ -531,6 +608,23 @@ namespace GridKit signals_.template writeExternalVariable(pref_set_); } + if (q_adjusted) + { + Log::warning() << "Reecb: Qmin/Qmax adjusted to include the initial reactive power\n"; + } + if (v_adjusted) + { + Log::warning() << "Reecb: Vmin/Vmax adjusted to include the initial terminal voltage\n"; + } + if (p_adjusted) + { + Log::warning() << "Reecb: Pmin/Pmax adjusted to include the initial active-power order\n"; + } + if (imax_adjusted) + { + Log::warning() << "Reecb: Imax adjusted to include the initial current commands\n"; + } + y_.setDataUpdated(); yp_.setToConst(static_cast(ZERO)); return 0; @@ -669,17 +763,14 @@ namespace GridKit * * The branch-free equation body preserves a fixed dependency structure; * parameter-selected paths enter through selector masks resolved by - * setDerivedParameters(). The `ILMAX` row uses a signed-square - * continuation, while its magnitude supplies the limiter bounds, so a - * negative nonlinear iterate does not invert either range. + * setDerivedParameters(). The `ILMAX` row uses a smooth signed-square + * continuation, while a smooth magnitude supplies the limiter bounds. * * @param[in] y Internal variables. * @param[in] yp Internal variable derivatives. * @param[in] wb Terminal-bus voltage components. * @param[in] ws External signal values in their documented port units and bases. * @param[out] f Internal residuals. - * @pre Jacobian evaluation requires `y[ILMAX] != 0`; initialization - * rejects the zero-capacity point. */ template [[gnu::always_inline]] inline int @@ -749,7 +840,8 @@ namespace GridKit const ScalarT epiv = q_pi_on_ * vpiq + v_ref_on_ * extref - q_on_ * vmeas; const ScalarT fpord = (pref - pord) / Tpord_; const ScalarT rpord = aslew(fpord, dPmin_, dPmax_); - const ScalarT ilcap = std::sqrt(ilmax * ilmax); + const ScalarT ilnorm = std::sqrt(ilmax * ilmax + INITIALIZATION_TOLERANCE); + const ScalarT ilcap = (ilmax / ilnorm) * ilmax; const ScalarT iqmax = pq_on_ * ilcap + pq_off_ * Imax_; const ScalarT ipmax = pq_on_ * Imax_ + pq_off_ * ilcap; const ScalarT iqbase = Math::clamp(Kvp_ * epiv + xpiv, -iqmax, iqmax); @@ -762,7 +854,8 @@ namespace GridKit f[QV] = -qv_dot + q_off_ * sdip * (qref / vmeas_safe - qv) / Tiq_; f[PORD] = -pord_dot + sdip * Math::antiwindup(pord, rpord, Pmin_, Pmax_); f[VT] = -vt * vt + vr * vr + vi * vi; - f[ILMAX] = -ilmax * ilcap + Imax_ * Imax_ - pq_on_ * ipcmd * ipcmd - pq_off_ * iqcmd * iqcmd; + f[ILMAX] = -ilmax * ilnorm + pq_on_ * (Imax_ - ipcmd) * (Imax_ + ipcmd) + + pq_off_ * (Imax_ - iqcmd) * (Imax_ + iqcmd); f[IQCMD] = -iqcmd + Math::clamp(iqraw, -iqmax, iqmax); f[IPCMD] = -ipcmd + Math::clamp(pord / vmeas_safe, ZERO, ipmax); @@ -776,24 +869,18 @@ namespace GridKit /** * @brief Smooth asymmetric slew-rate limiter * - * @param[in] f Unconstrained rate. - * @param[in] rate_min Negative rate limit. - * @param[in] rate_max Positive rate limit. + * @param[in] rate Unconstrained rate. + * @param[in] lower Negative rate limit. + * @param[in] upper Positive rate limit. * @return Limited rate. */ template [[gnu::always_inline]] inline scalar_type - Reecb::aslew( - const ScalarT f, - const RealT rate_min, - const RealT rate_max) + Reecb::aslew(ScalarT rate, RealT lower, RealT upper) { - assert(rate_min < ZERO && ZERO < rate_max); - - return f - / (ONE - + Math::ramp(f / rate_max - ONE) - + Math::ramp(f / rate_min - ONE)); + assert(lower < ZERO && ZERO < upper); + return rate + / (ONE + Math::ramp(rate / upper - ONE) + Math::ramp(rate / lower - ONE)); } /** @@ -803,8 +890,8 @@ namespace GridKit * algebraic quantity, so differentiation carries the band's own * contributions through the gate. * - * @param[in] x Limited PI state. - * @param[in] f Pre-limit derivative of x. + * @param[in] state Limited PI state. + * @param[in] rate Pre-limit derivative of state. * @param[in] band Nonnegative symmetric band edge. * @return Anti-windup-limited derivative. * @@ -812,18 +899,13 @@ namespace GridKit */ template [[gnu::always_inline]] inline scalar_type - Reecb::awband( - const ScalarT x, - const ScalarT f, - const ScalarT band) + Reecb::awband(ScalarT state, ScalarT rate, ScalarT band) { - const ScalarT above_min = Math::sigmoid(x + band); - const ScalarT below_max = Math::sigmoid(band - x); - - return (above_min * below_max // - + (ONE - below_max) * Math::sigmoid(-f) // - + (ONE - above_min) * Math::sigmoid(f)) - * f; + const ScalarT above_min = Math::above(state, -band); + const ScalarT below_max = Math::below(state, band); + return (above_min * below_max + (ONE - below_max) * Math::sigmoid(-rate) + + (ONE - above_min) * Math::sigmoid(rate)) + * rate; } /** @@ -1053,6 +1135,12 @@ namespace GridKit va_component_base_ = mva_base_ * static_cast(1.0e6); + if (PfFlag_ && QFlag_) + { + Log::warning() << "Reecb: PfFlag and QFlag are both enabled; " + << "this is an atypical control configuration\n"; + } + pf_on_ = ZERO; if (PfFlag_) { @@ -1103,9 +1191,9 @@ namespace GridKit */ template typename Reecb::RealT - Reecb::logOneMinusExp(RealT x) const + Reecb::logOneMinusExp(RealT x) { - static constexpr RealT log_two = std::numbers::ln2_v; + static const RealT log_two = std::log(TWO); if (x < log_two) { @@ -1117,22 +1205,50 @@ namespace GridKit /** * @brief Recover the input that produces a requested smooth-clamp output * + * Exact bounds use a finite offset derived from the initialization + * tolerance; collapsed bounds are reproduced directly. + * * @param[in] output Requested output. * @param[in] lower Lower smooth-clamp limit. * @param[in] upper Upper smooth-clamp limit. - * @return Inverse of `Math::clamp`, or a nonfinite value when no input - * produces `output`. - * @pre `lower <= upper`. + * @param[out] input Recovered clamp input on success. + * @return true when a finite admissible input was recovered. * @warning This function contains conditional branching and as such can * be used in initialization methods but not in residual evaluation. */ template - typename Reecb::RealT - Reecb::unclamp(RealT output, RealT lower, RealT upper) const + bool Reecb::iclamp(RealT output, RealT lower, RealT upper, RealT& input) const { - const RealT a = Math::MU * (output - lower); - const RealT b = Math::MU * (upper - output); - return lower + (a + logOneMinusExp(a) - logOneMinusExp(b)) / Math::MU; + if (!std::isfinite(output) || !std::isfinite(lower) || !std::isfinite(upper) || lower > upper + || output < lower - INITIALIZATION_TOLERANCE || output > upper + INITIALIZATION_TOLERANCE) + { + return false; + } + + output = std::clamp(output, lower, upper); + if (upper == lower) + { + input = lower; + return true; + } + + const RealT mu = Math::MU; + const RealT offset = -std::log(std::expm1(mu * HALF * INITIALIZATION_TOLERANCE)) / mu; + if (output == lower) + { + input = lower - offset; + return true; + } + if (output == upper) + { + input = upper + offset; + return true; + } + + const RealT a = mu * (output - lower); + const RealT b = mu * (upper - output); + input = lower + (a + logOneMinusExp(a) - logOneMinusExp(b)) / mu; + return std::isfinite(input); } /** diff --git a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp index 34267651a..ed204850c 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp @@ -715,8 +715,8 @@ namespace GridKit * * @note System model composition is flat; nested systems are not supported. * - * @throws std::runtime_error if storage allocation, child binding, or - * model verification fails. + * @throws std::runtime_error if storage allocation, child binding, model + * verification, or sparse initialization fails. */ template int SystemModel::allocate() diff --git a/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp b/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp index 7fa352ed6..54a88761d 100644 --- a/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp @@ -1,12 +1,10 @@ #pragma once #include -#include #include #include #include #include -#include #include #include #include @@ -158,6 +156,18 @@ namespace GridKit success *= invalidParameterCase(Params::Pmin, 3.0); success *= invalidParameterCase(Params::Imax, 0.0); + const std::array nonnegative_gains{{ + Params::kqv, + Params::Kqp, + Params::Kqi, + Params::Kvp, + Params::Kvi, + }}; + for (const Params gain : nonnegative_gains) + { + success *= invalidParameterCase(gain, -0.1); + } + const std::array flag_parameters{{ Params::PfFlag, Params::VFlag, @@ -299,7 +309,7 @@ namespace GridKit // the same commands land on a different measured power. auto system_base_data = makeData(); system_base_data.parameters.erase(Params::mva); - Fixture system_base(system_base_data, 1.0, 0.0, kSystemBaseVa); + Fixture system_base(system_base_data, 1.0, 0.0, static_cast(50.0e6)); system_base.attachAllInputs(); system_base.input(Ext::PE) = 0.75; success *= system_base.initialize(kInitialIqcmd, 1.5); @@ -314,72 +324,122 @@ namespace GridKit return success.report(__func__); } - /// Check initialization rejection, atomicity, and the admissible points - /// next to each rejected one. + /// Check adjusted limits, initialization rejection, and atomicity. TestOutcome initializationDomain() { TestStatus success = true; - noteExpectedLogs("Testing inadmissible REECB initialization points. " - "Logged errors are expected."); + noteExpectedLogs("Testing adjusted REECB limits and inadmissible initialization points. " + "Logged warnings and errors are expected."); const auto data = makeData(); - // The active-current command must stay strictly inside its limiter, - // and the current circle must leave low-priority capacity. - success *= initializationRejectedAtomically(data, 0.75, 0.0, "zero active-current command"); - success *= initializationRejectedAtomically(data, 0.75, 1.25, "active-current command at its limit"); - success *= initializationRejectedAtomically(data, 0.75, 1.5, "active-current command beyond the current circle"); - - // The reactive-current command endpoints are the low-priority limit. - success *= initializationRejectedAtomically(data, 1.0, 0.75, "reactive-current command at its limit"); - success *= initializationRejectedAtomically(data, -1.0, 0.75, "negative reactive-current command at its limit"); - - auto q_priority = data; - q_priority.parameters[Params::Pqflag] = false; - success *= initializationRejectedAtomically(q_priority, 0.75, 1.0, "Q-priority active-current command at its limit"); - - auto pord_above = data; - pord_above.parameters[Params::Pmax] = 1.0; - success *= initializationRejectedAtomically(pord_above, 0.75, 0.75, "recovered active-power order above Pmax"); - - auto pord_below = data; - pord_below.parameters[Params::Pmin] = 2.0; - success *= initializationRejectedAtomically(pord_below, 0.75, 0.75, "recovered active-power order below Pmin"); - - // The reactive-power integrator cannot hold a command outside the - // reactive-power limits. - auto reactive_pi = data; - reactive_pi.parameters[Params::QFlag] = true; - reactive_pi.parameters[Params::VFlag] = true; - reactive_pi.parameters[Params::Kqi] = 0.4; - success *= initializationRejectedAtomically(reactive_pi, 0.75, 0.75, "reactive feedback at Qmax", 0.75, 1.0); - success *= initializationRejectedAtomically(reactive_pi, 0.75, 0.75, "reactive feedback at Qmin", 0.75, -1.0); - - // The voltage-control integrator cannot hold a measured voltage the - // saturated Q-PI output does not reproduce. - auto voltage_pi = reactive_pi; - voltage_pi.parameters[Params::Kqi] = 0.0; - voltage_pi.parameters[Params::Kvi] = 0.5; - success *= initializationRejectedAtomically(voltage_pi, 0.75, 0.75, "measured voltage above Vmax", 0.96, 0.8, 1.6); - - // On a voltage limit the smooth Q-PI output only approaches the - // measurement, so an integrating voltage path has no equilibrium - // there. Collapsed limits pin it exactly and are admitted below. - success *= initializationRejectedAtomically(voltage_pi, 0.75, 0.75, "measured voltage at Vmin", 0.3, 0.3, 0.5); - - // Power-factor control needs a representable angle, so a vanishing or - // near-vanishing active power is rejected. - auto power_factor = data; - power_factor.parameters[Params::PfFlag] = true; - success *= initializationRejectedAtomically(power_factor, 0.75, 0.75, "power-factor target at zero active power", 0.0, 0.75); - success *= initializationRejectedAtomically(power_factor, 0.75, 0.75, "unrepresentable power-factor reference", 1.0e-8, 0.75); + success *= initializationRejectedAtomically(data, 0.75, -0.1, "negative active-current command"); + success *= initializationRejectedAtomically(data, 0.75, std::numeric_limits::infinity(), "nonfinite active-current command"); + success *= initializationRejectedAtomically( + data, std::numeric_limits::infinity(), 0.75, "nonfinite reactive-current command"); + + auto pord_above = data; + pord_above.parameters[Params::Pmax] = 1.0; + Fixture adjusted_pmax(pord_above); + success *= adjusted_pmax.initialize(0.75, 0.75); + success *= (adjusted_pmax.evaluate() == 0); + success *= stateMatches(adjusted_pmax.reecb, {{Vars::PORD, 1.5}}, "adjusted Pmax"); + success *= allResidualsWithinInitTolerance(adjusted_pmax.reecb); + setState(adjusted_pmax.reecb, {{Vars::PORD, 1.25}}); + success *= (adjusted_pmax.evaluate() == 0); + success *= residualsMatch(adjusted_pmax.reecb, {{Vars::PORD, 1.0}}, "adjusted Pmax"); + + auto pord_below = data; + pord_below.parameters[Params::Pmin] = 2.0; + Fixture adjusted_pmin(pord_below); + success *= adjusted_pmin.initialize(0.75, 0.75); + success *= (adjusted_pmin.evaluate() == 0); + success *= stateMatches(adjusted_pmin.reecb, {{Vars::PORD, 1.5}}, "adjusted Pmin"); + success *= allResidualsWithinInitTolerance(adjusted_pmin.reecb); + setState(adjusted_pmin.reecb, {{Vars::PORD, 1.75}}); + success *= (adjusted_pmin.evaluate() == 0); + success *= residualsMatch(adjusted_pmin.reecb, {{Vars::PORD, -1.0}}, "adjusted Pmin"); + + auto expanded_current = data; + expanded_current.parameters[Params::Imax] = 1.0; + Fixture adjusted_imax(expanded_current); + success *= adjusted_imax.initialize(0.75, 0.75); + success *= (adjusted_imax.evaluate() == 0); + success *= stateMatches(adjusted_imax.reecb, {{Vars::ILMAX, 1.5}}, "adjusted Imax"); + success *= allResidualsWithinInitTolerance(adjusted_imax.reecb); + + auto reactive_pi = data; + reactive_pi.parameters[Params::QFlag] = true; + reactive_pi.parameters[Params::VFlag] = true; + reactive_pi.parameters[Params::Kqi] = 5.0; + const std::array q_limits{{-1.25, 1.25}}; + for (const RealT qgen : q_limits) + { + Fixture adjusted_q(reactive_pi); + adjusted_q.attachAllInputs(); + adjusted_q.input(Ext::PE) = 0.75; + adjusted_q.input(Ext::QGEN) = qgen; + success *= adjusted_q.initialize(0.75, 0.75); + success *= (adjusted_q.evaluate() == 0); + success *= allResidualsWithinInitTolerance(adjusted_q.reecb); + } + + auto voltage_pi = reactive_pi; + voltage_pi.parameters[Params::Kqi] = 0.0; + voltage_pi.parameters[Params::Kvi] = 5.0; + + struct VoltageLimitCase + { + RealT voltage; + RealT pe; + RealT qgen; + }; + + const std::array voltage_limits{{ + {0.3, 0.3, 0.3}, + {1.6, 0.96, 0.8}, + }}; + for (const auto& test_case : voltage_limits) + { + Fixture adjusted_v(voltage_pi, test_case.voltage); + adjusted_v.attachAllInputs(); + adjusted_v.input(Ext::PE) = test_case.pe; + adjusted_v.input(Ext::QGEN) = test_case.qgen; + success *= adjusted_v.initialize(0.75, 0.75); + success *= (adjusted_v.evaluate() == 0); + success *= allResidualsWithinInitTolerance(adjusted_v.reecb); + } + + // Power-factor control needs a representable angle. + auto power_factor = data; + power_factor.parameters[Params::PfFlag] = true; + + auto late_data = power_factor; + late_data.parameters[Params::Pmax] = 1.0; + Fixture late(late_data); + late.attachAllInputs(); + late.input(Ext::PE) = 0.0; + success *= late.prepare(0.75, 0.75); + if (late.reecb.initialize() == 0) + { + std::cout << "Expected REECB initialization rejection after Pmax adjustment\n"; + success = false; + } + late.input(Ext::PREF) = 0.75; + setState(late.reecb, {{Vars::PORD, 1.25}, {Vars::VT, 1.0}}); + setDerivative(late.reecb, {{Vars::PORD, 0.0}}); + success *= (late.evaluate() == 0); + success *= residualsMatch(late.reecb, {{Vars::PORD, 0.0}}, "rejected Pmax adjustment"); + success *= initializationRejectedAtomically( + power_factor, 0.75, 0.75, "unrepresentable power-factor reference", 1.0e-8, 0.75); success *= initializationRejectedAtomically(data, 0.75, 0.75, "zero terminal voltage", 0.75, 0.75, 0.0); - success *= initializationRejectedAtomically(data, 0.75, 0.75, "nonfinite active-power feedback", std::numeric_limits::infinity(), 0.75); + success *= initializationRejectedAtomically( + data, 0.75, 0.75, "nonfinite active-power feedback", std::numeric_limits::infinity(), 0.75); - // Collapsed reactive and voltage limits admit only the equilibrium - // they pin, and reject every other operating point. + // Collapsed limits remain pinned at their initial output or expand to + // include it. auto collapsed = reactive_pi; collapsed.parameters[Params::Kvi] = 0.5; collapsed.parameters[Params::Qmin] = 1.2; @@ -394,19 +454,31 @@ namespace GridKit success *= (collapsed_limits.evaluate() == 0); success *= allResidualsWithinInitTolerance(collapsed_limits.reecb); - auto collapsed_reactive = collapsed; - collapsed_reactive.parameters[Params::Vmin] = 0.5; - collapsed_reactive.parameters[Params::Vmax] = 1.5; - collapsed_reactive.parameters[Params::Kvi] = 0.0; - success *= initializationRejectedAtomically(collapsed_reactive, 0.75, 0.75, "collapsed reactive limit away from equilibrium", 0.75, 0.3); - - auto collapsed_voltage = collapsed; - collapsed_voltage.parameters[Params::Qmin] = -2.0; - collapsed_voltage.parameters[Params::Qmax] = 2.0; - collapsed_voltage.parameters[Params::Kqi] = 0.0; - collapsed_voltage.parameters[Params::Vmin] = 1.4; - collapsed_voltage.parameters[Params::Vmax] = 1.4; - success *= initializationRejectedAtomically(collapsed_voltage, 0.75, 0.75, "collapsed voltage limit away from equilibrium", 0.75, 0.75); + auto collapsed_reactive = collapsed; + collapsed_reactive.parameters[Params::Vmin] = 0.5; + collapsed_reactive.parameters[Params::Vmax] = 1.5; + collapsed_reactive.parameters[Params::Kvi] = 0.0; + Fixture expanded_reactive(collapsed_reactive); + expanded_reactive.attachAllInputs(); + expanded_reactive.input(Ext::PE) = 0.75; + expanded_reactive.input(Ext::QGEN) = 0.3; + success *= expanded_reactive.initialize(0.75, 0.75); + success *= (expanded_reactive.evaluate() == 0); + success *= allResidualsWithinInitTolerance(expanded_reactive.reecb); + + auto collapsed_voltage = collapsed; + collapsed_voltage.parameters[Params::Qmin] = -2.0; + collapsed_voltage.parameters[Params::Qmax] = 2.0; + collapsed_voltage.parameters[Params::Kqi] = 0.0; + collapsed_voltage.parameters[Params::Vmin] = 1.4; + collapsed_voltage.parameters[Params::Vmax] = 1.4; + Fixture expanded_voltage(collapsed_voltage); + expanded_voltage.attachAllInputs(); + expanded_voltage.input(Ext::PE) = 0.75; + expanded_voltage.input(Ext::QGEN) = 0.75; + success *= expanded_voltage.initialize(0.75, 0.75); + success *= (expanded_voltage.evaluate() == 0); + success *= allResidualsWithinInitTolerance(expanded_voltage.reecb); // Zero integral gains leave both controllers unconstrained, so any // reactive feedback initializes. @@ -441,8 +513,8 @@ namespace GridKit return success.report(__func__); } - /// The private smooth-limiter inverse reproduces every requested command, - /// including commands pressed against a limit. + /// The smooth-limiter inverse reproduces interior and boundary commands, + /// including points that expand the current circle. TestOutcome initializationExactness() { TestStatus success = true; @@ -537,6 +609,94 @@ namespace GridKit success *= allResidualsWithinInitTolerance(reactive.reecb); } + struct BoundaryCase + { + bool p_priority; + RealT iqcmd; + RealT ipcmd; + RealT ilmax; + const char* label; + }; + + const std::array boundary_cases{{ + {true, 0.75, 0.0, 2.5, "zero active-current command"}, + {true, 0.0, 1.25, 0.0, "zero reactive-current capacity"}, + {true, 1.0, 0.75, 2.0, "upper reactive-current command"}, + {true, -1.0, 0.75, 2.0, "lower reactive-current command"}, + {false, 0.75, 1.0, 2.0, "upper active-current command"}, + {false, 1.25, 0.0, 0.0, "zero active-current capacity"}, + {true, 0.75, 1.5, 1.5, "expanded current circle"}, + }}; + for (const auto& test_case : boundary_cases) + { + auto boundary_data = exactness_data; + boundary_data.parameters[Params::Pqflag] = test_case.p_priority; + Fixture boundary(boundary_data); + success *= boundary.initialize(test_case.iqcmd, test_case.ipcmd); + success *= (boundary.evaluate() == 0); + success *= scalarPreserved(boundary.iqcmd(), test_case.iqcmd, test_case.label); + success *= scalarPreserved(boundary.ipcmd(), test_case.ipcmd, test_case.label); + success *= stateMatches(boundary.reecb, {{Vars::ILMAX, test_case.ilmax}}, test_case.label); + success *= allResidualsWithinInitTolerance(boundary.reecb); + } + + auto separated_data = exactness_data; + separated_data.parameters[Params::Imax] = 1.0; + Fixture separated(separated_data); + success *= separated.initialize(5.0e-13, 0.5); + success *= (separated.evaluate() == 0); + success *= scalarPreserved(separated.iqcmd(), 5.0e-13, "scale-separated current command"); + success *= allResidualsWithinInitTolerance(separated.reecb); + + auto capacity_data = exactness_data; + capacity_data.parameters[Params::mva] = 100.0; + capacity_data.parameters[Params::Pqflag] = true; + capacity_data.parameters[Params::Imax] = 0.1; + Fixture capacity(capacity_data); + success *= capacity.initialize(1.7, 0.3); + success *= (capacity.evaluate() == 0); + success *= scalarPreserved(capacity.iqcmd(), 1.7, "strict low-priority command"); + success *= scalarPreserved(capacity.ipcmd(), 0.3, "strict high-priority command"); + const RealT ilmax = static_cast(capacity.reecb.y().getData()[index(Vars::ILMAX)]); + const RealT ilcap = ilmax * ilmax / std::sqrt(ilmax * ilmax + ReecbT::INITIALIZATION_TOLERANCE); + if (ilcap < 1.7) + { + std::cout << "REECB low-priority capacity does not include its initial command\n"; + success = false; + } + success *= allResidualsWithinInitTolerance(capacity.reecb); + + auto nested_data = exactness_data; + nested_data.parameters[Params::mva] = 100.0; + nested_data.parameters[Params::Pqflag] = true; + nested_data.parameters[Params::QFlag] = true; + nested_data.parameters[Params::VFlag] = false; + nested_data.parameters[Params::Imax] = 1.0; + Fixture nested(nested_data); + nested.attachAllInputs(); + nested.input(Ext::PE) = 0.6; + nested.input(Ext::QGEN) = 0.8; + success *= nested.initialize(0.8, 0.6); + success *= (nested.evaluate() == 0); + success *= scalarPreserved(nested.iqcmd(), 0.8, "nested-clamp reactive command"); + success *= scalarPreserved(nested.ipcmd(), 0.6, "nested-clamp active command"); + success *= allResidualsWithinInitTolerance(nested.reecb); + + auto exhausted_data = exactness_data; + exhausted_data.parameters[Params::QFlag] = true; + exhausted_data.parameters[Params::kqv] = 1.0; + exhausted_data.parameters[Params::Iql1] = -0.4; + exhausted_data.parameters[Params::Iqh1] = 1.2; + exhausted_data.parameters[Params::Vref0] = 2.2; + Fixture exhausted(exhausted_data); + exhausted.attachAllInputs(); + exhausted.input(Ext::PE) = 1.25; + success *= exhausted.initialize(0.0, 1.25); + success *= (exhausted.evaluate() == 0); + success *= scalarPreserved(exhausted.iqcmd(), 0.0, "exhausted reactive-current capacity"); + success *= stateMatches(exhausted.reecb, {{Vars::ILMAX, 0.0}}, "injection does not expand current circle"); + success *= allResidualsWithinInitTolerance(exhausted.reecb); + return success.report(__func__); } @@ -584,15 +744,14 @@ namespace GridKit return success.report(__func__); } - /// Every valid selector combination initializes attached and unattached - /// signals to a zero-residual state; power-factor control with the - /// direct-voltage reference is rejected. + /// Every selector combination initializes attached and unattached signals + /// to a zero-residual state. TestOutcome selectorConfigurations() { TestStatus success = true; noteExpectedLogs("Testing REECB selector configurations. " - "Rejection of the power-factor direct-voltage combinations is expected."); + "Atypical PfFlag/QFlag warnings are expected."); const std::array selector_values{{false, true}}; for (const bool pf : selector_values) @@ -621,13 +780,6 @@ namespace GridKit fixture.input(Ext::QGEN) = 0.75; } - if (pf && reactive && !voltage) - { - success *= (fixture.reecb.verify() > 0); - success *= !fixture.initialize(0.75, 0.75); - continue; - } - success *= fixture.initialize(0.75, 0.75); success *= (fixture.evaluate() == 0); success *= allResidualsWithinInitTolerance(fixture.reecb); @@ -654,8 +806,8 @@ namespace GridKit { success *= scalarPreserved(fixture.input(Ext::PE), 0.75, "selector pe"); success *= scalarPreserved(fixture.input(Ext::QGEN), 0.75, "selector qgen"); - const RealT expected_qext = reactive && !voltage ? 1.0 : 0.75; - const RealT expected_pfaref = pf ? kUnitSlopeAngle : 0.0; + const RealT expected_qext = reactive && !voltage ? 1.0 : (pf ? 0.0 : 0.75); + const RealT expected_pfaref = pf && (!reactive || voltage) ? kUnitSlopeAngle : 0.0; success *= scalarMatches(fixture.input(Ext::QEXT), expected_qext, "published qext"); success *= scalarMatches(fixture.input(Ext::PFAREF), expected_pfaref, "published pfaref"); success *= scalarMatches(fixture.input(Ext::PREF), 0.75, "published pref"); @@ -1176,11 +1328,6 @@ namespace GridKit { for (const bool p_priority : selector_values) { - if (pf && reactive && !voltage) - { - continue; - } - auto data = makeJacobianData(); data.parameters[Params::PfFlag] = pf; data.parameters[Params::VFlag] = voltage; @@ -1208,7 +1355,8 @@ namespace GridKit success *= derivativeMatches(dependency, Vars::IPCMD, Vars::VMEAS, -0.5, "IPCMD-VMEAS"); success *= derivativeMatches(dependency, Vars::IQCMD, Vars::XPIV, reactive ? 1.0 : 0.0, "IQCMD-XPIV selector path"); success *= derivativeMatches(dependency, Vars::IQCMD, Vars::QV, reactive ? 0.0 : 1.0, "IQCMD-QV selector path"); - success *= derivativeMatches(dependency, Vars::XPIQ, kQgenColumn, reactive && voltage ? -0.8 : 0.0, "XPIQ-QGEN selector path"); + success *= derivativeMatches( + dependency, Vars::XPIQ, kQgenColumn, reactive && voltage ? -0.8 : 0.0, "XPIQ-QGEN selector path"); // The direct-voltage coefficient carries no power-base factor, // while the cascaded path converts the reference to component base. @@ -1246,6 +1394,9 @@ namespace GridKit success *= derivativeMatches(dependency, Vars::ILMAX, Vars::ILMAX, -4.0, "negative capacity continuation"); } + const auto zero_capacity = dependencyTrackingJacobian(makeJacobianData(), kNonunitAlpha, success, 0.0); + success *= derivativeMatches(zero_capacity, Vars::ILMAX, Vars::ILMAX, -std::sqrt(ReecbT::INITIALIZATION_TOLERANCE), "zero capacity continuation"); + // The selector sweep zeroes the injection gain, so this configuration // exercises the injection derivative on its own. { @@ -1273,7 +1424,10 @@ namespace GridKit { TestStatus success = true; - const std::array selector_values{{false, true}}; + const std::array selector_values{{false, true}}; + const std::array continuation_states{{-2.0, 0.0}}; + const std::array slew_bases{{25.0, 100.0}}; + const std::array moving_band_bases{{25.0, 200.0}}; for (const bool pf : selector_values) { for (const bool voltage : selector_values) @@ -1282,11 +1436,6 @@ namespace GridKit { for (const bool p_priority : selector_values) { - if (pf && reactive && !voltage) - { - continue; - } - auto data = makeJacobianData(); data.parameters[Params::PfFlag] = pf; data.parameters[Params::VFlag] = voltage; @@ -1306,9 +1455,33 @@ namespace GridKit auto data = makeJacobianData(); data.parameters[Params::Pqflag] = p_priority; - success *= jacobiansMatch( - dependencyTrackingJacobian(data, kNonunitAlpha, success, -2.0), - enzymeJacobian(data, kNonunitAlpha, success, -2.0)); + for (const RealT ilmax : continuation_states) + { + success *= jacobiansMatch( + dependencyTrackingJacobian(data, kNonunitAlpha, success, ilmax), + enzymeJacobian(data, kNonunitAlpha, success, ilmax)); + } + } + + // Base conversion drives the active-order rate through both asymmetric + // slew limits. + for (const RealT mva : slew_bases) + { + auto data = makeJacobianData(); + data.parameters[Params::mva] = mva; + success *= jacobiansMatch( + dependencyTrackingJacobian(data, kNonunitAlpha, success), + enzymeJacobian(data, kNonunitAlpha, success)); + } + + // These points place the voltage PI state below and above its moving band. + for (const RealT mva : moving_band_bases) + { + auto data = makeJacobianData(); + data.parameters[Params::mva] = mva; + success *= jacobiansMatch( + dependencyTrackingJacobian(data, kNonunitAlpha, success, 0.5), + enzymeJacobian(data, kNonunitAlpha, success, 0.5)); } auto injection_data = makeJacobianData(); @@ -1494,14 +1667,13 @@ namespace GridKit static constexpr RealT kNominalFrequency = static_cast(60.0); static constexpr RealT kStateVr = 0.9; static constexpr RealT kStateVi = 0.4; - // The commands, the current circle, and the terminal voltage are all - // exactly representable, so every smooth limiter the initial point - // clears reproduces it bitwise and the model rests at an exact zero. + // The commands, current circle, and voltage give a well-conditioned + // interior initialization point. static constexpr RealT kInitialIqcmd = 0.75; static constexpr RealT kInitialIpcmd = 0.75; static constexpr RealT kNonunitAlpha = 0.7; - static constexpr RealT kUnitSlopeAngle = std::numbers::pi_v / FOUR; + inline static const RealT kUnitSlopeAngle = std::atan(ONE); static constexpr size_t kBusVrColumn = index(Vars::MAXIMUM); static constexpr size_t kBusViColumn = kBusVrColumn + 1; diff --git a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp index 26d9cbd42..d4174426b 100644 --- a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp +++ b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp @@ -331,6 +331,7 @@ namespace GridKit data.signal[1].name = "Active Current Command"; auto regca_data = makeRegcaData(); + regca_data.parameters[RegcaParams::mva] = static_cast(50.0); regca_data.parameters[RegcaParams::p0] = static_cast(0.25); regca_data.parameters[RegcaParams::q0] = static_cast(0.05); regca_data.signal_inputs[RegcaInputs::ipcmd] = ipcmd_id; From 0beed75f17f20dcd72b037633498b4404f3e5828 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Thu, 6 Aug 2026 20:24:20 -0500 Subject: [PATCH 14/16] cleanup and fix last issues --- .../Controller/REECB/CMakeLists.txt | 5 + .../Controller/REECB/ReecbImpl.hpp | 5 +- .../SignalSource/ConstantSignalSourceImpl.hpp | 5 +- .../PhasorDynamics/PDIntegrationTests.hpp | 192 +++++++++++------- .../PhasorDynamics/runPDIntegrationTests.cpp | 2 +- .../ComponentConnectionTests.hpp | 2 +- .../PhasorDynamics/ControllerReecbTests.hpp | 171 ++++++++++++---- .../SystemSingleComponentTests.hpp | 76 +------ .../UnitTests/PhasorDynamics/SystemTests.hpp | 91 ++++++++- .../runSystemSingleComponentTests.cpp | 1 - .../PhasorDynamics/runSystemTests.cpp | 1 + 11 files changed, 363 insertions(+), 188 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Controller/REECB/CMakeLists.txt b/GridKit/Model/PhasorDynamics/Controller/REECB/CMakeLists.txt index 7bc3f9c68..50f8862f3 100644 --- a/GridKit/Model/PhasorDynamics/Controller/REECB/CMakeLists.txt +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/CMakeLists.txt @@ -23,6 +23,11 @@ if(GRIDKIT_ENABLE_ENZYME) -mllvm -enzyme-auto-sparsity=1 -fno-math-errno) + + if(CMAKE_CXX_COMPILER_ID STREQUAL "Clang" AND CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 19) + # Work around EnzymeAD/Enzyme#3101. + target_compile_options(phasor_dynamics_controller_reecb PRIVATE -fno-builtin-tan) + endif() else() gridkit_add_library( phasor_dynamics_controller_reecb diff --git a/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp index 92fb4716b..01db1de04 100644 --- a/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -354,7 +355,7 @@ namespace GridKit RealT iqneed0 = iqabs0; if (QFlag_ && iqabs0 > ZERO) { - iqneed0 += std::log(TWO) / Math::MU + INITIALIZATION_TOLERANCE; + iqneed0 += std::numbers::ln2_v / Math::MU + INITIALIZATION_TOLERANCE; } const RealT d = std::sqrt(INITIALIZATION_TOLERANCE); @@ -1193,7 +1194,7 @@ namespace GridKit typename Reecb::RealT Reecb::logOneMinusExp(RealT x) { - static const RealT log_two = std::log(TWO); + static constexpr auto log_two = std::numbers::ln2_v; if (x < log_two) { diff --git a/GridKit/Model/PhasorDynamics/SignalSource/ConstantSignalSourceImpl.hpp b/GridKit/Model/PhasorDynamics/SignalSource/ConstantSignalSourceImpl.hpp index a541dbe00..5310d4a88 100644 --- a/GridKit/Model/PhasorDynamics/SignalSource/ConstantSignalSourceImpl.hpp +++ b/GridKit/Model/PhasorDynamics/SignalSource/ConstantSignalSourceImpl.hpp @@ -112,10 +112,13 @@ namespace GridKit return 0; } + /** + * @brief Construct the empty Jacobian for this stateless source. + */ template int ConstantSignalSource::evaluateJacobian() { - return 0; + return this->constructCoo(); } } // namespace PhasorDynamics diff --git a/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp b/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp index 74125b0f8..070ae6ac6 100644 --- a/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp +++ b/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include #include @@ -722,80 +724,129 @@ namespace GridKit return success.report(__func__); } - /// A finite active-power-reference pulse moves the coupled REECB and + /// A finite plant-reference pulse moves the coupled REPCA, REECB, and /// REGCA states, after which the closed loop returns to equilibrium. - TestOutcome regcaReecbRecovery() + TestOutcome renewableControlChainRecovery() { using namespace GridKit::PhasorDynamics::Controller; using namespace GridKit::PhasorDynamics::Converter; using ReecbVar = ReecbInternalVariables; + using RepcaVar = RepcaInternalVariables; using RegcaVar = RegcaInternalVariables; + constexpr IdxT RENEWABLE_BUS_ID = static_cast(23); + constexpr IdxT IPCMD_SIGNAL_ID = static_cast(201); + constexpr IdxT IQCMD_SIGNAL_ID = static_cast(202); + constexpr IdxT IBRANCHR_SIGNAL_ID = static_cast(203); + constexpr IdxT IBRANCHI_SIGNAL_ID = static_cast(204); + constexpr IdxT PBRANCH_SIGNAL_ID = static_cast(205); + constexpr IdxT QBRANCH_SIGNAL_ID = static_cast(206); + constexpr IdxT QEXT_SIGNAL_ID = static_cast(207); + constexpr IdxT PEXT_SIGNAL_ID = static_cast(208); + constexpr IdxT PLANT_PREF_SIGNAL_ID = static_cast(209); + constexpr IdxT REGCA_COMPONENT_ID = static_cast(0); + constexpr IdxT REECB_COMPONENT_ID = static_cast(1); + constexpr IdxT REPCA_COMPONENT_ID = static_cast(2); + constexpr RealT COMPONENT_MVA = static_cast(50.0); + constexpr RealT INITIAL_ACTIVE_POWER = static_cast(0.4); + constexpr RealT INITIAL_REACTIVE_POWER = static_cast(0.05); + constexpr RealT REFERENCE_PULSE = static_cast(0.05); + constexpr RealT PULSE_END = static_cast(0.1); + constexpr RealT RESPONSE_TOLERANCE = static_cast(0.01); + constexpr RealT RECOVERY_HORIZON = static_cast(25.0); + constexpr RealT RECOVERY_MONITOR_STEP = static_cast(1.0 / 60.0); + constexpr RealT RECOVERY_TOLERANCE = static_cast(1.0e-6); + TestStatus success = true; SystemModelDataT data; data.va_base = static_cast(100.0e6); auto& bus = data.bus.emplace_back(); - bus.bus_id = kReecbBusId; + bus.bus_id = RENEWABLE_BUS_ID; bus.bus_type = BusDataT::BusType::SLACK; bus.Vr0 = ONE; bus.Vi0 = ZERO; - data.signal = {{"Active Current Command", kIpcmdSignalId}, - {"Reactive Current Command", kIqcmdSignalId}, - {"Branch Active Power", kPbranchSignalId}, - {"Branch Reactive Power", kQbranchSignalId}, - {"Active Power Reference", kPrefSignalId}}; - - auto& converter = data.regca.emplace_back(); - converter.buses[RegcaBuses::bus] = kReecbBusId; - converter.signal_inputs[RegcaSignalInputs::ipcmd] = kIpcmdSignalId; - converter.signal_inputs[RegcaSignalInputs::iqcmd] = kIqcmdSignalId; - converter.signal_outputs[RegcaSignalOutputs::pbranch] = kPbranchSignalId; - converter.signal_outputs[RegcaSignalOutputs::qbranch] = kQbranchSignalId; - converter.parameters[RegcaParameters::p0] = static_cast(0.4); - converter.parameters[RegcaParameters::q0] = static_cast(0.05); - converter.parameters[RegcaParameters::mva] = static_cast(100.0); - converter.parameters[RegcaParameters::Tg] = static_cast(0.02); - converter.parameters[RegcaParameters::TM] = static_cast(0.02); - converter.parameters[RegcaParameters::Rqmax] = static_cast(999.0); - converter.parameters[RegcaParameters::Rqmin] = static_cast(-999.0); - converter.parameters[RegcaParameters::Rpmax] = static_cast(999.0); - converter.parameters[RegcaParameters::sL] = true; - converter.parameters[RegcaParameters::IL1] = static_cast(1.1); - converter.parameters[RegcaParameters::VL0] = static_cast(0.4); - converter.parameters[RegcaParameters::VL1] = static_cast(0.9); - converter.parameters[RegcaParameters::VA0] = static_cast(0.4); - converter.parameters[RegcaParameters::VA1] = static_cast(0.9); - converter.parameters[RegcaParameters::Vhvmax] = static_cast(1.2); + data.signal = {{"Active Current Command", IPCMD_SIGNAL_ID}, + {"Reactive Current Command", IQCMD_SIGNAL_ID}, + {"Branch Current Real", IBRANCHR_SIGNAL_ID}, + {"Branch Current Imaginary", IBRANCHI_SIGNAL_ID}, + {"Branch Active Power", PBRANCH_SIGNAL_ID}, + {"Branch Reactive Power", QBRANCH_SIGNAL_ID}, + {"Reactive Power Command", QEXT_SIGNAL_ID}, + {"Active Power Command", PEXT_SIGNAL_ID}, + {"Plant Active Power Reference", PLANT_PREF_SIGNAL_ID}}; + + auto& converter = data.regca.emplace_back(); + converter.buses[RegcaBuses::bus] = RENEWABLE_BUS_ID; + converter.signal_inputs[RegcaSignalInputs::ipcmd] = IPCMD_SIGNAL_ID; + converter.signal_inputs[RegcaSignalInputs::iqcmd] = IQCMD_SIGNAL_ID; + converter.signal_outputs[RegcaSignalOutputs::ibranchr] = IBRANCHR_SIGNAL_ID; + converter.signal_outputs[RegcaSignalOutputs::ibranchi] = IBRANCHI_SIGNAL_ID; + converter.signal_outputs[RegcaSignalOutputs::pbranch] = PBRANCH_SIGNAL_ID; + converter.signal_outputs[RegcaSignalOutputs::qbranch] = QBRANCH_SIGNAL_ID; + converter.parameters[RegcaParameters::p0] = INITIAL_ACTIVE_POWER; + converter.parameters[RegcaParameters::q0] = INITIAL_REACTIVE_POWER; + converter.parameters[RegcaParameters::mva] = COMPONENT_MVA; + converter.parameters[RegcaParameters::Tg] = static_cast(0.02); + converter.parameters[RegcaParameters::TM] = static_cast(0.02); + converter.parameters[RegcaParameters::Rqmax] = static_cast(999.0); + converter.parameters[RegcaParameters::Rqmin] = static_cast(-999.0); + converter.parameters[RegcaParameters::Rpmax] = static_cast(999.0); + converter.parameters[RegcaParameters::sL] = true; + converter.parameters[RegcaParameters::IL1] = static_cast(1.1); + converter.parameters[RegcaParameters::VL0] = static_cast(0.4); + converter.parameters[RegcaParameters::VL1] = static_cast(0.9); + converter.parameters[RegcaParameters::VA0] = static_cast(0.4); + converter.parameters[RegcaParameters::VA1] = static_cast(0.9); + converter.parameters[RegcaParameters::Vhvmax] = static_cast(1.2); auto& controller = data.reecb.emplace_back(); - controller.buses[ReecbBuses::bus] = kReecbBusId; - controller.signal_inputs[ReecbSignalInputs::pe] = kPbranchSignalId; - controller.signal_inputs[ReecbSignalInputs::qgen] = kQbranchSignalId; - controller.signal_inputs[ReecbSignalInputs::pref] = kPrefSignalId; - controller.signal_outputs[ReecbSignalOutputs::ipcmd] = kIpcmdSignalId; - controller.signal_outputs[ReecbSignalOutputs::iqcmd] = kIqcmdSignalId; - controller.parameters[ReecbParameters::mva] = static_cast(100.0); + controller.buses[ReecbBuses::bus] = RENEWABLE_BUS_ID; + controller.signal_inputs[ReecbSignalInputs::pe] = PBRANCH_SIGNAL_ID; + controller.signal_inputs[ReecbSignalInputs::qgen] = QBRANCH_SIGNAL_ID; + controller.signal_inputs[ReecbSignalInputs::qext] = QEXT_SIGNAL_ID; + controller.signal_inputs[ReecbSignalInputs::pref] = PEXT_SIGNAL_ID; + controller.signal_outputs[ReecbSignalOutputs::ipcmd] = IPCMD_SIGNAL_ID; + controller.signal_outputs[ReecbSignalOutputs::iqcmd] = IQCMD_SIGNAL_ID; + controller.parameters[ReecbParameters::mva] = COMPONENT_MVA; controller.parameters[ReecbParameters::Trv] = static_cast(0.02); controller.parameters[ReecbParameters::Tp] = static_cast(0.02); controller.parameters[ReecbParameters::Kvi] = static_cast(5.0); controller.parameters[ReecbParameters::QFlag] = true; controller.parameters[ReecbParameters::VFlag] = true; + auto& plant = data.repca.emplace_back(); + plant.buses[RepcaBuses::bus] = RENEWABLE_BUS_ID; + plant.signal_inputs[RepcaSignalInputs::ir] = IBRANCHR_SIGNAL_ID; + plant.signal_inputs[RepcaSignalInputs::ii] = IBRANCHI_SIGNAL_ID; + plant.signal_inputs[RepcaSignalInputs::p] = PBRANCH_SIGNAL_ID; + plant.signal_inputs[RepcaSignalInputs::q] = QBRANCH_SIGNAL_ID; + plant.signal_inputs[RepcaSignalInputs::pref] = PLANT_PREF_SIGNAL_ID; + plant.signal_outputs[RepcaSignalOutputs::qext] = QEXT_SIGNAL_ID; + plant.signal_outputs[RepcaSignalOutputs::pext] = PEXT_SIGNAL_ID; + plant.parameters[RepcaParameters::mva] = COMPONENT_MVA; + plant.parameters[RepcaParameters::Freqflag] = true; + plant.parameters[RepcaParameters::Ddn] = ZERO; + plant.parameters[RepcaParameters::Dup] = ZERO; + plant.parameters[RepcaParameters::Tp] = static_cast(0.02); + plant.parameters[RepcaParameters::Tlag] = static_cast(0.5); + auto& reference = data.constant_source.emplace_back(); - reference.parameters[ConstantSignalSourceParameters::Sr] = ZERO; - reference.signal_outputs[ConstantSignalSourceSignalOutputs::sr] = kPrefSignalId; + reference.parameters[ConstantSignalSourceParameters::Sr] = INITIAL_ACTIVE_POWER; + reference.signal_outputs[ConstantSignalSourceSignalOutputs::sr] = PLANT_PREF_SIGNAL_ID; SystemModel system(data); success *= system.allocate() == 0; auto* regca = - dynamic_cast*>(system.getComponent(kConverterComponentId)); + dynamic_cast*>(system.getComponent(REGCA_COMPONENT_ID)); auto* reecb = - dynamic_cast*>(system.getComponent(kControllerComponentId)); - if (regca == nullptr || reecb == nullptr) + dynamic_cast*>(system.getComponent(REECB_COMPONENT_ID)); + auto* repca = + dynamic_cast*>(system.getComponent(REPCA_COMPONENT_ID)); + if (regca == nullptr || reecb == nullptr || repca == nullptr) { success = false; return success.report(__func__); @@ -811,37 +862,57 @@ namespace GridKit reecb->getVariableIndex(static_cast(ReecbVar::IPCMD))); const auto regca_ip_index = static_cast( regca->getVariableIndex(static_cast(RegcaVar::IP))); + const auto pext_index = static_cast( + repca->getVariableIndex(static_cast(RepcaVar::PEXT))); const auto* equilibrium_values = system.y().getData(); const std::vector equilibrium( equilibrium_values, equilibrium_values + static_cast(system.y().getSize())); - auto* pref_signal = system.getSignal(kPrefSignalId); + success *= isEqual(static_cast(system.getSignal(PLANT_PREF_SIGNAL_ID)->read()), + INITIAL_ACTIVE_POWER, + RECOVERY_TOLERANCE); + success *= isEqual(static_cast(system.getSignal(PEXT_SIGNAL_ID)->read()), + INITIAL_ACTIVE_POWER, + RECOVERY_TOLERANCE); + success *= isEqual(static_cast(system.getSignal(QEXT_SIGNAL_ID)->read()), + INITIAL_REACTIVE_POWER, + RECOVERY_TOLERANCE); + + auto* pref_signal = system.getSignal(PLANT_PREF_SIGNAL_ID); const RealT pref0 = static_cast(pref_signal->read()); - pref_signal->init(pref0 + kReferencePulse); + pref_signal->init(pref0 + REFERENCE_PULSE); success *= ida.initializeSimulation(ZERO) == 0; - success *= ida.runSimulation(kPulseEnd, kRecoveryMonitorStep) == 0; + success *= ida.runSimulation(PULSE_END, RECOVERY_MONITOR_STEP) == 0; const auto* pulse_values = system.y().getData(); const RealT pord_response = pulse_values[pord_index] - equilibrium[pord_index]; const RealT ipcmd_response = pulse_values[ipcmd_index] - equilibrium[ipcmd_index]; const RealT regca_ip_response = pulse_values[regca_ip_index] - equilibrium[regca_ip_index]; + const RealT pext_response = pulse_values[pext_index] - equilibrium[pext_index]; + + if (pext_response <= RESPONSE_TOLERANCE) + { + std::cout << "REPCA PEXT responded by only " << pext_response + << " during the reference pulse\n"; + success = false; + } - if (pord_response <= kResponseTolerance) + if (pord_response <= RESPONSE_TOLERANCE) { std::cout << "REECB PORD responded by only " << pord_response << " during the reference pulse\n"; success = false; } - if (ipcmd_response <= kResponseTolerance) + if (ipcmd_response <= RESPONSE_TOLERANCE) { std::cout << "REECB IPCMD responded by only " << ipcmd_response << " during the reference pulse\n"; success = false; } - if (regca_ip_response <= kResponseTolerance) + if (regca_ip_response <= RESPONSE_TOLERANCE) { std::cout << "REGCA IP responded by only " << regca_ip_response << " during the reference pulse\n"; @@ -849,16 +920,16 @@ namespace GridKit } pref_signal->init(pref0); - success *= ida.initializeSimulation(kPulseEnd) == 0; - success *= ida.runSimulation(kPulseEnd + kRecoveryHorizon, - kRecoveryMonitorStep) + success *= ida.initializeSimulation(PULSE_END) == 0; + success *= ida.runSimulation(PULSE_END + RECOVERY_HORIZON, + RECOVERY_MONITOR_STEP) == 0; const auto* final_values = system.y().getData(); for (size_t entry = 0; entry < equilibrium.size(); ++entry) { const RealT deviation = final_values[entry] - equilibrium[entry]; - if (!isEqual(deviation, ZERO, kRecoveryTolerance)) + if (!isEqual(deviation, ZERO, RECOVERY_TOLERANCE)) { std::cout << "State " << entry << " remains " << deviation << " from its equilibrium after recovery\n"; @@ -868,23 +939,6 @@ namespace GridKit return success.report(__func__); } - - private: - static constexpr IdxT kReecbBusId = static_cast(23); - static constexpr IdxT kIpcmdSignalId = static_cast(201); - static constexpr IdxT kIqcmdSignalId = static_cast(202); - static constexpr IdxT kPbranchSignalId = static_cast(203); - static constexpr IdxT kQbranchSignalId = static_cast(204); - static constexpr IdxT kPrefSignalId = static_cast(205); - static constexpr IdxT kConverterComponentId = static_cast(0); - static constexpr IdxT kControllerComponentId = static_cast(1); - - static constexpr RealT kReferencePulse = static_cast(0.05); - static constexpr RealT kPulseEnd = static_cast(0.1); - static constexpr RealT kResponseTolerance = static_cast(0.01); - static constexpr RealT kRecoveryHorizon = static_cast(25.0); - static constexpr RealT kRecoveryMonitorStep = static_cast(1.0 / 60.0); - static constexpr RealT kRecoveryTolerance = static_cast(1.0e-6); }; } // namespace Testing } // namespace GridKit diff --git a/tests/IntegrationTests/PhasorDynamics/runPDIntegrationTests.cpp b/tests/IntegrationTests/PhasorDynamics/runPDIntegrationTests.cpp index 714074c9b..575d8e884 100644 --- a/tests/IntegrationTests/PhasorDynamics/runPDIntegrationTests.cpp +++ b/tests/IntegrationTests/PhasorDynamics/runPDIntegrationTests.cpp @@ -12,7 +12,7 @@ int main() result += test.twoBusTgov1(); result += test.threeBusBasic(); result += test.threeBusClassical(); - result += test.regcaReecbRecovery(); + result += test.renewableControlChainRecovery(); return result.summary(); } diff --git a/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp b/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp index d8f55a9a6..920805837 100644 --- a/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp @@ -138,7 +138,7 @@ namespace GridKit return success.report(__func__); } -+ /// REGCA initializes first and publishes its branch current and power + /// REGCA initializes first and publishes its branch current and power /// to the four shared nodes. REPCA then initializes around those /// measurements and must hold a steady state without a frequency input. TestOutcome regcaRepca() diff --git a/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp b/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp index 54a88761d..a156aff2b 100644 --- a/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp @@ -265,16 +265,17 @@ namespace GridKit success *= fixture.initialize(kInitialIqcmd, kInitialIpcmd); success *= (fixture.evaluate() == 0); - success *= stateMatches(fixture.reecb, - {{Vars::VMEAS, 1.0}, - {Vars::PMEAS, 1.5}, - {Vars::XPIQ, 0.0}, - {Vars::XPIV, 0.0}, - {Vars::QV, 1.5}, - {Vars::PORD, 1.5}, - {Vars::VT, 1.0}, - {Vars::ILMAX, 2.0}}, - "initialization"); + const std::array initial_state{{ + {Vars::VMEAS, 1.0}, + {Vars::PMEAS, 1.5}, + {Vars::XPIQ, 0.0}, + {Vars::XPIV, 0.0}, + {Vars::QV, 1.5}, + {Vars::PORD, 1.5}, + {Vars::VT, 1.0}, + {Vars::ILMAX, 2.0}, + }}; + success *= stateMatches(fixture.reecb, initial_state, "initialization"); success *= scalarPreserved(fixture.iqcmd(), kInitialIqcmd, "preserved iqcmd"); success *= scalarPreserved(fixture.ipcmd(), kInitialIpcmd, "preserved ipcmd"); @@ -1313,8 +1314,8 @@ namespace GridKit return success.report(__func__); } - /// Fixed dependency-tracking coefficients pin every selector path at a - /// non-unit alpha. + /// Fixed coefficients and the complete structure pin every selector + /// path at a non-unit alpha. TestOutcome dependencyTracking() { TestStatus success = true; @@ -1336,6 +1337,7 @@ namespace GridKit const auto dependency = dependencyTrackingJacobian(data, kNonunitAlpha, success); + success *= jacobianStructureMatches(dependency, "dependency tracking"); success *= derivativeMatches(dependency, Vars::VMEAS, Vars::VMEAS, -5.7, "VMEAS diagonal"); success *= derivativeMatches(dependency, Vars::VMEAS, Vars::VT, 5.0, "VMEAS-VT"); success *= derivativeMatches(dependency, Vars::PMEAS, Vars::PMEAS, -3.2, "PMEAS diagonal"); @@ -1372,12 +1374,12 @@ namespace GridKit if (p_priority) { success *= derivativeMatches(dependency, Vars::ILMAX, Vars::IPCMD, -1.6, "P-priority current-circle column"); - success *= derivativeMatches(dependency, Vars::ILMAX, Vars::IQCMD, 0.0, "P-priority absent current-circle column"); + success *= derivativeMatches(dependency, Vars::ILMAX, Vars::IQCMD, 0.0, "P-priority inactive current-circle column"); } else { success *= derivativeMatches(dependency, Vars::ILMAX, Vars::IQCMD, -0.8, "Q-priority current-circle column"); - success *= derivativeMatches(dependency, Vars::ILMAX, Vars::IPCMD, 0.0, "Q-priority absent current-circle column"); + success *= derivativeMatches(dependency, Vars::ILMAX, Vars::IPCMD, 0.0, "Q-priority inactive current-circle column"); } } } @@ -1391,10 +1393,12 @@ namespace GridKit data.parameters[Params::Pqflag] = p_priority; const auto dependency = dependencyTrackingJacobian(data, kNonunitAlpha, success, -2.0); + success *= jacobianStructureMatches(dependency, "dependency tracking"); success *= derivativeMatches(dependency, Vars::ILMAX, Vars::ILMAX, -4.0, "negative capacity continuation"); } const auto zero_capacity = dependencyTrackingJacobian(makeJacobianData(), kNonunitAlpha, success, 0.0); + success *= jacobianStructureMatches(zero_capacity, "dependency tracking"); success *= derivativeMatches(zero_capacity, Vars::ILMAX, Vars::ILMAX, -std::sqrt(ReecbT::INITIALIZATION_TOLERANCE), "zero capacity continuation"); // The selector sweep zeroes the injection gain, so this configuration @@ -1410,6 +1414,7 @@ namespace GridKit data.parameters[Params::Vref0] = 2.2; const auto dependency = dependencyTrackingJacobian(data, kNonunitAlpha, success); + success *= jacobianStructureMatches(dependency, "dependency tracking"); success *= derivativeMatches(dependency, Vars::IQCMD, Vars::VMEAS, -1.0, "IQCMD-VMEAS injection path"); success *= derivativeMatches(dependency, Vars::IQCMD, Vars::QV, 1.0, "IQCMD-QV alongside injection"); } @@ -1418,8 +1423,8 @@ namespace GridKit } #ifdef GRIDKIT_ENABLE_ENZYME - /// Every selector mode and the continuation probes agree between Enzyme - /// and dependency tracking at a non-unit alpha. + /// Every selector mode and continuation probe has the expected structure + /// and agrees between Enzyme and dependency tracking. TestOutcome jacobian() { TestStatus success = true; @@ -1683,7 +1688,53 @@ namespace GridKit static constexpr size_t kQextColumn = kExternalColumnBase + index(Ext::QEXT); static constexpr size_t kPfarefColumn = kExternalColumnBase + index(Ext::PFAREF); static constexpr size_t kPrefColumn = kExternalColumnBase + index(Ext::PREF); - static constexpr size_t kColumnCount = kExternalColumnBase + index(Ext::MAXIMUM); + + static std::array, index(Vars::MAXIMUM)> + expectedJacobianStructure() + { + return {{ + {index(Vars::VMEAS), index(Vars::VT)}, + {index(Vars::PMEAS), kPeColumn}, + {index(Vars::XPIQ), + index(Vars::VT), + index(Vars::PMEAS), + kQgenColumn, + kQextColumn, + kPfarefColumn}, + {index(Vars::XPIV), + index(Vars::VT), + index(Vars::ILMAX), + index(Vars::VMEAS), + index(Vars::PMEAS), + index(Vars::XPIQ), + kQgenColumn, + kQextColumn, + kPfarefColumn}, + {index(Vars::QV), + index(Vars::VT), + index(Vars::VMEAS), + index(Vars::PMEAS), + kQextColumn, + kPfarefColumn}, + {index(Vars::PORD), index(Vars::VT), kPrefColumn}, + {index(Vars::VT), kBusVrColumn, kBusViColumn}, + {index(Vars::ILMAX), index(Vars::IQCMD), index(Vars::IPCMD)}, + {index(Vars::IQCMD), + index(Vars::VMEAS), + index(Vars::PMEAS), + index(Vars::XPIQ), + index(Vars::XPIV), + index(Vars::QV), + index(Vars::ILMAX), + kQgenColumn, + kQextColumn, + kPfarefColumn}, + {index(Vars::IPCMD), + index(Vars::PORD), + index(Vars::VMEAS), + index(Vars::ILMAX)}, + }}; + } Data makeMinimalData() const { @@ -2470,19 +2521,6 @@ namespace GridKit return rows; } - static RealT derivative( - const std::vector& jacobian, - size_t row, - size_t column) - { - const auto entry = jacobian[row].find(column); - if (entry == jacobian[row].end()) - { - return 0.0; - } - return entry->second; - } - bool derivativeMatches( const std::vector& jacobian, Vars row, @@ -2500,7 +2538,16 @@ namespace GridKit RealT expected, const char* label) const { - const RealT actual = derivative(jacobian, index(row), column); + const auto& dependencies = jacobian[index(row)]; + const auto entry = dependencies.find(column); + if (entry == dependencies.end()) + { + std::cout << "REECB Jacobian " << label + << " missing column " << column << '\n'; + return false; + } + + const RealT actual = entry->second; if (isEqual(actual, expected, kTol)) { return true; @@ -2511,6 +2558,42 @@ namespace GridKit return false; } + bool jacobianStructureMatches( + const std::vector& jacobian, + const char* source) const + { + const auto expected = expectedJacobianStructure(); + if (jacobian.size() != expected.size()) + { + std::cout << "REECB " << source + << " Jacobian row-count mismatch\n"; + return false; + } + + bool success = true; + for (size_t row = 0; row < expected.size(); ++row) + { + if (jacobian[row].size() != expected[row].size()) + { + std::cout << "REECB " << source << " Jacobian row " << row + << " column-count mismatch: " << jacobian[row].size() + << " != " << expected[row].size() << '\n'; + success = false; + } + + for (const size_t column : expected[row]) + { + if (!jacobian[row].contains(column)) + { + std::cout << "REECB " << source << " Jacobian row " << row + << " missing column " << column << '\n'; + success = false; + } + } + } + return success; + } + #ifdef GRIDKIT_ENABLE_ENZYME std::vector enzymeJacobian(const Data& data, @@ -2539,27 +2622,29 @@ namespace GridKit const std::vector& dependency, const std::vector& enzyme) const { + bool success = true; + if (!jacobianStructureMatches(dependency, "dependency tracking")) + { + success = false; + } + if (!jacobianStructureMatches(enzyme, "Enzyme")) + { + success = false; + } + if (dependency.size() != enzyme.size()) { std::cout << "REECB Jacobian row-count mismatch\n"; return false; } - bool success = true; for (size_t row = 0; row < dependency.size(); ++row) { - for (size_t column = 0; column < kColumnCount; ++column) + if (!isEqual(dependency[row], enzyme[row], kTol)) { - const RealT expected = derivative(dependency, row, column); - const RealT actual = derivative(enzyme, row, column); - if (!isEqual(actual, expected, kTol)) - { - std::cout << "REECB Jacobian (" << row << ", " << column - << ") backend mismatch: " - << std::setprecision(std::numeric_limits::max_digits10) - << actual << " != " << expected << '\n'; - success = false; - } + std::cout << "REECB Jacobian row " << row + << " mismatch between dependency tracking and Enzyme\n"; + success = false; } } return success; diff --git a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp index d4174426b..5c8f4d515 100644 --- a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp +++ b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp @@ -1,6 +1,5 @@ #include #include -#include #include #include @@ -298,53 +297,26 @@ namespace GridKit return success.report(__func__); } - /// REECB through the production data path, coupled to REGCA. + /// REECB through the production data path. TestOutcome reecb() { - using Data = PhasorDynamics::Controller::ReecbData; - using Buses = typename Data::Buses; - using Outputs = typename Data::SignalOutputs; - using Params = typename Data::Parameters; - using Vars = PhasorDynamics::Controller::ReecbInternalVariables; - using RegcaInputs = PhasorDynamics::Converter::RegcaSignalInputs; - using RegcaParams = PhasorDynamics::Converter::RegcaParameters; - using RegcaVars = PhasorDynamics::Converter::RegcaInternalVariables; + using Data = PhasorDynamics::Controller::ReecbData; + using Buses = typename Data::Buses; + using Vars = PhasorDynamics::Controller::ReecbInternalVariables; - constexpr IdxT bus_id = static_cast(1); - constexpr IdxT iqcmd_id = static_cast(1); - constexpr IdxT ipcmd_id = static_cast(2); + constexpr IdxT bus_id = static_cast(1); TestStatus success = true; PhasorDynamics::SystemModelData data; - data.va_base = static_cast(100.0e6); data.bus.resize(1); data.bus[0].bus_id = bus_id; data.bus[0].bus_type = PhasorDynamics::BusData::BusType::SLACK; data.bus[0].Vr0 = static_cast(1.0); data.bus[0].Vi0 = static_cast(0.0); - data.signal.resize(2); - data.signal[0].signal_id = iqcmd_id; - data.signal[0].name = "Reactive Current Command"; - data.signal[1].signal_id = ipcmd_id; - data.signal[1].name = "Active Current Command"; - - auto regca_data = makeRegcaData(); - regca_data.parameters[RegcaParams::mva] = static_cast(50.0); - regca_data.parameters[RegcaParams::p0] = static_cast(0.25); - regca_data.parameters[RegcaParams::q0] = static_cast(0.05); - regca_data.signal_inputs[RegcaInputs::ipcmd] = ipcmd_id; - regca_data.signal_inputs[RegcaInputs::iqcmd] = iqcmd_id; - data.regca.push_back(regca_data); - Data reecb_data; - reecb_data.device_class = "Reecb"; - reecb_data.disambiguation_string = "reecb_system"; - reecb_data.buses[Buses::bus] = bus_id; - reecb_data.parameters[Params::mva] = static_cast(50.0); - reecb_data.signal_outputs[Outputs::iqcmd] = iqcmd_id; - reecb_data.signal_outputs[Outputs::ipcmd] = ipcmd_id; + reecb_data.buses[Buses::bus] = bus_id; data.reecb.push_back(reecb_data); PhasorDynamics::SystemModel system(data); @@ -354,41 +326,7 @@ namespace GridKit success *= system.tagDifferentiable() == 0; success *= system.evaluateResidual() == 0; success *= system.evaluateJacobian() == 0; - success *= system.size() - == static_cast(RegcaVars::MAXIMUM) - + static_cast(Vars::MAXIMUM); - - return success.report(__func__); - } - - /// System initialization reports a statically valid component whose - /// operating point cannot be initialized. - TestOutcome initializationFailure() - { - TestStatus success = true; - - PhasorDynamics::SystemModelData data; - data.bus.resize(1); - data.bus[0].bus_id = static_cast(1); - data.bus[0].bus_type = PhasorDynamics::BusData::BusType::SLACK; - data.bus[0].Vr0 = static_cast(0.8); - data.bus[0].Vi0 = ZERO; - data.regca.push_back(makeRegcaData()); - - PhasorDynamics::SystemModel system(data); - - std::cout << "Testing expected component initialization failure.\n"; - success *= system.verify() == 0; - if (system.hasJacobian()) - { - success *= throws([&]() - { system.allocate(); }); - } - else - { - success *= system.allocate() == 0; - success *= system.initialize() != 0; - } + success *= system.size() == static_cast(Vars::MAXIMUM); return success.report(__func__); } diff --git a/tests/UnitTests/PhasorDynamics/SystemTests.hpp b/tests/UnitTests/PhasorDynamics/SystemTests.hpp index 968ad1462..1235ac56a 100644 --- a/tests/UnitTests/PhasorDynamics/SystemTests.hpp +++ b/tests/UnitTests/PhasorDynamics/SystemTests.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -13,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -34,7 +36,68 @@ namespace GridKit class SystemTests { private: - using RealT = typename PhasorDynamics::Component::RealT; + using ComponentT = PhasorDynamics::Component; + using RealT = typename ComponentT::RealT; + + class InitializationFailureComponent final : public ComponentT + { + public: + InitializationFailureComponent() + { + this->size_ = static_cast(1); + } + + int setGridKitComponentID(IdxT component_id) override final + { + this->gridkit_component_id_ = component_id; + return 0; + } + + int allocate() override final + { + if (!this->allocated_) + { + this->allocateVectors(this->size_); + } + + const auto size = static_cast(this->size_); + this->tag_.assign(size, false); + this->variable_indices_.resize(size); + this->residual_indices_.resize(size); + this->allocated_ = true; + return 0; + } + + int verify() const override final + { + return 0; + } + + int initialize() override final + { + return 1; + } + + int tagDifferentiable() override final + { + return 0; + } + + int setAbsoluteTolerance(RealT) override final + { + return 0; + } + + int evaluateResidual() override final + { + return 0; + } + + int evaluateJacobian() override final + { + return this->constructCoo(); + } + }; public: SystemTests() = default; @@ -341,6 +404,32 @@ namespace GridKit return status.report(__func__); } + /// SystemModel propagates a statically valid component's initialization error. + TestOutcome componentInitializationError() + { + TestStatus success = true; + + PhasorDynamics::SystemModel system; + InitializationFailureComponent component; + system.addComponent(&component); + + success *= system.verify() == 0; + + std::cout << "Testing expected component initialization failure.\n"; + if (system.hasJacobian()) + { + success *= throws([&]() + { system.allocate(); }); + } + else + { + success *= system.allocate() == 0; + success *= system.initialize() != 0; + } + + return success.report(__func__); + } + #ifdef GRIDKIT_ENABLE_ENZYME TestOutcome jacobian() { diff --git a/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp b/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp index 04a543f7d..7fe1dcbe0 100644 --- a/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp @@ -18,7 +18,6 @@ int main() result += test.regca(); result += test.repca(); result += test.reecb(); - result += test.initializationFailure(); result += test.genrou(); result += test.genClassical(); result += test.tgov1(); diff --git a/tests/UnitTests/PhasorDynamics/runSystemTests.cpp b/tests/UnitTests/PhasorDynamics/runSystemTests.cpp index f1dd5c778..71cbe85a3 100644 --- a/tests/UnitTests/PhasorDynamics/runSystemTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runSystemTests.cpp @@ -17,6 +17,7 @@ int main() #endif result += test.allocationError(); + result += test.componentInitializationError(); result += test.signalError(); return result.summary(); From d0f939166fe18fe16cd0b1979ca0f20372b8f3eb Mon Sep 17 00:00:00 2001 From: lukelowry Date: Thu, 6 Aug 2026 22:28:51 -0500 Subject: [PATCH 15/16] add bisect and also cleanup --- .../PhasorDynamics/Controller/REECB/Reecb.hpp | 38 +- .../Controller/REECB/ReecbImpl.hpp | 363 ++++++++++++------ .../Model/PhasorDynamics/SystemModelImpl.hpp | 13 +- .../PhasorDynamics/ControllerReecbTests.hpp | 70 +++- 4 files changed, 344 insertions(+), 140 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Controller/REECB/Reecb.hpp b/GridKit/Model/PhasorDynamics/Controller/REECB/Reecb.hpp index 5691d242e..7e000aac0 100644 --- a/GridKit/Model/PhasorDynamics/Controller/REECB/Reecb.hpp +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/Reecb.hpp @@ -130,19 +130,31 @@ namespace GridKit /// Smooth anti-windup derivative within a moving symmetric band. [[gnu::always_inline]] static inline ScalarT awband(ScalarT state, ScalarT rate, ScalarT band); - static void checkConfiguration(bool condition, const char* message, int& errors); - void loadRealParameter(const ModelDataT& data, - ReecbParameters parameter, - RealT& target, - const char* name); - void loadBooleanParameter(const ModelDataT& data, - ReecbParameters parameter, - bool& target, - const char* name); - bool floorTimeConstant(RealT& value, const char* name); - void initializeParameters(const ModelDataT& data); - void initializeMonitor(); - void setDerivedParameters(); + /// Current-circle continuation state for an initial component-base limit. + static RealT circleState(RealT imax, RealT high); + + /// Off-axis component-base capacity provided by a continuation state. + static RealT capacity(RealT ilmax); + + /// Bisect an initial-limit bracket to its first upper-side point. + template + static RealT bisect(RealT a, RealT b, FuncT below); + + /// Solve the smallest feasible initial limit at or above `lower`. + static RealT solveInitialLimit(RealT lower, RealT high, RealT low); + + void loadRealParameter(const ModelDataT& data, + ReecbParameters parameter, + RealT& target, + const char* name); + void loadBooleanParameter(const ModelDataT& data, + ReecbParameters parameter, + bool& target, + const char* name); + bool floorTimeConstant(RealT& value, const char* name); + void initializeParameters(const ModelDataT& data); + void initializeMonitor(); + void setDerivedParameters(); static RealT logOneMinusExp(RealT x); bool iclamp(RealT output, RealT lower, RealT upper, RealT& input) const; diff --git a/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp index 01db1de04..edd7daf35 100644 --- a/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/ReecbImpl.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -151,90 +152,98 @@ namespace GridKit { int ret = static_cast(parameter_error_count_); - checkConfiguration(bus_ != nullptr, "terminal bus is required", ret); + auto check = [&](bool condition, const char* message) + { + if (!condition) + { + Log::error() << "Reecb: " << message << '\n'; + ret += 1; + } + }; + + check(bus_ != nullptr, "terminal bus is required"); const RealT component_power_base = componentPowerBase(); const bool valid_component_base = std::isfinite(component_power_base) && component_power_base > ZERO; const bool valid_system_base = std::isfinite(va_system_base_) && va_system_base_ > ZERO; - checkConfiguration(valid_component_base, "component power base must be finite and positive", ret); - checkConfiguration(valid_system_base, "system power base must be finite and positive", ret); + check(valid_component_base, "component power base must be finite and positive"); + check(valid_system_base, "system power base must be finite and positive"); if (valid_component_base && valid_system_base) { const RealT system_to_component = va_system_base_ / component_power_base; const RealT component_to_system = component_power_base / va_system_base_; - checkConfiguration( + check( std::isfinite(system_to_component) && system_to_component > ZERO && std::isfinite(component_to_system) && component_to_system > ZERO, - "system/component power-base conversion ratios must be finite and positive", - ret); + "system/component power-base conversion ratios must be finite and positive"); } - checkConfiguration(std::isfinite(Trv_), "Trv must be finite", ret); - checkConfiguration(std::isfinite(Tp_), "Tp must be finite", ret); - checkConfiguration(std::isfinite(Vref0_), "Vref0 must be finite", ret); + check(std::isfinite(Trv_), "Trv must be finite"); + check(std::isfinite(Tp_), "Tp must be finite"); + check(std::isfinite(Vref0_), "Vref0 must be finite"); const bool finite_voltage_thresholds = std::isfinite(Vdip_) && std::isfinite(Vup_); - checkConfiguration(finite_voltage_thresholds, "Vdip and Vup must be finite", ret); + check(finite_voltage_thresholds, "Vdip and Vup must be finite"); if (finite_voltage_thresholds) { - checkConfiguration(Vdip_ < Vup_, "Vdip must be less than Vup", ret); + check(Vdip_ < Vup_, "Vdip must be less than Vup"); } const bool finite_voltage_deadband = std::isfinite(dbd1_) && std::isfinite(dbd2_); - checkConfiguration(finite_voltage_deadband, "dbd1 and dbd2 must be finite", ret); + check(finite_voltage_deadband, "dbd1 and dbd2 must be finite"); if (finite_voltage_deadband) { - checkConfiguration(dbd1_ <= ZERO && ZERO <= dbd2_, "dbd1 <= 0 <= dbd2 is required", ret); + check(dbd1_ <= ZERO && ZERO <= dbd2_, "dbd1 <= 0 <= dbd2 is required"); } - checkConfiguration(std::isfinite(kqv_) && kqv_ >= ZERO, "kqv must be finite and non-negative", ret); + check(std::isfinite(kqv_) && kqv_ >= ZERO, "kqv must be finite and non-negative"); const bool finite_injection_limits = std::isfinite(Iql1_) && std::isfinite(Iqh1_); - checkConfiguration(finite_injection_limits, "Iql1 and Iqh1 must be finite", ret); + check(finite_injection_limits, "Iql1 and Iqh1 must be finite"); if (finite_injection_limits) { - checkConfiguration(Iql1_ <= Iqh1_, "Iql1 must be less than or equal to Iqh1", ret); + check(Iql1_ <= Iqh1_, "Iql1 must be less than or equal to Iqh1"); } const bool finite_reactive_limits = std::isfinite(Qmin_) && std::isfinite(Qmax_); - checkConfiguration(finite_reactive_limits, "Qmin and Qmax must be finite", ret); + check(finite_reactive_limits, "Qmin and Qmax must be finite"); if (finite_reactive_limits) { - checkConfiguration(Qmin_ <= Qmax_, "Qmin must be less than or equal to Qmax", ret); + check(Qmin_ <= Qmax_, "Qmin must be less than or equal to Qmax"); } - checkConfiguration(std::isfinite(Kqp_) && Kqp_ >= ZERO, "Kqp must be finite and non-negative", ret); - checkConfiguration(std::isfinite(Kqi_) && Kqi_ >= ZERO, "Kqi must be finite and non-negative", ret); + check(std::isfinite(Kqp_) && Kqp_ >= ZERO, "Kqp must be finite and non-negative"); + check(std::isfinite(Kqi_) && Kqi_ >= ZERO, "Kqi must be finite and non-negative"); const bool finite_voltage_limits = std::isfinite(Vmin_) && std::isfinite(Vmax_); - checkConfiguration(finite_voltage_limits, "Vmin and Vmax must be finite", ret); + check(finite_voltage_limits, "Vmin and Vmax must be finite"); if (finite_voltage_limits) { - checkConfiguration(Vmin_ <= Vmax_, "Vmin must be less than or equal to Vmax", ret); + check(Vmin_ <= Vmax_, "Vmin must be less than or equal to Vmax"); } - checkConfiguration(std::isfinite(Kvp_) && Kvp_ >= ZERO, "Kvp must be finite and non-negative", ret); - checkConfiguration(std::isfinite(Kvi_) && Kvi_ >= ZERO, "Kvi must be finite and non-negative", ret); - checkConfiguration(std::isfinite(Tiq_), "Tiq must be finite", ret); - checkConfiguration(std::isfinite(Tpord_), "Tpord must be finite", ret); + check(std::isfinite(Kvp_) && Kvp_ >= ZERO, "Kvp must be finite and non-negative"); + check(std::isfinite(Kvi_) && Kvi_ >= ZERO, "Kvi must be finite and non-negative"); + check(std::isfinite(Tiq_), "Tiq must be finite"); + check(std::isfinite(Tpord_), "Tpord must be finite"); const bool finite_ramp_limits = std::isfinite(dPmin_) && std::isfinite(dPmax_); - checkConfiguration(finite_ramp_limits, "dPmin and dPmax must be finite", ret); + check(finite_ramp_limits, "dPmin and dPmax must be finite"); if (finite_ramp_limits) { - checkConfiguration(dPmin_ < ZERO && ZERO < dPmax_, "dPmin < 0 < dPmax is required", ret); + check(dPmin_ < ZERO && ZERO < dPmax_, "dPmin < 0 < dPmax is required"); } const bool finite_active_limits = std::isfinite(Pmin_) && std::isfinite(Pmax_); - checkConfiguration(finite_active_limits, "Pmin and Pmax must be finite", ret); + check(finite_active_limits, "Pmin and Pmax must be finite"); if (finite_active_limits) { - checkConfiguration(Pmin_ <= Pmax_, "Pmin must be less than or equal to Pmax", ret); + check(Pmin_ <= Pmax_, "Pmin must be less than or equal to Pmax"); } - checkConfiguration(std::isfinite(Imax_) && Imax_ > ZERO, "Imax must be finite and positive", ret); + check(std::isfinite(Imax_) && Imax_ > ZERO, "Imax must be finite and positive"); auto check_optional_signal = [&](const char* name) { @@ -358,51 +367,16 @@ namespace GridKit iqneed0 += std::numbers::ln2_v / Math::MU + INITIALIZATION_TOLERANCE; } - const RealT d = std::sqrt(INITIALIZATION_TOLERANCE); const RealT high0 = pq_on_ * ipcmd0 + pq_off_ * iqabs0; const RealT low0 = pq_on_ * iqneed0 + pq_off_ * ipcmd0; - RealT imax = std::max(Imax_, high0); - if (pq_off_ != ZERO) - { - imax = std::max(imax, iqneed0); - } - if (low0 > ZERO) - { - const RealT ilreq = std::sqrt(low0) * std::sqrt(HALF * (low0 + std::hypot(low0, TWO * d))); - const RealT required = std::hypot(high0, std::sqrt(ilreq) * std::sqrt(std::hypot(ilreq, d))); - if (required >= imax) - { - imax = std::nextafter(required, std::numeric_limits::infinity()); - } - } - - RealT ilrhs0 = ZERO; - RealT ilmax0 = ZERO; - RealT ilnorm0 = ZERO; - RealT ilcap0 = ZERO; - for (int correction = 0; correction <= std::numeric_limits::digits && std::isfinite(imax); ++correction) - { - ilrhs0 = (imax - high0) * (imax + high0); - ilmax0 = ZERO; - if (ilrhs0 > ZERO) - { - const RealT ratio = INITIALIZATION_TOLERANCE / ilrhs0; - ilmax0 = std::sqrt(ilrhs0) * std::sqrt(TWO / (std::hypot(ratio, TWO) + ratio)); - } - ilnorm0 = std::sqrt(ilmax0 * ilmax0 + INITIALIZATION_TOLERANCE); - ilcap0 = (ilmax0 / ilnorm0) * ilmax0; - if (!(ilcap0 < low0)) - { - break; - } - const RealT next = std::nextafter(imax, std::numeric_limits::infinity()); - if (next == imax) - { - break; - } - imax = next; - } - if (ilcap0 < low0) + // Q priority uses Imax directly for reactive current, so include the + // smooth-clamp recovery margin carried by iqneed0. + const RealT imax = solveInitialLimit( + std::max({Imax_, high0, low0, iqneed0}), high0, low0); + const RealT ilmax0 = circleState(imax, high0); + const RealT ilcap0 = capacity(ilmax0); + if (!std::isfinite(imax) || !std::isfinite(ilmax0) + || !std::isfinite(ilcap0) || ilcap0 < low0) { Log::error() << "Reecb: adjusted Imax cannot include the initial current commands\n"; return 1; @@ -420,28 +394,36 @@ namespace GridKit return 1; } - const RealT iqctl0 = iqraw0 - iqv0; - const RealT pord0 = vmeas_safe0 * ipraw0; - RealT qmin = q_pi_on_ != ZERO ? std::min(Qmin_, qgen0) : Qmin_; - RealT qmax = q_pi_on_ != ZERO ? std::max(Qmax_, qgen0) : Qmax_; - RealT vmin = q_pi_on_ != ZERO ? std::min(Vmin_, vmeas0) : Vmin_; - RealT vmax = q_pi_on_ != ZERO ? std::max(Vmax_, vmeas0) : Vmax_; - const RealT infinity = std::numeric_limits::infinity(); - if (q_pi_on_ != ZERO && qmin == qgen0 && qmin < qmax) - { - qmin = std::nextafter(qmin, -infinity); - } - if (q_pi_on_ != ZERO && qmax == qgen0 && qmin < qmax) - { - qmax = std::nextafter(qmax, infinity); - } - if (q_pi_on_ != ZERO && vmin == vmeas0 && vmin < vmax) - { - vmin = std::nextafter(vmin, -infinity); - } - if (q_pi_on_ != ZERO && vmax == vmeas0 && vmin < vmax) + const RealT iqctl0 = iqraw0 - iqv0; + const RealT pord0 = vmeas_safe0 * ipraw0; + RealT qmin = Qmin_; + RealT qmax = Qmax_; + RealT vmin = Vmin_; + RealT vmax = Vmax_; + if (q_pi_on_ != ZERO) { - vmax = std::nextafter(vmax, infinity); + qmin = std::min(Qmin_, qgen0); + qmax = std::max(Qmax_, qgen0); + vmin = std::min(Vmin_, vmeas0); + vmax = std::max(Vmax_, vmeas0); + + const RealT infinity = std::numeric_limits::infinity(); + if (qmin == qgen0 && qmin < qmax) + { + qmin = std::nextafter(qmin, -infinity); + } + if (qmax == qgen0 && qmin < qmax) + { + qmax = std::nextafter(qmax, infinity); + } + if (vmin == vmeas0 && vmin < vmax) + { + vmin = std::nextafter(vmin, -infinity); + } + if (vmax == vmeas0 && vmin < vmax) + { + vmax = std::nextafter(vmax, infinity); + } } const RealT pmin = std::min(Pmin_, pord0); const RealT pmax = std::max(Pmax_, pord0); @@ -539,8 +521,8 @@ namespace GridKit const RealT iqcmd_check = Math::clamp(q_on_ * iqbase0 + q_off_ * qv0 + iqv0, -iqmax0, iqmax0); const RealT ipcmd_check = Math::clamp(pord0 / vmeas_safe0, ZERO, ipmax0); - if (!std::isfinite(imax) || !std::isfinite(ilrhs0) || ilrhs0 < ZERO || !std::isfinite(ilmax0) - || !std::isfinite(ilcap0) || !std::isfinite(iqmax0) || !std::isfinite(ipmax0) + if (!std::isfinite(imax) || !std::isfinite(ilmax0) || !std::isfinite(ilcap0) + || !std::isfinite(iqmax0) || !std::isfinite(ipmax0) || !std::isfinite(ipraw0) || !std::isfinite(iqraw0) || !std::isfinite(pord0) || !std::isfinite(pref0_system) || !std::isfinite(qtarget0) || !std::isfinite(qref0) || !std::isfinite(qext0_port) || !std::isfinite(pfaref0) || !std::isfinite(eq0) @@ -843,6 +825,7 @@ namespace GridKit const ScalarT rpord = aslew(fpord, dPmin_, dPmax_); const ScalarT ilnorm = std::sqrt(ilmax * ilmax + INITIALIZATION_TOLERANCE); const ScalarT ilcap = (ilmax / ilnorm) * ilmax; + const ScalarT high = pq_on_ * ipcmd + pq_off_ * iqcmd; const ScalarT iqmax = pq_on_ * ilcap + pq_off_ * Imax_; const ScalarT ipmax = pq_on_ * Imax_ + pq_off_ * ilcap; const ScalarT iqbase = Math::clamp(Kvp_ * epiv + xpiv, -iqmax, iqmax); @@ -855,8 +838,7 @@ namespace GridKit f[QV] = -qv_dot + q_off_ * sdip * (qref / vmeas_safe - qv) / Tiq_; f[PORD] = -pord_dot + sdip * Math::antiwindup(pord, rpord, Pmin_, Pmax_); f[VT] = -vt * vt + vr * vr + vi * vi; - f[ILMAX] = -ilmax * ilnorm + pq_on_ * (Imax_ - ipcmd) * (Imax_ + ipcmd) - + pq_off_ * (Imax_ - iqcmd) * (Imax_ + iqcmd); + f[ILMAX] = -ilmax * ilnorm + (Imax_ - high) * (Imax_ + high); f[IQCMD] = -iqcmd + Math::clamp(iqraw, -iqmax, iqmax); f[IPCMD] = -ipcmd + Math::clamp(pord / vmeas_safe, ZERO, ipmax); @@ -910,21 +892,186 @@ namespace GridKit } /** - * @brief Record one failed configuration condition + * @brief Compute the initial current-circle continuation state + * + * Solves the implemented `ILMAX` row for its nonnegative continuation + * state at a total-current limit and priority-axis current. + * + * @param[in] imax Total-current limit on the component base. + * @param[in] high Priority-axis current command on the component base. + * @return Initial `ILMAX` state on the component base, or a quiet NaN + * when the circle geometry is invalid or unrepresentable. + * @pre `imax` and `high` are finite and @f$0 \le high \le imax@f$. + */ + template + typename Reecb::RealT + Reecb::circleState(RealT imax, RealT high) + { + const RealT rhs = (imax - high) * (imax + high); + if (!std::isfinite(rhs) || rhs < ZERO) + { + return std::numeric_limits::quiet_NaN(); + } + if (rhs == ZERO) + { + return ZERO; + } + + const RealT ratio = INITIALIZATION_TOLERANCE / rhs; + return std::sqrt(rhs) + * std::sqrt(TWO + / (std::hypot(ratio, TWO) + ratio)); + } + + /** + * @brief Compute off-axis capacity from a continuation state + * + * This expression must match the `ilcap` calculation during residual + * evaluation so initialization lands on the implemented model. + * + * @param[in] ilmax Current-circle continuation state on the component base. + * @return Available off-axis current on the component base. + */ + template + typename Reecb::RealT + Reecb::capacity(RealT ilmax) + { + const RealT ilnorm = std::sqrt(ilmax * ilmax + INITIALIZATION_TOLERANCE); + return (ilmax / ilnorm) * ilmax; + } + + /** + * @brief Bisect an initial-limit interval to machine rounding + * + * The upper endpoint is returned because initialization needs the first + * not-below point; the caller separately validates that point as finite + * and feasible. A finite floating-point interval contains finitely many + * representable values, so the loop terminates when no interior midpoint + * remains. + * + * @tparam FuncT Monotone predicate type. + * @param[in] a Lower endpoint, where `below` is true. + * @param[in] b Upper endpoint, where `below` is false. + * @param[in] below Predicate returning true only when finite reconstructed + * capacity lies below the requirement. + * @return The first representable upper-side endpoint. + * @pre `a` and `b` are finite, `a < b`, and `below` is monotone. + */ + template + template + typename Reecb::RealT + Reecb::bisect(RealT a, RealT b, FuncT below) + { + while (true) + { + const RealT mid = std::midpoint(a, b); + if (mid <= a || b <= mid) + { + break; + } + + if (below(mid)) + { + a = mid; + } + else + { + b = mid; + } + } + + return b; + } + + /** + * @brief Solve the smallest initial total-current limit * - * @param[in] condition Required condition. - * @param[in] message Error message when `condition` is false. - * @param[in,out] errors Accumulated configuration-error count. + * @param[in] lower Lower bound for the component-base total-current limit. + * @param[in] high Priority-axis current command on the component base. + * @param[in] low Required off-axis capacity on the component base. + * @return Smallest representable feasible component-base limit at or above + * `lower`, or a quiet NaN when no finite limit is found. + * @pre The arguments are finite and nonnegative, with `lower >= high` and + * `lower >= low`. + * @warning This function contains conditional branching and may be used + * during initialization, but not during residual evaluation. */ template - void Reecb::checkConfiguration( - bool condition, const char* message, int& errors) + typename Reecb::RealT + Reecb::solveInitialLimit( + RealT lower, RealT high, RealT low) { - if (!condition) + const RealT nan = std::numeric_limits::quiet_NaN(); + + const RealT ilmax = circleState(lower, high); + const RealT ilcap = capacity(ilmax); + if (!std::isfinite(ilcap)) + { + return nan; + } + if (!(ilcap < low)) + { + return lower; + } + + const auto below = [high, low](RealT limit) + { + const RealT state = circleState(limit, high); + const RealT cap = capacity(state); + return std::isfinite(cap) && cap < low; + }; + + // Invert ilcap = ilmax^2 / sqrt(ilmax^2 + tolerance), then recover + // imax from imax^2 = high^2 + ilmax * sqrt(ilmax^2 + tolerance). + const RealT delta = std::sqrt(INITIALIZATION_TOLERANCE); + const RealT ilreq = std::sqrt(low) + * std::sqrt(HALF + * (low + std::hypot(low, TWO * delta))); + const RealT seed = std::hypot( + high, std::sqrt(ilreq) * std::sqrt(std::hypot(ilreq, delta))); + + const RealT maximum = std::numeric_limits::max(); + RealT a = lower; + RealT b = seed; + if (!std::isfinite(b)) + { + b = maximum; + } + else + { + b = std::max(b, std::nextafter(a, maximum)); + } + if (!(a < b)) + { + return nan; + } + + while (below(b)) + { + a = b; + if (b >= maximum) + { + return nan; + } + if (b > maximum / TWO) + { + b = maximum; + } + else + { + b *= TWO; + } + } + + const RealT result = bisect(a, b, below); + const RealT final_state = circleState(result, high); + const RealT final_cap = capacity(final_state); + if (!std::isfinite(result) || !std::isfinite(final_state) + || !std::isfinite(final_cap) || final_cap < low) { - Log::error() << "Reecb: " << message << '\n'; - errors += 1; + return nan; } + return result; } /** diff --git a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp index ed204850c..1076b3e68 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp @@ -716,7 +716,7 @@ namespace GridKit * @note System model composition is flat; nested systems are not supported. * * @throws std::runtime_error if storage allocation, child binding, model - * verification, or sparse initialization fails. + * verification, or initialization for sparse Jacobian discovery fails. */ template int SystemModel::allocate() @@ -831,10 +831,11 @@ namespace GridKit throw std::runtime_error("SystemModel allocation failed"); } - // Perform an initial Jacobian evaluation for sparse Jacobians, such that - // the dynamic solver can querry the NNZ value when it is configured. - // @todo Replace with a sparsity analysis that sets the NNZ and allocates the Jacobian - // without needing the Jacobian values. + // Sparse-pattern discovery requires an initialized operating point. A failed + // initialization aborts allocation before residual/Jacobian evaluation or + // monitor startup. + // @todo Replace with a sparsity analysis that sets the NNZ and allocates + // the Jacobian without needing the Jacobian values. if (hasJacobian()) { const int status = initialize(); @@ -848,8 +849,6 @@ namespace GridKit evaluateJacobian(); } - // Start variable monitors only after allocation and sparse initialization - // complete successfully. initializeMonitor(); startMonitor(); diff --git a/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp b/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp index a156aff2b..ff5ae3538 100644 --- a/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp @@ -649,23 +649,34 @@ namespace GridKit success *= scalarPreserved(separated.iqcmd(), 5.0e-13, "scale-separated current command"); success *= allResidualsWithinInitTolerance(separated.reecb); + // A low configured Imax requires representable bisection to preserve + // a strict low-priority command. auto capacity_data = exactness_data; capacity_data.parameters[Params::mva] = 100.0; capacity_data.parameters[Params::Pqflag] = true; capacity_data.parameters[Params::Imax] = 0.1; - Fixture capacity(capacity_data); - success *= capacity.initialize(1.7, 0.3); - success *= (capacity.evaluate() == 0); - success *= scalarPreserved(capacity.iqcmd(), 1.7, "strict low-priority command"); - success *= scalarPreserved(capacity.ipcmd(), 0.3, "strict high-priority command"); - const RealT ilmax = static_cast(capacity.reecb.y().getData()[index(Vars::ILMAX)]); - const RealT ilcap = ilmax * ilmax / std::sqrt(ilmax * ilmax + ReecbT::INITIALIZATION_TOLERANCE); - if (ilcap < 1.7) - { - std::cout << "REECB low-priority capacity does not include its initial command\n"; - success = false; + + const std::array, 2> capacity_cases{{ + {1.7, 0.3}, + {1.5, 0.5}, + }}; + for (const auto& [iqcmd, ipcmd] : capacity_cases) + { + Fixture capacity_fixture(capacity_data); + success *= capacity_fixture.initialize(iqcmd, ipcmd); + success *= (capacity_fixture.evaluate() == 0); + success *= scalarPreserved(capacity_fixture.iqcmd(), iqcmd, "low-priority command"); + success *= scalarPreserved(capacity_fixture.ipcmd(), ipcmd, "high-priority command"); + const RealT ilmax = static_cast(capacity_fixture.reecb.y().getData()[index(Vars::ILMAX)]); + const RealT ilcap = ilmax * ilmax + / std::sqrt(ilmax * ilmax + ReecbT::INITIALIZATION_TOLERANCE); + if (ilcap < iqcmd) + { + std::cout << "REECB low-priority capacity does not include its initial command\n"; + success = false; + } + success *= allResidualsWithinInitTolerance(capacity_fixture.reecb); } - success *= allResidualsWithinInitTolerance(capacity.reecb); auto nested_data = exactness_data; nested_data.parameters[Params::mva] = 100.0; @@ -1275,6 +1286,41 @@ namespace GridKit } } + { + // Selecting the priority command before forming the circle keeps an + // overflowing inactive-command factor from producing NaN. + const RealT maximum = std::numeric_limits::max(); + const RealT limit = maximum / 1024.0; + const std::array priorities{{false, true}}; + for (const bool p_priority : priorities) + { + auto data = makeData(); + data.parameters[Params::mva] = 100.0; + data.parameters[Params::Imax] = limit; + data.parameters[Params::Pqflag] = p_priority; + + Fixture fixture(data); + success *= fixture.prepare(0.0, 0.0); + setControlState(fixture.reecb); + RealT iqcmd = limit; + RealT ipcmd = maximum; + if (p_priority) + { + iqcmd = maximum; + ipcmd = limit; + } + setState(fixture.reecb, + {{Vars::ILMAX, 0.0}, + {Vars::IQCMD, iqcmd}, + {Vars::IPCMD, ipcmd}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.reecb, + {{Vars::ILMAX, 0.0}}, + "finite selected current circle"); + success *= allResidualsFinite(fixture.reecb); + } + } + { // The signed-square continuation keeps a negative capacity iterate // finite, and its magnitude still bounds the low-priority command. From 5fcc81c2baf46586e0709d77bd9f2638acab2ff9 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Thu, 6 Aug 2026 23:27:33 -0500 Subject: [PATCH 16/16] tweak namign and verbosity --- .../PhasorDynamics/Controller/REECB/README.md | 3 ++- .../PhasorDynamics/PDIntegrationTests.hpp | 2 +- .../PhasorDynamics/runPDIntegrationTests.cpp | 2 +- .../PhasorDynamics/ControllerReecbTests.hpp | 20 ------------------- .../SystemSingleComponentTests.hpp | 11 ++++++---- .../UnitTests/PhasorDynamics/SystemTests.hpp | 5 ++++- .../runControllerReecbTests.cpp | 8 ++++++++ 7 files changed, 23 insertions(+), 28 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Controller/REECB/README.md b/GridKit/Model/PhasorDynamics/Controller/REECB/README.md index 063146aec..2de5597f9 100644 --- a/GridKit/Model/PhasorDynamics/Controller/REECB/README.md +++ b/GridKit/Model/PhasorDynamics/Controller/REECB/README.md @@ -369,7 +369,8 @@ Output | Units | Description | Note - `activeCurrentControl()` checks active-current control and current priority. - `dependencyTracking()` checks sparse dependencies. - `jacobian()` compares the Enzyme and dependency-tracking Jacobians. -- `regcaReecb()`, `reecb()`, and `initializationFailure()` check system wiring. +- `regcaReecb()` checks REGCA-REECB signal wiring. +- `reecb()` checks construction through the production system-data path. ## Appendix A: `iclamp` diff --git a/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp b/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp index 070ae6ac6..cd7011983 100644 --- a/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp +++ b/tests/IntegrationTests/PhasorDynamics/PDIntegrationTests.hpp @@ -726,7 +726,7 @@ namespace GridKit /// A finite plant-reference pulse moves the coupled REPCA, REECB, and /// REGCA states, after which the closed loop returns to equilibrium. - TestOutcome renewableControlChainRecovery() + TestOutcome regcaReecbRepca() { using namespace GridKit::PhasorDynamics::Controller; using namespace GridKit::PhasorDynamics::Converter; diff --git a/tests/IntegrationTests/PhasorDynamics/runPDIntegrationTests.cpp b/tests/IntegrationTests/PhasorDynamics/runPDIntegrationTests.cpp index 575d8e884..5be32152b 100644 --- a/tests/IntegrationTests/PhasorDynamics/runPDIntegrationTests.cpp +++ b/tests/IntegrationTests/PhasorDynamics/runPDIntegrationTests.cpp @@ -12,7 +12,7 @@ int main() result += test.twoBusTgov1(); result += test.threeBusBasic(); result += test.threeBusClassical(); - result += test.renewableControlChainRecovery(); + result += test.regcaReecbRepca(); return result.summary(); } diff --git a/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp b/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp index ff5ae3538..7e2752a4d 100644 --- a/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ControllerReecbTests.hpp @@ -19,15 +19,12 @@ #include #include #include -#include #include namespace GridKit { namespace Testing { - using Log = ::GridKit::Utilities::Logger; - template class ControllerReecbTests { @@ -48,9 +45,6 @@ namespace GridKit { TestStatus success = true; - noteExpectedLogs("Testing REECB defaults, parameter floors, and invalid " - "configurations. Logged errors and warnings are expected."); - PhasorDynamics::Bus bus(1.0, 0.0); PhasorDynamics::Controller::Reecb empty(&bus); @@ -330,9 +324,6 @@ namespace GridKit { TestStatus success = true; - noteExpectedLogs("Testing adjusted REECB limits and inadmissible initialization points. " - "Logged warnings and errors are expected."); - const auto data = makeData(); success *= initializationRejectedAtomically(data, 0.75, -0.1, "negative active-current command"); @@ -762,9 +753,6 @@ namespace GridKit { TestStatus success = true; - noteExpectedLogs("Testing REECB selector configurations. " - "Atypical PfFlag/QFlag warnings are expected."); - const std::array selector_values{{false, true}}; for (const bool pf : selector_values) { @@ -2508,14 +2496,6 @@ namespace GridKit return success; } - void noteExpectedLogs(const char* message) const - { - const auto previous_verbosity = Log::verbosity(); - Log::setVerbosity(Log::Verbosity::EVERYTHING); - Log::misc() << message << '\n'; - Log::setVerbosity(previous_verbosity); - } - void numberVariables(Fixture& fixture, RealT alpha) const { auto* y = fixture.reecb.y().getData(); diff --git a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp index 5c8f4d515..18592dacc 100644 --- a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp +++ b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp @@ -300,9 +300,10 @@ namespace GridKit /// REECB through the production data path. TestOutcome reecb() { - using Data = PhasorDynamics::Controller::ReecbData; - using Buses = typename Data::Buses; - using Vars = PhasorDynamics::Controller::ReecbInternalVariables; + using Data = PhasorDynamics::Controller::ReecbData; + using Buses = typename Data::Buses; + using Params = typename Data::Parameters; + using Vars = PhasorDynamics::Controller::ReecbInternalVariables; constexpr IdxT bus_id = static_cast(1); @@ -316,7 +317,9 @@ namespace GridKit data.bus[0].Vi0 = static_cast(0.0); Data reecb_data; - reecb_data.buses[Buses::bus] = bus_id; + reecb_data.buses[Buses::bus] = bus_id; + reecb_data.parameters[Params::Tp] = static_cast(0.02); + reecb_data.parameters[Params::Pmin] = static_cast(-1.0); data.reecb.push_back(reecb_data); PhasorDynamics::SystemModel system(data); diff --git a/tests/UnitTests/PhasorDynamics/SystemTests.hpp b/tests/UnitTests/PhasorDynamics/SystemTests.hpp index 1235ac56a..02eba339d 100644 --- a/tests/UnitTests/PhasorDynamics/SystemTests.hpp +++ b/tests/UnitTests/PhasorDynamics/SystemTests.hpp @@ -415,7 +415,9 @@ namespace GridKit success *= system.verify() == 0; - std::cout << "Testing expected component initialization failure.\n"; + const auto previous_verbosity = Log::verbosity(); + Log::setVerbosity(Log::Verbosity::NONE); + if (system.hasJacobian()) { success *= throws([&]() @@ -427,6 +429,7 @@ namespace GridKit success *= system.initialize() != 0; } + Log::setVerbosity(previous_verbosity); return success.report(__func__); } diff --git a/tests/UnitTests/PhasorDynamics/runControllerReecbTests.cpp b/tests/UnitTests/PhasorDynamics/runControllerReecbTests.cpp index 3750436a9..09522fd69 100644 --- a/tests/UnitTests/PhasorDynamics/runControllerReecbTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runControllerReecbTests.cpp @@ -1,7 +1,14 @@ +#include + #include "ControllerReecbTests.hpp" int main() { + using Log = GridKit::Utilities::Logger; + + const auto previous_verbosity = Log::verbosity(); + Log::setVerbosity(Log::Verbosity::NONE); + GridKit::Testing::TestingResults result; GridKit::Testing::ControllerReecbTests test; @@ -19,5 +26,6 @@ int main() result += test.jacobian(); #endif + Log::setVerbosity(previous_verbosity); return result.summary(); }