From ce93abc388d949632ad24d53b77b5a5aaeef6366 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Tue, 14 Jul 2026 20:35:20 -0500 Subject: [PATCH 01/18] Docs and Implementation for ESDC1A [skip ci] --- CHANGELOG.md | 1 + .../Model/PhasorDynamics/ComponentLibrary.hpp | 1 + .../PhasorDynamics/Exciter/CMakeLists.txt | 1 + .../Exciter/ESDC1A/CMakeLists.txt | 46 ++ .../PhasorDynamics/Exciter/ESDC1A/Esdc1a.cpp | 27 + .../PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp | 158 ++++ .../Exciter/ESDC1A/Esdc1aData.hpp | 94 +++ .../ESDC1A/Esdc1aDependencyTracking.cpp | 27 + .../Exciter/ESDC1A/Esdc1aEnzyme.cpp | 105 +++ .../Exciter/ESDC1A/Esdc1aImpl.hpp | 604 +++++++++++++++ .../PhasorDynamics/Exciter/ESDC1A/README.md | 344 ++++++++ .../Model/PhasorDynamics/Exciter/README.md | 3 +- GridKit/Model/PhasorDynamics/INPUT_FORMAT.md | 1 + .../Model/PhasorDynamics/SystemModelData.hpp | 3 + .../SystemModelDataJSONParser.hpp | 6 + .../Model/PhasorDynamics/SystemModelImpl.hpp | 48 ++ .../Figures/PhasorDynamics/ESDC1A/diagram.png | Bin 0 -> 58305 bytes .../PhasorDynamics/Exciter/ESDC1A/README.md | 6 + .../Model/PhasorDynamics/Exciter/README.md | 1 + tests/UnitTests/PhasorDynamics/CMakeLists.txt | 10 + .../PhasorDynamics/ExciterEsdc1aTests.hpp | 732 ++++++++++++++++++ .../PhasorDynamics/runExciterEsdc1aTests.cpp | 18 + 22 files changed, 2235 insertions(+), 1 deletion(-) create mode 100644 GridKit/Model/PhasorDynamics/Exciter/ESDC1A/CMakeLists.txt create mode 100644 GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.cpp create mode 100644 GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp create mode 100644 GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp create mode 100644 GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aDependencyTracking.cpp create mode 100644 GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aEnzyme.cpp create mode 100644 GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp create mode 100644 GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md create mode 100644 docs/Figures/PhasorDynamics/ESDC1A/diagram.png create mode 100644 docs/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md create mode 100644 tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp create mode 100644 tests/UnitTests/PhasorDynamics/runExciterEsdc1aTests.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c7d6bcff..ded783307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ - Added component model developer checklist to a README file. - Added `IEEEST` Stabilizer Model - Added `SEXS-PTI` Exciter Model +- Added `ESDC1A` Exciter Model - Added `GENSAL` Machine Model - Added 200 Bus Synthetic Illinois Case - Added node objects to `PowerElectronics` module & updated all examples to make use of them. diff --git a/GridKit/Model/PhasorDynamics/ComponentLibrary.hpp b/GridKit/Model/PhasorDynamics/ComponentLibrary.hpp index 6ac2375f3..0e110fd67 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/Exciter/CMakeLists.txt b/GridKit/Model/PhasorDynamics/Exciter/CMakeLists.txt index 1120e20c2..7e5e4b9e3 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/CMakeLists.txt +++ b/GridKit/Model/PhasorDynamics/Exciter/CMakeLists.txt @@ -3,5 +3,6 @@ # - Luke Lowery # ]] +add_subdirectory(ESDC1A) add_subdirectory(IEEET1) add_subdirectory(SEXS-PTI) diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/CMakeLists.txt b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/CMakeLists.txt new file mode 100644 index 000000000..53807c5df --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/CMakeLists.txt @@ -0,0 +1,46 @@ +# [[ +# Author(s): +# - Luke Lowery +# ]] + +set(_install_headers Esdc1a.hpp Esdc1aData.hpp) + +if(GRIDKIT_ENABLE_ENZYME) + gridkit_add_library( + phasor_dynamics_exciter_esdc1a + SOURCES Esdc1aEnzyme.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_exciter_esdc1a + SOURCES Esdc1a.cpp + HEADERS ${_install_headers} + INCLUDE_DIRECTORIES PRIVATE ${GRIDKIT_THIRD_PARTY_DIR}/magic-enum/include + LINK_LIBRARIES GridKit::phasor_dynamics_core GridKit::phasor_dynamics_signal) +endif() + +gridkit_add_library( + phasor_dynamics_exciter_esdc1a_dependency_tracking + SOURCES Esdc1aDependencyTracking.cpp + INCLUDE_DIRECTORIES PRIVATE ${GRIDKIT_THIRD_PARTY_DIR}/magic-enum/include + LINK_LIBRARIES GridKit::phasor_dynamics_core GridKit::phasor_dynamics_signal_dependency_tracking) + +target_link_libraries( + phasor_dynamics_components + INTERFACE GridKit::phasor_dynamics_exciter_esdc1a) +target_link_libraries( + phasor_dynamics_components_dependency_tracking + INTERFACE GridKit::phasor_dynamics_exciter_esdc1a_dependency_tracking) diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.cpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.cpp new file mode 100644 index 000000000..6c5c0f89c --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.cpp @@ -0,0 +1,27 @@ +/** + * @file Esdc1a.cpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Non-Enzyme instantiation for the ESDC1A exciter model. + */ + +#include "Esdc1aImpl.hpp" + +namespace GridKit +{ + namespace PhasorDynamics + { + namespace Exciter + { + template + int Esdc1a::evaluateJacobian() + { + Log::misc() << "Evaluate Jacobian for Esdc1a..." << std::endl; + Log::misc() << "Jacobian evaluation not implemented!" << std::endl; + return 0; + } + + template class Esdc1a; + template class Esdc1a; + } // namespace Exciter + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp new file mode 100644 index 000000000..8c28b97c3 --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp @@ -0,0 +1,158 @@ +/** + * @file Esdc1a.hpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Declaration of the ESDC1A exciter model. + */ + +#pragma once + +#include +#include +#include + +#include +#include +#include +#include + +namespace GridKit +{ + namespace PhasorDynamics + { + template + class BusBase; + + template + class SignalNode; + + namespace Exciter + { + /// Internal variables of an `Esdc1a`. + enum class Esdc1aInternalVariables : size_t + { + EFDP, ///< Field-voltage state before optional speed multiplier + VC, ///< Sensed compensated voltage + VR, ///< Voltage-regulator output + VF, ///< Stabilizing feedback output + XLL, ///< Lead-lag state + EV, ///< Voltage-regulator input error + VLL, ///< Lead-lag block output + VHV, ///< High-value gate output + SE, ///< Saturation coefficient + VFE, ///< Exciter feedback signal + EFD, ///< Field-voltage output + MAXIMUM, + }; + + /// External variables of an `Esdc1a`. + enum class Esdc1aExternalVariables : size_t + { + OMEGA, ///< Machine speed deviation + VREF, ///< Voltage-control reference + VS, ///< Stabilizer input signal + VUEL, ///< Under-excitation limiter input + MAXIMUM, + }; + + template + class Esdc1a : public Component + { + using Component::abs_tol_; + using Component::allocated_; + using Component::alpha_; + 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::variable_indices_; + using Component::wb_; + using Component::y_; + using Component::yp_; + + public: + using RealT = typename Component::RealT; + using bus_type = BusBase; + using signal_type = SignalNode; + using model_data_type = Esdc1aData; + using MonitorT = Model::VariableMonitor; + + Esdc1a(bus_type* bus); + Esdc1a(bus_type* bus, const model_data_type& data); + ~Esdc1a(); + + 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 rel_tol) 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 model_data_type& data); + void setDerivedParams(); + void initializeMonitor(); + + static constexpr RealT TIME_CONSTANT_MINIMUM = static_cast(1.0e-3); + + bus_type* bus_{nullptr}; + + RealT Tr_{0.0}; + RealT Ka_{40.0}; + RealT Ta_{0.1}; + RealT Tb_{0.0}; + RealT Tc_{0.0}; + RealT Vrmax_{1.0}; + RealT Vrmin_{-1.0}; + RealT Ke_{0.1}; + RealT Te_{0.5}; + RealT Kf_{0.05}; + RealT Tf1_{0.7}; + RealT spdmlt_{0.0}; + RealT E1_{2.8}; + RealT Se1_{0.08}; + RealT E2_{3.7}; + RealT Se2_{0.33}; + IdxT UEL_{0}; + RealT exclim_{1.0}; + + IdxT parameter_error_count_{0}; + + RealT sUEL_{0}; + RealT sUELoff_{1}; + RealT slim_{0}; + RealT slim_off_{1}; + RealT SA_{0}; + RealT SB_{0}; + + ScalarT vref_{0}; + + ComponentSignals signals_; + std::unique_ptr monitor_; + + std::vector ws_; + std::vector ws_indices_; + }; + } // namespace Exciter + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp new file mode 100644 index 000000000..3b389f81f --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp @@ -0,0 +1,94 @@ +/** + * @file Esdc1aData.hpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Modeling data for the ESDC1A exciter model. + */ + +#pragma once + +#include + +namespace GridKit +{ + namespace PhasorDynamics + { + namespace Exciter + { + /// Parameter keys for the ESDC1A exciter model. + enum class Esdc1aParameters + { + Tr, ///< Transducer time constant + Ka, ///< Voltage-regulator gain + Ta, ///< Voltage-regulator time constant + Tb, ///< Lead-lag denominator time constant + Tc, ///< Lead-lag numerator time constant + Vrmax, ///< Maximum voltage-regulator output + Vrmin, ///< Minimum voltage-regulator output + Ke, ///< Exciter field-resistance line-slope margin + Te, ///< Exciter field time constant + Kf, ///< Stabilizing feedback gain + Tf1, ///< Feedback lead time constant + Spdmlt, ///< Speed multiplier flag + E1, ///< First saturation voltage point + Se1, ///< Saturation value at E1 + E2, ///< Second saturation voltage point + Se2, ///< Saturation value at E2 + UEL, ///< UEL input-location selector + exclim ///< Exciter feedback lower-limit flag + }; + + /// Buses for the ESDC1A exciter model. + enum class Esdc1aBuses : size_t + { + bus, ///< Unique ID of the terminal bus + SIZE + }; + + /// Signal inputs for the ESDC1A exciter model. + enum class Esdc1aSignalInputs : size_t + { + speed, ///< Unique ID of the generator speed-deviation signal + vref, ///< Unique ID of the voltage-reference signal + vs, ///< Unique ID of the optional stabilizer input signal + vuel, ///< Unique ID of the optional UEL input signal + SIZE + }; + + /// Signal outputs for the ESDC1A exciter model. + enum class Esdc1aSignalOutputs : size_t + { + efd, ///< Unique ID of the output EFD signal + SIZE + }; + + /// Variables available through the monitor interface. + enum class Esdc1aMonitorableVariables + { + efd, ///< Field-voltage output + vc, ///< Sensed compensated voltage + vr, ///< Voltage-regulator output + vf, ///< Stabilizing feedback state + se, ///< Saturation coefficient + vfe ///< Exciter feedback signal + }; + + template + struct Esdc1aData : public ComponentData + { + Esdc1aData() = default; + + using Parameters = Esdc1aParameters; + using Buses = Esdc1aBuses; + using SignalInputs = Esdc1aSignalInputs; + using SignalOutputs = Esdc1aSignalOutputs; + using MonitorableVariables = Esdc1aMonitorableVariables; + }; + } // namespace Exciter + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aDependencyTracking.cpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aDependencyTracking.cpp new file mode 100644 index 000000000..c7c25aec6 --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aDependencyTracking.cpp @@ -0,0 +1,27 @@ +/** + * @file Esdc1aDependencyTracking.cpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Dependency-tracking instantiations for the ESDC1A exciter model. + */ + +#include "Esdc1aImpl.hpp" + +namespace GridKit +{ + namespace PhasorDynamics + { + namespace Exciter + { + template + int Esdc1a::evaluateJacobian() + { + Log::misc() << "Evaluate Jacobian for Esdc1a..." << std::endl; + Log::misc() << "Jacobian evaluation not implemented!" << std::endl; + return 0; + } + + template class Esdc1a; + template class Esdc1a; + } // namespace Exciter + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aEnzyme.cpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aEnzyme.cpp new file mode 100644 index 000000000..c364ff9c7 --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aEnzyme.cpp @@ -0,0 +1,105 @@ +/** + * @file Esdc1aEnzyme.cpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Enzyme sparse Jacobian for the ESDC1A exciter model. + */ + +#include + +#include "Esdc1aImpl.hpp" + +namespace GridKit +{ + namespace PhasorDynamics + { + namespace Exciter + { + template + int Esdc1a::evaluateJacobian() + { + Log::misc() << "Evaluate Jacobian for Esdc1a..." << 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 = 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::Exciter::Esdc1a; + 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 Esdc1a; + template class Esdc1a; + } // namespace Exciter + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp new file mode 100644 index 000000000..138ebfef2 --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp @@ -0,0 +1,604 @@ +/** + * @file Esdc1aImpl.hpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Definition of the ESDC1A exciter model. + */ + +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace GridKit +{ + namespace PhasorDynamics + { + namespace Exciter + { + using Log = ::GridKit::Utilities::Logger; + + template + Esdc1a::Esdc1a(bus_type* bus) + : bus_(bus) + { + setDerivedParams(); + size_ = static_cast(Esdc1aInternalVariables::MAXIMUM); + } + + template + Esdc1a::Esdc1a(bus_type* bus, const model_data_type& data) + : bus_(bus), + monitor_(std::make_unique(data)) + { + initModelParams(data); + setDerivedParams(); + initializeMonitor(); + size_ = static_cast(Esdc1aInternalVariables::MAXIMUM); + } + + template + Esdc1a::~Esdc1a() + { + } + + template + void Esdc1a::initModelParams(const model_data_type& data) + { + using Params = typename model_data_type::Parameters; + + parameter_error_count_ = 0; + + 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() << "Esdc1a: 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 = *bool_value ? ONE : ZERO; + } + 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() << "Esdc1a: parameter '" << name << "' must be bool or 0/1\n"; + ++parameter_error_count_; + } + }; + + auto load_selector = [&](auto key, IdxT& target, const char* name) + { + if (!data.parameters.contains(key)) + { + return; + } + + const auto& value = data.parameters.at(key); + if (const auto* index_value = std::get_if(&value)) + { + target = *index_value; + } + else if (const auto* real_value = std::get_if(&value)) + { + const RealT rounded = std::round(*real_value); + if (*real_value >= ZERO && *real_value == rounded) + { + target = static_cast(rounded); + } + else + { + Log::error() << "Esdc1a: parameter '" << name << "' must be an integer selector\n"; + ++parameter_error_count_; + } + } + else + { + Log::error() << "Esdc1a: parameter '" << name << "' must be an integer selector\n"; + ++parameter_error_count_; + } + }; + + load_real(Params::Tr, Tr_, "Tr"); + load_real(Params::Ka, Ka_, "Ka"); + load_real(Params::Ta, Ta_, "Ta"); + load_real(Params::Tb, Tb_, "Tb"); + load_real(Params::Tc, Tc_, "Tc"); + load_real(Params::Vrmax, Vrmax_, "Vrmax"); + load_real(Params::Vrmin, Vrmin_, "Vrmin"); + load_real(Params::Ke, Ke_, "Ke"); + load_real(Params::Te, Te_, "Te"); + load_real(Params::Kf, Kf_, "Kf"); + load_real(Params::Tf1, Tf1_, "Tf1"); + load_switch(Params::Spdmlt, spdmlt_, "Spdmlt"); + load_real(Params::E1, E1_, "E1"); + load_real(Params::Se1, Se1_, "Se1"); + load_real(Params::E2, E2_, "E2"); + load_real(Params::Se2, Se2_, "Se2"); + load_selector(Params::UEL, UEL_, "UEL"); + load_switch(Params::exclim, exclim_, "exclim"); + } + + template + void Esdc1a::setDerivedParams() + { + Tr_ = std::max(Tr_, TIME_CONSTANT_MINIMUM); + Tb_ = std::max(Tb_, TIME_CONSTANT_MINIMUM); + Tf1_ = std::max(Tf1_, TIME_CONSTANT_MINIMUM); + + sUEL_ = UEL_ >= static_cast(2) ? ONE : ZERO; + sUELoff_ = ONE - sUEL_; + slim_ = exclim_; + slim_off_ = ONE - slim_; + + if (Se1_ == ZERO && Se2_ == ZERO) + { + SA_ = ZERO; + SB_ = ZERO; + return; + } + if (E1_ <= ZERO || E2_ <= ZERO || E1_ == E2_ + || Se1_ <= ZERO || Se2_ <= ZERO || Se1_ == Se2_) + { + SA_ = ZERO; + SB_ = ZERO; + return; + } + + const RealT C = std::sqrt(Se2_ / Se1_); + SA_ = (C * E1_ - E2_) / (C - ONE); + SB_ = Se1_ / ((E1_ - SA_) * (E1_ - SA_)); + } + + template + const Model::VariableMonitorBase* Esdc1a::getMonitor() const + { + return monitor_.get(); + } + + template + void Esdc1a::initializeMonitor() + { + using Variable = typename model_data_type::MonitorableVariables; + auto index = [](Esdc1aInternalVariables variable) + { + return static_cast(variable); + }; + + monitor_->set(Variable::efd, [this, index] + { return y_.getData()[index(Esdc1aInternalVariables::EFD)]; }); + monitor_->set(Variable::vc, [this, index] + { return y_.getData()[index(Esdc1aInternalVariables::VC)]; }); + monitor_->set(Variable::vr, [this, index] + { return y_.getData()[index(Esdc1aInternalVariables::VR)]; }); + monitor_->set(Variable::vf, [this, index] + { return y_.getData()[index(Esdc1aInternalVariables::VF)]; }); + monitor_->set(Variable::se, [this, index] + { return y_.getData()[index(Esdc1aInternalVariables::SE)]; }); + monitor_->set(Variable::vfe, [this, index] + { return y_.getData()[index(Esdc1aInternalVariables::VFE)]; }); + } + + template + int Esdc1a::setGridKitComponentID(IdxT component_id) + { + gridkit_component_id_ = component_id; + return 0; + } + + template + int Esdc1a::allocate() + { + size_ = static_cast(Esdc1aInternalVariables::MAXIMUM); + auto size = static_cast(size_); + + if (!allocated_) + { + this->allocateVectors(size_); + } + + tag_.assign(size, false); + variable_indices_.resize(size); + residual_indices_.resize(size); + + wb_.assign(2, ScalarT{0}); + + auto signal_size = static_cast(Esdc1aExternalVariables::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(Esdc1aInternalVariables::EFD)], + &(this->getVariableIndex(static_cast(Esdc1aInternalVariables::EFD)))); + } + + allocated_ = true; + return 0; + } + + template + int Esdc1a::verify() const + { + int ret = static_cast(parameter_error_count_); + + auto check = [&](bool condition, const char* message) + { + if (!condition) + { + Log::error() << "Esdc1a: " << message << '\n'; + ret += 1; + } + }; + + if (bus_ == nullptr) + { + Log::error() << "Esdc1a: bus pointer is null\n"; + ret += 1; + } + + check(Ka_ > ZERO, "Ka must be positive"); + check(Ta_ > ZERO, "Ta must be positive"); + check(Tc_ >= ZERO, "Tc must be non-negative"); + check(Te_ > ZERO, "Te must be positive"); + check(Vrmin_ <= Vrmax_, "Vrmin must be less than or equal to Vrmax"); + check(spdmlt_ == ZERO || spdmlt_ == ONE, "Spdmlt must be 0 or 1"); + check(exclim_ == ZERO || exclim_ == ONE, "exclim must be 0 or 1"); + check(UEL_ >= static_cast(0) && UEL_ <= static_cast(3), + "UEL must be 0, 1, 2, or 3"); + + if (!(Se1_ == ZERO && Se2_ == ZERO) ) + { + check(E1_ > ZERO, "E1 must be positive when saturation is enabled"); + check(E2_ > ZERO, "E2 must be positive when saturation is enabled"); + check(Se1_ > ZERO, "Se1 must be positive when saturation is enabled"); + check(Se2_ > ZERO, "Se2 must be positive when saturation is enabled"); + check(E1_ != E2_, "E1 and E2 must differ when saturation is enabled"); + check(Se1_ != Se2_, "Se1 and Se2 must differ when saturation is enabled"); + } + + if (!signals_.template isAssigned()) + { + Log::error() << "Esdc1a: required EFD signal is not assigned\n"; + ret += 1; + } + + if (spdmlt_ == ONE + && !signals_.template isAttached()) + { + Log::error() << "Esdc1a: speed signal is required when Spdmlt is enabled\n"; + ret += 1; + } + + auto check_attached_signal = [&](bool attached, bool linked, const char* name) + { + if (attached && !linked) + { + Log::error() << "Esdc1a: " << name << " signal attached with no linked source\n"; + ret += 1; + } + }; + + check_attached_signal( + signals_.template isAttached(), + signals_.template isAttached() + && signals_.template isLinked(), + "speed"); + check_attached_signal( + signals_.template isAttached(), + signals_.template isAttached() + && signals_.template isLinked(), + "VREF"); + check_attached_signal( + signals_.template isAttached(), + signals_.template isAttached() + && signals_.template isLinked(), + "VS"); + check_attached_signal( + signals_.template isAttached(), + signals_.template isAttached() + && signals_.template isLinked(), + "VUEL"); + + return ret; + } + + template + int Esdc1a::initialize() + { + if (verify() > 0) + { + Log::error() << "Esdc1a: cannot initialize with invalid configuration\n"; + return 1; + } + + const auto EFDP = static_cast(Esdc1aInternalVariables::EFDP); + const auto VC = static_cast(Esdc1aInternalVariables::VC); + const auto VR = static_cast(Esdc1aInternalVariables::VR); + const auto VF = static_cast(Esdc1aInternalVariables::VF); + const auto XLL = static_cast(Esdc1aInternalVariables::XLL); + const auto EV = static_cast(Esdc1aInternalVariables::EV); + const auto VLL = static_cast(Esdc1aInternalVariables::VLL); + const auto VHV = static_cast(Esdc1aInternalVariables::VHV); + const auto SE = static_cast(Esdc1aInternalVariables::SE); + const auto VFE = static_cast(Esdc1aInternalVariables::VFE); + const auto EFD = static_cast(Esdc1aInternalVariables::EFD); + + auto* y = y_.getData(); + auto* yp = yp_.getData(); + + ScalarT omega0{ZERO}; + if (signals_.template isAttached()) + { + omega0 = signals_.template readExternalVariable(); + } + + ScalarT vs0{ZERO}; + if (signals_.template isAttached()) + { + vs0 = signals_.template readExternalVariable(); + } + + ScalarT vuel0{ZERO}; + if (signals_.template isAttached()) + { + vuel0 = signals_.template readExternalVariable(); + } + + const ScalarT d0 = ONE + spdmlt_ * omega0; + if (d0 == ZERO) + { + Log::error() << "Esdc1a: speed multiplier denominator is zero at initialization\n"; + return 1; + } + + const ScalarT Ec0 = std::sqrt(bus_->Vr() * bus_->Vr() + bus_->Vi() * bus_->Vi()); + + const ScalarT efd0 = y[EFD]; + const ScalarT efdp0 = efd0 / d0; + const ScalarT se0 = SB_ * Math::qramp(efdp0 - SA_); + const ScalarT vfe0 = slim_off_ * (Ke_ + se0) * efdp0 + + slim_ * Math::ramp((Ke_ + se0) * efdp0); + const ScalarT vr0 = vfe0; + const ScalarT vhv0 = vr0 / Ka_; + auto inverse_ramp = [](RealT y) + { + const RealT scaled_y = Math::MU * y; + if (scaled_y > static_cast(50.0)) + { + return y; + } + return std::log(std::expm1(scaled_y)) / Math::MU; + }; + + ScalarT gate_input0 = vhv0; + if (sUEL_ == ZERO) + { + const RealT ramp_target = static_cast(vhv0 - vuel0); + if (ramp_target <= ZERO) + { + Log::error() << "Esdc1a: smooth high-value gate is active at initialization\n"; + return 1; + } + gate_input0 = vuel0 + inverse_ramp(ramp_target); + } + + const ScalarT vc0 = Ec0; + const ScalarT vf0 = ScalarT{ZERO}; + const ScalarT ev0 = gate_input0; + const ScalarT xll0 = gate_input0; + const ScalarT vll0 = gate_input0; + + if (vr0 < Vrmin_ || vr0 > Vrmax_) + { + Log::error() << "Esdc1a: initialized VR is outside limits\n"; + return 1; + } + + vref_ = ev0 + vc0 + vf0 - vs0 - sUEL_ * vuel0; + if (signals_.template isAttached()) + { + signals_.template writeExternalVariable(vref_); + } + + y[EFDP] = efdp0; + y[VC] = vc0; + y[VR] = vr0; + y[VF] = vf0; + y[XLL] = xll0; + y[EV] = ev0; + y[VLL] = vll0; + y[VHV] = vhv0; + y[SE] = se0; + y[VFE] = vfe0; + y[EFD] = efd0; + + for (IdxT i = 0; i < size_; ++i) + { + yp[i] = ZERO; + } + + y_.setDataUpdated(); + yp_.setDataUpdated(); + return 0; + } + + template + int Esdc1a::tagDifferentiable() + { + std::fill(tag_.begin(), tag_.end(), false); + tag_[static_cast(Esdc1aInternalVariables::EFDP)] = true; + tag_[static_cast(Esdc1aInternalVariables::VC)] = true; + tag_[static_cast(Esdc1aInternalVariables::VR)] = true; + tag_[static_cast(Esdc1aInternalVariables::VF)] = true; + tag_[static_cast(Esdc1aInternalVariables::XLL)] = true; + return 0; + } + + template + int Esdc1a::setAbsoluteTolerance(RealT rel_tol) + { + abs_tol_.setToConst(static_cast(rel_tol)); + return 0; + } + + template + __attribute__((always_inline)) inline int Esdc1a::evaluateInternalResidual( + const ScalarT* y, + const ScalarT* yp, + const ScalarT* wb, + const ScalarT* ws, + ScalarT* f) + { + const auto EFDP = static_cast(Esdc1aInternalVariables::EFDP); + const auto VC = static_cast(Esdc1aInternalVariables::VC); + const auto VR = static_cast(Esdc1aInternalVariables::VR); + const auto VF = static_cast(Esdc1aInternalVariables::VF); + const auto XLL = static_cast(Esdc1aInternalVariables::XLL); + const auto EV = static_cast(Esdc1aInternalVariables::EV); + const auto VLL = static_cast(Esdc1aInternalVariables::VLL); + const auto VHV = static_cast(Esdc1aInternalVariables::VHV); + const auto SE = static_cast(Esdc1aInternalVariables::SE); + const auto VFE = static_cast(Esdc1aInternalVariables::VFE); + const auto EFD = static_cast(Esdc1aInternalVariables::EFD); + + const auto OMEGA = static_cast(Esdc1aExternalVariables::OMEGA); + const auto VREF = static_cast(Esdc1aExternalVariables::VREF); + const auto VS = static_cast(Esdc1aExternalVariables::VS); + const auto VUEL = static_cast(Esdc1aExternalVariables::VUEL); + + const ScalarT efdp = y[EFDP]; + const ScalarT vc = y[VC]; + const ScalarT vr = y[VR]; + const ScalarT vf = y[VF]; + const ScalarT xll = y[XLL]; + const ScalarT ev = y[EV]; + const ScalarT vll = y[VLL]; + const ScalarT vhv = y[VHV]; + const ScalarT se = y[SE]; + const ScalarT vfe = y[VFE]; + const ScalarT efd = y[EFD]; + + const ScalarT omega = ws[OMEGA]; + const ScalarT vref = ws[VREF]; + const ScalarT vs = ws[VS]; + const ScalarT vuel = ws[VUEL]; + + const ScalarT Ec = std::sqrt(wb[0] * wb[0] + wb[1] * wb[1]); + const ScalarT ev_target = vref + vs + sUEL_ * vuel - vc - vf; + + f[EFDP] = -yp[EFDP] + (vr - vfe) / Te_; + f[VC] = -yp[VC] + (Ec - vc) / Tr_; + f[VR] = -yp[VR] + Math::antiwindup(vr, -vr + Ka_ * vhv, Vrmin_, Vrmax_) / Ta_; + f[VF] = -yp[VF] + (-vf + Kf_ * (vr - vfe) / Te_) / Tf1_; + f[XLL] = -yp[XLL] + (ev - xll) / Tb_; + f[EV] = -ev + ev_target; + f[VLL] = -vll + xll + (Tc_ / Tb_) * (ev - xll); + f[VHV] = -vhv + sUEL_ * vll + sUELoff_ * Math::max(vll, vuel); + f[SE] = -se + SB_ * Math::qramp(efdp - SA_); + f[VFE] = -vfe + slim_off_ * (Ke_ + se) * efdp + slim_ * Math::ramp((Ke_ + se) * efdp); + f[EFD] = -efd + (ONE + spdmlt_ * omega) * efdp; + + return 0; + } + + template + int Esdc1a::evaluateResidual() + { + const auto OMEGA = static_cast(Esdc1aExternalVariables::OMEGA); + const auto VREF = static_cast(Esdc1aExternalVariables::VREF); + const auto VS = static_cast(Esdc1aExternalVariables::VS); + const auto VUEL = static_cast(Esdc1aExternalVariables::VUEL); + + std::fill(ws_.begin(), ws_.end(), ScalarT{ZERO}); + std::fill(ws_indices_.begin(), ws_indices_.end(), INVALID_INDEX); + ws_[VREF] = vref_; + + if (signals_.template isAttached()) + { + ws_[OMEGA] = signals_.template readExternalVariable(); + ws_indices_[OMEGA] = + signals_.template readExternalVariableIndex(); + } + if (signals_.template isAttached()) + { + ws_[VREF] = signals_.template readExternalVariable(); + ws_indices_[VREF] = + signals_.template readExternalVariableIndex(); + } + if (signals_.template isAttached()) + { + ws_[VS] = signals_.template readExternalVariable(); + ws_indices_[VS] = + signals_.template readExternalVariableIndex(); + } + if (signals_.template isAttached()) + { + ws_[VUEL] = signals_.template readExternalVariable(); + ws_indices_[VUEL] = + signals_.template readExternalVariableIndex(); + } + + wb_[0] = bus_->Vr(); + wb_[1] = bus_->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 Exciter + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md new file mode 100644 index 000000000..e46f1ce61 --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md @@ -0,0 +1,344 @@ +# **IEEE DC1A Excitation System Model (ESDC1A)** + +ESDC1A is an IEEE DC1A excitation-system model. In GridKit it reads the +connected bus voltage, optional stabilizer and under-excitation limiter signals, +and publishes field voltage. + +## Notes + +- Internal voltage signals are on model base unless otherwise stated. +- The connected bus supplies $E_C=\sqrt{V_{\mathrm{r}}^2+V_{\mathrm{i}}^2}$. +- The source diagram labels the optional multiplier input as `Speed`; GridKit + uses machine speed deviation, so the enabled multiplier is $1+\omega$. +- The PowerWorld parameter table names the UEL selector `UEL`; `UEL >= 2` + routes the UEL input through the input-error summing junction and `UEL < 2` + routes it through the high-value gate. +- `efd` is a required output signal. `speed` is required only when + $s_{\mathrm{spd}}=1$; `vs` and `vuel` are optional and default to zero. + +## Block Diagram + +Standard ESDC1A block diagram. + +![](../../../../../docs/Figures/PhasorDynamics/ESDC1A/diagram.png) + +Figure 1: ESDC1A block diagram. Figure courtesy of [PowerWorld](https://www.powerworld.com/WebHelp/) + +## Model Parameters + +Symbol | Units | JSON | Description | Typical Value | Note +------------------------------------|-----------|-----------|--------------------------------------------------|---------------|------ +$T_R$ | [sec] | `Tr` | Transducer time constant | 0.0 | State 2 +$K_A$ | [p.u.] | `Ka` | Voltage-regulator gain | 40.0 | +$T_A$ | [sec] | `Ta` | Voltage-regulator time constant | 0.1 | State 3 +$T_B$ | [sec] | `Tb` | Lead-lag denominator time constant | 0.0 | State 5 +$T_C$ | [sec] | `Tc` | Lead-lag numerator time constant | 0.0 | +$V_R^{\max}$ | [p.u.] | `Vrmax` | Maximum voltage-regulator output | 1.0 | +$V_R^{\min}$ | [p.u.] | `Vrmin` | Minimum voltage-regulator output | -1.0 | +$K_E$ | [p.u.] | `Ke` | Exciter field-resistance line-slope margin | 0.1 | +$T_E$ | [sec] | `Te` | Exciter field time constant | 0.5 | State 1 +$K_F$ | [p.u.] | `Kf` | Stabilizing feedback gain | 0.05 | +$T_{F1}$ | [sec] | `Tf1` | Feedback lead time constant | 0.7 | State 4 +$s_{\mathrm{spd}}$ | [binary] | `Spdmlt` | Speed multiplier flag | 0.0 | 1 enables the speed multiplier +$E_1$ | [p.u.] | `E1` | First saturation voltage point | 2.8 | +$S_E(E_1)$ | [p.u.] | `Se1` | Saturation value at $E_1$ | 0.08 | +$E_2$ | [p.u.] | `E2` | Second saturation voltage point | 3.7 | +$S_E(E_2)$ | [p.u.] | `Se2` | Saturation value at $E_2$ | 0.33 | +$I_{\mathrm{UEL}}$ | [integer] | `UEL` | Under-excitation limiter input-location selector | 0 | 0/1 = high-value gate, 2/3 = input-error summing junction +$s_{\mathrm{lim}}$ | [binary] | `exclim` | Exciter feedback lower-limit flag | 1.0 | 1 enables the zero lower limit on $V_{\mathrm{FE}}$ + +### Parameter Validation + +Invalid ESDC1A parameter sets are rejected by the following checks. Let $\epsilon_T=10^{-3}$. + +```math +\begin{aligned} + T &\leftarrow \max\!\left(T, \epsilon_T\right) + \quad T\in\{T_R,T_B,T_{F1}\} \\ + K_A + &> 0 \\ + T_A, T_E + &> 0 \\ + T_C + &\ge 0 \\ + V_R^{\min} + &\le V_R^{\max} \\ + s_{\mathrm{spd}}, s_{\mathrm{lim}} + &\in \{0,1\} \\ + I_{\mathrm{UEL}} + &\in \{0,1,2,3\} \\ + \left(S_E(E_1), S_E(E_2)\right) + &=(0,0) + \quad\text{or}\quad + \begin{gathered} + E_1, E_2, S_E(E_1), S_E(E_2) > 0 \\ + E_1 \ne E_2 \\ + S_E(E_1) \ne S_E(E_2) + \end{gathered} +\end{aligned} +``` + +### Model Derived Parameters + +```math +\begin{aligned} + s_{\mathrm{UEL}} + &= + \begin{cases} + 1 & I_{\mathrm{UEL}} \ge 2 \\ + 0 & I_{\mathrm{UEL}} < 2 + \end{cases} \\ + s_{\mathrm{UEL}}^\mathrm{off} + &= 1 - s_{\mathrm{UEL}} \\ + s_{\mathrm{lim}}^\mathrm{off} + &= 1 - s_{\mathrm{lim}} +\end{aligned} +``` + +When saturation is disabled, $S_A=0$ and $S_B=0$. Otherwise, + +```math +\begin{aligned} + C &= \sqrt{\dfrac{S_E(E_2)}{S_E(E_1)}} \\ + S_A &= \dfrac{C E_1 - E_2}{C - 1} \\ + S_B &= \dfrac{S_E(E_1)}{(E_1 - S_A)^2} +\end{aligned} +``` + +## Model Ports + +Name | Port | Init | Description +--------|--------|---------|------ +`bus` | Bus | Known | Terminal bus voltage +`speed` | Input | Known | Machine speed deviation +`vref` | Input | Unknown | Voltage-control reference +`vs` | Input | Known | Stabilizer input signal +`vuel` | Input | Known | Under-excitation limiter input +`efd` | Output | Known | Field-voltage output + +## Model Variables + +### Internal Variables + +#### Differential + +Symbol | Units | Description | Note +------------------------------------|--------|------------------------------------------------------|------ +$E_{\mathrm{fd}}'$ | [p.u.] | Field-voltage state before optional speed multiplier | State 1 in Fig. 1; source label: `EFD` +$V_C$ | [p.u.] | Sensed compensated voltage | State 2 in Fig. 1; source label: `Sensed Vt` +$V_R$ | [p.u.] | Voltage-regulator output | State 3 in Fig. 1; source label: `VR` +$V_F$ | [p.u.] | Stabilizing feedback output | State 4 in Fig. 1; source label: `VF` +$x_{\mathrm{LL}}$ | [p.u.] | Lead-lag block state | State 5 in Fig. 1; source label: `Lead-Lag` + +#### Algebraic + +Symbol | Units | Description | Note +------------------------------------|--------|--------------------------------------------------|------ +$e_V$ | [p.u.] | Voltage-regulator input error | +$V_{\mathrm{LL}}$ | [p.u.] | Lead-lag block output | Input to high-value gate +$V_{\mathrm{HV}}$ | [p.u.] | High-value gate output | Input to voltage regulator +$S_E$ | [p.u.] | Saturation coefficient evaluated at $E_{\mathrm{fd}}'$ | +$V_{\mathrm{FE}}$ | [p.u.] | Exciter feedback signal after optional lower limit | +$E_{\mathrm{fd}}$ | [p.u.] | Field-voltage output | Published through `efd` + +### External Variables + +#### Differential + +None. + +#### Algebraic + +Symbol | Units | Type | Description | Note +------------------------------------|--------|---------|-------------------------------------|------ +$V_{\mathrm{r}}$ | [p.u.] | Known | Terminal-bus voltage, real component | Bus input +$V_{\mathrm{i}}$ | [p.u.] | Known | Terminal-bus voltage, imaginary component | Bus input +$\omega$ | [p.u.] | Known | Machine speed deviation | Optional signal port `speed`; required when $s_{\mathrm{spd}}=1$ +$V_S$ | [p.u.] | Known | Stabilizer input signal | Optional signal port `vs`; defaults to zero +$V_{\mathrm{UEL}}$ | [p.u.] | Known | Under-excitation limiter input | Optional signal port `vuel`; defaults to zero +$V_{\mathrm{ref}}$ | [p.u.] | Unknown | Voltage-control reference | Optional signal port `vref`; initialized constant setpoint; source label: `VREF` + +## Model Equations + +### Differential Equations + +```math +\begin{aligned} + 0 &= + -\dot{E}_{\mathrm{fd}}' + + \dfrac{1}{T_E} + \left(V_R - V_{\mathrm{FE}}\right) \\ + 0 &= + -\dot{V}_C + + \dfrac{1}{T_R} + \left( + \sqrt{V_{\mathrm{r}}^2+V_{\mathrm{i}}^2} + - V_C + \right) \\ + 0 &= + -\dot{V}_R + + \dfrac{1}{T_A} + \text{antiwindup} + \left( + V_R,\, + -V_R + K_A V_{\mathrm{HV}};\, + V_R^{\min}, V_R^{\max} + \right) \\ + 0 &= + -\dot{V}_F + + \dfrac{1}{T_{F1}} + \left[ + -V_F + + \dfrac{K_F}{T_E} + \left(V_R - V_{\mathrm{FE}}\right) + \right] \\ + 0 &= + -\dot{x}_{\mathrm{LL}} + + \dfrac{1}{T_B} + \left(e_V - x_{\mathrm{LL}}\right) +\end{aligned} +``` + +CommonMath defines the [Anti-Windup](../../../../CommonMath.md#anti-windup-indicator) +target and smooth approximation. + +### Algebraic Equations + +```math +\begin{aligned} + 0 &= + -e_V + + V_{\mathrm{ref}} + + V_S + + s_{\mathrm{UEL}}V_{\mathrm{UEL}} + - V_C + - V_F \\ + 0 &= + -V_{\mathrm{LL}} + + x_{\mathrm{LL}} + + \dfrac{T_C}{T_B} + \left(e_V - x_{\mathrm{LL}}\right) \\ + 0 &= + -V_{\mathrm{HV}} + + s_{\mathrm{UEL}}V_{\mathrm{LL}} + + s_{\mathrm{UEL}}^\mathrm{off} + \text{max}\left(V_{\mathrm{LL}}, V_{\mathrm{UEL}}\right) \\ + 0 &= + -S_E + + S_B q\left(E_{\mathrm{fd}}' - S_A\right) \\ + 0 &= + -V_{\mathrm{FE}} + + s_{\mathrm{lim}}^\mathrm{off} + \left(K_E + S_E\right)E_{\mathrm{fd}}' + + s_{\mathrm{lim}}\rho + \left(\left(K_E + S_E\right)E_{\mathrm{fd}}'\right) \\ + 0 &= + -E_{\mathrm{fd}} + + \left(1 + s_{\mathrm{spd}}\omega\right)E_{\mathrm{fd}}' +\end{aligned} +``` + +CommonMath defines helper targets and smooth approximations for +[max](../../../../CommonMath.md#derived-functions), the [ramp](../../../../CommonMath.md#primitives) +$\rho$, and the [quadratic ramp](../../../../CommonMath.md#primitives) $q$. + +## Initialization + +### Input Initialization + +```math +\begin{aligned} + V_{\mathrm{r}}, V_{\mathrm{i}} + &\leftarrow \text{terminal-bus voltage} \\ + E_{\mathrm{fd}} + &\leftarrow \text{field-voltage signal start} \\ + \omega + &\leftarrow \text{speed-deviation input or }0 \\ + V_S + &\leftarrow \text{stabilizer input or }0 \\ + V_{\mathrm{UEL}} + &\leftarrow \text{under-excitation limiter input or }0 +\end{aligned} +``` + +### Internal Initialization + +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} + E_{C,0} + &= \sqrt{V_{\mathrm{r},0}^2+V_{\mathrm{i},0}^2} \\ + d_0 + &= 1 + s_{\mathrm{spd}}\omega_0 \\ + E_{\mathrm{fd},0}' + &= \dfrac{E_{\mathrm{fd},0}}{d_0} \\ + S_{E,0} + &= S_B q\left(E_{\mathrm{fd},0}' - S_A\right) \\ + V_{\mathrm{FE},0} + &= + s_{\mathrm{lim}}^\mathrm{off} + \left(K_E + S_{E,0}\right)E_{\mathrm{fd},0}' + + s_{\mathrm{lim}}\rho + \left(\left(K_E + S_{E,0}\right)E_{\mathrm{fd},0}'\right) \\ + V_{R,0} + &= V_{\mathrm{FE},0} \\ + V_{\mathrm{HV},0} + &= \dfrac{V_{R,0}}{K_A} \\ + g_0 + &= + \begin{cases} + V_{\mathrm{HV},0}, + & s_{\mathrm{UEL}}=1 \\ + V_{\mathrm{UEL},0} + + \rho^{-1} + \left(V_{\mathrm{HV},0}-V_{\mathrm{UEL},0}\right), + & s_{\mathrm{UEL}}=0 + \end{cases} \\ + V_{C,0} + &= E_{C,0} \\ + V_{F,0} + &= 0 \\ + e_{V,0} + &= g_0 \\ + x_{\mathrm{LL},0} + &= g_0 \\ + V_{\mathrm{LL},0} + &= g_0 +\end{aligned} +``` + +Initialization rejects $d_0=0$, $V_{R,0}$ outside +$[V_R^{\min},V_R^{\max}]$, and high-value-gate active starts with +$s_{\mathrm{UEL}}=0$ and $V_{\mathrm{HV},0}\le V_{\mathrm{UEL},0}$. + +### Output Initialization + +```math +\begin{aligned} + V_{\mathrm{ref}} + &\leftarrow + e_{V,0} + + V_{C,0} + + V_{F,0} + - V_{S,0} + - s_{\mathrm{UEL}}V_{\mathrm{UEL},0} +\end{aligned} +``` + +ESDC1A writes the resolved voltage-control reference to an attached `vref` +signal input. If no controller is connected, that value is used as a constant +reference input. + +## Monitorable Outputs + +Output | Units | Description | Note +----------------|--------|-------------------------------------|------ +`efd` | [p.u.] | Field-voltage output | $E_{\mathrm{fd}}$ +`vc` | [p.u.] | Sensed compensated voltage | $V_C$ +`vr` | [p.u.] | Voltage-regulator output | $V_R$ +`vf` | [p.u.] | Stabilizing feedback output | $V_F$ +`se` | [p.u.] | Saturation coefficient | $S_E$ +`vfe` | [p.u.] | Exciter feedback signal | $V_{\mathrm{FE}}$ diff --git a/GridKit/Model/PhasorDynamics/Exciter/README.md b/GridKit/Model/PhasorDynamics/Exciter/README.md index 5391918f0..63ea3bd87 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/README.md +++ b/GridKit/Model/PhasorDynamics/Exciter/README.md @@ -1,7 +1,7 @@ # **Exciter Models** > [!NOTE] -> IEEET1 and SEXS-PTI exciters are currently implemented. +> EXDC1 is not currently implemented. ## Introduction @@ -15,6 +15,7 @@ There are a few standard Exciter models - ESAC6A Excitation Model (See [ESAC6A](ESAC6A/README.md)) - IEEE Type 1 Excitation Model (See [IEEET1](IEEET1/README.md)) - IEEE DC1 Excitation Model (See [EXDC1](EXDC1/README.md)) +- IEEE DC1A Excitation Model (See [ESDC1A](ESDC1A/README.md)) - ESDC2A Excitation Model (See [ESDC2A](ESDC2A/README.md)) - EXAC1 Excitation Model (See [EXAC1](EXAC1/README.md)) - IEEE ST4B Excitation Model (See [ESST4B](ESST4B/README.md)) diff --git a/GridKit/Model/PhasorDynamics/INPUT_FORMAT.md b/GridKit/Model/PhasorDynamics/INPUT_FORMAT.md index 69df8017f..84c03ab16 100644 --- a/GridKit/Model/PhasorDynamics/INPUT_FORMAT.md +++ b/GridKit/Model/PhasorDynamics/INPUT_FORMAT.md @@ -147,6 +147,7 @@ are specified: `Tgov1 ` | the TGOV1 governor model | `pmech`, `speed` | `Trate`, `R`, `T1`, `T2`, `T3`, `Pvmax`, `Pvmin`, `Dt` | `none` `Regca` | WECC REGCA renewable generator/converter model | `bus`, `ipcmd`\*, `iqcmd`\*, `ibranchr`\*, `ibranchi`\*, `pbranch`\*, `qbranch`\* | `p0`, `q0`, `mva`, `Tg`, `TM`, `Rqmax`, `Rqmin`, `Rpmax`, `sL`, `IL1`, `VL0`, `VL1`, `VA0`, `VA1`, `Vhvmax`, `Qmin`, `Khv`, `Xe` | `ir`, `ii`, `p`, `q` `Ieeet1` | the IEEET1 exciter model | `bus`, `speed`, `efd`, `vs`\* | `Tr`, `Ka`, `Ta`, `Ke`, `Te`, `Kf`, `Tf`, `Vrmin`, `Vrmax`, `E1`, `E2`, `Se1`, `Se2`, `Ispdlim` | `efd`, `ksat` + `Esdc1a` | the ESDC1A exciter model | `bus`, `efd`, `speed`\*, `vref`\*, `vs`\*, `vuel`\* | `Tr`, `Ka`, `Ta`, `Tb`, `Tc`, `Vrmax`, `Vrmin`, `Ke`, `Te`, `Kf`, `Tf1`, `Spdmlt`, `E1`, `Se1`, `E2`, `Se2`, `UEL`, `exclim` | `efd`, `vc`, `vr`, `vf`, `se`, `vfe` `SexsPti` | the SEXS-PTI simplified exciter model | `bus`, `efd`, `vs`\* | `Ta`, `Tb`, `Te`, `K`, `Efdmax`, `Efdmin` | `efd` `Ieeest` | the IEEEST stabilizer model | `input`, `output` | `A1`, `A2`, `A3`, `A4`, `A5`, `A6`, `T1`, `T2`, `T3`, `T4`, `T5`, `T6`, `Ks`, `Lsmin`, `Lsmax`, `Vcl`, `Vcu`, `Tdelay` | `vss` `BusFault` | simple impedance-based fault at a bus | `bus`, `status`\* | `state0`, `R`, `X` | `state`, `ir`, `ii` diff --git a/GridKit/Model/PhasorDynamics/SystemModelData.hpp b/GridKit/Model/PhasorDynamics/SystemModelData.hpp index 2ff9adea8..699d750e5 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelData.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelData.hpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -44,6 +45,7 @@ namespace GridKit using BusFaultDataT = BusFaultData; using RegcaDataT = Converter::RegcaData; using Tgov1DataT = Governor::Tgov1Data; + using Esdc1aDataT = Exciter::Esdc1aData; using Ieeet1DataT = Exciter::Ieeet1Data; using SexsPtiDataT = Exciter::SexsPtiData; using IeeestDataT = Stabilizer::IeeestData; @@ -104,6 +106,7 @@ namespace GridKit std::vector loadz; ///< LoadZ instances within the model std::vector loadzip; ///< LoadZIP instances within the model std::vector gov; ///< Governors within the model + std::vector esdc1a; ///< ESDC1A exciters within the model std::vector exciter; ///< Exciters within the model std::vector sexspti; ///< SEXS-PTI exciters within the model std::vector stabilizer; ///< Stabilizers within the model diff --git a/GridKit/Model/PhasorDynamics/SystemModelDataJSONParser.hpp b/GridKit/Model/PhasorDynamics/SystemModelDataJSONParser.hpp index 12492f995..8c564e07c 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelDataJSONParser.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelDataJSONParser.hpp @@ -151,6 +151,12 @@ namespace GridKit raw_component.get_to(exciter); sm.exciter.push_back(exciter); } + else if (kind == "Esdc1a") + { + typename SystemModelData::Esdc1aDataT exciter; + raw_component.get_to(exciter); + sm.esdc1a.push_back(exciter); + } else if (kind == "SexsPti") { typename SystemModelData::SexsPtiDataT exciter; diff --git a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp index 0f22cacb5..ddab38efe 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp @@ -349,6 +349,54 @@ namespace GridKit addComponent(exciter); } + for (const auto& excitedata : data.esdc1a) + { + IdxT bus_index = 0; + if (excitedata.buses.contains(Esdc1aBuses::bus)) + { + bus_index = excitedata.buses.at(Esdc1aBuses::bus); + } + + auto* exciter = new Esdc1a(getBus(bus_index), excitedata); + + if (excitedata.signal_inputs.contains(Esdc1aSignalInputs::speed)) + { + IdxT speed = excitedata.signal_inputs.at(Esdc1aSignalInputs::speed); + constexpr auto OMEGA = Esdc1aExternalVariables::OMEGA; + exciter->getSignals().template attachSignalNode(getSignal(speed)); + } + + if (excitedata.signal_inputs.contains(Esdc1aSignalInputs::vref)) + { + IdxT vref = excitedata.signal_inputs.at(Esdc1aSignalInputs::vref); + constexpr auto VREF = Esdc1aExternalVariables::VREF; + exciter->getSignals().template attachSignalNode(getSignal(vref)); + } + + if (excitedata.signal_outputs.contains(Esdc1aSignalOutputs::efd)) + { + IdxT efd = excitedata.signal_outputs.at(Esdc1aSignalOutputs::efd); + constexpr auto EFD = Esdc1aInternalVariables::EFD; + exciter->getSignals().template assignSignalNode(getSignal(efd)); + } + + if (excitedata.signal_inputs.contains(Esdc1aSignalInputs::vs)) + { + IdxT vs = excitedata.signal_inputs.at(Esdc1aSignalInputs::vs); + constexpr auto VS = Esdc1aExternalVariables::VS; + exciter->getSignals().template attachSignalNode(getSignal(vs)); + } + + if (excitedata.signal_inputs.contains(Esdc1aSignalInputs::vuel)) + { + IdxT vuel = excitedata.signal_inputs.at(Esdc1aSignalInputs::vuel); + constexpr auto VUEL = Esdc1aExternalVariables::VUEL; + exciter->getSignals().template attachSignalNode(getSignal(vuel)); + } + + addComponent(exciter); + } + for (const auto& excitedata : data.sexspti) { IdxT bus_index = 0; diff --git a/docs/Figures/PhasorDynamics/ESDC1A/diagram.png b/docs/Figures/PhasorDynamics/ESDC1A/diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..c77a100a65fa9450a6c12dbecf67e21b8ce45278 GIT binary patch literal 58305 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 H`RsoH%K*So literal 0 HcmV?d00001 diff --git a/docs/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md b/docs/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md new file mode 100644 index 000000000..aa9a4c776 --- /dev/null +++ b/docs/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md @@ -0,0 +1,6 @@ +# ESDC1A + +```{include} ../../../../../../GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md +:start-line: 1 +:relative-images: +``` diff --git a/docs/GridKit/Model/PhasorDynamics/Exciter/README.md b/docs/GridKit/Model/PhasorDynamics/Exciter/README.md index 2980fd53c..8e11df505 100644 --- a/docs/GridKit/Model/PhasorDynamics/Exciter/README.md +++ b/docs/GridKit/Model/PhasorDynamics/Exciter/README.md @@ -6,6 +6,7 @@ :hidden: ESAC6A +ESDC1A IEEET1 EXDC1 ESDC2A diff --git a/tests/UnitTests/PhasorDynamics/CMakeLists.txt b/tests/UnitTests/PhasorDynamics/CMakeLists.txt index d643637a4..d235c9c25 100644 --- a/tests/UnitTests/PhasorDynamics/CMakeLists.txt +++ b/tests/UnitTests/PhasorDynamics/CMakeLists.txt @@ -96,6 +96,14 @@ target_link_libraries( GridKit::phasor_dynamics_components_dependency_tracking GridKit::testing) +add_executable(test_phasor_exciter_esdc1a runExciterEsdc1aTests.cpp) +target_link_libraries( + test_phasor_exciter_esdc1a + GridKit::definitions + GridKit::phasor_dynamics_components + GridKit::phasor_dynamics_components_dependency_tracking + GridKit::testing) + add_executable(test_phasor_exciter_sexspti runExciterSexsPtiTests.cpp) target_link_libraries( test_phasor_exciter_sexspti @@ -157,6 +165,7 @@ add_test(NAME PhasorDynamicsBranchTest COMMAND test_phasor_branch) add_test(NAME PhasorDynamicsGenrouTest COMMAND test_phasor_genrou) add_test(NAME PhasorDynamicsGovernorTgov1Test COMMAND test_phasor_governor_tgov1) add_test(NAME PhasorDynamicsExciterIeeet1Test COMMAND test_phasor_exciter_ieeet1) +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) @@ -180,6 +189,7 @@ install( test_phasor_genrou test_phasor_governor_tgov1 test_phasor_exciter_ieeet1 + test_phasor_exciter_esdc1a test_phasor_gensal test_phasor_exciter_sexspti test_phasor_converter_regca diff --git a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp new file mode 100644 index 000000000..1249579a2 --- /dev/null +++ b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp @@ -0,0 +1,732 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace GridKit +{ + namespace Testing + { + template + class ExciterEsdc1aTests + { + public: + using RealT = typename PhasorDynamics::Component::RealT; + + ExciterEsdc1aTests() = default; + ~ExciterEsdc1aTests() = default; + + static constexpr ScalarT kTol = static_cast(1.0e-12); + + TestOutcome constructor() + { + using namespace PhasorDynamics::Exciter; + + TestStatus success = true; + + PhasorDynamics::Bus bus(3.0, 4.0); + + Esdc1a default_exciter(&bus); + success *= (default_exciter.size() == static_cast(Esdc1aInternalVariables::MAXIMUM)); + success *= (default_exciter.getMonitor() == nullptr); + + auto data = makeDefaultData(); + data.monitored_variables.insert(Esdc1aMonitorableVariables::efd); + Esdc1a data_exciter(&bus, data); + success *= (data_exciter.size() == static_cast(Esdc1aInternalVariables::MAXIMUM)); + success *= (data_exciter.getMonitor() != nullptr); + + PhasorDynamics::SignalNode efd_node; + ScalarT efd_value{0.0}; + IdxT efd_index = INVALID_INDEX; + efd_node.set(&efd_value, &efd_index); + + data_exciter.getSignals().template assignSignalNode(&efd_node); + data_exciter.allocate(); + data_exciter.tagDifferentiable(); + + success *= (data_exciter.verify() == 0); + success *= (data_exciter.tag()[idx(Esdc1aInternalVariables::EFDP)] == true); + success *= (data_exciter.tag()[idx(Esdc1aInternalVariables::VC)] == true); + success *= (data_exciter.tag()[idx(Esdc1aInternalVariables::VR)] == true); + success *= (data_exciter.tag()[idx(Esdc1aInternalVariables::VF)] == true); + success *= (data_exciter.tag()[idx(Esdc1aInternalVariables::XLL)] == true); + + return success.report(__func__); + } + + TestOutcome zeroInitialResidual() + { + using namespace PhasorDynamics::Exciter; + + TestStatus success = true; + + Fixture fixture(makeDefaultData()); + success *= fixture.allocateAndInitialize(1.2); + + const auto& residual = fixture.exciter.getResidual(); + const auto* f = residual.getData(); + for (size_t i = 0; i < residual.getSize(); ++i) + { + if (!isEqual(f[i], static_cast(0.0), kTol)) + { + std::cout << "Non-zero ESDC1A residual at index " << i << ": " << f[i] << "\n"; + success = false; + } + } + + success *= fixture.efd_node.linked(); + success *= (fixture.efd_node.getVariableIndex() + == static_cast(idx(Esdc1aInternalVariables::EFD))); + success *= isEqual(fixture.efd_node.read(), static_cast(1.2), kTol); + success *= smoothHighValueGateInitialResidual(); + + return success.report(__func__); + } + + TestOutcome blockDiagramSemantics() + { + TestStatus success = true; + + success *= voltageErrorSummingJunction(); + success *= speedMultiplierSelector(); + success *= leadLagBlockSemantics(); + success *= timeConstantClampSemantics(); + success *= uelRoutingSelector(); + success *= exciterFeedbackLimiter(); + + return success.report(__func__); + } + + TestOutcome parameterValidation() + { + using namespace PhasorDynamics::Exciter; + + TestStatus success = true; + + PhasorDynamics::Bus bus(1.0, 0.0); + PhasorDynamics::SignalNode efd_node; + ScalarT efd_value{0.0}; + IdxT efd_index = INVALID_INDEX; + efd_node.set(&efd_value, &efd_index); + + auto valid = makeDefaultData(); + Esdc1a valid_model(&bus, valid); + valid_model.getSignals().template assignSignalNode(&efd_node); + valid_model.allocate(); + success *= (valid_model.verify() == 0); + + auto invalid_ta = makeDefaultData(); + invalid_ta.parameters[Esdc1aParameters::Ta] = 0.0; + Esdc1a invalid_ta_model(&bus, invalid_ta); + invalid_ta_model.getSignals().template assignSignalNode(&efd_node); + invalid_ta_model.allocate(); + success *= (invalid_ta_model.verify() > 0); + + auto zero_tb_nonzero_tc = makeDefaultData(); + zero_tb_nonzero_tc.parameters[Esdc1aParameters::Tc] = 0.1; + Esdc1a zero_tb_nonzero_tc_model(&bus, zero_tb_nonzero_tc); + zero_tb_nonzero_tc_model.getSignals().template assignSignalNode(&efd_node); + zero_tb_nonzero_tc_model.allocate(); + success *= (zero_tb_nonzero_tc_model.verify() == 0); + + auto invalid_tc = makeDefaultData(); + invalid_tc.parameters[Esdc1aParameters::Tc] = -0.1; + Esdc1a invalid_tc_model(&bus, invalid_tc); + invalid_tc_model.getSignals().template assignSignalNode(&efd_node); + invalid_tc_model.allocate(); + success *= (invalid_tc_model.verify() > 0); + + auto invalid_saturation = makeDefaultData(); + invalid_saturation.parameters[Esdc1aParameters::Se1] = 0.0; + invalid_saturation.parameters[Esdc1aParameters::Se2] = 0.33; + Esdc1a invalid_saturation_model(&bus, invalid_saturation); + invalid_saturation_model.getSignals().template assignSignalNode(&efd_node); + invalid_saturation_model.allocate(); + success *= (invalid_saturation_model.verify() > 0); + + auto missing_efd = makeDefaultData(); + Esdc1a missing_efd_model(&bus, missing_efd); + missing_efd_model.allocate(); + success *= (missing_efd_model.verify() > 0); + + auto missing_speed = makeDefaultData(); + missing_speed.parameters[Esdc1aParameters::Spdmlt] = 1.0; + Esdc1a missing_speed_model(&bus, missing_speed); + missing_speed_model.getSignals().template assignSignalNode(&efd_node); + missing_speed_model.allocate(); + success *= (missing_speed_model.verify() > 0); + + return success.report(__func__); + } + +#ifdef GRIDKIT_ENABLE_ENZYME + TestOutcome jacobianStructureAndValues() + { + TestStatus success = true; + + const auto tol = static_cast(1.0e-9); + + auto data = makeDefaultData(); + data.parameters[Params::Spdmlt] = 1.0; + data.parameters[Params::UEL] = static_cast(2); + + auto dependency_tracking_jacobian = dependencyTrackingJacobian(data); + auto enzyme_jacobian = enzymeJacobian(data); + + success *= (dependency_tracking_jacobian.size() == enzyme_jacobian.size()); + for (size_t i = 0; i < dependency_tracking_jacobian.size(); ++i) + { + success *= isEqual(dependency_tracking_jacobian[i], enzyme_jacobian[i], tol); + } + + return success.report(__func__); + } +#endif + + private: + using Internal = PhasorDynamics::Exciter::Esdc1aInternalVariables; + using External = PhasorDynamics::Exciter::Esdc1aExternalVariables; + using Params = PhasorDynamics::Exciter::Esdc1aParameters; + using DataT = PhasorDynamics::Exciter::Esdc1aData; + + static size_t idx(Internal variable) + { + return static_cast(variable); + } + + auto makeDefaultData() -> DataT + { + DataT data; + data.device_class = "exciter"; + data.disambiguation_string = "esdc1a_test"; + + data.parameters[Params::Tr] = 0.0; + data.parameters[Params::Ka] = 40.0; + data.parameters[Params::Ta] = 0.1; + data.parameters[Params::Tb] = 0.0; + data.parameters[Params::Tc] = 0.0; + data.parameters[Params::Vrmax] = 1.0; + data.parameters[Params::Vrmin] = -1.0; + data.parameters[Params::Ke] = 0.1; + data.parameters[Params::Te] = 0.5; + data.parameters[Params::Kf] = 0.05; + data.parameters[Params::Tf1] = 0.7; + data.parameters[Params::Spdmlt] = 0.0; + data.parameters[Params::E1] = 2.8; + data.parameters[Params::Se1] = 0.08; + data.parameters[Params::E2] = 3.7; + data.parameters[Params::Se2] = 0.33; + data.parameters[Params::UEL] = static_cast(0); + data.parameters[Params::exclim] = 1.0; + + return data; + } + + struct Fixture + { + using BusT = PhasorDynamics::Bus; + using SignalT = PhasorDynamics::SignalNode; + using ExciterT = PhasorDynamics::Exciter::Esdc1a; + + DataT data; + BusT bus; + SignalT efd_node; + SignalT omega_node; + SignalT vref_node; + SignalT vs_node; + SignalT vuel_node; + + ScalarT efd_value{0.0}; + ScalarT omega_value{0.0}; + ScalarT vref_value{0.0}; + ScalarT vs_value{0.0}; + ScalarT vuel_value{-2.0}; + + IdxT efd_index{INVALID_INDEX}; + IdxT omega_index{20}; + IdxT vref_index{21}; + IdxT vs_index{22}; + IdxT vuel_index{23}; + + ExciterT exciter; + + explicit Fixture(const DataT& data_in) + : data(data_in), + bus(3.0, 4.0), + exciter(&bus, data) + { + efd_node.set(&efd_value, &efd_index); + omega_node.set(&omega_value, &omega_index); + vref_node.set(&vref_value, &vref_index); + vs_node.set(&vs_value, &vs_index); + vuel_node.set(&vuel_value, &vuel_index); + + exciter.getSignals().template assignSignalNode(&efd_node); + exciter.getSignals().template attachSignalNode(&omega_node); + exciter.getSignals().template attachSignalNode(&vref_node); + exciter.getSignals().template attachSignalNode(&vs_node); + exciter.getSignals().template attachSignalNode(&vuel_node); + } + + bool allocateAndInitialize(ScalarT efd0) + { + bus.allocate(); + bus.initialize(); + exciter.allocate(); + efd_node.init(efd0); + return exciter.verify() == 0 + && exciter.initialize() == 0 + && exciter.evaluateResidual() == 0; + } + }; + + bool voltageErrorSummingJunction() + { + Fixture fixture(makeDefaultData()); + if (!fixture.allocateAndInitialize(1.2)) + { + return false; + } + + auto* y = fixture.exciter.y().getData(); + const auto* f = fixture.exciter.getResidual().getData(); + + fixture.vs_value += 0.1; + fixture.exciter.evaluateResidual(); + bool success = f[idx(Internal::EV)] > static_cast(0.0); + + fixture.vs_value -= 0.1; + fixture.vref_value += 0.1; + fixture.exciter.evaluateResidual(); + success = success && f[idx(Internal::EV)] > static_cast(0.0); + + fixture.vref_value -= 0.1; + y[idx(Internal::VC)] += 0.1; + fixture.exciter.y().setDataUpdated(); + fixture.exciter.evaluateResidual(); + success = success && f[idx(Internal::EV)] < static_cast(0.0); + + y[idx(Internal::VC)] -= 0.1; + y[idx(Internal::VF)] += 0.1; + fixture.exciter.y().setDataUpdated(); + fixture.exciter.evaluateResidual(); + success = success && f[idx(Internal::EV)] < static_cast(0.0); + + return success; + } + + bool speedMultiplierSelector() + { + auto disabled_data = makeDefaultData(); + Fixture disabled(disabled_data); + if (!disabled.allocateAndInitialize(1.2)) + { + return false; + } + + disabled.omega_value = 0.05; + disabled.exciter.evaluateResidual(); + const auto* disabled_f = disabled.exciter.getResidual().getData(); + bool success = isEqual(disabled_f[idx(Internal::EFD)], + static_cast(0.0), + kTol); + + auto enabled_data = makeDefaultData(); + enabled_data.parameters[Params::Spdmlt] = 1.0; + Fixture enabled(enabled_data); + if (!enabled.allocateAndInitialize(1.2)) + { + return false; + } + + enabled.omega_value = 0.05; + enabled.exciter.evaluateResidual(); + const auto* enabled_f = enabled.exciter.getResidual().getData(); + success = success && enabled_f[idx(Internal::EFD)] > static_cast(0.0); + + return success; + } + + bool leadLagBlockSemantics() + { + Fixture clamped(makeDefaultData()); + if (!clamped.allocateAndInitialize(1.2)) + { + return false; + } + + auto* clamped_y = clamped.exciter.y().getData(); + clamped_y[idx(Internal::VLL)] += 0.1; + clamped.exciter.y().setDataUpdated(); + clamped.exciter.evaluateResidual(); + const auto* clamped_f = clamped.exciter.getResidual().getData(); + bool success = clamped_f[idx(Internal::VLL)] < static_cast(0.0); + + auto active_data = makeDefaultData(); + active_data.parameters[Params::Tb] = 0.5; + active_data.parameters[Params::Tc] = 0.2; + Fixture active(active_data); + if (!active.allocateAndInitialize(1.2)) + { + return false; + } + + auto* active_y = active.exciter.y().getData(); + active_y[idx(Internal::EV)] += 0.1; + active.exciter.y().setDataUpdated(); + active.exciter.evaluateResidual(); + const auto* active_f = active.exciter.getResidual().getData(); + success = success && active_f[idx(Internal::VLL)] > static_cast(0.0); + + active_y[idx(Internal::EV)] -= 0.1; + active_y[idx(Internal::VLL)] += 0.1; + active.exciter.y().setDataUpdated(); + active.exciter.evaluateResidual(); + success = success && active_f[idx(Internal::VLL)] < static_cast(0.0); + + return success; + } + + bool smoothHighValueGateInitialResidual() + { + auto data = makeDefaultData(); + + PhasorDynamics::Bus bus(3.0, 4.0); + PhasorDynamics::SignalNode efd_node; + PhasorDynamics::SignalNode omega_node; + PhasorDynamics::SignalNode vs_node; + + ScalarT efd_value{0.0}; + ScalarT omega_value{0.0}; + ScalarT vs_value{0.0}; + + IdxT efd_index{INVALID_INDEX}; + IdxT omega_index{20}; + IdxT vs_index{21}; + + efd_node.set(&efd_value, &efd_index); + omega_node.set(&omega_value, &omega_index); + vs_node.set(&vs_value, &vs_index); + + PhasorDynamics::Exciter::Esdc1a exciter(&bus, data); + exciter.getSignals().template assignSignalNode(&efd_node); + exciter.getSignals().template attachSignalNode(&omega_node); + exciter.getSignals().template attachSignalNode(&vs_node); + + bus.allocate(); + bus.initialize(); + exciter.allocate(); + efd_node.init(1.2); + + TestStatus success = true; + success *= (exciter.verify() == 0); + success *= (exciter.initialize() == 0); + success *= (exciter.evaluateResidual() == 0); + const auto* f = exciter.getResidual().getData(); + success *= isEqual(f[idx(Internal::VHV)], static_cast(0.0), kTol); + + return success; + } + + bool timeConstantClampSemantics() + { + auto data = makeDefaultData(); + data.parameters[Params::Tr] = 0.0; + data.parameters[Params::Tb] = 0.0; + data.parameters[Params::Tc] = 0.1; + data.parameters[Params::Tf1] = 0.0; + + Fixture fixture(data); + if (!fixture.allocateAndInitialize(1.2)) + { + return false; + } + + fixture.exciter.tagDifferentiable(); + + bool success = fixture.exciter.tag()[idx(Internal::VC)]; + success = success && fixture.exciter.tag()[idx(Internal::VF)]; + success = success && fixture.exciter.tag()[idx(Internal::XLL)]; + + auto* y = fixture.exciter.y().getData(); + const auto* f = fixture.exciter.getResidual().getData(); + + y[idx(Internal::VC)] += 0.1; + fixture.exciter.y().setDataUpdated(); + fixture.exciter.evaluateResidual(); + success = success + && f[idx(Internal::VC)] < static_cast(0.0); + + y[idx(Internal::VC)] -= 0.1; + y[idx(Internal::VF)] += 0.1; + fixture.exciter.y().setDataUpdated(); + fixture.exciter.evaluateResidual(); + success = success + && f[idx(Internal::VF)] < static_cast(0.0); + + y[idx(Internal::VF)] -= 0.1; + y[idx(Internal::XLL)] += 0.1; + fixture.exciter.y().setDataUpdated(); + fixture.exciter.evaluateResidual(); + success = success + && f[idx(Internal::XLL)] < static_cast(0.0); + + return success; + } + + bool uelRoutingSelector() + { + Fixture hv_gate(makeDefaultData()); + if (!hv_gate.allocateAndInitialize(1.2)) + { + return false; + } + + hv_gate.vuel_value = hv_gate.exciter.y().getData()[idx(Internal::VLL)] + 0.1; + hv_gate.exciter.evaluateResidual(); + const auto* hv_gate_f = hv_gate.exciter.getResidual().getData(); + bool success = hv_gate_f[idx(Internal::VHV)] > static_cast(0.0); + success = success && isEqual(hv_gate_f[idx(Internal::EV)], static_cast(0.0), kTol); + + auto sum_data = makeDefaultData(); + sum_data.parameters[Params::UEL] = static_cast(2); + Fixture sum_junction(sum_data); + sum_junction.vuel_value = 0.0; + if (!sum_junction.allocateAndInitialize(1.2)) + { + return false; + } + + sum_junction.vuel_value = 0.1; + sum_junction.exciter.evaluateResidual(); + const auto* sum_f = sum_junction.exciter.getResidual().getData(); + success = success && sum_f[idx(Internal::EV)] > static_cast(0.0); + success = success && isEqual(sum_f[idx(Internal::VHV)], static_cast(0.0), kTol); + + return success; + } + + bool exciterFeedbackLimiter() + { + auto limited_data = makeDefaultData(); + limited_data.parameters[Params::Ke] = -0.2; + limited_data.parameters[Params::Se1] = 0.0; + limited_data.parameters[Params::Se2] = 0.0; + limited_data.parameters[Params::exclim] = 1.0; + Fixture limited(limited_data); + if (!limited.allocateAndInitialize(1.2)) + { + return false; + } + + auto* limited_y = limited.exciter.y().getData(); + limited_y[idx(Internal::EFDP)] = 1.0; + limited_y[idx(Internal::SE)] = 0.0; + limited_y[idx(Internal::VFE)] = 0.0; + limited.exciter.y().setDataUpdated(); + limited.exciter.evaluateResidual(); + const auto* limited_f = limited.exciter.getResidual().getData(); + bool success = std::abs(limited_f[idx(Internal::VFE)]) < kTol; + + auto unlimited_data = limited_data; + unlimited_data.parameters[Params::exclim] = 0.0; + Fixture unlimited(unlimited_data); + if (!unlimited.allocateAndInitialize(1.2)) + { + return false; + } + + auto* unlimited_y = unlimited.exciter.y().getData(); + unlimited_y[idx(Internal::EFDP)] = 1.0; + unlimited_y[idx(Internal::SE)] = 0.0; + unlimited_y[idx(Internal::VFE)] = 0.0; + unlimited.exciter.y().setDataUpdated(); + unlimited.exciter.evaluateResidual(); + const auto* unlimited_f = unlimited.exciter.getResidual().getData(); + success = success && unlimited_f[idx(Internal::VFE)] < static_cast(0.0); + + return success; + } + +#ifdef GRIDKIT_ENABLE_ENZYME + std::vector + dependencyTrackingJacobian(const DataT& data) + { + using Variable = DependencyTracking::Variable; + + PhasorDynamics::Bus bus(Variable{3.0}, Variable{4.0}); + PhasorDynamics::SignalNode efd_node; + PhasorDynamics::SignalNode omega_node; + PhasorDynamics::SignalNode vs_node; + PhasorDynamics::SignalNode vuel_node; + + Variable efd_value{0.0}; + Variable omega_value{0.0}; + Variable vs_value{0.0}; + Variable vuel_value{0.0}; + + IdxT efd_index = INVALID_INDEX; + IdxT omega_index = 13; + IdxT vs_index = 14; + IdxT vuel_index = 15; + + efd_node.set(&efd_value, &efd_index); + omega_node.set(&omega_value, &omega_index); + vs_node.set(&vs_value, &vs_index); + vuel_node.set(&vuel_value, &vuel_index); + + PhasorDynamics::Exciter::Esdc1a exciter(&bus, data); + exciter.getSignals().template assignSignalNode(&efd_node); + exciter.getSignals().template attachSignalNode(&omega_node); + exciter.getSignals().template attachSignalNode(&vs_node); + exciter.getSignals().template attachSignalNode(&vuel_node); + + bus.allocate(); + exciter.allocate(); + bus.initialize(); + efd_node.init(Variable{1.2}); + exciter.initialize(); + + auto* exciter_y = exciter.y().getData(); + for (size_t i = 0; i < exciter.size(); ++i) + { + exciter_y[i].setVariableNumber(i); + } + exciter.y().setDataUpdated(); + auto* bus_y = bus.y().getData(); + for (size_t i = 0; i < bus.size(); ++i) + { + bus_y[i].setVariableNumber(i + exciter.size()); + } + bus.y().setDataUpdated(); + omega_value.setVariableNumber(13); + vs_value.setVariableNumber(14); + vuel_value.setVariableNumber(15); + + bus.evaluateResidual(); + exciter.evaluateResidual(); + const auto& residual_y_view = exciter.getResidual(); + std::vector residual_y(residual_y_view.getData(), + residual_y_view.getData() + residual_y_view.getSize()); + + omega_value = 0.0; + vs_value = 0.0; + vuel_value = 0.0; + bus.initialize(); + efd_node.init(Variable{1.2}); + exciter.initialize(); + + auto* exciter_yp = exciter.yp().getData(); + for (size_t i = 0; i < exciter.size(); ++i) + { + exciter_yp[i].setVariableNumber(i); + } + exciter.yp().setDataUpdated(); + + bus.evaluateResidual(); + exciter.evaluateResidual(); + const auto& residual_yp_view = exciter.getResidual(); + std::vector residual_yp(residual_yp_view.getData(), + residual_yp_view.getData() + residual_yp_view.getSize()); + + std::vector dependencies(residual_y.size()); + for (IdxT i = 0; i < residual_y.size(); ++i) + { + auto dependency_y = residual_y[static_cast(i)].getDependencies(); + auto dependency_yp = residual_yp[static_cast(i)].getDependencies(); + + for (const auto& pair_y : dependency_y) + { + auto index_y = pair_y.first; + auto value_y = pair_y.second; + auto it_yp = dependency_yp.find(index_y); + if (it_yp != dependency_yp.end()) + { + dependencies[static_cast(i)].insert(std::make_pair(index_y, value_y + it_yp->second)); + } + else + { + dependencies[static_cast(i)].insert(pair_y); + } + } + + for (const auto& pair_yp : dependency_yp) + { + if (dependency_y.find(pair_yp.first) == dependency_y.end()) + { + dependencies[static_cast(i)].insert(pair_yp); + } + } + } + + return dependencies; + } + + std::vector + enzymeJacobian(const DataT& data) + { + PhasorDynamics::Bus bus(3.0, 4.0); + PhasorDynamics::SignalNode efd_node; + PhasorDynamics::SignalNode omega_node; + PhasorDynamics::SignalNode vs_node; + PhasorDynamics::SignalNode vuel_node; + + ScalarT efd_value{0.0}; + ScalarT omega_value{0.0}; + ScalarT vs_value{0.0}; + ScalarT vuel_value{0.0}; + + IdxT efd_index = INVALID_INDEX; + IdxT omega_index = 13; + IdxT vs_index = 14; + IdxT vuel_index = 15; + + efd_node.set(&efd_value, &efd_index); + omega_node.set(&omega_value, &omega_index); + vs_node.set(&vs_value, &vs_index); + vuel_node.set(&vuel_value, &vuel_index); + + PhasorDynamics::Exciter::Esdc1a exciter(&bus, data); + exciter.getSignals().template assignSignalNode(&efd_node); + exciter.getSignals().template attachSignalNode(&omega_node); + exciter.getSignals().template attachSignalNode(&vs_node); + exciter.getSignals().template attachSignalNode(&vuel_node); + + bus.allocate(); + exciter.allocate(); + bus.initialize(); + efd_node.init(1.2); + exciter.initialize(); + exciter.updateTime(0.0, 1.0); + + for (size_t i = 0; i < bus.size(); ++i) + { + bus.setVariableIndex(i, static_cast(i + exciter.size())); + bus.setResidualIndex(i, static_cast(i + exciter.size())); + } + + bus.evaluateResidual(); + exciter.evaluateResidual(); + exciter.evaluateJacobian(); + exciter.constructCsr(); + + auto* model_jacobian = exciter.getCsrJacobian(); + + return MapFromCsr(model_jacobian); + } +#endif + }; + } // namespace Testing +} // namespace GridKit diff --git a/tests/UnitTests/PhasorDynamics/runExciterEsdc1aTests.cpp b/tests/UnitTests/PhasorDynamics/runExciterEsdc1aTests.cpp new file mode 100644 index 000000000..04c40b955 --- /dev/null +++ b/tests/UnitTests/PhasorDynamics/runExciterEsdc1aTests.cpp @@ -0,0 +1,18 @@ +#include "ExciterEsdc1aTests.hpp" + +int main() +{ + GridKit::Testing::TestingResults result; + + GridKit::Testing::ExciterEsdc1aTests test; + + result += test.constructor(); + result += test.zeroInitialResidual(); + result += test.blockDiagramSemantics(); + result += test.parameterValidation(); +#ifdef GRIDKIT_ENABLE_ENZYME + result += test.jacobianStructureAndValues(); +#endif + + return result.summary(); +} From 2816e47e25e40d12e4abba22a21e28e817c57d2b Mon Sep 17 00:00:00 2001 From: lukelowry Date: Mon, 27 Jul 2026 11:15:30 -0500 Subject: [PATCH 02/18] revised and improved model --- .../Exciter/ESDC1A/CMakeLists.txt | 12 +- .../PhasorDynamics/Exciter/ESDC1A/Esdc1a.cpp | 6 +- .../PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp | 155 +- .../Exciter/ESDC1A/Esdc1aData.hpp | 20 +- .../ESDC1A/Esdc1aDependencyTracking.cpp | 6 +- .../Exciter/ESDC1A/Esdc1aEnzyme.cpp | 9 +- .../Exciter/ESDC1A/Esdc1aImpl.hpp | 714 +++++--- .../PhasorDynamics/Exciter/ESDC1A/README.md | 64 +- .../Model/PhasorDynamics/Exciter/README.md | 2 +- .../Model/PhasorDynamics/Exciter/README.md | 2 +- tests/UnitTests/PhasorDynamics/CMakeLists.txt | 6 +- .../PhasorDynamics/ExciterEsdc1aTests.hpp | 1568 +++++++++++------ .../PhasorDynamics/runExciterEsdc1aTests.cpp | 12 +- 13 files changed, 1650 insertions(+), 926 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/CMakeLists.txt b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/CMakeLists.txt index 53807c5df..599f7fcb2 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/CMakeLists.txt +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/CMakeLists.txt @@ -29,14 +29,22 @@ else() SOURCES Esdc1a.cpp HEADERS ${_install_headers} INCLUDE_DIRECTORIES PRIVATE ${GRIDKIT_THIRD_PARTY_DIR}/magic-enum/include - LINK_LIBRARIES GridKit::phasor_dynamics_core GridKit::phasor_dynamics_signal) + LINK_LIBRARIES + PUBLIC + GridKit::phasor_dynamics_core + PUBLIC + GridKit::phasor_dynamics_signal) endif() gridkit_add_library( phasor_dynamics_exciter_esdc1a_dependency_tracking SOURCES Esdc1aDependencyTracking.cpp INCLUDE_DIRECTORIES PRIVATE ${GRIDKIT_THIRD_PARTY_DIR}/magic-enum/include - LINK_LIBRARIES GridKit::phasor_dynamics_core GridKit::phasor_dynamics_signal_dependency_tracking) + LINK_LIBRARIES + PUBLIC + GridKit::phasor_dynamics_core + PUBLIC + GridKit::phasor_dynamics_signal_dependency_tracking) target_link_libraries( phasor_dynamics_components diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.cpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.cpp index 6c5c0f89c..60d370c22 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.cpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.cpp @@ -12,11 +12,11 @@ namespace GridKit { namespace Exciter { - template - int Esdc1a::evaluateJacobian() + template + int Esdc1a::evaluateJacobian() { Log::misc() << "Evaluate Jacobian for Esdc1a..." << std::endl; - Log::misc() << "Jacobian evaluation not implemented!" << std::endl; + Log::misc() << "Jacobian evaluation is not implemented!" << std::endl; return 0; } diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp index 8c28b97c3..e0c08d306 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp @@ -19,10 +19,10 @@ namespace GridKit { namespace PhasorDynamics { - template + template class BusBase; - template + template class SignalNode; namespace Exciter @@ -54,35 +54,64 @@ namespace GridKit MAXIMUM, }; - template - class Esdc1a : public Component + /// Indices into the ESDC1A state, derivative, and residual vectors. + struct Esdc1aIdx { - using Component::abs_tol_; - using Component::allocated_; - using Component::alpha_; - 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::variable_indices_; - using Component::wb_; - using Component::y_; - using Component::yp_; + static constexpr size_t EFDP = static_cast(Esdc1aInternalVariables::EFDP); + static constexpr size_t VC = static_cast(Esdc1aInternalVariables::VC); + static constexpr size_t VR = static_cast(Esdc1aInternalVariables::VR); + static constexpr size_t VF = static_cast(Esdc1aInternalVariables::VF); + static constexpr size_t XLL = static_cast(Esdc1aInternalVariables::XLL); + static constexpr size_t EV = static_cast(Esdc1aInternalVariables::EV); + static constexpr size_t VLL = static_cast(Esdc1aInternalVariables::VLL); + static constexpr size_t VHV = static_cast(Esdc1aInternalVariables::VHV); + static constexpr size_t SE = static_cast(Esdc1aInternalVariables::SE); + static constexpr size_t VFE = static_cast(Esdc1aInternalVariables::VFE); + static constexpr size_t EFD = static_cast(Esdc1aInternalVariables::EFD); + static constexpr size_t MAXIMUM = static_cast(Esdc1aInternalVariables::MAXIMUM); + }; + + /// Indices into the ESDC1A external-signal buffers. + struct Esdc1aExt + { + static constexpr size_t OMEGA = static_cast(Esdc1aExternalVariables::OMEGA); + static constexpr size_t VREF = static_cast(Esdc1aExternalVariables::VREF); + static constexpr size_t VS = static_cast(Esdc1aExternalVariables::VS); + static constexpr size_t VUEL = static_cast(Esdc1aExternalVariables::VUEL); + static constexpr size_t MAXIMUM = static_cast(Esdc1aExternalVariables::MAXIMUM); + }; + + template + class Esdc1a : public Component + { + using Component::abs_tol_; + using Component::allocated_; + using Component::alpha_; + 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::variable_indices_; + using Component::wb_; + using Component::y_; + using Component::yp_; public: - using RealT = typename Component::RealT; - using bus_type = BusBase; - using signal_type = SignalNode; - using model_data_type = Esdc1aData; - using MonitorT = Model::VariableMonitor; - - Esdc1a(bus_type* bus); - Esdc1a(bus_type* bus, const model_data_type& data); + using ScalarT = scalar_type; + using IdxT = index_type; + using RealT = typename Component::RealT; + using BusT = BusBase; + using SignalT = SignalNode; + using ModelDataT = Esdc1aData; + using MonitorT = Model::VariableMonitor; + + Esdc1a(BusT* bus); + Esdc1a(BusT* bus, const ModelDataT& data); ~Esdc1a(); int setGridKitComponentID(IdxT) override final; @@ -90,7 +119,7 @@ namespace GridKit int verify() const override final; int initialize() override final; int tagDifferentiable() override final; - int setAbsoluteTolerance(RealT rel_tol) override final; + int setAbsoluteTolerance(RealT) override final; int evaluateResidual() override final; int evaluateJacobian() override final; @@ -109,43 +138,53 @@ namespace GridKit const ScalarT*, const ScalarT*, const ScalarT*, const ScalarT*, ScalarT*); private: - void initModelParams(const model_data_type& data); - void setDerivedParams(); + void initializeParameters(const ModelDataT& data); void initializeMonitor(); + void setDerivedParameters(); - static constexpr RealT TIME_CONSTANT_MINIMUM = static_cast(1.0e-3); + /// Recover the input that the smooth CommonMath ramp maps to a + /// requested strictly positive output. + RealT inverseRamp(RealT ramp_output) const; - bus_type* bus_{nullptr}; - - RealT Tr_{0.0}; - RealT Ka_{40.0}; - RealT Ta_{0.1}; - RealT Tb_{0.0}; - RealT Tc_{0.0}; - RealT Vrmax_{1.0}; - RealT Vrmin_{-1.0}; - RealT Ke_{0.1}; - RealT Te_{0.5}; - RealT Kf_{0.05}; - RealT Tf1_{0.7}; - RealT spdmlt_{0.0}; - RealT E1_{2.8}; - RealT Se1_{0.08}; - RealT E2_{3.7}; - RealT Se2_{0.33}; - IdxT UEL_{0}; - RealT exclim_{1.0}; + ScalarT& Vr(); + ScalarT& Vi(); - IdxT parameter_error_count_{0}; + static constexpr RealT TIME_CONSTANT_MINIMUM = static_cast(1.0e-3); - RealT sUEL_{0}; - RealT sUELoff_{1}; - RealT slim_{0}; - RealT slim_off_{1}; + BusT* bus_{nullptr}; + + RealT Tr_{ZERO}; + RealT Ka_{static_cast(40.0)}; + RealT Ta_{static_cast(0.1)}; + RealT Tb_{ZERO}; + RealT Tc_{ZERO}; + RealT Vrmax_{ONE}; + RealT Vrmin_{static_cast(-1.0)}; + RealT Ke_{static_cast(0.1)}; + RealT Te_{static_cast(0.5)}; + RealT Kf_{static_cast(0.05)}; + RealT Tf1_{static_cast(0.7)}; + bool Spdmlt_{false}; + RealT E1_{static_cast(2.8)}; + RealT Se1_{static_cast(0.08)}; + RealT E2_{static_cast(3.7)}; + RealT Se2_{static_cast(0.33)}; + IdxT UEL_{0}; + bool exclim_{true}; + RealT spd_on_{0}; + RealT uel_on_{0}; + RealT uel_off_{1}; + RealT lim_on_{1}; + RealT lim_off_{0}; RealT SA_{0}; RealT SB_{0}; - ScalarT vref_{0}; + IdxT parameter_error_count_{0}; + + ScalarT omega_set_{0}; + ScalarT vref_set_{0}; + ScalarT vs_set_{0}; + ScalarT vuel_set_{0}; ComponentSignals signals_; std::unique_ptr monitor_; diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp index 3b389f81f..dbdce8502 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp @@ -40,24 +40,24 @@ namespace GridKit /// Buses for the ESDC1A exciter model. enum class Esdc1aBuses : size_t { - bus, ///< Unique ID of the terminal bus + bus, ///< Terminal bus ID SIZE }; /// Signal inputs for the ESDC1A exciter model. enum class Esdc1aSignalInputs : size_t { - speed, ///< Unique ID of the generator speed-deviation signal - vref, ///< Unique ID of the voltage-reference signal - vs, ///< Unique ID of the optional stabilizer input signal - vuel, ///< Unique ID of the optional UEL input signal + speed, ///< Machine speed-deviation signal ID + vref, ///< Optional voltage-reference signal ID + vs, ///< Optional stabilizer input signal ID + vuel, ///< Optional UEL input signal ID SIZE }; /// Signal outputs for the ESDC1A exciter model. enum class Esdc1aSignalOutputs : size_t { - efd, ///< Unique ID of the output EFD signal + efd, ///< Field-voltage output signal ID SIZE }; @@ -67,14 +67,14 @@ namespace GridKit efd, ///< Field-voltage output vc, ///< Sensed compensated voltage vr, ///< Voltage-regulator output - vf, ///< Stabilizing feedback state + vf, ///< Stabilizing feedback output se, ///< Saturation coefficient vfe ///< Exciter feedback signal }; - template - struct Esdc1aData : public ComponentData + struct Esdc1aData : public ComponentData - int Esdc1a::evaluateJacobian() + template + int Esdc1a::evaluateJacobian() { Log::misc() << "Evaluate Jacobian for Esdc1a..." << std::endl; - Log::misc() << "Jacobian evaluation not implemented!" << std::endl; + Log::misc() << "Jacobian evaluation is not implemented!" << std::endl; return 0; } diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aEnzyme.cpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aEnzyme.cpp index c364ff9c7..5a1ba4095 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aEnzyme.cpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aEnzyme.cpp @@ -14,8 +14,8 @@ namespace GridKit { namespace Exciter { - template - int Esdc1a::evaluateJacobian() + template + int Esdc1a::evaluateJacobian() { Log::misc() << "Evaluate Jacobian for Esdc1a..." << std::endl; Log::misc() << "Jacobian evaluation is experimental!" << std::endl; @@ -24,14 +24,14 @@ namespace GridKit { auto size = static_cast(size_); auto bus_size = static_cast(bus_->size()); - auto signal_size = static_cast(ws_.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::Exciter::Esdc1a; + using ModelT = GridKit::PhasorDynamics::Exciter::Esdc1a; using Fn = GridKit::Enzyme::Sparse::MemberFunctions; nnz_ = 0; @@ -92,7 +92,6 @@ namespace GridKit J_cols_buffer_, J_vals_buffer_, nnz_); - this->constructCoo(); return 0; diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp index 138ebfef2..4c6e4c90c 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp @@ -25,34 +25,172 @@ namespace GridKit { using Log = ::GridKit::Utilities::Logger; - template - Esdc1a::Esdc1a(bus_type* bus) + /** + * @brief Construct an ESDC1A exciter without parameters + * + * The model is sized but left unconfigured. Every parameter keeps its + * documented default and no monitor is created, so verify() reports a + * configuration error until an `efd` output node is assigned. + * + * @param[in] bus Terminal bus the exciter measures. + */ + template + Esdc1a::Esdc1a(BusT* bus) : bus_(bus) { - setDerivedParams(); - size_ = static_cast(Esdc1aInternalVariables::MAXIMUM); + size_ = static_cast(Esdc1aIdx::MAXIMUM); + setDerivedParameters(); } - template - Esdc1a::Esdc1a(bus_type* bus, const model_data_type& data) + /** + * @brief Construct an ESDC1A exciter from model data + * + * @param[in] bus Terminal bus the exciter measures. + * @param[in] data Parameters and monitored-variable selections. + */ + template + Esdc1a::Esdc1a(BusT* bus, const ModelDataT& data) : bus_(bus), monitor_(std::make_unique(data)) { - initModelParams(data); - setDerivedParams(); + initializeParameters(data); initializeMonitor(); - size_ = static_cast(Esdc1aInternalVariables::MAXIMUM); + size_ = static_cast(Esdc1aIdx::MAXIMUM); } - template - Esdc1a::~Esdc1a() + template + Esdc1a::~Esdc1a() { } - template - void Esdc1a::initModelParams(const model_data_type& data) + /** + * @brief Terminal-bus voltage, real component + * + * @return Reference to the bus variable. + */ + template + scalar_type& Esdc1a::Vr() { - using Params = typename model_data_type::Parameters; + return bus_->Vr(); + } + + /** + * @brief Terminal-bus voltage, imaginary component + * + * @return Reference to the bus variable. + */ + template + scalar_type& Esdc1a::Vi() + { + return bus_->Vi(); + } + + /** + * @brief Resolve the parameter-derived constants and selector masks + * + * Raises the transducer, lead-lag, and feedback lags to the + * well-posedness floor, fits the quadratic saturation curve, and turns + * the three selectors into complementary multiplicative masks. The + * masks let the residual select signal routing without + * parameter-dependent control flow, which keeps its structure fixed for + * sparse automatic differentiation. + */ + template + void Esdc1a::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() << "Esdc1a: " << name << " must be non-negative\n"; + ++parameter_error_count_; + } + }; + + check_non_negative(Tr_, "Tr"); + check_non_negative(Tb_, "Tb"); + check_non_negative(Tf1_, "Tf1"); + + if (Tr_ < TIME_CONSTANT_MINIMUM || Tb_ < TIME_CONSTANT_MINIMUM + || Tf1_ < TIME_CONSTANT_MINIMUM) + { + Log::warning() << "Esdc1a: Tr, Tb, and Tf1 below " + << TIME_CONSTANT_MINIMUM + << " s are raised to that floor to keep the exciter lags well posed\n"; + } + + Tr_ = std::max(Tr_, TIME_CONSTANT_MINIMUM); + Tb_ = std::max(Tb_, TIME_CONSTANT_MINIMUM); + Tf1_ = std::max(Tf1_, TIME_CONSTANT_MINIMUM); + + spd_on_ = Spdmlt_ ? ONE : ZERO; + uel_on_ = UEL_ >= static_cast(2) ? ONE : ZERO; + uel_off_ = ONE - uel_on_; + lim_on_ = exclim_ ? ONE : ZERO; + lim_off_ = ONE - lim_on_; + + // A disabled or inconsistent saturation curve keeps the zero fit so + // the coefficients stay finite; verify() reports inconsistent data. + const bool saturation_enabled = !(Se1_ == ZERO && Se2_ == ZERO); + const bool saturation_consistent = + E1_ > ZERO && E2_ > ZERO && E1_ != E2_ + && Se1_ > ZERO && Se2_ > ZERO && Se1_ != Se2_; + if (!saturation_enabled || !saturation_consistent) + { + SA_ = ZERO; + SB_ = ZERO; + return; + } + + const RealT C = std::sqrt(Se2_ / Se1_); + SA_ = (C * E1_ - E2_) / (C - ONE); + SB_ = Se1_ / ((E1_ - SA_) * (E1_ - SA_)); + } + + /** + * @brief Invert the smooth CommonMath ramp + * + * Initialization seeds the inactive high-value gate with the gate + * *input*, so the residual reproduces the requested output through the + * same smooth ramp it evaluates. Beyond the softplus width the smooth + * ramp is the identity to double precision, so the output is returned + * unchanged there. + * + * @param[in] ramp_output Strictly positive requested ramp output. + * @return The input the smooth ramp maps to the requested output. + */ + template + typename Esdc1a::RealT + Esdc1a::inverseRamp(RealT ramp_output) const + { + static constexpr RealT SOFTPLUS_WIDTH = static_cast(50.0); + + const RealT scaled_output = Math::MU * ramp_output; + if (scaled_output > SOFTPLUS_WIDTH) + { + return ramp_output; + } + return std::log(std::expm1(scaled_output)) / Math::MU; + } + + /** + * @brief Read the parameters out of the model data + * + * No parameter is required; every parameter keeps the default + * documented in the model README when omitted. A non-numeric value, a + * switch outside {0, 1}, or a non-integer selector 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 Esdc1a::initializeParameters(const ModelDataT& data) + { + using Params = typename ModelDataT::Parameters; parameter_error_count_ = 0; @@ -79,7 +217,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)) { @@ -89,17 +227,17 @@ namespace GridKit const auto& value = data.parameters.at(key); if (const auto* bool_value = std::get_if(&value)) { - target = *bool_value ? ONE : ZERO; + 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); + target = (*index_value == 1); } else if (const auto* real_value = std::get_if(&value); real_value && (*real_value == ZERO || *real_value == ONE) ) { - target = *real_value; + target = (*real_value == ONE); } else { @@ -120,18 +258,11 @@ namespace GridKit { target = *index_value; } - else if (const auto* real_value = std::get_if(&value)) + else if (const auto* real_value = std::get_if(&value); + real_value && *real_value >= ZERO + && *real_value == std::round(*real_value)) { - const RealT rounded = std::round(*real_value); - if (*real_value >= ZERO && *real_value == rounded) - { - target = static_cast(rounded); - } - else - { - Log::error() << "Esdc1a: parameter '" << name << "' must be an integer selector\n"; - ++parameter_error_count_; - } + target = static_cast(std::round(*real_value)); } else { @@ -151,92 +282,89 @@ namespace GridKit load_real(Params::Te, Te_, "Te"); load_real(Params::Kf, Kf_, "Kf"); load_real(Params::Tf1, Tf1_, "Tf1"); - load_switch(Params::Spdmlt, spdmlt_, "Spdmlt"); + load_switch(Params::Spdmlt, Spdmlt_, "Spdmlt"); load_real(Params::E1, E1_, "E1"); load_real(Params::Se1, Se1_, "Se1"); load_real(Params::E2, E2_, "E2"); load_real(Params::Se2, Se2_, "Se2"); load_selector(Params::UEL, UEL_, "UEL"); load_switch(Params::exclim, exclim_, "exclim"); + setDerivedParameters(); } - template - void Esdc1a::setDerivedParams() - { - Tr_ = std::max(Tr_, TIME_CONSTANT_MINIMUM); - Tb_ = std::max(Tb_, TIME_CONSTANT_MINIMUM); - Tf1_ = std::max(Tf1_, TIME_CONSTANT_MINIMUM); - - sUEL_ = UEL_ >= static_cast(2) ? ONE : ZERO; - sUELoff_ = ONE - sUEL_; - slim_ = exclim_; - slim_off_ = ONE - slim_; - - if (Se1_ == ZERO && Se2_ == ZERO) - { - SA_ = ZERO; - SB_ = ZERO; - return; - } - if (E1_ <= ZERO || E2_ <= ZERO || E1_ == E2_ - || Se1_ <= ZERO || Se2_ <= ZERO || Se1_ == Se2_) - { - SA_ = ZERO; - SB_ = ZERO; - return; - } - - const RealT C = std::sqrt(Se2_ / Se1_); - SA_ = (C * E1_ - E2_) / (C - ONE); - SB_ = Se1_ / ((E1_ - SA_) * (E1_ - SA_)); - } - - template - const Model::VariableMonitorBase* Esdc1a::getMonitor() const + /** + * @brief Access the monitor + * + * @return Monitor for this model, or nullptr when the model was + * constructed without data. + */ + template + const Model::VariableMonitorBase* Esdc1a::getMonitor() const { return monitor_.get(); } - template - void Esdc1a::initializeMonitor() + /** + * @brief Bind the monitorable variables to their internal states + * + * Every monitored quantity is a per-unit exciter voltage, as documented + * in the model README. + */ + template + void Esdc1a::initializeMonitor() { - using Variable = typename model_data_type::MonitorableVariables; - auto index = [](Esdc1aInternalVariables variable) - { - return static_cast(variable); - }; - - monitor_->set(Variable::efd, [this, index] - { return y_.getData()[index(Esdc1aInternalVariables::EFD)]; }); - monitor_->set(Variable::vc, [this, index] - { return y_.getData()[index(Esdc1aInternalVariables::VC)]; }); - monitor_->set(Variable::vr, [this, index] - { return y_.getData()[index(Esdc1aInternalVariables::VR)]; }); - monitor_->set(Variable::vf, [this, index] - { return y_.getData()[index(Esdc1aInternalVariables::VF)]; }); - monitor_->set(Variable::se, [this, index] - { return y_.getData()[index(Esdc1aInternalVariables::SE)]; }); - monitor_->set(Variable::vfe, [this, index] - { return y_.getData()[index(Esdc1aInternalVariables::VFE)]; }); + using I = Esdc1aIdx; + using Variable = typename ModelDataT::MonitorableVariables; + + monitor_->set(Variable::efd, [this] + { return y_.getData()[I::EFD]; }); + monitor_->set(Variable::vc, [this] + { return y_.getData()[I::VC]; }); + monitor_->set(Variable::vr, [this] + { return y_.getData()[I::VR]; }); + monitor_->set(Variable::vf, [this] + { return y_.getData()[I::VF]; }); + monitor_->set(Variable::se, [this] + { return y_.getData()[I::SE]; }); + monitor_->set(Variable::vfe, [this] + { return y_.getData()[I::VFE]; }); } - template - int Esdc1a::setGridKitComponentID(IdxT component_id) + /** + * @brief Set the component ID + * + * @param[in] component_id Identifier assigned by the system model. + * @return int 0 on success. + */ + template + int Esdc1a::setGridKitComponentID(IdxT component_id) { gridkit_component_id_ = component_id; return 0; } - template - int Esdc1a::allocate() + /** + * @brief Allocate the model vectors and wire the field-voltage output + * + * Sizes the state, residual, bus-interface, and signal-interface + * buffers, seeds the identity index maps, and points an assigned `efd` + * node at the internal field-voltage state. That node aliases ESDC1A + * storage from here on, which is how initialize() reads the seed a + * machine model wrote. Repeated calls reuse the allocated vectors. + * + * @return int 0 on success. + */ + template + int Esdc1a::allocate() { - size_ = static_cast(Esdc1aInternalVariables::MAXIMUM); - auto size = static_cast(size_); + using I = Esdc1aIdx; + using E = Esdc1aExt; if (!allocated_) { this->allocateVectors(size_); } + auto size = static_cast(size_); tag_.assign(size, false); variable_indices_.resize(size); @@ -244,7 +372,7 @@ namespace GridKit wb_.assign(2, ScalarT{0}); - auto signal_size = static_cast(Esdc1aExternalVariables::MAXIMUM); + auto signal_size = E::MAXIMUM; ws_.assign(signal_size, ScalarT{0}); ws_indices_.assign(signal_size, INVALID_INDEX); @@ -254,20 +382,31 @@ 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(Esdc1aInternalVariables::EFD)], - &(this->getVariableIndex(static_cast(Esdc1aInternalVariables::EFD)))); + &y[I::EFD], + &(this->getVariableIndex(static_cast(I::EFD)))); } allocated_ = true; return 0; } - template - int Esdc1a::verify() const + /** + * @brief Validate the ESDC1A configuration + * + * Checks parameter-loading errors, static parameter relationships, + * terminal-bus association, the required field-voltage output, and + * attached external signals. Seed feasibility is operating-point + * dependent and is checked by initialize(). + * + * @return int Number of configuration errors; zero when valid. + */ + template + int Esdc1a::verify() const { int ret = static_cast(parameter_error_count_); @@ -288,11 +427,9 @@ namespace GridKit check(Ka_ > ZERO, "Ka must be positive"); check(Ta_ > ZERO, "Ta must be positive"); - check(Tc_ >= ZERO, "Tc must be non-negative"); check(Te_ > ZERO, "Te must be positive"); + check(Tc_ >= ZERO, "Tc must be non-negative"); check(Vrmin_ <= Vrmax_, "Vrmin must be less than or equal to Vrmax"); - check(spdmlt_ == ZERO || spdmlt_ == ONE, "Spdmlt must be 0 or 1"); - check(exclim_ == ZERO || exclim_ == ONE, "exclim must be 0 or 1"); check(UEL_ >= static_cast(0) && UEL_ <= static_cast(3), "UEL must be 0, 1, 2, or 3"); @@ -308,73 +445,73 @@ namespace GridKit if (!signals_.template isAssigned()) { - Log::error() << "Esdc1a: required EFD signal is not assigned\n"; + Log::error() << "Esdc1a: required efd output signal is not assigned\n"; ret += 1; } - if (spdmlt_ == ONE - && !signals_.template isAttached()) + if (Spdmlt_ && !signals_.template isAttached()) { Log::error() << "Esdc1a: speed signal is required when Spdmlt is enabled\n"; ret += 1; } - 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() << "Esdc1a: " << name << " signal attached with no linked source\n"; ret += 1; } }; - check_attached_signal( - signals_.template isAttached(), - signals_.template isAttached() - && signals_.template isLinked(), - "speed"); - check_attached_signal( - signals_.template isAttached(), - signals_.template isAttached() - && signals_.template isLinked(), - "VREF"); - check_attached_signal( - signals_.template isAttached(), - signals_.template isAttached() - && signals_.template isLinked(), - "VS"); - check_attached_signal( - signals_.template isAttached(), - signals_.template isAttached() - && signals_.template isLinked(), - "VUEL"); + check_attached_signal.template operator()("speed"); + check_attached_signal.template operator()("vref"); + check_attached_signal.template operator()("vs"); + check_attached_signal.template operator()("vuel"); return ret; } - template - int Esdc1a::initialize() + /** + * @brief Initialize ESDC1A from the seeded field-voltage output + * + * Reads the assigned `efd` node, resolves the steady state that + * preserves that seed in dependency order, latches the attached input + * values as constant fallbacks, and publishes the resolved + * voltage-control reference to an attached `vref` signal. All + * operating-point checks are completed before model or signal storage + * is modified, so a rejected initialization leaves both unchanged. + * + * @pre allocate() has completed. + * @pre The terminal bus and the assigned `efd` node have been + * initialized. + * + * @return int 0 on success; nonzero when the configuration is invalid, + * the bus voltage or field-voltage seed is not finite, the + * speed-multiplier denominator vanishes, the regulator + * output falls outside its limits, or the high-value gate + * is active at the start. + */ + template + int Esdc1a::initialize() { + using I = Esdc1aIdx; + if (verify() > 0) { Log::error() << "Esdc1a: cannot initialize with invalid configuration\n"; return 1; } - const auto EFDP = static_cast(Esdc1aInternalVariables::EFDP); - const auto VC = static_cast(Esdc1aInternalVariables::VC); - const auto VR = static_cast(Esdc1aInternalVariables::VR); - const auto VF = static_cast(Esdc1aInternalVariables::VF); - const auto XLL = static_cast(Esdc1aInternalVariables::XLL); - const auto EV = static_cast(Esdc1aInternalVariables::EV); - const auto VLL = static_cast(Esdc1aInternalVariables::VLL); - const auto VHV = static_cast(Esdc1aInternalVariables::VHV); - const auto SE = static_cast(Esdc1aInternalVariables::SE); - const auto VFE = static_cast(Esdc1aInternalVariables::VFE); - const auto EFD = static_cast(Esdc1aInternalVariables::EFD); - - auto* y = y_.getData(); - auto* yp = yp_.getData(); + auto* y = y_.getData(); + + // The assigned efd node aliases this entry after allocate(). Its + // seeded value remains untouched throughout initialization. + const ScalarT efd0 = y[I::EFD]; ScalarT omega0{ZERO}; if (signals_.template isAttached()) @@ -394,208 +531,241 @@ namespace GridKit vuel0 = signals_.template readExternalVariable(); } - const ScalarT d0 = ONE + spdmlt_ * omega0; + const ScalarT ec0 = std::sqrt(Vr() * Vr() + Vi() * Vi()); + + if (!std::isfinite(static_cast(efd0)) + || !std::isfinite(static_cast(ec0))) + { + Log::error() << "Esdc1a: initial bus voltage and field-voltage seed must be finite\n"; + return 1; + } + + const ScalarT d0 = ONE + spd_on_ * omega0; if (d0 == ZERO) { Log::error() << "Esdc1a: speed multiplier denominator is zero at initialization\n"; return 1; } - const ScalarT Ec0 = std::sqrt(bus_->Vr() * bus_->Vr() + bus_->Vi() * bus_->Vi()); - - const ScalarT efd0 = y[EFD]; const ScalarT efdp0 = efd0 / d0; const ScalarT se0 = SB_ * Math::qramp(efdp0 - SA_); - const ScalarT vfe0 = slim_off_ * (Ke_ + se0) * efdp0 - + slim_ * Math::ramp((Ke_ + se0) * efdp0); - const ScalarT vr0 = vfe0; - const ScalarT vhv0 = vr0 / Ka_; - auto inverse_ramp = [](RealT y) + const ScalarT vfe0 = lim_off_ * (Ke_ + se0) * efdp0 + + lim_on_ * Math::ramp((Ke_ + se0) * efdp0); + const ScalarT vr0 = vfe0; + const ScalarT vhv0 = vr0 / Ka_; + + if (vr0 < Vrmin_ || vr0 > Vrmax_) { - const RealT scaled_y = Math::MU * y; - if (scaled_y > static_cast(50.0)) - { - return y; - } - return std::log(std::expm1(scaled_y)) / Math::MU; - }; + Log::error() << "Esdc1a: initialized VR is outside limits\n"; + return 1; + } + // An inactive high-value gate is seeded with the gate input, so the + // residual reproduces VHV through the same smooth maximum. ScalarT gate_input0 = vhv0; - if (sUEL_ == ZERO) + if (uel_on_ == ZERO) { - const RealT ramp_target = static_cast(vhv0 - vuel0); - if (ramp_target <= ZERO) + const RealT gate_margin0 = static_cast(vhv0 - vuel0); + if (gate_margin0 <= ZERO) { Log::error() << "Esdc1a: smooth high-value gate is active at initialization\n"; return 1; } - gate_input0 = vuel0 + inverse_ramp(ramp_target); + gate_input0 = vuel0 + inverseRamp(gate_margin0); } - const ScalarT vc0 = Ec0; - const ScalarT vf0 = ScalarT{ZERO}; - const ScalarT ev0 = gate_input0; - const ScalarT xll0 = gate_input0; - const ScalarT vll0 = gate_input0; + const ScalarT vc0 = ec0; + const ScalarT vf0 = ScalarT{ZERO}; + const ScalarT ev0 = gate_input0; + const ScalarT xll0 = gate_input0; + const ScalarT vll0 = gate_input0; + const ScalarT vref0 = ev0 + vc0 + vf0 - vs0 - uel_on_ * vuel0; + + y[I::EFDP] = efdp0; + y[I::VC] = vc0; + y[I::VR] = vr0; + y[I::VF] = vf0; + y[I::XLL] = xll0; + y[I::EV] = ev0; + y[I::VLL] = vll0; + y[I::VHV] = vhv0; + y[I::SE] = se0; + y[I::VFE] = vfe0; + y[I::EFD] = efd0; + + omega_set_ = omega0; + vref_set_ = vref0; + vs_set_ = vs0; + vuel_set_ = vuel0; - if (vr0 < Vrmin_ || vr0 > Vrmax_) - { - Log::error() << "Esdc1a: initialized VR is outside limits\n"; - return 1; - } - - vref_ = ev0 + vc0 + vf0 - vs0 - sUEL_ * vuel0; if (signals_.template isAttached()) { - signals_.template writeExternalVariable(vref_); - } - - y[EFDP] = efdp0; - y[VC] = vc0; - y[VR] = vr0; - y[VF] = vf0; - y[XLL] = xll0; - y[EV] = ev0; - y[VLL] = vll0; - y[VHV] = vhv0; - y[SE] = se0; - y[VFE] = vfe0; - y[EFD] = efd0; - - for (IdxT i = 0; i < size_; ++i) - { - yp[i] = ZERO; + signals_.template writeExternalVariable(vref_set_); } y_.setDataUpdated(); - yp_.setDataUpdated(); + yp_.setToConst(static_cast(ZERO)); return 0; } - template - int Esdc1a::tagDifferentiable() + /** + * @brief Identify the differential variables + * + * The field-voltage state, the voltage transducer, the regulator, the + * stabilizing feedback, and the lead-lag state carry derivatives; + * every other internal variable is algebraic. + * + * @return int 0 on success. + */ + template + int Esdc1a::tagDifferentiable() { + using I = Esdc1aIdx; + std::fill(tag_.begin(), tag_.end(), false); - tag_[static_cast(Esdc1aInternalVariables::EFDP)] = true; - tag_[static_cast(Esdc1aInternalVariables::VC)] = true; - tag_[static_cast(Esdc1aInternalVariables::VR)] = true; - tag_[static_cast(Esdc1aInternalVariables::VF)] = true; - tag_[static_cast(Esdc1aInternalVariables::XLL)] = true; + tag_[I::EFDP] = true; + tag_[I::VC] = true; + tag_[I::VR] = true; + tag_[I::VF] = true; + tag_[I::XLL] = true; return 0; } - template - int Esdc1a::setAbsoluteTolerance(RealT rel_tol) + /** + * @brief Compute the absolute tolerance for each variable in the model + * + * All ESDC1A variables are per-unit exciter voltages 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 Esdc1a::setAbsoluteTolerance(RealT rel_tol) { abs_tol_.setToConst(static_cast(rel_tol)); return 0; } - template - __attribute__((always_inline)) inline int Esdc1a::evaluateInternalResidual( + /** + * @brief Internal residual + * + * Evaluates the five exciter states and the six 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 three selector decisions 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. + * @param[out] f Internal residuals. + * @return int 0 on success. + */ + template + __attribute__((always_inline)) inline int + Esdc1a::evaluateInternalResidual( const ScalarT* y, const ScalarT* yp, const ScalarT* wb, const ScalarT* ws, ScalarT* f) { - const auto EFDP = static_cast(Esdc1aInternalVariables::EFDP); - const auto VC = static_cast(Esdc1aInternalVariables::VC); - const auto VR = static_cast(Esdc1aInternalVariables::VR); - const auto VF = static_cast(Esdc1aInternalVariables::VF); - const auto XLL = static_cast(Esdc1aInternalVariables::XLL); - const auto EV = static_cast(Esdc1aInternalVariables::EV); - const auto VLL = static_cast(Esdc1aInternalVariables::VLL); - const auto VHV = static_cast(Esdc1aInternalVariables::VHV); - const auto SE = static_cast(Esdc1aInternalVariables::SE); - const auto VFE = static_cast(Esdc1aInternalVariables::VFE); - const auto EFD = static_cast(Esdc1aInternalVariables::EFD); - - const auto OMEGA = static_cast(Esdc1aExternalVariables::OMEGA); - const auto VREF = static_cast(Esdc1aExternalVariables::VREF); - const auto VS = static_cast(Esdc1aExternalVariables::VS); - const auto VUEL = static_cast(Esdc1aExternalVariables::VUEL); - - const ScalarT efdp = y[EFDP]; - const ScalarT vc = y[VC]; - const ScalarT vr = y[VR]; - const ScalarT vf = y[VF]; - const ScalarT xll = y[XLL]; - const ScalarT ev = y[EV]; - const ScalarT vll = y[VLL]; - const ScalarT vhv = y[VHV]; - const ScalarT se = y[SE]; - const ScalarT vfe = y[VFE]; - const ScalarT efd = y[EFD]; - - const ScalarT omega = ws[OMEGA]; - const ScalarT vref = ws[VREF]; - const ScalarT vs = ws[VS]; - const ScalarT vuel = ws[VUEL]; - - const ScalarT Ec = std::sqrt(wb[0] * wb[0] + wb[1] * wb[1]); - const ScalarT ev_target = vref + vs + sUEL_ * vuel - vc - vf; - - f[EFDP] = -yp[EFDP] + (vr - vfe) / Te_; - f[VC] = -yp[VC] + (Ec - vc) / Tr_; - f[VR] = -yp[VR] + Math::antiwindup(vr, -vr + Ka_ * vhv, Vrmin_, Vrmax_) / Ta_; - f[VF] = -yp[VF] + (-vf + Kf_ * (vr - vfe) / Te_) / Tf1_; - f[XLL] = -yp[XLL] + (ev - xll) / Tb_; - f[EV] = -ev + ev_target; - f[VLL] = -vll + xll + (Tc_ / Tb_) * (ev - xll); - f[VHV] = -vhv + sUEL_ * vll + sUELoff_ * Math::max(vll, vuel); - f[SE] = -se + SB_ * Math::qramp(efdp - SA_); - f[VFE] = -vfe + slim_off_ * (Ke_ + se) * efdp + slim_ * Math::ramp((Ke_ + se) * efdp); - f[EFD] = -efd + (ONE + spdmlt_ * omega) * efdp; + using I = Esdc1aIdx; + using E = Esdc1aExt; + + const ScalarT efdp = y[I::EFDP]; + const ScalarT vc = y[I::VC]; + const ScalarT vr = y[I::VR]; + const ScalarT vf = y[I::VF]; + const ScalarT xll = y[I::XLL]; + const ScalarT ev = y[I::EV]; + const ScalarT vll = y[I::VLL]; + const ScalarT vhv = y[I::VHV]; + const ScalarT se = y[I::SE]; + const ScalarT vfe = y[I::VFE]; + const ScalarT efd = y[I::EFD]; + + const ScalarT efdp_dot = yp[I::EFDP]; + const ScalarT vc_dot = yp[I::VC]; + const ScalarT vr_dot = yp[I::VR]; + const ScalarT vf_dot = yp[I::VF]; + const ScalarT xll_dot = yp[I::XLL]; + + const ScalarT omega = ws[E::OMEGA]; + const ScalarT vref = ws[E::VREF]; + const ScalarT vs = ws[E::VS]; + const ScalarT vuel = ws[E::VUEL]; + + const ScalarT ec = std::sqrt(wb[0] * wb[0] + wb[1] * wb[1]); + const ScalarT ev_target = vref + vs + uel_on_ * vuel - vc - vf; + + f[I::EFDP] = -efdp_dot + (vr - vfe) / Te_; + f[I::VC] = -vc_dot + (ec - vc) / Tr_; + f[I::VR] = -vr_dot + Math::antiwindup(vr, -vr + Ka_ * vhv, Vrmin_, Vrmax_) / Ta_; + f[I::VF] = -vf_dot + (-vf + Kf_ * (vr - vfe) / Te_) / Tf1_; + f[I::XLL] = -xll_dot + (ev - xll) / Tb_; + f[I::EV] = -ev + ev_target; + f[I::VLL] = -vll + xll + (Tc_ / Tb_) * (ev - xll); + f[I::VHV] = -vhv + uel_on_ * vll + uel_off_ * Math::max(vll, vuel); + f[I::SE] = -se + SB_ * Math::qramp(efdp - SA_); + f[I::VFE] = -vfe + lim_off_ * (Ke_ + se) * efdp + lim_on_ * Math::ramp((Ke_ + se) * efdp); + f[I::EFD] = -efd + (ONE + spd_on_ * omega) * efdp; return 0; } - template - int Esdc1a::evaluateResidual() + /** + * @brief Residuals of system equations + * + * Refreshes the bus and signal interface buffers and evaluates the + * internal residual. ESDC1A 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 Esdc1a::evaluateResidual() { - const auto OMEGA = static_cast(Esdc1aExternalVariables::OMEGA); - const auto VREF = static_cast(Esdc1aExternalVariables::VREF); - const auto VS = static_cast(Esdc1aExternalVariables::VS); - const auto VUEL = static_cast(Esdc1aExternalVariables::VUEL); + using E = Esdc1aExt; - std::fill(ws_.begin(), ws_.end(), ScalarT{ZERO}); + ws_[E::OMEGA] = omega_set_; + ws_[E::VREF] = vref_set_; + ws_[E::VS] = vs_set_; + ws_[E::VUEL] = vuel_set_; std::fill(ws_indices_.begin(), ws_indices_.end(), INVALID_INDEX); - ws_[VREF] = vref_; if (signals_.template isAttached()) { - ws_[OMEGA] = signals_.template readExternalVariable(); - ws_indices_[OMEGA] = - signals_.template readExternalVariableIndex(); + ws_[E::OMEGA] = signals_.template readExternalVariable(); + ws_indices_[E::OMEGA] = signals_.template readExternalVariableIndex(); } if (signals_.template isAttached()) { - ws_[VREF] = signals_.template readExternalVariable(); - ws_indices_[VREF] = - signals_.template readExternalVariableIndex(); + ws_[E::VREF] = signals_.template readExternalVariable(); + ws_indices_[E::VREF] = signals_.template readExternalVariableIndex(); } if (signals_.template isAttached()) { - ws_[VS] = signals_.template readExternalVariable(); - ws_indices_[VS] = - signals_.template readExternalVariableIndex(); + ws_[E::VS] = signals_.template readExternalVariable(); + ws_indices_[E::VS] = signals_.template readExternalVariableIndex(); } if (signals_.template isAttached()) { - ws_[VUEL] = signals_.template readExternalVariable(); - ws_indices_[VUEL] = - signals_.template readExternalVariableIndex(); + ws_[E::VUEL] = signals_.template readExternalVariable(); + ws_indices_[E::VUEL] = signals_.template readExternalVariableIndex(); } - wb_[0] = bus_->Vr(); - wb_[1] = bus_->Vi(); + 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); + evaluateInternalResidual(y, yp, wb_.data(), ws_.data(), f); f_.setDataUpdated(); return 0; } diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md index e46f1ce61..8e8334d5e 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md @@ -6,7 +6,8 @@ and publishes field voltage. ## Notes -- Internal voltage signals are on model base unless otherwise stated. +- Internal voltage signals are on ESDC1A component base unless otherwise + stated. - The connected bus supplies $E_C=\sqrt{V_{\mathrm{r}}^2+V_{\mathrm{i}}^2}$. - The source diagram labels the optional multiplier input as `Speed`; GridKit uses machine speed deviation, so the enabled multiplier is $1+\omega$. @@ -18,43 +19,44 @@ and publishes field voltage. ## Block Diagram -Standard ESDC1A block diagram. +![ESDC1A exciter block diagram](../../../../../docs/Figures/PhasorDynamics/ESDC1A/diagram.png) -![](../../../../../docs/Figures/PhasorDynamics/ESDC1A/diagram.png) - -Figure 1: ESDC1A block diagram. Figure courtesy of [PowerWorld](https://www.powerworld.com/WebHelp/) +Figure 1: ESDC1A exciter model. Figure courtesy of the +[PowerWorld ESDC1A model reference](https://www.powerworld.com/WebHelp/Content/TransientModels_HTML/Exciter%20ESDC1A.htm). ## Model Parameters Symbol | Units | JSON | Description | Typical Value | Note ------------------------------------|-----------|-----------|--------------------------------------------------|---------------|------ -$T_R$ | [sec] | `Tr` | Transducer time constant | 0.0 | State 2 +$T_R$ | [sec] | `Tr` | Transducer time constant | 0.0 | State 2; raised to the minimum-time floor $K_A$ | [p.u.] | `Ka` | Voltage-regulator gain | 40.0 | $T_A$ | [sec] | `Ta` | Voltage-regulator time constant | 0.1 | State 3 -$T_B$ | [sec] | `Tb` | Lead-lag denominator time constant | 0.0 | State 5 +$T_B$ | [sec] | `Tb` | Lead-lag denominator time constant | 0.0 | State 5; raised to the minimum-time floor $T_C$ | [sec] | `Tc` | Lead-lag numerator time constant | 0.0 | $V_R^{\max}$ | [p.u.] | `Vrmax` | Maximum voltage-regulator output | 1.0 | $V_R^{\min}$ | [p.u.] | `Vrmin` | Minimum voltage-regulator output | -1.0 | $K_E$ | [p.u.] | `Ke` | Exciter field-resistance line-slope margin | 0.1 | $T_E$ | [sec] | `Te` | Exciter field time constant | 0.5 | State 1 $K_F$ | [p.u.] | `Kf` | Stabilizing feedback gain | 0.05 | -$T_{F1}$ | [sec] | `Tf1` | Feedback lead time constant | 0.7 | State 4 -$s_{\mathrm{spd}}$ | [binary] | `Spdmlt` | Speed multiplier flag | 0.0 | 1 enables the speed multiplier +$T_{F1}$ | [sec] | `Tf1` | Feedback lead time constant | 0.7 | State 4; raised to the minimum-time floor +$s_{\mathrm{spd}}$ | [binary] | `Spdmlt` | Speed multiplier flag | 0 | 1 enables the speed multiplier $E_1$ | [p.u.] | `E1` | First saturation voltage point | 2.8 | $S_E(E_1)$ | [p.u.] | `Se1` | Saturation value at $E_1$ | 0.08 | $E_2$ | [p.u.] | `E2` | Second saturation voltage point | 3.7 | $S_E(E_2)$ | [p.u.] | `Se2` | Saturation value at $E_2$ | 0.33 | $I_{\mathrm{UEL}}$ | [integer] | `UEL` | Under-excitation limiter input-location selector | 0 | 0/1 = high-value gate, 2/3 = input-error summing junction -$s_{\mathrm{lim}}$ | [binary] | `exclim` | Exciter feedback lower-limit flag | 1.0 | 1 enables the zero lower limit on $V_{\mathrm{FE}}$ +$s_{\mathrm{lim}}$ | [binary] | `exclim` | Exciter feedback lower-limit flag | 1 | 1 enables the zero lower limit on $V_{\mathrm{FE}}$ ### Parameter Validation -Invalid ESDC1A parameter sets are rejected by the following checks. Let $\epsilon_T=10^{-3}$. +Invalid ESDC1A parameter sets are rejected by the following checks: ```math \begin{aligned} - T &\leftarrow \max\!\left(T, \epsilon_T\right) - \quad T\in\{T_R,T_B,T_{F1}\} \\ + s_{\mathrm{spd}}, s_{\mathrm{lim}} + &\in \{0,1\} \\ + T_R, T_B, T_{F1} + &\ge 0 \\ K_A &> 0 \\ T_A, T_E @@ -63,8 +65,6 @@ Invalid ESDC1A parameter sets are rejected by the following checks. Let $\epsilo &\ge 0 \\ V_R^{\min} &\le V_R^{\max} \\ - s_{\mathrm{spd}}, s_{\mathrm{lim}} - &\in \{0,1\} \\ I_{\mathrm{UEL}} &\in \{0,1,2,3\} \\ \left(S_E(E_1), S_E(E_2)\right) @@ -80,8 +80,14 @@ Invalid ESDC1A parameter sets are rejected by the following checks. Let $\epsilo ### 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\{R,B,F1\} \\ s_{\mathrm{UEL}} &= \begin{cases} @@ -116,6 +122,10 @@ Name | Port | Init | Description `vuel` | Input | Known | Under-excitation limiter input `efd` | Output | Known | Field-voltage output +`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 ### Internal Variables @@ -149,14 +159,14 @@ None. #### Algebraic -Symbol | Units | Type | Description | Note +Symbol | Units | Init | Description | Note ------------------------------------|--------|---------|-------------------------------------|------ $V_{\mathrm{r}}$ | [p.u.] | Known | Terminal-bus voltage, real component | Bus input $V_{\mathrm{i}}$ | [p.u.] | Known | Terminal-bus voltage, imaginary component | Bus input $\omega$ | [p.u.] | Known | Machine speed deviation | Optional signal port `speed`; required when $s_{\mathrm{spd}}=1$ +$V_{\mathrm{ref}}$ | [p.u.] | Unknown | Voltage-control reference | Optional signal port `vref`; initialized constant setpoint; source label: `VREF` $V_S$ | [p.u.] | Known | Stabilizer input signal | Optional signal port `vs`; defaults to zero $V_{\mathrm{UEL}}$ | [p.u.] | Known | Under-excitation limiter input | Optional signal port `vuel`; defaults to zero -$V_{\mathrm{ref}}$ | [p.u.] | Unknown | Voltage-control reference | Optional signal port `vref`; initialized constant setpoint; source label: `VREF` ## Model Equations @@ -199,7 +209,7 @@ $V_{\mathrm{ref}}$ | [p.u.] | Unknown | Voltage-control referen \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 @@ -261,11 +271,15 @@ $\rho$, and the [quadratic ramp](../../../../CommonMath.md#primitives) $q$. \end{aligned} ``` +Initialization never replaces the seeded value held in $E_{\mathrm{fd}}$. + ### Internal Initialization 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: +dependency order. The high-value gate uses the smooth CommonMath +[ramp](../../../../CommonMath.md#primitives) $\rho$, so an inactive gate is +seeded with the gate *input* through the ramp inverse $\rho^{-1}$. Let +subscript $0$ denote initial values and set all internal derivatives to zero: ```math \begin{aligned} @@ -310,9 +324,13 @@ derivatives to zero: \end{aligned} ``` -Initialization rejects $d_0=0$, $V_{R,0}$ outside -$[V_R^{\min},V_R^{\max}]$, and high-value-gate active starts with -$s_{\mathrm{UEL}}=0$ and $V_{\mathrm{HV},0}\le V_{\mathrm{UEL},0}$. +Initialization rejects a non-finite bus voltage or field-voltage seed, +$d_0=0$, $V_{R,0}$ outside $[V_R^{\min},V_R^{\max}]$, and high-value-gate +active starts with $s_{\mathrm{UEL}}=0$ and +$V_{\mathrm{HV},0}\le V_{\mathrm{UEL},0}$. + +Every check resolves before any storage is written, so a rejected +initialization leaves state, the `efd` seed, and external signals unchanged. ### Output Initialization diff --git a/GridKit/Model/PhasorDynamics/Exciter/README.md b/GridKit/Model/PhasorDynamics/Exciter/README.md index 63ea3bd87..69e1851d5 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/README.md +++ b/GridKit/Model/PhasorDynamics/Exciter/README.md @@ -1,7 +1,7 @@ # **Exciter Models** > [!NOTE] -> EXDC1 is not currently implemented. +> IEEET1, SEXS-PTI, and ESDC1A exciters are currently implemented. ## Introduction diff --git a/docs/GridKit/Model/PhasorDynamics/Exciter/README.md b/docs/GridKit/Model/PhasorDynamics/Exciter/README.md index 8e11df505..143bad900 100644 --- a/docs/GridKit/Model/PhasorDynamics/Exciter/README.md +++ b/docs/GridKit/Model/PhasorDynamics/Exciter/README.md @@ -6,9 +6,9 @@ :hidden: ESAC6A -ESDC1A IEEET1 EXDC1 +ESDC1A ESDC2A EXAC1 ESST4B diff --git a/tests/UnitTests/PhasorDynamics/CMakeLists.txt b/tests/UnitTests/PhasorDynamics/CMakeLists.txt index d235c9c25..98c03c39d 100644 --- a/tests/UnitTests/PhasorDynamics/CMakeLists.txt +++ b/tests/UnitTests/PhasorDynamics/CMakeLists.txt @@ -100,8 +100,10 @@ add_executable(test_phasor_exciter_esdc1a runExciterEsdc1aTests.cpp) target_link_libraries( test_phasor_exciter_esdc1a GridKit::definitions - GridKit::phasor_dynamics_components - GridKit::phasor_dynamics_components_dependency_tracking + GridKit::phasor_dynamics_exciter_esdc1a + GridKit::phasor_dynamics_exciter_esdc1a_dependency_tracking + GridKit::phasor_dynamics_bus + GridKit::phasor_dynamics_bus_dependency_tracking GridKit::testing) add_executable(test_phasor_exciter_sexspti runExciterSexsPtiTests.cpp) diff --git a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp index 1249579a2..996a839b5 100644 --- a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp @@ -1,7 +1,12 @@ #pragma once -#include +#include +#include +#include #include +#include +#include +#include #include #include @@ -10,185 +15,627 @@ #include #include #include +#include #include #include +#include #include namespace GridKit { namespace Testing { - template + using Log = ::GridKit::Utilities::Logger; + + template class ExciterEsdc1aTests { public: - using RealT = typename PhasorDynamics::Component::RealT; + using ScalarT = scalar_type; + using IdxT = index_type; + using RealT = typename PhasorDynamics::Component::RealT; ExciterEsdc1aTests() = default; ~ExciterEsdc1aTests() = default; - static constexpr ScalarT kTol = static_cast(1.0e-12); + // ESDC1A initialization seeds the smooth high-value gate through the + // ramp inverse, leaving steady residuals of O(1e-12). Behavioral + // comparisons use a tolerance three orders above that guard. + static constexpr RealT kBehaviorTol = 1.0e-9; - TestOutcome constructor() - { - using namespace PhasorDynamics::Exciter; + // 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(3.0, 4.0); - - Esdc1a default_exciter(&bus); - success *= (default_exciter.size() == static_cast(Esdc1aInternalVariables::MAXIMUM)); - success *= (default_exciter.getMonitor() == nullptr); - - auto data = makeDefaultData(); - data.monitored_variables.insert(Esdc1aMonitorableVariables::efd); - Esdc1a data_exciter(&bus, data); - success *= (data_exciter.size() == static_cast(Esdc1aInternalVariables::MAXIMUM)); - success *= (data_exciter.getMonitor() != nullptr); - - PhasorDynamics::SignalNode efd_node; - ScalarT efd_value{0.0}; - IdxT efd_index = INVALID_INDEX; - efd_node.set(&efd_value, &efd_index); - - data_exciter.getSignals().template assignSignalNode(&efd_node); - data_exciter.allocate(); - data_exciter.tagDifferentiable(); + noteExpectedLogs("Testing ESDC1A defaults and invalid configurations. " + "Logged errors and time-constant warnings are expected."); + + PhasorDynamics::Bus bus(1.0, 0.0); + + PhasorDynamics::Exciter::Esdc1a empty(&bus); + success *= (empty.size() == static_cast(I::MAXIMUM)); + success *= (empty.getMonitor() == nullptr); + success *= (empty.verify() > 0); + + Fixture configured(makeData()); + success *= (configured.esdc1a.size() == static_cast(I::MAXIMUM)); + success *= (configured.esdc1a.getMonitor() != nullptr); + success *= configured.prepare(1.2); + + // ESDC1A has no required parameters: an empty parameter set is the + // documented-default model. + Fixture minimal(makeMinimalData()); + success *= minimal.prepare(1.2); + success *= defaultsMatchDocumentedValues(); + + // A model without the required efd output assignment is rejected. + PhasorDynamics::Exciter::Esdc1a unassigned(&bus, makeData()); + success *= (unassigned.verify() > 0); + + success *= invalidParameterCase(Params::Ka, 0.0); + success *= invalidParameterCase(Params::Ta, 0.0); + success *= invalidParameterCase(Params::Te, 0.0); + success *= invalidParameterCase(Params::Tc, -0.1); + success *= invalidParameterCase(Params::Tr, -0.1); + success *= invalidParameterCase(Params::Tb, -0.1); + success *= invalidParameterCase(Params::Tf1, -0.1); + success *= invalidParameterCase(Params::Vrmin, 2.0); + success *= invalidParameterCase(Params::UEL, static_cast(4)); + success *= invalidParameterCase(Params::UEL, static_cast(2.5)); + success *= invalidParameterCase(Params::UEL, true); + success *= invalidParameterCase(Params::Se1, 0.0); + success *= invalidParameterCase(Params::E2, 2.8); + success *= invalidParameterCase(Params::Se2, 0.08); + success *= invalidParameterCase(Params::E1, -1.0); + + // Integer JSON values are accepted for real parameters; booleans are + // not numeric. + auto integer_real = makeData(); + integer_real.parameters[Params::Ka] = static_cast(40); + Fixture integer_real_fixture(integer_real); + success *= (integer_real_fixture.esdc1a.verify() == 0); + success *= invalidParameterCase(Params::Ka, true); + + for (const Params flag : {Params::Spdmlt, Params::exclim}) + { + auto bad_integer = makeData(); + bad_integer.parameters[flag] = static_cast(2); + Fixture bad_integer_fixture(bad_integer); + success *= (bad_integer_fixture.esdc1a.verify() > 0); + + auto bad_real = makeData(); + bad_real.parameters[flag] = static_cast(0.5); + Fixture bad_real_fixture(bad_real); + success *= (bad_real_fixture.esdc1a.verify() > 0); + } - success *= (data_exciter.verify() == 0); - success *= (data_exciter.tag()[idx(Esdc1aInternalVariables::EFDP)] == true); - success *= (data_exciter.tag()[idx(Esdc1aInternalVariables::VC)] == true); - success *= (data_exciter.tag()[idx(Esdc1aInternalVariables::VR)] == true); - success *= (data_exciter.tag()[idx(Esdc1aInternalVariables::VF)] == true); - success *= (data_exciter.tag()[idx(Esdc1aInternalVariables::XLL)] == true); + // Real-valued 0/1, integer 0/1, and JSON booleans are all accepted + // for the two switches, matching ESDC1A's REPCA-style parameter + // contract. + auto switch_forms = makeData(); + switch_forms.parameters[Params::Spdmlt] = static_cast(1.0); + switch_forms.parameters[Params::exclim] = static_cast(0); + Fixture switch_fixture(switch_forms); + switch_fixture.attachAllInputs(); + success *= (switch_fixture.esdc1a.verify() == 0); + + // The enabled speed multiplier requires an attached speed input. + auto speed_required = makeData(); + speed_required.parameters[Params::Spdmlt] = true; + Fixture speed_required_fixture(speed_required); + success *= (speed_required_fixture.esdc1a.verify() > 0); + + PhasorDynamics::SignalNode busless_efd_node; + PhasorDynamics::Exciter::Esdc1a busless(nullptr, makeData()); + busless.getSignals().template assignSignalNode(&busless_efd_node); + success *= (busless.verify() > 0); + + success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); + + // All three floored time constants at zero use the documented + // numerical floor and still admit a consistent steady-state + // initialization. + auto zero_time = makeData(); + zero_time.parameters[Params::Tr] = 0.0; + zero_time.parameters[Params::Tb] = 0.0; + zero_time.parameters[Params::Tf1] = 0.0; + + Fixture floored(zero_time); + success *= floored.initialize(1.2); + success *= (floored.evaluate() == 0); + success *= allResidualsZero(floored.esdc1a); return success.report(__func__); } - TestOutcome zeroInitialResidual() + /// Initialization with every port attached: the seeded field voltage + /// is preserved, the resolved voltage reference is published, the + /// known inputs are read but never overwritten, and every selector + /// combination reaches a zero-derivative, zero-residual state. + TestOutcome initializationAndSignals() { - using namespace PhasorDynamics::Exciter; - TestStatus success = true; - Fixture fixture(makeDefaultData()); - success *= fixture.allocateAndInitialize(1.2); + Fixture fixture(makeData()); + fixture.attachAllInputs(99.0); + fixture.input(E::OMEGA) = 0.02; + fixture.input(E::VS) = 0.03; + fixture.input(E::VUEL) = -0.4; + success *= fixture.initialize(1.2); + success *= (fixture.esdc1a.tagDifferentiable() == 0); + success *= (fixture.evaluate() == 0); + + const auto* y = fixture.esdc1a.y().getData(); + success *= scalarMatches(y[I::EFDP], 1.2, "EFDP"); + success *= scalarMatches(y[I::VC], 1.0, "VC"); + success *= scalarMatches(y[I::VR], 0.12, "VR"); + success *= scalarMatches(y[I::VF], 0.0, "VF"); + success *= scalarMatches(y[I::EV], 0.003, "EV gate input"); + success *= scalarMatches(y[I::VHV], 0.003, "VHV"); + success *= scalarMatches(y[I::SE], 0.0, "SE"); + success *= scalarMatches(y[I::VFE], 0.12, "VFE"); + success *= scalarMatches(fixture.efd(), 1.2, "seeded efd"); + + success *= scalarMatches(fixture.input(E::VREF), 0.973, "published vref"); + success *= scalarMatches(fixture.input(E::OMEGA), 0.02, "preserved speed input"); + success *= scalarMatches(fixture.input(E::VS), 0.03, "preserved vs input"); + success *= scalarMatches(fixture.input(E::VUEL), -0.4, "preserved vuel input"); + + RealT time = 0.0; + Model::VariableMonitorController monitor(time); + monitor.addMonitor(fixture.esdc1a.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,Esdc1a_esdc1a_test_efd,Esdc1a_esdc1a_test_vc," + "Esdc1a_esdc1a_test_vr,Esdc1a_esdc1a_test_vf," + "Esdc1a_esdc1a_test_se,Esdc1a_esdc1a_test_vfe"); + const auto monitored = Tokenizer(monitor_values, ',')(); + if (monitored.size() == 7) + { + success *= scalarMatches(monitored[1], 1.2, "monitored efd"); + success *= scalarMatches(monitored[2], 1.0, "monitored vc"); + success *= scalarMatches(monitored[3], 0.12, "monitored vr"); + success *= scalarMatches(monitored[4], 0.0, "monitored vf"); + success *= scalarMatches(monitored[5], 0.0, "monitored se"); + success *= scalarMatches(monitored[6], 0.12, "monitored vfe"); + } + else + { + std::cout << "ESDC1A monitor emitted " << monitored.size() + << " values instead of 7\n"; + success = false; + } - const auto& residual = fixture.exciter.getResidual(); - const auto* f = residual.getData(); - for (size_t i = 0; i < residual.getSize(); ++i) + for (size_t i = 0; i < static_cast(fixture.esdc1a.size()); ++i) { - if (!isEqual(f[i], static_cast(0.0), kTol)) + const bool expected = i <= I::XLL; + if (fixture.esdc1a.tag()[i] != expected) { - std::cout << "Non-zero ESDC1A residual at index " << i << ": " << f[i] << "\n"; + std::cout << "ESDC1A differentiability tag " << i << " mismatch\n"; success = false; } } + success *= allResidualsZero(fixture.esdc1a); + + // With no attached inputs the latched values act as constant + // references. + Fixture unattached(makeData()); + success *= unattached.initialize(1.2); + success *= (unattached.evaluate() == 0); + success *= allResidualsZero(unattached.esdc1a); + + // Every selector combination must preserve the seeded field voltage + // and produce a zero-derivative, zero-residual state. + for (IdxT uel = 0; uel < 4; ++uel) + { + for (const bool speed_flag : {false, true}) + { + for (const bool limit_flag : {false, true}) + { + auto scenario_data = makeData(); + scenario_data.parameters[Params::UEL] = uel; + scenario_data.parameters[Params::Spdmlt] = speed_flag; + scenario_data.parameters[Params::exclim] = limit_flag; + + Fixture scenario(scenario_data); + scenario.attachAllInputs(); + scenario.input(E::OMEGA) = 0.02; + scenario.input(E::VS) = 0.03; + scenario.input(E::VUEL) = -0.4; + if (!scenario.initialize(1.2)) + { + std::cout << "ESDC1A initialization scenario failed: UEL=" << uel + << ", Spdmlt=" << speed_flag + << ", exclim=" << limit_flag << '\n'; + success = false; + continue; + } + + success *= (scenario.evaluate() == 0); + success *= allResidualsZero(scenario.esdc1a); + success *= scalarMatches(scenario.efd(), 1.2, "scenario efd preservation"); + } + } + } + + return success.report(__func__); + } - success *= fixture.efd_node.linked(); - success *= (fixture.efd_node.getVariableIndex() - == static_cast(idx(Esdc1aInternalVariables::EFD))); - success *= isEqual(fixture.efd_node.read(), static_cast(1.2), kTol); - success *= smoothHighValueGateInitialResidual(); + /// The inadmissible initialization points: every rejection is atomic, + /// and the admissible operating points next to them still initialize + /// to zero residuals. + TestOutcome initializationDomain() + { + TestStatus success = true; + + noteExpectedLogs("Testing inadmissible ESDC1A initialization points. " + "Logged errors are expected."); + + // An enabled speed multiplier with omega = -1 zeroes the seed + // denominator. + auto speed_data = makeData(); + speed_data.parameters[Params::Spdmlt] = true; + success *= initializationRejectedAtomically(speed_data, + 1.2, + {-1.0, 77.0, 77.0, -77.0}, + "zero speed-multiplier denominator"); + + // The seeded field voltage maps to a regulator output above Vrmax. + auto limit_data = makeData(); + limit_data.parameters[Params::Vrmax] = 0.05; + limit_data.parameters[Params::Vrmin] = -0.05; + success *= initializationRejectedAtomically(limit_data, + 1.2, + {0.0, 77.0, 77.0, -77.0}, + "regulator output outside limits"); + + // A UEL input above the gate operating point holds the high-value + // gate active, which the smooth gate cannot represent at rest. + success *= initializationRejectedAtomically(makeData(), + 1.2, + {0.0, 77.0, 77.0, 0.5}, + "active high-value gate"); + + // A non-finite field-voltage seed is rejected before any signal is + // published. + Fixture nonfinite(makeData()); + nonfinite.attachAllInputs(77.0); + success *= nonfinite.prepare(std::numeric_limits::quiet_NaN()); + success *= (nonfinite.esdc1a.initialize() != 0); + success *= scalarMatches(nonfinite.input(E::VREF), 77.0, "rejected vref preservation"); + + // An invalid configuration is rejected before any state is written. + auto invalid_data = makeData(); + invalid_data.parameters[Params::Ka] = 0.0; + Fixture invalid_fixture(invalid_data); + invalid_fixture.attachAllInputs(); + success *= (invalid_fixture.esdc1a.allocate() == 0); + poisonState(invalid_fixture, 1.2); + const auto invalid_y = copyVector(invalid_fixture.esdc1a.y()); + const auto invalid_yp = copyVector(invalid_fixture.esdc1a.yp()); + if (invalid_fixture.esdc1a.initialize() == 0) + { + std::cout << "Expected initialization rejection: invalid configuration\n"; + success = false; + } + success *= vectorUnchanged(invalid_fixture.esdc1a.y(), invalid_y, "state"); + success *= vectorUnchanged(invalid_fixture.esdc1a.yp(), invalid_yp, "derivative"); + + // A depressed speed input rescales the seed without rejection. + auto admissible_speed = makeData(); + admissible_speed.parameters[Params::Spdmlt] = true; + Fixture speed_fixture(admissible_speed); + speed_fixture.attachAllInputs(); + speed_fixture.input(E::OMEGA) = -0.5; + success *= speed_fixture.initialize(1.2); + success *= (speed_fixture.evaluate() == 0); + success *= allResidualsZero(speed_fixture.esdc1a); + success *= scalarMatches(speed_fixture.esdc1a.y().getData()[I::EFDP], + 2.4, + "rescaled EFDP"); + + // The gate stays representable arbitrarily close to its operating + // point: the ramp inverse seeds a 0.003 margin exactly. + Fixture near_gate(makeData()); + near_gate.attachAllInputs(); + success *= near_gate.initialize(1.2); + success *= (near_gate.evaluate() == 0); + success *= allResidualsZero(near_gate.esdc1a); + + // Summing-junction routing removes the gate constraint entirely. + auto junction_data = makeData(); + junction_data.parameters[Params::UEL] = static_cast(2); + Fixture junction(junction_data); + junction.attachAllInputs(); + junction.input(E::VUEL) = 0.7; + success *= junction.initialize(1.2); + success *= (junction.evaluate() == 0); + success *= allResidualsZero(junction.esdc1a); return success.report(__func__); } - TestOutcome blockDiagramSemantics() + /// A fixed numerical answer key for all 11 ESDC1A residual rows. The + /// expected values are literals, not a second implementation of ESDC1A. + TestOutcome residualEquations() { TestStatus success = true; - success *= voltageErrorSummingJunction(); - success *= speedMultiplierSelector(); - success *= leadLagBlockSemantics(); - success *= timeConstantClampSemantics(); - success *= uelRoutingSelector(); - success *= exciterFeedbackLimiter(); + Fixture fixture(makeResidualData(), kStateVr, kStateVi); + fixture.attachAllInputs(); + success *= fixture.initialize(1.2); + setAnswerKeyInputs(fixture); + setAnswerKeyState(fixture.esdc1a); + 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::EFDP, 0.04000000000000004}, + {I::VC, 0.19442890089805262}, + {I::VR, 0.13666666666666663}, + {I::VF, -0.022222222222222213}, + {I::XLL, 0.024999999999999994}, + {I::EV, -0.27999999999999986}, + {I::VLL, -0.007500000000000031}, + {I::VHV, 0.31535073999664665}, + {I::SE, -0.07541019662496842}, + {I::VFE, 0.1600000000000001}, + {I::EFD, 0.9100000000000001}, + }}; + + success *= (static_cast(fixture.esdc1a.getResidual().getSize()) == expected.size()); + success *= residualsMatch(fixture.esdc1a, expected); return success.report(__func__); } - TestOutcome parameterValidation() + /// The transducer, summing junction, lead-lag, stabilizing feedback, + /// and regulator anti-windup behavior at driven states with literal + /// expectations. + TestOutcome voltageRegulation() { - using namespace PhasorDynamics::Exciter; + TestStatus success = true; + Fixture fixture(makeData()); + fixture.attachAllInputs(); + success *= fixture.initialize(1.2); + + // Transducer: the sensed voltage relaxes toward the bus magnitude. + setState(fixture.esdc1a, {{I::VC, 1.1}}); + setDerivative(fixture.esdc1a, {{I::VC, 0.2}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.esdc1a, {{I::VC, -5.2}}, "voltage transducer"); + + // The field-voltage state and the stabilizing feedback share the + // (VR - VFE) drive. + setState(fixture.esdc1a, {{I::VR, 0.6}, {I::VFE, 0.2}, {I::VF, 0.1}}); + setDerivative(fixture.esdc1a, {{I::EFDP, 0.1}, {I::VF, 0.05}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.esdc1a, + {{I::EFDP, 0.7}, {I::VF, -0.13571428571428573}}, + "field-voltage and feedback drive"); + + // Summing junction: UEL < 2 excludes the UEL input from the error. + Fixture summing(makeData()); + summing.attachAllInputs(); + success *= summing.initialize(1.2); + summing.input(E::VREF) = 1.1; + summing.input(E::VS) = 0.05; + summing.input(E::VUEL) = 0.2; + setState(summing.esdc1a, {{I::VC, 0.9}, {I::VF, 0.02}, {I::EV, 0.1}}); + success *= (summing.evaluate() == 0); + success *= residualsMatch(summing.esdc1a, {{I::EV, 0.13}}, "summing junction"); + + // UEL >= 2 routes the UEL input through the summing junction and + // turns the high-value gate into a lead-lag passthrough. + auto junction_data = makeData(); + junction_data.parameters[Params::UEL] = static_cast(2); + Fixture junction(junction_data); + junction.attachAllInputs(); + success *= junction.initialize(1.2); + junction.input(E::VREF) = 1.1; + junction.input(E::VS) = 0.05; + junction.input(E::VUEL) = 0.2; + setState(junction.esdc1a, + {{I::VC, 0.9}, {I::VF, 0.02}, {I::EV, 0.1}, {I::VLL, 0.5}, {I::VHV, 0.2}}); + success *= (junction.evaluate() == 0); + success *= residualsMatch(junction.esdc1a, + {{I::EV, 0.33}, {I::VHV, 0.3}}, + "summing-junction UEL routing"); + + // An active lead-lag pair advances the error and relaxes its state. + auto lead_lag_data = makeData(); + lead_lag_data.parameters[Params::Tc] = 0.2; + Fixture lead_lag(lead_lag_data); + lead_lag.attachAllInputs(); + success *= lead_lag.initialize(1.2); + setState(lead_lag.esdc1a, {{I::XLL, 0.4}, {I::EV, 0.7}, {I::VLL, 0.5}}); + setDerivative(lead_lag.esdc1a, {{I::XLL, 0.0}}); + success *= (lead_lag.evaluate() == 0); + success *= residualsMatch(lead_lag.esdc1a, + {{I::XLL, 0.6}, {I::VLL, 0.02}}, + "lead-lag"); + + // The regulator anti-windup blocks outward rates at both limits and + // admits restoring rates. + struct AntiWindupCase + { + const char* label; + RealT vr; + RealT vhv; + RealT expected; + }; + + const std::array antiwindup_cases{{ + {"Vrmax blocks an outward regulator rate", 1.5, 0.05, 0.0}, + {"Vrmax admits a restoring regulator rate", 1.5, 0.025, -5.0}, + {"Vrmin blocks an outward regulator rate", -1.5, -0.05, 0.0}, + {"Vrmin admits a restoring regulator rate", -1.5, -0.025, 5.0}, + }}; + + for (const auto& test_case : antiwindup_cases) + { + setState(fixture.esdc1a, {{I::VR, test_case.vr}, {I::VHV, test_case.vhv}}); + setDerivative(fixture.esdc1a, {{I::VR, 0.0}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.esdc1a, + {{I::VR, test_case.expected}}, + test_case.label); + } + + return success.report(__func__); + } + + /// High-value gate selection, quadratic saturation, the exciter + /// feedback lower limit, and the speed multiplier at driven states + /// with literal expectations. + TestOutcome excitationLimits() + { TestStatus success = true; - PhasorDynamics::Bus bus(1.0, 0.0); - PhasorDynamics::SignalNode efd_node; - ScalarT efd_value{0.0}; - IdxT efd_index = INVALID_INDEX; - efd_node.set(&efd_value, &efd_index); - - auto valid = makeDefaultData(); - Esdc1a valid_model(&bus, valid); - valid_model.getSignals().template assignSignalNode(&efd_node); - valid_model.allocate(); - success *= (valid_model.verify() == 0); - - auto invalid_ta = makeDefaultData(); - invalid_ta.parameters[Esdc1aParameters::Ta] = 0.0; - Esdc1a invalid_ta_model(&bus, invalid_ta); - invalid_ta_model.getSignals().template assignSignalNode(&efd_node); - invalid_ta_model.allocate(); - success *= (invalid_ta_model.verify() > 0); - - auto zero_tb_nonzero_tc = makeDefaultData(); - zero_tb_nonzero_tc.parameters[Esdc1aParameters::Tc] = 0.1; - Esdc1a zero_tb_nonzero_tc_model(&bus, zero_tb_nonzero_tc); - zero_tb_nonzero_tc_model.getSignals().template assignSignalNode(&efd_node); - zero_tb_nonzero_tc_model.allocate(); - success *= (zero_tb_nonzero_tc_model.verify() == 0); - - auto invalid_tc = makeDefaultData(); - invalid_tc.parameters[Esdc1aParameters::Tc] = -0.1; - Esdc1a invalid_tc_model(&bus, invalid_tc); - invalid_tc_model.getSignals().template assignSignalNode(&efd_node); - invalid_tc_model.allocate(); - success *= (invalid_tc_model.verify() > 0); - - auto invalid_saturation = makeDefaultData(); - invalid_saturation.parameters[Esdc1aParameters::Se1] = 0.0; - invalid_saturation.parameters[Esdc1aParameters::Se2] = 0.33; - Esdc1a invalid_saturation_model(&bus, invalid_saturation); - invalid_saturation_model.getSignals().template assignSignalNode(&efd_node); - invalid_saturation_model.allocate(); - success *= (invalid_saturation_model.verify() > 0); - - auto missing_efd = makeDefaultData(); - Esdc1a missing_efd_model(&bus, missing_efd); - missing_efd_model.allocate(); - success *= (missing_efd_model.verify() > 0); - - auto missing_speed = makeDefaultData(); - missing_speed.parameters[Esdc1aParameters::Spdmlt] = 1.0; - Esdc1a missing_speed_model(&bus, missing_speed); - missing_speed_model.getSignals().template assignSignalNode(&efd_node); - missing_speed_model.allocate(); - success *= (missing_speed_model.verify() > 0); + // The gate passes the larger of VLL and VUEL when UEL < 2; the + // smooth maximum keeps two-sided sensitivity at the tie. + struct GateCase + { + const char* label; + RealT vuel; + RealT expected; + }; + + const std::array gate_cases{{ + {"gate selects the lead-lag branch", -0.5, 0.3}, + {"gate selects the UEL branch", 0.8, 0.6000000000000001}, + {"gate tie point", 0.5, 0.30288811325233306}, + }}; + + Fixture gate(makeData()); + gate.attachAllInputs(); + success *= gate.initialize(1.2); + for (const auto& test_case : gate_cases) + { + gate.input(E::VUEL) = test_case.vuel; + setState(gate.esdc1a, {{I::VLL, 0.5}, {I::VHV, 0.2}}); + success *= (gate.evaluate() == 0); + success *= residualsMatch(gate.esdc1a, {{I::VHV, test_case.expected}}, test_case.label); + } + + // Quadratic saturation above and below the fitted knee, then with + // the fit disabled at the same field voltage. + Fixture saturation(makeResidualData()); + saturation.attachAllInputs(); + success *= saturation.initialize(1.2); + setState(saturation.esdc1a, {{I::EFDP, 2.0}, {I::SE, 0.05}}); + success *= (saturation.evaluate() == 0); + success *= residualsMatch(saturation.esdc1a, + {{I::SE, -0.035410196624968436}}, + "saturation above the knee"); + setState(saturation.esdc1a, {{I::EFDP, 1.0}}); + success *= (saturation.evaluate() == 0); + success *= residualsMatch(saturation.esdc1a, + {{I::SE, -0.05}}, + "saturation below the knee"); + + auto disabled_data = makeResidualData(); + disabled_data.parameters[Params::Se1] = 0.0; + disabled_data.parameters[Params::Se2] = 0.0; + Fixture disabled(disabled_data); + disabled.attachAllInputs(); + success *= disabled.initialize(1.2); + setState(disabled.esdc1a, {{I::EFDP, 2.0}, {I::SE, 0.05}}); + success *= (disabled.evaluate() == 0); + success *= residualsMatch(disabled.esdc1a, {{I::SE, -0.05}}, "saturation disabled"); + + // The exciter feedback lower limit clamps a negative feedback drive + // to zero only while enabled. + auto feedback_data = makeData(); + feedback_data.parameters[Params::Ke] = -0.2; + feedback_data.parameters[Params::Se1] = 0.0; + feedback_data.parameters[Params::Se2] = 0.0; + + for (const auto& [limited, expected] : std::array, 2>{{ + {true, 0.0}, + {false, -0.2}, + }}) + { + auto data = feedback_data; + data.parameters[Params::exclim] = limited; + Fixture feedback(data); + feedback.attachAllInputs(); + feedback.input(E::VUEL) = -0.5; + success *= feedback.initialize(1.2); + setState(feedback.esdc1a, {{I::EFDP, 1.0}, {I::SE, 0.0}, {I::VFE, 0.0}}); + success *= (feedback.evaluate() == 0); + success *= residualsMatch(feedback.esdc1a, + {{I::VFE, expected}}, + limited ? "feedback lower limit engaged" + : "feedback lower limit disabled"); + } + + // The speed multiplier scales the published field voltage only when + // enabled. + for (const auto& [enabled, expected] : std::array, 2>{{ + {false, 0.0}, + {true, 0.06000000000000005}, + }}) + { + auto data = makeData(); + data.parameters[Params::Spdmlt] = enabled; + Fixture speed(data); + speed.attachAllInputs(); + success *= speed.initialize(1.2); + speed.input(E::OMEGA) = 0.05; + setState(speed.esdc1a, {{I::EFDP, 1.2}, {I::EFD, 1.2}}); + success *= (speed.evaluate() == 0); + success *= residualsMatch(speed.esdc1a, + {{I::EFD, expected}}, + enabled ? "speed multiplier enabled" + : "speed multiplier disabled"); + } return success.report(__func__); } #ifdef GRIDKIT_ENABLE_ENZYME - TestOutcome jacobianStructureAndValues() + /// A single rich state and all four external inputs drive both + /// sensitivity paths; every Enzyme CSR row must match dependency + /// tracking. + TestOutcome jacobian() { TestStatus success = true; - const auto tol = static_cast(1.0e-9); - - auto data = makeDefaultData(); - data.parameters[Params::Spdmlt] = 1.0; - data.parameters[Params::UEL] = static_cast(2); + const auto data = makeResidualData(); - auto dependency_tracking_jacobian = dependencyTrackingJacobian(data); - auto enzyme_jacobian = enzymeJacobian(data); + const auto dependency_jacobian = dependencyTrackingJacobian(data, success); + const auto enzyme_jacobian = enzymeJacobian(data, success); - success *= (dependency_tracking_jacobian.size() == enzyme_jacobian.size()); - for (size_t i = 0; i < dependency_tracking_jacobian.size(); ++i) + 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) { - success *= isEqual(dependency_tracking_jacobian[i], enzyme_jacobian[i], tol); + if (!isEqual(dependency_jacobian[row], enzyme_jacobian[row], kJacobianTol)) + { + std::cout << "ESDC1A Jacobian row " << row + << " mismatch between dependency tracking and Enzyme\n"; + success = false; + } } return success.report(__func__); @@ -196,22 +643,149 @@ namespace GridKit #endif private: - using Internal = PhasorDynamics::Exciter::Esdc1aInternalVariables; - using External = PhasorDynamics::Exciter::Esdc1aExternalVariables; - using Params = PhasorDynamics::Exciter::Esdc1aParameters; - using DataT = PhasorDynamics::Exciter::Esdc1aData; + using Params = PhasorDynamics::Exciter::Esdc1aParameters; + using Vars = PhasorDynamics::Exciter::Esdc1aInternalVariables; + using Ext = PhasorDynamics::Exciter::Esdc1aExternalVariables; + using Mon = PhasorDynamics::Exciter::Esdc1aMonitorableVariables; + using Data = PhasorDynamics::Exciter::Esdc1aData; + using I = PhasorDynamics::Exciter::Esdc1aIdx; + using E = PhasorDynamics::Exciter::Esdc1aExt; + + /// A vector row paired with a value: either an input to write or an + /// expected result. Rows are `Esdc1aIdx`/`Esdc1aExt` constants, so a + /// failure report locates itself without any name string to maintain. + using Row = std::pair; + using Rows = std::initializer_list; + using Esdc1aT = PhasorDynamics::Exciter::Esdc1a; + + /// Owns the terminal bus, ESDC1A, the assigned field-voltage node, and + /// the attached input nodes. Signal storage is declared before the + /// model so every referenced node outlives ESDC1A. 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 efd_node_; + + public: + explicit Fixture(const Data& data, RealT vr = 0.8, RealT vi = 0.6) + : bus(static_cast(vr), static_cast(vi)), + esdc1a(&bus, data) + { + esdc1a.getSignals().template assignSignalNode(&efd_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) + { + const IdxT external_index_base = esdc1a.size() + bus.size(); + + 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]); + } + + auto& signals = esdc1a.getSignals(); + signals.template attachSignalNode(&input_nodes_[E::OMEGA]); + signals.template attachSignalNode(&input_nodes_[E::VREF]); + signals.template attachSignalNode(&input_nodes_[E::VS]); + signals.template attachSignalNode(&input_nodes_[E::VUEL]); + } + + /// Seed the assigned field-voltage node. + void seedEfd(RealT efd) + { + efd_node_.init(static_cast(efd)); + } + + /// Everything ESDC1A initialization requires: allocation, + /// verification, an initialized terminal bus, and a seeded + /// field-voltage node. + bool prepare(RealT efd) + { + const bool success = (bus.allocate() == 0) && (esdc1a.allocate() == 0) + && (esdc1a.verify() == 0) && (bus.initialize() == 0); + if (!success) + { + std::cout << "ESDC1A fixture preparation failed\n"; + return false; + } + + seedEfd(efd); + return true; + } - static size_t idx(Internal variable) + /// prepare() plus successful ESDC1A initialization. + bool initialize(RealT efd) + { + if (!prepare(efd)) + { + return false; + } + if (esdc1a.initialize() != 0) + { + std::cout << "ESDC1A initialization failed\n"; + return false; + } + return true; + } + + int evaluate() + { + return esdc1a.evaluateResidual(); + } + + T efd() const + { + return efd_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::Exciter::Esdc1a esdc1a; + }; + + static constexpr RealT kStateVr = 0.9; + static constexpr RealT kStateVi = 0.4; + + Data makeMinimalData() const { - return static_cast(variable); + Data data; + data.device_class = "Esdc1a"; + data.disambiguation_string = "esdc1a_test"; + data.monitored_variables.insert(Mon::efd); + data.monitored_variables.insert(Mon::vc); + data.monitored_variables.insert(Mon::vr); + data.monitored_variables.insert(Mon::vf); + data.monitored_variables.insert(Mon::se); + data.monitored_variables.insert(Mon::vfe); + return data; } - auto makeDefaultData() -> DataT + Data makeExplicitDefaultData() const { - DataT data; - data.device_class = "exciter"; - data.disambiguation_string = "esdc1a_test"; + auto data = makeMinimalData(); + // These are the documented defaults. data.parameters[Params::Tr] = 0.0; data.parameters[Params::Ka] = 40.0; data.parameters[Params::Ta] = 0.1; @@ -223,508 +797,420 @@ namespace GridKit data.parameters[Params::Te] = 0.5; data.parameters[Params::Kf] = 0.05; data.parameters[Params::Tf1] = 0.7; - data.parameters[Params::Spdmlt] = 0.0; + data.parameters[Params::Spdmlt] = false; data.parameters[Params::E1] = 2.8; data.parameters[Params::Se1] = 0.08; data.parameters[Params::E2] = 3.7; data.parameters[Params::Se2] = 0.33; data.parameters[Params::UEL] = static_cast(0); - data.parameters[Params::exclim] = 1.0; - + data.parameters[Params::exclim] = true; return data; } - struct Fixture + Data makeData() const { - using BusT = PhasorDynamics::Bus; - using SignalT = PhasorDynamics::SignalNode; - using ExciterT = PhasorDynamics::Exciter::Esdc1a; - - DataT data; - BusT bus; - SignalT efd_node; - SignalT omega_node; - SignalT vref_node; - SignalT vs_node; - SignalT vuel_node; - - ScalarT efd_value{0.0}; - ScalarT omega_value{0.0}; - ScalarT vref_value{0.0}; - ScalarT vs_value{0.0}; - ScalarT vuel_value{-2.0}; + auto data = makeMinimalData(); - IdxT efd_index{INVALID_INDEX}; - IdxT omega_index{20}; - IdxT vref_index{21}; - IdxT vs_index{22}; - IdxT vuel_index{23}; - - ExciterT exciter; + // The documented typical values with the floored time constants + // raised above the floor, so routine fixtures log no warnings. + data.parameters[Params::Tr] = 0.02; + data.parameters[Params::Ka] = 40.0; + data.parameters[Params::Ta] = 0.1; + data.parameters[Params::Tb] = 0.5; + data.parameters[Params::Tc] = 0.0; + data.parameters[Params::Vrmax] = 1.0; + data.parameters[Params::Vrmin] = -1.0; + data.parameters[Params::Ke] = 0.1; + data.parameters[Params::Te] = 0.5; + data.parameters[Params::Kf] = 0.05; + data.parameters[Params::Tf1] = 0.7; + data.parameters[Params::Spdmlt] = false; + data.parameters[Params::E1] = 2.8; + data.parameters[Params::Se1] = 0.08; + data.parameters[Params::E2] = 3.7; + data.parameters[Params::Se2] = 0.33; + data.parameters[Params::UEL] = static_cast(0); + data.parameters[Params::exclim] = true; + return data; + } - explicit Fixture(const DataT& data_in) - : data(data_in), - bus(3.0, 4.0), - exciter(&bus, data) - { - efd_node.set(&efd_value, &efd_index); - omega_node.set(&omega_value, &omega_index); - vref_node.set(&vref_value, &vref_index); - vs_node.set(&vs_value, &vs_index); - vuel_node.set(&vuel_value, &vuel_index); + Data makeResidualData() const + { + auto data = makeData(); + + // Dynamic-response parameters: every gain, lag, and saturation + // coefficient is nontrivial, the lead-lag is active, and the speed + // multiplier is enabled. + data.parameters[Params::Tr] = 0.2; + data.parameters[Params::Ka] = 25.0; + data.parameters[Params::Ta] = 0.3; + data.parameters[Params::Tb] = 0.8; + data.parameters[Params::Tc] = 0.3; + data.parameters[Params::Vrmax] = 2.0; + data.parameters[Params::Vrmin] = -2.0; + data.parameters[Params::Ke] = 0.2; + data.parameters[Params::Te] = 0.6; + data.parameters[Params::Kf] = 0.08; + data.parameters[Params::Tf1] = 0.9; + data.parameters[Params::Spdmlt] = true; + data.parameters[Params::E1] = 2.4; + data.parameters[Params::Se1] = 0.1; + data.parameters[Params::E2] = 3.2; + data.parameters[Params::Se2] = 0.5; + return data; + } - exciter.getSignals().template assignSignalNode(&efd_node); - exciter.getSignals().template attachSignalNode(&omega_node); - exciter.getSignals().template attachSignalNode(&vref_node); - exciter.getSignals().template attachSignalNode(&vs_node); - exciter.getSignals().template attachSignalNode(&vuel_node); - } + /// The external inputs the residual answer key is evaluated against. + template + void setAnswerKeyInputs(Fixture& fixture) const + { + fixture.input(E::OMEGA) = 0.03; + fixture.input(E::VREF) = 1.05; + fixture.input(E::VS) = 0.04; + fixture.input(E::VUEL) = 0.334; + } - bool allocateAndInitialize(ScalarT efd0) - { - bus.allocate(); - bus.initialize(); - exciter.allocate(); - efd_node.init(efd0); - return exciter.verify() == 0 - && exciter.initialize() == 0 - && exciter.evaluateResidual() == 0; - } - }; + /// The rich state shared by the residual answer key and the Jacobian + /// comparison. Every row is distinct so a swapped index cannot pass, + /// and VLL sits close enough to VUEL that the smooth gate keeps + /// two-sided sensitivity. + template + void setAnswerKeyState(PhasorDynamics::Exciter::Esdc1a& esdc1a) const + { + setState(esdc1a, + {{I::EFDP, 2.00}, {I::VC, 0.95}, {I::VR, 0.45}, {I::VF, 0.06}, {I::XLL, 0.30}, {I::EV, 0.36}, {I::VLL, 0.33}, {I::VHV, 0.02}, {I::SE, 0.09}, {I::VFE, 0.42}, {I::EFD, 1.15}}); + setDerivative(esdc1a, + {{I::EFDP, 0.01}, + {I::VC, -0.02}, + {I::VR, 0.03}, + {I::VF, -0.04}, + {I::XLL, 0.05}}); + } - bool voltageErrorSummingJunction() + /// Omitting every parameter must give exactly the model built from the + /// defaults the README documents, at rest and under load. + bool defaultsMatchDocumentedValues() const { - Fixture fixture(makeDefaultData()); - if (!fixture.allocateAndInitialize(1.2)) + Fixture implicit_defaults(makeMinimalData(), kStateVr, kStateVi); + Fixture explicit_defaults(makeExplicitDefaultData(), kStateVr, kStateVi); + implicit_defaults.attachAllInputs(); + explicit_defaults.attachAllInputs(); + + bool success = implicit_defaults.initialize(1.2) + && explicit_defaults.initialize(1.2); + if (!success) { + std::cout << "ESDC1A documented-default comparison failed to initialize\n"; return false; } - auto* y = fixture.exciter.y().getData(); - const auto* f = fixture.exciter.getResidual().getData(); - - fixture.vs_value += 0.1; - fixture.exciter.evaluateResidual(); - bool success = f[idx(Internal::EV)] > static_cast(0.0); - - fixture.vs_value -= 0.1; - fixture.vref_value += 0.1; - fixture.exciter.evaluateResidual(); - success = success && f[idx(Internal::EV)] > static_cast(0.0); + success *= (implicit_defaults.evaluate() == 0); + success *= (explicit_defaults.evaluate() == 0); + success *= vectorUnchanged(implicit_defaults.esdc1a.y(), + copyVector(explicit_defaults.esdc1a.y()), + "documented-default state"); + success *= vectorUnchanged(implicit_defaults.esdc1a.yp(), + copyVector(explicit_defaults.esdc1a.yp()), + "documented-default derivative"); + success *= vectorUnchanged(implicit_defaults.esdc1a.getResidual(), + copyVector(explicit_defaults.esdc1a.getResidual()), + "documented-default residual"); + + setAnswerKeyInputs(implicit_defaults); + setAnswerKeyInputs(explicit_defaults); + setAnswerKeyState(implicit_defaults.esdc1a); + setAnswerKeyState(explicit_defaults.esdc1a); + success *= (implicit_defaults.evaluate() == 0); + success *= (explicit_defaults.evaluate() == 0); + success *= vectorUnchanged(implicit_defaults.esdc1a.getResidual(), + copyVector(explicit_defaults.esdc1a.getResidual()), + "documented-default dynamic residual"); + return success; + } - fixture.vref_value -= 0.1; - y[idx(Internal::VC)] += 0.1; - fixture.exciter.y().setDataUpdated(); - fixture.exciter.evaluateResidual(); - success = success && f[idx(Internal::EV)] < static_cast(0.0); + template + bool invalidParameterCase(Params parameter, ValueT value) const + { + auto data = makeData(); + data.parameters[parameter] = value; + Fixture fixture(data); + return fixture.esdc1a.verify() > 0; + } - y[idx(Internal::VC)] -= 0.1; - y[idx(Internal::VF)] += 0.1; - fixture.exciter.y().setDataUpdated(); - fixture.exciter.evaluateResidual(); - success = success && f[idx(Internal::EV)] < static_cast(0.0); + template + bool unlinkedSignalRejected() const + { + PhasorDynamics::SignalNode unlinked_node; + Fixture fixture(makeData()); + fixture.esdc1a.getSignals().template attachSignalNode(&unlinked_node); + return fixture.esdc1a.verify() > 0; + } - return success; + template + std::vector copyVector(const VectorT& vector) const + { + const auto* values = vector.getData(); + return std::vector(values, + values + static_cast(vector.getSize())); } - bool speedMultiplierSelector() + /// Every row of a vector still holds its snapshot value. + template + bool vectorUnchanged(const VectorT& vector, + const std::vector& snapshot, + const char* what) const { - auto disabled_data = makeDefaultData(); - Fixture disabled(disabled_data); - if (!disabled.allocateAndInitialize(1.2)) + bool success = true; + const auto* values = vector.getData(); + for (size_t i = 0; i < snapshot.size(); ++i) { - return false; + success &= rowMatches(static_cast(values[i]), snapshot[i], what, i, "changed"); } + return success; + } - disabled.omega_value = 0.05; - disabled.exciter.evaluateResidual(); - const auto* disabled_f = disabled.exciter.getResidual().getData(); - bool success = isEqual(disabled_f[idx(Internal::EFD)], - static_cast(0.0), - kTol); - - auto enabled_data = makeDefaultData(); - enabled_data.parameters[Params::Spdmlt] = 1.0; - Fixture enabled(enabled_data); - if (!enabled.allocateAndInitialize(1.2)) + /// Fill the state and derivative with a recognizable ramp, then re-seed + /// the aliased efd entry, so any write by a rejected initialization + /// is visible. + void poisonState(Fixture& fixture, RealT efd) const + { + auto* y = fixture.esdc1a.y().getData(); + auto* yp = fixture.esdc1a.yp().getData(); + for (size_t i = 0; i < static_cast(fixture.esdc1a.y().getSize()); ++i) { - return false; + y[i] = 0.125 + 0.01 * static_cast(i); + yp[i] = -0.25 - 0.01 * static_cast(i); } - - enabled.omega_value = 0.05; - enabled.exciter.evaluateResidual(); - const auto* enabled_f = enabled.exciter.getResidual().getData(); - success = success && enabled_f[idx(Internal::EFD)] > static_cast(0.0); - - return success; + fixture.seedEfd(efd); + fixture.esdc1a.y().setDataUpdated(); + fixture.esdc1a.yp().setDataUpdated(); } - bool leadLagBlockSemantics() + bool initializationRejectedAtomically(const Data& data, + RealT efd_seed, + const std::array& inputs, + const char* label) const { - Fixture clamped(makeDefaultData()); - if (!clamped.allocateAndInitialize(1.2)) + Fixture fixture(data); + fixture.attachAllInputs(); + for (size_t port = 0; port < E::MAXIMUM; ++port) { - return false; + fixture.input(port) = inputs[port]; } - - auto* clamped_y = clamped.exciter.y().getData(); - clamped_y[idx(Internal::VLL)] += 0.1; - clamped.exciter.y().setDataUpdated(); - clamped.exciter.evaluateResidual(); - const auto* clamped_f = clamped.exciter.getResidual().getData(); - bool success = clamped_f[idx(Internal::VLL)] < static_cast(0.0); - - auto active_data = makeDefaultData(); - active_data.parameters[Params::Tb] = 0.5; - active_data.parameters[Params::Tc] = 0.2; - Fixture active(active_data); - if (!active.allocateAndInitialize(1.2)) + if (!fixture.prepare(efd_seed)) { return false; } - auto* active_y = active.exciter.y().getData(); - active_y[idx(Internal::EV)] += 0.1; - active.exciter.y().setDataUpdated(); - active.exciter.evaluateResidual(); - const auto* active_f = active.exciter.getResidual().getData(); - success = success && active_f[idx(Internal::VLL)] > static_cast(0.0); + poisonState(fixture, efd_seed); + const auto y_before = copyVector(fixture.esdc1a.y()); + const auto yp_before = copyVector(fixture.esdc1a.yp()); - active_y[idx(Internal::EV)] -= 0.1; - active_y[idx(Internal::VLL)] += 0.1; - active.exciter.y().setDataUpdated(); - active.exciter.evaluateResidual(); - success = success && active_f[idx(Internal::VLL)] < static_cast(0.0); + bool success = true; + if (fixture.esdc1a.initialize() == 0) + { + std::cout << "Expected initialization rejection: " << label << "\n"; + success = false; + } + success *= scalarMatches(fixture.efd(), efd_seed, "rejected efd preservation"); + for (size_t port = 0; port < E::MAXIMUM; ++port) + { + success &= rowMatches(static_cast(fixture.input(port)), + inputs[port], + "external input", + port, + "changed"); + } + success *= vectorUnchanged(fixture.esdc1a.y(), y_before, "state"); + success *= vectorUnchanged(fixture.esdc1a.yp(), yp_before, "derivative"); return success; } - bool smoothHighValueGateInitialResidual() + /// Write state rows and publish the update, folding in the + /// setDataUpdated() that a hand-written write block has to remember. + template + void setState(PhasorDynamics::Exciter::Esdc1a& esdc1a, Rows rows) const { - auto data = makeDefaultData(); - - PhasorDynamics::Bus bus(3.0, 4.0); - PhasorDynamics::SignalNode efd_node; - PhasorDynamics::SignalNode omega_node; - PhasorDynamics::SignalNode vs_node; - - ScalarT efd_value{0.0}; - ScalarT omega_value{0.0}; - ScalarT vs_value{0.0}; - - IdxT efd_index{INVALID_INDEX}; - IdxT omega_index{20}; - IdxT vs_index{21}; - - efd_node.set(&efd_value, &efd_index); - omega_node.set(&omega_value, &omega_index); - vs_node.set(&vs_value, &vs_index); - - PhasorDynamics::Exciter::Esdc1a exciter(&bus, data); - exciter.getSignals().template assignSignalNode(&efd_node); - exciter.getSignals().template attachSignalNode(&omega_node); - exciter.getSignals().template attachSignalNode(&vs_node); - - bus.allocate(); - bus.initialize(); - exciter.allocate(); - efd_node.init(1.2); - - TestStatus success = true; - success *= (exciter.verify() == 0); - success *= (exciter.initialize() == 0); - success *= (exciter.evaluateResidual() == 0); - const auto* f = exciter.getResidual().getData(); - success *= isEqual(f[idx(Internal::VHV)], static_cast(0.0), kTol); - - return success; + auto* y = esdc1a.y().getData(); + for (const auto& [row, value] : rows) + { + y[row] = static_cast(value); + } + esdc1a.y().setDataUpdated(); } - bool timeConstantClampSemantics() + /// setState() for the derivative vector. + template + void setDerivative(PhasorDynamics::Exciter::Esdc1a& esdc1a, Rows rows) const { - auto data = makeDefaultData(); - data.parameters[Params::Tr] = 0.0; - data.parameters[Params::Tb] = 0.0; - data.parameters[Params::Tc] = 0.1; - data.parameters[Params::Tf1] = 0.0; - - Fixture fixture(data); - if (!fixture.allocateAndInitialize(1.2)) + auto* yp = esdc1a.yp().getData(); + for (const auto& [row, value] : rows) { - return false; + yp[row] = static_cast(value); } - - fixture.exciter.tagDifferentiable(); - - bool success = fixture.exciter.tag()[idx(Internal::VC)]; - success = success && fixture.exciter.tag()[idx(Internal::VF)]; - success = success && fixture.exciter.tag()[idx(Internal::XLL)]; - - auto* y = fixture.exciter.y().getData(); - const auto* f = fixture.exciter.getResidual().getData(); - - y[idx(Internal::VC)] += 0.1; - fixture.exciter.y().setDataUpdated(); - fixture.exciter.evaluateResidual(); - success = success - && f[idx(Internal::VC)] < static_cast(0.0); - - y[idx(Internal::VC)] -= 0.1; - y[idx(Internal::VF)] += 0.1; - fixture.exciter.y().setDataUpdated(); - fixture.exciter.evaluateResidual(); - success = success - && f[idx(Internal::VF)] < static_cast(0.0); - - y[idx(Internal::VF)] -= 0.1; - y[idx(Internal::XLL)] += 0.1; - fixture.exciter.y().setDataUpdated(); - fixture.exciter.evaluateResidual(); - success = success - && f[idx(Internal::XLL)] < static_cast(0.0); - - return success; + esdc1a.yp().setDataUpdated(); } - bool uelRoutingSelector() + /// 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 `Esdc1aIdx` 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) { - Fixture hv_gate(makeDefaultData()); - if (!hv_gate.allocateAndInitialize(1.2)) + if (isEqual(actual, expected, kBehaviorTol)) { - return false; + return true; } + std::cout << "ESDC1A " << what << " row " << row << ' ' << context + << " mismatch: " << std::setprecision(16) << actual + << " != " << expected << '\n'; + return false; + } - hv_gate.vuel_value = hv_gate.exciter.y().getData()[idx(Internal::VLL)] + 0.1; - hv_gate.exciter.evaluateResidual(); - const auto* hv_gate_f = hv_gate.exciter.getResidual().getData(); - bool success = hv_gate_f[idx(Internal::VHV)] > static_cast(0.0); - success = success && isEqual(hv_gate_f[idx(Internal::EV)], static_cast(0.0), kTol); - - auto sum_data = makeDefaultData(); - sum_data.parameters[Params::UEL] = static_cast(2); - Fixture sum_junction(sum_data); - sum_junction.vuel_value = 0.0; - if (!sum_junction.allocateAndInitialize(1.2)) + /// 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) { - return false; + const auto& [row, expected] = rows[i]; + success &= rowMatches(static_cast(values[row]), expected, what, row, context); } + return success; + } - sum_junction.vuel_value = 0.1; - sum_junction.exciter.evaluateResidual(); - const auto* sum_f = sum_junction.exciter.getResidual().getData(); - success = success && sum_f[idx(Internal::EV)] > static_cast(0.0); - success = success && isEqual(sum_f[idx(Internal::VHV)], static_cast(0.0), kTol); + bool residualsMatch(const Esdc1aT& esdc1a, Rows rows, const char* context = "") const + { + return rowsMatch(esdc1a.getResidual(), rows.begin(), rows.size(), "residual", context); + } - return success; + template + bool residualsMatch(const Esdc1aT& esdc1a, + const std::array& rows, + const char* context = "") const + { + return rowsMatch(esdc1a.getResidual(), rows.data(), size, "residual", context); } - bool exciterFeedbackLimiter() + /// The model sits at a steady state: every residual and every + /// derivative is zero. + bool allResidualsZero(const Esdc1aT& esdc1a) const { - auto limited_data = makeDefaultData(); - limited_data.parameters[Params::Ke] = -0.2; - limited_data.parameters[Params::Se1] = 0.0; - limited_data.parameters[Params::Se2] = 0.0; - limited_data.parameters[Params::exclim] = 1.0; - Fixture limited(limited_data); - if (!limited.allocateAndInitialize(1.2)) + bool success = true; + const auto* f = esdc1a.getResidual().getData(); + const auto* yp = esdc1a.yp().getData(); + for (size_t row = 0; row < static_cast(esdc1a.getResidual().getSize()); ++row) { - return false; + 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; + } - auto* limited_y = limited.exciter.y().getData(); - limited_y[idx(Internal::EFDP)] = 1.0; - limited_y[idx(Internal::SE)] = 0.0; - limited_y[idx(Internal::VFE)] = 0.0; - limited.exciter.y().setDataUpdated(); - limited.exciter.evaluateResidual(); - const auto* limited_f = limited.exciter.getResidual().getData(); - bool success = std::abs(limited_f[idx(Internal::VFE)]) < kTol; - - auto unlimited_data = limited_data; - unlimited_data.parameters[Params::exclim] = 0.0; - Fixture unlimited(unlimited_data); - if (!unlimited.allocateAndInitialize(1.2)) + bool scalarMatches(ScalarT actual, + ScalarT expected, + const char* label, + ScalarT tolerance = kBehaviorTol) const + { + if (isEqual(actual, expected, tolerance)) { - return false; + return true; } + std::cout << label << " mismatch: " << std::setprecision(16) << actual + << " != " << expected << "\n"; + return false; + } - auto* unlimited_y = unlimited.exciter.y().getData(); - unlimited_y[idx(Internal::EFDP)] = 1.0; - unlimited_y[idx(Internal::SE)] = 0.0; - unlimited_y[idx(Internal::VFE)] = 0.0; - unlimited.exciter.y().setDataUpdated(); - unlimited.exciter.evaluateResidual(); - const auto* unlimited_f = unlimited.exciter.getResidual().getData(); - success = success && unlimited_f[idx(Internal::VFE)] < static_cast(0.0); - - 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); } #ifdef GRIDKIT_ENABLE_ENZYME - std::vector - dependencyTrackingJacobian(const DataT& data) + void numberVariables(Fixture& fixture) const { - using Variable = DependencyTracking::Variable; - - PhasorDynamics::Bus bus(Variable{3.0}, Variable{4.0}); - PhasorDynamics::SignalNode efd_node; - PhasorDynamics::SignalNode omega_node; - PhasorDynamics::SignalNode vs_node; - PhasorDynamics::SignalNode vuel_node; - - Variable efd_value{0.0}; - Variable omega_value{0.0}; - Variable vs_value{0.0}; - Variable vuel_value{0.0}; - - IdxT efd_index = INVALID_INDEX; - IdxT omega_index = 13; - IdxT vs_index = 14; - IdxT vuel_index = 15; + auto* y = fixture.esdc1a.y().getData(); + auto* yp = fixture.esdc1a.yp().getData(); + auto* bus_y = fixture.bus.y().getData(); - efd_node.set(&efd_value, &efd_index); - omega_node.set(&omega_value, &omega_index); - vs_node.set(&vs_value, &vs_index); - vuel_node.set(&vuel_value, &vuel_index); - - PhasorDynamics::Exciter::Esdc1a exciter(&bus, data); - exciter.getSignals().template assignSignalNode(&efd_node); - exciter.getSignals().template attachSignalNode(&omega_node); - exciter.getSignals().template attachSignalNode(&vs_node); - exciter.getSignals().template attachSignalNode(&vuel_node); - - bus.allocate(); - exciter.allocate(); - bus.initialize(); - efd_node.init(Variable{1.2}); - exciter.initialize(); - - auto* exciter_y = exciter.y().getData(); - for (size_t i = 0; i < exciter.size(); ++i) + const auto model_size = static_cast(fixture.esdc1a.size()); + for (size_t i = 0; i < model_size; ++i) { - exciter_y[i].setVariableNumber(i); + y[i].setVariableNumber(i); + yp[i].setVariableNumber(i); } - exciter.y().setDataUpdated(); - auto* bus_y = bus.y().getData(); - for (size_t i = 0; i < bus.size(); ++i) + for (size_t i = 0; i < static_cast(fixture.bus.size()); ++i) { - bus_y[i].setVariableNumber(i + exciter.size()); + bus_y[i].setVariableNumber(model_size + i); } - bus.y().setDataUpdated(); - omega_value.setVariableNumber(13); - vs_value.setVariableNumber(14); - vuel_value.setVariableNumber(15); - - bus.evaluateResidual(); - exciter.evaluateResidual(); - const auto& residual_y_view = exciter.getResidual(); - std::vector residual_y(residual_y_view.getData(), - residual_y_view.getData() + residual_y_view.getSize()); - - omega_value = 0.0; - vs_value = 0.0; - vuel_value = 0.0; - bus.initialize(); - efd_node.init(Variable{1.2}); - exciter.initialize(); - - auto* exciter_yp = exciter.yp().getData(); - for (size_t i = 0; i < exciter.size(); ++i) + for (size_t port = 0; port < E::MAXIMUM; ++port) { - exciter_yp[i].setVariableNumber(i); + fixture.input(port).setVariableNumber(fixture.inputIndex(port)); } - exciter.yp().setDataUpdated(); - bus.evaluateResidual(); - exciter.evaluateResidual(); - const auto& residual_yp_view = exciter.getResidual(); - std::vector residual_yp(residual_yp_view.getData(), - residual_yp_view.getData() + residual_yp_view.getSize()); + fixture.esdc1a.y().setDataUpdated(); + fixture.esdc1a.yp().setDataUpdated(); + fixture.bus.y().setDataUpdated(); + } - std::vector dependencies(residual_y.size()); - for (IdxT i = 0; i < residual_y.size(); ++i) + std::vector dependencyTrackingJacobian( + const Data& data, + TestStatus& success) const + { + using DepVar = DependencyTracking::Variable; + + Fixture fixture(data, kStateVr, kStateVi); + fixture.attachAllInputs(); + success *= fixture.initialize(1.2); + setAnswerKeyInputs(fixture); + setAnswerKeyState(fixture.esdc1a); + numberVariables(fixture); + success *= (fixture.evaluate() == 0); + + const auto model_size = static_cast(fixture.esdc1a.size()); + std::vector rows(model_size); + const auto* f = fixture.esdc1a.getResidual().getData(); + for (size_t i = 0; i < model_size; ++i) { - auto dependency_y = residual_y[static_cast(i)].getDependencies(); - auto dependency_yp = residual_yp[static_cast(i)].getDependencies(); - - for (const auto& pair_y : dependency_y) - { - auto index_y = pair_y.first; - auto value_y = pair_y.second; - auto it_yp = dependency_yp.find(index_y); - if (it_yp != dependency_yp.end()) - { - dependencies[static_cast(i)].insert(std::make_pair(index_y, value_y + it_yp->second)); - } - else - { - dependencies[static_cast(i)].insert(pair_y); - } - } - - for (const auto& pair_yp : dependency_yp) - { - if (dependency_y.find(pair_yp.first) == dependency_y.end()) - { - dependencies[static_cast(i)].insert(pair_yp); - } - } + rows[i] = f[i].getDependencies(); } - - return dependencies; + return rows; } - std::vector - enzymeJacobian(const DataT& data) + std::vector enzymeJacobian( + const Data& data, + TestStatus& success) const { - PhasorDynamics::Bus bus(3.0, 4.0); - PhasorDynamics::SignalNode efd_node; - PhasorDynamics::SignalNode omega_node; - PhasorDynamics::SignalNode vs_node; - PhasorDynamics::SignalNode vuel_node; + Fixture fixture(data, kStateVr, kStateVi); + fixture.attachAllInputs(); + success *= fixture.initialize(1.2); - ScalarT efd_value{0.0}; - ScalarT omega_value{0.0}; - ScalarT vs_value{0.0}; - ScalarT vuel_value{0.0}; - - IdxT efd_index = INVALID_INDEX; - IdxT omega_index = 13; - IdxT vs_index = 14; - IdxT vuel_index = 15; - - efd_node.set(&efd_value, &efd_index); - omega_node.set(&omega_value, &omega_index); - vs_node.set(&vs_value, &vs_index); - vuel_node.set(&vuel_value, &vuel_index); - - PhasorDynamics::Exciter::Esdc1a exciter(&bus, data); - exciter.getSignals().template assignSignalNode(&efd_node); - exciter.getSignals().template attachSignalNode(&omega_node); - exciter.getSignals().template attachSignalNode(&vs_node); - exciter.getSignals().template attachSignalNode(&vuel_node); - - bus.allocate(); - exciter.allocate(); - bus.initialize(); - efd_node.init(1.2); - exciter.initialize(); - exciter.updateTime(0.0, 1.0); - - for (size_t i = 0; i < bus.size(); ++i) + for (IdxT i = 0; i < fixture.bus.size(); ++i) { - bus.setVariableIndex(i, static_cast(i + exciter.size())); - bus.setResidualIndex(i, static_cast(i + exciter.size())); + fixture.bus.setVariableIndex(i, fixture.esdc1a.size() + i); } - bus.evaluateResidual(); - exciter.evaluateResidual(); - exciter.evaluateJacobian(); - exciter.constructCsr(); - - auto* model_jacobian = exciter.getCsrJacobian(); - - return MapFromCsr(model_jacobian); + setAnswerKeyInputs(fixture); + setAnswerKeyState(fixture.esdc1a); + fixture.esdc1a.updateTime(0.0, 1.0); + success *= (fixture.evaluate() == 0); + success *= (fixture.esdc1a.evaluateJacobian() == 0); + success *= (fixture.esdc1a.constructCsr() == 0); + return MapFromCsr(fixture.esdc1a.getCsrJacobian()); } #endif }; diff --git a/tests/UnitTests/PhasorDynamics/runExciterEsdc1aTests.cpp b/tests/UnitTests/PhasorDynamics/runExciterEsdc1aTests.cpp index 04c40b955..e3630861c 100644 --- a/tests/UnitTests/PhasorDynamics/runExciterEsdc1aTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runExciterEsdc1aTests.cpp @@ -6,12 +6,14 @@ int main() GridKit::Testing::ExciterEsdc1aTests test; - result += test.constructor(); - result += test.zeroInitialResidual(); - result += test.blockDiagramSemantics(); - result += test.parameterValidation(); + result += test.validation(); + result += test.initializationAndSignals(); + result += test.initializationDomain(); + result += test.residualEquations(); + result += test.voltageRegulation(); + result += test.excitationLimits(); #ifdef GRIDKIT_ENABLE_ENZYME - result += test.jacobianStructureAndValues(); + result += test.jacobian(); #endif return result.summary(); From 0d578dda7375086efb703d4a162bbfcacd8e8fdc Mon Sep 17 00:00:00 2001 From: lukelowry Date: Fri, 31 Jul 2026 22:03:51 -0500 Subject: [PATCH 03/18] minor cleaning --- .../PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp | 96 +++--- .../Exciter/ESDC1A/Esdc1aData.hpp | 60 ++-- .../Exciter/ESDC1A/Esdc1aImpl.hpp | 270 +++++++-------- .../PhasorDynamics/Exciter/ESDC1A/README.md | 307 ++++++++++-------- .../PhasorDynamics/ExciterEsdc1aTests.hpp | 191 ++++++----- 5 files changed, 485 insertions(+), 439 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp index e0c08d306..1bd83dc57 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp @@ -30,57 +30,30 @@ namespace GridKit /// Internal variables of an `Esdc1a`. enum class Esdc1aInternalVariables : size_t { - EFDP, ///< Field-voltage state before optional speed multiplier - VC, ///< Sensed compensated voltage - VR, ///< Voltage-regulator output - VF, ///< Stabilizing feedback output - XLL, ///< Lead-lag state - EV, ///< Voltage-regulator input error - VLL, ///< Lead-lag block output - VHV, ///< High-value gate output - SE, ///< Saturation coefficient - VFE, ///< Exciter feedback signal - EFD, ///< Field-voltage output + EFDP, ///< \f$E_{\mathrm{fd}}'\f$ Exciter field-voltage state + VC, ///< \f$V_C\f$ Filtered terminal-voltage magnitude + VR, ///< \f$V_R\f$ Voltage-regulator output + VF, ///< \f$V_F\f$ Stabilizing feedback state + XLL, ///< \f$x_{\mathrm{LL}}\f$ Input lead-lag denominator state + EV, ///< \f$e_V\f$ Voltage-error summing output + VLL, ///< \f$V_{\mathrm{LL}}\f$ Input lead-lag output + VHV, ///< \f$V_{\mathrm{HV}}\f$ High-value gate output + SE, ///< \f$S_E\f$ Exciter saturation coefficient + VFE, ///< \f$V_{\mathrm{FE}}\f$ Exciter feedback drive + EFD, ///< \f$E_{\mathrm{fd}}\f$ Field-voltage output MAXIMUM, }; /// External variables of an `Esdc1a`. enum class Esdc1aExternalVariables : size_t { - OMEGA, ///< Machine speed deviation - VREF, ///< Voltage-control reference - VS, ///< Stabilizer input signal - VUEL, ///< Under-excitation limiter input + OMEGA, ///< \f$\omega\f$ Machine speed deviation + VREF, ///< \f$V_{\mathrm{ref}}\f$ Voltage-control reference + VS, ///< \f$V_S\f$ Stabilizer input signal + VUEL, ///< \f$V_{\mathrm{UEL}}\f$ Under-excitation limiter input MAXIMUM, }; - /// Indices into the ESDC1A state, derivative, and residual vectors. - struct Esdc1aIdx - { - static constexpr size_t EFDP = static_cast(Esdc1aInternalVariables::EFDP); - static constexpr size_t VC = static_cast(Esdc1aInternalVariables::VC); - static constexpr size_t VR = static_cast(Esdc1aInternalVariables::VR); - static constexpr size_t VF = static_cast(Esdc1aInternalVariables::VF); - static constexpr size_t XLL = static_cast(Esdc1aInternalVariables::XLL); - static constexpr size_t EV = static_cast(Esdc1aInternalVariables::EV); - static constexpr size_t VLL = static_cast(Esdc1aInternalVariables::VLL); - static constexpr size_t VHV = static_cast(Esdc1aInternalVariables::VHV); - static constexpr size_t SE = static_cast(Esdc1aInternalVariables::SE); - static constexpr size_t VFE = static_cast(Esdc1aInternalVariables::VFE); - static constexpr size_t EFD = static_cast(Esdc1aInternalVariables::EFD); - static constexpr size_t MAXIMUM = static_cast(Esdc1aInternalVariables::MAXIMUM); - }; - - /// Indices into the ESDC1A external-signal buffers. - struct Esdc1aExt - { - static constexpr size_t OMEGA = static_cast(Esdc1aExternalVariables::OMEGA); - static constexpr size_t VREF = static_cast(Esdc1aExternalVariables::VREF); - static constexpr size_t VS = static_cast(Esdc1aExternalVariables::VS); - static constexpr size_t VUEL = static_cast(Esdc1aExternalVariables::VUEL); - static constexpr size_t MAXIMUM = static_cast(Esdc1aExternalVariables::MAXIMUM); - }; - template class Esdc1a : public Component { @@ -102,13 +75,15 @@ 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 = Esdc1aData; - 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 = Esdc1aData; + using MonitorT = Model::VariableMonitor; + using InternalVariablesT = Esdc1aInternalVariables; + using ExternalVariablesT = Esdc1aExternalVariables; Esdc1a(BusT* bus); Esdc1a(BusT* bus, const ModelDataT& data); @@ -126,8 +101,8 @@ namespace GridKit auto getSignals() -> ComponentSignals& + InternalVariablesT, + ExternalVariablesT>& { return signals_; } @@ -138,6 +113,19 @@ namespace GridKit const ScalarT*, const ScalarT*, const ScalarT*, const ScalarT*, ScalarT*); private: + using I = InternalVariablesT; + using E = ExternalVariablesT; + + static constexpr size_t index(I variable) + { + return static_cast(variable); + } + + static constexpr size_t index(E variable) + { + return static_cast(variable); + } + void initializeParameters(const ModelDataT& data); void initializeMonitor(); void setDerivedParameters(); @@ -173,9 +161,7 @@ namespace GridKit bool exclim_{true}; RealT spd_on_{0}; RealT uel_on_{0}; - RealT uel_off_{1}; RealT lim_on_{1}; - RealT lim_off_{0}; RealT SA_{0}; RealT SB_{0}; @@ -186,8 +172,8 @@ namespace GridKit ScalarT vs_set_{0}; ScalarT vuel_set_{0}; - ComponentSignals signals_; - std::unique_ptr monitor_; + ComponentSignals signals_; + std::unique_ptr monitor_; std::vector ws_; std::vector ws_indices_; diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp index dbdce8502..2f16b3a53 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp @@ -17,59 +17,59 @@ namespace GridKit /// Parameter keys for the ESDC1A exciter model. enum class Esdc1aParameters { - Tr, ///< Transducer time constant - Ka, ///< Voltage-regulator gain - Ta, ///< Voltage-regulator time constant - Tb, ///< Lead-lag denominator time constant - Tc, ///< Lead-lag numerator time constant - Vrmax, ///< Maximum voltage-regulator output - Vrmin, ///< Minimum voltage-regulator output - Ke, ///< Exciter field-resistance line-slope margin - Te, ///< Exciter field time constant - Kf, ///< Stabilizing feedback gain - Tf1, ///< Feedback lead time constant - Spdmlt, ///< Speed multiplier flag - E1, ///< First saturation voltage point - Se1, ///< Saturation value at E1 - E2, ///< Second saturation voltage point - Se2, ///< Saturation value at E2 - UEL, ///< UEL input-location selector - exclim ///< Exciter feedback lower-limit flag + Tr, ///< \f$T_R\f$ Voltage transducer time constant + Ka, ///< \f$K_A\f$ Voltage-regulator gain + Ta, ///< \f$T_A\f$ Voltage-regulator time constant + Tb, ///< \f$T_B\f$ Input lead-lag denominator time constant + Tc, ///< \f$T_C\f$ Input lead-lag numerator time constant + Vrmax, ///< \f$V_R^{\max}\f$ Maximum voltage-regulator output + Vrmin, ///< \f$V_R^{\min}\f$ Minimum voltage-regulator output + Ke, ///< \f$K_E\f$ Exciter constant + Te, ///< \f$T_E\f$ Exciter time constant + Kf, ///< \f$K_F\f$ Stabilizing feedback gain + Tf1, ///< \f$T_{F1}\f$ Stabilizing feedback time constant + Spdmlt, ///< \f$s_{\mathrm{spd}}\f$ Field-voltage speed-multiplier flag + E1, ///< \f$E_1\f$ First saturation voltage point + Se1, ///< \f$S_E(E_1)\f$ Saturation coefficient at \f$E_1\f$ + E2, ///< \f$E_2\f$ Second saturation voltage point + Se2, ///< \f$S_E(E_2)\f$ Saturation coefficient at \f$E_2\f$ + UEL, ///< \f$I_{\mathrm{UEL}}\f$ UEL input-routing selector + exclim ///< \f$s_{\mathrm{lim}}\f$ Exciter feedback lower-limit flag }; /// Buses for the ESDC1A exciter model. enum class Esdc1aBuses : size_t { - bus, ///< Terminal bus ID + bus, ///< Terminal bus ID for \f$V_{\mathrm{r}}\f$ and \f$V_{\mathrm{i}}\f$ SIZE }; /// Signal inputs for the ESDC1A exciter model. enum class Esdc1aSignalInputs : size_t { - speed, ///< Machine speed-deviation signal ID - vref, ///< Optional voltage-reference signal ID - vs, ///< Optional stabilizer input signal ID - vuel, ///< Optional UEL input signal ID + speed, ///< \f$\omega\f$ Machine speed-deviation signal ID + vref, ///< \f$V_{\mathrm{ref}}\f$ Optional voltage-reference signal ID + vs, ///< \f$V_S\f$ Optional stabilizer input signal ID + vuel, ///< \f$V_{\mathrm{UEL}}\f$ Optional UEL input signal ID SIZE }; /// Signal outputs for the ESDC1A exciter model. enum class Esdc1aSignalOutputs : size_t { - efd, ///< Field-voltage output signal ID + efd, ///< \f$E_{\mathrm{fd}}\f$ Required field-voltage output signal ID SIZE }; /// Variables available through the monitor interface. enum class Esdc1aMonitorableVariables { - efd, ///< Field-voltage output - vc, ///< Sensed compensated voltage - vr, ///< Voltage-regulator output - vf, ///< Stabilizing feedback output - se, ///< Saturation coefficient - vfe ///< Exciter feedback signal + efd, ///< \f$E_{\mathrm{fd}}\f$ Field-voltage output + vc, ///< \f$V_C\f$ Filtered terminal-voltage magnitude + vr, ///< \f$V_R\f$ Voltage-regulator output + vf, ///< \f$V_F\f$ Stabilizing feedback state + se, ///< \f$S_E\f$ Exciter saturation coefficient + vfe ///< \f$V_{\mathrm{FE}}\f$ Exciter feedback drive }; template diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp index 4c6e4c90c..dab1f3a8b 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp @@ -38,7 +38,7 @@ namespace GridKit Esdc1a::Esdc1a(BusT* bus) : bus_(bus) { - size_ = static_cast(Esdc1aIdx::MAXIMUM); + size_ = static_cast(I::MAXIMUM); setDerivedParameters(); } @@ -55,7 +55,7 @@ namespace GridKit { initializeParameters(data); initializeMonitor(); - size_ = static_cast(Esdc1aIdx::MAXIMUM); + size_ = static_cast(I::MAXIMUM); } template @@ -88,9 +88,9 @@ namespace GridKit /** * @brief Resolve the parameter-derived constants and selector masks * - * Raises the transducer, lead-lag, and feedback lags to the - * well-posedness floor, fits the quadratic saturation curve, and turns - * the three selectors into complementary multiplicative masks. The + * Raises the transducer, regulator, lead-lag, exciter, and feedback + * lags to the well-posedness floor, fits the quadratic saturation + * curve, and turns the three selectors into multiplicative masks. The * masks let the residual select signal routing without * parameter-dependent control flow, which keeps its structure fixed for * sparse automatic differentiation. @@ -111,26 +111,43 @@ namespace GridKit }; check_non_negative(Tr_, "Tr"); + check_non_negative(Ta_, "Ta"); check_non_negative(Tb_, "Tb"); + check_non_negative(Te_, "Te"); check_non_negative(Tf1_, "Tf1"); - if (Tr_ < TIME_CONSTANT_MINIMUM || Tb_ < TIME_CONSTANT_MINIMUM + if (Tr_ < TIME_CONSTANT_MINIMUM || Ta_ < TIME_CONSTANT_MINIMUM + || Tb_ < TIME_CONSTANT_MINIMUM || Te_ < TIME_CONSTANT_MINIMUM || Tf1_ < TIME_CONSTANT_MINIMUM) { - Log::warning() << "Esdc1a: Tr, Tb, and Tf1 below " + Log::warning() << "Esdc1a: Tr, Ta, Tb, Te, and Tf1 below " << TIME_CONSTANT_MINIMUM << " s are raised to that floor to keep the exciter lags well posed\n"; } Tr_ = std::max(Tr_, TIME_CONSTANT_MINIMUM); + Ta_ = std::max(Ta_, TIME_CONSTANT_MINIMUM); Tb_ = std::max(Tb_, TIME_CONSTANT_MINIMUM); + Te_ = std::max(Te_, TIME_CONSTANT_MINIMUM); Tf1_ = std::max(Tf1_, TIME_CONSTANT_MINIMUM); - spd_on_ = Spdmlt_ ? ONE : ZERO; - uel_on_ = UEL_ >= static_cast(2) ? ONE : ZERO; - uel_off_ = ONE - uel_on_; - lim_on_ = exclim_ ? ONE : ZERO; - lim_off_ = ONE - lim_on_; + spd_on_ = ZERO; + if (Spdmlt_) + { + spd_on_ = ONE; + } + + uel_on_ = ZERO; + if (UEL_ >= static_cast(2)) + { + uel_on_ = ONE; + } + + lim_on_ = ZERO; + if (exclim_) + { + lim_on_ = ONE; + } // A disabled or inconsistent saturation curve keeps the zero fit so // the coefficients stay finite; verify() reports inconsistent data. @@ -313,21 +330,20 @@ namespace GridKit template void Esdc1a::initializeMonitor() { - using I = Esdc1aIdx; using Variable = typename ModelDataT::MonitorableVariables; monitor_->set(Variable::efd, [this] - { return y_.getData()[I::EFD]; }); + { return y_.getData()[index(I::EFD)]; }); monitor_->set(Variable::vc, [this] - { return y_.getData()[I::VC]; }); + { return y_.getData()[index(I::VC)]; }); monitor_->set(Variable::vr, [this] - { return y_.getData()[I::VR]; }); + { return y_.getData()[index(I::VR)]; }); monitor_->set(Variable::vf, [this] - { return y_.getData()[I::VF]; }); + { return y_.getData()[index(I::VF)]; }); monitor_->set(Variable::se, [this] - { return y_.getData()[I::SE]; }); + { return y_.getData()[index(I::SE)]; }); monitor_->set(Variable::vfe, [this] - { return y_.getData()[I::VFE]; }); + { return y_.getData()[index(I::VFE)]; }); } /** @@ -357,9 +373,6 @@ namespace GridKit template int Esdc1a::allocate() { - using I = Esdc1aIdx; - using E = Esdc1aExt; - if (!allocated_) { this->allocateVectors(size_); @@ -372,7 +385,7 @@ namespace GridKit wb_.assign(2, ScalarT{0}); - auto signal_size = E::MAXIMUM; + const auto signal_size = index(E::MAXIMUM); ws_.assign(signal_size, ScalarT{0}); ws_indices_.assign(signal_size, INVALID_INDEX); @@ -384,10 +397,10 @@ namespace GridKit auto* y = y_.getData(); - if (signals_.template isAssigned()) + if (signals_.template isAssigned()) { - signals_.template getSignalNode()->set( - &y[I::EFD], + signals_.template getSignalNode()->set( + &y[index(I::EFD)], &(this->getVariableIndex(static_cast(I::EFD)))); } @@ -426,8 +439,6 @@ namespace GridKit } check(Ka_ > ZERO, "Ka must be positive"); - check(Ta_ > ZERO, "Ta must be positive"); - check(Te_ > ZERO, "Te must be positive"); check(Tc_ >= ZERO, "Tc must be non-negative"); check(Vrmin_ <= Vrmax_, "Vrmin must be less than or equal to Vrmax"); check(UEL_ >= static_cast(0) && UEL_ <= static_cast(3), @@ -443,13 +454,13 @@ namespace GridKit check(Se1_ != Se2_, "Se1 and Se2 must differ when saturation is enabled"); } - if (!signals_.template isAssigned()) + if (!signals_.template isAssigned()) { Log::error() << "Esdc1a: required efd output signal is not assigned\n"; ret += 1; } - if (Spdmlt_ && !signals_.template isAttached()) + if (Spdmlt_ && !signals_.template isAttached()) { Log::error() << "Esdc1a: speed signal is required when Spdmlt is enabled\n"; ret += 1; @@ -458,7 +469,7 @@ namespace GridKit // 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) + [&](const char* name) { if (signals_.template isAttached() && !signals_.template isLinked()) @@ -468,10 +479,10 @@ namespace GridKit } }; - check_attached_signal.template operator()("speed"); - check_attached_signal.template operator()("vref"); - check_attached_signal.template operator()("vs"); - check_attached_signal.template operator()("vuel"); + check_attached_signal.template operator()("speed"); + check_attached_signal.template operator()("vref"); + check_attached_signal.template operator()("vs"); + check_attached_signal.template operator()("vuel"); return ret; } @@ -499,8 +510,6 @@ namespace GridKit template int Esdc1a::initialize() { - using I = Esdc1aIdx; - if (verify() > 0) { Log::error() << "Esdc1a: cannot initialize with invalid configuration\n"; @@ -511,30 +520,30 @@ namespace GridKit // The assigned efd node aliases this entry after allocate(). Its // seeded value remains untouched throughout initialization. - const ScalarT efd0 = y[I::EFD]; + const ScalarT efd0 = y[index(I::EFD)]; ScalarT omega0{ZERO}; - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - omega0 = signals_.template readExternalVariable(); + omega0 = signals_.template readExternalVariable(); } ScalarT vs0{ZERO}; - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - vs0 = signals_.template readExternalVariable(); + vs0 = signals_.template readExternalVariable(); } ScalarT vuel0{ZERO}; - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - vuel0 = signals_.template readExternalVariable(); + vuel0 = signals_.template readExternalVariable(); } - const ScalarT ec0 = std::sqrt(Vr() * Vr() + Vi() * Vi()); + const ScalarT vc0 = std::sqrt(Vr() * Vr() + Vi() * Vi()); if (!std::isfinite(static_cast(efd0)) - || !std::isfinite(static_cast(ec0))) + || !std::isfinite(static_cast(vc0))) { Log::error() << "Esdc1a: initial bus voltage and field-voltage seed must be finite\n"; return 1; @@ -547,10 +556,11 @@ namespace GridKit return 1; } - const ScalarT efdp0 = efd0 / d0; - const ScalarT se0 = SB_ * Math::qramp(efdp0 - SA_); - const ScalarT vfe0 = lim_off_ * (Ke_ + se0) * efdp0 - + lim_on_ * Math::ramp((Ke_ + se0) * efdp0); + const ScalarT efdp0 = efd0 / d0; + const ScalarT se0 = SB_ * Math::qramp(efdp0 - SA_); + const ScalarT vfe_drive0 = (Ke_ + se0) * efdp0; + const ScalarT vfe0 = + (ONE - lim_on_) * vfe_drive0 + lim_on_ * Math::ramp(vfe_drive0); const ScalarT vr0 = vfe0; const ScalarT vhv0 = vr0 / Ka_; @@ -562,7 +572,7 @@ namespace GridKit // An inactive high-value gate is seeded with the gate input, so the // residual reproduces VHV through the same smooth maximum. - ScalarT gate_input0 = vhv0; + ScalarT vll0 = vhv0; if (uel_on_ == ZERO) { const RealT gate_margin0 = static_cast(vhv0 - vuel0); @@ -571,36 +581,34 @@ namespace GridKit Log::error() << "Esdc1a: smooth high-value gate is active at initialization\n"; return 1; } - gate_input0 = vuel0 + inverseRamp(gate_margin0); + vll0 = vuel0 + inverseRamp(gate_margin0); } - const ScalarT vc0 = ec0; const ScalarT vf0 = ScalarT{ZERO}; - const ScalarT ev0 = gate_input0; - const ScalarT xll0 = gate_input0; - const ScalarT vll0 = gate_input0; + const ScalarT ev0 = vll0; + const ScalarT xll0 = ev0; const ScalarT vref0 = ev0 + vc0 + vf0 - vs0 - uel_on_ * vuel0; - y[I::EFDP] = efdp0; - y[I::VC] = vc0; - y[I::VR] = vr0; - y[I::VF] = vf0; - y[I::XLL] = xll0; - y[I::EV] = ev0; - y[I::VLL] = vll0; - y[I::VHV] = vhv0; - y[I::SE] = se0; - y[I::VFE] = vfe0; - y[I::EFD] = efd0; + y[index(I::EFDP)] = efdp0; + y[index(I::VC)] = vc0; + y[index(I::VR)] = vr0; + y[index(I::VF)] = vf0; + y[index(I::XLL)] = xll0; + y[index(I::EV)] = ev0; + y[index(I::VLL)] = vll0; + y[index(I::VHV)] = vhv0; + y[index(I::SE)] = se0; + y[index(I::VFE)] = vfe0; + y[index(I::EFD)] = efd0; omega_set_ = omega0; vref_set_ = vref0; vs_set_ = vs0; vuel_set_ = vuel0; - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - signals_.template writeExternalVariable(vref_set_); + signals_.template writeExternalVariable(vref_set_); } y_.setDataUpdated(); @@ -620,14 +628,12 @@ namespace GridKit template int Esdc1a::tagDifferentiable() { - using I = Esdc1aIdx; - std::fill(tag_.begin(), tag_.end(), false); - tag_[I::EFDP] = true; - tag_[I::VC] = true; - tag_[I::VR] = true; - tag_[I::VF] = true; - tag_[I::XLL] = true; + tag_[index(I::EFDP)] = true; + tag_[index(I::VC)] = true; + tag_[index(I::VR)] = true; + tag_[index(I::VF)] = true; + tag_[index(I::XLL)] = true; return 0; } @@ -672,46 +678,46 @@ namespace GridKit const ScalarT* ws, ScalarT* f) { - using I = Esdc1aIdx; - using E = Esdc1aExt; - - const ScalarT efdp = y[I::EFDP]; - const ScalarT vc = y[I::VC]; - const ScalarT vr = y[I::VR]; - const ScalarT vf = y[I::VF]; - const ScalarT xll = y[I::XLL]; - const ScalarT ev = y[I::EV]; - const ScalarT vll = y[I::VLL]; - const ScalarT vhv = y[I::VHV]; - const ScalarT se = y[I::SE]; - const ScalarT vfe = y[I::VFE]; - const ScalarT efd = y[I::EFD]; - - const ScalarT efdp_dot = yp[I::EFDP]; - const ScalarT vc_dot = yp[I::VC]; - const ScalarT vr_dot = yp[I::VR]; - const ScalarT vf_dot = yp[I::VF]; - const ScalarT xll_dot = yp[I::XLL]; - - const ScalarT omega = ws[E::OMEGA]; - const ScalarT vref = ws[E::VREF]; - const ScalarT vs = ws[E::VS]; - const ScalarT vuel = ws[E::VUEL]; + const ScalarT efdp = y[index(I::EFDP)]; + const ScalarT vc = y[index(I::VC)]; + const ScalarT vr = y[index(I::VR)]; + const ScalarT vf = y[index(I::VF)]; + const ScalarT xll = y[index(I::XLL)]; + const ScalarT ev = y[index(I::EV)]; + const ScalarT vll = y[index(I::VLL)]; + const ScalarT vhv = y[index(I::VHV)]; + const ScalarT se = y[index(I::SE)]; + const ScalarT vfe = y[index(I::VFE)]; + const ScalarT efd = y[index(I::EFD)]; + + const ScalarT efdp_dot = yp[index(I::EFDP)]; + const ScalarT vc_dot = yp[index(I::VC)]; + const ScalarT vr_dot = yp[index(I::VR)]; + const ScalarT vf_dot = yp[index(I::VF)]; + const ScalarT xll_dot = yp[index(I::XLL)]; + + const ScalarT omega = ws[index(E::OMEGA)]; + const ScalarT vref = ws[index(E::VREF)]; + const ScalarT vs = ws[index(E::VS)]; + const ScalarT vuel = ws[index(E::VUEL)]; const ScalarT ec = std::sqrt(wb[0] * wb[0] + wb[1] * wb[1]); const ScalarT ev_target = vref + vs + uel_on_ * vuel - vc - vf; - - f[I::EFDP] = -efdp_dot + (vr - vfe) / Te_; - f[I::VC] = -vc_dot + (ec - vc) / Tr_; - f[I::VR] = -vr_dot + Math::antiwindup(vr, -vr + Ka_ * vhv, Vrmin_, Vrmax_) / Ta_; - f[I::VF] = -vf_dot + (-vf + Kf_ * (vr - vfe) / Te_) / Tf1_; - f[I::XLL] = -xll_dot + (ev - xll) / Tb_; - f[I::EV] = -ev + ev_target; - f[I::VLL] = -vll + xll + (Tc_ / Tb_) * (ev - xll); - f[I::VHV] = -vhv + uel_on_ * vll + uel_off_ * Math::max(vll, vuel); - f[I::SE] = -se + SB_ * Math::qramp(efdp - SA_); - f[I::VFE] = -vfe + lim_off_ * (Ke_ + se) * efdp + lim_on_ * Math::ramp((Ke_ + se) * efdp); - f[I::EFD] = -efd + (ONE + spd_on_ * omega) * efdp; + const ScalarT vfe_drive = (Ke_ + se) * efdp; + + f[index(I::EFDP)] = -efdp_dot + (vr - vfe) / Te_; + f[index(I::VC)] = -vc_dot + (ec - vc) / Tr_; + f[index(I::VR)] = -vr_dot + Math::antiwindup(vr, -vr + Ka_ * vhv, Vrmin_, Vrmax_) / Ta_; + f[index(I::VF)] = -vf_dot + (-vf + Kf_ * (vr - vfe) / Te_) / Tf1_; + f[index(I::XLL)] = -xll_dot + (ev - xll) / Tb_; + f[index(I::EV)] = -ev + ev_target; + f[index(I::VLL)] = -vll + xll + (Tc_ / Tb_) * (ev - xll); + f[index(I::VHV)] = -vhv + uel_on_ * vll + + (ONE - uel_on_) * Math::max(vll, vuel); + f[index(I::SE)] = -se + SB_ * Math::qramp(efdp - SA_); + f[index(I::VFE)] = -vfe + (ONE - lim_on_) * vfe_drive + + lim_on_ * Math::ramp(vfe_drive); + f[index(I::EFD)] = -efd + (ONE + spd_on_ * omega) * efdp; return 0; } @@ -729,33 +735,35 @@ namespace GridKit template int Esdc1a::evaluateResidual() { - using E = Esdc1aExt; - - ws_[E::OMEGA] = omega_set_; - ws_[E::VREF] = vref_set_; - ws_[E::VS] = vs_set_; - ws_[E::VUEL] = vuel_set_; + ws_[index(E::OMEGA)] = omega_set_; + ws_[index(E::VREF)] = vref_set_; + ws_[index(E::VS)] = vs_set_; + ws_[index(E::VUEL)] = vuel_set_; std::fill(ws_indices_.begin(), ws_indices_.end(), INVALID_INDEX); - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - ws_[E::OMEGA] = signals_.template readExternalVariable(); - ws_indices_[E::OMEGA] = signals_.template readExternalVariableIndex(); + ws_[index(E::OMEGA)] = signals_.template readExternalVariable(); + ws_indices_[index(E::OMEGA)] = + signals_.template readExternalVariableIndex(); } - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - ws_[E::VREF] = signals_.template readExternalVariable(); - ws_indices_[E::VREF] = signals_.template readExternalVariableIndex(); + ws_[index(E::VREF)] = signals_.template readExternalVariable(); + ws_indices_[index(E::VREF)] = + signals_.template readExternalVariableIndex(); } - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - ws_[E::VS] = signals_.template readExternalVariable(); - ws_indices_[E::VS] = signals_.template readExternalVariableIndex(); + ws_[index(E::VS)] = signals_.template readExternalVariable(); + ws_indices_[index(E::VS)] = + signals_.template readExternalVariableIndex(); } - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - ws_[E::VUEL] = signals_.template readExternalVariable(); - ws_indices_[E::VUEL] = signals_.template readExternalVariableIndex(); + ws_[index(E::VUEL)] = signals_.template readExternalVariable(); + ws_indices_[index(E::VUEL)] = + signals_.template readExternalVariableIndex(); } wb_[0] = Vr(); diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md index 8e8334d5e..1e27cf1ca 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md @@ -1,21 +1,16 @@ # **IEEE DC1A Excitation System Model (ESDC1A)** -ESDC1A is an IEEE DC1A excitation-system model. In GridKit it reads the -connected bus voltage, optional stabilizer and under-excitation limiter signals, -and publishes field voltage. +ESDC1A is an IEEE DC1A excitation-system model with a voltage transducer, +input lead-lag compensation, a limited voltage regulator, exciter feedback and +saturation, under-excitation limiter routing, and an optional speed multiplier. ## Notes -- Internal voltage signals are on ESDC1A component base unless otherwise - stated. -- The connected bus supplies $E_C=\sqrt{V_{\mathrm{r}}^2+V_{\mathrm{i}}^2}$. +- Internal voltage signals are on component base. - The source diagram labels the optional multiplier input as `Speed`; GridKit uses machine speed deviation, so the enabled multiplier is $1+\omega$. -- The PowerWorld parameter table names the UEL selector `UEL`; `UEL >= 2` - routes the UEL input through the input-error summing junction and `UEL < 2` - routes it through the high-value gate. -- `efd` is a required output signal. `speed` is required only when - $s_{\mathrm{spd}}=1$; `vs` and `vuel` are optional and default to zero. +- The UEL selector routes $V_{\mathrm{UEL}}$ either through the high-value gate + or through the voltage-error summing junction. ## Block Diagram @@ -26,26 +21,28 @@ Figure 1: ESDC1A exciter model. Figure courtesy of the ## Model Parameters -Symbol | Units | JSON | Description | Typical Value | Note -------------------------------------|-----------|-----------|--------------------------------------------------|---------------|------ -$T_R$ | [sec] | `Tr` | Transducer time constant | 0.0 | State 2; raised to the minimum-time floor -$K_A$ | [p.u.] | `Ka` | Voltage-regulator gain | 40.0 | -$T_A$ | [sec] | `Ta` | Voltage-regulator time constant | 0.1 | State 3 -$T_B$ | [sec] | `Tb` | Lead-lag denominator time constant | 0.0 | State 5; raised to the minimum-time floor -$T_C$ | [sec] | `Tc` | Lead-lag numerator time constant | 0.0 | -$V_R^{\max}$ | [p.u.] | `Vrmax` | Maximum voltage-regulator output | 1.0 | -$V_R^{\min}$ | [p.u.] | `Vrmin` | Minimum voltage-regulator output | -1.0 | -$K_E$ | [p.u.] | `Ke` | Exciter field-resistance line-slope margin | 0.1 | -$T_E$ | [sec] | `Te` | Exciter field time constant | 0.5 | State 1 -$K_F$ | [p.u.] | `Kf` | Stabilizing feedback gain | 0.05 | -$T_{F1}$ | [sec] | `Tf1` | Feedback lead time constant | 0.7 | State 4; raised to the minimum-time floor -$s_{\mathrm{spd}}$ | [binary] | `Spdmlt` | Speed multiplier flag | 0 | 1 enables the speed multiplier -$E_1$ | [p.u.] | `E1` | First saturation voltage point | 2.8 | -$S_E(E_1)$ | [p.u.] | `Se1` | Saturation value at $E_1$ | 0.08 | -$E_2$ | [p.u.] | `E2` | Second saturation voltage point | 3.7 | -$S_E(E_2)$ | [p.u.] | `Se2` | Saturation value at $E_2$ | 0.33 | -$I_{\mathrm{UEL}}$ | [integer] | `UEL` | Under-excitation limiter input-location selector | 0 | 0/1 = high-value gate, 2/3 = input-error summing junction -$s_{\mathrm{lim}}$ | [binary] | `exclim` | Exciter feedback lower-limit flag | 1 | 1 enables the zero lower limit on $V_{\mathrm{FE}}$ +Symbol | Units | JSON | Description | Typical Value +------------------------------------|-----------|-----------|-------------------------------------------------|-------------- +$T_R$ | [sec] | `Tr` | Voltage transducer time constant | 0.0 +$K_A$ | [p.u.] | `Ka` | Voltage-regulator gain | 40.0 +$T_A$ | [sec] | `Ta` | Voltage-regulator time constant | 0.1 +$T_B$ | [sec] | `Tb` | Input lead-lag denominator time constant | 0.0 +$T_C$ | [sec] | `Tc` | Input lead-lag numerator time constant | 0.0 +$V_R^{\max}$ | [p.u.] | `Vrmax` | Maximum voltage-regulator output | 1.0 +$V_R^{\min}$ | [p.u.] | `Vrmin` | Minimum voltage-regulator output | -1.0 +$K_E$ | [p.u.] | `Ke` | Exciter constant | 0.1 +$T_E$ | [sec] | `Te` | Exciter time constant | 0.5 +$K_F$ | [p.u.] | `Kf` | Stabilizing feedback gain | 0.05 +$T_{F1}$ | [sec] | `Tf1` | Stabilizing feedback time constant | 0.7 +$s_{\mathrm{spd}}$ | [binary] | `Spdmlt` | Field-voltage speed-multiplier flag | 0 +$E_1$ | [p.u.] | `E1` | First saturation voltage point | 2.8 +$S_E(E_1)$ | [p.u.] | `Se1` | Saturation coefficient at $E_1$ | 0.08 +$E_2$ | [p.u.] | `E2` | Second saturation voltage point | 3.7 +$S_E(E_2)$ | [p.u.] | `Se2` | Saturation coefficient at $E_2$ | 0.33 +$I_{\mathrm{UEL}}$ | [integer] | `UEL` | Under-excitation limiter input-routing selector | 0 +$s_{\mathrm{lim}}$ | [binary] | `exclim` | Exciter feedback lower-limit flag | 1 + +Every parameter is optional. ### Parameter Validation @@ -53,55 +50,55 @@ Invalid ESDC1A parameter sets are rejected by the following checks: ```math \begin{aligned} - s_{\mathrm{spd}}, s_{\mathrm{lim}} - &\in \{0,1\} \\ - T_R, T_B, T_{F1} - &\ge 0 \\ K_A &> 0 \\ - T_A, T_E - &> 0 \\ - T_C + T_R, T_A, T_B, T_C, T_E, T_{F1} &\ge 0 \\ V_R^{\min} &\le V_R^{\max} \\ + s_{\mathrm{spd}}, s_{\mathrm{lim}} + &\in \{0,1\} \\ I_{\mathrm{UEL}} - &\in \{0,1,2,3\} \\ - \left(S_E(E_1), S_E(E_2)\right) - &=(0,0) - \quad\text{or}\quad - \begin{gathered} - E_1, E_2, S_E(E_1), S_E(E_2) > 0 \\ - E_1 \ne E_2 \\ - S_E(E_1) \ne S_E(E_2) - \end{gathered} + &\in \{0,1,2,3\} +\end{aligned} +``` + +The saturation points are either disabled together, + +```math +S_E(E_1) = S_E(E_2) = 0, +``` + +or define a valid two-point quadratic fit: + +```math +\begin{aligned} + E_1, E_2, S_E(E_1), S_E(E_2) &> 0 \\ + E_1 &\ne E_2 \\ + S_E(E_1) &\ne S_E(E_2) \end{aligned} ``` ### Model Derived Parameters -Let $\epsilon_T=10^{-3}\ \mathrm{s}$. A time constant below $\epsilon_T$ is +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\{R,B,F1\} \\ + \quad x\in\{R,A,B,E,F1\} \\ s_{\mathrm{UEL}} &= \begin{cases} 1 & I_{\mathrm{UEL}} \ge 2 \\ 0 & I_{\mathrm{UEL}} < 2 - \end{cases} \\ - s_{\mathrm{UEL}}^\mathrm{off} - &= 1 - s_{\mathrm{UEL}} \\ - s_{\mathrm{lim}}^\mathrm{off} - &= 1 - s_{\mathrm{lim}} + \end{cases} \end{aligned} ``` -When saturation is disabled, $S_A=0$ and $S_B=0$. Otherwise, +When saturation is disabled, $S_A = 0$ and $S_B = 0$. Otherwise, ```math \begin{aligned} @@ -122,9 +119,12 @@ Name | Port | Init | Description `vuel` | Input | Known | Under-excitation limiter input `efd` | Output | Known | Field-voltage output -`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. +`Known` ports hold their initial values before `initialize()` and are preserved +by it. `Unknown` inputs are resolved during initialization and written to +attached signal storage, or retained as constant inputs when unattached. The +`efd` output must be assigned. The `speed` input is required when +$s_{\mathrm{spd}} = 1$; every other signal input is optional. Unattached `speed`, +`vs`, and `vuel` inputs default to zero. ## Model Variables @@ -132,24 +132,24 @@ storage, or retained as constant inputs when the port is unattached. #### Differential -Symbol | Units | Description | Note -------------------------------------|--------|------------------------------------------------------|------ -$E_{\mathrm{fd}}'$ | [p.u.] | Field-voltage state before optional speed multiplier | State 1 in Fig. 1; source label: `EFD` -$V_C$ | [p.u.] | Sensed compensated voltage | State 2 in Fig. 1; source label: `Sensed Vt` -$V_R$ | [p.u.] | Voltage-regulator output | State 3 in Fig. 1; source label: `VR` -$V_F$ | [p.u.] | Stabilizing feedback output | State 4 in Fig. 1; source label: `VF` -$x_{\mathrm{LL}}$ | [p.u.] | Lead-lag block state | State 5 in Fig. 1; source label: `Lead-Lag` +Symbol | Units | Description | Note +------------------------------------|--------|--------------------------------------------------|------ +$E_{\mathrm{fd}}'$ | [p.u.] | Exciter field-voltage state | State 1 in Fig. 1; before the optional speed multiplier +$V_C$ | [p.u.] | Filtered terminal-voltage magnitude | State 2 in Fig. 1 +$V_R$ | [p.u.] | Voltage-regulator output | State 3 in Fig. 1 +$V_F$ | [p.u.] | Stabilizing feedback state | State 4 in Fig. 1 +$x_{\mathrm{LL}}$ | [p.u.] | Input lead-lag denominator state | State 5 in Fig. 1 #### Algebraic -Symbol | Units | Description | Note -------------------------------------|--------|--------------------------------------------------|------ -$e_V$ | [p.u.] | Voltage-regulator input error | -$V_{\mathrm{LL}}$ | [p.u.] | Lead-lag block output | Input to high-value gate -$V_{\mathrm{HV}}$ | [p.u.] | High-value gate output | Input to voltage regulator -$S_E$ | [p.u.] | Saturation coefficient evaluated at $E_{\mathrm{fd}}'$ | -$V_{\mathrm{FE}}$ | [p.u.] | Exciter feedback signal after optional lower limit | -$E_{\mathrm{fd}}$ | [p.u.] | Field-voltage output | Published through `efd` +Symbol | Units | Description | Note +------------------------------------|--------|-----------------------------------|------ +$e_V$ | [p.u.] | Voltage-error summing output | +$V_{\mathrm{LL}}$ | [p.u.] | Input lead-lag output | +$V_{\mathrm{HV}}$ | [p.u.] | High-value gate output | +$S_E$ | [p.u.] | Exciter saturation coefficient | Evaluated at $E_{\mathrm{fd}}'$ +$V_{\mathrm{FE}}$ | [p.u.] | Exciter feedback drive | Lower limited at zero when $s_{\mathrm{lim}} = 1$ +$E_{\mathrm{fd}}$ | [p.u.] | Field-voltage output | Published through `efd` ### External Variables @@ -159,14 +159,14 @@ None. #### Algebraic -Symbol | Units | Init | Description | Note -------------------------------------|--------|---------|-------------------------------------|------ -$V_{\mathrm{r}}$ | [p.u.] | Known | Terminal-bus voltage, real component | Bus input -$V_{\mathrm{i}}$ | [p.u.] | Known | Terminal-bus voltage, imaginary component | Bus input -$\omega$ | [p.u.] | Known | Machine speed deviation | Optional signal port `speed`; required when $s_{\mathrm{spd}}=1$ -$V_{\mathrm{ref}}$ | [p.u.] | Unknown | Voltage-control reference | Optional signal port `vref`; initialized constant setpoint; source label: `VREF` -$V_S$ | [p.u.] | Known | Stabilizer input signal | Optional signal port `vs`; defaults to zero -$V_{\mathrm{UEL}}$ | [p.u.] | Known | Under-excitation limiter input | Optional signal port `vuel`; defaults to zero +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 +$\omega$ | [p.u.] | Known | Machine speed deviation | Signal port `speed` +$V_{\mathrm{ref}}$ | [p.u.] | Unknown | Voltage-control reference | Signal port `vref` +$V_S$ | [p.u.] | Known | Stabilizer input signal | Signal port `vs` +$V_{\mathrm{UEL}}$ | [p.u.] | Known | Under-excitation limiter input | Signal port `vuel` ## Model Equations @@ -230,18 +230,23 @@ target and smooth approximation. \left(e_V - x_{\mathrm{LL}}\right) \\ 0 &= -V_{\mathrm{HV}} - + s_{\mathrm{UEL}}V_{\mathrm{LL}} - + s_{\mathrm{UEL}}^\mathrm{off} - \text{max}\left(V_{\mathrm{LL}}, V_{\mathrm{UEL}}\right) \\ + + \begin{cases} + \text{max}\left(V_{\mathrm{LL}}, V_{\mathrm{UEL}}\right) + & s_{\mathrm{UEL}} = 0 \\ + V_{\mathrm{LL}} + & s_{\mathrm{UEL}} = 1 + \end{cases} \\ 0 &= -S_E + S_B q\left(E_{\mathrm{fd}}' - S_A\right) \\ 0 &= -V_{\mathrm{FE}} - + s_{\mathrm{lim}}^\mathrm{off} - \left(K_E + S_E\right)E_{\mathrm{fd}}' - + s_{\mathrm{lim}}\rho - \left(\left(K_E + S_E\right)E_{\mathrm{fd}}'\right) \\ + + \begin{cases} + \left(K_E + S_E\right)E_{\mathrm{fd}}' + & s_{\mathrm{lim}} = 0 \\ + \rho\!\left(\left(K_E + S_E\right)E_{\mathrm{fd}}'\right) + & s_{\mathrm{lim}} = 1 + \end{cases} \\ 0 &= -E_{\mathrm{fd}} + \left(1 + s_{\mathrm{spd}}\omega\right)E_{\mathrm{fd}}' @@ -261,11 +266,11 @@ $\rho$, and the [quadratic ramp](../../../../CommonMath.md#primitives) $q$. V_{\mathrm{r}}, V_{\mathrm{i}} &\leftarrow \text{terminal-bus voltage} \\ E_{\mathrm{fd}} - &\leftarrow \text{field-voltage signal start} \\ + &\leftarrow \text{machine field voltage} \\ \omega - &\leftarrow \text{speed-deviation input or }0 \\ + &\leftarrow \text{machine speed deviation or }0 \\ V_S - &\leftarrow \text{stabilizer input or }0 \\ + &\leftarrow \text{stabilizer signal or }0 \\ V_{\mathrm{UEL}} &\leftarrow \text{under-excitation limiter input or }0 \end{aligned} @@ -275,59 +280,57 @@ Initialization never replaces the seeded value held in $E_{\mathrm{fd}}$. ### Internal Initialization -Initialization is performed by evaluating the steady-state residuals in -dependency order. The high-value gate uses the smooth CommonMath -[ramp](../../../../CommonMath.md#primitives) $\rho$, so an inactive gate is -seeded with the gate *input* through the ramp inverse $\rho^{-1}$. Let -subscript $0$ denote initial values and set all internal derivatives to zero: +All internal derivatives are set to zero. The steady-state residuals are then +resolved in dependency order. The smooth high-value gate requires its input to +be recovered through the inverse CommonMath +[ramp](../../../../CommonMath.md#primitives) $\rho^{-1}$ when the UEL input is +routed through the gate: ```math \begin{aligned} - E_{C,0} - &= \sqrt{V_{\mathrm{r},0}^2+V_{\mathrm{i},0}^2} \\ - d_0 - &= 1 + s_{\mathrm{spd}}\omega_0 \\ - E_{\mathrm{fd},0}' - &= \dfrac{E_{\mathrm{fd},0}}{d_0} \\ - S_{E,0} - &= S_B q\left(E_{\mathrm{fd},0}' - S_A\right) \\ - V_{\mathrm{FE},0} - &= - s_{\mathrm{lim}}^\mathrm{off} - \left(K_E + S_{E,0}\right)E_{\mathrm{fd},0}' - + s_{\mathrm{lim}}\rho - \left(\left(K_E + S_{E,0}\right)E_{\mathrm{fd},0}'\right) \\ - V_{R,0} - &= V_{\mathrm{FE},0} \\ - V_{\mathrm{HV},0} - &= \dfrac{V_{R,0}}{K_A} \\ - g_0 - &= + V_C + &\leftarrow \sqrt{V_{\mathrm{r}}^2+V_{\mathrm{i}}^2} \\ + E_{\mathrm{fd}}' + &\leftarrow + \dfrac{E_{\mathrm{fd}}}{1 + s_{\mathrm{spd}}\omega} \\ + S_E + &\leftarrow S_B q\left(E_{\mathrm{fd}}' - S_A\right) \\ + V_{\mathrm{FE}} + &\leftarrow + \begin{cases} + \left(K_E + S_E\right)E_{\mathrm{fd}}' + & s_{\mathrm{lim}} = 0 \\ + \rho\!\left(\left(K_E + S_E\right)E_{\mathrm{fd}}'\right) + & s_{\mathrm{lim}} = 1 + \end{cases} \\ + V_R + &\leftarrow V_{\mathrm{FE}} \\ + V_{\mathrm{HV}} + &\leftarrow \dfrac{V_R}{K_A} \\ + V_{\mathrm{LL}} + &\leftarrow \begin{cases} - V_{\mathrm{HV},0}, - & s_{\mathrm{UEL}}=1 \\ - V_{\mathrm{UEL},0} + V_{\mathrm{UEL}} + \rho^{-1} - \left(V_{\mathrm{HV},0}-V_{\mathrm{UEL},0}\right), - & s_{\mathrm{UEL}}=0 + \left(V_{\mathrm{HV}}-V_{\mathrm{UEL}}\right) + & s_{\mathrm{UEL}} = 0 \\ + V_{\mathrm{HV}} + & s_{\mathrm{UEL}} = 1 \end{cases} \\ - V_{C,0} - &= E_{C,0} \\ - V_{F,0} - &= 0 \\ - e_{V,0} - &= g_0 \\ - x_{\mathrm{LL},0} - &= g_0 \\ - V_{\mathrm{LL},0} - &= g_0 + V_F + &\leftarrow 0 \\ + e_V + &\leftarrow V_{\mathrm{LL}} \\ + x_{\mathrm{LL}} + &\leftarrow e_V \end{aligned} ``` Initialization rejects a non-finite bus voltage or field-voltage seed, -$d_0=0$, $V_{R,0}$ outside $[V_R^{\min},V_R^{\max}]$, and high-value-gate -active starts with $s_{\mathrm{UEL}}=0$ and -$V_{\mathrm{HV},0}\le V_{\mathrm{UEL},0}$. +$1 + s_{\mathrm{spd}}\omega = 0$, $V_R$ outside +$[V_R^{\min},V_R^{\max}]$, and high-value-gate active starts with +$s_{\mathrm{UEL}} = 0$ and +$V_{\mathrm{HV}}\le V_{\mathrm{UEL}}$. Every check resolves before any storage is written, so a rejected initialization leaves state, the `efd` seed, and external signals unchanged. @@ -338,11 +341,11 @@ initialization leaves state, the `efd` seed, and external signals unchanged. \begin{aligned} V_{\mathrm{ref}} &\leftarrow - e_{V,0} - + V_{C,0} - + V_{F,0} - - V_{S,0} - - s_{\mathrm{UEL}}V_{\mathrm{UEL},0} + e_V + + V_C + + V_F + - V_S + - s_{\mathrm{UEL}}V_{\mathrm{UEL}} \end{aligned} ``` @@ -355,8 +358,26 @@ reference input. Output | Units | Description | Note ----------------|--------|-------------------------------------|------ `efd` | [p.u.] | Field-voltage output | $E_{\mathrm{fd}}$ -`vc` | [p.u.] | Sensed compensated voltage | $V_C$ +`vc` | [p.u.] | Filtered terminal-voltage magnitude | $V_C$ `vr` | [p.u.] | Voltage-regulator output | $V_R$ -`vf` | [p.u.] | Stabilizing feedback output | $V_F$ -`se` | [p.u.] | Saturation coefficient | $S_E$ -`vfe` | [p.u.] | Exciter feedback signal | $V_{\mathrm{FE}}$ +`vf` | [p.u.] | Stabilizing feedback state | $V_F$ +`se` | [p.u.] | Exciter saturation coefficient | $S_E$ +`vfe` | [p.u.] | Exciter feedback drive | $V_{\mathrm{FE}}$ + +## Testing + +- `validation()` checks construction, documented defaults, parameter + validation, signal configuration, and minimum time-constant handling. +- `initializationAndSignals()` checks steady initialization, selector + combinations, signal publication and latching, monitor output, and + differentiability tags. +- `initializationDomain()` checks rejected and accepted field-voltage, + speed-multiplier, regulator-limit, and high-value-gate operating points. +- `residualEquations()` checks every model residual against a fixed + numerical answer key. +- `voltageRegulation()` checks the transducer, summing junction, lead-lag, + stabilizing feedback, and regulator anti-windup behavior. +- `excitationLimits()` checks high-value-gate routing, saturation, exciter + limiting, and the optional speed multiplier. +- `jacobian()` compares the dependency-tracking and Enzyme Jacobians when + Enzyme support is enabled. diff --git a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp index 996a839b5..a35f20a6b 100644 --- a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp @@ -79,8 +79,8 @@ namespace GridKit success *= (unassigned.verify() > 0); success *= invalidParameterCase(Params::Ka, 0.0); - success *= invalidParameterCase(Params::Ta, 0.0); - success *= invalidParameterCase(Params::Te, 0.0); + success *= invalidParameterCase(Params::Ta, -0.1); + success *= invalidParameterCase(Params::Te, -0.1); success *= invalidParameterCase(Params::Tc, -0.1); success *= invalidParameterCase(Params::Tr, -0.1); success *= invalidParameterCase(Params::Tb, -0.1); @@ -133,20 +133,22 @@ namespace GridKit PhasorDynamics::SignalNode busless_efd_node; PhasorDynamics::Exciter::Esdc1a busless(nullptr, makeData()); - busless.getSignals().template assignSignalNode(&busless_efd_node); + busless.getSignals().template assignSignalNode(&busless_efd_node); success *= (busless.verify() > 0); - success *= unlinkedSignalRejected(); - success *= unlinkedSignalRejected(); - success *= unlinkedSignalRejected(); - success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); - // All three floored time constants at zero use the documented + // All five floored time constants at zero use the documented // numerical floor and still admit a consistent steady-state // initialization. auto zero_time = makeData(); zero_time.parameters[Params::Tr] = 0.0; + zero_time.parameters[Params::Ta] = 0.0; zero_time.parameters[Params::Tb] = 0.0; + zero_time.parameters[Params::Te] = 0.0; zero_time.parameters[Params::Tf1] = 0.0; Fixture floored(zero_time); @@ -175,14 +177,14 @@ namespace GridKit success *= (fixture.evaluate() == 0); const auto* y = fixture.esdc1a.y().getData(); - success *= scalarMatches(y[I::EFDP], 1.2, "EFDP"); - success *= scalarMatches(y[I::VC], 1.0, "VC"); - success *= scalarMatches(y[I::VR], 0.12, "VR"); - success *= scalarMatches(y[I::VF], 0.0, "VF"); - success *= scalarMatches(y[I::EV], 0.003, "EV gate input"); - success *= scalarMatches(y[I::VHV], 0.003, "VHV"); - success *= scalarMatches(y[I::SE], 0.0, "SE"); - success *= scalarMatches(y[I::VFE], 0.12, "VFE"); + success *= scalarMatches(y[index(I::EFDP)], 1.2, "EFDP"); + success *= scalarMatches(y[index(I::VC)], 1.0, "VC"); + success *= scalarMatches(y[index(I::VR)], 0.12, "VR"); + success *= scalarMatches(y[index(I::VF)], 0.0, "VF"); + success *= scalarMatches(y[index(I::EV)], 0.003, "EV gate input"); + success *= scalarMatches(y[index(I::VHV)], 0.003, "VHV"); + success *= scalarMatches(y[index(I::SE)], 0.0, "SE"); + success *= scalarMatches(y[index(I::VFE)], 0.12, "VFE"); success *= scalarMatches(fixture.efd(), 1.2, "seeded efd"); success *= scalarMatches(fixture.input(E::VREF), 0.973, "published vref"); @@ -225,7 +227,7 @@ namespace GridKit for (size_t i = 0; i < static_cast(fixture.esdc1a.size()); ++i) { - const bool expected = i <= I::XLL; + const bool expected = i <= index(I::XLL); if (fixture.esdc1a.tag()[i] != expected) { std::cout << "ESDC1A differentiability tag " << i << " mismatch\n"; @@ -294,7 +296,10 @@ namespace GridKit speed_data.parameters[Params::Spdmlt] = true; success *= initializationRejectedAtomically(speed_data, 1.2, - {-1.0, 77.0, 77.0, -77.0}, + {{E::OMEGA, -1.0}, + {E::VREF, 77.0}, + {E::VS, 77.0}, + {E::VUEL, -77.0}}, "zero speed-multiplier denominator"); // The seeded field voltage maps to a regulator output above Vrmax. @@ -303,14 +308,20 @@ namespace GridKit limit_data.parameters[Params::Vrmin] = -0.05; success *= initializationRejectedAtomically(limit_data, 1.2, - {0.0, 77.0, 77.0, -77.0}, + {{E::OMEGA, 0.0}, + {E::VREF, 77.0}, + {E::VS, 77.0}, + {E::VUEL, -77.0}}, "regulator output outside limits"); // A UEL input above the gate operating point holds the high-value // gate active, which the smooth gate cannot represent at rest. success *= initializationRejectedAtomically(makeData(), 1.2, - {0.0, 77.0, 77.0, 0.5}, + {{E::OMEGA, 0.0}, + {E::VREF, 77.0}, + {E::VS, 77.0}, + {E::VUEL, 0.5}}, "active high-value gate"); // A non-finite field-voltage seed is rejected before any signal is @@ -347,7 +358,7 @@ namespace GridKit success *= speed_fixture.initialize(1.2); success *= (speed_fixture.evaluate() == 0); success *= allResidualsZero(speed_fixture.esdc1a); - success *= scalarMatches(speed_fixture.esdc1a.y().getData()[I::EFDP], + success *= scalarMatches(speed_fixture.esdc1a.y().getData()[index(I::EFDP)], 2.4, "rescaled EFDP"); @@ -387,7 +398,7 @@ namespace GridKit // Values are pinned after an independent one-time evaluation of the // documented equations at setAnswerKeyState()/setAnswerKeyInputs(). - const std::array expected{{ + const std::array expected{{ {I::EFDP, 0.04000000000000004}, {I::VC, 0.19442890089805262}, {I::VR, 0.13666666666666663}, @@ -643,20 +654,27 @@ namespace GridKit #endif private: - using Params = PhasorDynamics::Exciter::Esdc1aParameters; - using Vars = PhasorDynamics::Exciter::Esdc1aInternalVariables; - using Ext = PhasorDynamics::Exciter::Esdc1aExternalVariables; - using Mon = PhasorDynamics::Exciter::Esdc1aMonitorableVariables; - using Data = PhasorDynamics::Exciter::Esdc1aData; - using I = PhasorDynamics::Exciter::Esdc1aIdx; - using E = PhasorDynamics::Exciter::Esdc1aExt; - - /// A vector row paired with a value: either an input to write or an - /// expected result. Rows are `Esdc1aIdx`/`Esdc1aExt` constants, so a - /// failure report locates itself without any name string to maintain. - using Row = std::pair; - using Rows = std::initializer_list; using Esdc1aT = PhasorDynamics::Exciter::Esdc1a; + using Data = typename Esdc1aT::ModelDataT; + using Params = typename Data::Parameters; + using Mon = typename Data::MonitorableVariables; + using I = typename Esdc1aT::InternalVariablesT; + using E = typename Esdc1aT::ExternalVariablesT; + + using InternalRow = std::pair; + using InternalRows = std::vector; + using ExternalRow = std::pair; + using ExternalRows = std::vector; + + static constexpr size_t index(I variable) + { + return static_cast(variable); + } + + static constexpr size_t index(E variable) + { + return static_cast(variable); + } /// Owns the terminal bus, ESDC1A, the assigned field-voltage node, and /// the attached input nodes. Signal storage is declared before the @@ -666,9 +684,10 @@ 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(E::MAXIMUM)> + input_nodes_{}; PhasorDynamics::SignalNode efd_node_; @@ -677,7 +696,7 @@ namespace GridKit : bus(static_cast(vr), static_cast(vi)), esdc1a(&bus, data) { - esdc1a.getSignals().template assignSignalNode(&efd_node_); + esdc1a.getSignals().template assignSignalNode(&efd_node_); } Fixture(const Fixture&) = delete; @@ -688,7 +707,7 @@ namespace GridKit { const IdxT external_index_base = esdc1a.size() + bus.size(); - for (size_t port = 0; port < E::MAXIMUM; ++port) + for (size_t port = 0; port < input_values_.size(); ++port) { input_values_[port] = static_cast(initial_value); input_indices_[port] = external_index_base + static_cast(port); @@ -696,10 +715,10 @@ namespace GridKit } auto& signals = esdc1a.getSignals(); - signals.template attachSignalNode(&input_nodes_[E::OMEGA]); - signals.template attachSignalNode(&input_nodes_[E::VREF]); - signals.template attachSignalNode(&input_nodes_[E::VS]); - signals.template attachSignalNode(&input_nodes_[E::VUEL]); + signals.template attachSignalNode(&input_nodes_[index(E::OMEGA)]); + signals.template attachSignalNode(&input_nodes_[index(E::VREF)]); + signals.template attachSignalNode(&input_nodes_[index(E::VS)]); + signals.template attachSignalNode(&input_nodes_[index(E::VUEL)]); } /// Seed the assigned field-voltage node. @@ -750,14 +769,14 @@ namespace GridKit return efd_node_.read(); } - T& input(size_t port) + T& input(E port) { - return input_values_[port]; + return input_values_[index(port)]; } - IdxT inputIndex(size_t port) const + IdxT inputIndex(E port) const { - return input_indices_[port]; + return input_indices_[index(port)]; } PhasorDynamics::Bus bus; @@ -878,7 +897,17 @@ namespace GridKit void setAnswerKeyState(PhasorDynamics::Exciter::Esdc1a& esdc1a) const { setState(esdc1a, - {{I::EFDP, 2.00}, {I::VC, 0.95}, {I::VR, 0.45}, {I::VF, 0.06}, {I::XLL, 0.30}, {I::EV, 0.36}, {I::VLL, 0.33}, {I::VHV, 0.02}, {I::SE, 0.09}, {I::VFE, 0.42}, {I::EFD, 1.15}}); + {{I::EFDP, 2.00}, + {I::VC, 0.95}, + {I::VR, 0.45}, + {I::VF, 0.06}, + {I::XLL, 0.30}, + {I::EV, 0.36}, + {I::VLL, 0.33}, + {I::VHV, 0.02}, + {I::SE, 0.09}, + {I::VFE, 0.42}, + {I::EFD, 1.15}}); setDerivative(esdc1a, {{I::EFDP, 0.01}, {I::VC, -0.02}, @@ -937,7 +966,7 @@ namespace GridKit return fixture.esdc1a.verify() > 0; } - template + template bool unlinkedSignalRejected() const { PhasorDynamics::SignalNode unlinked_node; @@ -986,16 +1015,16 @@ namespace GridKit fixture.esdc1a.yp().setDataUpdated(); } - bool initializationRejectedAtomically(const Data& data, - RealT efd_seed, - const std::array& inputs, - const char* label) const + bool initializationRejectedAtomically(const Data& data, + RealT efd_seed, + const ExternalRows& inputs, + const char* label) const { Fixture fixture(data); fixture.attachAllInputs(); - for (size_t port = 0; port < E::MAXIMUM; ++port) + for (const auto& [port, value] : inputs) { - fixture.input(port) = inputs[port]; + fixture.input(port) = value; } if (!fixture.prepare(efd_seed)) { @@ -1014,12 +1043,12 @@ namespace GridKit } success *= scalarMatches(fixture.efd(), efd_seed, "rejected efd preservation"); - for (size_t port = 0; port < E::MAXIMUM; ++port) + for (const auto& [port, value] : inputs) { success &= rowMatches(static_cast(fixture.input(port)), - inputs[port], + value, "external input", - port, + index(port), "changed"); } success *= vectorUnchanged(fixture.esdc1a.y(), y_before, "state"); @@ -1030,32 +1059,33 @@ namespace GridKit /// Write state rows and publish the update, folding in the /// setDataUpdated() that a hand-written write block has to remember. template - void setState(PhasorDynamics::Exciter::Esdc1a& esdc1a, Rows rows) const + void setState(PhasorDynamics::Exciter::Esdc1a& esdc1a, + const InternalRows& rows) const { auto* y = esdc1a.y().getData(); - for (const auto& [row, value] : rows) + for (const auto& [variable, value] : rows) { - y[row] = static_cast(value); + y[index(variable)] = static_cast(value); } esdc1a.y().setDataUpdated(); } /// setState() for the derivative vector. template - void setDerivative(PhasorDynamics::Exciter::Esdc1a& esdc1a, Rows rows) const + void setDerivative(PhasorDynamics::Exciter::Esdc1a& esdc1a, + const InternalRows& rows) const { auto* yp = esdc1a.yp().getData(); - for (const auto& [row, value] : rows) + for (const auto& [variable, value] : rows) { - yp[row] = static_cast(value); + yp[index(variable)] = static_cast(value); } esdc1a.yp().setDataUpdated(); } /// 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 `Esdc1aIdx` constant the - /// expectation was written with, leaving no name string to maintain. + /// Rows are named by their canonical internal-variable enumeration. static bool rowMatches(RealT actual, RealT expected, const char* what, @@ -1073,34 +1103,35 @@ namespace GridKit } /// Check selected rows of a model vector against expected values. - template + template bool rowsMatch(const VectorT& vector, - const Row* rows, - size_t count, + const RowsT& rows, const char* what, const char* context) const { bool success = true; const auto* values = vector.getData(); - for (size_t i = 0; i < count; ++i) + for (const auto& [variable, expected] : rows) { - const auto& [row, expected] = rows[i]; - success &= rowMatches(static_cast(values[row]), expected, what, row, context); + const auto row = index(variable); + success &= rowMatches(static_cast(values[row]), expected, what, row, context); } return success; } - bool residualsMatch(const Esdc1aT& esdc1a, Rows rows, const char* context = "") const + bool residualsMatch(const Esdc1aT& esdc1a, + const InternalRows& rows, + const char* context = "") const { - return rowsMatch(esdc1a.getResidual(), rows.begin(), rows.size(), "residual", context); + return rowsMatch(esdc1a.getResidual(), rows, "residual", context); } template - bool residualsMatch(const Esdc1aT& esdc1a, - const std::array& rows, - const char* context = "") const + bool residualsMatch(const Esdc1aT& esdc1a, + const std::array& rows, + const char* context = "") const { - return rowsMatch(esdc1a.getResidual(), rows.data(), size, "residual", context); + return rowsMatch(esdc1a.getResidual(), rows, "residual", context); } /// The model sits at a steady state: every residual and every @@ -1157,7 +1188,7 @@ namespace GridKit { bus_y[i].setVariableNumber(model_size + i); } - for (size_t port = 0; port < E::MAXIMUM; ++port) + for (E port : {E::OMEGA, E::VREF, E::VS, E::VUEL}) { fixture.input(port).setVariableNumber(fixture.inputIndex(port)); } From 36a3fe075cea138eb821569df3df6338edac5f6f Mon Sep 17 00:00:00 2001 From: lukelowry Date: Sun, 2 Aug 2026 21:13:48 -0500 Subject: [PATCH 04/18] consistant use of enums/ordering/doxygen --- .../PhasorDynamics/Exciter/ESDC1A/Esdc1a.cpp | 3 + .../PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp | 74 +- .../Exciter/ESDC1A/Esdc1aData.hpp | 78 +- .../ESDC1A/Esdc1aDependencyTracking.cpp | 6 + .../Exciter/ESDC1A/Esdc1aEnzyme.cpp | 12 + .../Exciter/ESDC1A/Esdc1aImpl.hpp | 1128 +++++++++-------- .../PhasorDynamics/ExciterEsdc1aTests.hpp | 328 ++--- 7 files changed, 852 insertions(+), 777 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.cpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.cpp index 60d370c22..0dfe1f692 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.cpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.cpp @@ -12,6 +12,9 @@ namespace GridKit { namespace Exciter { + /** + * @brief Report that a separate Jacobian is unavailable in the plain build. + */ template int Esdc1a::evaluateJacobian() { diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp index 1bd83dc57..94cbaefa5 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp @@ -30,30 +30,36 @@ namespace GridKit /// Internal variables of an `Esdc1a`. enum class Esdc1aInternalVariables : size_t { - EFDP, ///< \f$E_{\mathrm{fd}}'\f$ Exciter field-voltage state - VC, ///< \f$V_C\f$ Filtered terminal-voltage magnitude - VR, ///< \f$V_R\f$ Voltage-regulator output - VF, ///< \f$V_F\f$ Stabilizing feedback state - XLL, ///< \f$x_{\mathrm{LL}}\f$ Input lead-lag denominator state - EV, ///< \f$e_V\f$ Voltage-error summing output - VLL, ///< \f$V_{\mathrm{LL}}\f$ Input lead-lag output - VHV, ///< \f$V_{\mathrm{HV}}\f$ High-value gate output - SE, ///< \f$S_E\f$ Exciter saturation coefficient - VFE, ///< \f$V_{\mathrm{FE}}\f$ Exciter feedback drive - EFD, ///< \f$E_{\mathrm{fd}}\f$ Field-voltage output - MAXIMUM, + EFDP, ///< \f$E_{\mathrm{fd}}'\f$ Differential exciter field-voltage state [p.u.] + VC, ///< \f$V_C\f$ Differential filtered terminal-voltage magnitude [p.u.] + VR, ///< \f$V_R\f$ Differential voltage-regulator output [p.u.] + VF, ///< \f$V_F\f$ Differential stabilizing feedback state [p.u.] + XLL, ///< \f$x_{\mathrm{LL}}\f$ Differential input lead-lag denominator state [p.u.] + EV, ///< \f$e_V\f$ Algebraic voltage-error summing output [p.u.] + VLL, ///< \f$V_{\mathrm{LL}}\f$ Algebraic input lead-lag output [p.u.] + VHV, ///< \f$V_{\mathrm{HV}}\f$ Algebraic high-value gate output [p.u.] + SE, ///< \f$S_E\f$ Algebraic exciter saturation coefficient [p.u.] + VFE, ///< \f$V_{\mathrm{FE}}\f$ Algebraic exciter feedback drive [p.u.] + EFD, ///< \f$E_{\mathrm{fd}}\f$ Algebraic field-voltage output [p.u.] + MAXIMUM, ///< Number of ESDC1A internal variables }; - /// External variables of an `Esdc1a`. + /// External signal variables read or initialized by an `Esdc1a`. enum class Esdc1aExternalVariables : size_t { - OMEGA, ///< \f$\omega\f$ Machine speed deviation - VREF, ///< \f$V_{\mathrm{ref}}\f$ Voltage-control reference - VS, ///< \f$V_S\f$ Stabilizer input signal - VUEL, ///< \f$V_{\mathrm{UEL}}\f$ Under-excitation limiter input - MAXIMUM, + OMEGA, ///< \f$\omega\f$ Known machine speed deviation [p.u.] + VREF, ///< \f$V_{\mathrm{ref}}\f$ Unknown voltage-control reference [p.u.] + VS, ///< \f$V_S\f$ Known stabilizer input signal [p.u.] + VUEL, ///< \f$V_{\mathrm{UEL}}\f$ Known under-excitation limiter input [p.u.] + MAXIMUM, ///< Number of ESDC1A external signal variables }; + /** + * @brief IEEE DC1A excitation-system model (ESDC1A). + * + * @tparam scalar_type Plain real or differentiable scalar type. + * @tparam index_type Integer index type. + */ template class Esdc1a : public Component { @@ -89,20 +95,20 @@ namespace GridKit Esdc1a(BusT* bus, const ModelDataT& data); ~Esdc1a(); - 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; auto getSignals() -> ComponentSignals& + Esdc1aInternalVariables, + Esdc1aExternalVariables>& { return signals_; } @@ -110,22 +116,13 @@ 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: - using I = InternalVariablesT; - using E = ExternalVariablesT; - - static constexpr size_t index(I variable) - { - return static_cast(variable); - } - - static constexpr size_t index(E variable) - { - return static_cast(variable); - } - void initializeParameters(const ModelDataT& data); void initializeMonitor(); void setDerivedParameters(); @@ -172,8 +169,9 @@ namespace GridKit ScalarT vs_set_{0}; ScalarT vuel_set_{0}; - ComponentSignals signals_; - std::unique_ptr monitor_; + ComponentSignals + signals_; + std::unique_ptr monitor_; std::vector ws_; std::vector ws_indices_; diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp index 2f16b3a53..e4587373d 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp @@ -14,64 +14,74 @@ namespace GridKit { namespace Exciter { - /// Parameter keys for the ESDC1A exciter model. + /// Parameter keys for the ESDC1A exciter model. Every parameter is + /// optional and retains its documented default when omitted. enum class Esdc1aParameters { - Tr, ///< \f$T_R\f$ Voltage transducer time constant - Ka, ///< \f$K_A\f$ Voltage-regulator gain - Ta, ///< \f$T_A\f$ Voltage-regulator time constant - Tb, ///< \f$T_B\f$ Input lead-lag denominator time constant - Tc, ///< \f$T_C\f$ Input lead-lag numerator time constant - Vrmax, ///< \f$V_R^{\max}\f$ Maximum voltage-regulator output - Vrmin, ///< \f$V_R^{\min}\f$ Minimum voltage-regulator output - Ke, ///< \f$K_E\f$ Exciter constant - Te, ///< \f$T_E\f$ Exciter time constant - Kf, ///< \f$K_F\f$ Stabilizing feedback gain - Tf1, ///< \f$T_{F1}\f$ Stabilizing feedback time constant - Spdmlt, ///< \f$s_{\mathrm{spd}}\f$ Field-voltage speed-multiplier flag - E1, ///< \f$E_1\f$ First saturation voltage point - Se1, ///< \f$S_E(E_1)\f$ Saturation coefficient at \f$E_1\f$ - E2, ///< \f$E_2\f$ Second saturation voltage point - Se2, ///< \f$S_E(E_2)\f$ Saturation coefficient at \f$E_2\f$ - UEL, ///< \f$I_{\mathrm{UEL}}\f$ UEL input-routing selector - exclim ///< \f$s_{\mathrm{lim}}\f$ Exciter feedback lower-limit flag + Tr, ///< \f$T_R\f$ Voltage transducer time constant [sec] + Ka, ///< \f$K_A\f$ Voltage-regulator gain [p.u.] + Ta, ///< \f$T_A\f$ Voltage-regulator time constant [sec] + Tb, ///< \f$T_B\f$ Input lead-lag denominator time constant [sec] + Tc, ///< \f$T_C\f$ Input lead-lag numerator time constant [sec] + Vrmax, ///< \f$V_R^{\max}\f$ Maximum voltage-regulator output [p.u.] + Vrmin, ///< \f$V_R^{\min}\f$ Minimum voltage-regulator output [p.u.] + Ke, ///< \f$K_E\f$ Exciter constant [p.u.] + Te, ///< \f$T_E\f$ Exciter time constant [sec] + Kf, ///< \f$K_F\f$ Stabilizing feedback gain [p.u.] + Tf1, ///< \f$T_{F1}\f$ Stabilizing feedback time constant [sec] + Spdmlt, ///< \f$s_{\mathrm{spd}}\f$ Field-voltage speed-multiplier flag [binary] + E1, ///< \f$E_1\f$ First saturation voltage point [p.u.] + Se1, ///< \f$S_E(E_1)\f$ Saturation coefficient at \f$E_1\f$ [p.u.] + E2, ///< \f$E_2\f$ Second saturation voltage point [p.u.] + Se2, ///< \f$S_E(E_2)\f$ Saturation coefficient at \f$E_2\f$ [p.u.] + UEL, ///< \f$I_{\mathrm{UEL}}\f$ UEL input-routing selector [integer] + exclim ///< \f$s_{\mathrm{lim}}\f$ Exciter field-voltage-state lower-limit flag [binary] }; /// Buses for the ESDC1A exciter model. enum class Esdc1aBuses : size_t { - bus, ///< Terminal bus ID for \f$V_{\mathrm{r}}\f$ and \f$V_{\mathrm{i}}\f$ - SIZE + bus, ///< \f$V_{\mathrm{r}},V_{\mathrm{i}}\f$ Required Known terminal-bus voltage [p.u.] + SIZE ///< Number of ESDC1A bus ports }; /// Signal inputs for the ESDC1A exciter model. enum class Esdc1aSignalInputs : size_t { - speed, ///< \f$\omega\f$ Machine speed-deviation signal ID - vref, ///< \f$V_{\mathrm{ref}}\f$ Optional voltage-reference signal ID - vs, ///< \f$V_S\f$ Optional stabilizer input signal ID - vuel, ///< \f$V_{\mathrm{UEL}}\f$ Optional UEL input signal ID - SIZE + speed, ///< \f$\omega\f$ Known machine speed-deviation input [p.u.]; required when \f$s_{\mathrm{spd}}=1\f$ + vref, ///< \f$V_{\mathrm{ref}}\f$ Optional Unknown voltage-reference input [p.u.] + vs, ///< \f$V_S\f$ Optional Known stabilizer input [p.u.] + vuel, ///< \f$V_{\mathrm{UEL}}\f$ Optional Known UEL input [p.u.] + SIZE ///< Number of ESDC1A input-signal ports }; /// Signal outputs for the ESDC1A exciter model. enum class Esdc1aSignalOutputs : size_t { - efd, ///< \f$E_{\mathrm{fd}}\f$ Required field-voltage output signal ID - SIZE + efd, ///< \f$E_{\mathrm{fd}}\f$ Required Known field-voltage output [p.u.] + SIZE ///< Number of ESDC1A output-signal ports }; /// Variables available through the monitor interface. enum class Esdc1aMonitorableVariables { - efd, ///< \f$E_{\mathrm{fd}}\f$ Field-voltage output - vc, ///< \f$V_C\f$ Filtered terminal-voltage magnitude - vr, ///< \f$V_R\f$ Voltage-regulator output - vf, ///< \f$V_F\f$ Stabilizing feedback state - se, ///< \f$S_E\f$ Exciter saturation coefficient - vfe ///< \f$V_{\mathrm{FE}}\f$ Exciter feedback drive + efd, ///< \f$E_{\mathrm{fd}}\f$ Field-voltage output [p.u.] + vc, ///< \f$V_C\f$ Filtered terminal-voltage magnitude [p.u.] + vr, ///< \f$V_R\f$ Voltage-regulator output [p.u.] + vf, ///< \f$V_F\f$ Stabilizing feedback state [p.u.] + se, ///< \f$S_E\f$ Exciter saturation coefficient [p.u.] + vfe ///< \f$V_{\mathrm{FE}}\f$ Exciter feedback drive [p.u.] }; + /** + * @brief Model data for ESDC1A parameters, terminal bus, signal ports, + * and monitored variables. + * + * @tparam real_type Real parameter value type. + * @tparam index_type Integer index type. + * + * @see Esdc1a + */ template struct Esdc1aData : public ComponentData int Esdc1a::evaluateJacobian() { diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aEnzyme.cpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aEnzyme.cpp index 5a1ba4095..812ea4031 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aEnzyme.cpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aEnzyme.cpp @@ -14,6 +14,18 @@ namespace GridKit { namespace Exciter { + /** + * @brief Assemble the sparse ESDC1A component Jacobian with Enzyme. + * + * Differentiates the internal residual with respect to internal states, + * state derivatives, terminal-bus variables, and linked signal values, + * then assembles the resulting entries in COO form. + * + * @pre allocate() has completed. + * @pre evaluateResidual() has refreshed the interface buffers at the + * current state. + * @pre Solver alpha and global variable and residual indices are set. + */ template int Esdc1a::evaluateJacobian() { diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp index dab1f3a8b..c743c7afb 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp @@ -23,14 +23,15 @@ namespace GridKit { namespace Exciter { + /// Logger used for ESDC1A diagnostics. using Log = ::GridKit::Utilities::Logger; /** * @brief Construct an ESDC1A exciter without parameters * * The model is sized but left unconfigured. Every parameter keeps its - * documented default and no monitor is created, so verify() reports a - * configuration error until an `efd` output node is assigned. + * documented default, and no monitor is created. verify() rejects the + * model until an `efd` output node is assigned. * * @param[in] bus Terminal bus the exciter measures. */ @@ -38,7 +39,7 @@ namespace GridKit Esdc1a::Esdc1a(BusT* bus) : bus_(bus) { - size_ = static_cast(I::MAXIMUM); + size_ = static_cast(Esdc1aInternalVariables::MAXIMUM); setDerivedParameters(); } @@ -55,499 +56,222 @@ namespace GridKit { initializeParameters(data); initializeMonitor(); - size_ = static_cast(I::MAXIMUM); + size_ = static_cast(Esdc1aInternalVariables::MAXIMUM); } + /** + * @brief Destroy the ESDC1A exciter. + */ template Esdc1a::~Esdc1a() { } /** - * @brief Terminal-bus voltage, real component + * @brief Set the component ID * - * @return Reference to the bus variable. + * @param[in] component_id Identifier assigned by the system model. + * @return Zero on success. */ template - scalar_type& Esdc1a::Vr() + int Esdc1a::setGridKitComponentID(IdxT component_id) { - return bus_->Vr(); + gridkit_component_id_ = component_id; + return 0; } /** - * @brief Terminal-bus voltage, imaginary component + * @brief Allocate the model vectors and wire the field-voltage output * - * @return Reference to the bus variable. + * Sizes the state, residual, bus-interface, and signal-interface + * buffers, seeds the identity index maps, and points an assigned `efd` + * node at the internal field-voltage state. That node aliases ESDC1A + * storage from here on, which is how initialize() reads the seed a + * machine model wrote. Repeated calls reuse the allocated vectors. + * + * @return Zero on success. */ template - scalar_type& Esdc1a::Vi() + int Esdc1a::allocate() { - return bus_->Vi(); + const auto EFD = static_cast(Esdc1aInternalVariables::EFD); + + 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}); + + const auto signal_size = static_cast(Esdc1aExternalVariables::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); + } + + auto* y = y_.getData(); + + if (signals_.template isAssigned()) + { + signals_.template getSignalNode()->set( + &y[EFD], + &(this->getVariableIndex(static_cast(EFD)))); + } + + allocated_ = true; + return 0; } /** - * @brief Resolve the parameter-derived constants and selector masks + * @brief Validate the ESDC1A configuration * - * Raises the transducer, regulator, lead-lag, exciter, and feedback - * lags to the well-posedness floor, fits the quadratic saturation - * curve, and turns the three selectors into multiplicative masks. The - * masks let the residual select signal routing without - * parameter-dependent control flow, which keeps its structure fixed for - * sparse automatic differentiation. + * Checks parameter-loading errors, static parameter relationships, + * terminal-bus association, the required field-voltage output, and + * attached external signals. Seed feasibility is operating-point + * dependent and is checked by initialize(). + * + * @return Number of configuration errors; zero when valid. */ template - void Esdc1a::setDerivedParameters() + int Esdc1a::verify() const { - // 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) + int ret = static_cast(parameter_error_count_); + + auto check = [&](bool condition, const char* message) { - if (value < ZERO) + if (!condition) { - Log::error() << "Esdc1a: " << name << " must be non-negative\n"; - ++parameter_error_count_; + Log::error() << "Esdc1a: " << message << '\n'; + ret += 1; } }; - check_non_negative(Tr_, "Tr"); - check_non_negative(Ta_, "Ta"); - check_non_negative(Tb_, "Tb"); - check_non_negative(Te_, "Te"); - check_non_negative(Tf1_, "Tf1"); - - if (Tr_ < TIME_CONSTANT_MINIMUM || Ta_ < TIME_CONSTANT_MINIMUM - || Tb_ < TIME_CONSTANT_MINIMUM || Te_ < TIME_CONSTANT_MINIMUM - || Tf1_ < TIME_CONSTANT_MINIMUM) + if (bus_ == nullptr) { - Log::warning() << "Esdc1a: Tr, Ta, Tb, Te, and Tf1 below " - << TIME_CONSTANT_MINIMUM - << " s are raised to that floor to keep the exciter lags well posed\n"; + Log::error() << "Esdc1a: bus pointer is null\n"; + ret += 1; } - Tr_ = std::max(Tr_, TIME_CONSTANT_MINIMUM); - Ta_ = std::max(Ta_, TIME_CONSTANT_MINIMUM); - Tb_ = std::max(Tb_, TIME_CONSTANT_MINIMUM); - Te_ = std::max(Te_, TIME_CONSTANT_MINIMUM); - Tf1_ = std::max(Tf1_, TIME_CONSTANT_MINIMUM); + check(Ka_ > ZERO, "Ka must be positive"); + check(Tc_ >= ZERO, "Tc must be non-negative"); + check(Vrmin_ <= Vrmax_, "Vrmin must be less than or equal to Vrmax"); + check(UEL_ >= static_cast(0) && UEL_ <= static_cast(3), + "UEL must be 0, 1, 2, or 3"); - spd_on_ = ZERO; - if (Spdmlt_) + if (!(Se1_ == ZERO && Se2_ == ZERO) ) { - spd_on_ = ONE; + check(E1_ > ZERO, "E1 must be positive when saturation is enabled"); + check(E2_ > ZERO, "E2 must be positive when saturation is enabled"); + check(Se1_ > ZERO, "Se1 must be positive when saturation is enabled"); + check(Se2_ > ZERO, "Se2 must be positive when saturation is enabled"); + check(E1_ != E2_, "E1 and E2 must differ when saturation is enabled"); + check(Se1_ != Se2_, "Se1 and Se2 must differ when saturation is enabled"); } - uel_on_ = ZERO; - if (UEL_ >= static_cast(2)) + if (!signals_.template isAssigned()) { - uel_on_ = ONE; + Log::error() << "Esdc1a: required efd output signal is not assigned\n"; + ret += 1; } - lim_on_ = ZERO; - if (exclim_) + if (Spdmlt_ && !signals_.template isAttached()) { - lim_on_ = ONE; + Log::error() << "Esdc1a: speed signal is required when Spdmlt is enabled\n"; + ret += 1; } - // A disabled or inconsistent saturation curve keeps the zero fit so - // the coefficients stay finite; verify() reports inconsistent data. - const bool saturation_enabled = !(Se1_ == ZERO && Se2_ == ZERO); - const bool saturation_consistent = - E1_ > ZERO && E2_ > ZERO && E1_ != E2_ - && Se1_ > ZERO && Se2_ > ZERO && Se1_ != Se2_; - if (!saturation_enabled || !saturation_consistent) + // 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) { - SA_ = ZERO; - SB_ = ZERO; - return; - } + if (signals_.template isAttached() + && !signals_.template isLinked()) + { + Log::error() << "Esdc1a: " << name << " signal attached with no linked source\n"; + ret += 1; + } + }; - const RealT C = std::sqrt(Se2_ / Se1_); - SA_ = (C * E1_ - E2_) / (C - ONE); - SB_ = Se1_ / ((E1_ - SA_) * (E1_ - SA_)); + check_attached_signal.template operator()("speed"); + check_attached_signal.template operator()("vref"); + check_attached_signal.template operator()("vs"); + check_attached_signal.template operator()("vuel"); + + return ret; } /** - * @brief Invert the smooth CommonMath ramp + * @brief Initialize ESDC1A from the field-voltage output * - * Initialization seeds the inactive high-value gate with the gate - * *input*, so the residual reproduces the requested output through the - * same smooth ramp it evaluates. Beyond the softplus width the smooth - * ramp is the identity to double precision, so the output is returned - * unchanged there. + * Resolves the steady internal state and voltage reference while + * preserving the seeded `efd`, latches attached Known inputs, and + * publishes the reference to an attached `vref` signal. * - * @param[in] ramp_output Strictly positive requested ramp output. - * @return The input the smooth ramp maps to the requested output. + * @return Zero on success; nonzero when the configuration or operating point is rejected. */ template - typename Esdc1a::RealT - Esdc1a::inverseRamp(RealT ramp_output) const + int Esdc1a::initialize() { - static constexpr RealT SOFTPLUS_WIDTH = static_cast(50.0); + const auto EFDP = static_cast(Esdc1aInternalVariables::EFDP); + const auto VC = static_cast(Esdc1aInternalVariables::VC); + const auto VR = static_cast(Esdc1aInternalVariables::VR); + const auto VF = static_cast(Esdc1aInternalVariables::VF); + const auto XLL = static_cast(Esdc1aInternalVariables::XLL); + const auto EV = static_cast(Esdc1aInternalVariables::EV); + const auto VLL = static_cast(Esdc1aInternalVariables::VLL); + const auto VHV = static_cast(Esdc1aInternalVariables::VHV); + const auto SE = static_cast(Esdc1aInternalVariables::SE); + const auto VFE = static_cast(Esdc1aInternalVariables::VFE); + const auto EFD = static_cast(Esdc1aInternalVariables::EFD); - const RealT scaled_output = Math::MU * ramp_output; - if (scaled_output > SOFTPLUS_WIDTH) + if (verify() > 0) { - return ramp_output; + Log::error() << "Esdc1a: cannot initialize with invalid configuration\n"; + return 1; } - return std::log(std::expm1(scaled_output)) / Math::MU; - } - /** - * @brief Read the parameters out of the model data - * - * No parameter is required; every parameter keeps the default - * documented in the model README when omitted. A non-numeric value, a - * switch outside {0, 1}, or a non-integer selector 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 Esdc1a::initializeParameters(const ModelDataT& data) - { - using Params = typename ModelDataT::Parameters; + auto* y = y_.getData(); - parameter_error_count_ = 0; + // The assigned efd node aliases this entry after allocate(). Its + // seeded value remains untouched throughout initialization. + const ScalarT efd0 = y[EFD]; - auto load_real = [&](auto key, RealT& target, const char* name) + ScalarT omega0{ZERO}; + if (signals_.template isAttached()) { - if (!data.parameters.contains(key)) - { - return; - } + omega0 = signals_.template readExternalVariable(); + } - 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() << "Esdc1a: parameter '" << name << "' must be numeric\n"; - ++parameter_error_count_; - } - }; + ScalarT vs0{ZERO}; + if (signals_.template isAttached()) + { + vs0 = signals_.template readExternalVariable(); + } - auto load_switch = [&](auto key, bool& target, const char* name) + ScalarT vuel0{ZERO}; + if (signals_.template isAttached()) { - if (!data.parameters.contains(key)) - { - return; - } + vuel0 = signals_.template readExternalVariable(); + } - 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 if (const auto* real_value = std::get_if(&value); - real_value && (*real_value == ZERO || *real_value == ONE) ) - { - target = (*real_value == ONE); - } - else - { - Log::error() << "Esdc1a: parameter '" << name << "' must be bool or 0/1\n"; - ++parameter_error_count_; - } - }; + const ScalarT vc0 = std::sqrt(Vr() * Vr() + Vi() * Vi()); - auto load_selector = [&](auto key, IdxT& target, const char* name) + if (!std::isfinite(static_cast(efd0)) + || !std::isfinite(static_cast(vc0))) { - if (!data.parameters.contains(key)) - { - return; - } - - const auto& value = data.parameters.at(key); - if (const auto* index_value = std::get_if(&value)) - { - target = *index_value; - } - else if (const auto* real_value = std::get_if(&value); - real_value && *real_value >= ZERO - && *real_value == std::round(*real_value)) - { - target = static_cast(std::round(*real_value)); - } - else - { - Log::error() << "Esdc1a: parameter '" << name << "' must be an integer selector\n"; - ++parameter_error_count_; - } - }; - - load_real(Params::Tr, Tr_, "Tr"); - load_real(Params::Ka, Ka_, "Ka"); - load_real(Params::Ta, Ta_, "Ta"); - load_real(Params::Tb, Tb_, "Tb"); - load_real(Params::Tc, Tc_, "Tc"); - load_real(Params::Vrmax, Vrmax_, "Vrmax"); - load_real(Params::Vrmin, Vrmin_, "Vrmin"); - load_real(Params::Ke, Ke_, "Ke"); - load_real(Params::Te, Te_, "Te"); - load_real(Params::Kf, Kf_, "Kf"); - load_real(Params::Tf1, Tf1_, "Tf1"); - load_switch(Params::Spdmlt, Spdmlt_, "Spdmlt"); - load_real(Params::E1, E1_, "E1"); - load_real(Params::Se1, Se1_, "Se1"); - load_real(Params::E2, E2_, "E2"); - load_real(Params::Se2, Se2_, "Se2"); - load_selector(Params::UEL, UEL_, "UEL"); - load_switch(Params::exclim, exclim_, "exclim"); - setDerivedParameters(); - } - - /** - * @brief Access the monitor - * - * @return Monitor for this model, or nullptr when the model was - * constructed without data. - */ - template - const Model::VariableMonitorBase* Esdc1a::getMonitor() const - { - return monitor_.get(); - } - - /** - * @brief Bind the monitorable variables to their internal states - * - * Every monitored quantity is a per-unit exciter voltage, as documented - * in the model README. - */ - template - void Esdc1a::initializeMonitor() - { - using Variable = typename ModelDataT::MonitorableVariables; - - monitor_->set(Variable::efd, [this] - { return y_.getData()[index(I::EFD)]; }); - monitor_->set(Variable::vc, [this] - { return y_.getData()[index(I::VC)]; }); - monitor_->set(Variable::vr, [this] - { return y_.getData()[index(I::VR)]; }); - monitor_->set(Variable::vf, [this] - { return y_.getData()[index(I::VF)]; }); - monitor_->set(Variable::se, [this] - { return y_.getData()[index(I::SE)]; }); - monitor_->set(Variable::vfe, [this] - { return y_.getData()[index(I::VFE)]; }); - } - - /** - * @brief Set the component ID - * - * @param[in] component_id Identifier assigned by the system model. - * @return int 0 on success. - */ - template - int Esdc1a::setGridKitComponentID(IdxT component_id) - { - gridkit_component_id_ = component_id; - return 0; - } - - /** - * @brief Allocate the model vectors and wire the field-voltage output - * - * Sizes the state, residual, bus-interface, and signal-interface - * buffers, seeds the identity index maps, and points an assigned `efd` - * node at the internal field-voltage state. That node aliases ESDC1A - * storage from here on, which is how initialize() reads the seed a - * machine model wrote. Repeated calls reuse the allocated vectors. - * - * @return int 0 on success. - */ - template - int Esdc1a::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}); - - const auto signal_size = index(E::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); - } - - auto* y = y_.getData(); - - if (signals_.template isAssigned()) - { - signals_.template getSignalNode()->set( - &y[index(I::EFD)], - &(this->getVariableIndex(static_cast(I::EFD)))); - } - - allocated_ = true; - return 0; - } - - /** - * @brief Validate the ESDC1A configuration - * - * Checks parameter-loading errors, static parameter relationships, - * terminal-bus association, the required field-voltage output, and - * attached external signals. Seed feasibility is operating-point - * dependent and is checked by initialize(). - * - * @return int Number of configuration errors; zero when valid. - */ - template - int Esdc1a::verify() const - { - int ret = static_cast(parameter_error_count_); - - auto check = [&](bool condition, const char* message) - { - if (!condition) - { - Log::error() << "Esdc1a: " << message << '\n'; - ret += 1; - } - }; - - if (bus_ == nullptr) - { - Log::error() << "Esdc1a: bus pointer is null\n"; - ret += 1; - } - - check(Ka_ > ZERO, "Ka must be positive"); - check(Tc_ >= ZERO, "Tc must be non-negative"); - check(Vrmin_ <= Vrmax_, "Vrmin must be less than or equal to Vrmax"); - check(UEL_ >= static_cast(0) && UEL_ <= static_cast(3), - "UEL must be 0, 1, 2, or 3"); - - if (!(Se1_ == ZERO && Se2_ == ZERO) ) - { - check(E1_ > ZERO, "E1 must be positive when saturation is enabled"); - check(E2_ > ZERO, "E2 must be positive when saturation is enabled"); - check(Se1_ > ZERO, "Se1 must be positive when saturation is enabled"); - check(Se2_ > ZERO, "Se2 must be positive when saturation is enabled"); - check(E1_ != E2_, "E1 and E2 must differ when saturation is enabled"); - check(Se1_ != Se2_, "Se1 and Se2 must differ when saturation is enabled"); - } - - if (!signals_.template isAssigned()) - { - Log::error() << "Esdc1a: required efd output signal is not assigned\n"; - ret += 1; - } - - if (Spdmlt_ && !signals_.template isAttached()) - { - Log::error() << "Esdc1a: speed signal is required when Spdmlt is enabled\n"; - ret += 1; - } - - // 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() - && !signals_.template isLinked()) - { - Log::error() << "Esdc1a: " << name << " signal attached with no linked source\n"; - ret += 1; - } - }; - - check_attached_signal.template operator()("speed"); - check_attached_signal.template operator()("vref"); - check_attached_signal.template operator()("vs"); - check_attached_signal.template operator()("vuel"); - - return ret; - } - - /** - * @brief Initialize ESDC1A from the seeded field-voltage output - * - * Reads the assigned `efd` node, resolves the steady state that - * preserves that seed in dependency order, latches the attached input - * values as constant fallbacks, and publishes the resolved - * voltage-control reference to an attached `vref` signal. All - * operating-point checks are completed before model or signal storage - * is modified, so a rejected initialization leaves both unchanged. - * - * @pre allocate() has completed. - * @pre The terminal bus and the assigned `efd` node have been - * initialized. - * - * @return int 0 on success; nonzero when the configuration is invalid, - * the bus voltage or field-voltage seed is not finite, the - * speed-multiplier denominator vanishes, the regulator - * output falls outside its limits, or the high-value gate - * is active at the start. - */ - template - int Esdc1a::initialize() - { - if (verify() > 0) - { - Log::error() << "Esdc1a: cannot initialize with invalid configuration\n"; - return 1; - } - - auto* y = y_.getData(); - - // The assigned efd node aliases this entry after allocate(). Its - // seeded value remains untouched throughout initialization. - const ScalarT efd0 = y[index(I::EFD)]; - - ScalarT omega0{ZERO}; - if (signals_.template isAttached()) - { - omega0 = signals_.template readExternalVariable(); - } - - ScalarT vs0{ZERO}; - if (signals_.template isAttached()) - { - vs0 = signals_.template readExternalVariable(); - } - - ScalarT vuel0{ZERO}; - if (signals_.template isAttached()) - { - vuel0 = signals_.template readExternalVariable(); - } - - const ScalarT vc0 = std::sqrt(Vr() * Vr() + Vi() * Vi()); - - if (!std::isfinite(static_cast(efd0)) - || !std::isfinite(static_cast(vc0))) - { - Log::error() << "Esdc1a: initial bus voltage and field-voltage seed must be finite\n"; - return 1; - } + Log::error() << "Esdc1a: initial bus voltage and field-voltage seed must be finite\n"; + return 1; + } const ScalarT d0 = ONE + spd_on_ * omega0; if (d0 == ZERO) @@ -589,26 +313,26 @@ namespace GridKit const ScalarT xll0 = ev0; const ScalarT vref0 = ev0 + vc0 + vf0 - vs0 - uel_on_ * vuel0; - y[index(I::EFDP)] = efdp0; - y[index(I::VC)] = vc0; - y[index(I::VR)] = vr0; - y[index(I::VF)] = vf0; - y[index(I::XLL)] = xll0; - y[index(I::EV)] = ev0; - y[index(I::VLL)] = vll0; - y[index(I::VHV)] = vhv0; - y[index(I::SE)] = se0; - y[index(I::VFE)] = vfe0; - y[index(I::EFD)] = efd0; + y[EFDP] = efdp0; + y[VC] = vc0; + y[VR] = vr0; + y[VF] = vf0; + y[XLL] = xll0; + y[EV] = ev0; + y[VLL] = vll0; + y[VHV] = vhv0; + y[SE] = se0; + y[VFE] = vfe0; + y[EFD] = efd0; omega_set_ = omega0; vref_set_ = vref0; vs_set_ = vs0; vuel_set_ = vuel0; - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - signals_.template writeExternalVariable(vref_set_); + signals_.template writeExternalVariable(vref_set_); } y_.setDataUpdated(); @@ -623,28 +347,34 @@ namespace GridKit * stabilizing feedback, and the lead-lag state carry derivatives; * every other internal variable is algebraic. * - * @return int 0 on success. + * @return Zero on success. */ template int Esdc1a::tagDifferentiable() { + const auto EFDP = static_cast(Esdc1aInternalVariables::EFDP); + const auto VC = static_cast(Esdc1aInternalVariables::VC); + const auto VR = static_cast(Esdc1aInternalVariables::VR); + const auto VF = static_cast(Esdc1aInternalVariables::VF); + const auto XLL = static_cast(Esdc1aInternalVariables::XLL); + std::fill(tag_.begin(), tag_.end(), false); - tag_[index(I::EFDP)] = true; - tag_[index(I::VC)] = true; - tag_[index(I::VR)] = true; - tag_[index(I::VF)] = true; - tag_[index(I::XLL)] = true; + tag_[EFDP] = true; + tag_[VC] = true; + tag_[VR] = true; + tag_[VF] = true; + tag_[XLL] = true; return 0; } /** * @brief Compute the absolute tolerance for each variable in the model * - * All ESDC1A variables are per-unit exciter voltages of the same - * order, so they share the relative tolerance as their absolute floor. + * Every internal variable receives @p rel_tol as its absolute + * tolerance. * * @param[in] rel_tol Solver relative tolerance. - * @return int 0 on success. + * @return Zero on success. */ template int Esdc1a::setAbsoluteTolerance(RealT rel_tol) @@ -653,75 +383,6 @@ namespace GridKit return 0; } - /** - * @brief Internal residual - * - * Evaluates the five exciter states and the six 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 three selector decisions 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. - * @param[out] f Internal residuals. - * @return int 0 on success. - */ - template - __attribute__((always_inline)) inline int - Esdc1a::evaluateInternalResidual( - const ScalarT* y, - const ScalarT* yp, - const ScalarT* wb, - const ScalarT* ws, - ScalarT* f) - { - const ScalarT efdp = y[index(I::EFDP)]; - const ScalarT vc = y[index(I::VC)]; - const ScalarT vr = y[index(I::VR)]; - const ScalarT vf = y[index(I::VF)]; - const ScalarT xll = y[index(I::XLL)]; - const ScalarT ev = y[index(I::EV)]; - const ScalarT vll = y[index(I::VLL)]; - const ScalarT vhv = y[index(I::VHV)]; - const ScalarT se = y[index(I::SE)]; - const ScalarT vfe = y[index(I::VFE)]; - const ScalarT efd = y[index(I::EFD)]; - - const ScalarT efdp_dot = yp[index(I::EFDP)]; - const ScalarT vc_dot = yp[index(I::VC)]; - const ScalarT vr_dot = yp[index(I::VR)]; - const ScalarT vf_dot = yp[index(I::VF)]; - const ScalarT xll_dot = yp[index(I::XLL)]; - - const ScalarT omega = ws[index(E::OMEGA)]; - const ScalarT vref = ws[index(E::VREF)]; - const ScalarT vs = ws[index(E::VS)]; - const ScalarT vuel = ws[index(E::VUEL)]; - - const ScalarT ec = std::sqrt(wb[0] * wb[0] + wb[1] * wb[1]); - const ScalarT ev_target = vref + vs + uel_on_ * vuel - vc - vf; - const ScalarT vfe_drive = (Ke_ + se) * efdp; - - f[index(I::EFDP)] = -efdp_dot + (vr - vfe) / Te_; - f[index(I::VC)] = -vc_dot + (ec - vc) / Tr_; - f[index(I::VR)] = -vr_dot + Math::antiwindup(vr, -vr + Ka_ * vhv, Vrmin_, Vrmax_) / Ta_; - f[index(I::VF)] = -vf_dot + (-vf + Kf_ * (vr - vfe) / Te_) / Tf1_; - f[index(I::XLL)] = -xll_dot + (ev - xll) / Tb_; - f[index(I::EV)] = -ev + ev_target; - f[index(I::VLL)] = -vll + xll + (Tc_ / Tb_) * (ev - xll); - f[index(I::VHV)] = -vhv + uel_on_ * vll - + (ONE - uel_on_) * Math::max(vll, vuel); - f[index(I::SE)] = -se + SB_ * Math::qramp(efdp - SA_); - f[index(I::VFE)] = -vfe + (ONE - lim_on_) * vfe_drive - + lim_on_ * Math::ramp(vfe_drive); - f[index(I::EFD)] = -efd + (ONE + spd_on_ * omega) * efdp; - - return 0; - } - /** * @brief Residuals of system equations * @@ -730,40 +391,45 @@ namespace GridKit * residual. An unattached input port falls back to the value latched * by initialize(). * - * @return int 0 on success. + * @return Zero on success. */ template int Esdc1a::evaluateResidual() { - ws_[index(E::OMEGA)] = omega_set_; - ws_[index(E::VREF)] = vref_set_; - ws_[index(E::VS)] = vs_set_; - ws_[index(E::VUEL)] = vuel_set_; + const auto OMEGA = static_cast(Esdc1aExternalVariables::OMEGA); + const auto VREF = static_cast(Esdc1aExternalVariables::VREF); + const auto VS = static_cast(Esdc1aExternalVariables::VS); + const auto VUEL = static_cast(Esdc1aExternalVariables::VUEL); + + ws_[OMEGA] = omega_set_; + ws_[VREF] = vref_set_; + ws_[VS] = vs_set_; + ws_[VUEL] = vuel_set_; std::fill(ws_indices_.begin(), ws_indices_.end(), INVALID_INDEX); - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - ws_[index(E::OMEGA)] = signals_.template readExternalVariable(); - ws_indices_[index(E::OMEGA)] = - signals_.template readExternalVariableIndex(); + ws_[OMEGA] = signals_.template readExternalVariable(); + ws_indices_[OMEGA] = + signals_.template readExternalVariableIndex(); } - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - ws_[index(E::VREF)] = signals_.template readExternalVariable(); - ws_indices_[index(E::VREF)] = - signals_.template readExternalVariableIndex(); + ws_[VREF] = signals_.template readExternalVariable(); + ws_indices_[VREF] = + signals_.template readExternalVariableIndex(); } - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - ws_[index(E::VS)] = signals_.template readExternalVariable(); - ws_indices_[index(E::VS)] = - signals_.template readExternalVariableIndex(); + ws_[VS] = signals_.template readExternalVariable(); + ws_indices_[VS] = + signals_.template readExternalVariableIndex(); } - if (signals_.template isAttached()) + if (signals_.template isAttached()) { - ws_[index(E::VUEL)] = signals_.template readExternalVariable(); - ws_indices_[index(E::VUEL)] = - signals_.template readExternalVariableIndex(); + ws_[VUEL] = signals_.template readExternalVariable(); + ws_indices_[VUEL] = + signals_.template readExternalVariableIndex(); } wb_[0] = Vr(); @@ -777,6 +443,386 @@ namespace GridKit f_.setDataUpdated(); return 0; } + + /** + * @brief Access the monitor + * + * @return Monitor for this model, or nullptr when the model was + * constructed without data. + */ + template + const Model::VariableMonitorBase* Esdc1a::getMonitor() const + { + return monitor_.get(); + } + + /** + * @brief Evaluate the ESDC1A internal residual. + * + * Evaluates the five exciter states and the six algebraic equations + * documented in the model README. The body is kept free of branches + * and loops so sparse automatic differentiation resolves a fixed + * structure; the three selector decisions enter as multiplicative + * masks set by setDerivedParameters(). + * + * @param[in] y Internal variables in Esdc1aInternalVariables order. + * @param[in] yp Internal derivatives in the same enum order. + * @param[in] wb Terminal-bus \f$(V_{\mathrm{r}},V_{\mathrm{i}})\f$ + * voltage components. + * @param[in] ws Signal values in Esdc1aExternalVariables order. + * @param[out] f Residuals in Esdc1aInternalVariables order. + * @return Zero on success. + */ + template + __attribute__((always_inline)) inline int + Esdc1a::evaluateInternalResidual( + const ScalarT* y, + const ScalarT* yp, + const ScalarT* wb, + const ScalarT* ws, + ScalarT* f) + { + const auto EFDP = static_cast(Esdc1aInternalVariables::EFDP); + const auto VC = static_cast(Esdc1aInternalVariables::VC); + const auto VR = static_cast(Esdc1aInternalVariables::VR); + const auto VF = static_cast(Esdc1aInternalVariables::VF); + const auto XLL = static_cast(Esdc1aInternalVariables::XLL); + const auto EV = static_cast(Esdc1aInternalVariables::EV); + const auto VLL = static_cast(Esdc1aInternalVariables::VLL); + const auto VHV = static_cast(Esdc1aInternalVariables::VHV); + const auto SE = static_cast(Esdc1aInternalVariables::SE); + const auto VFE = static_cast(Esdc1aInternalVariables::VFE); + const auto EFD = static_cast(Esdc1aInternalVariables::EFD); + + const auto OMEGA = static_cast(Esdc1aExternalVariables::OMEGA); + const auto VREF = static_cast(Esdc1aExternalVariables::VREF); + const auto VS = static_cast(Esdc1aExternalVariables::VS); + const auto VUEL = static_cast(Esdc1aExternalVariables::VUEL); + + const ScalarT efdp = y[EFDP]; + const ScalarT vc = y[VC]; + const ScalarT vr = y[VR]; + const ScalarT vf = y[VF]; + const ScalarT xll = y[XLL]; + const ScalarT ev = y[EV]; + const ScalarT vll = y[VLL]; + const ScalarT vhv = y[VHV]; + const ScalarT se = y[SE]; + const ScalarT vfe = y[VFE]; + const ScalarT efd = y[EFD]; + + const ScalarT efdp_dot = yp[EFDP]; + const ScalarT vc_dot = yp[VC]; + const ScalarT vr_dot = yp[VR]; + const ScalarT vf_dot = yp[VF]; + const ScalarT xll_dot = yp[XLL]; + + const ScalarT omega = ws[OMEGA]; + const ScalarT vref = ws[VREF]; + const ScalarT vs = ws[VS]; + const ScalarT vuel = ws[VUEL]; + + const ScalarT ec = std::sqrt(wb[0] * wb[0] + wb[1] * wb[1]); + const ScalarT ev_target = vref + vs + uel_on_ * vuel - vc - vf; + const ScalarT vfe_drive = (Ke_ + se) * efdp; + + f[EFDP] = -efdp_dot + (vr - vfe) / Te_; + f[VC] = -vc_dot + (ec - vc) / Tr_; + f[VR] = -vr_dot + Math::antiwindup(vr, -vr + Ka_ * vhv, Vrmin_, Vrmax_) / Ta_; + f[VF] = -vf_dot + (-vf + Kf_ * (vr - vfe) / Te_) / Tf1_; + f[XLL] = -xll_dot + (ev - xll) / Tb_; + f[EV] = -ev + ev_target; + f[VLL] = -vll + xll + (Tc_ / Tb_) * (ev - xll); + f[VHV] = -vhv + uel_on_ * vll + + (ONE - uel_on_) * Math::max(vll, vuel); + f[SE] = -se + SB_ * Math::qramp(efdp - SA_); + f[VFE] = -vfe + (ONE - lim_on_) * vfe_drive + + lim_on_ * Math::ramp(vfe_drive); + f[EFD] = -efd + (ONE + spd_on_ * omega) * efdp; + + return 0; + } + + // + // Private methods + // + + /** + * @brief Read the parameters out of the model data + * + * No parameter is required; every parameter keeps the default + * documented in the model README when omitted. A non-numeric value, a + * switch outside \f$\{0,1\}\f$, or a non-integer selector 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 Esdc1a::initializeParameters(const ModelDataT& data) + { + using Params = typename ModelDataT::Parameters; + + parameter_error_count_ = 0; + + 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() << "Esdc1a: 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 if (const auto* real_value = std::get_if(&value); + real_value && (*real_value == ZERO || *real_value == ONE) ) + { + target = (*real_value == ONE); + } + else + { + Log::error() << "Esdc1a: parameter '" << name << "' must be bool or 0/1\n"; + ++parameter_error_count_; + } + }; + + auto load_selector = [&](auto key, IdxT& target, const char* name) + { + if (!data.parameters.contains(key)) + { + return; + } + + const auto& value = data.parameters.at(key); + if (const auto* index_value = std::get_if(&value)) + { + target = *index_value; + } + else if (const auto* real_value = std::get_if(&value); + real_value && *real_value >= ZERO + && *real_value == std::round(*real_value)) + { + target = static_cast(std::round(*real_value)); + } + else + { + Log::error() << "Esdc1a: parameter '" << name << "' must be an integer selector\n"; + ++parameter_error_count_; + } + }; + + load_real(Params::Tr, Tr_, "Tr"); + load_real(Params::Ka, Ka_, "Ka"); + load_real(Params::Ta, Ta_, "Ta"); + load_real(Params::Tb, Tb_, "Tb"); + load_real(Params::Tc, Tc_, "Tc"); + load_real(Params::Vrmax, Vrmax_, "Vrmax"); + load_real(Params::Vrmin, Vrmin_, "Vrmin"); + load_real(Params::Ke, Ke_, "Ke"); + load_real(Params::Te, Te_, "Te"); + load_real(Params::Kf, Kf_, "Kf"); + load_real(Params::Tf1, Tf1_, "Tf1"); + load_switch(Params::Spdmlt, Spdmlt_, "Spdmlt"); + load_real(Params::E1, E1_, "E1"); + load_real(Params::Se1, Se1_, "Se1"); + load_real(Params::E2, E2_, "E2"); + load_real(Params::Se2, Se2_, "Se2"); + load_selector(Params::UEL, UEL_, "UEL"); + load_switch(Params::exclim, exclim_, "exclim"); + setDerivedParameters(); + } + + /** + * @brief Bind the monitorable variables to their internal states + * + * Binds configured monitor keys to their corresponding internal + * variables. + */ + template + void Esdc1a::initializeMonitor() + { + using Variable = typename ModelDataT::MonitorableVariables; + + monitor_->set(Variable::efd, [this] + { return y_.getData()[static_cast(Esdc1aInternalVariables::EFD)]; }); + monitor_->set(Variable::vc, [this] + { return y_.getData()[static_cast(Esdc1aInternalVariables::VC)]; }); + monitor_->set(Variable::vr, [this] + { return y_.getData()[static_cast(Esdc1aInternalVariables::VR)]; }); + monitor_->set(Variable::vf, [this] + { return y_.getData()[static_cast(Esdc1aInternalVariables::VF)]; }); + monitor_->set(Variable::se, [this] + { return y_.getData()[static_cast(Esdc1aInternalVariables::SE)]; }); + monitor_->set(Variable::vfe, [this] + { return y_.getData()[static_cast(Esdc1aInternalVariables::VFE)]; }); + } + + /** + * @brief Resolve the parameter-derived constants and selector masks + * + * Raises the transducer, regulator, lead-lag, exciter, and feedback + * lags to the well-posedness floor, fits the quadratic saturation + * curve, and turns the three selectors into multiplicative masks. The + * masks let the residual select signal routing without + * parameter-dependent control flow, which keeps its structure fixed for + * sparse automatic differentiation. + */ + template + void Esdc1a::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() << "Esdc1a: " << name << " must be non-negative\n"; + ++parameter_error_count_; + } + }; + + check_non_negative(Tr_, "Tr"); + check_non_negative(Ta_, "Ta"); + check_non_negative(Tb_, "Tb"); + check_non_negative(Te_, "Te"); + check_non_negative(Tf1_, "Tf1"); + + if (Tr_ < TIME_CONSTANT_MINIMUM || Ta_ < TIME_CONSTANT_MINIMUM + || Tb_ < TIME_CONSTANT_MINIMUM || Te_ < TIME_CONSTANT_MINIMUM + || Tf1_ < TIME_CONSTANT_MINIMUM) + { + Log::warning() << "Esdc1a: Tr, Ta, Tb, Te, and Tf1 below " + << TIME_CONSTANT_MINIMUM + << " s are raised to that floor to keep the exciter lags well posed\n"; + } + + Tr_ = std::max(Tr_, TIME_CONSTANT_MINIMUM); + Ta_ = std::max(Ta_, TIME_CONSTANT_MINIMUM); + Tb_ = std::max(Tb_, TIME_CONSTANT_MINIMUM); + Te_ = std::max(Te_, TIME_CONSTANT_MINIMUM); + Tf1_ = std::max(Tf1_, TIME_CONSTANT_MINIMUM); + + spd_on_ = ZERO; + if (Spdmlt_) + { + spd_on_ = ONE; + } + + uel_on_ = ZERO; + if (UEL_ >= static_cast(2)) + { + uel_on_ = ONE; + } + + lim_on_ = ZERO; + if (exclim_) + { + lim_on_ = ONE; + } + + // A disabled or inconsistent saturation curve keeps the zero fit so + // the coefficients stay finite; verify() reports inconsistent data. + const bool saturation_enabled = !(Se1_ == ZERO && Se2_ == ZERO); + const bool saturation_consistent = + E1_ > ZERO && E2_ > ZERO && E1_ != E2_ + && Se1_ > ZERO && Se2_ > ZERO && Se1_ != Se2_; + if (!saturation_enabled || !saturation_consistent) + { + SA_ = ZERO; + SB_ = ZERO; + return; + } + + const RealT C = std::sqrt(Se2_ / Se1_); + SA_ = (C * E1_ - E2_) / (C - ONE); + SB_ = Se1_ / ((E1_ - SA_) * (E1_ - SA_)); + } + + /** + * @brief Invert the smooth CommonMath ramp + * + * Initialization seeds the inactive high-value gate with the gate + * *input*, so the residual reproduces the requested output through the + * same smooth ramp it evaluates. Beyond the softplus width the smooth + * ramp is the identity to double precision, so the output is returned + * unchanged there. + * + * @param[in] ramp_output Strictly positive requested ramp output. + * @return The input the smooth ramp maps to the requested output. + * + * @pre @p ramp_output is finite and strictly positive. + * @warning This function contains conditional branching and may be used + * during initialization, but not during residual or Jacobian + * evaluation. + */ + template + typename Esdc1a::RealT + Esdc1a::inverseRamp(RealT ramp_output) const + { + static constexpr RealT SOFTPLUS_WIDTH = static_cast(50.0); + + const RealT scaled_output = Math::MU * ramp_output; + if (scaled_output > SOFTPLUS_WIDTH) + { + return ramp_output; + } + return std::log(std::expm1(scaled_output)) / Math::MU; + } + + /** + * @brief Access the terminal-bus real voltage component. + * + * @return Reference to the bus variable. + */ + template + scalar_type& Esdc1a::Vr() + { + return bus_->Vr(); + } + + /** + * @brief Access the terminal-bus imaginary voltage component. + * + * @return Reference to the bus variable. + */ + template + scalar_type& Esdc1a::Vi() + { + return bus_->Vi(); + } + } // namespace Exciter } // namespace PhasorDynamics } // namespace GridKit diff --git a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp index a35f20a6b..df353f187 100644 --- a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp @@ -59,12 +59,12 @@ namespace GridKit PhasorDynamics::Bus bus(1.0, 0.0); PhasorDynamics::Exciter::Esdc1a empty(&bus); - success *= (empty.size() == static_cast(I::MAXIMUM)); + success *= (empty.size() == static_cast(Internal::MAXIMUM)); success *= (empty.getMonitor() == nullptr); success *= (empty.verify() > 0); Fixture configured(makeData()); - success *= (configured.esdc1a.size() == static_cast(I::MAXIMUM)); + success *= (configured.esdc1a.size() == static_cast(Internal::MAXIMUM)); success *= (configured.esdc1a.getMonitor() != nullptr); success *= configured.prepare(1.2); @@ -133,13 +133,13 @@ namespace GridKit PhasorDynamics::SignalNode busless_efd_node; PhasorDynamics::Exciter::Esdc1a busless(nullptr, makeData()); - busless.getSignals().template assignSignalNode(&busless_efd_node); + busless.getSignals().template assignSignalNode(&busless_efd_node); success *= (busless.verify() > 0); - success *= unlinkedSignalRejected(); - success *= unlinkedSignalRejected(); - success *= unlinkedSignalRejected(); - success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); // All five floored time constants at zero use the documented // numerical floor and still admit a consistent steady-state @@ -169,28 +169,28 @@ namespace GridKit Fixture fixture(makeData()); fixture.attachAllInputs(99.0); - fixture.input(E::OMEGA) = 0.02; - fixture.input(E::VS) = 0.03; - fixture.input(E::VUEL) = -0.4; - success *= fixture.initialize(1.2); - success *= (fixture.esdc1a.tagDifferentiable() == 0); - success *= (fixture.evaluate() == 0); + fixture.input(External::OMEGA) = 0.02; + fixture.input(External::VS) = 0.03; + fixture.input(External::VUEL) = -0.4; + success *= fixture.initialize(1.2); + success *= (fixture.esdc1a.tagDifferentiable() == 0); + success *= (fixture.evaluate() == 0); const auto* y = fixture.esdc1a.y().getData(); - success *= scalarMatches(y[index(I::EFDP)], 1.2, "EFDP"); - success *= scalarMatches(y[index(I::VC)], 1.0, "VC"); - success *= scalarMatches(y[index(I::VR)], 0.12, "VR"); - success *= scalarMatches(y[index(I::VF)], 0.0, "VF"); - success *= scalarMatches(y[index(I::EV)], 0.003, "EV gate input"); - success *= scalarMatches(y[index(I::VHV)], 0.003, "VHV"); - success *= scalarMatches(y[index(I::SE)], 0.0, "SE"); - success *= scalarMatches(y[index(I::VFE)], 0.12, "VFE"); + success *= scalarMatches(y[static_cast(Internal::EFDP)], 1.2, "EFDP"); + success *= scalarMatches(y[static_cast(Internal::VC)], 1.0, "VC"); + success *= scalarMatches(y[static_cast(Internal::VR)], 0.12, "VR"); + success *= scalarMatches(y[static_cast(Internal::VF)], 0.0, "VF"); + success *= scalarMatches(y[static_cast(Internal::EV)], 0.003, "EV gate input"); + success *= scalarMatches(y[static_cast(Internal::VHV)], 0.003, "VHV"); + success *= scalarMatches(y[static_cast(Internal::SE)], 0.0, "SE"); + success *= scalarMatches(y[static_cast(Internal::VFE)], 0.12, "VFE"); success *= scalarMatches(fixture.efd(), 1.2, "seeded efd"); - success *= scalarMatches(fixture.input(E::VREF), 0.973, "published vref"); - success *= scalarMatches(fixture.input(E::OMEGA), 0.02, "preserved speed input"); - success *= scalarMatches(fixture.input(E::VS), 0.03, "preserved vs input"); - success *= scalarMatches(fixture.input(E::VUEL), -0.4, "preserved vuel input"); + success *= scalarMatches(fixture.input(External::VREF), 0.973, "published vref"); + success *= scalarMatches(fixture.input(External::OMEGA), 0.02, "preserved speed input"); + success *= scalarMatches(fixture.input(External::VS), 0.03, "preserved vs input"); + success *= scalarMatches(fixture.input(External::VUEL), -0.4, "preserved vuel input"); RealT time = 0.0; Model::VariableMonitorController monitor(time); @@ -227,7 +227,7 @@ namespace GridKit for (size_t i = 0; i < static_cast(fixture.esdc1a.size()); ++i) { - const bool expected = i <= index(I::XLL); + const bool expected = i <= static_cast(Internal::XLL); if (fixture.esdc1a.tag()[i] != expected) { std::cout << "ESDC1A differentiability tag " << i << " mismatch\n"; @@ -258,9 +258,9 @@ namespace GridKit Fixture scenario(scenario_data); scenario.attachAllInputs(); - scenario.input(E::OMEGA) = 0.02; - scenario.input(E::VS) = 0.03; - scenario.input(E::VUEL) = -0.4; + scenario.input(External::OMEGA) = 0.02; + scenario.input(External::VS) = 0.03; + scenario.input(External::VUEL) = -0.4; if (!scenario.initialize(1.2)) { std::cout << "ESDC1A initialization scenario failed: UEL=" << uel @@ -296,10 +296,10 @@ namespace GridKit speed_data.parameters[Params::Spdmlt] = true; success *= initializationRejectedAtomically(speed_data, 1.2, - {{E::OMEGA, -1.0}, - {E::VREF, 77.0}, - {E::VS, 77.0}, - {E::VUEL, -77.0}}, + {{External::OMEGA, -1.0}, + {External::VREF, 77.0}, + {External::VS, 77.0}, + {External::VUEL, -77.0}}, "zero speed-multiplier denominator"); // The seeded field voltage maps to a regulator output above Vrmax. @@ -308,20 +308,20 @@ namespace GridKit limit_data.parameters[Params::Vrmin] = -0.05; success *= initializationRejectedAtomically(limit_data, 1.2, - {{E::OMEGA, 0.0}, - {E::VREF, 77.0}, - {E::VS, 77.0}, - {E::VUEL, -77.0}}, + {{External::OMEGA, 0.0}, + {External::VREF, 77.0}, + {External::VS, 77.0}, + {External::VUEL, -77.0}}, "regulator output outside limits"); // A UEL input above the gate operating point holds the high-value // gate active, which the smooth gate cannot represent at rest. success *= initializationRejectedAtomically(makeData(), 1.2, - {{E::OMEGA, 0.0}, - {E::VREF, 77.0}, - {E::VS, 77.0}, - {E::VUEL, 0.5}}, + {{External::OMEGA, 0.0}, + {External::VREF, 77.0}, + {External::VS, 77.0}, + {External::VUEL, 0.5}}, "active high-value gate"); // A non-finite field-voltage seed is rejected before any signal is @@ -330,7 +330,7 @@ namespace GridKit nonfinite.attachAllInputs(77.0); success *= nonfinite.prepare(std::numeric_limits::quiet_NaN()); success *= (nonfinite.esdc1a.initialize() != 0); - success *= scalarMatches(nonfinite.input(E::VREF), 77.0, "rejected vref preservation"); + success *= scalarMatches(nonfinite.input(External::VREF), 77.0, "rejected vref preservation"); // An invalid configuration is rejected before any state is written. auto invalid_data = makeData(); @@ -354,13 +354,14 @@ namespace GridKit admissible_speed.parameters[Params::Spdmlt] = true; Fixture speed_fixture(admissible_speed); speed_fixture.attachAllInputs(); - speed_fixture.input(E::OMEGA) = -0.5; - success *= speed_fixture.initialize(1.2); - success *= (speed_fixture.evaluate() == 0); - success *= allResidualsZero(speed_fixture.esdc1a); - success *= scalarMatches(speed_fixture.esdc1a.y().getData()[index(I::EFDP)], - 2.4, - "rescaled EFDP"); + speed_fixture.input(External::OMEGA) = -0.5; + success *= speed_fixture.initialize(1.2); + success *= (speed_fixture.evaluate() == 0); + success *= allResidualsZero(speed_fixture.esdc1a); + success *= scalarMatches( + speed_fixture.esdc1a.y().getData()[static_cast(Internal::EFDP)], + 2.4, + "rescaled EFDP"); // The gate stays representable arbitrarily close to its operating // point: the ramp inverse seeds a 0.003 margin exactly. @@ -375,15 +376,15 @@ namespace GridKit junction_data.parameters[Params::UEL] = static_cast(2); Fixture junction(junction_data); junction.attachAllInputs(); - junction.input(E::VUEL) = 0.7; - success *= junction.initialize(1.2); - success *= (junction.evaluate() == 0); - success *= allResidualsZero(junction.esdc1a); + junction.input(External::VUEL) = 0.7; + success *= junction.initialize(1.2); + success *= (junction.evaluate() == 0); + success *= allResidualsZero(junction.esdc1a); return success.report(__func__); } - /// A fixed numerical answer key for all 11 ESDC1A residual rows. The + /// A fixed numerical answer key for all 11 ESDC1A equations. The /// expected values are literals, not a second implementation of ESDC1A. TestOutcome residualEquations() { @@ -398,18 +399,18 @@ namespace GridKit // Values are pinned after an independent one-time evaluation of the // documented equations at setAnswerKeyState()/setAnswerKeyInputs(). - const std::array expected{{ - {I::EFDP, 0.04000000000000004}, - {I::VC, 0.19442890089805262}, - {I::VR, 0.13666666666666663}, - {I::VF, -0.022222222222222213}, - {I::XLL, 0.024999999999999994}, - {I::EV, -0.27999999999999986}, - {I::VLL, -0.007500000000000031}, - {I::VHV, 0.31535073999664665}, - {I::SE, -0.07541019662496842}, - {I::VFE, 0.1600000000000001}, - {I::EFD, 0.9100000000000001}, + const std::array(Internal::MAXIMUM)> expected{{ + {Internal::EFDP, 0.04000000000000004}, + {Internal::VC, 0.19442890089805262}, + {Internal::VR, 0.13666666666666663}, + {Internal::VF, -0.022222222222222213}, + {Internal::XLL, 0.024999999999999994}, + {Internal::EV, -0.27999999999999986}, + {Internal::VLL, -0.007500000000000031}, + {Internal::VHV, 0.31535073999664665}, + {Internal::SE, -0.07541019662496842}, + {Internal::VFE, 0.1600000000000001}, + {Internal::EFD, 0.9100000000000001}, }}; success *= (static_cast(fixture.esdc1a.getResidual().getSize()) == expected.size()); @@ -430,30 +431,30 @@ namespace GridKit success *= fixture.initialize(1.2); // Transducer: the sensed voltage relaxes toward the bus magnitude. - setState(fixture.esdc1a, {{I::VC, 1.1}}); - setDerivative(fixture.esdc1a, {{I::VC, 0.2}}); + setState(fixture.esdc1a, {{Internal::VC, 1.1}}); + setDerivative(fixture.esdc1a, {{Internal::VC, 0.2}}); success *= (fixture.evaluate() == 0); - success *= residualsMatch(fixture.esdc1a, {{I::VC, -5.2}}, "voltage transducer"); + success *= residualsMatch(fixture.esdc1a, {{Internal::VC, -5.2}}, "voltage transducer"); // The field-voltage state and the stabilizing feedback share the // (VR - VFE) drive. - setState(fixture.esdc1a, {{I::VR, 0.6}, {I::VFE, 0.2}, {I::VF, 0.1}}); - setDerivative(fixture.esdc1a, {{I::EFDP, 0.1}, {I::VF, 0.05}}); + setState(fixture.esdc1a, {{Internal::VR, 0.6}, {Internal::VFE, 0.2}, {Internal::VF, 0.1}}); + setDerivative(fixture.esdc1a, {{Internal::EFDP, 0.1}, {Internal::VF, 0.05}}); success *= (fixture.evaluate() == 0); success *= residualsMatch(fixture.esdc1a, - {{I::EFDP, 0.7}, {I::VF, -0.13571428571428573}}, + {{Internal::EFDP, 0.7}, {Internal::VF, -0.13571428571428573}}, "field-voltage and feedback drive"); // Summing junction: UEL < 2 excludes the UEL input from the error. Fixture summing(makeData()); summing.attachAllInputs(); - success *= summing.initialize(1.2); - summing.input(E::VREF) = 1.1; - summing.input(E::VS) = 0.05; - summing.input(E::VUEL) = 0.2; - setState(summing.esdc1a, {{I::VC, 0.9}, {I::VF, 0.02}, {I::EV, 0.1}}); + success *= summing.initialize(1.2); + summing.input(External::VREF) = 1.1; + summing.input(External::VS) = 0.05; + summing.input(External::VUEL) = 0.2; + setState(summing.esdc1a, {{Internal::VC, 0.9}, {Internal::VF, 0.02}, {Internal::EV, 0.1}}); success *= (summing.evaluate() == 0); - success *= residualsMatch(summing.esdc1a, {{I::EV, 0.13}}, "summing junction"); + success *= residualsMatch(summing.esdc1a, {{Internal::EV, 0.13}}, "summing junction"); // UEL >= 2 routes the UEL input through the summing junction and // turns the high-value gate into a lead-lag passthrough. @@ -461,15 +462,19 @@ namespace GridKit junction_data.parameters[Params::UEL] = static_cast(2); Fixture junction(junction_data); junction.attachAllInputs(); - success *= junction.initialize(1.2); - junction.input(E::VREF) = 1.1; - junction.input(E::VS) = 0.05; - junction.input(E::VUEL) = 0.2; + success *= junction.initialize(1.2); + junction.input(External::VREF) = 1.1; + junction.input(External::VS) = 0.05; + junction.input(External::VUEL) = 0.2; setState(junction.esdc1a, - {{I::VC, 0.9}, {I::VF, 0.02}, {I::EV, 0.1}, {I::VLL, 0.5}, {I::VHV, 0.2}}); + {{Internal::VC, 0.9}, + {Internal::VF, 0.02}, + {Internal::EV, 0.1}, + {Internal::VLL, 0.5}, + {Internal::VHV, 0.2}}); success *= (junction.evaluate() == 0); success *= residualsMatch(junction.esdc1a, - {{I::EV, 0.33}, {I::VHV, 0.3}}, + {{Internal::EV, 0.33}, {Internal::VHV, 0.3}}, "summing-junction UEL routing"); // An active lead-lag pair advances the error and relaxes its state. @@ -478,11 +483,11 @@ namespace GridKit Fixture lead_lag(lead_lag_data); lead_lag.attachAllInputs(); success *= lead_lag.initialize(1.2); - setState(lead_lag.esdc1a, {{I::XLL, 0.4}, {I::EV, 0.7}, {I::VLL, 0.5}}); - setDerivative(lead_lag.esdc1a, {{I::XLL, 0.0}}); + setState(lead_lag.esdc1a, {{Internal::XLL, 0.4}, {Internal::EV, 0.7}, {Internal::VLL, 0.5}}); + setDerivative(lead_lag.esdc1a, {{Internal::XLL, 0.0}}); success *= (lead_lag.evaluate() == 0); success *= residualsMatch(lead_lag.esdc1a, - {{I::XLL, 0.6}, {I::VLL, 0.02}}, + {{Internal::XLL, 0.6}, {Internal::VLL, 0.02}}, "lead-lag"); // The regulator anti-windup blocks outward rates at both limits and @@ -504,11 +509,11 @@ namespace GridKit for (const auto& test_case : antiwindup_cases) { - setState(fixture.esdc1a, {{I::VR, test_case.vr}, {I::VHV, test_case.vhv}}); - setDerivative(fixture.esdc1a, {{I::VR, 0.0}}); + setState(fixture.esdc1a, {{Internal::VR, test_case.vr}, {Internal::VHV, test_case.vhv}}); + setDerivative(fixture.esdc1a, {{Internal::VR, 0.0}}); success *= (fixture.evaluate() == 0); success *= residualsMatch(fixture.esdc1a, - {{I::VR, test_case.expected}}, + {{Internal::VR, test_case.expected}}, test_case.label); } @@ -542,10 +547,10 @@ namespace GridKit success *= gate.initialize(1.2); for (const auto& test_case : gate_cases) { - gate.input(E::VUEL) = test_case.vuel; - setState(gate.esdc1a, {{I::VLL, 0.5}, {I::VHV, 0.2}}); + gate.input(External::VUEL) = test_case.vuel; + setState(gate.esdc1a, {{Internal::VLL, 0.5}, {Internal::VHV, 0.2}}); success *= (gate.evaluate() == 0); - success *= residualsMatch(gate.esdc1a, {{I::VHV, test_case.expected}}, test_case.label); + success *= residualsMatch(gate.esdc1a, {{Internal::VHV, test_case.expected}}, test_case.label); } // Quadratic saturation above and below the fitted knee, then with @@ -553,15 +558,15 @@ namespace GridKit Fixture saturation(makeResidualData()); saturation.attachAllInputs(); success *= saturation.initialize(1.2); - setState(saturation.esdc1a, {{I::EFDP, 2.0}, {I::SE, 0.05}}); + setState(saturation.esdc1a, {{Internal::EFDP, 2.0}, {Internal::SE, 0.05}}); success *= (saturation.evaluate() == 0); success *= residualsMatch(saturation.esdc1a, - {{I::SE, -0.035410196624968436}}, + {{Internal::SE, -0.035410196624968436}}, "saturation above the knee"); - setState(saturation.esdc1a, {{I::EFDP, 1.0}}); + setState(saturation.esdc1a, {{Internal::EFDP, 1.0}}); success *= (saturation.evaluate() == 0); success *= residualsMatch(saturation.esdc1a, - {{I::SE, -0.05}}, + {{Internal::SE, -0.05}}, "saturation below the knee"); auto disabled_data = makeResidualData(); @@ -570,9 +575,9 @@ namespace GridKit Fixture disabled(disabled_data); disabled.attachAllInputs(); success *= disabled.initialize(1.2); - setState(disabled.esdc1a, {{I::EFDP, 2.0}, {I::SE, 0.05}}); + setState(disabled.esdc1a, {{Internal::EFDP, 2.0}, {Internal::SE, 0.05}}); success *= (disabled.evaluate() == 0); - success *= residualsMatch(disabled.esdc1a, {{I::SE, -0.05}}, "saturation disabled"); + success *= residualsMatch(disabled.esdc1a, {{Internal::SE, -0.05}}, "saturation disabled"); // The exciter feedback lower limit clamps a negative feedback drive // to zero only while enabled. @@ -590,12 +595,12 @@ namespace GridKit data.parameters[Params::exclim] = limited; Fixture feedback(data); feedback.attachAllInputs(); - feedback.input(E::VUEL) = -0.5; - success *= feedback.initialize(1.2); - setState(feedback.esdc1a, {{I::EFDP, 1.0}, {I::SE, 0.0}, {I::VFE, 0.0}}); + feedback.input(External::VUEL) = -0.5; + success *= feedback.initialize(1.2); + setState(feedback.esdc1a, {{Internal::EFDP, 1.0}, {Internal::SE, 0.0}, {Internal::VFE, 0.0}}); success *= (feedback.evaluate() == 0); success *= residualsMatch(feedback.esdc1a, - {{I::VFE, expected}}, + {{Internal::VFE, expected}}, limited ? "feedback lower limit engaged" : "feedback lower limit disabled"); } @@ -611,12 +616,12 @@ namespace GridKit data.parameters[Params::Spdmlt] = enabled; Fixture speed(data); speed.attachAllInputs(); - success *= speed.initialize(1.2); - speed.input(E::OMEGA) = 0.05; - setState(speed.esdc1a, {{I::EFDP, 1.2}, {I::EFD, 1.2}}); + success *= speed.initialize(1.2); + speed.input(External::OMEGA) = 0.05; + setState(speed.esdc1a, {{Internal::EFDP, 1.2}, {Internal::EFD, 1.2}}); success *= (speed.evaluate() == 0); success *= residualsMatch(speed.esdc1a, - {{I::EFD, expected}}, + {{Internal::EFD, expected}}, enabled ? "speed multiplier enabled" : "speed multiplier disabled"); } @@ -654,28 +659,18 @@ namespace GridKit #endif private: - using Esdc1aT = PhasorDynamics::Exciter::Esdc1a; - using Data = typename Esdc1aT::ModelDataT; - using Params = typename Data::Parameters; - using Mon = typename Data::MonitorableVariables; - using I = typename Esdc1aT::InternalVariablesT; - using E = typename Esdc1aT::ExternalVariablesT; - - using InternalRow = std::pair; + using Esdc1aT = PhasorDynamics::Exciter::Esdc1a; + using Data = typename Esdc1aT::ModelDataT; + using Params = typename Data::Parameters; + using Mon = typename Data::MonitorableVariables; + using Internal = typename Esdc1aT::InternalVariablesT; + using External = typename Esdc1aT::ExternalVariablesT; + + using InternalRow = std::pair; using InternalRows = std::vector; - using ExternalRow = std::pair; + using ExternalRow = std::pair; using ExternalRows = std::vector; - static constexpr size_t index(I variable) - { - return static_cast(variable); - } - - static constexpr size_t index(E variable) - { - return static_cast(variable); - } - /// Owns the terminal bus, ESDC1A, the assigned field-voltage node, and /// the attached input nodes. Signal storage is declared before the /// model so every referenced node outlives ESDC1A. Copying would @@ -684,9 +679,10 @@ namespace GridKit class Fixture { private: - std::array input_values_{}; - std::array input_indices_{}; - std::array, index(E::MAXIMUM)> + std::array(External::MAXIMUM)> input_values_{}; + std::array(External::MAXIMUM)> input_indices_{}; + std::array, + static_cast(External::MAXIMUM)> input_nodes_{}; PhasorDynamics::SignalNode efd_node_; @@ -696,7 +692,7 @@ namespace GridKit : bus(static_cast(vr), static_cast(vi)), esdc1a(&bus, data) { - esdc1a.getSignals().template assignSignalNode(&efd_node_); + esdc1a.getSignals().template assignSignalNode(&efd_node_); } Fixture(const Fixture&) = delete; @@ -715,10 +711,14 @@ namespace GridKit } auto& signals = esdc1a.getSignals(); - signals.template attachSignalNode(&input_nodes_[index(E::OMEGA)]); - signals.template attachSignalNode(&input_nodes_[index(E::VREF)]); - signals.template attachSignalNode(&input_nodes_[index(E::VS)]); - signals.template attachSignalNode(&input_nodes_[index(E::VUEL)]); + signals.template attachSignalNode( + &input_nodes_[static_cast(External::OMEGA)]); + signals.template attachSignalNode( + &input_nodes_[static_cast(External::VREF)]); + signals.template attachSignalNode( + &input_nodes_[static_cast(External::VS)]); + signals.template attachSignalNode( + &input_nodes_[static_cast(External::VUEL)]); } /// Seed the assigned field-voltage node. @@ -769,14 +769,14 @@ namespace GridKit return efd_node_.read(); } - T& input(E port) + T& input(External port) { - return input_values_[index(port)]; + return input_values_[static_cast(port)]; } - IdxT inputIndex(E port) const + IdxT inputIndex(External port) const { - return input_indices_[index(port)]; + return input_indices_[static_cast(port)]; } PhasorDynamics::Bus bus; @@ -883,10 +883,10 @@ namespace GridKit template void setAnswerKeyInputs(Fixture& fixture) const { - fixture.input(E::OMEGA) = 0.03; - fixture.input(E::VREF) = 1.05; - fixture.input(E::VS) = 0.04; - fixture.input(E::VUEL) = 0.334; + fixture.input(External::OMEGA) = 0.03; + fixture.input(External::VREF) = 1.05; + fixture.input(External::VS) = 0.04; + fixture.input(External::VUEL) = 0.334; } /// The rich state shared by the residual answer key and the Jacobian @@ -897,23 +897,23 @@ namespace GridKit void setAnswerKeyState(PhasorDynamics::Exciter::Esdc1a& esdc1a) const { setState(esdc1a, - {{I::EFDP, 2.00}, - {I::VC, 0.95}, - {I::VR, 0.45}, - {I::VF, 0.06}, - {I::XLL, 0.30}, - {I::EV, 0.36}, - {I::VLL, 0.33}, - {I::VHV, 0.02}, - {I::SE, 0.09}, - {I::VFE, 0.42}, - {I::EFD, 1.15}}); + {{Internal::EFDP, 2.00}, + {Internal::VC, 0.95}, + {Internal::VR, 0.45}, + {Internal::VF, 0.06}, + {Internal::XLL, 0.30}, + {Internal::EV, 0.36}, + {Internal::VLL, 0.33}, + {Internal::VHV, 0.02}, + {Internal::SE, 0.09}, + {Internal::VFE, 0.42}, + {Internal::EFD, 1.15}}); setDerivative(esdc1a, - {{I::EFDP, 0.01}, - {I::VC, -0.02}, - {I::VR, 0.03}, - {I::VF, -0.04}, - {I::XLL, 0.05}}); + {{Internal::EFDP, 0.01}, + {Internal::VC, -0.02}, + {Internal::VR, 0.03}, + {Internal::VF, -0.04}, + {Internal::XLL, 0.05}}); } /// Omitting every parameter must give exactly the model built from the @@ -966,7 +966,7 @@ namespace GridKit return fixture.esdc1a.verify() > 0; } - template + template bool unlinkedSignalRejected() const { PhasorDynamics::SignalNode unlinked_node; @@ -1048,7 +1048,7 @@ namespace GridKit success &= rowMatches(static_cast(fixture.input(port)), value, "external input", - index(port), + static_cast(port), "changed"); } success *= vectorUnchanged(fixture.esdc1a.y(), y_before, "state"); @@ -1065,7 +1065,7 @@ namespace GridKit auto* y = esdc1a.y().getData(); for (const auto& [variable, value] : rows) { - y[index(variable)] = static_cast(value); + y[static_cast(variable)] = static_cast(value); } esdc1a.y().setDataUpdated(); } @@ -1078,7 +1078,7 @@ namespace GridKit auto* yp = esdc1a.yp().getData(); for (const auto& [variable, value] : rows) { - yp[index(variable)] = static_cast(value); + yp[static_cast(variable)] = static_cast(value); } esdc1a.yp().setDataUpdated(); } @@ -1113,7 +1113,7 @@ namespace GridKit const auto* values = vector.getData(); for (const auto& [variable, expected] : rows) { - const auto row = index(variable); + const auto row = static_cast(variable); success &= rowMatches(static_cast(values[row]), expected, what, row, context); } return success; @@ -1188,7 +1188,7 @@ namespace GridKit { bus_y[i].setVariableNumber(model_size + i); } - for (E port : {E::OMEGA, E::VREF, E::VS, E::VUEL}) + for (External port : {External::OMEGA, External::VREF, External::VS, External::VUEL}) { fixture.input(port).setVariableNumber(fixture.inputIndex(port)); } From ae8f13647e6199cd940271d8ebd4a1fa97da01a3 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Sun, 2 Aug 2026 21:31:59 -0500 Subject: [PATCH 05/18] Lower antiwindup limit (design similar to HYGOV) --- .../PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp | 5 + .../Exciter/ESDC1A/Esdc1aImpl.hpp | 67 +++++++--- .../PhasorDynamics/Exciter/ESDC1A/README.md | 44 ++++--- .../PhasorDynamics/ExciterEsdc1aTests.hpp | 118 +++++++++++++++--- 4 files changed, 180 insertions(+), 54 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp index 94cbaefa5..dd42f55ac 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1a.hpp @@ -127,6 +127,11 @@ namespace GridKit void initializeMonitor(); void setDerivedParameters(); + static __attribute__((always_inline)) inline ScalarT awmin( + ScalarT x, + ScalarT f, + RealT xmin); + /// Recover the input that the smooth CommonMath ramp maps to a /// requested strictly positive output. RealT inverseRamp(RealT ramp_output) const; diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp index c743c7afb..8ae3cc44e 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp @@ -280,11 +280,15 @@ namespace GridKit return 1; } - const ScalarT efdp0 = efd0 / d0; - const ScalarT se0 = SB_ * Math::qramp(efdp0 - SA_); - const ScalarT vfe_drive0 = (Ke_ + se0) * efdp0; - const ScalarT vfe0 = - (ONE - lim_on_) * vfe_drive0 + lim_on_ * Math::ramp(vfe_drive0); + const ScalarT efdp0 = efd0 / d0; + if (exclim_ && efdp0 < ZERO) + { + Log::error() << "Esdc1a: initial Efd' is below its enabled zero limit\n"; + return 1; + } + + const ScalarT se0 = SB_ * Math::qramp(efdp0 - SA_); + const ScalarT vfe0 = (Ke_ + se0) * efdp0; const ScalarT vr0 = vfe0; const ScalarT vhv0 = vr0 / Ka_; @@ -522,22 +526,24 @@ namespace GridKit const ScalarT vs = ws[VS]; const ScalarT vuel = ws[VUEL]; - const ScalarT ec = std::sqrt(wb[0] * wb[0] + wb[1] * wb[1]); - const ScalarT ev_target = vref + vs + uel_on_ * vuel - vc - vf; - const ScalarT vfe_drive = (Ke_ + se) * efdp; - - f[EFDP] = -efdp_dot + (vr - vfe) / Te_; - f[VC] = -vc_dot + (ec - vc) / Tr_; - f[VR] = -vr_dot + Math::antiwindup(vr, -vr + Ka_ * vhv, Vrmin_, Vrmax_) / Ta_; - f[VF] = -vf_dot + (-vf + Kf_ * (vr - vfe) / Te_) / Tf1_; - f[XLL] = -xll_dot + (ev - xll) / Tb_; - f[EV] = -ev + ev_target; - f[VLL] = -vll + xll + (Tc_ / Tb_) * (ev - xll); - f[VHV] = -vhv + uel_on_ * vll + const ScalarT ec = std::sqrt(wb[0] * wb[0] + wb[1] * wb[1]); + const ScalarT ev_target = vref + vs + uel_on_ * vuel - vc - vf; + const ScalarT vfe_target = (Ke_ + se) * efdp; + const ScalarT efdp_rate = (vr - vfe) / Te_; + const ScalarT limited_efdp_rate = awmin(efdp, efdp_rate, ZERO); + + f[EFDP] = -efdp_dot + (ONE - lim_on_) * efdp_rate + + lim_on_ * limited_efdp_rate; + f[VC] = -vc_dot + (ec - vc) / Tr_; + f[VR] = -vr_dot + Math::antiwindup(vr, -vr + Ka_ * vhv, Vrmin_, Vrmax_) / Ta_; + f[VF] = -vf_dot + (-vf + Kf_ * (vr - vfe) / Te_) / Tf1_; + f[XLL] = -xll_dot + (ev - xll) / Tb_; + f[EV] = -ev + ev_target; + f[VLL] = -vll + xll + (Tc_ / Tb_) * (ev - xll); + f[VHV] = -vhv + uel_on_ * vll + (ONE - uel_on_) * Math::max(vll, vuel); f[SE] = -se + SB_ * Math::qramp(efdp - SA_); - f[VFE] = -vfe + (ONE - lim_on_) * vfe_drive - + lim_on_ * Math::ramp(vfe_drive); + f[VFE] = -vfe + vfe_target; f[EFD] = -efd + (ONE + spd_on_ * omega) * efdp; return 0; @@ -547,6 +553,29 @@ namespace GridKit // Private methods // + /** + * @brief Smooth anti-windup derivative above a fixed lower bound + * + * Passes the unconstrained rate above the bound, admits restoring + * motion from below it, and smoothly blocks outward motion. + * + * @param[in] x State limited from below. + * @param[in] f Unconstrained derivative of @p x. + * @param[in] xmin Fixed lower bound on @p x. + * @return Anti-windup-limited derivative. + */ + template + __attribute__((always_inline)) inline scalar_type + Esdc1a::awmin( + const ScalarT x, + const ScalarT f, + const RealT xmin) + { + const ScalarT above = Math::above(x, xmin); + + return (above + (ONE - above) * Math::sigmoid(f)) * f; + } + /** * @brief Read the parameters out of the model data * diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md index 1e27cf1ca..5430911a2 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md @@ -40,7 +40,7 @@ $S_E(E_1)$ | [p.u.] | `Se1` | Saturation coeffic $E_2$ | [p.u.] | `E2` | Second saturation voltage point | 3.7 $S_E(E_2)$ | [p.u.] | `Se2` | Saturation coefficient at $E_2$ | 0.33 $I_{\mathrm{UEL}}$ | [integer] | `UEL` | Under-excitation limiter input-routing selector | 0 -$s_{\mathrm{lim}}$ | [binary] | `exclim` | Exciter feedback lower-limit flag | 1 +$s_{\mathrm{lim}}$ | [binary] | `exclim` | Exciter field-voltage-state lower-limit flag | 1 Every parameter is optional. @@ -170,14 +170,21 @@ $V_{\mathrm{UEL}}$ | [p.u.] | Known | Under-excitation limite ## Model Equations +Define the pre-limit exciter field-voltage rate: + +```math +r_E = \dfrac{V_R-V_{\mathrm{FE}}}{T_E}. +``` + ### Differential Equations ```math \begin{aligned} 0 &= -\dot{E}_{\mathrm{fd}}' - + \dfrac{1}{T_E} - \left(V_R - V_{\mathrm{FE}}\right) \\ + + \left(1-s_{\mathrm{lim}}\right)r_E + + s_{\mathrm{lim}}\, + \text{awmin}\left(E_{\mathrm{fd}}',r_E;0\right) \\ 0 &= -\dot{V}_C + \dfrac{1}{T_R} @@ -210,7 +217,16 @@ $V_{\mathrm{UEL}}$ | [p.u.] | Known | Under-excitation limite ``` CommonMath defines the [`antiwindup`](../../../../CommonMath.md#antiwindup) -target and smooth approximation. +target and smooth approximation. ESDC1A uses its fixed-lower-bound form, + +```math +\text{awmin}(x,f;\ell) + \approx + \left[ + \text{above}(x;\ell) + + \left(1-\text{above}(x;\ell)\right)\sigma(f) + \right]f. +``` ### Algebraic Equations @@ -241,12 +257,7 @@ target and smooth approximation. + S_B q\left(E_{\mathrm{fd}}' - S_A\right) \\ 0 &= -V_{\mathrm{FE}} - + \begin{cases} - \left(K_E + S_E\right)E_{\mathrm{fd}}' - & s_{\mathrm{lim}} = 0 \\ - \rho\!\left(\left(K_E + S_E\right)E_{\mathrm{fd}}'\right) - & s_{\mathrm{lim}} = 1 - \end{cases} \\ + + \left(K_E + S_E\right)E_{\mathrm{fd}}' \\ 0 &= -E_{\mathrm{fd}} + \left(1 + s_{\mathrm{spd}}\omega\right)E_{\mathrm{fd}}' @@ -296,13 +307,7 @@ routed through the gate: S_E &\leftarrow S_B q\left(E_{\mathrm{fd}}' - S_A\right) \\ V_{\mathrm{FE}} - &\leftarrow - \begin{cases} - \left(K_E + S_E\right)E_{\mathrm{fd}}' - & s_{\mathrm{lim}} = 0 \\ - \rho\!\left(\left(K_E + S_E\right)E_{\mathrm{fd}}'\right) - & s_{\mathrm{lim}} = 1 - \end{cases} \\ + &\leftarrow \left(K_E + S_E\right)E_{\mathrm{fd}}' \\ V_R &\leftarrow V_{\mathrm{FE}} \\ V_{\mathrm{HV}} @@ -327,6 +332,7 @@ routed through the gate: ``` Initialization rejects a non-finite bus voltage or field-voltage seed, +$E_{\mathrm{fd}}'<0$ while $s_{\mathrm{lim}}=1$, $1 + s_{\mathrm{spd}}\omega = 0$, $V_R$ outside $[V_R^{\min},V_R^{\max}]$, and high-value-gate active starts with $s_{\mathrm{UEL}} = 0$ and @@ -377,7 +383,7 @@ Output | Units | Description | Note numerical answer key. - `voltageRegulation()` checks the transducer, summing junction, lead-lag, stabilizing feedback, and regulator anti-windup behavior. -- `excitationLimits()` checks high-value-gate routing, saturation, exciter - limiting, and the optional speed multiplier. +- `excitationLimits()` checks high-value-gate routing, saturation, + field-voltage-state limiting, and the optional speed multiplier. - `jacobian()` compares the dependency-tracking and Enzyme Jacobians when Enzyme support is enabled. diff --git a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp index df353f187..e9d77996c 100644 --- a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp @@ -302,6 +302,16 @@ namespace GridKit {External::VUEL, -77.0}}, "zero speed-multiplier denominator"); + // The enabled exciter lower limit rejects a negative field-voltage + // state before initialization writes any storage. + success *= initializationRejectedAtomically(makeData(), + -0.2, + {{External::OMEGA, 0.0}, + {External::VREF, 77.0}, + {External::VS, 77.0}, + {External::VUEL, -0.5}}, + "field-voltage state below zero limit"); + // The seeded field voltage maps to a regulator output above Vrmax. auto limit_data = makeData(); limit_data.parameters[Params::Vrmax] = 0.05; @@ -349,6 +359,24 @@ namespace GridKit success *= vectorUnchanged(invalid_fixture.esdc1a.y(), invalid_y, "state"); success *= vectorUnchanged(invalid_fixture.esdc1a.yp(), invalid_yp, "derivative"); + // The zero boundary is admissible, and disabling the limiter admits + // the same negative seed rejected above. + Fixture zero_boundary(makeData()); + zero_boundary.attachAllInputs(); + zero_boundary.input(External::VUEL) = -0.5; + success *= zero_boundary.initialize(0.0); + success *= (zero_boundary.evaluate() == 0); + success *= allResidualsZero(zero_boundary.esdc1a); + + auto unlimited_data = makeData(); + unlimited_data.parameters[Params::exclim] = false; + Fixture unlimited(unlimited_data); + unlimited.attachAllInputs(); + unlimited.input(External::VUEL) = -0.5; + success *= unlimited.initialize(-0.2); + success *= (unlimited.evaluate() == 0); + success *= allResidualsZero(unlimited.esdc1a); + // A depressed speed input rescales the seed without rejection. auto admissible_speed = makeData(); admissible_speed.parameters[Params::Spdmlt] = true; @@ -520,9 +548,9 @@ namespace GridKit return success.report(__func__); } - /// High-value gate selection, quadratic saturation, the exciter - /// feedback lower limit, and the speed multiplier at driven states - /// with literal expectations. + /// High-value gate selection, quadratic saturation, field-voltage-state + /// limiting, and the speed multiplier at driven states with literal + /// expectations. TestOutcome excitationLimits() { TestStatus success = true; @@ -579,30 +607,88 @@ namespace GridKit success *= (disabled.evaluate() == 0); success *= residualsMatch(disabled.esdc1a, {{Internal::SE, -0.05}}, "saturation disabled"); - // The exciter feedback lower limit clamps a negative feedback drive - // to zero only while enabled. + // The field-voltage-state lower limit blocks outward motion, admits + // restoring motion, and preserves the CommonMath transition at zero. + struct FieldLimitCase + { + const char* label; + bool enabled; + RealT efdp; + RealT vr; + RealT expected; + }; + + const std::array field_limit_cases{{ + {"below zero blocks an outward field-voltage rate", true, -0.2, -0.1, 0.0}, + {"zero retains the smooth lower-limit transition", true, 0.0, -0.1, -0.1}, + {"above zero admits a downward field-voltage rate", true, 0.2, -0.1, -0.2}, + {"below zero admits a restoring field-voltage rate", true, -0.2, 0.1, 0.2}, + {"disabled lower limit admits an outward rate", false, -0.2, -0.1, -0.2}, + }}; + + for (const auto& test_case : field_limit_cases) + { + auto data = makeData(); + data.parameters[Params::exclim] = test_case.enabled; + Fixture limit(data); + limit.attachAllInputs(); + success *= limit.initialize(1.2); + setState(limit.esdc1a, + {{Internal::EFDP, test_case.efdp}, + {Internal::VR, test_case.vr}, + {Internal::VFE, 0.0}}); + setDerivative(limit.esdc1a, {{Internal::EFDP, 0.0}}); + success *= (limit.evaluate() == 0); + success *= residualsMatch(limit.esdc1a, + {{Internal::EFDP, test_case.expected}}, + test_case.label); + } + + // The lower-limit selector does not alter the algebraic exciter + // feedback drive. auto feedback_data = makeData(); feedback_data.parameters[Params::Ke] = -0.2; feedback_data.parameters[Params::Se1] = 0.0; feedback_data.parameters[Params::Se2] = 0.0; - - for (const auto& [limited, expected] : std::array, 2>{{ - {true, 0.0}, - {false, -0.2}, - }}) + for (const bool enabled : {false, true}) { auto data = feedback_data; - data.parameters[Params::exclim] = limited; + data.parameters[Params::exclim] = enabled; Fixture feedback(data); feedback.attachAllInputs(); feedback.input(External::VUEL) = -0.5; success *= feedback.initialize(1.2); - setState(feedback.esdc1a, {{Internal::EFDP, 1.0}, {Internal::SE, 0.0}, {Internal::VFE, 0.0}}); + setState(feedback.esdc1a, + {{Internal::EFDP, 1.0}, {Internal::SE, 0.0}, {Internal::VFE, 0.0}}); success *= (feedback.evaluate() == 0); success *= residualsMatch(feedback.esdc1a, - {{Internal::VFE, expected}}, - limited ? "feedback lower limit engaged" - : "feedback lower limit disabled"); + {{Internal::VFE, -0.2}}, + enabled ? "feedback with lower limit enabled" + : "feedback with lower limit disabled"); + } + + // At the lower-limit transition, pin the assembled alpha = 1 + // field-voltage-state row independently of either Jacobian backend. + { + using DepVar = DependencyTracking::Variable; + + Fixture transition(makeData()); + transition.attachAllInputs(); + success *= transition.initialize(1.2); + setState(transition.esdc1a, + {{Internal::EFDP, 0.0}, {Internal::VR, -0.1}, {Internal::VFE, 0.0}}); + setDerivative(transition.esdc1a, {{Internal::EFDP, 0.0}}); + numberVariables(transition); + success *= (transition.evaluate() == 0); + + const auto& dependencies = + transition.esdc1a.getResidual().getData()[static_cast(Internal::EFDP)].getDependencies(); + const DepVar::DependencyMap expected{{ + {static_cast(Internal::EFDP), -13.0}, + {static_cast(Internal::VR), 1.0}, + {static_cast(Internal::VFE), -1.0}, + }}; + success *= isEqual(dependencies, expected, kJacobianTol); } // The speed multiplier scales the published field voltage only when @@ -1171,7 +1257,6 @@ namespace GridKit Log::setVerbosity(previous_verbosity); } -#ifdef GRIDKIT_ENABLE_ENZYME void numberVariables(Fixture& fixture) const { auto* y = fixture.esdc1a.y().getData(); @@ -1198,6 +1283,7 @@ namespace GridKit fixture.bus.y().setDataUpdated(); } +#ifdef GRIDKIT_ENABLE_ENZYME std::vector dependencyTrackingJacobian( const Data& data, TestStatus& success) const From dd4fce559aa1b7bc6b9ddf2593dfc34e099901ec Mon Sep 17 00:00:00 2001 From: lukelowry Date: Sun, 2 Aug 2026 21:47:30 -0500 Subject: [PATCH 06/18] saturation parameter validation --- .../Exciter/ESDC1A/Esdc1aImpl.hpp | 16 +++- .../PhasorDynamics/Exciter/ESDC1A/README.md | 57 ++++++++---- .../PhasorDynamics/ExciterEsdc1aTests.hpp | 88 +++++++++++++++---- 3 files changed, 124 insertions(+), 37 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp index 8ae3cc44e..174678034 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp @@ -173,8 +173,12 @@ namespace GridKit check(E2_ > ZERO, "E2 must be positive when saturation is enabled"); check(Se1_ > ZERO, "Se1 must be positive when saturation is enabled"); check(Se2_ > ZERO, "Se2 must be positive when saturation is enabled"); - check(E1_ != E2_, "E1 and E2 must differ when saturation is enabled"); - check(Se1_ != Se2_, "Se1 and Se2 must differ when saturation is enabled"); + + const bool saturation_points_are_ordered = + (E2_ > E1_ && Se2_ > Se1_) + || (E2_ < E1_ && Se2_ < Se1_); + check(saturation_points_are_ordered, + "E1/E2 and Se1/Se2 must be ordered consistently"); } if (!signals_.template isAssigned()) @@ -784,9 +788,13 @@ namespace GridKit // A disabled or inconsistent saturation curve keeps the zero fit so // the coefficients stay finite; verify() reports inconsistent data. const bool saturation_enabled = !(Se1_ == ZERO && Se2_ == ZERO); + const bool saturation_points_are_ordered = + (E2_ > E1_ && Se2_ > Se1_) + || (E2_ < E1_ && Se2_ < Se1_); const bool saturation_consistent = - E1_ > ZERO && E2_ > ZERO && E1_ != E2_ - && Se1_ > ZERO && Se2_ > ZERO && Se1_ != Se2_; + E1_ > ZERO && E2_ > ZERO + && Se1_ > ZERO && Se2_ > ZERO + && saturation_points_are_ordered; if (!saturation_enabled || !saturation_consistent) { SA_ = ZERO; diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md index 5430911a2..bf304208c 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md @@ -74,8 +74,8 @@ or define a valid two-point quadratic fit: ```math \begin{aligned} E_1, E_2, S_E(E_1), S_E(E_2) &> 0 \\ - E_1 &\ne E_2 \\ - S_E(E_1) &\ne S_E(E_2) + \left(E_2-E_1\right) + \left[S_E(E_2)-S_E(E_1)\right] &> 0 \end{aligned} ``` @@ -134,7 +134,7 @@ $s_{\mathrm{spd}} = 1$; every other signal input is optional. Unattached `speed` Symbol | Units | Description | Note ------------------------------------|--------|--------------------------------------------------|------ -$E_{\mathrm{fd}}'$ | [p.u.] | Exciter field-voltage state | State 1 in Fig. 1; before the optional speed multiplier +$E_{\mathrm{fd}}'$ | [p.u.] | Exciter field-voltage state | State 1 in Fig. 1; lower bounded at zero when $s_{\mathrm{lim}} = 1$; before the optional speed multiplier $V_C$ | [p.u.] | Filtered terminal-voltage magnitude | State 2 in Fig. 1 $V_R$ | [p.u.] | Voltage-regulator output | State 3 in Fig. 1 $V_F$ | [p.u.] | Stabilizing feedback state | State 4 in Fig. 1 @@ -148,7 +148,7 @@ $e_V$ | [p.u.] | Voltage-error summing output $V_{\mathrm{LL}}$ | [p.u.] | Input lead-lag output | $V_{\mathrm{HV}}$ | [p.u.] | High-value gate output | $S_E$ | [p.u.] | Exciter saturation coefficient | Evaluated at $E_{\mathrm{fd}}'$ -$V_{\mathrm{FE}}$ | [p.u.] | Exciter feedback drive | Lower limited at zero when $s_{\mathrm{lim}} = 1$ +$V_{\mathrm{FE}}$ | [p.u.] | Exciter feedback drive | $E_{\mathrm{fd}}$ | [p.u.] | Field-voltage output | Published through `efd` ### External Variables @@ -173,7 +173,7 @@ $V_{\mathrm{UEL}}$ | [p.u.] | Known | Under-excitation limite Define the pre-limit exciter field-voltage rate: ```math -r_E = \dfrac{V_R-V_{\mathrm{FE}}}{T_E}. +f_E = \dfrac{V_R-V_{\mathrm{FE}}}{T_E}. ``` ### Differential Equations @@ -182,9 +182,9 @@ r_E = \dfrac{V_R-V_{\mathrm{FE}}}{T_E}. \begin{aligned} 0 &= -\dot{E}_{\mathrm{fd}}' - + \left(1-s_{\mathrm{lim}}\right)r_E + + \left(1-s_{\mathrm{lim}}\right)f_E + s_{\mathrm{lim}}\, - \text{awmin}\left(E_{\mathrm{fd}}',r_E;0\right) \\ + \text{awmin}\left(E_{\mathrm{fd}}',f_E;0\right) \\ 0 &= -\dot{V}_C + \dfrac{1}{T_R} @@ -216,17 +216,8 @@ r_E = \dfrac{V_R-V_{\mathrm{FE}}}{T_E}. \end{aligned} ``` -CommonMath defines the [`antiwindup`](../../../../CommonMath.md#antiwindup) -target and smooth approximation. ESDC1A uses its fixed-lower-bound form, - -```math -\text{awmin}(x,f;\ell) - \approx - \left[ - \text{above}(x;\ell) - + \left(1-\text{above}(x;\ell)\right)\sigma(f) - \right]f. -``` +The field-voltage-state limiter uses the fixed-lower-bound anti-windup rule +of [Appendix A](#appendix-a-awmin). ### Algebraic Equations @@ -387,3 +378,33 @@ Output | Units | Description | Note field-voltage-state limiting, and the optional speed multiplier. - `jacobian()` compares the dependency-tracking and Enzyme Jacobians when Enzyme support is enabled. + +## Appendix A: `awmin` + +The exact anti-windup rule at a fixed lower bound $\ell$ is + +```math +\text{awmin}(x,f;\ell) = + \begin{cases} + f & x > \ell \\ + \text{max}(f,0) & x \le \ell + \end{cases} +``` + +Above the bound the unconstrained derivative passes. At or below the bound, +outward motion is blocked and restoring motion is admitted. + +The model evaluates this rule with the following smooth approximation: + +```math +\text{awmin}(x,f;\ell) + \approx + \left[ + \sigma(f) + + \left(1-\sigma(f)\right)\text{above}(x;\ell) + \right]f. +``` + +CommonMath defines the [`above`](../../../../CommonMath.md#derived-functions) +and [`sigmoid`](../../../../CommonMath.md#primitives) targets and smooth +approximations. diff --git a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp index e9d77996c..d080fb4bd 100644 --- a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp @@ -94,6 +94,28 @@ namespace GridKit success *= invalidParameterCase(Params::Se2, 0.08); success *= invalidParameterCase(Params::E1, -1.0); + // Saturation voltage and coefficient pairs must move in the same + // direction; either enumeration direction is otherwise valid. + auto reversed_saturation = makeData(); + reversed_saturation.parameters[Params::E1] = 3.7; + reversed_saturation.parameters[Params::Se1] = 0.33; + reversed_saturation.parameters[Params::E2] = 2.8; + reversed_saturation.parameters[Params::Se2] = 0.08; + Fixture reversed_saturation_fixture(reversed_saturation); + success *= (reversed_saturation_fixture.esdc1a.verify() == 0); + + auto crossed_ascending = makeData(); + crossed_ascending.parameters[Params::Se1] = 0.33; + crossed_ascending.parameters[Params::Se2] = 0.08; + Fixture crossed_ascending_fixture(crossed_ascending); + success *= (crossed_ascending_fixture.esdc1a.verify() > 0); + + auto crossed_descending = makeData(); + crossed_descending.parameters[Params::E1] = 3.7; + crossed_descending.parameters[Params::E2] = 2.8; + Fixture crossed_descending_fixture(crossed_descending); + success *= (crossed_descending_fixture.esdc1a.verify() > 0); + // Integer JSON values are accepted for real parameters; booleans are // not numeric. auto integer_real = makeData(); @@ -581,21 +603,57 @@ namespace GridKit success *= residualsMatch(gate.esdc1a, {{Internal::VHV, test_case.expected}}, test_case.label); } - // Quadratic saturation above and below the fitted knee, then with - // the fit disabled at the same field voltage. - Fixture saturation(makeResidualData()); - saturation.attachAllInputs(); - success *= saturation.initialize(1.2); - setState(saturation.esdc1a, {{Internal::EFDP, 2.0}, {Internal::SE, 0.05}}); - success *= (saturation.evaluate() == 0); - success *= residualsMatch(saturation.esdc1a, - {{Internal::SE, -0.035410196624968436}}, - "saturation above the knee"); - setState(saturation.esdc1a, {{Internal::EFDP, 1.0}}); - success *= (saturation.evaluate() == 0); - success *= residualsMatch(saturation.esdc1a, - {{Internal::SE, -0.05}}, - "saturation below the knee"); + // Both valid point orderings produce the same quadratic curve at the + // supplied points and on either side of the fitted knee. + struct SaturationOrderCase + { + const char* label; + RealT e1; + RealT se1; + RealT e2; + RealT se2; + }; + + const std::array saturation_order_cases{{ + {"ascending saturation points", 2.4, 0.1, 3.2, 0.5}, + {"descending saturation points", 3.2, 0.5, 2.4, 0.1}, + }}; + + for (const auto& test_case : saturation_order_cases) + { + auto data = makeResidualData(); + data.parameters[Params::E1] = test_case.e1; + data.parameters[Params::Se1] = test_case.se1; + data.parameters[Params::E2] = test_case.e2; + data.parameters[Params::Se2] = test_case.se2; + Fixture saturation(data); + saturation.attachAllInputs(); + success *= saturation.initialize(1.2); + + setState(saturation.esdc1a, {{Internal::EFDP, 2.4}, {Internal::SE, 0.0}}); + success *= (saturation.evaluate() == 0); + success *= residualsMatch(saturation.esdc1a, + {{Internal::SE, 0.1}}, + test_case.label); + + setState(saturation.esdc1a, {{Internal::EFDP, 3.2}}); + success *= (saturation.evaluate() == 0); + success *= residualsMatch(saturation.esdc1a, + {{Internal::SE, 0.5}}, + test_case.label); + + setState(saturation.esdc1a, {{Internal::EFDP, 2.0}, {Internal::SE, 0.05}}); + success *= (saturation.evaluate() == 0); + success *= residualsMatch(saturation.esdc1a, + {{Internal::SE, -0.035410196624968436}}, + test_case.label); + + setState(saturation.esdc1a, {{Internal::EFDP, 1.0}}); + success *= (saturation.evaluate() == 0); + success *= residualsMatch(saturation.esdc1a, + {{Internal::SE, -0.05}}, + test_case.label); + } auto disabled_data = makeResidualData(); disabled_data.parameters[Params::Se1] = 0.0; From 12d7b85dc1dabb8486ce03361ea437556e0dd30b Mon Sep 17 00:00:00 2001 From: lukelowry Date: Sun, 2 Aug 2026 21:56:04 -0500 Subject: [PATCH 07/18] ret convention and zero comparison --- .../Exciter/ESDC1A/Esdc1aImpl.hpp | 30 ++++++++++++------- .../PhasorDynamics/Exciter/ESDC1A/README.md | 5 ++-- .../PhasorDynamics/ExciterEsdc1aTests.hpp | 29 ++++++++++++++++-- 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp index 174678034..30f156f07 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp @@ -238,7 +238,8 @@ namespace GridKit const auto VFE = static_cast(Esdc1aInternalVariables::VFE); const auto EFD = static_cast(Esdc1aInternalVariables::EFD); - if (verify() > 0) + bool ret = verify() == 0; + if (!ret) { Log::error() << "Esdc1a: cannot initialize with invalid configuration\n"; return 1; @@ -270,22 +271,28 @@ namespace GridKit const ScalarT vc0 = std::sqrt(Vr() * Vr() + Vi() * Vi()); - if (!std::isfinite(static_cast(efd0)) - || !std::isfinite(static_cast(vc0))) + ret = std::isfinite(static_cast(efd0)) + && std::isfinite(static_cast(vc0)); + if (!ret) { Log::error() << "Esdc1a: initial bus voltage and field-voltage seed must be finite\n"; return 1; } - const ScalarT d0 = ONE + spd_on_ * omega0; - if (d0 == ZERO) + const ScalarT speed_multiplier = ONE + spd_on_ * omega0; + + ret = std::isfinite(static_cast(speed_multiplier)) + && speed_multiplier > ZERO; + if (!ret) { - Log::error() << "Esdc1a: speed multiplier denominator is zero at initialization\n"; + Log::error() << "Esdc1a: speed multiplier must be finite and positive at initialization\n"; return 1; } - const ScalarT efdp0 = efd0 / d0; - if (exclim_ && efdp0 < ZERO) + const ScalarT efdp0 = efd0 / speed_multiplier; + + ret = !exclim_ || efdp0 >= ZERO; + if (!ret) { Log::error() << "Esdc1a: initial Efd' is below its enabled zero limit\n"; return 1; @@ -296,7 +303,8 @@ namespace GridKit const ScalarT vr0 = vfe0; const ScalarT vhv0 = vr0 / Ka_; - if (vr0 < Vrmin_ || vr0 > Vrmax_) + ret = vr0 >= Vrmin_ && vr0 <= Vrmax_; + if (!ret) { Log::error() << "Esdc1a: initialized VR is outside limits\n"; return 1; @@ -308,7 +316,9 @@ namespace GridKit if (uel_on_ == ZERO) { const RealT gate_margin0 = static_cast(vhv0 - vuel0); - if (gate_margin0 <= ZERO) + + ret = gate_margin0 > ZERO; + if (!ret) { Log::error() << "Esdc1a: smooth high-value gate is active at initialization\n"; return 1; diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md index bf304208c..7a5b168b6 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md @@ -323,8 +323,9 @@ routed through the gate: ``` Initialization rejects a non-finite bus voltage or field-voltage seed, -$E_{\mathrm{fd}}'<0$ while $s_{\mathrm{lim}}=1$, -$1 + s_{\mathrm{spd}}\omega = 0$, $V_R$ outside +a non-finite or nonpositive speed multiplier +$1 + s_{\mathrm{spd}}\omega$, $E_{\mathrm{fd}}'<0$ while +$s_{\mathrm{lim}}=1$, $V_R$ outside $[V_R^{\min},V_R^{\max}]$, and high-value-gate active starts with $s_{\mathrm{UEL}} = 0$ and $V_{\mathrm{HV}}\le V_{\mathrm{UEL}}$. diff --git a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp index d080fb4bd..21048c4c7 100644 --- a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp @@ -312,17 +312,28 @@ namespace GridKit noteExpectedLogs("Testing inadmissible ESDC1A initialization points. " "Logged errors are expected."); - // An enabled speed multiplier with omega = -1 zeroes the seed - // denominator. + // An enabled speed multiplier must remain finite and strictly + // positive. Other initialization limits are relaxed so these cases + // isolate that domain. auto speed_data = makeData(); speed_data.parameters[Params::Spdmlt] = true; + speed_data.parameters[Params::exclim] = false; + speed_data.parameters[Params::Vrmin] = -100.0; + speed_data.parameters[Params::Vrmax] = 100.0; success *= initializationRejectedAtomically(speed_data, 1.2, {{External::OMEGA, -1.0}, {External::VREF, 77.0}, {External::VS, 77.0}, {External::VUEL, -77.0}}, - "zero speed-multiplier denominator"); + "zero speed multiplier"); + success *= initializationRejectedAtomically(speed_data, + 1.2, + {{External::OMEGA, -1.1}, + {External::VREF, 77.0}, + {External::VS, 77.0}, + {External::VUEL, -77.0}}, + "negative speed multiplier"); // The enabled exciter lower limit rejects a negative field-voltage // state before initialization writes any storage. @@ -364,6 +375,18 @@ namespace GridKit success *= (nonfinite.esdc1a.initialize() != 0); success *= scalarMatches(nonfinite.input(External::VREF), 77.0, "rejected vref preservation"); + // A non-finite speed multiplier is rejected before any signal is + // published. + Fixture nonfinite_speed(speed_data); + nonfinite_speed.attachAllInputs(77.0); + nonfinite_speed.input(External::OMEGA) = std::numeric_limits::infinity(); + nonfinite_speed.input(External::VUEL) = -77.0; + success *= nonfinite_speed.prepare(1.2); + success *= (nonfinite_speed.esdc1a.initialize() != 0); + success *= scalarMatches(nonfinite_speed.input(External::VREF), + 77.0, + "rejected vref preservation"); + // An invalid configuration is rejected before any state is written. auto invalid_data = makeData(); invalid_data.parameters[Params::Ka] = 0.0; From 3bccf92334211b2d8b1b99935c9b2d21922c8f67 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Sun, 2 Aug 2026 21:58:47 -0500 Subject: [PATCH 08/18] success test oeprator accumulation --- .../PhasorDynamics/ExciterEsdc1aTests.hpp | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp index 21048c4c7..a32c876c8 100644 --- a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp @@ -1100,15 +1100,15 @@ namespace GridKit return false; } - success *= (implicit_defaults.evaluate() == 0); - success *= (explicit_defaults.evaluate() == 0); - success *= vectorUnchanged(implicit_defaults.esdc1a.y(), + success &= (implicit_defaults.evaluate() == 0); + success &= (explicit_defaults.evaluate() == 0); + success &= vectorUnchanged(implicit_defaults.esdc1a.y(), copyVector(explicit_defaults.esdc1a.y()), "documented-default state"); - success *= vectorUnchanged(implicit_defaults.esdc1a.yp(), + success &= vectorUnchanged(implicit_defaults.esdc1a.yp(), copyVector(explicit_defaults.esdc1a.yp()), "documented-default derivative"); - success *= vectorUnchanged(implicit_defaults.esdc1a.getResidual(), + success &= vectorUnchanged(implicit_defaults.esdc1a.getResidual(), copyVector(explicit_defaults.esdc1a.getResidual()), "documented-default residual"); @@ -1116,9 +1116,9 @@ namespace GridKit setAnswerKeyInputs(explicit_defaults); setAnswerKeyState(implicit_defaults.esdc1a); setAnswerKeyState(explicit_defaults.esdc1a); - success *= (implicit_defaults.evaluate() == 0); - success *= (explicit_defaults.evaluate() == 0); - success *= vectorUnchanged(implicit_defaults.esdc1a.getResidual(), + success &= (implicit_defaults.evaluate() == 0); + success &= (explicit_defaults.evaluate() == 0); + success &= vectorUnchanged(implicit_defaults.esdc1a.getResidual(), copyVector(explicit_defaults.esdc1a.getResidual()), "documented-default dynamic residual"); return success; @@ -1209,7 +1209,7 @@ namespace GridKit success = false; } - success *= scalarMatches(fixture.efd(), efd_seed, "rejected efd preservation"); + success &= scalarMatches(fixture.efd(), efd_seed, "rejected efd preservation"); for (const auto& [port, value] : inputs) { success &= rowMatches(static_cast(fixture.input(port)), @@ -1218,8 +1218,8 @@ namespace GridKit static_cast(port), "changed"); } - success *= vectorUnchanged(fixture.esdc1a.y(), y_before, "state"); - success *= vectorUnchanged(fixture.esdc1a.yp(), yp_before, "derivative"); + success &= vectorUnchanged(fixture.esdc1a.y(), y_before, "state"); + success &= vectorUnchanged(fixture.esdc1a.yp(), yp_before, "derivative"); return success; } From 231ba4545e0e51b766387f70042dd7fdd9a9a50a Mon Sep 17 00:00:00 2001 From: lukelowry Date: Sun, 2 Aug 2026 22:19:27 -0500 Subject: [PATCH 09/18] system integratino and basic assembly tests --- .../Model/PhasorDynamics/SystemModelImpl.hpp | 6 +- .../SystemSingleComponentTests.hpp | 61 +++++++++++++++++++ .../UnitTests/PhasorDynamics/SystemTests.hpp | 50 +++++++++++++++ .../runSystemSingleComponentTests.cpp | 1 + .../PhasorDynamics/runSystemTests.cpp | 1 + tests/UnitTests/Utilities/CaseFormatTests.hpp | 51 +++++++++++++++- 6 files changed, 164 insertions(+), 6 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp index ddab38efe..d4c126cd7 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp @@ -351,13 +351,13 @@ namespace GridKit for (const auto& excitedata : data.esdc1a) { - IdxT bus_index = 0; + BusT* bus = nullptr; if (excitedata.buses.contains(Esdc1aBuses::bus)) { - bus_index = excitedata.buses.at(Esdc1aBuses::bus); + bus = getBus(excitedata.buses.at(Esdc1aBuses::bus)); } - auto* exciter = new Esdc1a(getBus(bus_index), excitedata); + auto* exciter = new Esdc1a(bus, excitedata); if (excitedata.signal_inputs.contains(Esdc1aSignalInputs::speed)) { diff --git a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp index 34e5ffa3e..dae9bbb57 100644 --- a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp +++ b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp @@ -118,6 +118,67 @@ namespace GridKit return success.report(__func__); } + /// ESDC1A through the production path: model data to system + /// construction to required bus and field-voltage signal wiring. UEL + /// mode 2 makes zero field voltage an admissible standalone state. + TestOutcome esdc1a() + { + using Data = PhasorDynamics::Exciter::Esdc1aData; + using Buses = typename Data::Buses; + using Outputs = typename Data::SignalOutputs; + using Params = typename Data::Parameters; + using Vars = PhasorDynamics::Exciter::Esdc1aInternalVariables; + + constexpr IdxT bus_id = static_cast(1); + constexpr IdxT efd_id = static_cast(1); + + TestStatus success = true; + + PhasorDynamics::SystemModelData data; + 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(1); + data.signal[0].signal_id = efd_id; + data.signal[0].name = "Field Voltage"; + + Data esdc1a_data; + esdc1a_data.device_class = "Esdc1a"; + esdc1a_data.disambiguation_string = "esdc1a_system"; + esdc1a_data.buses[Buses::bus] = bus_id; + esdc1a_data.parameters[Params::Tr] = static_cast(0.02); + esdc1a_data.parameters[Params::Tb] = static_cast(0.5); + esdc1a_data.parameters[Params::UEL] = static_cast(2); + esdc1a_data.signal_outputs[Outputs::efd] = efd_id; + data.esdc1a.push_back(esdc1a_data); + + PhasorDynamics::SystemModel system(data); + + success *= system.allocate() == 0; + success *= system.initialize() == 0; + success *= system.tagDifferentiable() == 0; + success *= system.evaluateResidual() == 0; + success *= system.evaluateJacobian() == 0; + success *= system.size() == static_cast(Vars::MAXIMUM); + + auto* efd = system.getSignal(efd_id); + success *= efd->linked(); + success *= efd->getVariableIndex() == static_cast(Vars::EFD); + + auto missing_bus_data = data; + missing_bus_data.bus[0].bus_id = static_cast(0); + missing_bus_data.esdc1a[0].buses.clear(); + + PhasorDynamics::SystemModel missing_bus_system(missing_bus_data); + std::cout << "Testing expected ESDC1A missing-bus configuration error.\n"; + success *= missing_bus_system.verify() > 0; + + return success.report(__func__); + } + TestOutcome load() { TestStatus success = true; diff --git a/tests/UnitTests/PhasorDynamics/SystemTests.hpp b/tests/UnitTests/PhasorDynamics/SystemTests.hpp index c0d416828..d158acafc 100644 --- a/tests/UnitTests/PhasorDynamics/SystemTests.hpp +++ b/tests/UnitTests/PhasorDynamics/SystemTests.hpp @@ -13,7 +13,10 @@ #include #include #include +#include #include +#include +#include #include #include #include @@ -159,6 +162,53 @@ namespace GridKit return success.report(__func__); } + /// GENROU seeds the shared field-voltage node before ESDC1A consumes + /// and preserves that value during system initialization. + TestOutcome esdc1aInitializationHandoff() + { + using MachineExternal = PhasorDynamics::GenrouExternalVariables; + using ExciterInternal = PhasorDynamics::Exciter::Esdc1aInternalVariables; + using ExciterParams = PhasorDynamics::Exciter::Esdc1aParameters; + + constexpr RealT kTol = static_cast(1.0e-9); + + TestStatus success = true; + + PhasorDynamics::SystemModel system; + PhasorDynamics::BusInfinite bus( + static_cast(1.0), + static_cast(0.0)); + PhasorDynamics::SignalNode efd; + PhasorDynamics::Genrou machine(&bus); + + PhasorDynamics::Exciter::Esdc1aData exciter_data; + exciter_data.parameters[ExciterParams::Tr] = static_cast(0.02); + exciter_data.parameters[ExciterParams::Tb] = static_cast(0.5); + + PhasorDynamics::Exciter::Esdc1a exciter(&bus, exciter_data); + + machine.getSignals().template attachSignalNode(&efd); + exciter.getSignals().template assignSignalNode(&efd); + + system.addBus(&bus); + system.addComponent(&machine); + system.addComponent(&exciter); + + success *= system.allocate() == 0; + success *= efd.linked(); + success *= system.initialize() == 0; + success *= system.evaluateResidual() == 0; + success *= isEqual(efd.read(), static_cast(1.0), kTol); + + const auto* residual = exciter.getResidual().getData(); + for (IdxT row = 0; row < exciter.size(); ++row) + { + success *= isEqual(residual[row], static_cast(0.0), kTol); + } + + return success.report(__func__); + } + TestOutcome reallocateAfterTopologyChange() { TestStatus success = true; diff --git a/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp b/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp index 2eb8cecb8..ebf13c6fb 100644 --- a/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp @@ -12,6 +12,7 @@ int main() result += test.bus(); result += test.busFault(); result += test.ieeet1(); + result += test.esdc1a(); result += test.load(); result += test.loadZIP(); result += test.regca(); diff --git a/tests/UnitTests/PhasorDynamics/runSystemTests.cpp b/tests/UnitTests/PhasorDynamics/runSystemTests.cpp index f1dd5c778..41c116f47 100644 --- a/tests/UnitTests/PhasorDynamics/runSystemTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runSystemTests.cpp @@ -10,6 +10,7 @@ int main() result += test.constructor(); result += test.composer(); + result += test.esdc1aInitializationHandoff(); result += test.reallocateAfterTopologyChange(); result += test.modelVectorsAliasSystemStorage(); #ifdef GRIDKIT_ENABLE_ENZYME diff --git a/tests/UnitTests/Utilities/CaseFormatTests.hpp b/tests/UnitTests/Utilities/CaseFormatTests.hpp index aa9e18ff0..c939b49a6 100644 --- a/tests/UnitTests/Utilities/CaseFormatTests.hpp +++ b/tests/UnitTests/Utilities/CaseFormatTests.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -183,8 +184,9 @@ namespace GridKit TestOutcome signalParse() { using namespace GridKit::PhasorDynamics; - using BusData = BusData; - using BusType = typename BusData::BusType; + using BusData = BusData; + using BusType = typename BusData::BusType; + using Esdc1aData = Exciter::Esdc1aData; const char data[] = R"({ @@ -204,12 +206,16 @@ namespace GridKit "signals": [ { "signal_id": 1, "name": "Machine Speed Deviation"}, { "signal_id": 2, "name": "Mechanical Power"}, - { "signal_id": 3, "name": "Excitation Field"} + { "signal_id": 3, "name": "Excitation Field"}, + { "signal_id": 4, "name": "Voltage Reference"}, + { "signal_id": 5, "name": "Stabilizer Signal"}, + { "signal_id": 6, "name": "Under-excitation Limiter"} ], "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} }, { "class": "Genrou", "ports": {"bus":1, "speed": 1, "pmech":2, "efd":3}, "id": "DV1", "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, "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": "Tgov1", "ports": {"bus":1, "speed": 1, "pmech":2}, "id": "DV2", "params": {"R":0.05, "T1":0.5,"T2":2.5, "T3":7.5, "Pvmax":0.0, "Pvmin":1.0, "Dt":0.0}}, + { "class": "Esdc1a", "ports": {"bus":1, "speed":1, "vref":4, "vs":5, "vuel":6, "efd":3}, "id": "DV5", "params": {"Tr":0.0, "Ka":40.0, "Ta":0.1, "Tb":0.0, "Tc":0.0, "Vrmax":1.0, "Vrmin":-1.0, "Ke":0.1, "Te":0.5, "Kf":0.05, "Tf1":0.7, "Spdmlt":false, "E1":2.8, "Se1":0.08, "E2":3.7, "Se2":0.33, "UEL":0, "exclim":true}, "mon": ["efd", "vc", "vr", "vf", "se", "vfe"] }, { "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": "BusFault", "ports": {"bus":1}, "id": "1", "params": {"state0": false, "R":0.0, "X":1e-3} } @@ -233,6 +239,7 @@ namespace GridKit success *= result.bus_fault.size() == 1; success *= result.genrou.size() == 1; success *= result.gov.size() == 1; + success *= result.esdc1a.size() == 1; success *= result.loadz.size() == 0; success *= result.exciter.size() == 1; success *= result.sexspti.size() == 1; @@ -259,6 +266,12 @@ namespace GridKit success *= result.signal[1].name == "Mechanical Power"; success *= result.signal[2].signal_id == 3; success *= result.signal[2].name == "Excitation Field"; + success *= result.signal[3].signal_id == 4; + success *= result.signal[3].name == "Voltage Reference"; + success *= result.signal[4].signal_id == 5; + success *= result.signal[4].name == "Stabilizer Signal"; + success *= result.signal[5].signal_id == 6; + success *= result.signal[5].name == "Under-excitation Limiter"; success *= std::get(result.branch[0].parameters[BranchParameters::R]) == 0.0; success *= std::get(result.branch[0].parameters[BranchParameters::X]) == 0.1; @@ -309,6 +322,38 @@ namespace GridKit success *= result.gov[0].signal_outputs[Governor::Tgov1SignalOutputs::pmech] == 2; success *= result.gov[0].disambiguation_string == "DV2"; + success *= std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::Tr]) == 0.0; + success *= std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::Ka]) == 40.0; + success *= std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::Ta]) == 0.1; + success *= std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::Tb]) == 0.0; + success *= std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::Tc]) == 0.0; + success *= std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::Vrmax]) == 1.0; + success *= std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::Vrmin]) == -1.0; + success *= std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::Ke]) == 0.1; + success *= std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::Te]) == 0.5; + success *= std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::Kf]) == 0.05; + success *= std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::Tf1]) == 0.7; + success *= !std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::Spdmlt]); + success *= std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::E1]) == 2.8; + success *= std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::Se1]) == 0.08; + success *= std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::E2]) == 3.7; + success *= std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::Se2]) == 0.33; + success *= std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::UEL]) == 0; + success *= std::get(result.esdc1a[0].parameters[Esdc1aData::Parameters::exclim]); + success *= result.esdc1a[0].buses[Esdc1aData::Buses::bus] == 1; + success *= result.esdc1a[0].signal_inputs[Esdc1aData::SignalInputs::speed] == 1; + success *= result.esdc1a[0].signal_inputs[Esdc1aData::SignalInputs::vref] == 4; + success *= result.esdc1a[0].signal_inputs[Esdc1aData::SignalInputs::vs] == 5; + success *= result.esdc1a[0].signal_inputs[Esdc1aData::SignalInputs::vuel] == 6; + success *= result.esdc1a[0].signal_outputs[Esdc1aData::SignalOutputs::efd] == 3; + success *= result.esdc1a[0].disambiguation_string == "DV5"; + success *= result.esdc1a[0].monitored_variables.contains(Esdc1aData::MonitorableVariables::efd); + success *= result.esdc1a[0].monitored_variables.contains(Esdc1aData::MonitorableVariables::vc); + success *= result.esdc1a[0].monitored_variables.contains(Esdc1aData::MonitorableVariables::vr); + success *= result.esdc1a[0].monitored_variables.contains(Esdc1aData::MonitorableVariables::vf); + success *= result.esdc1a[0].monitored_variables.contains(Esdc1aData::MonitorableVariables::se); + success *= result.esdc1a[0].monitored_variables.contains(Esdc1aData::MonitorableVariables::vfe); + success *= std::get(result.exciter[0].parameters[Exciter::Ieeet1Parameters::Tr]) == 0.0; success *= std::get(result.exciter[0].parameters[Exciter::Ieeet1Parameters::Ka]) == 50.0; success *= std::get(result.exciter[0].parameters[Exciter::Ieeet1Parameters::Ta]) == 0.04; From eb9c9b464259b817f6444783b80cb01a244977a9 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Sun, 2 Aug 2026 22:31:12 -0500 Subject: [PATCH 10/18] esdc1a final touches --- .../Exciter/ESDC1A/Esdc1aData.hpp | 4 +- .../Exciter/ESDC1A/Esdc1aImpl.hpp | 49 ++++--- .../PhasorDynamics/Exciter/ESDC1A/README.md | 19 ++- .../PhasorDynamics/ExciterEsdc1aTests.hpp | 137 +++++++++++++----- 4 files changed, 141 insertions(+), 68 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp index e4587373d..0dfc3bc42 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aData.hpp @@ -29,13 +29,13 @@ namespace GridKit Te, ///< \f$T_E\f$ Exciter time constant [sec] Kf, ///< \f$K_F\f$ Stabilizing feedback gain [p.u.] Tf1, ///< \f$T_{F1}\f$ Stabilizing feedback time constant [sec] - Spdmlt, ///< \f$s_{\mathrm{spd}}\f$ Field-voltage speed-multiplier flag [binary] + Spdmlt, ///< \f$s_{\mathrm{spd}}\f$ Field-voltage speed-multiplier flag [boolean] E1, ///< \f$E_1\f$ First saturation voltage point [p.u.] Se1, ///< \f$S_E(E_1)\f$ Saturation coefficient at \f$E_1\f$ [p.u.] E2, ///< \f$E_2\f$ Second saturation voltage point [p.u.] Se2, ///< \f$S_E(E_2)\f$ Saturation coefficient at \f$E_2\f$ [p.u.] UEL, ///< \f$I_{\mathrm{UEL}}\f$ UEL input-routing selector [integer] - exclim ///< \f$s_{\mathrm{lim}}\f$ Exciter field-voltage-state lower-limit flag [binary] + exclim ///< \f$s_{\mathrm{lim}}\f$ Exciter field-voltage-state lower-limit flag [boolean] }; /// Buses for the ESDC1A exciter model. diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp index 30f156f07..923729617 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp @@ -272,10 +272,21 @@ namespace GridKit const ScalarT vc0 = std::sqrt(Vr() * Vr() + Vi() * Vi()); ret = std::isfinite(static_cast(efd0)) - && std::isfinite(static_cast(vc0)); + && std::isfinite(static_cast(vc0)) + && vc0 > ZERO; if (!ret) { - Log::error() << "Esdc1a: initial bus voltage and field-voltage seed must be finite\n"; + Log::error() << "Esdc1a: initial bus-voltage magnitude must be finite and positive, " + "and the field-voltage seed must be finite\n"; + return 1; + } + + ret = std::isfinite(static_cast(omega0)) + && std::isfinite(static_cast(vs0)) + && std::isfinite(static_cast(vuel0)); + if (!ret) + { + Log::error() << "Esdc1a: initial speed, stabilizer, and UEL inputs must be finite\n"; return 1; } @@ -616,19 +627,31 @@ namespace GridKit } const auto& value = data.parameters.at(key); + RealT parsed_value{}; if (const auto* real_value = std::get_if(&value)) { - target = *real_value; + parsed_value = *real_value; } else if (const auto* index_value = std::get_if(&value)) { - target = static_cast(*index_value); + parsed_value = static_cast(*index_value); } else { Log::error() << "Esdc1a: parameter '" << name << "' must be numeric\n"; ++parameter_error_count_; + return; } + + const bool ret = std::isfinite(parsed_value); + if (!ret) + { + Log::error() << "Esdc1a: parameter '" << name << "' must be finite\n"; + ++parameter_error_count_; + return; + } + + target = parsed_value; }; auto load_switch = [&](auto key, bool& target, const char* name) @@ -643,19 +666,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 if (const auto* real_value = std::get_if(&value); - real_value && (*real_value == ZERO || *real_value == ONE) ) - { - target = (*real_value == ONE); - } else { - Log::error() << "Esdc1a: parameter '" << name << "' must be bool or 0/1\n"; + Log::error() << "Esdc1a: parameter '" << name << "' must be boolean\n"; ++parameter_error_count_; } }; @@ -672,12 +685,6 @@ namespace GridKit { target = *index_value; } - else if (const auto* real_value = std::get_if(&value); - real_value && *real_value >= ZERO - && *real_value == std::round(*real_value)) - { - target = static_cast(std::round(*real_value)); - } else { Log::error() << "Esdc1a: parameter '" << name << "' must be an integer selector\n"; diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md index 7a5b168b6..aed2f0d58 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/README.md @@ -34,15 +34,17 @@ $K_E$ | [p.u.] | `Ke` | Exciter constant $T_E$ | [sec] | `Te` | Exciter time constant | 0.5 $K_F$ | [p.u.] | `Kf` | Stabilizing feedback gain | 0.05 $T_{F1}$ | [sec] | `Tf1` | Stabilizing feedback time constant | 0.7 -$s_{\mathrm{spd}}$ | [binary] | `Spdmlt` | Field-voltage speed-multiplier flag | 0 +$s_{\mathrm{spd}}$ | [boolean] | `Spdmlt` | Field-voltage speed-multiplier flag | `false` $E_1$ | [p.u.] | `E1` | First saturation voltage point | 2.8 $S_E(E_1)$ | [p.u.] | `Se1` | Saturation coefficient at $E_1$ | 0.08 $E_2$ | [p.u.] | `E2` | Second saturation voltage point | 3.7 $S_E(E_2)$ | [p.u.] | `Se2` | Saturation coefficient at $E_2$ | 0.33 $I_{\mathrm{UEL}}$ | [integer] | `UEL` | Under-excitation limiter input-routing selector | 0 -$s_{\mathrm{lim}}$ | [binary] | `exclim` | Exciter field-voltage-state lower-limit flag | 1 +$s_{\mathrm{lim}}$ | [boolean] | `exclim` | Exciter field-voltage-state lower-limit flag | `true` Every parameter is optional. +All real-valued parameters must be finite. `Spdmlt` and `exclim` must be +JSON booleans, and `UEL` must be a JSON integer. ### Parameter Validation @@ -322,9 +324,9 @@ routed through the gate: \end{aligned} ``` -Initialization rejects a non-finite bus voltage or field-voltage seed, -a non-finite or nonpositive speed multiplier -$1 + s_{\mathrm{spd}}\omega$, $E_{\mathrm{fd}}'<0$ while +Initialization rejects a non-finite or zero bus-voltage magnitude, a +non-finite field-voltage seed, non-finite Known signal inputs, a nonpositive +speed multiplier $1 + s_{\mathrm{spd}}\omega$, $E_{\mathrm{fd}}'<0$ while $s_{\mathrm{lim}}=1$, $V_R$ outside $[V_R^{\min},V_R^{\max}]$, and high-value-gate active starts with $s_{\mathrm{UEL}} = 0$ and @@ -364,13 +366,14 @@ Output | Units | Description | Note ## Testing -- `validation()` checks construction, documented defaults, parameter - validation, signal configuration, and minimum time-constant handling. +- `validation()` checks construction, documented defaults, parameter types + and domains, signal configuration, and minimum time-constant handling. - `initializationAndSignals()` checks steady initialization, selector combinations, signal publication and latching, monitor output, and differentiability tags. - `initializationDomain()` checks rejected and accepted field-voltage, - speed-multiplier, regulator-limit, and high-value-gate operating points. + terminal-voltage, Known-input, speed-multiplier, regulator-limit, and + high-value-gate operating points. - `residualEquations()` checks every model residual against a fixed numerical answer key. - `voltageRegulation()` checks the transducer, summing junction, lead-lag, diff --git a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp index a32c876c8..e542dd13d 100644 --- a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp @@ -87,6 +87,7 @@ namespace GridKit success *= invalidParameterCase(Params::Tf1, -0.1); success *= invalidParameterCase(Params::Vrmin, 2.0); success *= invalidParameterCase(Params::UEL, static_cast(4)); + success *= invalidParameterCase(Params::UEL, static_cast(2.0)); success *= invalidParameterCase(Params::UEL, static_cast(2.5)); success *= invalidParameterCase(Params::UEL, true); success *= invalidParameterCase(Params::Se1, 0.0); @@ -94,6 +95,26 @@ namespace GridKit success *= invalidParameterCase(Params::Se2, 0.08); success *= invalidParameterCase(Params::E1, -1.0); + for (const Params parameter : {Params::Tr, + Params::Ka, + Params::Ta, + Params::Tb, + Params::Tc, + Params::Vrmax, + Params::Vrmin, + Params::Ke, + Params::Te, + Params::Kf, + Params::Tf1, + Params::E1, + Params::Se1, + Params::E2, + Params::Se2}) + { + success *= invalidParameterCase(parameter, std::numeric_limits::quiet_NaN()); + success *= invalidParameterCase(parameter, std::numeric_limits::infinity()); + } + // Saturation voltage and coefficient pairs must move in the same // direction; either enumeration direction is otherwise valid. auto reversed_saturation = makeData(); @@ -124,29 +145,24 @@ namespace GridKit success *= (integer_real_fixture.esdc1a.verify() == 0); success *= invalidParameterCase(Params::Ka, true); + // Binary selectors accept JSON booleans only. + auto boolean_switches = makeData(); + boolean_switches.parameters[Params::Spdmlt] = true; + boolean_switches.parameters[Params::exclim] = false; + Fixture boolean_switch_fixture(boolean_switches); + boolean_switch_fixture.attachAllInputs(); + success *= (boolean_switch_fixture.esdc1a.verify() == 0); + for (const Params flag : {Params::Spdmlt, Params::exclim}) { - auto bad_integer = makeData(); - bad_integer.parameters[flag] = static_cast(2); - Fixture bad_integer_fixture(bad_integer); - success *= (bad_integer_fixture.esdc1a.verify() > 0); - - auto bad_real = makeData(); - bad_real.parameters[flag] = static_cast(0.5); - Fixture bad_real_fixture(bad_real); - success *= (bad_real_fixture.esdc1a.verify() > 0); + success *= invalidParameterCase(flag, static_cast(0)); + success *= invalidParameterCase(flag, static_cast(1)); + success *= invalidParameterCase(flag, static_cast(2)); + success *= invalidParameterCase(flag, static_cast(0.0)); + success *= invalidParameterCase(flag, static_cast(0.5)); + success *= invalidParameterCase(flag, static_cast(1.0)); } - // Real-valued 0/1, integer 0/1, and JSON booleans are all accepted - // for the two switches, matching ESDC1A's REPCA-style parameter - // contract. - auto switch_forms = makeData(); - switch_forms.parameters[Params::Spdmlt] = static_cast(1.0); - switch_forms.parameters[Params::exclim] = static_cast(0); - Fixture switch_fixture(switch_forms); - switch_fixture.attachAllInputs(); - success *= (switch_fixture.esdc1a.verify() == 0); - // The enabled speed multiplier requires an attached speed input. auto speed_required = makeData(); speed_required.parameters[Params::Spdmlt] = true; @@ -375,17 +391,43 @@ namespace GridKit success *= (nonfinite.esdc1a.initialize() != 0); success *= scalarMatches(nonfinite.input(External::VREF), 77.0, "rejected vref preservation"); - // A non-finite speed multiplier is rejected before any signal is - // published. - Fixture nonfinite_speed(speed_data); - nonfinite_speed.attachAllInputs(77.0); - nonfinite_speed.input(External::OMEGA) = std::numeric_limits::infinity(); - nonfinite_speed.input(External::VUEL) = -77.0; - success *= nonfinite_speed.prepare(1.2); - success *= (nonfinite_speed.esdc1a.initialize() != 0); - success *= scalarMatches(nonfinite_speed.input(External::VREF), - 77.0, - "rejected vref preservation"); + success *= initializationRejectedAtomically( + speed_data, + 1.2, + {{External::OMEGA, std::numeric_limits::infinity()}, + {External::VREF, 77.0}, + {External::VS, 0.0}, + {External::VUEL, -77.0}}, + "non-finite speed input"); + + success *= initializationRejectedAtomically( + makeData(), + 1.2, + {{External::OMEGA, 0.0}, + {External::VREF, 77.0}, + {External::VS, std::numeric_limits::infinity()}, + {External::VUEL, -0.5}}, + "non-finite stabilizer input"); + + success *= initializationRejectedAtomically( + makeData(), + 1.2, + {{External::OMEGA, 0.0}, + {External::VREF, 77.0}, + {External::VS, 0.0}, + {External::VUEL, -std::numeric_limits::infinity()}}, + "non-finite UEL input"); + + success *= initializationRejectedAtomically( + makeData(), + 1.2, + {{External::OMEGA, 0.0}, + {External::VREF, 77.0}, + {External::VS, 0.0}, + {External::VUEL, -0.5}}, + "zero terminal voltage", + 0.0, + 0.0); // An invalid configuration is rejected before any state is written. auto invalid_data = makeData(); @@ -1165,6 +1207,26 @@ namespace GridKit return success; } + /// An initialization input retains exactly the value supplied by its + /// owner, including signed infinities and NaN. + bool scalarPreserved(RealT actual, + RealT expected, + const char* what, + size_t row) const + { + bool ret = actual == expected; + if (std::isnan(expected)) + { + ret = std::isnan(actual); + } + if (!ret) + { + std::cout << "ESDC1A " << what << " row " << row + << " changed mismatch: " << actual << " != " << expected << "\n"; + } + return ret; + } + /// Fill the state and derivative with a recognizable ramp, then re-seed /// the aliased efd entry, so any write by a rejected initialization /// is visible. @@ -1185,9 +1247,11 @@ namespace GridKit bool initializationRejectedAtomically(const Data& data, RealT efd_seed, const ExternalRows& inputs, - const char* label) const + const char* label, + RealT vr = 0.8, + RealT vi = 0.6) const { - Fixture fixture(data); + Fixture fixture(data, vr, vi); fixture.attachAllInputs(); for (const auto& [port, value] : inputs) { @@ -1212,11 +1276,10 @@ namespace GridKit success &= scalarMatches(fixture.efd(), efd_seed, "rejected efd preservation"); for (const auto& [port, value] : inputs) { - success &= rowMatches(static_cast(fixture.input(port)), - value, - "external input", - static_cast(port), - "changed"); + success &= scalarPreserved(static_cast(fixture.input(port)), + value, + "external input", + static_cast(port)); } success &= vectorUnchanged(fixture.esdc1a.y(), y_before, "state"); success &= vectorUnchanged(fixture.esdc1a.yp(), yp_before, "derivative"); From 418391f65ef847fab4d9fc30612dc846b7fef4b3 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Mon, 3 Aug 2026 10:31:47 -0500 Subject: [PATCH 11/18] Replace ampersand again --- .../PhasorDynamics/ExciterEsdc1aTests.hpp | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp index e542dd13d..9bf2eb252 100644 --- a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp @@ -1134,23 +1134,23 @@ namespace GridKit implicit_defaults.attachAllInputs(); explicit_defaults.attachAllInputs(); - bool success = implicit_defaults.initialize(1.2) - && explicit_defaults.initialize(1.2); + TestStatus success = implicit_defaults.initialize(1.2) + && explicit_defaults.initialize(1.2); if (!success) { std::cout << "ESDC1A documented-default comparison failed to initialize\n"; return false; } - success &= (implicit_defaults.evaluate() == 0); - success &= (explicit_defaults.evaluate() == 0); - success &= vectorUnchanged(implicit_defaults.esdc1a.y(), + success *= (implicit_defaults.evaluate() == 0); + success *= (explicit_defaults.evaluate() == 0); + success *= vectorUnchanged(implicit_defaults.esdc1a.y(), copyVector(explicit_defaults.esdc1a.y()), "documented-default state"); - success &= vectorUnchanged(implicit_defaults.esdc1a.yp(), + success *= vectorUnchanged(implicit_defaults.esdc1a.yp(), copyVector(explicit_defaults.esdc1a.yp()), "documented-default derivative"); - success &= vectorUnchanged(implicit_defaults.esdc1a.getResidual(), + success *= vectorUnchanged(implicit_defaults.esdc1a.getResidual(), copyVector(explicit_defaults.esdc1a.getResidual()), "documented-default residual"); @@ -1158,12 +1158,12 @@ namespace GridKit setAnswerKeyInputs(explicit_defaults); setAnswerKeyState(implicit_defaults.esdc1a); setAnswerKeyState(explicit_defaults.esdc1a); - success &= (implicit_defaults.evaluate() == 0); - success &= (explicit_defaults.evaluate() == 0); - success &= vectorUnchanged(implicit_defaults.esdc1a.getResidual(), + success *= (implicit_defaults.evaluate() == 0); + success *= (explicit_defaults.evaluate() == 0); + success *= vectorUnchanged(implicit_defaults.esdc1a.getResidual(), copyVector(explicit_defaults.esdc1a.getResidual()), "documented-default dynamic residual"); - return success; + return static_cast(success); } template @@ -1198,13 +1198,13 @@ namespace GridKit const std::vector& snapshot, const char* what) const { - bool success = true; + TestStatus 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"); + success *= rowMatches(static_cast(values[i]), snapshot[i], what, i, "changed"); } - return success; + return static_cast(success); } /// An initialization input retains exactly the value supplied by its @@ -1266,24 +1266,24 @@ namespace GridKit const auto y_before = copyVector(fixture.esdc1a.y()); const auto yp_before = copyVector(fixture.esdc1a.yp()); - bool success = true; + TestStatus success = true; if (fixture.esdc1a.initialize() == 0) { std::cout << "Expected initialization rejection: " << label << "\n"; success = false; } - success &= scalarMatches(fixture.efd(), efd_seed, "rejected efd preservation"); + success *= scalarMatches(fixture.efd(), efd_seed, "rejected efd preservation"); for (const auto& [port, value] : inputs) { - success &= scalarPreserved(static_cast(fixture.input(port)), + success *= scalarPreserved(static_cast(fixture.input(port)), value, "external input", static_cast(port)); } - success &= vectorUnchanged(fixture.esdc1a.y(), y_before, "state"); - success &= vectorUnchanged(fixture.esdc1a.yp(), yp_before, "derivative"); - return success; + success *= vectorUnchanged(fixture.esdc1a.y(), y_before, "state"); + success *= vectorUnchanged(fixture.esdc1a.yp(), yp_before, "derivative"); + return static_cast(success); } /// Write state rows and publish the update, folding in the @@ -1339,14 +1339,14 @@ namespace GridKit const char* what, const char* context) const { - bool success = true; + TestStatus success = true; const auto* values = vector.getData(); for (const auto& [variable, expected] : rows) { const auto row = static_cast(variable); - success &= rowMatches(static_cast(values[row]), expected, what, row, context); + success *= rowMatches(static_cast(values[row]), expected, what, row, context); } - return success; + return static_cast(success); } bool residualsMatch(const Esdc1aT& esdc1a, @@ -1368,15 +1368,15 @@ namespace GridKit /// derivative is zero. bool allResidualsZero(const Esdc1aT& esdc1a) const { - bool success = true; + TestStatus success = true; const auto* f = esdc1a.getResidual().getData(); const auto* yp = esdc1a.yp().getData(); for (size_t row = 0; row < static_cast(esdc1a.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"); + success *= rowMatches(static_cast(yp[row]), 0.0, "derivative", row, "at rest"); } - return success; + return static_cast(success); } bool scalarMatches(ScalarT actual, From c49be178ce0f5d9d22508b1ab6af118061ba59a0 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Mon, 3 Aug 2026 11:29:12 -0500 Subject: [PATCH 12/18] TestOutcome consistancy usage --- .../PhasorDynamics/ExciterEsdc1aTests.hpp | 126 ++++++++++++------ 1 file changed, 87 insertions(+), 39 deletions(-) diff --git a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp index 9bf2eb252..88ffca1af 100644 --- a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp @@ -1134,36 +1134,60 @@ namespace GridKit implicit_defaults.attachAllInputs(); explicit_defaults.attachAllInputs(); - TestStatus success = implicit_defaults.initialize(1.2) - && explicit_defaults.initialize(1.2); + bool success = implicit_defaults.initialize(1.2) + && explicit_defaults.initialize(1.2); if (!success) { std::cout << "ESDC1A documented-default comparison failed to initialize\n"; return false; } - success *= (implicit_defaults.evaluate() == 0); - success *= (explicit_defaults.evaluate() == 0); - success *= vectorUnchanged(implicit_defaults.esdc1a.y(), - copyVector(explicit_defaults.esdc1a.y()), - "documented-default state"); - success *= vectorUnchanged(implicit_defaults.esdc1a.yp(), - copyVector(explicit_defaults.esdc1a.yp()), - "documented-default derivative"); - success *= vectorUnchanged(implicit_defaults.esdc1a.getResidual(), - copyVector(explicit_defaults.esdc1a.getResidual()), - "documented-default residual"); + if (implicit_defaults.evaluate() != 0) + { + success = false; + } + if (explicit_defaults.evaluate() != 0) + { + success = false; + } + if (!vectorUnchanged(implicit_defaults.esdc1a.y(), + copyVector(explicit_defaults.esdc1a.y()), + "documented-default state")) + { + success = false; + } + if (!vectorUnchanged(implicit_defaults.esdc1a.yp(), + copyVector(explicit_defaults.esdc1a.yp()), + "documented-default derivative")) + { + success = false; + } + if (!vectorUnchanged(implicit_defaults.esdc1a.getResidual(), + copyVector(explicit_defaults.esdc1a.getResidual()), + "documented-default residual")) + { + success = false; + } setAnswerKeyInputs(implicit_defaults); setAnswerKeyInputs(explicit_defaults); setAnswerKeyState(implicit_defaults.esdc1a); setAnswerKeyState(explicit_defaults.esdc1a); - success *= (implicit_defaults.evaluate() == 0); - success *= (explicit_defaults.evaluate() == 0); - success *= vectorUnchanged(implicit_defaults.esdc1a.getResidual(), - copyVector(explicit_defaults.esdc1a.getResidual()), - "documented-default dynamic residual"); - return static_cast(success); + if (implicit_defaults.evaluate() != 0) + { + success = false; + } + if (explicit_defaults.evaluate() != 0) + { + success = false; + } + if (!vectorUnchanged(implicit_defaults.esdc1a.getResidual(), + copyVector(explicit_defaults.esdc1a.getResidual()), + "documented-default dynamic residual")) + { + success = false; + } + return success; } template @@ -1198,13 +1222,16 @@ namespace GridKit const std::vector& snapshot, const char* what) const { - TestStatus success = true; + 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"); + if (!rowMatches(static_cast(values[i]), snapshot[i], what, i, "changed")) + { + success = false; + } } - return static_cast(success); + return success; } /// An initialization input retains exactly the value supplied by its @@ -1266,24 +1293,36 @@ namespace GridKit const auto y_before = copyVector(fixture.esdc1a.y()); const auto yp_before = copyVector(fixture.esdc1a.yp()); - TestStatus success = true; + bool success = true; if (fixture.esdc1a.initialize() == 0) { std::cout << "Expected initialization rejection: " << label << "\n"; success = false; } - success *= scalarMatches(fixture.efd(), efd_seed, "rejected efd preservation"); + if (!scalarMatches(fixture.efd(), efd_seed, "rejected efd preservation")) + { + success = false; + } for (const auto& [port, value] : inputs) { - success *= scalarPreserved(static_cast(fixture.input(port)), - value, - "external input", - static_cast(port)); + if (!scalarPreserved(static_cast(fixture.input(port)), + value, + "external input", + static_cast(port))) + { + success = false; + } + } + if (!vectorUnchanged(fixture.esdc1a.y(), y_before, "state")) + { + success = false; + } + if (!vectorUnchanged(fixture.esdc1a.yp(), yp_before, "derivative")) + { + success = false; } - success *= vectorUnchanged(fixture.esdc1a.y(), y_before, "state"); - success *= vectorUnchanged(fixture.esdc1a.yp(), yp_before, "derivative"); - return static_cast(success); + return success; } /// Write state rows and publish the update, folding in the @@ -1339,14 +1378,17 @@ namespace GridKit const char* what, const char* context) const { - TestStatus success = true; + bool success = true; const auto* values = vector.getData(); for (const auto& [variable, expected] : rows) { - const auto row = static_cast(variable); - success *= rowMatches(static_cast(values[row]), expected, what, row, context); + const auto row = static_cast(variable); + if (!rowMatches(static_cast(values[row]), expected, what, row, context)) + { + success = false; + } } - return static_cast(success); + return success; } bool residualsMatch(const Esdc1aT& esdc1a, @@ -1368,15 +1410,21 @@ namespace GridKit /// derivative is zero. bool allResidualsZero(const Esdc1aT& esdc1a) const { - TestStatus success = true; + bool success = true; const auto* f = esdc1a.getResidual().getData(); const auto* yp = esdc1a.yp().getData(); for (size_t row = 0; row < static_cast(esdc1a.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"); + if (!rowMatches(static_cast(f[row]), 0.0, "residual", row, "at rest")) + { + success = false; + } + if (!rowMatches(static_cast(yp[row]), 0.0, "derivative", row, "at rest")) + { + success = false; + } } - return static_cast(success); + return success; } bool scalarMatches(ScalarT actual, From d453d864dc1012e7a21eb9c3261f0372176ed892 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Mon, 3 Aug 2026 12:51:40 -0500 Subject: [PATCH 13/18] implementation zero comparisons --- .../Exciter/ESDC1A/Esdc1aImpl.hpp | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp index 923729617..10d263520 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp @@ -167,7 +167,13 @@ namespace GridKit check(UEL_ >= static_cast(0) && UEL_ <= static_cast(3), "UEL must be 0, 1, 2, or 3"); - if (!(Se1_ == ZERO && Se2_ == ZERO) ) + // Saturation is enabled when either coefficient deviates from zero + // in any direction; load_real() has already rejected non-finite + // values, so the sign tests are exact. + auto deviates_from_zero = [](RealT value) + { return value < ZERO || value > ZERO; }; + + if (deviates_from_zero(Se1_) || deviates_from_zero(Se2_)) { check(E1_ > ZERO, "E1 must be positive when saturation is enabled"); check(E2_ > ZERO, "E2 must be positive when saturation is enabled"); @@ -322,9 +328,11 @@ namespace GridKit } // An inactive high-value gate is seeded with the gate input, so the - // residual reproduces VHV through the same smooth maximum. + // residual reproduces VHV through the same smooth maximum. UEL modes + // below 2 route VUEL through the gate; setDerivedParameters() derives + // the uel_on_ blend mask from the same threshold. ScalarT vll0 = vhv0; - if (uel_on_ == ZERO) + if (UEL_ < static_cast(2)) { const RealT gate_margin0 = static_cast(vhv0 - vuel0); @@ -804,7 +812,13 @@ namespace GridKit // A disabled or inconsistent saturation curve keeps the zero fit so // the coefficients stay finite; verify() reports inconsistent data. - const bool saturation_enabled = !(Se1_ == ZERO && Se2_ == ZERO); + // Saturation is enabled when either coefficient deviates from zero, + // matching the verify() predicate. + auto deviates_from_zero = [](RealT value) + { return value < ZERO || value > ZERO; }; + + const bool saturation_enabled = + deviates_from_zero(Se1_) || deviates_from_zero(Se2_); const bool saturation_points_are_ordered = (E2_ > E1_ && Se2_ > Se1_) || (E2_ < E1_ && Se2_ < Se1_); From d707f05406ab28794f7269a0e9192db53e056db0 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Tue, 4 Aug 2026 13:15:45 -0500 Subject: [PATCH 14/18] Move tests and zero comparison --- .../Exciter/ESDC1A/Esdc1aImpl.hpp | 23 ++--- .../PhasorDynamics/CMakeLists.txt | 9 +- .../ComponentConnectionTests.hpp | 87 +++++++++++++++++++ .../runComponentConnectionTests.cpp | 13 +++ .../PhasorDynamics/ExciterEsdc1aTests.hpp | 20 ++--- .../UnitTests/PhasorDynamics/SystemTests.hpp | 50 ----------- .../PhasorDynamics/runSystemTests.cpp | 1 - 7 files changed, 125 insertions(+), 78 deletions(-) create mode 100644 tests/IntegrationTests/PhasorDynamics/ComponentConnectionTests.hpp create mode 100644 tests/IntegrationTests/PhasorDynamics/runComponentConnectionTests.cpp diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp index 10d263520..5dd59ee9e 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp @@ -167,13 +167,12 @@ namespace GridKit check(UEL_ >= static_cast(0) && UEL_ <= static_cast(3), "UEL must be 0, 1, 2, or 3"); - // Saturation is enabled when either coefficient deviates from zero - // in any direction; load_real() has already rejected non-finite - // values, so the sign tests are exact. - auto deviates_from_zero = [](RealT value) - { return value < ZERO || value > ZERO; }; + // Model data uses an exact zero to mean "saturation bypassed", so + // this is an exact comparison by intent rather than a tolerance test. + const bool saturation_disabled = + Se1_ == ZERO && Se2_ == ZERO; - if (deviates_from_zero(Se1_) || deviates_from_zero(Se2_)) + if (!saturation_disabled) { check(E1_ > ZERO, "E1 must be positive when saturation is enabled"); check(E2_ > ZERO, "E2 must be positive when saturation is enabled"); @@ -812,13 +811,9 @@ namespace GridKit // A disabled or inconsistent saturation curve keeps the zero fit so // the coefficients stay finite; verify() reports inconsistent data. - // Saturation is enabled when either coefficient deviates from zero, - // matching the verify() predicate. - auto deviates_from_zero = [](RealT value) - { return value < ZERO || value > ZERO; }; - - const bool saturation_enabled = - deviates_from_zero(Se1_) || deviates_from_zero(Se2_); + // The disabled test matches the verify() predicate exactly. + const bool saturation_disabled = + Se1_ == ZERO && Se2_ == ZERO; const bool saturation_points_are_ordered = (E2_ > E1_ && Se2_ > Se1_) || (E2_ < E1_ && Se2_ < Se1_); @@ -826,7 +821,7 @@ namespace GridKit E1_ > ZERO && E2_ > ZERO && Se1_ > ZERO && Se2_ > ZERO && saturation_points_are_ordered; - if (!saturation_enabled || !saturation_consistent) + if (saturation_disabled || !saturation_consistent) { SA_ = ZERO; SB_ = ZERO; diff --git a/tests/IntegrationTests/PhasorDynamics/CMakeLists.txt b/tests/IntegrationTests/PhasorDynamics/CMakeLists.txt index 34b7b5053..39808db39 100644 --- a/tests/IntegrationTests/PhasorDynamics/CMakeLists.txt +++ b/tests/IntegrationTests/PhasorDynamics/CMakeLists.txt @@ -8,4 +8,11 @@ add_test( COMMAND test_pd_integration WORKING_DIRECTORY ${CMAKE_BINARY_DIR}/examples/PhasorDynamics/Tiny) -install(TARGETS test_pd_integration) +add_executable(test_pd_component_connection runComponentConnectionTests.cpp) +target_link_libraries( + test_pd_component_connection + PRIVATE GridKit::phasor_dynamics_systemmodel GridKit::testing) + +add_test(NAME PhasorDynamicsComponentConnectionTest COMMAND test_pd_component_connection) + +install(TARGETS test_pd_integration test_pd_component_connection) diff --git a/tests/IntegrationTests/PhasorDynamics/ComponentConnectionTests.hpp b/tests/IntegrationTests/PhasorDynamics/ComponentConnectionTests.hpp new file mode 100644 index 000000000..665dccdd3 --- /dev/null +++ b/tests/IntegrationTests/PhasorDynamics/ComponentConnectionTests.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace GridKit +{ + namespace Testing + { + /// Verify that a pair of components wired through a shared signal node + /// resolves that node and agrees on its value across system assembly. + /// These cases exercise component-to-component connections only; the + /// solver-driven cases live in @ref PDIntegrationTests. + template + class ComponentConnectionTests + { + public: + using ScalarT = scalar_type; + using IdxT = index_type; + using RealT = typename PhasorDynamics::Component::RealT; + + ComponentConnectionTests() = default; + ~ComponentConnectionTests() = default; + + // Initialization and evaluateResidual() reassociate the same + // expressions, so a steady state lands within a few ulps of exact zero + // rather than on it. The cases here are exact to within one ulp; 100 + // eps is the shared margin used across the phasor-dynamics suites. + static constexpr RealT kTol = + static_cast(100.0) * std::numeric_limits::epsilon(); + + /// GENROU seeds the shared field-voltage node before ESDC1A consumes + /// and preserves that value during system initialization. + TestOutcome genrouEsdc1a() + { + using MachineExternal = PhasorDynamics::GenrouExternalVariables; + using ExciterInternal = PhasorDynamics::Exciter::Esdc1aInternalVariables; + using ExciterParams = PhasorDynamics::Exciter::Esdc1aParameters; + + TestStatus success = true; + + PhasorDynamics::SystemModel system; + PhasorDynamics::BusInfinite bus( + static_cast(1.0), + static_cast(0.0)); + PhasorDynamics::SignalNode efd; + PhasorDynamics::Genrou machine(&bus); + + PhasorDynamics::Exciter::Esdc1aData exciter_data; + exciter_data.parameters[ExciterParams::Tr] = static_cast(0.02); + exciter_data.parameters[ExciterParams::Tb] = static_cast(0.5); + + PhasorDynamics::Exciter::Esdc1a exciter(&bus, exciter_data); + + machine.getSignals().template attachSignalNode(&efd); + exciter.getSignals().template assignSignalNode(&efd); + + system.addBus(&bus); + system.addComponent(&machine); + system.addComponent(&exciter); + + success *= system.allocate() == 0; + success *= efd.linked(); + success *= system.initialize() == 0; + success *= system.evaluateResidual() == 0; + success *= isEqual(efd.read(), static_cast(1.0), kTol); + + const auto* residual = exciter.getResidual().getData(); + for (IdxT row = 0; row < exciter.size(); ++row) + { + success *= isEqual(residual[row], static_cast(0.0), kTol); + } + + return success.report(__func__); + } + }; + + } // namespace Testing +} // namespace GridKit diff --git a/tests/IntegrationTests/PhasorDynamics/runComponentConnectionTests.cpp b/tests/IntegrationTests/PhasorDynamics/runComponentConnectionTests.cpp new file mode 100644 index 000000000..b9e9253d1 --- /dev/null +++ b/tests/IntegrationTests/PhasorDynamics/runComponentConnectionTests.cpp @@ -0,0 +1,13 @@ +#include + +#include "ComponentConnectionTests.hpp" + +int main() +{ + GridKit::Testing::TestingResults result; + GridKit::Testing::ComponentConnectionTests test; + + result += test.genrouEsdc1a(); + + return result.summary(); +} diff --git a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp index 88ffca1af..881cd48ce 100644 --- a/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ExciterEsdc1aTests.hpp @@ -38,14 +38,10 @@ namespace GridKit ExciterEsdc1aTests() = default; ~ExciterEsdc1aTests() = default; - // ESDC1A initialization seeds the smooth high-value gate through the - // ramp inverse, leaving steady residuals of O(1e-12). Behavioral - // comparisons use a tolerance three orders above that 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; + // Rows that divide by a time constant amplify by 1/T; the + // worst here is VR, which recovers Ka*(Vr/Ka) through 1/Ta at 31 eps. + static constexpr RealT kTol = + static_cast(100.0) * std::numeric_limits::epsilon(); /// Construction and every verify() error class, including parameter /// types, parameter relationships, bus ownership, and signal linkage. @@ -811,7 +807,7 @@ namespace GridKit {static_cast(Internal::VR), 1.0}, {static_cast(Internal::VFE), -1.0}, }}; - success *= isEqual(dependencies, expected, kJacobianTol); + success *= isEqual(dependencies, expected, kTol); } // The speed multiplier scales the published field voltage only when @@ -855,7 +851,7 @@ namespace GridKit 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)) + if (!isEqual(dependency_jacobian[row], enzyme_jacobian[row], kTol)) { std::cout << "ESDC1A Jacobian row " << row << " mismatch between dependency tracking and Enzyme\n"; @@ -1361,7 +1357,7 @@ namespace GridKit size_t row, const char* context) { - if (isEqual(actual, expected, kBehaviorTol)) + if (isEqual(actual, expected, kTol)) { return true; } @@ -1430,7 +1426,7 @@ namespace GridKit bool scalarMatches(ScalarT actual, ScalarT expected, const char* label, - ScalarT tolerance = kBehaviorTol) const + ScalarT tolerance = kTol) const { if (isEqual(actual, expected, tolerance)) { diff --git a/tests/UnitTests/PhasorDynamics/SystemTests.hpp b/tests/UnitTests/PhasorDynamics/SystemTests.hpp index d158acafc..c0d416828 100644 --- a/tests/UnitTests/PhasorDynamics/SystemTests.hpp +++ b/tests/UnitTests/PhasorDynamics/SystemTests.hpp @@ -13,10 +13,7 @@ #include #include #include -#include #include -#include -#include #include #include #include @@ -162,53 +159,6 @@ namespace GridKit return success.report(__func__); } - /// GENROU seeds the shared field-voltage node before ESDC1A consumes - /// and preserves that value during system initialization. - TestOutcome esdc1aInitializationHandoff() - { - using MachineExternal = PhasorDynamics::GenrouExternalVariables; - using ExciterInternal = PhasorDynamics::Exciter::Esdc1aInternalVariables; - using ExciterParams = PhasorDynamics::Exciter::Esdc1aParameters; - - constexpr RealT kTol = static_cast(1.0e-9); - - TestStatus success = true; - - PhasorDynamics::SystemModel system; - PhasorDynamics::BusInfinite bus( - static_cast(1.0), - static_cast(0.0)); - PhasorDynamics::SignalNode efd; - PhasorDynamics::Genrou machine(&bus); - - PhasorDynamics::Exciter::Esdc1aData exciter_data; - exciter_data.parameters[ExciterParams::Tr] = static_cast(0.02); - exciter_data.parameters[ExciterParams::Tb] = static_cast(0.5); - - PhasorDynamics::Exciter::Esdc1a exciter(&bus, exciter_data); - - machine.getSignals().template attachSignalNode(&efd); - exciter.getSignals().template assignSignalNode(&efd); - - system.addBus(&bus); - system.addComponent(&machine); - system.addComponent(&exciter); - - success *= system.allocate() == 0; - success *= efd.linked(); - success *= system.initialize() == 0; - success *= system.evaluateResidual() == 0; - success *= isEqual(efd.read(), static_cast(1.0), kTol); - - const auto* residual = exciter.getResidual().getData(); - for (IdxT row = 0; row < exciter.size(); ++row) - { - success *= isEqual(residual[row], static_cast(0.0), kTol); - } - - return success.report(__func__); - } - TestOutcome reallocateAfterTopologyChange() { TestStatus success = true; diff --git a/tests/UnitTests/PhasorDynamics/runSystemTests.cpp b/tests/UnitTests/PhasorDynamics/runSystemTests.cpp index 41c116f47..f1dd5c778 100644 --- a/tests/UnitTests/PhasorDynamics/runSystemTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runSystemTests.cpp @@ -10,7 +10,6 @@ int main() result += test.constructor(); result += test.composer(); - result += test.esdc1aInitializationHandoff(); result += test.reallocateAfterTopologyChange(); result += test.modelVectorsAliasSystemStorage(); #ifdef GRIDKIT_ENABLE_ENZYME From f68eb835a1296905baa5e61aa7d8c9275af07197 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Tue, 4 Aug 2026 13:52:45 -0500 Subject: [PATCH 15/18] test tolerance clearer --- .../ComponentConnectionTests.hpp | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/tests/IntegrationTests/PhasorDynamics/ComponentConnectionTests.hpp b/tests/IntegrationTests/PhasorDynamics/ComponentConnectionTests.hpp index 665dccdd3..b973b830e 100644 --- a/tests/IntegrationTests/PhasorDynamics/ComponentConnectionTests.hpp +++ b/tests/IntegrationTests/PhasorDynamics/ComponentConnectionTests.hpp @@ -15,10 +15,10 @@ namespace GridKit { namespace Testing { - /// Verify that a pair of components wired through a shared signal node - /// resolves that node and agrees on its value across system assembly. - /// These cases exercise component-to-component connections only; the - /// solver-driven cases live in @ref PDIntegrationTests. + /// Connection tests for pairs of components that share a signal node. + /// Each case checks that the node links both components and that they + /// agree on its value after initialization. Solver-driven cases live + /// in @ref PDIntegrationTests. template class ComponentConnectionTests { @@ -30,15 +30,12 @@ namespace GridKit ComponentConnectionTests() = default; ~ComponentConnectionTests() = default; - // Initialization and evaluateResidual() reassociate the same - // expressions, so a steady state lands within a few ulps of exact zero - // rather than on it. The cases here are exact to within one ulp; 100 - // eps is the shared margin used across the phasor-dynamics suites. - static constexpr RealT kTol = - static_cast(100.0) * std::numeric_limits::epsilon(); + // The tolerance only absorbs floating-point roundoff. + static constexpr RealT kTol = std::numeric_limits::epsilon(); - /// GENROU seeds the shared field-voltage node before ESDC1A consumes - /// and preserves that value during system initialization. + /// GENROU initializes first and writes the field voltage it needs to + /// the shared node. ESDC1A then initializes around that value and + /// must leave it unchanged at a steady state. TestOutcome genrouEsdc1a() { using MachineExternal = PhasorDynamics::GenrouExternalVariables; @@ -71,6 +68,8 @@ namespace GridKit success *= efd.linked(); success *= system.initialize() == 0; success *= system.evaluateResidual() == 0; + + // At zero power the required field voltage equals the terminal voltage. success *= isEqual(efd.read(), static_cast(1.0), kTol); const auto* residual = exciter.getResidual().getData(); From 9a145c1752faf4a8d69b9000ec6e8d86df3cd72a Mon Sep 17 00:00:00 2001 From: Luke Lowery Date: Tue, 4 Aug 2026 15:45:39 -0500 Subject: [PATCH 16/18] Update GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp Co-authored-by: Nicholson Koukpaizan <72402802+nkoukpaizan@users.noreply.github.com> --- .../Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp index 5dd59ee9e..e378113c2 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp @@ -838,9 +838,11 @@ namespace GridKit * * Initialization seeds the inactive high-value gate with the gate * *input*, so the residual reproduces the requested output through the - * same smooth ramp it evaluates. Beyond the softplus width the smooth - * ramp is the identity to double precision, so the output is returned - * unchanged there. + * same smooth ramp it evaluates. + * + * For large positive values, the ramp is effectively equal to the input, so the + * inverse is effectively the output. In that regime this function returns `ramp_output` directly. + * This branching is numerically more robust. * * @param[in] ramp_output Strictly positive requested ramp output. * @return The input the smooth ramp maps to the requested output. From 9734cc1e8dc88944ba02e8c552a45e6b12d7c003 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Tue, 4 Aug 2026 20:46:08 +0000 Subject: [PATCH 17/18] Apply pre-commit fixes --- GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp index e378113c2..11906d3d9 100644 --- a/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Exciter/ESDC1A/Esdc1aImpl.hpp @@ -838,8 +838,8 @@ namespace GridKit * * Initialization seeds the inactive high-value gate with the gate * *input*, so the residual reproduces the requested output through the - * same smooth ramp it evaluates. - * + * same smooth ramp it evaluates. + * * For large positive values, the ramp is effectively equal to the input, so the * inverse is effectively the output. In that regime this function returns `ramp_output` directly. * This branching is numerically more robust. From 86818218f220ebad9cd0196dd6fc4942a1fd43b8 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Tue, 4 Aug 2026 16:00:56 -0500 Subject: [PATCH 18/18] Move connection tests to unit --- tests/IntegrationTests/PhasorDynamics/CMakeLists.txt | 9 +-------- tests/UnitTests/PhasorDynamics/CMakeLists.txt | 9 +++++++++ .../PhasorDynamics/ComponentConnectionTests.hpp | 0 .../PhasorDynamics/runComponentConnectionTests.cpp | 0 4 files changed, 10 insertions(+), 8 deletions(-) rename tests/{IntegrationTests => UnitTests}/PhasorDynamics/ComponentConnectionTests.hpp (100%) rename tests/{IntegrationTests => UnitTests}/PhasorDynamics/runComponentConnectionTests.cpp (100%) diff --git a/tests/IntegrationTests/PhasorDynamics/CMakeLists.txt b/tests/IntegrationTests/PhasorDynamics/CMakeLists.txt index 39808db39..34b7b5053 100644 --- a/tests/IntegrationTests/PhasorDynamics/CMakeLists.txt +++ b/tests/IntegrationTests/PhasorDynamics/CMakeLists.txt @@ -8,11 +8,4 @@ add_test( COMMAND test_pd_integration WORKING_DIRECTORY ${CMAKE_BINARY_DIR}/examples/PhasorDynamics/Tiny) -add_executable(test_pd_component_connection runComponentConnectionTests.cpp) -target_link_libraries( - test_pd_component_connection - PRIVATE GridKit::phasor_dynamics_systemmodel GridKit::testing) - -add_test(NAME PhasorDynamicsComponentConnectionTest COMMAND test_pd_component_connection) - -install(TARGETS test_pd_integration test_pd_component_connection) +install(TARGETS test_pd_integration) diff --git a/tests/UnitTests/PhasorDynamics/CMakeLists.txt b/tests/UnitTests/PhasorDynamics/CMakeLists.txt index 98c03c39d..87950ac62 100644 --- a/tests/UnitTests/PhasorDynamics/CMakeLists.txt +++ b/tests/UnitTests/PhasorDynamics/CMakeLists.txt @@ -160,6 +160,13 @@ target_link_libraries( GridKit::phasor_dynamics_systemmodel_dependency_tracking GridKit::testing) +add_executable(test_phasor_component_connection runComponentConnectionTests.cpp) +target_link_libraries( + test_phasor_component_connection + GridKit::definitions + GridKit::phasor_dynamics_systemmodel + GridKit::testing) + add_test(NAME PhasorDynamicsBusTest COMMAND test_phasor_bus) add_test(NAME PhasorDynamicsBusFaultTest COMMAND test_phasor_bus_fault) add_test(NAME PhasorDynamicsBusToSignalAdapterTest COMMAND test_phasor_bustosignaladapter) @@ -180,6 +187,7 @@ add_test( COMMAND test_phasor_system WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) add_test(NAME PhasorDynamicsSystemSingleComponentTest COMMAND test_phasor_system_single_component) +add_test(NAME PhasorDynamicsComponentConnectionTest COMMAND test_phasor_component_connection) install( TARGETS test_phasor_bus @@ -199,4 +207,5 @@ install( test_phasor_gen_classical test_phasor_system test_phasor_system_single_component + test_phasor_component_connection RUNTIME DESTINATION bin) diff --git a/tests/IntegrationTests/PhasorDynamics/ComponentConnectionTests.hpp b/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp similarity index 100% rename from tests/IntegrationTests/PhasorDynamics/ComponentConnectionTests.hpp rename to tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp diff --git a/tests/IntegrationTests/PhasorDynamics/runComponentConnectionTests.cpp b/tests/UnitTests/PhasorDynamics/runComponentConnectionTests.cpp similarity index 100% rename from tests/IntegrationTests/PhasorDynamics/runComponentConnectionTests.cpp rename to tests/UnitTests/PhasorDynamics/runComponentConnectionTests.cpp