From dc88e0ae4bd5edcff12e76be2ae79f98c3837d05 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Tue, 14 Jul 2026 20:34:58 -0500 Subject: [PATCH 01/17] Docs and Implementation for HYGOV [skip ci] --- CHANGELOG.md | 1 + .../Model/PhasorDynamics/ComponentLibrary.hpp | 1 + .../PhasorDynamics/Governor/CMakeLists.txt | 1 + .../Governor/HYGOV/CMakeLists.txt | 54 ++ .../PhasorDynamics/Governor/HYGOV/Hygov.cpp | 27 + .../PhasorDynamics/Governor/HYGOV/Hygov.hpp | 160 +++++ .../Governor/HYGOV/HygovData.hpp | 104 +++ .../HYGOV/HygovDependencyTracking.cpp | 27 + .../Governor/HYGOV/HygovEnzyme.cpp | 90 +++ .../Governor/HYGOV/HygovImpl.hpp | 584 ++++++++++++++++ .../PhasorDynamics/Governor/HYGOV/README.md | 310 +++++++++ .../Model/PhasorDynamics/Governor/README.md | 1 + GridKit/Model/PhasorDynamics/INPUT_FORMAT.md | 1 + .../Model/PhasorDynamics/SystemModelData.hpp | 3 + .../SystemModelDataJSONParser.hpp | 6 + .../Model/PhasorDynamics/SystemModelImpl.hpp | 36 + docs/Figures/PhasorDynamics/HYGOV/diagram.png | Bin 0 -> 72381 bytes .../PhasorDynamics/Governor/HYGOV/README.md | 6 + .../Model/PhasorDynamics/Governor/README.md | 1 + tests/UnitTests/PhasorDynamics/CMakeLists.txt | 10 + .../PhasorDynamics/GovernorHygovTests.hpp | 627 ++++++++++++++++++ .../PhasorDynamics/runGovernorHygovTests.cpp | 22 + 22 files changed, 2072 insertions(+) create mode 100644 GridKit/Model/PhasorDynamics/Governor/HYGOV/CMakeLists.txt create mode 100644 GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.cpp create mode 100644 GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp create mode 100644 GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovData.hpp create mode 100644 GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovDependencyTracking.cpp create mode 100644 GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovEnzyme.cpp create mode 100644 GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp create mode 100644 GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md create mode 100644 docs/Figures/PhasorDynamics/HYGOV/diagram.png create mode 100644 docs/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md create mode 100644 tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp create mode 100644 tests/UnitTests/PhasorDynamics/runGovernorHygovTests.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index ded783307..ec2882196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ - Added EMT model and operator documentation. - Added `REGCA` converter model implementation for PhasorDynamics. - Remove unnecessary data copying while evaluating `PowerElectronics` models, speeding up large simulations by up to 3x +- Added `HYGOV` governor model implementation for PhasorDynamics. ## v0.1 diff --git a/GridKit/Model/PhasorDynamics/ComponentLibrary.hpp b/GridKit/Model/PhasorDynamics/ComponentLibrary.hpp index 0e110fd67..51c2d78e5 100644 --- a/GridKit/Model/PhasorDynamics/ComponentLibrary.hpp +++ b/GridKit/Model/PhasorDynamics/ComponentLibrary.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include diff --git a/GridKit/Model/PhasorDynamics/Governor/CMakeLists.txt b/GridKit/Model/PhasorDynamics/Governor/CMakeLists.txt index 7c7269784..fbe71c740 100644 --- a/GridKit/Model/PhasorDynamics/Governor/CMakeLists.txt +++ b/GridKit/Model/PhasorDynamics/Governor/CMakeLists.txt @@ -4,3 +4,4 @@ # ]] add_subdirectory(Tgov1) +add_subdirectory(HYGOV) diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/CMakeLists.txt b/GridKit/Model/PhasorDynamics/Governor/HYGOV/CMakeLists.txt new file mode 100644 index 000000000..1719101b0 --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/CMakeLists.txt @@ -0,0 +1,54 @@ +# [[ +# Author(s): +# - Luke Lowery +# ]] + +set(_install_headers Hygov.hpp HygovData.hpp) + +if(GRIDKIT_ENABLE_ENZYME) + gridkit_add_library( + phasor_dynamics_governor_hygov + SOURCES HygovEnzyme.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_governor_hygov + SOURCES Hygov.cpp + HEADERS ${_install_headers} + INCLUDE_DIRECTORIES PRIVATE ${GRIDKIT_THIRD_PARTY_DIR}/magic-enum/include + LINK_LIBRARIES + PUBLIC + GridKit::phasor_dynamics_core + PUBLIC + GridKit::phasor_dynamics_signal) +endif() + +gridkit_add_library( + phasor_dynamics_governor_hygov_dependency_tracking + SOURCES HygovDependencyTracking.cpp + INCLUDE_DIRECTORIES PRIVATE ${GRIDKIT_THIRD_PARTY_DIR}/magic-enum/include + LINK_LIBRARIES + PUBLIC + GridKit::phasor_dynamics_core + PUBLIC + GridKit::phasor_dynamics_signal_dependency_tracking) + +target_link_libraries( + phasor_dynamics_components + INTERFACE GridKit::phasor_dynamics_governor_hygov) +target_link_libraries( + phasor_dynamics_components_dependency_tracking + INTERFACE GridKit::phasor_dynamics_governor_hygov_dependency_tracking) diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.cpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.cpp new file mode 100644 index 000000000..bdb7e15c0 --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.cpp @@ -0,0 +1,27 @@ +/** + * @file Hygov.cpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Non-Enzyme instantiation for the HYGOV governor model. + */ + +#include "HygovImpl.hpp" + +namespace GridKit +{ + namespace PhasorDynamics + { + namespace Governor + { + template + int Hygov::evaluateJacobian() + { + Log::misc() << "Evaluate Jacobian for Hygov..." << std::endl; + Log::misc() << "Jacobian evaluation is not implemented!" << std::endl; + return 0; + } + + template class Hygov; + template class Hygov; + } // namespace Governor + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp new file mode 100644 index 000000000..2fb9d506c --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp @@ -0,0 +1,160 @@ +/** + * @file Hygov.hpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Declaration of the HYGOV governor model. + */ + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace GridKit +{ + namespace PhasorDynamics + { + template + class SignalNode; + + namespace Governor + { + /// Internal variables of a `Hygov`. + enum class HygovInternalVariables : size_t + { + XN, ///< Speed lead-lag denominator state + XF, ///< Governor error filter output + C, ///< Desired-gate position + G, ///< Gate position + Q, ///< Turbine flow + OMEGADB, ///< Deadbanded speed deviation + EF, ///< Governor error into the filter + FC, ///< Desired-gate derivative target + RC, ///< Rate-limited desired-gate derivative target + PGV, ///< Nonlinear gate-to-power curve output + H, ///< Turbine head + PMECH, ///< Mechanical-power output + MAXIMUM, + }; + + /// External variables of a `Hygov`. + enum class HygovExternalVariables : size_t + { + OMEGA, ///< Machine speed deviation + PREF, ///< Active-power/load reference + PAUX, ///< Auxiliary power input + MAXIMUM, + }; + + template + class Hygov : public Component + { + using Component::alpha_; + using Component::abs_tol_; + using Component::allocated_; + using Component::f_; + using Component::gridkit_component_id_; + using Component::J_cols_buffer_; + using Component::J_rows_buffer_; + using Component::J_vals_buffer_; + using Component::nnz_; + using Component::residual_indices_; + using Component::size_; + using Component::tag_; + using Component::va_system_base_; + using Component::variable_indices_; + using Component::wb_; + using Component::y_; + using Component::yp_; + + public: + using ScalarT = scalar_type; + using IdxT = index_type; + using RealT = typename Component::RealT; + using SignalT = SignalNode; + using ModelDataT = HygovData; + using MonitorT = Model::VariableMonitor; + + Hygov(); + Hygov(const ModelDataT& data); + ~Hygov() override; + + int setGridKitComponentID(IdxT) override final; + int allocate() override final; + int verify() const override final; + int initialize() override final; + int tagDifferentiable() override final; + int setAbsoluteTolerance(RealT) override final; + int evaluateResidual() override final; + int evaluateJacobian() override final; + + auto getSignals() + -> ComponentSignals& + { + return signals_; + } + + const Model::VariableMonitorBase* getMonitor() const override; + + __attribute__((always_inline)) inline int evaluateInternalResidual( + const ScalarT*, const ScalarT*, const ScalarT*, const ScalarT*, ScalarT*); + + private: + void initModelParams(const ModelDataT& data); + void initializeMonitor(); + void setDerivedParameters(); + + ScalarT gatePower(ScalarT gate) const; + RealT invertGatePower(RealT pgv) const; + ScalarT toComponentBase(ScalarT value) const; + ScalarT toSystemBase(ScalarT value) const; + + static constexpr RealT TIME_CONSTANT_MINIMUM = static_cast(1.0e-3); + + RealT Trate_{0}; + RealT Rperm_{0}; + RealT Rtemp_{0}; + RealT Tr_{0}; + RealT Tf_{0}; + RealT Tg_{0}; + RealT Velm_{0}; + RealT Gmax_{0}; + RealT Gmin_{0}; + RealT Tw_{0}; + RealT At_{0}; + RealT Dturb_{0}; + RealT Qnl_{0}; + RealT Tn_{0}; + RealT Tnp_{0}; + RealT leadlag_gain_{0}; + RealT db1_{0}; + RealT db2_{0}; + RealT Hdam_{1}; + std::array Gv_{}; + std::array Pgv_{}; + + RealT va_component_base_{0}; + + int parameter_error_count_{0}; + + ScalarT pref_set_{0}; + ScalarT paux_set_{0}; + + ComponentSignals signals_; + std::unique_ptr monitor_; + + std::vector ws_; + std::vector ws_indices_; + }; + } // namespace Governor + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovData.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovData.hpp new file mode 100644 index 000000000..4fdbf04c5 --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovData.hpp @@ -0,0 +1,104 @@ +/** + * @file HygovData.hpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Modeling data for the HYGOV governor model. + */ + +#pragma once + +#include + +namespace GridKit +{ + namespace PhasorDynamics + { + namespace Governor + { + /// Parameter keys for the HYGOV governor model. + enum class HygovParameters + { + Trate, ///< Turbine-rating power base + Rperm, ///< Permanent droop + Rtemp, ///< Temporary droop + Tr, ///< Temporary-droop reset time constant + Tf, ///< Governor error filter time constant + Tg, ///< Gate servo time constant + Velm, ///< Maximum desired-gate velocity magnitude + Gmax, ///< Maximum desired-gate position + Gmin, ///< Minimum desired-gate position + Tw, ///< Water inertia time constant + At, ///< Turbine gain + Dturb, ///< Turbine damping coefficient + Qnl, ///< No-load flow at nominal head + Tn, ///< Speed lead-lag numerator time constant + Tnp, ///< Speed lead-lag denominator time constant + db1, ///< Type 1 speed deadband threshold + db2, ///< Unsupported mechanical backlash deadband + Hdam, ///< Head available at dam + Gv0, ///< Gate point 0 + Gv1, ///< Gate point 1 + Gv2, ///< Gate point 2 + Gv3, ///< Gate point 3 + Gv4, ///< Gate point 4 + Gv5, ///< Gate point 5 + Pgv0, ///< Power point 0 + Pgv1, ///< Power point 1 + Pgv2, ///< Power point 2 + Pgv3, ///< Power point 3 + Pgv4, ///< Power point 4 + Pgv5 ///< Power point 5 + }; + + /// Buses for the HYGOV governor model. + enum class HygovBuses : size_t + { + SIZE + }; + + /// Signal inputs for the HYGOV governor model. + enum class HygovSignalInputs : size_t + { + speed, ///< Machine speed-deviation signal ID + pref, ///< Optional active-power/load reference signal ID + paux, ///< Optional auxiliary power input signal ID + SIZE + }; + + /// Signal outputs for the HYGOV governor model. + enum class HygovSignalOutputs : size_t + { + pmech, ///< Mechanical-power output signal ID + SIZE + }; + + /// Variables available through the monitor interface. + enum class HygovMonitorableVariables + { + pmech, ///< Mechanical power output + filter, ///< Governor error filter output + desiredgate, ///< Desired-gate position + gate, ///< Gate position + flow, ///< Turbine flow + head ///< Turbine head + }; + + template + struct HygovData : public ComponentData + { + HygovData() = default; + + using Parameters = HygovParameters; + using Buses = HygovBuses; + using SignalInputs = HygovSignalInputs; + using SignalOutputs = HygovSignalOutputs; + using MonitorableVariables = HygovMonitorableVariables; + }; + } // namespace Governor + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovDependencyTracking.cpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovDependencyTracking.cpp new file mode 100644 index 000000000..760bac957 --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovDependencyTracking.cpp @@ -0,0 +1,27 @@ +/** + * @file HygovDependencyTracking.cpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Dependency-tracking instantiations for the HYGOV governor model. + */ + +#include "HygovImpl.hpp" + +namespace GridKit +{ + namespace PhasorDynamics + { + namespace Governor + { + template + int Hygov::evaluateJacobian() + { + Log::misc() << "Evaluate Jacobian for Hygov..." << std::endl; + Log::misc() << "Jacobian evaluation is not implemented!" << std::endl; + return 0; + } + + template class Hygov; + template class Hygov; + } // namespace Governor + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovEnzyme.cpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovEnzyme.cpp new file mode 100644 index 000000000..56d71f59b --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovEnzyme.cpp @@ -0,0 +1,90 @@ +/** + * @file HygovEnzyme.cpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Enzyme sparse Jacobian for the HYGOV governor model. + */ + +#include + +#include "HygovImpl.hpp" + +namespace GridKit +{ + namespace PhasorDynamics + { + namespace Governor + { + template + int Hygov::evaluateJacobian() + { + Log::misc() << "Evaluate Jacobian for Hygov..." << std::endl; + Log::misc() << "Jacobian evaluation is experimental!" << std::endl; + + if (J_rows_buffer_ == nullptr) + { + auto size = static_cast(size_); + auto signal_size = static_cast(ws_.size()); + auto buffer_size = 2 * size * 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]; + } + + nnz_ = 0; + + GridKit::Enzyme::Sparse::DfDy, + GridKit::Enzyme::Sparse::MemberFunctions::InternalResidualWithSignal>::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, + GridKit::Enzyme::Sparse::MemberFunctions::InternalResidualWithSignal>::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::DfDws, + GridKit::Enzyme::Sparse::MemberFunctions::InternalResidualWithSignal>::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 Hygov; + template class Hygov; + } // namespace Governor + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp new file mode 100644 index 000000000..30f09e559 --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp @@ -0,0 +1,584 @@ +/** + * @file HygovImpl.hpp + * @author Luke Lowery (lukel@tamu.edu) + * @brief Definition of the HYGOV governor model. + */ + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace GridKit +{ + namespace PhasorDynamics + { + namespace Governor + { + using Log = ::GridKit::Utilities::Logger; + + template + Hygov::Hygov() + { + size_ = static_cast(HygovInternalVariables::MAXIMUM); + } + + template + Hygov::Hygov(const ModelDataT& data) + : monitor_(std::make_unique(data)) + { + initModelParams(data); + initializeMonitor(); + + size_ = static_cast(HygovInternalVariables::MAXIMUM); + } + + template + Hygov::~Hygov() + { + } + + template + void Hygov::setDerivedParameters() + { + va_component_base_ = Trate_ * static_cast(1.0e6); + + Tr_ = std::max(Tr_, TIME_CONSTANT_MINIMUM); + Tf_ = std::max(Tf_, TIME_CONSTANT_MINIMUM); + Tg_ = std::max(Tg_, TIME_CONSTANT_MINIMUM); + Tw_ = std::max(Tw_, TIME_CONSTANT_MINIMUM); + Tnp_ = std::max(Tnp_, TIME_CONSTANT_MINIMUM); + + leadlag_gain_ = Tn_ / Tnp_; + } + + template + scalar_type Hygov::toComponentBase(scalar_type value) const + { + return value * va_system_base_ / va_component_base_; + } + + template + scalar_type Hygov::toSystemBase(scalar_type value) const + { + return value / toComponentBase(static_cast(ONE)); + } + + template + void Hygov::initModelParams(const ModelDataT& data) + { + using Params = typename ModelDataT::Parameters; + + parameter_error_count_ = 0; + + Trate_ = ZERO; + Rperm_ = static_cast(0.04); + Rtemp_ = static_cast(0.3); + Tr_ = static_cast(5.0); + Tf_ = static_cast(0.05); + Tg_ = static_cast(0.5); + Velm_ = static_cast(0.2); + Gmax_ = ONE; + Gmin_ = ZERO; + Tw_ = ONE; + At_ = static_cast(1.2); + Dturb_ = static_cast(0.5); + Qnl_ = static_cast(0.05); + Tn_ = ZERO; + Tnp_ = ZERO; + db1_ = ZERO; + db2_ = ZERO; + Hdam_ = ONE; + Gv_.fill(ZERO); + Pgv_.fill(ZERO); + + 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() << "Hygov: parameter '" << name << "' must be numeric\n"; + ++parameter_error_count_; + } + }; + + auto load_required_real = [&](auto key, RealT& target, const char* name) + { + if (!data.parameters.contains(key)) + { + Log::error() << "Hygov: missing required parameter '" << name << "'\n"; + ++parameter_error_count_; + return; + } + + load_real(key, target, name); + }; + + load_required_real(Params::Trate, Trate_, "Trate"); + load_real(Params::Rperm, Rperm_, "Rperm"); + load_real(Params::Rtemp, Rtemp_, "Rtemp"); + load_real(Params::Tr, Tr_, "Tr"); + load_real(Params::Tf, Tf_, "Tf"); + load_real(Params::Tg, Tg_, "Tg"); + load_real(Params::Velm, Velm_, "Velm"); + load_real(Params::Gmax, Gmax_, "Gmax"); + load_real(Params::Gmin, Gmin_, "Gmin"); + load_real(Params::Tw, Tw_, "Tw"); + load_real(Params::At, At_, "At"); + load_real(Params::Dturb, Dturb_, "Dturb"); + load_real(Params::Qnl, Qnl_, "Qnl"); + load_real(Params::Tn, Tn_, "Tn"); + load_real(Params::Tnp, Tnp_, "Tnp"); + load_real(Params::db1, db1_, "db1"); + load_real(Params::db2, db2_, "db2"); + load_real(Params::Hdam, Hdam_, "Hdam"); + load_real(Params::Gv0, Gv_[0], "Gv0"); + load_real(Params::Gv1, Gv_[1], "Gv1"); + load_real(Params::Gv2, Gv_[2], "Gv2"); + load_real(Params::Gv3, Gv_[3], "Gv3"); + load_real(Params::Gv4, Gv_[4], "Gv4"); + load_real(Params::Gv5, Gv_[5], "Gv5"); + load_real(Params::Pgv0, Pgv_[0], "Pgv0"); + load_real(Params::Pgv1, Pgv_[1], "Pgv1"); + load_real(Params::Pgv2, Pgv_[2], "Pgv2"); + load_real(Params::Pgv3, Pgv_[3], "Pgv3"); + load_real(Params::Pgv4, Pgv_[4], "Pgv4"); + load_real(Params::Pgv5, Pgv_[5], "Pgv5"); + + auto check_nonnegative = [&](RealT value, const char* name) + { + if (value < ZERO) + { + Log::error() << "Hygov: parameter '" << name << "' must be non-negative\n"; + ++parameter_error_count_; + } + }; + + check_nonnegative(Tr_, "Tr"); + check_nonnegative(Tf_, "Tf"); + check_nonnegative(Tg_, "Tg"); + check_nonnegative(Tw_, "Tw"); + check_nonnegative(Tnp_, "Tnp"); + + const bool source_default_curve = + std::all_of(Gv_.begin(), Gv_.end(), [](RealT value) + { return value == ZERO; }) + && std::all_of(Pgv_.begin(), Pgv_.end(), [](RealT value) + { return value == ZERO; }); + if (source_default_curve) + { + Gv_ = {ZERO, + static_cast(0.2), + static_cast(0.4), + static_cast(0.6), + static_cast(0.8), + ONE}; + Pgv_ = Gv_; + } + + setDerivedParameters(); + } + + template + const Model::VariableMonitorBase* Hygov::getMonitor() const + { + return monitor_.get(); + } + + template + void Hygov::initializeMonitor() + { + using Variable = typename ModelDataT::MonitorableVariables; + auto index = [](HygovInternalVariables variable) + { + return static_cast(variable); + }; + + monitor_->set(Variable::pmech, [this, index] + { return y_.getData()[index(HygovInternalVariables::PMECH)]; }); + monitor_->set(Variable::filter, [this, index] + { return y_.getData()[index(HygovInternalVariables::XF)]; }); + monitor_->set(Variable::desiredgate, [this, index] + { return y_.getData()[index(HygovInternalVariables::C)]; }); + monitor_->set(Variable::gate, [this, index] + { return y_.getData()[index(HygovInternalVariables::G)]; }); + monitor_->set(Variable::flow, [this, index] + { return y_.getData()[index(HygovInternalVariables::Q)]; }); + monitor_->set(Variable::head, [this, index] + { return y_.getData()[index(HygovInternalVariables::H)]; }); + } + + template + int Hygov::setGridKitComponentID(IdxT component_id) + { + gridkit_component_id_ = component_id; + return 0; + } + + template + int Hygov::allocate() + { + size_ = static_cast(HygovInternalVariables::MAXIMUM); + auto size = static_cast(size_); + + if (!allocated_) + { + this->allocateVectors(size_); + } + + tag_.assign(size, false); + variable_indices_.resize(size); + residual_indices_.resize(size); + + wb_.clear(); + + auto signal_size = static_cast(HygovExternalVariables::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(HygovInternalVariables::PMECH)], + &(this->getVariableIndex(static_cast(HygovInternalVariables::PMECH)))); + } + + allocated_ = true; + return 0; + } + + template + int Hygov::verify() const + { + int ret = parameter_error_count_; + + auto check = [&](bool condition, const char* message) + { + if (!condition) + { + Log::error() << "Hygov: " << message << '\n'; + ret += 1; + } + }; + + check(Trate_ > ZERO, "Trate must be positive"); + check(Rtemp_ != ZERO, "Rtemp must be nonzero"); + check(Tr_ >= ZERO, "Tr must be non-negative"); + check(Tf_ >= ZERO, "Tf must be non-negative"); + check(Tg_ >= ZERO, "Tg must be non-negative"); + check(Tw_ >= ZERO, "Tw must be non-negative"); + check(Tn_ >= ZERO, "Tn must be non-negative"); + check(Tnp_ >= ZERO, "Tnp must be non-negative"); + check(Velm_ >= ZERO, "Velm must be non-negative"); + check(Gmin_ <= Gmax_, "Gmin must be less than or equal to Gmax"); + check(At_ > ZERO, "At must be positive"); + check(Dturb_ >= ZERO, "Dturb must be non-negative"); + check(db1_ >= ZERO, "db1 must be non-negative"); + check(Hdam_ > ZERO, "Hdam must be positive"); + + for (size_t i = 1; i < Gv_.size(); ++i) + { + check(Gv_[i - 1] < Gv_[i], "Gv points must be strictly increasing"); + check(Pgv_[i - 1] <= Pgv_[i], "Pgv points must be non-decreasing"); + } + + if (signals_.template isAttached() + && !signals_.template isLinked()) + { + Log::error() << "Hygov: omega signal attached with no linked source\n"; + ret += 1; + } + + if (signals_.template isAttached() + && !signals_.template isLinked()) + { + Log::error() << "Hygov: pref signal attached with no linked source\n"; + ret += 1; + } + + if (signals_.template isAttached() + && !signals_.template isLinked()) + { + Log::error() << "Hygov: paux signal attached with no linked source\n"; + ret += 1; + } + + return ret; + } + + template + scalar_type Hygov::gatePower(scalar_type gate) const + { + return ScalarT{Pgv_[0]} + + Math::linseg(gate, Gv_[0], Gv_[1], Pgv_[1] - Pgv_[0]) + + Math::linseg(gate, Gv_[1], Gv_[2], Pgv_[2] - Pgv_[1]) + + Math::linseg(gate, Gv_[2], Gv_[3], Pgv_[3] - Pgv_[2]) + + Math::linseg(gate, Gv_[3], Gv_[4], Pgv_[4] - Pgv_[3]) + + Math::linseg(gate, Gv_[4], Gv_[5], Pgv_[5] - Pgv_[4]); + } + + template + typename Hygov::RealT + Hygov::invertGatePower( + typename Hygov::RealT pgv) const + { + static constexpr RealT tol = static_cast(1.0e-10); + + if (std::abs(pgv - Pgv_[0]) <= tol) + { + return Gv_[0]; + } + + for (size_t i = 0; i < 5; ++i) + { + if (Pgv_[i + 1] <= Pgv_[i]) + { + continue; + } + + if (Pgv_[i] - tol <= pgv && pgv <= Pgv_[i + 1] + tol) + { + const RealT fraction = (pgv - Pgv_[i]) / (Pgv_[i + 1] - Pgv_[i]); + return Gv_[i] + fraction * (Gv_[i + 1] - Gv_[i]); + } + } + + return std::numeric_limits::quiet_NaN(); + } + + template + int Hygov::initialize() + { + if (parameter_error_count_ > 0 || verify() > 0) + { + Log::error() << "Hygov: cannot initialize with invalid configuration\n"; + return 1; + } + + const auto XN = static_cast(HygovInternalVariables::XN); + const auto XF = static_cast(HygovInternalVariables::XF); + const auto C = static_cast(HygovInternalVariables::C); + const auto G = static_cast(HygovInternalVariables::G); + const auto Q = static_cast(HygovInternalVariables::Q); + const auto OMEGADB = static_cast(HygovInternalVariables::OMEGADB); + const auto EF = static_cast(HygovInternalVariables::EF); + const auto FC = static_cast(HygovInternalVariables::FC); + const auto RC = static_cast(HygovInternalVariables::RC); + const auto PGV = static_cast(HygovInternalVariables::PGV); + const auto H = static_cast(HygovInternalVariables::H); + const auto PMECH = static_cast(HygovInternalVariables::PMECH); + + auto* y = y_.getData(); + auto* yp = yp_.getData(); + + ScalarT omega0{ZERO}; + if (signals_.template isAttached()) + { + omega0 = signals_.template readExternalVariable(); + } + + paux_set_ = ScalarT{ZERO}; + if (signals_.template isAttached()) + { + paux_set_ = signals_.template readExternalVariable(); + } + + const ScalarT paux0 = toComponentBase(paux_set_); + const ScalarT pmech0 = toComponentBase(y[PMECH]); + y[H] = Hdam_; + y[Q] = Qnl_ + pmech0 / (At_ * y[H]); + y[PGV] = y[Q] / std::sqrt(y[H]); + + const RealT gate0 = invertGatePower(static_cast(y[PGV])); + if (std::isnan(gate0)) + { + Log::error() << "Hygov: initial Pgv is outside the invertible gate curve\n"; + return 1; + } + + y[G] = gate0; + y[C] = y[G]; + + if (y[C] < Gmin_ || y[C] > Gmax_) + { + Log::error() << "Hygov: initialized gate is outside Gmin/Gmax\n"; + return 1; + } + + y[OMEGADB] = Math::deadband1(omega0, -db1_, db1_); + y[XN] = y[OMEGADB]; + y[XF] = ZERO; + y[EF] = ZERO; + y[FC] = ZERO; + y[RC] = ZERO; + y[PMECH] = toSystemBase(pmech0); + + const ScalarT yomega = y[XN] + leadlag_gain_ * (y[OMEGADB] - y[XN]); + pref_set_ = toSystemBase(y[EF] - paux0 + yomega + Rperm_ * y[C]); + if (signals_.template isAttached()) + { + signals_.template writeExternalVariable(pref_set_); + } + + for (IdxT i = 0; i < size_; ++i) + { + yp[i] = ZERO; + } + + y_.setDataUpdated(); + yp_.setDataUpdated(); + return 0; + } + + template + int Hygov::tagDifferentiable() + { + std::fill(tag_.begin(), tag_.end(), false); + tag_[static_cast(HygovInternalVariables::XN)] = true; + tag_[static_cast(HygovInternalVariables::XF)] = true; + tag_[static_cast(HygovInternalVariables::C)] = true; + tag_[static_cast(HygovInternalVariables::G)] = true; + tag_[static_cast(HygovInternalVariables::Q)] = true; + return 0; + } + + template + int Hygov::setAbsoluteTolerance(RealT rel_tol) + { + abs_tol_.setToConst(static_cast(rel_tol)); + return 0; + } + + template + __attribute__((always_inline)) inline int + Hygov::evaluateInternalResidual( + const ScalarT* y, + const ScalarT* yp, + [[maybe_unused]] const ScalarT* wb, + const ScalarT* ws, + ScalarT* f) + { + const auto XN = static_cast(HygovInternalVariables::XN); + const auto XF = static_cast(HygovInternalVariables::XF); + const auto C = static_cast(HygovInternalVariables::C); + const auto G = static_cast(HygovInternalVariables::G); + const auto Q = static_cast(HygovInternalVariables::Q); + const auto OMEGADB = static_cast(HygovInternalVariables::OMEGADB); + const auto EF = static_cast(HygovInternalVariables::EF); + const auto FC = static_cast(HygovInternalVariables::FC); + const auto RC = static_cast(HygovInternalVariables::RC); + const auto PGV = static_cast(HygovInternalVariables::PGV); + const auto H = static_cast(HygovInternalVariables::H); + const auto PMECH = static_cast(HygovInternalVariables::PMECH); + + const auto OMEGA = static_cast(HygovExternalVariables::OMEGA); + const auto PREF = static_cast(HygovExternalVariables::PREF); + const auto PAUX = static_cast(HygovExternalVariables::PAUX); + + const ScalarT xn = y[XN]; + const ScalarT xf = y[XF]; + const ScalarT c = y[C]; + const ScalarT g = y[G]; + const ScalarT q = y[Q]; + const ScalarT omegadb = y[OMEGADB]; + const ScalarT ef = y[EF]; + const ScalarT fc = y[FC]; + const ScalarT rc = y[RC]; + const ScalarT pgv = y[PGV]; + const ScalarT head = y[H]; + const ScalarT pmech = y[PMECH]; + + const ScalarT omega = ws[OMEGA]; + const ScalarT pref = toComponentBase(ws[PREF]); + const ScalarT paux = toComponentBase(ws[PAUX]); + + const ScalarT yomega = xn + leadlag_gain_ * (omegadb - xn); + + f[XN] = -yp[XN] + (omegadb - xn) / Tnp_; + f[XF] = -yp[XF] + (ef - xf) / Tf_; + f[C] = -yp[C] + Math::antiwindup(c, rc, Gmin_, Gmax_); + f[G] = -yp[G] + (c - g) / Tg_; + f[Q] = -yp[Q] + (Hdam_ - head) / Tw_; + f[OMEGADB] = -omegadb + Math::deadband1(omega, -db1_, db1_); + f[EF] = -ef + pref + paux - yomega - Rperm_ * c; + f[FC] = -fc + (xf / Tr_ + (ef - xf) / Tf_) / Rtemp_; + f[RC] = -rc + Math::clamp(fc, -Velm_, Velm_); + f[PGV] = -pgv + gatePower(g); + f[H] = -q * q + head * pgv * pgv; + f[PMECH] = -toComponentBase(pmech) + At_ * head * (q - Qnl_) - Dturb_ * omega * g; + + return 0; + } + + template + int Hygov::evaluateResidual() + { + const auto OMEGA = static_cast(HygovExternalVariables::OMEGA); + const auto PREF = static_cast(HygovExternalVariables::PREF); + const auto PAUX = static_cast(HygovExternalVariables::PAUX); + + ws_[OMEGA] = ZERO; + ws_[PREF] = pref_set_; + ws_[PAUX] = paux_set_; + std::fill(ws_indices_.begin(), ws_indices_.end(), INVALID_INDEX); + + if (signals_.template isAttached()) + { + ws_[OMEGA] = signals_.template readExternalVariable(); + ws_indices_[OMEGA] = + signals_.template readExternalVariableIndex(); + } + + if (signals_.template isAttached()) + { + ws_[PREF] = signals_.template readExternalVariable(); + ws_indices_[PREF] = + signals_.template readExternalVariableIndex(); + } + + if (signals_.template isAttached()) + { + ws_[PAUX] = signals_.template readExternalVariable(); + ws_indices_[PAUX] = + signals_.template readExternalVariableIndex(); + } + + 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 Governor + } // namespace PhasorDynamics +} // namespace GridKit diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md b/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md new file mode 100644 index 000000000..526ebec1b --- /dev/null +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md @@ -0,0 +1,310 @@ +# **Hydro Turbine-Governor Model (HYGOV)** + +HYGOV is a hydro turbine-governor model with temporary droop, a gate servo, and +a nonlinear single-penstock turbine. + +## Notes + +- Power signals and the `pmech` monitor output are on system base. +- Internal load-reference, gate, flow, and head quantities are on HYGOV + component base. +- HYGOV uses $T^\mathrm{rate}$, loaded from `Trate`, as its component power base. +- PowerWorld uses the connected machine base when `Trate = 0`; GridKit requires + `Trate` to be set explicitly. +- HYGOVD `dbL`/`dbH`, `db2` backlash, and Kaplan blade-servo fields are not + modeled. The `db2` JSON field is accepted only for source-format compatibility. + +## Block Diagram + +Standard HYGOV block diagram. + +![](../../../../../docs/Figures/PhasorDynamics/HYGOV/diagram.png) + +Figure 1: HYGOV block diagram. Figure courtesy of [PowerWorld](https://www.powerworld.com/WebHelp/) + +## Model Parameters + +Symbol | Units | JSON | Description | Typical Value | Note +--------------------------------|----------|----------|----------------------------------------------|---------------|------ +$T^\mathrm{rate}$ | [MW] | `Trate` | Turbine-rating power base | 100.0 | Required positive value +$R_{\mathrm{perm}}$ | [p.u.] | `Rperm` | Permanent droop | 0.04 | Source diagram label: `R` +$R_{\mathrm{temp}}$ | [p.u.] | `Rtemp` | Temporary droop | 0.3 | Source diagram label: `r` +$T_r$ | [sec] | `Tr` | Temporary-droop reset time constant | 5.0 | +$T_f$ | [sec] | `Tf` | Governor error filter time constant | 0.05 | State 1 +$T_g$ | [sec] | `Tg` | Gate servo time constant | 0.5 | State 3 +$V_{\mathrm{elm}}$ | [p.u./s] | `Velm` | Maximum desired-gate velocity magnitude | 0.2 | Symmetric rate limit on State 2 +$G^{\max}$ | [p.u.] | `Gmax` | Maximum desired-gate position | 1.0 | +$G^{\min}$ | [p.u.] | `Gmin` | Minimum desired-gate position | 0.0 | +$T_w$ | [sec] | `Tw` | Water inertia time constant | 1.0 | State 4 +$A_t$ | [p.u.] | `At` | Turbine gain | 1.2 | +$D_{\mathrm{turb}}$ | [p.u.] | `Dturb` | Turbine damping coefficient | 0.5 | Multiplied by speed deviation and gate +$q_{\mathrm{NL}}$ | [p.u.] | `Qnl` | No-load flow at nominal head | 0.05 | +$T_n$ | [sec] | `Tn` | Speed lead-lag numerator time constant | 0.0 | +$T_{np}$ | [sec] | `Tnp` | Speed lead-lag denominator time constant | 0.0 | +$D_{\omega}$ | [p.u.] | `db1` | Type 1 speed deadband threshold | 0.0 | Uses CommonMath `deadband1` +$H_{\mathrm{dam}}$ | [p.u.] | `Hdam` | Head available at dam | 1.0 | +$G_V^{(k)}$ | [p.u.] | `Gv0`-`Gv5` | Gate point $k$ of the gain curve | 0.0 | $k=0,\ldots,5$ +$P_{\mathrm{GV}}^{(k)}$ | [p.u.] | `Pgv0`-`Pgv5` | Power point $k$ of the gain curve | 0.0 | $k=0,\ldots,5$ + +All-zero `Gv` and `Pgv` source points select the identity curve. + +### Parameter Validation + +Invalid HYGOV parameter sets are rejected by the following checks. The +displayed equations use effective time constants with $\epsilon_T=10^{-3}$. + +```math +\begin{aligned} + T &\leftarrow \max\!\left(T, \epsilon_T\right) + \quad T\in\{T_r,T_f,T_g,T_w,T_{np}\} \\ + T^\mathrm{rate}, H_{\mathrm{dam}}, A_t + &> 0 \\ + T_r, T_f, T_g, T_w, T_n, T_{np} + &\ge 0 \\ + R_{\mathrm{temp}} + &\ne 0 \\ + V_{\mathrm{elm}}, D_{\mathrm{turb}}, D_{\omega} + &\ge 0 \\ + G^{\min} + &\le G^{\max} \\ + G_V^{(k)} + &< G_V^{(k+1)} + \quad k\in\{0,\ldots,4\} \\ + P_{\mathrm{GV}}^{(k)} + &\le P_{\mathrm{GV}}^{(k+1)} + \quad k\in\{0,\ldots,4\} +\end{aligned} +``` + +Initialization also requires $N_{\mathrm{GV}}^{-1}$ to be single-valued at the +initial operating point. + +### Model Derived Parameters + +```math +\begin{aligned} + k_{\mathrm{base}} + &= \dfrac{S^\mathrm{sys}}{T^\mathrm{rate}} \\ + k_n + &= \dfrac{T_n}{T_{np}} \\ + N_{\mathrm{GV}}(x) + &= + P_{\mathrm{GV}}^{(0)} + + \sum_{k\in\{0,\ldots,4\}} + \text{linseg}\!\left( + x;\, + G_V^{(k)},\, + G_V^{(k+1)},\, + P_{\mathrm{GV}}^{(k+1)} - P_{\mathrm{GV}}^{(k)} + \right) +\end{aligned} +``` + +CommonMath defines the [linear segment](../../../../CommonMath.md#linseg) +helper used by $N_{\mathrm{GV}}$. + +## Model Ports + +Name | Port | Init | Description +--------|--------|---------|------ +`speed` | Input | Known | Machine speed deviation +`pref` | Input | Unknown | Active-power/load reference +`paux` | Input | Known | Auxiliary power input +`pmech` | Output | Unknown | Mechanical power output + +## Model Variables + +### Internal Variables + +#### Differential + +Symbol | Units | Description | Note +------------------------|--------|-------------------------------------|------ +$x_n$ | [p.u.] | Speed lead-lag denominator state | Not circled in Fig. 1; realizes the `Tn`/`Tnp` block +$x_f$ | [p.u.] | Governor error filter output | State 1 in Fig. 1 +$c$ | [p.u.] | Desired-gate position | State 2 in Fig. 1 +$g$ | [p.u.] | Gate position | State 3 in Fig. 1 +$q$ | [p.u.] | Turbine flow | State 4 in Fig. 1 + +#### Algebraic + +Symbol | Units | Description | Note +--------------------------------|----------|-------------------------------------|------ +$\omega_{\mathrm{db}}$ | [p.u.] | Type 1 deadbanded speed deviation | Defined by CommonMath `deadband1` +$e_f$ | [p.u.] | Governor error into the filter | Reference path less conditioned speed and permanent-droop feedback +$f_c$ | [p.u./s] | Desired-gate derivative target | Before rate and position limits +$r_c$ | [p.u./s] | Rate-limited desired-gate derivative target | Limited by $\pm V_{\mathrm{elm}}$ +$P_{\mathrm{GV}}$ | [p.u.] | Nonlinear gate-to-power curve output | $N_{\mathrm{GV}}(g)$ +$H$ | [p.u.] | Turbine head | Implicit water-column head +$P_{\text{m}}$ | [p.u.] | Mechanical power to generator | System base; assigned to `pmech` + +### External Variables + +#### Differential +None. + +#### Algebraic + +Symbol | Units | Description | Note +--------------------------------|--------|-----------------------------|------ +$\omega$ | [p.u.] | Machine speed deviation | Defaults to zero +$P^\mathrm{ref}$ | [p.u.] | Active-power/load reference | System base +$P^\mathrm{aux}$ | [p.u.] | Auxiliary power input | System base; defaults to zero + +## Model Equations + +### Differential Equations + +The lag residuals are written in Hessenberg form using the effective time +constants defined in [Parameter Validation](#parameter-validation). + +```math +\begin{aligned} + 0 &= + -\dot{x}_n + + \dfrac{1}{T_{np}} + \left(\omega_{\mathrm{db}} - x_n\right) \\ + 0 &= + -\dot{x}_f + + \dfrac{1}{T_f} + \left(e_f - x_f\right) \\ + 0 &= + -\dot{c} + + \text{antiwindup} + \left(c, r_c;\, G^{\min}, G^{\max}\right) \\ + 0 &= + -\dot{g} + + \dfrac{1}{T_g} + \left(c - g\right) \\ + 0 &= + -\dot{q} + + \dfrac{1}{T_w} + \left(H_{\mathrm{dam}} - H\right) +\end{aligned} +``` + +CommonMath defines the [Anti-Windup](../../../../CommonMath.md#anti-windup-indicator) +target and smooth approximation. + +### Algebraic Equations + +```math +\begin{aligned} + 0 &= + -\omega_{\mathrm{db}} + + \text{deadband1} + \left(\omega;\, -D_{\omega}, D_{\omega}\right) \\ + 0 &= + -e_f + + k_{\mathrm{base}}P^\mathrm{ref} + + k_{\mathrm{base}}P^\mathrm{aux} + - x_n + - k_n\left(\omega_{\mathrm{db}} - x_n\right) + - R_{\mathrm{perm}}c \\ + 0 &= + -f_c + + \dfrac{1}{R_{\mathrm{temp}}} + \left[ + \dfrac{x_f}{T_r} + + \dfrac{e_f - x_f}{T_f} + \right] \\ + 0 &= + -r_c + + \text{clamp} + \left(f_c;\, -V_{\mathrm{elm}}, V_{\mathrm{elm}}\right) \\ + 0 &= + -P_{\mathrm{GV}} + + N_{\mathrm{GV}}(g) \\ + 0 &= + -q^2 + + H P_{\mathrm{GV}}^2 \\ + 0 &= + -k_{\mathrm{base}}P_{\text{m}} + + A_t H\left(q - q_{\mathrm{NL}}\right) + - D_{\mathrm{turb}}\omega g +\end{aligned} +``` + +CommonMath defines helper targets and smooth approximations for +[deadband1](../../../../CommonMath.md#deadband1) and +[clamp](../../../../CommonMath.md#clamp). + +## Initialization + +### Input Initialization + +```math +\begin{aligned} + \omega + &\leftarrow \text{machine speed deviation} \\ + P_{\text{m}} + &\leftarrow \text{machine mechanical-power start on system base} \\ + P^\mathrm{aux} + &\leftarrow \text{auxiliary power input on system base} +\end{aligned} +``` + +### Internal Initialization + +Initialization is performed by evaluating the steady-state residuals in +dependency order: + +```math +\begin{aligned} + H + &= H_{\mathrm{dam}} \\ + q + &= q_{\mathrm{NL}} + + \dfrac{k_{\mathrm{base}}P_{\text{m},0}}{A_tH_0} \\ + P_{\mathrm{GV}} + &= \dfrac{q_0}{\sqrt{H_0}} \\ + g + &= N_{\mathrm{GV}}^{-1}\!\left(P_{\mathrm{GV},0}\right) \\ + c + &= g_0 \\ + \omega_{\mathrm{db}} + &= \text{deadband1}\!\left(\omega_0;\, -D_{\omega}, D_{\omega}\right) \\ + x_n + &= \omega_{\mathrm{db},0} \\ + x_f + &= 0 \\ + e_f + &= 0 \\ + f_c + &= 0 \\ + r_c + &= 0 +\end{aligned} +``` + +### Output Initialization + +```math +\begin{aligned} + P^\mathrm{ref} + &\leftarrow + \dfrac{1}{k_{\mathrm{base}}} + \left[ + e_{f,0} + - k_{\mathrm{base}}P^\mathrm{aux}_0 + + x_{n,0} + + k_n\left(\omega_{\mathrm{db},0} - x_{n,0}\right) + + R_{\mathrm{perm}}c_0 + \right] +\end{aligned} +``` + +HYGOV writes the resolved active-power/load reference to an attached `pref` +signal input. If no controller is connected, that value is used as a constant +reference input. + +## Monitorable Outputs + +Output | Units | Description | Note +---------------|--------|-------------------------------------|------ +`pmech` | [p.u.] | Mechanical-power output | $P_{\text{m}}$ (system base) +`filter` | [p.u.] | Governor error filter output | $x_f$ +`desiredgate` | [p.u.] | Desired-gate position | $c$ +`gate` | [p.u.] | Gate position | $g$ +`flow` | [p.u.] | Turbine flow | $q$ +`head` | [p.u.] | Turbine head | $H$ diff --git a/GridKit/Model/PhasorDynamics/Governor/README.md b/GridKit/Model/PhasorDynamics/Governor/README.md index fc46e5eb3..6d3983adb 100644 --- a/GridKit/Model/PhasorDynamics/Governor/README.md +++ b/GridKit/Model/PhasorDynamics/Governor/README.md @@ -9,5 +9,6 @@ A governor models the control system that regulates the output power of a machin There are a few standard Governor models - Turbine Governor (See [TGOV1](Tgov1/README.md)) +- Hydro Turbine Governor (See [HYGOV](HYGOV/README.md)) - IEEE Type G1 Turbine Governor (See [IEEEG1](IEEEG1/README.md)) - General Governor (See [GGOV1](GGOV1/README.md)) diff --git a/GridKit/Model/PhasorDynamics/INPUT_FORMAT.md b/GridKit/Model/PhasorDynamics/INPUT_FORMAT.md index 0ebbcd7ba..7a8fa915f 100644 --- a/GridKit/Model/PhasorDynamics/INPUT_FORMAT.md +++ b/GridKit/Model/PhasorDynamics/INPUT_FORMAT.md @@ -153,6 +153,7 @@ are specified: [GenClassical](SynchronousMachine/GenClassical/README.md) | the classical machine model [Regca](Converter/REGCA/README.md) | WECC REGCA renewable generator/converter model [Tgov1](Governor/Tgov1/README.md) | the TGOV1 governor model + [Hygov](Governor/HYGOV/README.md) | the HYGOV hydro turbine-governor model [Ieeet1](Exciter/IEEET1/README.md) | the IEEET1 exciter model [Esdc1a](Exciter/ESDC1A/README.md) | the ESDC1A exciter model [SexsPti](Exciter/SEXS-PTI/README.md) | the SEXS-PTI simplified exciter model diff --git a/GridKit/Model/PhasorDynamics/SystemModelData.hpp b/GridKit/Model/PhasorDynamics/SystemModelData.hpp index d743ab63e..e82fc965e 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelData.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelData.hpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -46,6 +47,7 @@ namespace GridKit using RegcaDataT = Converter::RegcaData; using Tgov1DataT = Governor::Tgov1Data; using Esdc1aDataT = Exciter::Esdc1aData; + using HygovDataT = Governor::HygovData; using Ieeet1DataT = Exciter::Ieeet1Data; using SexsPtiDataT = Exciter::SexsPtiData; using IeeestDataT = Stabilizer::IeeestData; @@ -107,6 +109,7 @@ namespace GridKit 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 hygov; ///< HYGOV governors 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 dc9a7e74c..5856619c0 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelDataJSONParser.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelDataJSONParser.hpp @@ -147,6 +147,12 @@ namespace GridKit raw_component.get_to(gov); sm.gov.push_back(gov); } + else if (kind == "Hygov") + { + typename SystemModelData::HygovDataT gov; + raw_component.get_to(gov); + sm.hygov.push_back(gov); + } else if (kind == "Ieeet1") { typename SystemModelData::Ieeet1DataT exciter; diff --git a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp index d4c126cd7..775c1537f 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp @@ -315,6 +315,42 @@ namespace GridKit addComponent(gov); } + // Add HYGOV governors + for (const auto& govdata : data.hygov) + { + auto* gov = new Hygov(govdata); + + if (govdata.signal_inputs.contains(HygovSignalInputs::speed)) + { + IdxT speed = govdata.signal_inputs.at(HygovSignalInputs::speed); + constexpr auto OMEGA = HygovExternalVariables::OMEGA; + gov->getSignals().template attachSignalNode(getSignal(speed)); + } + + if (govdata.signal_outputs.contains(HygovSignalOutputs::pmech)) + { + IdxT pmech = govdata.signal_outputs.at(HygovSignalOutputs::pmech); + constexpr auto PMECH = HygovInternalVariables::PMECH; + gov->getSignals().template assignSignalNode(getSignal(pmech)); + } + + if (govdata.signal_inputs.contains(HygovSignalInputs::pref)) + { + IdxT pref = govdata.signal_inputs.at(HygovSignalInputs::pref); + constexpr auto PREF = HygovExternalVariables::PREF; + gov->getSignals().template attachSignalNode(getSignal(pref)); + } + + if (govdata.signal_inputs.contains(HygovSignalInputs::paux)) + { + IdxT paux = govdata.signal_inputs.at(HygovSignalInputs::paux); + constexpr auto PAUX = HygovExternalVariables::PAUX; + gov->getSignals().template attachSignalNode(getSignal(paux)); + } + + addComponent(gov); + } + for (const auto& excitedata : data.exciter) { IdxT bus_index = 0; diff --git a/docs/Figures/PhasorDynamics/HYGOV/diagram.png b/docs/Figures/PhasorDynamics/HYGOV/diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..fc7d7916d2887f6758718be182a29573e7599993 GIT binary patch literal 72381 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ⅈ6DHDP>{8{t#{hD)Qk%kXT45-U3^JH60a>d!ki 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 literal 0 HcmV?d00001 diff --git a/docs/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md b/docs/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md new file mode 100644 index 000000000..0e4dd411a --- /dev/null +++ b/docs/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md @@ -0,0 +1,6 @@ +# HYGOV + +```{include} ../../../../../../GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md +:start-line: 1 +:relative-images: +``` diff --git a/docs/GridKit/Model/PhasorDynamics/Governor/README.md b/docs/GridKit/Model/PhasorDynamics/Governor/README.md index fe3d5b5b7..4e38abd24 100644 --- a/docs/GridKit/Model/PhasorDynamics/Governor/README.md +++ b/docs/GridKit/Model/PhasorDynamics/Governor/README.md @@ -8,6 +8,7 @@ TGOV1 IEEEG1 GGOV1 +HYGOV ``` ```{include} ../../../../../GridKit/Model/PhasorDynamics/Governor/README.md diff --git a/tests/UnitTests/PhasorDynamics/CMakeLists.txt b/tests/UnitTests/PhasorDynamics/CMakeLists.txt index 87950ac62..c0517d7ec 100644 --- a/tests/UnitTests/PhasorDynamics/CMakeLists.txt +++ b/tests/UnitTests/PhasorDynamics/CMakeLists.txt @@ -88,6 +88,14 @@ target_link_libraries( GridKit::phasor_dynamics_bus_dependency_tracking GridKit::testing) +add_executable(test_phasor_governor_hygov runGovernorHygovTests.cpp) +target_link_libraries( + test_phasor_governor_hygov + GridKit::definitions + GridKit::phasor_dynamics_systemmodel + GridKit::phasor_dynamics_systemmodel_dependency_tracking + GridKit::testing) + add_executable(test_phasor_exciter_ieeet1 runExciterIeeet1Tests.cpp) target_link_libraries( test_phasor_exciter_ieeet1 @@ -173,6 +181,7 @@ add_test(NAME PhasorDynamicsBusToSignalAdapterTest COMMAND test_phasor_bustosign 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 PhasorDynamicsGovernorHygovTest COMMAND test_phasor_governor_hygov) 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) @@ -198,6 +207,7 @@ install( test_phasor_loadzip test_phasor_genrou test_phasor_governor_tgov1 + test_phasor_governor_hygov test_phasor_exciter_ieeet1 test_phasor_exciter_esdc1a test_phasor_gensal diff --git a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp new file mode 100644 index 000000000..0a4412b25 --- /dev/null +++ b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp @@ -0,0 +1,627 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace GridKit +{ + namespace Testing + { + template + class GovernorHygovTests + { + public: + using ScalarT = scalar_type; + using IdxT = index_type; + using RealT = typename PhasorDynamics::Component::RealT; + using Gov = PhasorDynamics::Governor::Hygov; + using Data = PhasorDynamics::Governor::HygovData; + using Var = PhasorDynamics::Governor::HygovInternalVariables; + using Ext = PhasorDynamics::Governor::HygovExternalVariables; + using Params = PhasorDynamics::Governor::HygovParameters; + using Mon = PhasorDynamics::Governor::HygovMonitorableVariables; + + static constexpr ScalarT kTol = static_cast(1.0e-8); + + TestOutcome constructionAndValidation() + { + TestStatus success = true; + + Gov hygov(makeHygovData()); + success *= (hygov.size() == static_cast(Var::MAXIMUM)); + success *= (hygov.getMonitor() != nullptr); + success *= (hygov.verify() == 0); + + auto source_default_hygov = makeHygovSourceDefaultData(); + Gov source_default_hygov_model(source_default_hygov); + success *= (source_default_hygov_model.verify() == 0); + + auto bad_hygov_curve = makeHygovData(); + bad_hygov_curve.parameters[Params::Gv2] = static_cast(0.2); + Gov bad_hygov_curve_model(bad_hygov_curve); + success *= (bad_hygov_curve_model.verify() > 0); + + auto bad_hygov_trate = makeHygovData(); + bad_hygov_trate.parameters[Params::Trate] = static_cast(0.0); + Gov bad_hygov_trate_model(bad_hygov_trate); + success *= (bad_hygov_trate_model.verify() > 0); + + auto missing_hygov_trate = makeHygovData(); + missing_hygov_trate.parameters.erase(Params::Trate); + Gov missing_hygov_trate_model(missing_hygov_trate); + success *= (missing_hygov_trate_model.verify() > 0); + + auto bad_hygov_hdam = makeHygovData(); + bad_hygov_hdam.parameters[Params::Hdam] = static_cast(0.0); + Gov bad_hygov_hdam_model(bad_hygov_hdam); + success *= (bad_hygov_hdam_model.verify() > 0); + + auto bad_hygov_rtemp = makeHygovData(); + bad_hygov_rtemp.parameters[Params::Rtemp] = static_cast(0.0); + Gov bad_hygov_rtemp_model(bad_hygov_rtemp); + success *= (bad_hygov_rtemp_model.verify() > 0); + + auto bad_hygov_limits = makeHygovData(); + bad_hygov_limits.parameters[Params::Velm] = static_cast(-0.1); + bad_hygov_limits.parameters[Params::db1] = static_cast(-0.1); + Gov bad_hygov_limits_model(bad_hygov_limits); + success *= (bad_hygov_limits_model.verify() > 0); + + auto bad_hygov_gate_limits = makeHygovData(); + bad_hygov_gate_limits.parameters[Params::Gmin] = static_cast(1.1); + Gov bad_hygov_gate_limits_model(bad_hygov_gate_limits); + success *= (bad_hygov_gate_limits_model.verify() > 0); + + return success.report(__func__); + } + + TestOutcome signals() + { + TestStatus success = true; + + PhasorDynamics::SignalNode pmech_node; + PhasorDynamics::SignalNode omega_node; + ScalarT pmech_value{0.40}; + ScalarT omega_value{0.0}; + IdxT pmech_index = INVALID_INDEX; + IdxT omega_index = 5; + pmech_node.set(&pmech_value, &pmech_index); + omega_node.set(&omega_value, &omega_index); + + Gov hygov(makeHygovData()); + auto& hygov_signals = hygov.getSignals(); + hygov_signals.template assignSignalNode(&pmech_node); + hygov_signals.template attachSignalNode(&omega_node); + + success *= (hygov.allocate() == 0); + pmech_node.init(pmech_value); + success *= pmech_node.linked(); + success *= (pmech_node.getVariableIndex() == static_cast(Var::PMECH)); + success *= (hygov.verify() == 0); + success *= (hygov.initialize() == 0); + success *= (hygov.tagDifferentiable() == 0); + success *= (hygov.evaluateResidual() == 0); + + const auto* y = hygov.y().getData(); + const auto* yp = hygov.yp().getData(); + const auto& residual = hygov.getResidual(); + const auto* f = residual.getData(); + success *= isEqual(y[index(Var::Q)], static_cast(0.50), kTol); + success *= isEqual(y[index(Var::G)], static_cast(0.50), kTol); + success *= isEqual(y[index(Var::C)], static_cast(0.50), kTol); + success *= (hygov.tag()[index(Var::G)] == true); + + for (size_t i = 0; i < residual.getSize(); ++i) + { + success *= isEqual(f[i], static_cast(0.0), kTol); + success *= isEqual(yp[i], static_cast(0.0), kTol); + } + + return success.report(__func__); + } + + TestOutcome sourceDefault() + { + TestStatus success = true; + + PhasorDynamics::SignalNode pmech_node; + ScalarT pmech_value{0.40}; + IdxT pmech_index = INVALID_INDEX; + pmech_node.set(&pmech_value, &pmech_index); + + Gov hygov(makeHygovSourceDefaultData()); + hygov.getSignals().template assignSignalNode(&pmech_node); + + success *= (hygov.allocate() == 0); + pmech_node.init(pmech_value); + success *= (hygov.verify() == 0); + success *= (hygov.initialize() == 0); + success *= (hygov.tagDifferentiable() == 0); + success *= (hygov.evaluateResidual() == 0); + + const auto* y = hygov.y().getData(); + const auto* yp = hygov.yp().getData(); + const auto& residual = hygov.getResidual(); + const auto* f = residual.getData(); + success *= isEqual(y[index(Var::Q)], static_cast(0.50), kTol); + success *= isEqual(y[index(Var::PGV)], static_cast(0.50), kTol); + success *= isEqual(y[index(Var::G)], static_cast(0.50), kTol); + success *= (hygov.tag()[index(Var::XN)] == true); + + for (size_t i = 0; i < residual.getSize(); ++i) + { + success *= isEqual(f[i], static_cast(0.0), kTol); + success *= isEqual(yp[i], static_cast(0.0), kTol); + } + + return success.report(__func__); + } + + TestOutcome zeroTimeConstants() + { + TestStatus success = true; + + PhasorDynamics::SignalNode pmech_node; + ScalarT pmech_value{0.40}; + IdxT pmech_index = INVALID_INDEX; + pmech_node.set(&pmech_value, &pmech_index); + + auto data = makeHygovData(); + data.parameters[Params::Tr] = static_cast(0.0); + data.parameters[Params::Tf] = static_cast(0.0); + data.parameters[Params::Tg] = static_cast(0.0); + data.parameters[Params::Tw] = static_cast(0.0); + data.parameters[Params::Tnp] = static_cast(0.0); + + Gov hygov(data); + hygov.getSignals().template assignSignalNode(&pmech_node); + + success *= (hygov.allocate() == 0); + pmech_node.init(pmech_value); + success *= (hygov.verify() == 0); + success *= (hygov.initialize() == 0); + success *= (hygov.tagDifferentiable() == 0); + success *= (hygov.evaluateResidual() == 0); + + success *= (hygov.tag()[index(Var::XN)] == true); + success *= (hygov.tag()[index(Var::XF)] == true); + success *= (hygov.tag()[index(Var::C)] == true); + success *= (hygov.tag()[index(Var::G)] == true); + success *= (hygov.tag()[index(Var::Q)] == true); + + const auto* yp = hygov.yp().getData(); + const auto& residual = hygov.getResidual(); + const auto* f = residual.getData(); + for (size_t i = 0; i < residual.getSize(); ++i) + { + success *= isEqual(f[i], static_cast(0.0), kTol); + success *= isEqual(yp[i], static_cast(0.0), kTol); + } + + return success.report(__func__); + } + + TestOutcome baseConversion() + { + TestStatus success = true; + + PhasorDynamics::SignalNode pmech_node; + ScalarT pmech_value{0.40}; + IdxT pmech_index = INVALID_INDEX; + pmech_node.set(&pmech_value, &pmech_index); + + auto data = makeHygovData(); + data.parameters[Params::Trate] = static_cast(50.0); + + Gov hygov(data); + hygov.getSignals().template assignSignalNode(&pmech_node); + + success *= (hygov.allocate() == 0); + pmech_node.init(pmech_value); + success *= (hygov.verify() == 0); + success *= (hygov.initialize() == 0); + success *= (hygov.evaluateResidual() == 0); + + const auto* y = hygov.y().getData(); + const auto* yp = hygov.yp().getData(); + const auto& residual = hygov.getResidual(); + const auto* f = residual.getData(); + success *= isEqual(y[index(Var::Q)], static_cast(0.90), kTol); + success *= isEqual(y[index(Var::G)], static_cast(0.90), kTol); + success *= isEqual(y[index(Var::PMECH)], static_cast(0.40), kTol); + success *= isEqual(pmech_node.read(), static_cast(0.40), kTol); + + for (size_t i = 0; i < residual.getSize(); ++i) + { + success *= isEqual(f[i], static_cast(0.0), kTol); + success *= isEqual(yp[i], static_cast(0.0), kTol); + } + + return success.report(__func__); + } + + TestOutcome absoluteTolerance() + { + TestStatus success = true; + + Gov hygov(makeHygovData()); + + success *= (hygov.allocate() == 0); + success *= (hygov.setAbsoluteTolerance(static_cast(1.0e-7)) == 0); + const auto& abs_tol = hygov.absoluteTolerance(); + success *= (abs_tol.getSize() == static_cast(Var::MAXIMUM)); + + const auto* tolerances = abs_tol.getData(); + for (size_t i = 0; i < abs_tol.getSize(); ++i) + { + success *= isEqual(tolerances[i], scalar(1.0e-7), kTol); + } + + return success.report(__func__); + } + + TestOutcome prefSignal() + { + TestStatus success = true; + + Gov hygov(makeHygovData()); + + PhasorDynamics::SignalNode pmech_node; + PhasorDynamics::SignalNode pref_node; + PhasorDynamics::SignalNode paux_node; + const ScalarT pmech0 = scalar(kInitialPmech); + ScalarT pmech_value{0.0}; + ScalarT pref_value = scalar(99.0); + ScalarT paux_value = scalar(kInitialPaux); + IdxT pmech_index = INVALID_INDEX; + IdxT pref_index = 7; + IdxT paux_index = 8; + pmech_node.set(&pmech_value, &pmech_index); + pref_node.set(&pref_value, &pref_index); + paux_node.set(&paux_value, &paux_index); + + hygov.getSignals().template assignSignalNode(&pmech_node); + hygov.getSignals().template attachSignalNode(&pref_node); + hygov.getSignals().template attachSignalNode(&paux_node); + + success *= (hygov.allocate() == 0); + pmech_node.init(pmech0); + success *= (hygov.verify() == 0); + success *= (hygov.initialize() == 0); + success *= isEqual(pref_node.read(), + prefForInitialPoint(pmech0, + paux_value, + scalar(kDefaultTrate), + scalar(kDefaultSystemBase)), + kTol); + success *= (hygov.evaluateResidual() == 0); + + const auto* yp = hygov.yp().getData(); + const auto& residual = hygov.getResidual(); + const auto* f = residual.getData(); + for (size_t i = 0; i < residual.getSize(); ++i) + { + success *= isEqual(f[i], static_cast(0.0), kTol); + success *= isEqual(yp[i], static_cast(0.0), kTol); + } + + pref_value += scalar(kPrefStep); + success *= (hygov.evaluateResidual() == 0); + success *= isEqual(f[index(Var::EF)], scalar(kPrefStep), kTol); + + return success.report(__func__); + } + + TestOutcome prefSignalBaseConversion() + { + TestStatus success = true; + + auto data = makeHygovData(); + data.parameters[Params::Trate] = static_cast(kConversionTrate); + + Gov hygov(data); + hygov.setSystemBase(static_cast(kSystemFrequency), + static_cast(kConversionSystemBase * 1.0e6)); + + PhasorDynamics::SignalNode pmech_node; + PhasorDynamics::SignalNode pref_node; + PhasorDynamics::SignalNode paux_node; + const ScalarT pmech0 = scalar(kInitialPmech); + ScalarT pmech_value{0.0}; + ScalarT pref_value = scalar(99.0); + ScalarT paux_value = scalar(kInitialPaux); + IdxT pmech_index = INVALID_INDEX; + IdxT pref_index = 7; + IdxT paux_index = 8; + pmech_node.set(&pmech_value, &pmech_index); + pref_node.set(&pref_value, &pref_index); + paux_node.set(&paux_value, &paux_index); + + hygov.getSignals().template assignSignalNode(&pmech_node); + hygov.getSignals().template attachSignalNode(&pref_node); + hygov.getSignals().template attachSignalNode(&paux_node); + + success *= (hygov.allocate() == 0); + pmech_node.init(pmech0); + success *= (hygov.verify() == 0); + success *= (hygov.initialize() == 0); + + const ScalarT expected_pref = + prefForInitialPoint(pmech0, + paux_value, + scalar(kConversionTrate), + scalar(kConversionSystemBase)); + + success *= isEqual(pref_node.read(), expected_pref, kTol); + const auto* y = hygov.y().getData(); + success *= isEqual(y[index(Var::Q)], scalar(0.90), kTol); + success *= isEqual(y[index(Var::G)], scalar(0.90), kTol); + success *= (hygov.evaluateResidual() == 0); + + const auto* yp = hygov.yp().getData(); + const auto& residual = hygov.getResidual(); + const auto* f = residual.getData(); + for (size_t i = 0; i < residual.getSize(); ++i) + { + success *= isEqual(f[i], static_cast(0.0), kTol); + success *= isEqual(yp[i], static_cast(0.0), kTol); + } + + pref_value += scalar(kPrefStep); + success *= (hygov.evaluateResidual() == 0); + success *= isEqual(f[index(Var::EF)], + scalar(kConversionSystemBase / kConversionTrate * kPrefStep), + kTol); + + return success.report(__func__); + } + + TestOutcome parameterValidation() + { + TestStatus success = true; + + auto invalid_trate = makeHygovData(); + invalid_trate.parameters[Params::Trate] = true; + Gov invalid_trate_model(invalid_trate); + success *= (invalid_trate_model.verify() > 0); + + auto negative_time = makeHygovData(); + negative_time.parameters[Params::Tf] = static_cast(-0.1); + negative_time.parameters[Params::Tn] = static_cast(-0.1); + Gov negative_time_model(negative_time); + success *= (negative_time_model.verify() > 0); + + auto invalid_at = makeHygovData(); + invalid_at.parameters[Params::At] = static_cast(0.0); + Gov invalid_at_model(invalid_at); + success *= (invalid_at_model.verify() > 0); + + auto invalid_damping = makeHygovData(); + invalid_damping.parameters[Params::Dturb] = static_cast(-0.1); + Gov invalid_damping_model(invalid_damping); + success *= (invalid_damping_model.verify() > 0); + + return success.report(__func__); + } + + TestOutcome signalValidation() + { + TestStatus success = true; + + PhasorDynamics::SignalNode omega_node; + Gov omega_model(makeHygovData()); + omega_model.getSignals().template attachSignalNode(&omega_node); + success *= (omega_model.verify() > 0); + + PhasorDynamics::SignalNode pref_node; + Gov pref_model(makeHygovData()); + pref_model.getSignals().template attachSignalNode(&pref_node); + success *= (pref_model.verify() > 0); + + PhasorDynamics::SignalNode paux_node; + Gov paux_model(makeHygovData()); + paux_model.getSignals().template attachSignalNode(&paux_node); + success *= (paux_model.verify() > 0); + + return success.report(__func__); + } + + TestOutcome jsonParseAndSystemAssembly() + { + TestStatus success = true; + + std::istringstream input(R"json( +{ + "header": { + "format_version": 0, + "format_revision": 1, + "case_name": "hydro governor", + "case_description": "HYGOV parser test", + "case_comments": "", + "freq_base": 60.0, + "va_base": 100000000.0 + }, + "buses": [ + { + "number": 1, + "class": "bus", + "name": "Bus 1", + "init": { "Vr": 1.0, "Vi": 0.0 }, + "params": { "kv": 1.0 } + } + ], + "signals": [ + { "signal_id": 10, "name": "Pmech" } + ], + "devices": [ + { + "class": "Genrou", + "ports": { "bus": 1, "pmech": 10 }, + "id": "GEN1", + "params": { + "p0": 0.3, "q0": 0.0, "H": 3.0, "D": 0.0, "Ra": 0.0, + "Tdop": 7.0, "Tdopp": 0.04, "Tqop": 0.75, "Tqopp": 0.05, + "Xd": 2.1, "Xdp": 0.2, "Xdpp": 0.18, "Xq": 0.5, "Xqp": 0.5, + "Xqpp": 0.18, "Xl": 0.15, "S10": 0.0, "S12": 0.0, + "mva": 100.0 + } + }, + { + "class": "Hygov", + "ports": { "pmech": 10 }, + "id": "HYG1", + "params": { + "Trate": 50.0, "Rperm": 0.05, "Rtemp": 0.4, "Tr": 5.0, + "Tf": 0.2, "Tg": 0.0, "Velm": 0.5, "Gmax": 1.0, "Gmin": 0.0, + "Tw": 1.0, "At": 1.0, "Dturb": 0.0, "Qnl": 0.1, + "Tn": 0.0, "Tnp": 1.0, "db1": 0.0, "db2": 0.0, "Hdam": 1.0, + "Gv0": 0.0, "Gv1": 0.2, "Gv2": 0.4, "Gv3": 0.6, "Gv4": 0.8, "Gv5": 1.0, + "Pgv0": 0.0, "Pgv1": 0.2, "Pgv2": 0.4, "Pgv3": 0.6, "Pgv4": 0.8, "Pgv5": 1.0 + } + } + ] +} +)json"); + + auto data = PhasorDynamics::parseSystemModelData(input); + success *= (data.hygov.size() == 1); + const auto trate_param = + data.hygov[0].parameters.at(PhasorDynamics::Governor::HygovParameters::Trate); + success *= (std::get(trate_param) == static_cast(50.0)); + using SignalOutput = typename Data::SignalOutputs; + success *= data.hygov[0].buses.empty(); + success *= data.hygov[0].signal_inputs.empty(); + success *= (data.hygov[0].signal_outputs.at(SignalOutput::pmech) + == static_cast(10)); + PhasorDynamics::SystemModel system(data); + success *= (system.allocate() == 0); + success *= (system.initialize() == 0); + const auto hygov_size = + static_cast(PhasorDynamics::Governor::HygovInternalVariables::MAXIMUM); + const auto hygov_offset = static_cast(system.size() - hygov_size); + const auto* system_y = system.y().getData(); + success *= isEqual( + system_y[hygov_offset + index(PhasorDynamics::Governor::HygovInternalVariables::Q)], + static_cast(0.70), + kTol); + success *= isEqual( + system_y[hygov_offset + index(PhasorDynamics::Governor::HygovInternalVariables::G)], + static_cast(0.70), + kTol); + success *= (system.evaluateResidual() == 0); + success *= (system.size() == 33); + + return success.report(__func__); + } + + private: + static constexpr RealT kSystemFrequency = 60.0; + static constexpr RealT kDefaultSystemBase = 100.0; + static constexpr RealT kDefaultTrate = 100.0; + static constexpr RealT kConversionSystemBase = 100.0; + static constexpr RealT kConversionTrate = 50.0; + static constexpr RealT kInitialPmech = 0.40; + static constexpr RealT kInitialPaux = 0.02; + static constexpr RealT kPrefStep = 0.10; + static constexpr RealT kRperm = 0.05; + static constexpr RealT kAt = 1.0; + static constexpr RealT kQnl = 0.1; + static constexpr RealT kHdam = 1.0; + + static size_t index(PhasorDynamics::Governor::HygovInternalVariables variable) + { + return static_cast(variable); + } + + static ScalarT scalar(RealT value) + { + return static_cast(value); + } + + static ScalarT prefForInitialPoint(ScalarT pmech0, + ScalarT paux0, + ScalarT trate, + ScalarT system_base) + { + const ScalarT k_base = system_base / trate; + const ScalarT q0 = scalar(kQnl) + k_base * pmech0 / (scalar(kAt) * scalar(kHdam)); + const ScalarT gate0 = q0 / std::sqrt(scalar(kHdam)); + return (scalar(kRperm) * gate0 - k_base * paux0) / k_base; + } + + auto makeHygovData() -> Data + { + Data data; + data.device_class = "Hygov"; + data.disambiguation_string = "hygov_test"; + data.monitored_variables.insert(Mon::pmech); + data.monitored_variables.insert(Mon::gate); + + data.parameters[Params::Trate] = static_cast(100.0); + data.parameters[Params::Rperm] = static_cast(0.05); + data.parameters[Params::Rtemp] = static_cast(0.4); + data.parameters[Params::Tr] = static_cast(5.0); + data.parameters[Params::Tf] = static_cast(0.2); + data.parameters[Params::Tg] = static_cast(0.0); + data.parameters[Params::Velm] = static_cast(0.5); + data.parameters[Params::Gmax] = static_cast(1.0); + data.parameters[Params::Gmin] = static_cast(0.0); + data.parameters[Params::Tw] = static_cast(1.0); + data.parameters[Params::At] = static_cast(1.0); + data.parameters[Params::Dturb] = static_cast(0.0); + data.parameters[Params::Qnl] = static_cast(0.1); + data.parameters[Params::Tn] = static_cast(0.0); + data.parameters[Params::Tnp] = static_cast(1.0); + data.parameters[Params::db1] = static_cast(0.0); + data.parameters[Params::db2] = static_cast(0.0); + data.parameters[Params::Hdam] = static_cast(1.0); + data.parameters[Params::Gv0] = static_cast(0.0); + data.parameters[Params::Gv1] = static_cast(0.2); + data.parameters[Params::Gv2] = static_cast(0.4); + data.parameters[Params::Gv3] = static_cast(0.6); + data.parameters[Params::Gv4] = static_cast(0.8); + data.parameters[Params::Gv5] = static_cast(1.0); + data.parameters[Params::Pgv0] = static_cast(0.0); + data.parameters[Params::Pgv1] = static_cast(0.2); + data.parameters[Params::Pgv2] = static_cast(0.4); + data.parameters[Params::Pgv3] = static_cast(0.6); + data.parameters[Params::Pgv4] = static_cast(0.8); + data.parameters[Params::Pgv5] = static_cast(1.0); + + return data; + } + + auto makeHygovSourceDefaultData() -> Data + { + auto data = makeHygovData(); + data.parameters[Params::Tn] = static_cast(0.0); + data.parameters[Params::Tnp] = static_cast(0.0); + data.parameters[Params::Gv0] = static_cast(0.0); + data.parameters[Params::Gv1] = static_cast(0.0); + data.parameters[Params::Gv2] = static_cast(0.0); + data.parameters[Params::Gv3] = static_cast(0.0); + data.parameters[Params::Gv4] = static_cast(0.0); + data.parameters[Params::Gv5] = static_cast(0.0); + data.parameters[Params::Pgv0] = static_cast(0.0); + data.parameters[Params::Pgv1] = static_cast(0.0); + data.parameters[Params::Pgv2] = static_cast(0.0); + data.parameters[Params::Pgv3] = static_cast(0.0); + data.parameters[Params::Pgv4] = static_cast(0.0); + data.parameters[Params::Pgv5] = static_cast(0.0); + + return data; + } + }; + } // namespace Testing +} // namespace GridKit diff --git a/tests/UnitTests/PhasorDynamics/runGovernorHygovTests.cpp b/tests/UnitTests/PhasorDynamics/runGovernorHygovTests.cpp new file mode 100644 index 000000000..19747f956 --- /dev/null +++ b/tests/UnitTests/PhasorDynamics/runGovernorHygovTests.cpp @@ -0,0 +1,22 @@ +#include "GovernorHygovTests.hpp" + +int main() +{ + GridKit::Testing::TestingResults result; + + GridKit::Testing::GovernorHygovTests test; + + result += test.constructionAndValidation(); + result += test.signals(); + result += test.sourceDefault(); + result += test.zeroTimeConstants(); + result += test.baseConversion(); + result += test.absoluteTolerance(); + result += test.prefSignal(); + result += test.prefSignalBaseConversion(); + result += test.parameterValidation(); + result += test.signalValidation(); + result += test.jsonParseAndSystemAssembly(); + + return result.summary(); +} From c2b3e0de61778d49f1a26e6a16c2e56e54ee8369 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Mon, 27 Jul 2026 05:21:14 -0500 Subject: [PATCH 02/17] initial improvments, some jacobian issues --- .../PhasorDynamics/Governor/HYGOV/Hygov.hpp | 94 +- .../Governor/HYGOV/HygovEnzyme.cpp | 108 +- .../Governor/HYGOV/HygovImpl.hpp | 669 +++++--- .../PhasorDynamics/Governor/HYGOV/README.md | 210 ++- .../Model/PhasorDynamics/Governor/README.md | 2 +- tests/UnitTests/PhasorDynamics/CMakeLists.txt | 4 +- .../PhasorDynamics/GovernorHygovTests.hpp | 1477 +++++++++++------ .../PhasorDynamics/runGovernorHygovTests.cpp | 20 +- 8 files changed, 1671 insertions(+), 913 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp index 2fb9d506c..550c70eb0 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp @@ -52,12 +52,39 @@ namespace GridKit MAXIMUM, }; + /// Indices into the HYGOV state, derivative, and residual vectors. + struct HygovIdx + { + static constexpr size_t XN = static_cast(HygovInternalVariables::XN); + static constexpr size_t XF = static_cast(HygovInternalVariables::XF); + static constexpr size_t C = static_cast(HygovInternalVariables::C); + static constexpr size_t G = static_cast(HygovInternalVariables::G); + static constexpr size_t Q = static_cast(HygovInternalVariables::Q); + static constexpr size_t OMEGADB = static_cast(HygovInternalVariables::OMEGADB); + static constexpr size_t EF = static_cast(HygovInternalVariables::EF); + static constexpr size_t FC = static_cast(HygovInternalVariables::FC); + static constexpr size_t RC = static_cast(HygovInternalVariables::RC); + static constexpr size_t PGV = static_cast(HygovInternalVariables::PGV); + static constexpr size_t H = static_cast(HygovInternalVariables::H); + static constexpr size_t PMECH = static_cast(HygovInternalVariables::PMECH); + static constexpr size_t MAXIMUM = static_cast(HygovInternalVariables::MAXIMUM); + }; + + /// Indices into the HYGOV external-signal buffers. + struct HygovExt + { + static constexpr size_t OMEGA = static_cast(HygovExternalVariables::OMEGA); + static constexpr size_t PREF = static_cast(HygovExternalVariables::PREF); + static constexpr size_t PAUX = static_cast(HygovExternalVariables::PAUX); + static constexpr size_t MAXIMUM = static_cast(HygovExternalVariables::MAXIMUM); + }; + template class Hygov : public Component { - using Component::alpha_; using Component::abs_tol_; using Component::allocated_; + using Component::alpha_; using Component::f_; using Component::gridkit_component_id_; using Component::J_cols_buffer_; @@ -82,8 +109,8 @@ namespace GridKit using MonitorT = Model::VariableMonitor; Hygov(); - Hygov(const ModelDataT& data); - ~Hygov() override; + explicit Hygov(const ModelDataT& data); + ~Hygov(); int setGridKitComponentID(IdxT) override final; int allocate() override final; @@ -109,42 +136,53 @@ namespace GridKit const ScalarT*, const ScalarT*, const ScalarT*, const ScalarT*, ScalarT*); private: - void initModelParams(const ModelDataT& data); + void initializeParameters(const ModelDataT& data); void initializeMonitor(); void setDerivedParameters(); + /// Evaluate the nonlinear gate-to-power curve as a fixed sum of + /// smooth linear segments. ScalarT gatePower(ScalarT gate) const; - RealT invertGatePower(RealT pgv) const; + + /// Analytic slope of the smooth gate-to-power curve, used to stamp + /// the Jacobian entry the Enzyme auto-sparsity pass drops. + RealT gatePowerDerivative(RealT gate) const; + + /// Solve the steady gate position that reproduces a seeded + /// component-base mechanical power at an initial speed deviation. + RealT solveInitialGate(RealT pmech, RealT omega) const; + ScalarT toComponentBase(ScalarT value) const; ScalarT toSystemBase(ScalarT value) const; - static constexpr RealT TIME_CONSTANT_MINIMUM = static_cast(1.0e-3); - - RealT Trate_{0}; - RealT Rperm_{0}; - RealT Rtemp_{0}; - RealT Tr_{0}; - RealT Tf_{0}; - RealT Tg_{0}; - RealT Velm_{0}; - RealT Gmax_{0}; - RealT Gmin_{0}; - RealT Tw_{0}; - RealT At_{0}; - RealT Dturb_{0}; - RealT Qnl_{0}; - RealT Tn_{0}; - RealT Tnp_{0}; - RealT leadlag_gain_{0}; - RealT db1_{0}; - RealT db2_{0}; - RealT Hdam_{1}; + static constexpr RealT TIME_CONSTANT_MINIMUM = static_cast(1.0e-3); + static constexpr RealT INITIALIZATION_TOLERANCE = static_cast(1.0e-10); + + RealT Trate_{ZERO}; + RealT Rperm_{static_cast(0.04)}; + RealT Rtemp_{static_cast(0.3)}; + RealT Tr_{static_cast(5.0)}; + RealT Tf_{static_cast(0.05)}; + RealT Tg_{static_cast(0.5)}; + RealT Velm_{static_cast(0.2)}; + RealT Gmax_{ONE}; + RealT Gmin_{ZERO}; + RealT Tw_{ONE}; + RealT At_{static_cast(1.2)}; + RealT Dturb_{static_cast(0.5)}; + RealT Qnl_{static_cast(0.05)}; + RealT Tn_{ZERO}; + RealT Tnp_{ZERO}; + RealT db1_{ZERO}; + RealT db2_{ZERO}; + RealT Hdam_{ONE}; std::array Gv_{}; std::array Pgv_{}; - RealT va_component_base_{0}; + RealT va_component_base_{ZERO}; + RealT leadlag_gain_{ZERO}; - int parameter_error_count_{0}; + IdxT parameter_error_count_{0}; ScalarT pref_set_{0}; ScalarT paux_set_{0}; diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovEnzyme.cpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovEnzyme.cpp index 56d71f59b..f21ddcafc 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovEnzyme.cpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovEnzyme.cpp @@ -24,59 +24,79 @@ namespace GridKit { auto size = static_cast(size_); auto signal_size = static_cast(ws_.size()); - auto buffer_size = 2 * size * size + size * signal_size; + // One slot past the DfDy/DfDyp/DfDws maxima holds the analytically + // stamped gate-curve entry appended after the Enzyme blocks. + auto buffer_size = 2 * size * size + size * signal_size + 1; 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::Governor::Hygov; + using Fn = GridKit::Enzyme::Sparse::MemberFunctions; + nnz_ = 0; - GridKit::Enzyme::Sparse::DfDy, - GridKit::Enzyme::Sparse::MemberFunctions::InternalResidualWithSignal>::eval(this, - static_cast(f_.getSize()), - static_cast(y_.getSize()), - (this->getResidualIndices()).data(), - (this->getVariableIndices()).data(), - y_.getData(), - yp_.getData(), - wb_.data(), - ws_.data(), - J_rows_buffer_, - J_cols_buffer_, - J_vals_buffer_, - nnz_); + GridKit::Enzyme::Sparse::DfDy::eval(this, + static_cast(f_.getSize()), + static_cast(y_.getSize()), + (this->getResidualIndices()).data(), + (this->getVariableIndices()).data(), + y_.getData(), + yp_.getData(), + wb_.data(), + ws_.data(), + J_rows_buffer_, + J_cols_buffer_, + J_vals_buffer_, + nnz_); + + GridKit::Enzyme::Sparse::DfDyp::eval(this, + static_cast(f_.getSize()), + static_cast(y_.getSize()), + (this->getResidualIndices()).data(), + (this->getVariableIndices()).data(), + y_.getData(), + yp_.getData(), + wb_.data(), + ws_.data(), + alpha_, + J_rows_buffer_, + J_cols_buffer_, + J_vals_buffer_, + nnz_); - GridKit::Enzyme::Sparse::DfDyp, - GridKit::Enzyme::Sparse::MemberFunctions::InternalResidualWithSignal>::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::DfDws::eval(this, + static_cast(f_.getSize()), + ws_.size(), + (this->getResidualIndices()).data(), + ws_indices_.data(), + y_.getData(), + yp_.getData(), + wb_.data(), + ws_.data(), + J_rows_buffer_, + J_cols_buffer_, + J_vals_buffer_, + nnz_); - GridKit::Enzyme::Sparse::DfDws, - GridKit::Enzyme::Sparse::MemberFunctions::InternalResidualWithSignal>::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_); + // The Enzyme auto-sparsity pass silently drops the gate-curve entry + // of the PGV row: with three or more smooth linear segments feeding + // one store, the pattern solver loses the y[G] column (two segments + // survive). The entry is stamped analytically until the upstream + // pass handles it; the unit-test comparison against dependency + // tracking guards this value and flags the day Enzyme emits the + // entry itself. + { + const auto G = static_cast(HygovInternalVariables::G); + const auto PGV = static_cast(HygovInternalVariables::PGV); + + J_rows_buffer_[nnz_] = this->getResidualIndex(PGV); + J_cols_buffer_[nnz_] = this->getVariableIndex(G); + J_vals_buffer_[nnz_] = + gatePowerDerivative(static_cast(y_.getData()[static_cast(G)])); + ++nnz_; + } this->constructCoo(); diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp index 30f09e559..b48dbe4dc 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp @@ -25,20 +25,32 @@ namespace GridKit { using Log = ::GridKit::Utilities::Logger; + /** + * @brief Construct a HYGOV governor without parameters + * + * The model is sized but left unconfigured. Every parameter keeps its + * documented default, the required power base is absent, and no monitor + * is created, so verify() reports configuration errors until the data + * constructor is used instead. + */ template Hygov::Hygov() { - size_ = static_cast(HygovInternalVariables::MAXIMUM); + size_ = static_cast(HygovIdx::MAXIMUM); } + /** + * @brief Construct a HYGOV governor from model data + * + * @param[in] data Parameters and monitored-variable selections. + */ template Hygov::Hygov(const ModelDataT& data) : monitor_(std::make_unique(data)) { - initModelParams(data); + initializeParameters(data); initializeMonitor(); - - size_ = static_cast(HygovInternalVariables::MAXIMUM); + size_ = static_cast(HygovIdx::MAXIMUM); } template @@ -46,10 +58,43 @@ namespace GridKit { } + /** + * @brief Resolve the parameter-derived constants + * + * Raises each governor lag to the well-posedness floor, sizes the + * component power base, and derives the speed lead-lag gain from the + * floored denominator so the residual keeps a fixed structure for + * sparse automatic differentiation. + */ template void Hygov::setDerivedParameters() { - va_component_base_ = Trate_ * static_cast(1.0e6); + // 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() << "Hygov: " << name << " must be non-negative\n"; + ++parameter_error_count_; + } + }; + + check_non_negative(Tr_, "Tr"); + check_non_negative(Tf_, "Tf"); + check_non_negative(Tg_, "Tg"); + check_non_negative(Tw_, "Tw"); + check_non_negative(Tnp_, "Tnp"); + + if (Tr_ < TIME_CONSTANT_MINIMUM || Tf_ < TIME_CONSTANT_MINIMUM + || Tg_ < TIME_CONSTANT_MINIMUM || Tw_ < TIME_CONSTANT_MINIMUM + || Tnp_ < TIME_CONSTANT_MINIMUM) + { + Log::warning() << "Hygov: Tr, Tf, Tg, Tw, and Tnp below " + << TIME_CONSTANT_MINIMUM + << " s are raised to that floor to keep the governor lags well posed\n"; + } Tr_ = std::max(Tr_, TIME_CONSTANT_MINIMUM); Tf_ = std::max(Tf_, TIME_CONSTANT_MINIMUM); @@ -57,49 +102,162 @@ namespace GridKit Tw_ = std::max(Tw_, TIME_CONSTANT_MINIMUM); Tnp_ = std::max(Tnp_, TIME_CONSTANT_MINIMUM); + va_component_base_ = Trate_ * static_cast(1.0e6); + leadlag_gain_ = Tn_ / Tnp_; } + /** + * @brief Evaluate the nonlinear gate-to-power curve + * + * Sums the five smooth CommonMath linear segments spanned by the + * `Gv`/`Pgv` points, so the same fixed expression serves the residual + * and both scalar instantiations. + * + * @param[in] gate Gate position. + * @return Turbine power at nominal head. + */ + template + scalar_type Hygov::gatePower(scalar_type gate) const + { + return ScalarT{Pgv_[0]} + + Math::linseg(gate, Gv_[0], Gv_[1], Pgv_[1] - Pgv_[0]) + + Math::linseg(gate, Gv_[1], Gv_[2], Pgv_[2] - Pgv_[1]) + + Math::linseg(gate, Gv_[2], Gv_[3], Pgv_[3] - Pgv_[2]) + + Math::linseg(gate, Gv_[3], Gv_[4], Pgv_[4] - Pgv_[3]) + + Math::linseg(gate, Gv_[4], Gv_[5], Pgv_[5] - Pgv_[4]); + } + + /** + * @brief Slope of the smooth gate-to-power curve + * + * Analytic derivative of gatePower(): each smooth linear segment + * contributes its slope gated by the logistic derivative of the smooth + * CommonMath ramp. Used to stamp the one Jacobian entry the Enzyme + * auto-sparsity pass drops from the gate-curve row. + * + * @param[in] gate Gate position. + * @return Derivative of the turbine power with respect to the gate. + */ + template + typename Hygov::RealT + Hygov::gatePowerDerivative(RealT gate) const + { + auto ramp_slope = [](RealT x) + { + return ONE / (ONE + std::exp(-Math::MU * x)); + }; + auto segment_slope = [&](size_t i) + { + return (Pgv_[i + 1] - Pgv_[i]) / (Gv_[i + 1] - Gv_[i]) + * (ramp_slope(gate - Gv_[i]) - ramp_slope(gate - Gv_[i + 1])); + }; + + return segment_slope(0) + segment_slope(1) + segment_slope(2) + + segment_slope(3) + segment_slope(4); + } + + /** + * @brief Solve the steady gate position for a seeded mechanical power + * + * At the steady state the head rests at the dam head, the flow rides + * the gate curve, and the turbine power less the speed-damping loss + * reproduces the seed: + * @f[ + * A_t H_0 \left(\sqrt{H_0}\,N_{\mathrm{GV}}(g) - q_{\mathrm{NL}}\right) + * - D_{\mathrm{turb}}\,\omega_0\, g = P_{\mathrm{m},0}. + * @f] + * The curve is linear on each rising segment, so the equation is solved + * segment by segment and the lowest admissible gate is selected. Flat + * segments carry no power information and are skipped. + * + * @param[in] pmech Seeded mechanical power on the component base. + * @param[in] omega Initial machine speed deviation. + * @return The gate position, or a quiet NaN when no rising segment + * reproduces the seed. + */ + template + typename Hygov::RealT + Hygov::solveInitialGate(RealT pmech, RealT omega) const + { + const RealT h0 = Hdam_; + const RealT gain = At_ * h0 * std::sqrt(h0); + const RealT damping = Dturb_ * omega; + const RealT target = pmech + At_ * h0 * Qnl_; + + if (std::abs(gain * Pgv_[0] - damping * Gv_[0] - target) <= INITIALIZATION_TOLERANCE) + { + return Gv_[0]; + } + + for (size_t i = 0; i < 5; ++i) + { + if (Pgv_[i + 1] <= Pgv_[i]) + { + continue; + } + + const RealT slope = (Pgv_[i + 1] - Pgv_[i]) / (Gv_[i + 1] - Gv_[i]); + const RealT denominator = gain * slope - damping; + if (std::abs(denominator) <= INITIALIZATION_TOLERANCE) + { + continue; + } + + const RealT gate = (target - gain * (Pgv_[i] - slope * Gv_[i])) / denominator; + if (Gv_[i] - INITIALIZATION_TOLERANCE <= gate + && gate <= Gv_[i + 1] + INITIALIZATION_TOLERANCE) + { + return gate; + } + } + + return std::numeric_limits::quiet_NaN(); + } + + /** + * @brief Convert a system-base power to HYGOV component base + * + * @param[in] value Quantity on the system base. + * @return The same quantity on the component base. + */ template scalar_type Hygov::toComponentBase(scalar_type value) const { return value * va_system_base_ / va_component_base_; } + /** + * @brief Convert a component-base power to the system base + * + * @param[in] value Quantity on the component base. + * @return The same quantity on the system base. + */ template scalar_type Hygov::toSystemBase(scalar_type value) const { return value / toComponentBase(static_cast(ONE)); } + /** + * @brief Read the parameters out of the model data + * + * Only the turbine-rating power base is required; every other parameter + * keeps the default documented in the model README when omitted. A + * missing required key or a non-numeric value is counted and reported + * by verify() rather than throwing. Integer JSON values are accepted + * for real parameters. All-zero `Gv` and `Pgv` source points select the + * identity gate curve. + * + * @param[in] data Parameters and monitored-variable selections. + */ template - void Hygov::initModelParams(const ModelDataT& data) + void Hygov::initializeParameters(const ModelDataT& data) { using Params = typename ModelDataT::Parameters; parameter_error_count_ = 0; - Trate_ = ZERO; - Rperm_ = static_cast(0.04); - Rtemp_ = static_cast(0.3); - Tr_ = static_cast(5.0); - Tf_ = static_cast(0.05); - Tg_ = static_cast(0.5); - Velm_ = static_cast(0.2); - Gmax_ = ONE; - Gmin_ = ZERO; - Tw_ = ONE; - At_ = static_cast(1.2); - Dturb_ = static_cast(0.5); - Qnl_ = static_cast(0.05); - Tn_ = ZERO; - Tnp_ = ZERO; - db1_ = ZERO; - db2_ = ZERO; - Hdam_ = ONE; - Gv_.fill(ZERO); - Pgv_.fill(ZERO); - auto load_real = [&](auto key, RealT& target, const char* name) { if (!data.parameters.contains(key)) @@ -123,19 +281,12 @@ namespace GridKit } }; - auto load_required_real = [&](auto key, RealT& target, const char* name) + if (!data.parameters.contains(Params::Trate)) { - if (!data.parameters.contains(key)) - { - Log::error() << "Hygov: missing required parameter '" << name << "'\n"; - ++parameter_error_count_; - return; - } - - load_real(key, target, name); - }; - - load_required_real(Params::Trate, Trate_, "Trate"); + Log::error() << "Hygov: missing required parameter 'Trate'\n"; + ++parameter_error_count_; + } + load_real(Params::Trate, Trate_, "Trate"); load_real(Params::Rperm, Rperm_, "Rperm"); load_real(Params::Rtemp, Rtemp_, "Rtemp"); load_real(Params::Tr, Tr_, "Tr"); @@ -166,21 +317,6 @@ namespace GridKit load_real(Params::Pgv4, Pgv_[4], "Pgv4"); load_real(Params::Pgv5, Pgv_[5], "Pgv5"); - auto check_nonnegative = [&](RealT value, const char* name) - { - if (value < ZERO) - { - Log::error() << "Hygov: parameter '" << name << "' must be non-negative\n"; - ++parameter_error_count_; - } - }; - - check_nonnegative(Tr_, "Tr"); - check_nonnegative(Tf_, "Tf"); - check_nonnegative(Tg_, "Tg"); - check_nonnegative(Tw_, "Tw"); - check_nonnegative(Tnp_, "Tnp"); - const bool source_default_curve = std::all_of(Gv_.begin(), Gv_.end(), [](RealT value) { return value == ZERO; }) @@ -200,35 +336,51 @@ namespace GridKit setDerivedParameters(); } + /** + * @brief Access the monitor + * + * @return Monitor for this model, or nullptr when the model was + * constructed without data. + */ template const Model::VariableMonitorBase* Hygov::getMonitor() const { return monitor_.get(); } + /** + * @brief Bind the monitorable variables to their internal states + * + * The mechanical-power output is published on the system base and the + * remaining outputs on the component base, as documented in the model + * README. + */ template void Hygov::initializeMonitor() { + using I = HygovIdx; using Variable = typename ModelDataT::MonitorableVariables; - auto index = [](HygovInternalVariables variable) - { - return static_cast(variable); - }; - monitor_->set(Variable::pmech, [this, index] - { return y_.getData()[index(HygovInternalVariables::PMECH)]; }); - monitor_->set(Variable::filter, [this, index] - { return y_.getData()[index(HygovInternalVariables::XF)]; }); - monitor_->set(Variable::desiredgate, [this, index] - { return y_.getData()[index(HygovInternalVariables::C)]; }); - monitor_->set(Variable::gate, [this, index] - { return y_.getData()[index(HygovInternalVariables::G)]; }); - monitor_->set(Variable::flow, [this, index] - { return y_.getData()[index(HygovInternalVariables::Q)]; }); - monitor_->set(Variable::head, [this, index] - { return y_.getData()[index(HygovInternalVariables::H)]; }); + monitor_->set(Variable::pmech, [this] + { return y_.getData()[I::PMECH]; }); + monitor_->set(Variable::filter, [this] + { return y_.getData()[I::XF]; }); + monitor_->set(Variable::desiredgate, [this] + { return y_.getData()[I::C]; }); + monitor_->set(Variable::gate, [this] + { return y_.getData()[I::G]; }); + monitor_->set(Variable::flow, [this] + { return y_.getData()[I::Q]; }); + monitor_->set(Variable::head, [this] + { return y_.getData()[I::H]; }); } + /** + * @brief Set the component ID + * + * @param[in] component_id Identifier assigned by the system model. + * @return int 0 on success. + */ template int Hygov::setGridKitComponentID(IdxT component_id) { @@ -236,16 +388,29 @@ namespace GridKit return 0; } + /** + * @brief Allocate the model vectors and wire the mechanical-power output + * + * Sizes the state, residual, and signal-interface buffers, seeds the + * identity index maps, and points the assigned `pmech` node at the + * internal state it publishes. That node aliases HYGOV storage from + * here on, which is how initialize() reads the seed the machine wrote. + * HYGOV attaches to no bus, so the bus-interface buffer stays empty. + * Repeated calls reuse the allocated vectors. + * + * @return int 0 on success. + */ template int Hygov::allocate() { - size_ = static_cast(HygovInternalVariables::MAXIMUM); - auto size = static_cast(size_); + using I = HygovIdx; + using E = HygovExt; if (!allocated_) { this->allocateVectors(size_); } + auto size = static_cast(size_); tag_.assign(size, false); variable_indices_.resize(size); @@ -253,7 +418,7 @@ namespace GridKit wb_.clear(); - auto signal_size = static_cast(HygovExternalVariables::MAXIMUM); + auto signal_size = E::MAXIMUM; ws_.assign(signal_size, ScalarT{0}); ws_indices_.assign(signal_size, INVALID_INDEX); @@ -267,18 +432,28 @@ namespace GridKit { auto* y = y_.getData(); signals_.template getSignalNode()->set( - &y[static_cast(HygovInternalVariables::PMECH)], - &(this->getVariableIndex(static_cast(HygovInternalVariables::PMECH)))); + &y[I::PMECH], + &(this->getVariableIndex(static_cast(I::PMECH)))); } allocated_ = true; return 0; } + /** + * @brief Validate the HYGOV configuration + * + * Checks parameter-loading errors, static parameter relationships, the + * gate-curve monotonicity, 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 Hygov::verify() const { - int ret = parameter_error_count_; + int ret = static_cast(parameter_error_count_); auto check = [&](bool condition, const char* message) { @@ -291,12 +466,7 @@ namespace GridKit check(Trate_ > ZERO, "Trate must be positive"); check(Rtemp_ != ZERO, "Rtemp must be nonzero"); - check(Tr_ >= ZERO, "Tr must be non-negative"); - check(Tf_ >= ZERO, "Tf must be non-negative"); - check(Tg_ >= ZERO, "Tg must be non-negative"); - check(Tw_ >= ZERO, "Tw must be non-negative"); check(Tn_ >= ZERO, "Tn must be non-negative"); - check(Tnp_ >= ZERO, "Tnp must be non-negative"); check(Velm_ >= ZERO, "Velm must be non-negative"); check(Gmin_ <= Gmax_, "Gmin must be less than or equal to Gmax"); check(At_ > ZERO, "At must be positive"); @@ -310,94 +480,58 @@ namespace GridKit check(Pgv_[i - 1] <= Pgv_[i], "Pgv points must be non-decreasing"); } - if (signals_.template isAttached() - && !signals_.template isLinked()) - { - Log::error() << "Hygov: omega signal attached with no linked source\n"; - ret += 1; - } - - if (signals_.template isAttached() - && !signals_.template isLinked()) - { - Log::error() << "Hygov: pref signal attached with no linked source\n"; - ret += 1; - } - - if (signals_.template isAttached() - && !signals_.template isLinked()) - { - Log::error() << "Hygov: paux signal attached with no linked source\n"; - ret += 1; - } - - return ret; - } - - template - scalar_type Hygov::gatePower(scalar_type gate) const - { - return ScalarT{Pgv_[0]} - + Math::linseg(gate, Gv_[0], Gv_[1], Pgv_[1] - Pgv_[0]) - + Math::linseg(gate, Gv_[1], Gv_[2], Pgv_[2] - Pgv_[1]) - + Math::linseg(gate, Gv_[2], Gv_[3], Pgv_[3] - Pgv_[2]) - + Math::linseg(gate, Gv_[3], Gv_[4], Pgv_[4] - Pgv_[3]) - + Math::linseg(gate, Gv_[4], Gv_[5], Pgv_[5] - Pgv_[4]); - } - - template - typename Hygov::RealT - Hygov::invertGatePower( - typename Hygov::RealT pgv) const - { - static constexpr RealT tol = static_cast(1.0e-10); - - if (std::abs(pgv - Pgv_[0]) <= tol) - { - return Gv_[0]; - } - - for (size_t i = 0; i < 5; ++i) + // An attached port must resolve to readable signal storage. The + // enumerator is a template argument, so each port names itself once. + auto check_attached_signal = + [&](const char* name) { - if (Pgv_[i + 1] <= Pgv_[i]) + if (signals_.template isAttached() + && !signals_.template isLinked()) { - continue; + Log::error() << "Hygov: " << name << " signal attached with no linked source\n"; + ret += 1; } + }; - if (Pgv_[i] - tol <= pgv && pgv <= Pgv_[i + 1] + tol) - { - const RealT fraction = (pgv - Pgv_[i]) / (Pgv_[i + 1] - Pgv_[i]); - return Gv_[i] + fraction * (Gv_[i + 1] - Gv_[i]); - } - } + check_attached_signal.template operator()("speed"); + check_attached_signal.template operator()("pref"); + check_attached_signal.template operator()("paux"); - return std::numeric_limits::quiet_NaN(); + return ret; } + /** + * @brief Initialize HYGOV from the seeded mechanical-power port + * + * Reads the assigned system-base `pmech` node and the attached speed + * and auxiliary-power inputs, solves the component-base steady state + * that preserves the seed, and publishes the resolved load reference to + * an attached `pref` signal. All operating-point checks are completed + * before model or signal storage is modified. + * + * @pre allocate() has completed. + * @pre The machine model has seeded the assigned `pmech` node. + * + * @return int 0 on success; nonzero when the configuration is invalid, + * no rising segment of the gate curve reproduces the seeded + * power, or the resulting gate is outside Gmin/Gmax. + */ template int Hygov::initialize() { - if (parameter_error_count_ > 0 || verify() > 0) + using I = HygovIdx; + + if (verify() > 0) { Log::error() << "Hygov: cannot initialize with invalid configuration\n"; return 1; } - const auto XN = static_cast(HygovInternalVariables::XN); - const auto XF = static_cast(HygovInternalVariables::XF); - const auto C = static_cast(HygovInternalVariables::C); - const auto G = static_cast(HygovInternalVariables::G); - const auto Q = static_cast(HygovInternalVariables::Q); - const auto OMEGADB = static_cast(HygovInternalVariables::OMEGADB); - const auto EF = static_cast(HygovInternalVariables::EF); - const auto FC = static_cast(HygovInternalVariables::FC); - const auto RC = static_cast(HygovInternalVariables::RC); - const auto PGV = static_cast(HygovInternalVariables::PGV); - const auto H = static_cast(HygovInternalVariables::H); - const auto PMECH = static_cast(HygovInternalVariables::PMECH); - - auto* y = y_.getData(); - auto* yp = yp_.getData(); + auto* y = y_.getData(); + + // The assigned pmech node aliases this entry after allocate(). Its + // system-base seed remains untouched throughout initialization. + const ScalarT pmech0 = toComponentBase(y[I::PMECH]); ScalarT omega0{ZERO}; if (signals_.template isAttached()) @@ -405,71 +539,93 @@ namespace GridKit omega0 = signals_.template readExternalVariable(); } - paux_set_ = ScalarT{ZERO}; + ScalarT paux0_system{ZERO}; if (signals_.template isAttached()) { - paux_set_ = signals_.template readExternalVariable(); + paux0_system = signals_.template readExternalVariable(); } + const ScalarT paux0 = toComponentBase(paux0_system); - const ScalarT paux0 = toComponentBase(paux_set_); - const ScalarT pmech0 = toComponentBase(y[PMECH]); - y[H] = Hdam_; - y[Q] = Qnl_ + pmech0 / (At_ * y[H]); - y[PGV] = y[Q] / std::sqrt(y[H]); - - const RealT gate0 = invertGatePower(static_cast(y[PGV])); + const RealT gate0 = solveInitialGate(static_cast(pmech0), + static_cast(omega0)); if (std::isnan(gate0)) { - Log::error() << "Hygov: initial Pgv is outside the invertible gate curve\n"; + Log::error() << "Hygov: initial mechanical power is outside the invertible gate curve\n"; return 1; } - - y[G] = gate0; - y[C] = y[G]; - - if (y[C] < Gmin_ || y[C] > Gmax_) + if (gate0 < Gmin_ - INITIALIZATION_TOLERANCE + || gate0 > Gmax_ + INITIALIZATION_TOLERANCE) { Log::error() << "Hygov: initialized gate is outside Gmin/Gmax\n"; return 1; } - y[OMEGADB] = Math::deadband1(omega0, -db1_, db1_); - y[XN] = y[OMEGADB]; - y[XF] = ZERO; - y[EF] = ZERO; - y[FC] = ZERO; - y[RC] = ZERO; - y[PMECH] = toSystemBase(pmech0); + const ScalarT h0 = static_cast(Hdam_); + const ScalarT pgv0 = gatePower(static_cast(gate0)); + const ScalarT q0 = std::sqrt(Hdam_) * pgv0; + const ScalarT omegadb0 = Math::deadband1(omega0, -db1_, db1_); + const ScalarT xn0 = omegadb0; + const ScalarT yomega0 = xn0 + leadlag_gain_ * (omegadb0 - xn0); + const ScalarT pref0 = toSystemBase(yomega0 + Rperm_ * gate0 - paux0); + + y[I::XN] = xn0; + y[I::XF] = ZERO; + y[I::C] = gate0; + y[I::G] = gate0; + y[I::Q] = q0; + y[I::OMEGADB] = omegadb0; + y[I::EF] = ZERO; + y[I::FC] = ZERO; + y[I::RC] = ZERO; + y[I::PGV] = pgv0; + y[I::H] = h0; + + pref_set_ = pref0; + paux_set_ = paux0_system; - const ScalarT yomega = y[XN] + leadlag_gain_ * (y[OMEGADB] - y[XN]); - pref_set_ = toSystemBase(y[EF] - paux0 + yomega + Rperm_ * y[C]); if (signals_.template isAttached()) { signals_.template writeExternalVariable(pref_set_); } - for (IdxT i = 0; i < size_; ++i) - { - yp[i] = ZERO; - } - y_.setDataUpdated(); - yp_.setDataUpdated(); + yp_.setToConst(static_cast(ZERO)); return 0; } + /** + * @brief Identify the differential variables + * + * The speed lead-lag state, the governor error filter, the desired + * gate, the gate servo, and the turbine flow carry derivatives; every + * other internal variable is algebraic. + * + * @return int 0 on success. + */ template int Hygov::tagDifferentiable() { + using I = HygovIdx; + std::fill(tag_.begin(), tag_.end(), false); - tag_[static_cast(HygovInternalVariables::XN)] = true; - tag_[static_cast(HygovInternalVariables::XF)] = true; - tag_[static_cast(HygovInternalVariables::C)] = true; - tag_[static_cast(HygovInternalVariables::G)] = true; - tag_[static_cast(HygovInternalVariables::Q)] = true; + tag_[I::XN] = true; + tag_[I::XF] = true; + tag_[I::C] = true; + tag_[I::G] = true; + tag_[I::Q] = true; return 0; } + /** + * @brief Compute the absolute tolerance for each variable in the model + * + * All HYGOV variables are per-unit speeds, gates, flows, heads, and + * powers 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 Hygov::setAbsoluteTolerance(RealT rel_tol) { @@ -477,6 +633,22 @@ namespace GridKit return 0; } + /** + * @brief Internal residual + * + * Evaluates the five governor states and the seven 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 gate curve enters as a fixed sum of smooth linear + * segments. + * + * @param[in] y Internal variables. + * @param[in] yp Internal variable derivatives. + * @param[in] wb Bus voltage components; unused, HYGOV attaches to no bus. + * @param[in] ws External signal values on system base. + * @param[out] f Internal residuals. + * @return int 0 on success. + */ template __attribute__((always_inline)) inline int Hygov::evaluateInternalResidual( @@ -486,96 +658,95 @@ namespace GridKit const ScalarT* ws, ScalarT* f) { - const auto XN = static_cast(HygovInternalVariables::XN); - const auto XF = static_cast(HygovInternalVariables::XF); - const auto C = static_cast(HygovInternalVariables::C); - const auto G = static_cast(HygovInternalVariables::G); - const auto Q = static_cast(HygovInternalVariables::Q); - const auto OMEGADB = static_cast(HygovInternalVariables::OMEGADB); - const auto EF = static_cast(HygovInternalVariables::EF); - const auto FC = static_cast(HygovInternalVariables::FC); - const auto RC = static_cast(HygovInternalVariables::RC); - const auto PGV = static_cast(HygovInternalVariables::PGV); - const auto H = static_cast(HygovInternalVariables::H); - const auto PMECH = static_cast(HygovInternalVariables::PMECH); - - const auto OMEGA = static_cast(HygovExternalVariables::OMEGA); - const auto PREF = static_cast(HygovExternalVariables::PREF); - const auto PAUX = static_cast(HygovExternalVariables::PAUX); - - const ScalarT xn = y[XN]; - const ScalarT xf = y[XF]; - const ScalarT c = y[C]; - const ScalarT g = y[G]; - const ScalarT q = y[Q]; - const ScalarT omegadb = y[OMEGADB]; - const ScalarT ef = y[EF]; - const ScalarT fc = y[FC]; - const ScalarT rc = y[RC]; - const ScalarT pgv = y[PGV]; - const ScalarT head = y[H]; - const ScalarT pmech = y[PMECH]; - - const ScalarT omega = ws[OMEGA]; - const ScalarT pref = toComponentBase(ws[PREF]); - const ScalarT paux = toComponentBase(ws[PAUX]); + using I = HygovIdx; + using E = HygovExt; + + const ScalarT xn = y[I::XN]; + const ScalarT xf = y[I::XF]; + const ScalarT c = y[I::C]; + const ScalarT g = y[I::G]; + const ScalarT q = y[I::Q]; + const ScalarT omegadb = y[I::OMEGADB]; + const ScalarT ef = y[I::EF]; + const ScalarT fc = y[I::FC]; + const ScalarT rc = y[I::RC]; + const ScalarT pgv = y[I::PGV]; + const ScalarT head = y[I::H]; + const ScalarT pmech = y[I::PMECH]; + + const ScalarT xn_dot = yp[I::XN]; + const ScalarT xf_dot = yp[I::XF]; + const ScalarT c_dot = yp[I::C]; + const ScalarT g_dot = yp[I::G]; + const ScalarT q_dot = yp[I::Q]; + + const ScalarT omega = ws[E::OMEGA]; + const ScalarT pref = toComponentBase(ws[E::PREF]); + const ScalarT paux = toComponentBase(ws[E::PAUX]); const ScalarT yomega = xn + leadlag_gain_ * (omegadb - xn); - f[XN] = -yp[XN] + (omegadb - xn) / Tnp_; - f[XF] = -yp[XF] + (ef - xf) / Tf_; - f[C] = -yp[C] + Math::antiwindup(c, rc, Gmin_, Gmax_); - f[G] = -yp[G] + (c - g) / Tg_; - f[Q] = -yp[Q] + (Hdam_ - head) / Tw_; - f[OMEGADB] = -omegadb + Math::deadband1(omega, -db1_, db1_); - f[EF] = -ef + pref + paux - yomega - Rperm_ * c; - f[FC] = -fc + (xf / Tr_ + (ef - xf) / Tf_) / Rtemp_; - f[RC] = -rc + Math::clamp(fc, -Velm_, Velm_); - f[PGV] = -pgv + gatePower(g); - f[H] = -q * q + head * pgv * pgv; - f[PMECH] = -toComponentBase(pmech) + At_ * head * (q - Qnl_) - Dturb_ * omega * g; + f[I::XN] = -xn_dot + (omegadb - xn) / Tnp_; + f[I::XF] = -xf_dot + (ef - xf) / Tf_; + f[I::C] = -c_dot + Math::antiwindup(c, rc, Gmin_, Gmax_); + f[I::G] = -g_dot + (c - g) / Tg_; + f[I::Q] = -q_dot + (Hdam_ - head) / Tw_; + f[I::OMEGADB] = -omegadb + Math::deadband1(omega, -db1_, db1_); + f[I::EF] = -ef + pref + paux - yomega - Rperm_ * c; + f[I::FC] = -fc + (xf / Tr_ + (ef - xf) / Tf_) / Rtemp_; + f[I::RC] = -rc + Math::clamp(fc, -Velm_, Velm_); + f[I::PGV] = -pgv + gatePower(g); + f[I::H] = -q * q + head * pgv * pgv; + f[I::PMECH] = -toComponentBase(pmech) + At_ * head * (q - Qnl_) - Dturb_ * omega * g; return 0; } + /** + * @brief Residuals of system equations + * + * Refreshes the signal interface buffers and evaluates the internal + * residual. HYGOV attaches to no bus, so there is no bus interface to + * refresh. An unattached reference or auxiliary port falls back to the + * value latched by initialize(); an unattached speed port reads zero + * deviation. + * + * @return int 0 on success. + */ template int Hygov::evaluateResidual() { - const auto OMEGA = static_cast(HygovExternalVariables::OMEGA); - const auto PREF = static_cast(HygovExternalVariables::PREF); - const auto PAUX = static_cast(HygovExternalVariables::PAUX); + using E = HygovExt; - ws_[OMEGA] = ZERO; - ws_[PREF] = pref_set_; - ws_[PAUX] = paux_set_; + ws_[E::OMEGA] = ZERO; + ws_[E::PREF] = pref_set_; + ws_[E::PAUX] = paux_set_; std::fill(ws_indices_.begin(), ws_indices_.end(), INVALID_INDEX); if (signals_.template isAttached()) { - ws_[OMEGA] = signals_.template readExternalVariable(); - ws_indices_[OMEGA] = + ws_[E::OMEGA] = signals_.template readExternalVariable(); + ws_indices_[E::OMEGA] = signals_.template readExternalVariableIndex(); } - if (signals_.template isAttached()) { - ws_[PREF] = signals_.template readExternalVariable(); - ws_indices_[PREF] = + ws_[E::PREF] = signals_.template readExternalVariable(); + ws_indices_[E::PREF] = signals_.template readExternalVariableIndex(); } - if (signals_.template isAttached()) { - ws_[PAUX] = signals_.template readExternalVariable(); - ws_indices_[PAUX] = + ws_[E::PAUX] = signals_.template readExternalVariable(); + ws_indices_[E::PAUX] = signals_.template readExternalVariableIndex(); } 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/Governor/HYGOV/README.md b/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md index 526ebec1b..25535e2a9 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md @@ -16,57 +16,63 @@ a nonlinear single-penstock turbine. ## Block Diagram -Standard HYGOV block diagram. +![HYGOV governor block diagram](../../../../../docs/Figures/PhasorDynamics/HYGOV/diagram.png) -![](../../../../../docs/Figures/PhasorDynamics/HYGOV/diagram.png) - -Figure 1: HYGOV block diagram. Figure courtesy of [PowerWorld](https://www.powerworld.com/WebHelp/) +Figure 1: HYGOV governor model. Figure courtesy of the +[PowerWorld HYGOV model reference](https://www.powerworld.com/WebHelp/Content/TransientModels_HTML/Governor%20HYGOV%20and%20HYGOVD.htm). ## Model Parameters -Symbol | Units | JSON | Description | Typical Value | Note ---------------------------------|----------|----------|----------------------------------------------|---------------|------ -$T^\mathrm{rate}$ | [MW] | `Trate` | Turbine-rating power base | 100.0 | Required positive value -$R_{\mathrm{perm}}$ | [p.u.] | `Rperm` | Permanent droop | 0.04 | Source diagram label: `R` -$R_{\mathrm{temp}}$ | [p.u.] | `Rtemp` | Temporary droop | 0.3 | Source diagram label: `r` -$T_r$ | [sec] | `Tr` | Temporary-droop reset time constant | 5.0 | -$T_f$ | [sec] | `Tf` | Governor error filter time constant | 0.05 | State 1 -$T_g$ | [sec] | `Tg` | Gate servo time constant | 0.5 | State 3 -$V_{\mathrm{elm}}$ | [p.u./s] | `Velm` | Maximum desired-gate velocity magnitude | 0.2 | Symmetric rate limit on State 2 -$G^{\max}$ | [p.u.] | `Gmax` | Maximum desired-gate position | 1.0 | -$G^{\min}$ | [p.u.] | `Gmin` | Minimum desired-gate position | 0.0 | -$T_w$ | [sec] | `Tw` | Water inertia time constant | 1.0 | State 4 -$A_t$ | [p.u.] | `At` | Turbine gain | 1.2 | -$D_{\mathrm{turb}}$ | [p.u.] | `Dturb` | Turbine damping coefficient | 0.5 | Multiplied by speed deviation and gate -$q_{\mathrm{NL}}$ | [p.u.] | `Qnl` | No-load flow at nominal head | 0.05 | -$T_n$ | [sec] | `Tn` | Speed lead-lag numerator time constant | 0.0 | -$T_{np}$ | [sec] | `Tnp` | Speed lead-lag denominator time constant | 0.0 | -$D_{\omega}$ | [p.u.] | `db1` | Type 1 speed deadband threshold | 0.0 | Uses CommonMath `deadband1` -$H_{\mathrm{dam}}$ | [p.u.] | `Hdam` | Head available at dam | 1.0 | -$G_V^{(k)}$ | [p.u.] | `Gv0`-`Gv5` | Gate point $k$ of the gain curve | 0.0 | $k=0,\ldots,5$ -$P_{\mathrm{GV}}^{(k)}$ | [p.u.] | `Pgv0`-`Pgv5` | Power point $k$ of the gain curve | 0.0 | $k=0,\ldots,5$ +Symbol | Units | JSON | Description | Typical Value | Note +------------------------|----------|---------------|------------------------------------------|---------------|------ +$T^\mathrm{rate}$ | [MW] | `Trate` | Turbine-rating power base | 100.0 | Required positive value +$R_{\mathrm{perm}}$ | [p.u.] | `Rperm` | Permanent droop | 0.04 | Source label: `R` +$R_{\mathrm{temp}}$ | [p.u.] | `Rtemp` | Temporary droop | 0.3 | Source label: `r` +$T_r$ | [sec] | `Tr` | Temporary-droop reset time constant | 5.0 | Raised to the minimum-time floor +$T_f$ | [sec] | `Tf` | Governor error filter time constant | 0.05 | State 1; raised to the minimum-time floor +$T_g$ | [sec] | `Tg` | Gate servo time constant | 0.5 | State 3; raised to the minimum-time floor +$V_{\mathrm{elm}}$ | [p.u./s] | `Velm` | Maximum desired-gate velocity magnitude | 0.2 | Symmetric rate limit on State 2 +$G^{\max}$ | [p.u.] | `Gmax` | Maximum desired-gate position | 1.0 | +$G^{\min}$ | [p.u.] | `Gmin` | Minimum desired-gate position | 0.0 | +$T_w$ | [sec] | `Tw` | Water inertia time constant | 1.0 | State 4; raised to the minimum-time floor +$A_t$ | [p.u.] | `At` | Turbine gain | 1.2 | +$D_{\mathrm{turb}}$ | [p.u.] | `Dturb` | Turbine damping coefficient | 0.5 | Multiplied by speed deviation and gate +$q_{\mathrm{NL}}$ | [p.u.] | `Qnl` | No-load flow at nominal head | 0.05 | +$T_n$ | [sec] | `Tn` | Speed lead-lag numerator time constant | 0.0 | +$T_{\mathrm{np}}$ | [sec] | `Tnp` | Speed lead-lag denominator time constant | 0.0 | Raised to the minimum-time floor +$D_{\omega}$ | [p.u.] | `db1` | Type 1 speed deadband threshold | 0.0 | Uses CommonMath `deadband1` +$D_2$ | [p.u.] | `db2` | Unsupported mechanical backlash deadband | 0.0 | Accepted for source-format compatibility; not modeled +$H_{\mathrm{dam}}$ | [p.u.] | `Hdam` | Head available at dam | 1.0 | +$G_V^{(k)}$ | [p.u.] | `Gv0`-`Gv5` | Gate point $k$ of the gain curve | 0.0 | $k=0,\ldots,5$ +$P_{\mathrm{GV}}^{(k)}$ | [p.u.] | `Pgv0`-`Pgv5` | Power point $k$ of the gain curve | 0.0 | $k=0,\ldots,5$ All-zero `Gv` and `Pgv` source points select the identity curve. ### Parameter Validation -Invalid HYGOV parameter sets are rejected by the following checks. The -displayed equations use effective time constants with $\epsilon_T=10^{-3}$. +Invalid HYGOV 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_f,T_g,T_w,T_{np}\} \\ - T^\mathrm{rate}, H_{\mathrm{dam}}, A_t - &> 0 \\ - T_r, T_f, T_g, T_w, T_n, T_{np} + T^\mathrm{rate} &> 0 \\ + T_r, T_f, T_g, T_w, T_{\mathrm{np}} &\ge 0 \\ R_{\mathrm{temp}} &\ne 0 \\ - V_{\mathrm{elm}}, D_{\mathrm{turb}}, D_{\omega} + T_n + &\ge 0 \\ + V_{\mathrm{elm}} &\ge 0 \\ G^{\min} &\le G^{\max} \\ + A_t + &> 0 \\ + D_{\mathrm{turb}} + &\ge 0 \\ + D_{\omega} + &\ge 0 \\ + H_{\mathrm{dam}} + &> 0 \\ G_V^{(k)} &< G_V^{(k+1)} \quad k\in\{0,\ldots,4\} \\ @@ -76,17 +82,20 @@ displayed equations use effective time constants with $\epsilon_T=10^{-3}$. \end{aligned} ``` -Initialization also requires $N_{\mathrm{GV}}^{-1}$ to be single-valued at the -initial operating point. - ### 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,f,g,w,\mathrm{np}\} \\ k_{\mathrm{base}} &= \dfrac{S^\mathrm{sys}}{T^\mathrm{rate}} \\ k_n - &= \dfrac{T_n}{T_{np}} \\ + &= \dfrac{T_n}{T_{\mathrm{np}}} \\ N_{\mathrm{GV}}(x) &= P_{\mathrm{GV}}^{(0)} @@ -100,8 +109,10 @@ initial operating point. \end{aligned} ``` -CommonMath defines the [linear segment](../../../../CommonMath.md#linseg) -helper used by $N_{\mathrm{GV}}$. +Multiplying by $k_\mathrm{base}$ converts system base to component base. + +CommonMath defines the [`linseg`](../../../../CommonMath.md#linseg) helper +used by $N_{\mathrm{GV}}$. ## Model Ports @@ -110,7 +121,11 @@ Name | Port | Init | Description `speed` | Input | Known | Machine speed deviation `pref` | Input | Unknown | Active-power/load reference `paux` | Input | Known | Auxiliary power input -`pmech` | Output | Unknown | Mechanical power output +`pmech` | Output | Known | Mechanical power 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 @@ -128,41 +143,39 @@ $q$ | [p.u.] | Turbine flow | State 4 #### Algebraic -Symbol | Units | Description | Note ---------------------------------|----------|-------------------------------------|------ -$\omega_{\mathrm{db}}$ | [p.u.] | Type 1 deadbanded speed deviation | Defined by CommonMath `deadband1` -$e_f$ | [p.u.] | Governor error into the filter | Reference path less conditioned speed and permanent-droop feedback -$f_c$ | [p.u./s] | Desired-gate derivative target | Before rate and position limits -$r_c$ | [p.u./s] | Rate-limited desired-gate derivative target | Limited by $\pm V_{\mathrm{elm}}$ -$P_{\mathrm{GV}}$ | [p.u.] | Nonlinear gate-to-power curve output | $N_{\mathrm{GV}}(g)$ -$H$ | [p.u.] | Turbine head | Implicit water-column head -$P_{\text{m}}$ | [p.u.] | Mechanical power to generator | System base; assigned to `pmech` +Symbol | Units | Description | Note +------------------------|----------|---------------------------------------------|------ +$\omega_{\mathrm{db}}$ | [p.u.] | Type 1 deadbanded speed deviation | Defined by CommonMath `deadband1` +$e_f$ | [p.u.] | Governor error into the filter | Reference path less conditioned speed and permanent-droop feedback +$f_c$ | [p.u./s] | Desired-gate derivative target | Before rate and position limits +$r_c$ | [p.u./s] | Rate-limited desired-gate derivative target | Limited by $\pm V_{\mathrm{elm}}$ +$P_{\mathrm{GV}}$ | [p.u.] | Nonlinear gate-to-power curve output | $N_{\mathrm{GV}}(g)$ +$H$ | [p.u.] | Turbine head | Implicit water-column head +$P_{\mathrm{m}}$ | [p.u.] | Mechanical power to generator | System base; assigned to `pmech` ### External Variables #### Differential + None. #### Algebraic -Symbol | Units | Description | Note ---------------------------------|--------|-----------------------------|------ -$\omega$ | [p.u.] | Machine speed deviation | Defaults to zero -$P^\mathrm{ref}$ | [p.u.] | Active-power/load reference | System base -$P^\mathrm{aux}$ | [p.u.] | Auxiliary power input | System base; defaults to zero +Symbol | Units | Init | Description | Note +------------------|--------|---------|-----------------------------|------ +$\omega$ | [p.u.] | Known | Machine speed deviation | Optional signal port `speed`; defaults to zero +$P^\mathrm{ref}$ | [p.u.] | Unknown | Active-power/load reference | Optional signal port `pref`; system base +$P^\mathrm{aux}$ | [p.u.] | Known | Auxiliary power input | Optional signal port `paux`; system base; defaults to zero ## Model Equations ### Differential Equations -The lag residuals are written in Hessenberg form using the effective time -constants defined in [Parameter Validation](#parameter-validation). - ```math \begin{aligned} 0 &= -\dot{x}_n - + \dfrac{1}{T_{np}} + + \dfrac{1}{T_{\mathrm{np}}} \left(\omega_{\mathrm{db}} - x_n\right) \\ 0 &= -\dot{x}_f @@ -183,7 +196,7 @@ constants defined in [Parameter Validation](#parameter-validation). \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 @@ -219,15 +232,14 @@ target and smooth approximation. -q^2 + H P_{\mathrm{GV}}^2 \\ 0 &= - -k_{\mathrm{base}}P_{\text{m}} + -k_{\mathrm{base}}P_{\mathrm{m}} + A_t H\left(q - q_{\mathrm{NL}}\right) - D_{\mathrm{turb}}\omega g \end{aligned} ``` CommonMath defines helper targets and smooth approximations for -[deadband1](../../../../CommonMath.md#deadband1) and -[clamp](../../../../CommonMath.md#clamp). +[deadband1 and clamp](../../../../CommonMath.md#derived-functions). ## Initialization @@ -237,46 +249,66 @@ CommonMath defines helper targets and smooth approximations for \begin{aligned} \omega &\leftarrow \text{machine speed deviation} \\ - P_{\text{m}} - &\leftarrow \text{machine mechanical-power start on system base} \\ + P_{\mathrm{m}} + &\leftarrow \text{machine mechanical-power seed on system base} \\ P^\mathrm{aux} &\leftarrow \text{auxiliary power input on system base} \end{aligned} ``` +Initialization never replaces the system-base value held in $P_{\mathrm{m}}$. + ### Internal Initialization -Initialization is performed by evaluating the steady-state residuals in -dependency order: +Initialization evaluates the steady-state residuals in dependency order. +Subscript $0$ denotes initial values; all internal derivatives start at zero. +The gate solves the steady turbine-power equation on the rising linear +segments of $N_{\mathrm{GV}}$; when several segments reproduce the seeded +power, the lowest admissible gate is selected: ```math \begin{aligned} - H + H_0 &= H_{\mathrm{dam}} \\ - q - &= q_{\mathrm{NL}} - + \dfrac{k_{\mathrm{base}}P_{\text{m},0}}{A_tH_0} \\ - P_{\mathrm{GV}} - &= \dfrac{q_0}{\sqrt{H_0}} \\ - g - &= N_{\mathrm{GV}}^{-1}\!\left(P_{\mathrm{GV},0}\right) \\ - c + k_{\mathrm{base}}P_{\mathrm{m},0} + &= A_t H_0\left(\sqrt{H_0}\,N_{\mathrm{GV}}(g_0) - q_{\mathrm{NL}}\right) + - D_{\mathrm{turb}}\,\omega_0\, g_0 \\ + P_{\mathrm{GV},0} + &= N_{\mathrm{GV}}(g_0) \\ + q_0 + &= \sqrt{H_0}\,P_{\mathrm{GV},0} \\ + c_0 &= g_0 \\ - \omega_{\mathrm{db}} + \omega_{\mathrm{db},0} &= \text{deadband1}\!\left(\omega_0;\, -D_{\omega}, D_{\omega}\right) \\ - x_n + x_{n,0} &= \omega_{\mathrm{db},0} \\ - x_f + x_{f,0} &= 0 \\ - e_f + e_{f,0} &= 0 \\ - f_c + f_{c,0} &= 0 \\ - r_c + r_{c,0} &= 0 \end{aligned} ``` +Initialization rejects an operating point when any of the following holds: + +- no rising segment of $N_{\mathrm{GV}}$ reproduces the seeded mechanical + power; or +- the resulting gate lies outside $[G^{\min}, G^{\max}]$ by more than + $\epsilon_0 = 10^{-10}$. + +The gate is solved on the piecewise-linear gain curve while the residual +evaluates its smooth approximation, so operating points within a few +hundredths of a $G_V$ breakpoint start with a mechanical-power residual up to +$O(10^{-3})$; mid-segment points rest at $O(10^{-13})$. + +Every check resolves before any storage is written, so a rejected +initialization leaves state, the `pmech` seed, and external signals unchanged. + ### Output Initialization ```math @@ -300,11 +332,11 @@ reference input. ## Monitorable Outputs -Output | Units | Description | Note ----------------|--------|-------------------------------------|------ -`pmech` | [p.u.] | Mechanical-power output | $P_{\text{m}}$ (system base) -`filter` | [p.u.] | Governor error filter output | $x_f$ -`desiredgate` | [p.u.] | Desired-gate position | $c$ -`gate` | [p.u.] | Gate position | $g$ -`flow` | [p.u.] | Turbine flow | $q$ -`head` | [p.u.] | Turbine head | $H$ +Output | Units | Description | Note +---------------|--------|------------------------------|------ +`pmech` | [p.u.] | Mechanical-power output | $P_{\mathrm{m}}$ (system base) +`filter` | [p.u.] | Governor error filter output | $x_f$ (component base) +`desiredgate` | [p.u.] | Desired-gate position | $c$ (component base) +`gate` | [p.u.] | Gate position | $g$ (component base) +`flow` | [p.u.] | Turbine flow | $q$ (component base) +`head` | [p.u.] | Turbine head | $H$ (component base) diff --git a/docs/GridKit/Model/PhasorDynamics/Governor/README.md b/docs/GridKit/Model/PhasorDynamics/Governor/README.md index 4e38abd24..45b32ae26 100644 --- a/docs/GridKit/Model/PhasorDynamics/Governor/README.md +++ b/docs/GridKit/Model/PhasorDynamics/Governor/README.md @@ -6,9 +6,9 @@ :hidden: TGOV1 +HYGOV IEEEG1 GGOV1 -HYGOV ``` ```{include} ../../../../../GridKit/Model/PhasorDynamics/Governor/README.md diff --git a/tests/UnitTests/PhasorDynamics/CMakeLists.txt b/tests/UnitTests/PhasorDynamics/CMakeLists.txt index c0517d7ec..122e925ee 100644 --- a/tests/UnitTests/PhasorDynamics/CMakeLists.txt +++ b/tests/UnitTests/PhasorDynamics/CMakeLists.txt @@ -92,8 +92,8 @@ add_executable(test_phasor_governor_hygov runGovernorHygovTests.cpp) target_link_libraries( test_phasor_governor_hygov GridKit::definitions - GridKit::phasor_dynamics_systemmodel - GridKit::phasor_dynamics_systemmodel_dependency_tracking + GridKit::phasor_dynamics_governor_hygov + GridKit::phasor_dynamics_governor_hygov_dependency_tracking GridKit::testing) add_executable(test_phasor_exciter_ieeet1 runExciterIeeet1Tests.cpp) diff --git a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp index 0a4412b25..e6315c8dd 100644 --- a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp +++ b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp @@ -1,23 +1,29 @@ #pragma once -#include +#include +#include +#include #include #include -#include +#include +#include #include #include #include #include -#include -#include +#include #include #include +#include +#include namespace GridKit { namespace Testing { + using Log = ::GridKit::Utilities::Logger; + template class GovernorHygovTests { @@ -25,603 +31,1096 @@ namespace GridKit using ScalarT = scalar_type; using IdxT = index_type; using RealT = typename PhasorDynamics::Component::RealT; - using Gov = PhasorDynamics::Governor::Hygov; - using Data = PhasorDynamics::Governor::HygovData; - using Var = PhasorDynamics::Governor::HygovInternalVariables; - using Ext = PhasorDynamics::Governor::HygovExternalVariables; - using Params = PhasorDynamics::Governor::HygovParameters; - using Mon = PhasorDynamics::Governor::HygovMonitorableVariables; - static constexpr ScalarT kTol = static_cast(1.0e-8); + GovernorHygovTests() = default; + ~GovernorHygovTests() = default; + + // HYGOV initialization solves the piecewise-linear gate curve exactly + // while the residual rides its smooth CommonMath approximation. At the + // mid-segment operating points used here the resulting steady residuals + // are O(1e-13), so behavioral comparisons use a tolerance well above + // that gap and well below every pinned answer-key digit. + 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; - TestOutcome constructionAndValidation() + /// Construction and every verify() error class, including parameter + /// types, parameter relationships, curve monotonicity, and signal + /// linkage. + TestOutcome validation() { TestStatus success = true; - Gov hygov(makeHygovData()); - success *= (hygov.size() == static_cast(Var::MAXIMUM)); - success *= (hygov.getMonitor() != nullptr); - success *= (hygov.verify() == 0); - - auto source_default_hygov = makeHygovSourceDefaultData(); - Gov source_default_hygov_model(source_default_hygov); - success *= (source_default_hygov_model.verify() == 0); - - auto bad_hygov_curve = makeHygovData(); - bad_hygov_curve.parameters[Params::Gv2] = static_cast(0.2); - Gov bad_hygov_curve_model(bad_hygov_curve); - success *= (bad_hygov_curve_model.verify() > 0); - - auto bad_hygov_trate = makeHygovData(); - bad_hygov_trate.parameters[Params::Trate] = static_cast(0.0); - Gov bad_hygov_trate_model(bad_hygov_trate); - success *= (bad_hygov_trate_model.verify() > 0); - - auto missing_hygov_trate = makeHygovData(); - missing_hygov_trate.parameters.erase(Params::Trate); - Gov missing_hygov_trate_model(missing_hygov_trate); - success *= (missing_hygov_trate_model.verify() > 0); - - auto bad_hygov_hdam = makeHygovData(); - bad_hygov_hdam.parameters[Params::Hdam] = static_cast(0.0); - Gov bad_hygov_hdam_model(bad_hygov_hdam); - success *= (bad_hygov_hdam_model.verify() > 0); - - auto bad_hygov_rtemp = makeHygovData(); - bad_hygov_rtemp.parameters[Params::Rtemp] = static_cast(0.0); - Gov bad_hygov_rtemp_model(bad_hygov_rtemp); - success *= (bad_hygov_rtemp_model.verify() > 0); - - auto bad_hygov_limits = makeHygovData(); - bad_hygov_limits.parameters[Params::Velm] = static_cast(-0.1); - bad_hygov_limits.parameters[Params::db1] = static_cast(-0.1); - Gov bad_hygov_limits_model(bad_hygov_limits); - success *= (bad_hygov_limits_model.verify() > 0); - - auto bad_hygov_gate_limits = makeHygovData(); - bad_hygov_gate_limits.parameters[Params::Gmin] = static_cast(1.1); - Gov bad_hygov_gate_limits_model(bad_hygov_gate_limits); - success *= (bad_hygov_gate_limits_model.verify() > 0); + PhasorDynamics::Governor::Hygov empty; + success *= (empty.size() == static_cast(I::MAXIMUM)); + success *= (empty.getMonitor() == nullptr); + + PhasorDynamics::Governor::Hygov configured(makeData()); + success *= (configured.size() == static_cast(I::MAXIMUM)); + success *= (configured.getMonitor() != nullptr); + success *= (configured.verify() == 0); + + noteExpectedLogs("Testing HYGOV defaults and invalid configurations. " + "Logged errors and time-constant warnings are expected."); + + auto minimal_data = makeMinimalData(); + minimal_data.parameters[Params::Trate] = 100.0; + PhasorDynamics::Governor::Hygov minimal(minimal_data); + success *= (minimal.verify() == 0); + success *= defaultsMatchDocumentedValues(); + + success *= (empty.verify() > 0); + + PhasorDynamics::Governor::Hygov missing_trate(makeMinimalData()); + success *= (missing_trate.verify() > 0); + + success *= invalidParameterCase(Params::Trate, 0.0); + success *= invalidParameterCase(Params::Rtemp, 0.0); + success *= invalidParameterCase(Params::Tr, -0.1); + success *= invalidParameterCase(Params::Tf, -0.1); + success *= invalidParameterCase(Params::Tg, -0.1); + success *= invalidParameterCase(Params::Tw, -0.1); + success *= invalidParameterCase(Params::Tn, -0.1); + success *= invalidParameterCase(Params::Tnp, -0.1); + success *= invalidParameterCase(Params::Velm, -0.1); + success *= invalidParameterCase(Params::Gmin, 1.1); + success *= invalidParameterCase(Params::At, 0.0); + success *= invalidParameterCase(Params::Dturb, -0.1); + success *= invalidParameterCase(Params::db1, -0.1); + success *= invalidParameterCase(Params::Hdam, 0.0); + success *= invalidParameterCase(Params::Gv2, 0.1); + success *= invalidParameterCase(Params::Pgv2, 0.1); + + // db2 is accepted for source-format compatibility and never used. + auto backlash_data = makeData(); + backlash_data.parameters[Params::db2] = 0.5; + PhasorDynamics::Governor::Hygov backlash_model(backlash_data); + success *= (backlash_model.verify() == 0); + + // Integer JSON values are accepted for real parameters; booleans are + // not numeric. + auto integer_real = makeData(); + integer_real.parameters[Params::Tw] = static_cast(2); + PhasorDynamics::Governor::Hygov integer_real_model(integer_real); + success *= (integer_real_model.verify() == 0); + + auto bad_numeric_type = makeData(); + bad_numeric_type.parameters[Params::Trate] = true; + PhasorDynamics::Governor::Hygov bad_numeric_model(bad_numeric_type); + success *= (bad_numeric_model.verify() > 0); + + success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); + + // All five zero time constants 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::Tf] = 0.0; + zero_time.parameters[Params::Tg] = 0.0; + zero_time.parameters[Params::Tw] = 0.0; + zero_time.parameters[Params::Tnp] = 0.0; + + Fixture fixture(zero_time); + success *= fixture.initialize(0.4); + success *= (fixture.evaluate() == 0); + success *= allResidualsZero(fixture.hygov); return success.report(__func__); } - TestOutcome signals() + /// A nonidentity power-base initialization with every port attached. + /// The machine-seeded pmech node must remain unchanged while HYGOV + /// initializes and publishes its resolved load reference. + TestOutcome initializationAndSignals() { TestStatus success = true; - PhasorDynamics::SignalNode pmech_node; - PhasorDynamics::SignalNode omega_node; - ScalarT pmech_value{0.40}; - ScalarT omega_value{0.0}; - IdxT pmech_index = INVALID_INDEX; - IdxT omega_index = 5; - pmech_node.set(&pmech_value, &pmech_index); - omega_node.set(&omega_value, &omega_index); - - Gov hygov(makeHygovData()); - auto& hygov_signals = hygov.getSignals(); - hygov_signals.template assignSignalNode(&pmech_node); - hygov_signals.template attachSignalNode(&omega_node); - - success *= (hygov.allocate() == 0); - pmech_node.init(pmech_value); - success *= pmech_node.linked(); - success *= (pmech_node.getVariableIndex() == static_cast(Var::PMECH)); - success *= (hygov.verify() == 0); - success *= (hygov.initialize() == 0); - success *= (hygov.tagDifferentiable() == 0); - success *= (hygov.evaluateResidual() == 0); - - const auto* y = hygov.y().getData(); - const auto* yp = hygov.yp().getData(); - const auto& residual = hygov.getResidual(); - const auto* f = residual.getData(); - success *= isEqual(y[index(Var::Q)], static_cast(0.50), kTol); - success *= isEqual(y[index(Var::G)], static_cast(0.50), kTol); - success *= isEqual(y[index(Var::C)], static_cast(0.50), kTol); - success *= (hygov.tag()[index(Var::G)] == true); - - for (size_t i = 0; i < residual.getSize(); ++i) - { - success *= isEqual(f[i], static_cast(0.0), kTol); - success *= isEqual(yp[i], static_cast(0.0), kTol); + auto data = makeData(); + data.parameters[Params::Trate] = 50.0; + + Fixture fixture(data); + fixture.attachAllInputs(); + fixture.input(E::PAUX) = 0.02; + fixture.input(E::PREF) = 99.0; // stale value the publication must replace + success *= fixture.initialize(0.4); + success *= (fixture.hygov.tagDifferentiable() == 0); + success *= (fixture.evaluate() == 0); + + const auto* y = fixture.hygov.y().getData(); + success *= scalarMatches(y[I::XF], 0.0, "XF at rest"); + success *= scalarMatches(y[I::C], 0.9, "C on component base"); + success *= scalarMatches(y[I::G], 0.9, "G on component base"); + success *= scalarMatches(y[I::Q], 0.9, "Q on component base"); + success *= scalarMatches(y[I::PGV], 0.9, "PGV on component base"); + success *= scalarMatches(y[I::H], 1.0, "H at the dam head"); + success *= scalarMatches(fixture.pmech(), 0.4, "preserved pmech seed"); + + success *= scalarMatches(fixture.input(E::OMEGA), 0.0, "preserved omega input"); + success *= scalarMatches(fixture.input(E::PREF), 0.0025, "published pref"); + success *= scalarMatches(fixture.input(E::PAUX), 0.02, "preserved paux input"); + + RealT time = 0.0; + Model::VariableMonitorController monitor(time); + monitor.addMonitor(fixture.hygov.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,Hygov_hygov_test_pmech,Hygov_hygov_test_filter," + "Hygov_hygov_test_desiredgate,Hygov_hygov_test_gate," + "Hygov_hygov_test_flow,Hygov_hygov_test_head"); + const auto monitored = Tokenizer(monitor_values, ',')(); + if (monitored.size() == 7) + { + success *= scalarMatches(monitored[1], 0.4, "monitored pmech"); + success *= scalarMatches(monitored[2], 0.0, "monitored filter"); + success *= scalarMatches(monitored[3], 0.9, "monitored desiredgate"); + success *= scalarMatches(monitored[4], 0.9, "monitored gate"); + success *= scalarMatches(monitored[5], 0.9, "monitored flow"); + success *= scalarMatches(monitored[6], 1.0, "monitored head"); + } + else + { + std::cout << "HYGOV monitor emitted " << monitored.size() + << " values instead of 7\n"; + success = false; } + for (size_t i = 0; i < static_cast(fixture.hygov.size()); ++i) + { + const bool expected = i <= I::Q; + if (fixture.hygov.tag()[i] != expected) + { + std::cout << "HYGOV differentiability tag " << i << " mismatch\n"; + success = false; + } + } + success *= allResidualsZero(fixture.hygov); + + // A system-base reference step lands on the governor error scaled by + // the base ratio. + fixture.input(E::PREF) = 0.1025; // the published 0.0025 plus a 0.1 step + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.hygov, + {{I::EF, 0.2}}, + "reference step on the component base"); + + // Unattached ports fall back to the references latched by + // initialize(), so the same steady state holds without a controller. + Fixture fallback(data); + success *= fallback.initialize(0.4); + success *= (fallback.evaluate() == 0); + success *= allResidualsZero(fallback.hygov); + return success.report(__func__); } - TestOutcome sourceDefault() + /// Mechanical-power and gate-limit initialization domains. Every + /// rejection is atomic; exact Gmin/Gmax boundaries and a zero power + /// seed remain admissible. + TestOutcome initializationDomain() { TestStatus success = true; - PhasorDynamics::SignalNode pmech_node; - ScalarT pmech_value{0.40}; - IdxT pmech_index = INVALID_INDEX; - pmech_node.set(&pmech_value, &pmech_index); + noteExpectedLogs("Testing inadmissible HYGOV mechanical-power and gate " + "initialization points. Logged errors are expected."); - Gov hygov(makeHygovSourceDefaultData()); - hygov.getSignals().template assignSignalNode(&pmech_node); - - success *= (hygov.allocate() == 0); - pmech_node.init(pmech_value); - success *= (hygov.verify() == 0); - success *= (hygov.initialize() == 0); - success *= (hygov.tagDifferentiable() == 0); - success *= (hygov.evaluateResidual() == 0); + struct RejectionCase + { + const char* label; + RealT pmech; + RealT gmin; + RealT gmax; + }; + + const std::array rejected{{ + {"mechanical power above the gate curve", 1.0, 0.05, 0.95}, + {"mechanical power below the gate curve", -0.3, 0.05, 0.95}, + {"initialized gate above Gmax", 0.4, 0.05, 0.5}, + {"initialized gate below Gmin", 0.4, 0.6, 0.95}, + }}; + + for (const auto& test_case : rejected) + { + auto data = makeResidualData(); + data.parameters[Params::Gmin] = test_case.gmin; + data.parameters[Params::Gmax] = test_case.gmax; + success *= initializationRejectedAtomically( + data, test_case.pmech, test_case.label); + } - const auto* y = hygov.y().getData(); - const auto* yp = hygov.yp().getData(); - const auto& residual = hygov.getResidual(); - const auto* f = residual.getData(); - success *= isEqual(y[index(Var::Q)], static_cast(0.50), kTol); - success *= isEqual(y[index(Var::PGV)], static_cast(0.50), kTol); - success *= isEqual(y[index(Var::G)], static_cast(0.50), kTol); - success *= (hygov.tag()[index(Var::XN)] == true); + // An invalid configuration is rejected before any state is written. + auto invalid_data = makeResidualData(); + invalid_data.parameters[Params::Rtemp] = 0.0; + Fixture invalid_fixture(invalid_data); + invalid_fixture.attachAllInputs(); + success *= (invalid_fixture.hygov.allocate() == 0); + poisonState(invalid_fixture, 0.4); + const auto invalid_y = copyVector(invalid_fixture.hygov.y()); + const auto invalid_yp = copyVector(invalid_fixture.hygov.yp()); + if (invalid_fixture.hygov.initialize() == 0) + { + std::cout << "Expected initialization rejection: invalid configuration\n"; + success = false; + } + success *= vectorUnchanged(invalid_fixture.hygov.y(), invalid_y, "state"); + success *= vectorUnchanged(invalid_fixture.hygov.yp(), invalid_yp, "derivative"); - for (size_t i = 0; i < residual.getSize(); ++i) + // Exact gate-limit boundaries and a zero power seed stay admissible. + struct AdmissibleCase + { + const char* label; + RealT pmech; + RealT gmin; + RealT gmax; + RealT gate; + }; + + for (const auto& accepted : std::array{{ + {"gate landing exactly on Gmax", 0.8, 0.0, 0.9, 0.9}, + {"gate landing exactly on Gmin", 0.0, 0.1, 1.0, 0.1}, + {"zero mechanical-power seed", 0.0, 0.0, 1.0, 0.1}, + }}) { - success *= isEqual(f[i], static_cast(0.0), kTol); - success *= isEqual(yp[i], static_cast(0.0), kTol); + auto data = makeData(); + data.parameters[Params::Gmin] = accepted.gmin; + data.parameters[Params::Gmax] = accepted.gmax; + + Fixture fixture(data); + success *= fixture.initialize(accepted.pmech); + success *= stateMatches(fixture.hygov, + {{I::C, accepted.gate}, {I::G, accepted.gate}}, + accepted.label); + success *= (fixture.evaluate() == 0); + success *= allResidualsZero(fixture.hygov); } return success.report(__func__); } - TestOutcome zeroTimeConstants() + /// A fixed numerical answer key for all 12 HYGOV residual rows. The + /// expected values are literals, not a second implementation of HYGOV. + TestOutcome residualEquations() { TestStatus success = true; - PhasorDynamics::SignalNode pmech_node; - ScalarT pmech_value{0.40}; - IdxT pmech_index = INVALID_INDEX; - pmech_node.set(&pmech_value, &pmech_index); + Fixture fixture(makeResidualData()); + fixture.attachAllInputs(); + success *= fixture.initialize(0.4); + setAnswerKeyInputs(fixture); + setAnswerKeyState(fixture.hygov); + 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::XN, -0.07785714285714286}, + {I::XF, -0.7300000000000001}, + {I::C, 0.06}, + {I::G, 0.1233333333333334}, + {I::Q, 0.011538461538461414}, + {I::OMEGADB, 0.0033514666467982894}, + {I::EF, 0.5863}, + {I::FC, -1.8512500000000003}, + {I::RC, 0.029996890386450745}, + {I::PGV, -0.04600000003160343}, + {I::H, -0.033299999999999885}, + {I::PMECH, -0.012679999999999934}, + }}; + + success *= (static_cast(fixture.hygov.getResidual().getSize()) == expected.size()); + success *= residualsMatch(fixture.hygov, expected); - auto data = makeHygovData(); - data.parameters[Params::Tr] = static_cast(0.0); - data.parameters[Params::Tf] = static_cast(0.0); - data.parameters[Params::Tg] = static_cast(0.0); - data.parameters[Params::Tw] = static_cast(0.0); - data.parameters[Params::Tnp] = static_cast(0.0); + return success.report(__func__); + } - Gov hygov(data); - hygov.getSignals().template assignSignalNode(&pmech_node); + /// Speed deadband, desired-gate velocity limiting, gate-position + /// anti-windup, and turbine damping at nonzero speed deviation. + TestOutcome governorControl() + { + TestStatus success = true; - success *= (hygov.allocate() == 0); - pmech_node.init(pmech_value); - success *= (hygov.verify() == 0); - success *= (hygov.initialize() == 0); - success *= (hygov.tagDifferentiable() == 0); - success *= (hygov.evaluateResidual() == 0); + Fixture fixture(makeResidualData()); + fixture.attachAllInputs(); + success *= fixture.initialize(0.4); - success *= (hygov.tag()[index(Var::XN)] == true); - success *= (hygov.tag()[index(Var::XF)] == true); - success *= (hygov.tag()[index(Var::C)] == true); - success *= (hygov.tag()[index(Var::G)] == true); - success *= (hygov.tag()[index(Var::Q)] == true); + // The type-1 deadband below, inside, and above the +-0.01 band. + struct DeadbandCase + { + RealT omega; + RealT expected; + }; + + for (const auto& test_case : std::array{{ + {-0.05, -0.049996641662021946}, + {0.004, 0.0009004582873718001}, + {0.05, 0.049996641662021946}, + }}) + { + fixture.input(E::OMEGA) = test_case.omega; + setState(fixture.hygov, {{I::OMEGADB, 0.0}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.hygov, + {{I::OMEGADB, test_case.expected}}, + "speed deadband"); + } + fixture.input(E::OMEGA) = 0.0; + + // The desired-gate velocity target driven below, inside, and above + // the +-Velm rate limit. + struct VelocityCase + { + RealT fc; + RealT expected; + }; + + for (const auto& test_case : std::array{{ + {-0.6, -0.15}, + {0.05, 0.04999999999984272}, + {0.6, 0.15000000000000002}, + }}) + { + setState(fixture.hygov, {{I::FC, test_case.fc}, {I::RC, 0.0}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.hygov, + {{I::RC, test_case.expected}}, + "gate velocity limit"); + } - const auto* yp = hygov.yp().getData(); - const auto& residual = hygov.getResidual(); - const auto* f = residual.getData(); - for (size_t i = 0; i < residual.getSize(); ++i) + // The desired-gate anti-windup at three controller directions: both + // saturations block an outward rate and Gmax admits a restoring one. + struct AntiWindupCase + { + const char* label; + RealT c; + RealT rc; + RealT expected; + }; + + for (const auto& test_case : std::array{{ + {"Gmax blocks an outward desired-gate rate", 1.2, 0.2, 0.0}, + {"Gmin blocks an outward desired-gate rate", -0.2, -0.2, 0.0}, + {"Gmax admits a restoring desired-gate rate", 1.2, -0.2, -0.2}, + }}) { - success *= isEqual(f[i], static_cast(0.0), kTol); - success *= isEqual(yp[i], static_cast(0.0), kTol); + setState(fixture.hygov, {{I::C, test_case.c}, {I::RC, test_case.rc}}); + setDerivative(fixture.hygov, {{I::C, 0.0}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.hygov, + {{I::C, test_case.expected}}, + test_case.label); } + // Turbine damping proportional to speed deviation and gate. + fixture.input(E::OMEGA) = 0.05; + setState(fixture.hygov, + {{I::G, 0.6}, {I::Q, 0.7}, {I::H, 1.1}, {I::PMECH, 0.5}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.hygov, + {{I::PMECH, -0.2677999999999999}}, + "turbine damping"); + return success.report(__func__); } - TestOutcome baseConversion() + /// The nonlinear gate-power curve on every rising segment, the water + /// column away from the dam head, and initialization through the curve + /// with and without the speed-damping term. + TestOutcome turbineDynamics() { TestStatus success = true; - PhasorDynamics::SignalNode pmech_node; - ScalarT pmech_value{0.40}; - IdxT pmech_index = INVALID_INDEX; - pmech_node.set(&pmech_value, &pmech_index); + Fixture fixture(makeResidualData()); + fixture.attachAllInputs(); + success *= fixture.initialize(0.4); + + // One gate point inside each of the five curve segments. + struct CurveCase + { + RealT gate; + RealT expected; + }; + + for (const auto& test_case : std::array{{ + {0.1, 0.07500000000021236}, + {0.3, 0.28500000000007075}, + {0.5, 0.5399999999999371}, + {0.7, 0.7549999999999292}, + {0.9, 0.9249999999998506}, + }}) + { + setState(fixture.hygov, {{I::G, test_case.gate}, {I::PGV, 0.0}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.hygov, + {{I::PGV, test_case.expected}}, + "gate-power curve"); + } + + // A head away from the dam head drives the flow and head rows. + setState(fixture.hygov, {{I::Q, 0.61}, {I::H, 0.9}, {I::PGV, 0.55}}); + setDerivative(fixture.hygov, {{I::Q, 0.05}}); + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.hygov, + {{I::Q, 0.18076923076923068}, {I::H, -0.09984999999999994}}, + "water column"); + + // A seed inside the third curve segment initializes through the + // nonidentity curve inversion. + Fixture curve_fixture(makeResidualData()); + curve_fixture.attachAllInputs(); + success *= curve_fixture.initialize(0.33761676); + success *= stateMatches(curve_fixture.hygov, + {{I::C, 0.5000001394782843}, {I::G, 0.5000001394782843}}, + "nonidentity curve inversion"); + success *= scalarMatches(curve_fixture.input(E::PREF), + 0.015000004184348527, + "nonidentity-curve published pref"); + success *= scalarMatches(curve_fixture.pmech(), 0.33761676, "preserved pmech seed"); + success *= (curve_fixture.evaluate() == 0); + success *= allResidualsZero(curve_fixture.hygov); + + // A nonzero initial speed deviation folds the Dturb damping loss into + // the gate solve, so the damped point still initializes at rest. + Fixture damped_fixture(makeResidualData()); + damped_fixture.attachAllInputs(); + damped_fixture.input(E::OMEGA) = 0.03; + success *= damped_fixture.initialize(0.48676047); + success *= stateMatches(damped_fixture.hygov, + {{I::G, 0.7000002496006894}, + {I::OMEGADB, 0.029757154589893794}}, + "damped initialization"); + success *= scalarMatches(damped_fixture.input(E::PREF), + 0.03587858478296758, + "damped published pref"); + success *= (damped_fixture.evaluate() == 0); + success *= allResidualsZero(damped_fixture.hygov); - auto data = makeHygovData(); - data.parameters[Params::Trate] = static_cast(50.0); + return success.report(__func__); + } - Gov hygov(data); - hygov.getSignals().template assignSignalNode(&pmech_node); +#ifdef GRIDKIT_ENABLE_ENZYME + /// A single rich state and all three external inputs drive both + /// sensitivity paths; every Enzyme CSR row must match dependency + /// tracking. + TestOutcome jacobian() + { + TestStatus success = true; - success *= (hygov.allocate() == 0); - pmech_node.init(pmech_value); - success *= (hygov.verify() == 0); - success *= (hygov.initialize() == 0); - success *= (hygov.evaluateResidual() == 0); + const auto data = makeResidualData(); - const auto* y = hygov.y().getData(); - const auto* yp = hygov.yp().getData(); - const auto& residual = hygov.getResidual(); - const auto* f = residual.getData(); - success *= isEqual(y[index(Var::Q)], static_cast(0.90), kTol); - success *= isEqual(y[index(Var::G)], static_cast(0.90), kTol); - success *= isEqual(y[index(Var::PMECH)], static_cast(0.40), kTol); - success *= isEqual(pmech_node.read(), static_cast(0.40), kTol); + const auto dependency_jacobian = dependencyTrackingJacobian(data, success); + const auto enzyme_jacobian = enzymeJacobian(data, success); - for (size_t i = 0; i < residual.getSize(); ++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(f[i], static_cast(0.0), kTol); - success *= isEqual(yp[i], static_cast(0.0), kTol); + if (!isEqual(dependency_jacobian[row], enzyme_jacobian[row], kJacobianTol)) + { + std::cout << "HYGOV Jacobian row " << row + << " mismatch between dependency tracking and Enzyme\n"; + success = false; + } } return success.report(__func__); } +#endif - TestOutcome absoluteTolerance() + private: + using Params = PhasorDynamics::Governor::HygovParameters; + using Vars = PhasorDynamics::Governor::HygovInternalVariables; + using Ext = PhasorDynamics::Governor::HygovExternalVariables; + using Mon = PhasorDynamics::Governor::HygovMonitorableVariables; + using Data = PhasorDynamics::Governor::HygovData; + using I = PhasorDynamics::Governor::HygovIdx; + using E = PhasorDynamics::Governor::HygovExt; + + /// A vector row paired with a value: either an input to write or an + /// expected result. Rows are `HygovIdx`/`HygovExt` constants, so a + /// failure report locates itself without any name string to maintain. + using Row = std::pair; + using Rows = std::initializer_list; + using HygovT = PhasorDynamics::Governor::Hygov; + + /// Owns the HYGOV model, the assigned mechanical-power node, and the + /// attached input nodes. Signal storage is declared before the model so + /// every referenced node outlives HYGOV. Copying would invalidate the + /// model and signal-node pointers. + template + class Fixture { - TestStatus success = true; + private: + std::array input_values_{}; + std::array input_indices_{}; + std::array, E::MAXIMUM> input_nodes_{}; + + PhasorDynamics::SignalNode pmech_node_; - Gov hygov(makeHygovData()); + public: + explicit Fixture(const Data& data, RealT system_va_base = 100.0e6) + : hygov(data) + { + hygov.setSystemBase(60.0, system_va_base); + hygov.getSignals().template assignSignalNode(&pmech_node_); + } - success *= (hygov.allocate() == 0); - success *= (hygov.setAbsoluteTolerance(static_cast(1.0e-7)) == 0); - const auto& abs_tol = hygov.absoluteTolerance(); - success *= (abs_tol.getSize() == static_cast(Var::MAXIMUM)); + Fixture(const Fixture&) = delete; + Fixture& operator=(const Fixture&) = delete; - const auto* tolerances = abs_tol.getData(); - for (size_t i = 0; i < abs_tol.getSize(); ++i) + /// Attach fixture-owned storage to every external input. + void attachAllInputs(RealT initial_value = 0.0) { - success *= isEqual(tolerances[i], scalar(1.0e-7), kTol); + const IdxT external_index_base = hygov.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 = hygov.getSignals(); + signals.template attachSignalNode(&input_nodes_[E::OMEGA]); + signals.template attachSignalNode(&input_nodes_[E::PREF]); + signals.template attachSignalNode(&input_nodes_[E::PAUX]); } - return success.report(__func__); - } + /// Seed the assigned mechanical-power node on the system base. + void seedPmech(RealT pmech) + { + pmech_node_.init(static_cast(pmech)); + } + + /// Everything HYGOV initialization requires: allocation, + /// verification, and a machine-seeded mechanical-power node. + bool prepare(RealT pmech) + { + const bool success = (hygov.allocate() == 0) && (hygov.verify() == 0); + if (!success) + { + std::cout << "HYGOV fixture preparation failed\n"; + return false; + } + + seedPmech(pmech); + return true; + } + + /// prepare() plus successful HYGOV initialization. + bool initialize(RealT pmech) + { + if (!prepare(pmech)) + { + return false; + } + if (hygov.initialize() != 0) + { + std::cout << "HYGOV initialization failed\n"; + return false; + } + return true; + } + + int evaluate() + { + return hygov.evaluateResidual(); + } + + T pmech() const + { + return pmech_node_.read(); + } + + T& input(size_t port) + { + return input_values_[port]; + } - TestOutcome prefSignal() + IdxT inputIndex(size_t port) const + { + return input_indices_[port]; + } + + PhasorDynamics::Governor::Hygov hygov; + }; + + Data makeMinimalData() const { - TestStatus success = true; + Data data; + data.device_class = "Hygov"; + data.disambiguation_string = "hygov_test"; + data.monitored_variables.insert(Mon::pmech); + data.monitored_variables.insert(Mon::filter); + data.monitored_variables.insert(Mon::desiredgate); + data.monitored_variables.insert(Mon::gate); + data.monitored_variables.insert(Mon::flow); + data.monitored_variables.insert(Mon::head); + return data; + } - Gov hygov(makeHygovData()); - - PhasorDynamics::SignalNode pmech_node; - PhasorDynamics::SignalNode pref_node; - PhasorDynamics::SignalNode paux_node; - const ScalarT pmech0 = scalar(kInitialPmech); - ScalarT pmech_value{0.0}; - ScalarT pref_value = scalar(99.0); - ScalarT paux_value = scalar(kInitialPaux); - IdxT pmech_index = INVALID_INDEX; - IdxT pref_index = 7; - IdxT paux_index = 8; - pmech_node.set(&pmech_value, &pmech_index); - pref_node.set(&pref_value, &pref_index); - paux_node.set(&paux_value, &paux_index); - - hygov.getSignals().template assignSignalNode(&pmech_node); - hygov.getSignals().template attachSignalNode(&pref_node); - hygov.getSignals().template attachSignalNode(&paux_node); - - success *= (hygov.allocate() == 0); - pmech_node.init(pmech0); - success *= (hygov.verify() == 0); - success *= (hygov.initialize() == 0); - success *= isEqual(pref_node.read(), - prefForInitialPoint(pmech0, - paux_value, - scalar(kDefaultTrate), - scalar(kDefaultSystemBase)), - kTol); - success *= (hygov.evaluateResidual() == 0); - - const auto* yp = hygov.yp().getData(); - const auto& residual = hygov.getResidual(); - const auto* f = residual.getData(); - for (size_t i = 0; i < residual.getSize(); ++i) - { - success *= isEqual(f[i], static_cast(0.0), kTol); - success *= isEqual(yp[i], static_cast(0.0), kTol); - } - - pref_value += scalar(kPrefStep); - success *= (hygov.evaluateResidual() == 0); - success *= isEqual(f[index(Var::EF)], scalar(kPrefStep), kTol); + Data makeExplicitDefaultData() const + { + auto data = makeMinimalData(); + + // These are the documented defaults. The all-zero source curve + // selects the identity curve, spelled out here point by point. + data.parameters[Params::Trate] = 100.0; + data.parameters[Params::Rperm] = 0.04; + data.parameters[Params::Rtemp] = 0.3; + data.parameters[Params::Tr] = 5.0; + data.parameters[Params::Tf] = 0.05; + data.parameters[Params::Tg] = 0.5; + data.parameters[Params::Velm] = 0.2; + data.parameters[Params::Gmax] = 1.0; + data.parameters[Params::Gmin] = 0.0; + data.parameters[Params::Tw] = 1.0; + data.parameters[Params::At] = 1.2; + data.parameters[Params::Dturb] = 0.5; + data.parameters[Params::Qnl] = 0.05; + data.parameters[Params::Tn] = 0.0; + data.parameters[Params::Tnp] = 0.0; + data.parameters[Params::db1] = 0.0; + data.parameters[Params::db2] = 0.0; + data.parameters[Params::Hdam] = 1.0; + data.parameters[Params::Gv0] = 0.0; + data.parameters[Params::Gv1] = 0.2; + data.parameters[Params::Gv2] = 0.4; + data.parameters[Params::Gv3] = 0.6; + data.parameters[Params::Gv4] = 0.8; + data.parameters[Params::Gv5] = 1.0; + data.parameters[Params::Pgv0] = 0.0; + data.parameters[Params::Pgv1] = 0.2; + data.parameters[Params::Pgv2] = 0.4; + data.parameters[Params::Pgv3] = 0.6; + data.parameters[Params::Pgv4] = 0.8; + data.parameters[Params::Pgv5] = 1.0; + return data; + } - return success.report(__func__); + Data makeData() const + { + auto data = makeMinimalData(); + + data.parameters[Params::Trate] = 100.0; + data.parameters[Params::Rperm] = 0.05; + data.parameters[Params::Rtemp] = 0.4; + data.parameters[Params::Tr] = 5.0; + data.parameters[Params::Tf] = 0.2; + data.parameters[Params::Tg] = 0.5; + data.parameters[Params::Velm] = 0.5; + data.parameters[Params::Gmax] = 1.0; + data.parameters[Params::Gmin] = 0.0; + data.parameters[Params::Tw] = 1.0; + data.parameters[Params::At] = 1.0; + data.parameters[Params::Dturb] = 0.0; + data.parameters[Params::Qnl] = 0.1; + data.parameters[Params::Tn] = 0.0; + data.parameters[Params::Tnp] = 1.0; + data.parameters[Params::db1] = 0.0; + data.parameters[Params::db2] = 0.0; + data.parameters[Params::Hdam] = 1.0; + data.parameters[Params::Gv0] = 0.0; + data.parameters[Params::Gv1] = 0.2; + data.parameters[Params::Gv2] = 0.4; + data.parameters[Params::Gv3] = 0.6; + data.parameters[Params::Gv4] = 0.8; + data.parameters[Params::Gv5] = 1.0; + data.parameters[Params::Pgv0] = 0.0; + data.parameters[Params::Pgv1] = 0.2; + data.parameters[Params::Pgv2] = 0.4; + data.parameters[Params::Pgv3] = 0.6; + data.parameters[Params::Pgv4] = 0.8; + data.parameters[Params::Pgv5] = 1.0; + return data; } - TestOutcome prefSignalBaseConversion() + Data makeResidualData() const { - TestStatus success = true; + auto data = makeData(); + + data.parameters[Params::Trate] = 50.0; + data.parameters[Params::Rperm] = 0.06; + data.parameters[Params::Rtemp] = 0.4; + data.parameters[Params::Tr] = 4.0; + data.parameters[Params::Tf] = 0.2; + data.parameters[Params::Tg] = 0.6; + data.parameters[Params::Velm] = 0.15; + data.parameters[Params::Gmax] = 0.95; + data.parameters[Params::Gmin] = 0.05; + data.parameters[Params::Tw] = 1.3; + data.parameters[Params::At] = 1.1; + data.parameters[Params::Dturb] = 0.6; + data.parameters[Params::Qnl] = 0.08; + data.parameters[Params::Tn] = 0.7; + data.parameters[Params::Tnp] = 1.4; + data.parameters[Params::db1] = 0.01; + data.parameters[Params::Hdam] = 1.2; + data.parameters[Params::Pgv1] = 0.15; + data.parameters[Params::Pgv2] = 0.42; + data.parameters[Params::Pgv3] = 0.66; + data.parameters[Params::Pgv4] = 0.85; + return data; + } - auto data = makeHygovData(); - data.parameters[Params::Trate] = static_cast(kConversionTrate); - - Gov hygov(data); - hygov.setSystemBase(static_cast(kSystemFrequency), - static_cast(kConversionSystemBase * 1.0e6)); - - PhasorDynamics::SignalNode pmech_node; - PhasorDynamics::SignalNode pref_node; - PhasorDynamics::SignalNode paux_node; - const ScalarT pmech0 = scalar(kInitialPmech); - ScalarT pmech_value{0.0}; - ScalarT pref_value = scalar(99.0); - ScalarT paux_value = scalar(kInitialPaux); - IdxT pmech_index = INVALID_INDEX; - IdxT pref_index = 7; - IdxT paux_index = 8; - pmech_node.set(&pmech_value, &pmech_index); - pref_node.set(&pref_value, &pref_index); - paux_node.set(&paux_value, &paux_index); - - hygov.getSignals().template assignSignalNode(&pmech_node); - hygov.getSignals().template attachSignalNode(&pref_node); - hygov.getSignals().template attachSignalNode(&paux_node); - - success *= (hygov.allocate() == 0); - pmech_node.init(pmech0); - success *= (hygov.verify() == 0); - success *= (hygov.initialize() == 0); - - const ScalarT expected_pref = - prefForInitialPoint(pmech0, - paux_value, - scalar(kConversionTrate), - scalar(kConversionSystemBase)); - - success *= isEqual(pref_node.read(), expected_pref, kTol); - const auto* y = hygov.y().getData(); - success *= isEqual(y[index(Var::Q)], scalar(0.90), kTol); - success *= isEqual(y[index(Var::G)], scalar(0.90), kTol); - success *= (hygov.evaluateResidual() == 0); - - const auto* yp = hygov.yp().getData(); - const auto& residual = hygov.getResidual(); - const auto* f = residual.getData(); - for (size_t i = 0; i < residual.getSize(); ++i) - { - success *= isEqual(f[i], static_cast(0.0), kTol); - success *= isEqual(yp[i], static_cast(0.0), kTol); - } - - pref_value += scalar(kPrefStep); - success *= (hygov.evaluateResidual() == 0); - success *= isEqual(f[index(Var::EF)], - scalar(kConversionSystemBase / kConversionTrate * kPrefStep), - kTol); + /// The external inputs the residual answer key is evaluated against. + template + void setAnswerKeyInputs(Fixture& fixture) const + { + fixture.input(E::OMEGA) = static_cast(0.02); + fixture.input(E::PREF) = static_cast(0.31); + fixture.input(E::PAUX) = static_cast(0.07); + } - return success.report(__func__); + /// The rich state shared by the residual answer key and the Jacobian + /// comparison. Every row is distinct so a swapped index cannot pass. + template + void setAnswerKeyState(PhasorDynamics::Governor::Hygov& hygov) const + { + setState(hygov, + {{I::XN, 0.11}, + {I::XF, 0.23}, + {I::C, 0.52}, + {I::G, 0.47}, + {I::Q, 0.61}, + {I::OMEGADB, 0.015}, + {I::EF, 0.08}, + {I::FC, 0.12}, + {I::RC, 0.09}, + {I::PGV, 0.55}, + {I::H, 1.12}, + {I::PMECH, 0.33}}); + setDerivative(hygov, + {{I::XN, 0.01}, + {I::XF, -0.02}, + {I::C, 0.03}, + {I::G, -0.04}, + {I::Q, 0.05}}); } - TestOutcome parameterValidation() + /// Omitting every optional parameter must give exactly the model built + /// from the defaults the README documents, at rest and under load. + bool defaultsMatchDocumentedValues() const { - TestStatus success = true; + auto implicit_data = makeMinimalData(); + implicit_data.parameters[Params::Trate] = 100.0; - auto invalid_trate = makeHygovData(); - invalid_trate.parameters[Params::Trate] = true; - Gov invalid_trate_model(invalid_trate); - success *= (invalid_trate_model.verify() > 0); + Fixture implicit_defaults(implicit_data); + Fixture explicit_defaults(makeExplicitDefaultData()); + implicit_defaults.attachAllInputs(); + explicit_defaults.attachAllInputs(); - auto negative_time = makeHygovData(); - negative_time.parameters[Params::Tf] = static_cast(-0.1); - negative_time.parameters[Params::Tn] = static_cast(-0.1); - Gov negative_time_model(negative_time); - success *= (negative_time_model.verify() > 0); + bool success = implicit_defaults.initialize(0.3) + && explicit_defaults.initialize(0.3); + if (!success) + { + std::cout << "HYGOV documented-default comparison failed to initialize\n"; + return false; + } - auto invalid_at = makeHygovData(); - invalid_at.parameters[Params::At] = static_cast(0.0); - Gov invalid_at_model(invalid_at); - success *= (invalid_at_model.verify() > 0); + success *= (implicit_defaults.evaluate() == 0); + success *= (explicit_defaults.evaluate() == 0); + success *= vectorUnchanged(implicit_defaults.hygov.y(), + copyVector(explicit_defaults.hygov.y()), + "documented-default state"); + success *= vectorUnchanged(implicit_defaults.hygov.yp(), + copyVector(explicit_defaults.hygov.yp()), + "documented-default derivative"); + success *= vectorUnchanged(implicit_defaults.hygov.getResidual(), + copyVector(explicit_defaults.hygov.getResidual()), + "documented-default residual"); + + setAnswerKeyInputs(implicit_defaults); + setAnswerKeyInputs(explicit_defaults); + setAnswerKeyState(implicit_defaults.hygov); + setAnswerKeyState(explicit_defaults.hygov); + success *= (implicit_defaults.evaluate() == 0); + success *= (explicit_defaults.evaluate() == 0); + success *= vectorUnchanged(implicit_defaults.hygov.getResidual(), + copyVector(explicit_defaults.hygov.getResidual()), + "documented-default dynamic residual"); + return success; + } - auto invalid_damping = makeHygovData(); - invalid_damping.parameters[Params::Dturb] = static_cast(-0.1); - Gov invalid_damping_model(invalid_damping); - success *= (invalid_damping_model.verify() > 0); + bool invalidParameterCase(Params parameter, RealT value) const + { + auto data = makeData(); + data.parameters[parameter] = value; + PhasorDynamics::Governor::Hygov model(data); + return model.verify() > 0; + } - return success.report(__func__); + template + bool unlinkedSignalRejected() const + { + PhasorDynamics::SignalNode unlinked_node; + PhasorDynamics::Governor::Hygov model(makeData()); + model.getSignals().template attachSignalNode(&unlinked_node); + return model.verify() > 0; } - TestOutcome signalValidation() + template + std::vector copyVector(const VectorT& vector) const { - TestStatus success = true; + const auto* values = vector.getData(); + return std::vector(values, + values + static_cast(vector.getSize())); + } - PhasorDynamics::SignalNode omega_node; - Gov omega_model(makeHygovData()); - omega_model.getSignals().template attachSignalNode(&omega_node); - success *= (omega_model.verify() > 0); + /// Every row of a vector still holds its snapshot value. + template + bool vectorUnchanged(const VectorT& vector, + const std::vector& snapshot, + const char* what) const + { + bool success = true; + const auto* values = vector.getData(); + for (size_t i = 0; i < snapshot.size(); ++i) + { + success &= rowMatches(static_cast(values[i]), snapshot[i], what, i, "changed"); + } + return success; + } - PhasorDynamics::SignalNode pref_node; - Gov pref_model(makeHygovData()); - pref_model.getSignals().template attachSignalNode(&pref_node); - success *= (pref_model.verify() > 0); + /// Fill the state and derivative with a recognizable ramp, then re-seed + /// the aliased pmech entry, so any write by a rejected initialization + /// is visible. + void poisonState(Fixture& fixture, RealT pmech) const + { + auto* y = fixture.hygov.y().getData(); + auto* yp = fixture.hygov.yp().getData(); + for (size_t i = 0; i < static_cast(fixture.hygov.y().getSize()); ++i) + { + y[i] = 0.125 + 0.01 * static_cast(i); + yp[i] = -0.25 - 0.01 * static_cast(i); + } + fixture.seedPmech(pmech); + fixture.hygov.y().setDataUpdated(); + fixture.hygov.yp().setDataUpdated(); + } + + bool initializationRejectedAtomically(const Data& data, + RealT pmech, + const char* label) const + { + Fixture fixture(data); + fixture.attachAllInputs(); + fixture.input(E::PAUX) = 0.02; + fixture.input(E::PREF) = 77.0; // must stay untouched on rejection + if (!fixture.prepare(pmech)) + { + return false; + } - PhasorDynamics::SignalNode paux_node; - Gov paux_model(makeHygovData()); - paux_model.getSignals().template attachSignalNode(&paux_node); - success *= (paux_model.verify() > 0); + poisonState(fixture, pmech); + const auto y_before = copyVector(fixture.hygov.y()); + const auto yp_before = copyVector(fixture.hygov.yp()); - return success.report(__func__); + bool success = true; + if (fixture.hygov.initialize() == 0) + { + std::cout << "Expected initialization rejection: " << label << "\n"; + success = false; + } + + success *= scalarMatches(fixture.pmech(), pmech, "rejected pmech seed preservation"); + success *= scalarMatches(fixture.input(E::OMEGA), 0.0, "rejected omega preservation"); + success *= scalarMatches(fixture.input(E::PREF), 77.0, "rejected pref preservation"); + success *= scalarMatches(fixture.input(E::PAUX), 0.02, "rejected paux preservation"); + success *= vectorUnchanged(fixture.hygov.y(), y_before, "state"); + success *= vectorUnchanged(fixture.hygov.yp(), yp_before, "derivative"); + return success; } - TestOutcome jsonParseAndSystemAssembly() + /// Write state rows and publish the update, folding in the + /// setDataUpdated() that a hand-written write block has to remember. + template + void setState(PhasorDynamics::Governor::Hygov& hygov, Rows rows) const { - TestStatus success = true; + auto* y = hygov.y().getData(); + for (const auto& [row, value] : rows) + { + y[row] = static_cast(value); + } + hygov.y().setDataUpdated(); + } - std::istringstream input(R"json( -{ - "header": { - "format_version": 0, - "format_revision": 1, - "case_name": "hydro governor", - "case_description": "HYGOV parser test", - "case_comments": "", - "freq_base": 60.0, - "va_base": 100000000.0 - }, - "buses": [ - { - "number": 1, - "class": "bus", - "name": "Bus 1", - "init": { "Vr": 1.0, "Vi": 0.0 }, - "params": { "kv": 1.0 } - } - ], - "signals": [ - { "signal_id": 10, "name": "Pmech" } - ], - "devices": [ - { - "class": "Genrou", - "ports": { "bus": 1, "pmech": 10 }, - "id": "GEN1", - "params": { - "p0": 0.3, "q0": 0.0, "H": 3.0, "D": 0.0, "Ra": 0.0, - "Tdop": 7.0, "Tdopp": 0.04, "Tqop": 0.75, "Tqopp": 0.05, - "Xd": 2.1, "Xdp": 0.2, "Xdpp": 0.18, "Xq": 0.5, "Xqp": 0.5, - "Xqpp": 0.18, "Xl": 0.15, "S10": 0.0, "S12": 0.0, - "mva": 100.0 - } - }, - { - "class": "Hygov", - "ports": { "pmech": 10 }, - "id": "HYG1", - "params": { - "Trate": 50.0, "Rperm": 0.05, "Rtemp": 0.4, "Tr": 5.0, - "Tf": 0.2, "Tg": 0.0, "Velm": 0.5, "Gmax": 1.0, "Gmin": 0.0, - "Tw": 1.0, "At": 1.0, "Dturb": 0.0, "Qnl": 0.1, - "Tn": 0.0, "Tnp": 1.0, "db1": 0.0, "db2": 0.0, "Hdam": 1.0, - "Gv0": 0.0, "Gv1": 0.2, "Gv2": 0.4, "Gv3": 0.6, "Gv4": 0.8, "Gv5": 1.0, - "Pgv0": 0.0, "Pgv1": 0.2, "Pgv2": 0.4, "Pgv3": 0.6, "Pgv4": 0.8, "Pgv5": 1.0 - } - } - ] -} -)json"); - - auto data = PhasorDynamics::parseSystemModelData(input); - success *= (data.hygov.size() == 1); - const auto trate_param = - data.hygov[0].parameters.at(PhasorDynamics::Governor::HygovParameters::Trate); - success *= (std::get(trate_param) == static_cast(50.0)); - using SignalOutput = typename Data::SignalOutputs; - success *= data.hygov[0].buses.empty(); - success *= data.hygov[0].signal_inputs.empty(); - success *= (data.hygov[0].signal_outputs.at(SignalOutput::pmech) - == static_cast(10)); - PhasorDynamics::SystemModel system(data); - success *= (system.allocate() == 0); - success *= (system.initialize() == 0); - const auto hygov_size = - static_cast(PhasorDynamics::Governor::HygovInternalVariables::MAXIMUM); - const auto hygov_offset = static_cast(system.size() - hygov_size); - const auto* system_y = system.y().getData(); - success *= isEqual( - system_y[hygov_offset + index(PhasorDynamics::Governor::HygovInternalVariables::Q)], - static_cast(0.70), - kTol); - success *= isEqual( - system_y[hygov_offset + index(PhasorDynamics::Governor::HygovInternalVariables::G)], - static_cast(0.70), - kTol); - success *= (system.evaluateResidual() == 0); - success *= (system.size() == 33); + /// setState() for the derivative vector. + template + void setDerivative(PhasorDynamics::Governor::Hygov& hygov, Rows rows) const + { + auto* yp = hygov.yp().getData(); + for (const auto& [row, value] : rows) + { + yp[row] = static_cast(value); + } + hygov.yp().setDataUpdated(); + } - return success.report(__func__); + /// 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 `HygovIdx` 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) + { + if (isEqual(actual, expected, kBehaviorTol)) + { + return true; + } + std::cout << "HYGOV " << what << " row " << row << ' ' << context + << " mismatch: " << std::setprecision(16) << actual + << " != " << expected << '\n'; + return false; } - private: - static constexpr RealT kSystemFrequency = 60.0; - static constexpr RealT kDefaultSystemBase = 100.0; - static constexpr RealT kDefaultTrate = 100.0; - static constexpr RealT kConversionSystemBase = 100.0; - static constexpr RealT kConversionTrate = 50.0; - static constexpr RealT kInitialPmech = 0.40; - static constexpr RealT kInitialPaux = 0.02; - static constexpr RealT kPrefStep = 0.10; - static constexpr RealT kRperm = 0.05; - static constexpr RealT kAt = 1.0; - static constexpr RealT kQnl = 0.1; - static constexpr RealT kHdam = 1.0; + /// Check selected rows of a model vector against expected values. + template + bool rowsMatch(const VectorT& vector, + const Row* rows, + size_t count, + const char* what, + const char* context) const + { + bool success = true; + const auto* values = vector.getData(); + for (size_t i = 0; i < count; ++i) + { + const auto& [row, expected] = rows[i]; + success &= rowMatches(static_cast(values[row]), expected, what, row, context); + } + return success; + } - static size_t index(PhasorDynamics::Governor::HygovInternalVariables variable) + bool residualsMatch(const HygovT& hygov, Rows rows, const char* context = "") const { - return static_cast(variable); + return rowsMatch(hygov.getResidual(), rows.begin(), rows.size(), "residual", context); } - static ScalarT scalar(RealT value) + template + bool residualsMatch(const HygovT& hygov, + const std::array& rows, + const char* context = "") const { - return static_cast(value); + return rowsMatch(hygov.getResidual(), rows.data(), size, "residual", context); } - static ScalarT prefForInitialPoint(ScalarT pmech0, - ScalarT paux0, - ScalarT trate, - ScalarT system_base) + bool stateMatches(const HygovT& hygov, Rows rows, const char* context = "") const { - const ScalarT k_base = system_base / trate; - const ScalarT q0 = scalar(kQnl) + k_base * pmech0 / (scalar(kAt) * scalar(kHdam)); - const ScalarT gate0 = q0 / std::sqrt(scalar(kHdam)); - return (scalar(kRperm) * gate0 - k_base * paux0) / k_base; + return rowsMatch(hygov.y(), rows.begin(), rows.size(), "state", context); } - auto makeHygovData() -> Data + /// The model sits at a steady state: every residual and every + /// derivative is zero. + bool allResidualsZero(const HygovT& hygov) const { - Data data; - data.device_class = "Hygov"; - data.disambiguation_string = "hygov_test"; - data.monitored_variables.insert(Mon::pmech); - data.monitored_variables.insert(Mon::gate); + bool success = true; + const auto* f = hygov.getResidual().getData(); + const auto* yp = hygov.yp().getData(); + for (size_t row = 0; row < static_cast(hygov.getResidual().getSize()); ++row) + { + success &= rowMatches(static_cast(f[row]), 0.0, "residual", row, "at rest"); + success &= rowMatches(static_cast(yp[row]), 0.0, "derivative", row, "at rest"); + } + return success; + } + + bool scalarMatches(ScalarT actual, + ScalarT expected, + const char* label, + ScalarT tolerance = kBehaviorTol) const + { + if (isEqual(actual, expected, tolerance)) + { + return true; + } + std::cout << label << " mismatch: " << std::setprecision(16) << actual + << " != " << expected << "\n"; + return false; + } + + void noteExpectedLogs(const char* message) const + { + const auto previous_verbosity = Log::verbosity(); + Log::setVerbosity(Log::Verbosity::EVERYTHING); + Log::misc() << message << "\n"; + Log::setVerbosity(previous_verbosity); + } - data.parameters[Params::Trate] = static_cast(100.0); - data.parameters[Params::Rperm] = static_cast(0.05); - data.parameters[Params::Rtemp] = static_cast(0.4); - data.parameters[Params::Tr] = static_cast(5.0); - data.parameters[Params::Tf] = static_cast(0.2); - data.parameters[Params::Tg] = static_cast(0.0); - data.parameters[Params::Velm] = static_cast(0.5); - data.parameters[Params::Gmax] = static_cast(1.0); - data.parameters[Params::Gmin] = static_cast(0.0); - data.parameters[Params::Tw] = static_cast(1.0); - data.parameters[Params::At] = static_cast(1.0); - data.parameters[Params::Dturb] = static_cast(0.0); - data.parameters[Params::Qnl] = static_cast(0.1); - data.parameters[Params::Tn] = static_cast(0.0); - data.parameters[Params::Tnp] = static_cast(1.0); - data.parameters[Params::db1] = static_cast(0.0); - data.parameters[Params::db2] = static_cast(0.0); - data.parameters[Params::Hdam] = static_cast(1.0); - data.parameters[Params::Gv0] = static_cast(0.0); - data.parameters[Params::Gv1] = static_cast(0.2); - data.parameters[Params::Gv2] = static_cast(0.4); - data.parameters[Params::Gv3] = static_cast(0.6); - data.parameters[Params::Gv4] = static_cast(0.8); - data.parameters[Params::Gv5] = static_cast(1.0); - data.parameters[Params::Pgv0] = static_cast(0.0); - data.parameters[Params::Pgv1] = static_cast(0.2); - data.parameters[Params::Pgv2] = static_cast(0.4); - data.parameters[Params::Pgv3] = static_cast(0.6); - data.parameters[Params::Pgv4] = static_cast(0.8); - data.parameters[Params::Pgv5] = static_cast(1.0); +#ifdef GRIDKIT_ENABLE_ENZYME + void numberVariables(Fixture& fixture) const + { + auto* y = fixture.hygov.y().getData(); + auto* yp = fixture.hygov.yp().getData(); - return data; + const auto model_size = static_cast(fixture.hygov.size()); + for (size_t i = 0; i < model_size; ++i) + { + y[i].setVariableNumber(i); + yp[i].setVariableNumber(i); + } + for (size_t port = 0; port < E::MAXIMUM; ++port) + { + fixture.input(port).setVariableNumber(fixture.inputIndex(port)); + } + + fixture.hygov.y().setDataUpdated(); + fixture.hygov.yp().setDataUpdated(); } - auto makeHygovSourceDefaultData() -> Data - { - auto data = makeHygovData(); - data.parameters[Params::Tn] = static_cast(0.0); - data.parameters[Params::Tnp] = static_cast(0.0); - data.parameters[Params::Gv0] = static_cast(0.0); - data.parameters[Params::Gv1] = static_cast(0.0); - data.parameters[Params::Gv2] = static_cast(0.0); - data.parameters[Params::Gv3] = static_cast(0.0); - data.parameters[Params::Gv4] = static_cast(0.0); - data.parameters[Params::Gv5] = static_cast(0.0); - data.parameters[Params::Pgv0] = static_cast(0.0); - data.parameters[Params::Pgv1] = static_cast(0.0); - data.parameters[Params::Pgv2] = static_cast(0.0); - data.parameters[Params::Pgv3] = static_cast(0.0); - data.parameters[Params::Pgv4] = static_cast(0.0); - data.parameters[Params::Pgv5] = static_cast(0.0); + std::vector dependencyTrackingJacobian( + const Data& data, + TestStatus& success) const + { + using DepVar = DependencyTracking::Variable; + + Fixture fixture(data); + fixture.attachAllInputs(); + success *= fixture.initialize(0.4); + setAnswerKeyInputs(fixture); + setAnswerKeyState(fixture.hygov); + numberVariables(fixture); + success *= (fixture.evaluate() == 0); + + const auto model_size = static_cast(fixture.hygov.size()); + std::vector rows(model_size); + const auto* f = fixture.hygov.getResidual().getData(); + for (size_t i = 0; i < model_size; ++i) + { + rows[i] = f[i].getDependencies(); + } + return rows; + } - return data; + std::vector enzymeJacobian( + const Data& data, + TestStatus& success) const + { + Fixture fixture(data); + fixture.attachAllInputs(); + success *= fixture.initialize(0.4); + setAnswerKeyInputs(fixture); + setAnswerKeyState(fixture.hygov); + fixture.hygov.updateTime(0.0, 1.0); + success *= (fixture.evaluate() == 0); + success *= (fixture.hygov.evaluateJacobian() == 0); + success *= (fixture.hygov.constructCsr() == 0); + return MapFromCsr(fixture.hygov.getCsrJacobian()); } +#endif }; } // namespace Testing } // namespace GridKit diff --git a/tests/UnitTests/PhasorDynamics/runGovernorHygovTests.cpp b/tests/UnitTests/PhasorDynamics/runGovernorHygovTests.cpp index 19747f956..1596c7319 100644 --- a/tests/UnitTests/PhasorDynamics/runGovernorHygovTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runGovernorHygovTests.cpp @@ -6,17 +6,15 @@ int main() GridKit::Testing::GovernorHygovTests test; - result += test.constructionAndValidation(); - result += test.signals(); - result += test.sourceDefault(); - result += test.zeroTimeConstants(); - result += test.baseConversion(); - result += test.absoluteTolerance(); - result += test.prefSignal(); - result += test.prefSignalBaseConversion(); - result += test.parameterValidation(); - result += test.signalValidation(); - result += test.jsonParseAndSystemAssembly(); + result += test.validation(); + result += test.initializationAndSignals(); + result += test.initializationDomain(); + result += test.residualEquations(); + result += test.governorControl(); + result += test.turbineDynamics(); +#ifdef GRIDKIT_ENABLE_ENZYME + result += test.jacobian(); +#endif return result.summary(); } From afe0a9a0c508e7bb8cd1dcd3958181a6eadcbc89 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Thu, 30 Jul 2026 17:36:44 -0500 Subject: [PATCH 03/17] Fixed gatePower with Enzyme --- .../PhasorDynamics/Governor/HYGOV/Hygov.hpp | 6 +--- .../Governor/HYGOV/HygovEnzyme.cpp | 22 +------------ .../Governor/HYGOV/HygovImpl.hpp | 32 ++----------------- 3 files changed, 4 insertions(+), 56 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp index 550c70eb0..63b45808e 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp @@ -142,11 +142,7 @@ namespace GridKit /// Evaluate the nonlinear gate-to-power curve as a fixed sum of /// smooth linear segments. - ScalarT gatePower(ScalarT gate) const; - - /// Analytic slope of the smooth gate-to-power curve, used to stamp - /// the Jacobian entry the Enzyme auto-sparsity pass drops. - RealT gatePowerDerivative(RealT gate) const; + __attribute__((always_inline)) inline ScalarT gatePower(ScalarT gate) const; /// Solve the steady gate position that reproduces a seeded /// component-base mechanical power at an initial speed deviation. diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovEnzyme.cpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovEnzyme.cpp index f21ddcafc..174f23959 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovEnzyme.cpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovEnzyme.cpp @@ -24,9 +24,7 @@ namespace GridKit { auto size = static_cast(size_); auto signal_size = static_cast(ws_.size()); - // One slot past the DfDy/DfDyp/DfDws maxima holds the analytically - // stamped gate-curve entry appended after the Enzyme blocks. - auto buffer_size = 2 * size * size + size * signal_size + 1; + auto buffer_size = 2 * size * 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]; @@ -80,24 +78,6 @@ namespace GridKit J_vals_buffer_, nnz_); - // The Enzyme auto-sparsity pass silently drops the gate-curve entry - // of the PGV row: with three or more smooth linear segments feeding - // one store, the pattern solver loses the y[G] column (two segments - // survive). The entry is stamped analytically until the upstream - // pass handles it; the unit-test comparison against dependency - // tracking guards this value and flags the day Enzyme emits the - // entry itself. - { - const auto G = static_cast(HygovInternalVariables::G); - const auto PGV = static_cast(HygovInternalVariables::PGV); - - J_rows_buffer_[nnz_] = this->getResidualIndex(PGV); - J_cols_buffer_[nnz_] = this->getVariableIndex(G); - J_vals_buffer_[nnz_] = - gatePowerDerivative(static_cast(y_.getData()[static_cast(G)])); - ++nnz_; - } - this->constructCoo(); return 0; diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp index b48dbe4dc..312f4beff 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp @@ -118,7 +118,8 @@ namespace GridKit * @return Turbine power at nominal head. */ template - scalar_type Hygov::gatePower(scalar_type gate) const + __attribute__((always_inline)) inline scalar_type + Hygov::gatePower(scalar_type gate) const { return ScalarT{Pgv_[0]} + Math::linseg(gate, Gv_[0], Gv_[1], Pgv_[1] - Pgv_[0]) @@ -128,35 +129,6 @@ namespace GridKit + Math::linseg(gate, Gv_[4], Gv_[5], Pgv_[5] - Pgv_[4]); } - /** - * @brief Slope of the smooth gate-to-power curve - * - * Analytic derivative of gatePower(): each smooth linear segment - * contributes its slope gated by the logistic derivative of the smooth - * CommonMath ramp. Used to stamp the one Jacobian entry the Enzyme - * auto-sparsity pass drops from the gate-curve row. - * - * @param[in] gate Gate position. - * @return Derivative of the turbine power with respect to the gate. - */ - template - typename Hygov::RealT - Hygov::gatePowerDerivative(RealT gate) const - { - auto ramp_slope = [](RealT x) - { - return ONE / (ONE + std::exp(-Math::MU * x)); - }; - auto segment_slope = [&](size_t i) - { - return (Pgv_[i + 1] - Pgv_[i]) / (Gv_[i + 1] - Gv_[i]) - * (ramp_slope(gate - Gv_[i]) - ramp_slope(gate - Gv_[i + 1])); - }; - - return segment_slope(0) + segment_slope(1) + segment_slope(2) - + segment_slope(3) + segment_slope(4); - } - /** * @brief Solve the steady gate position for a seeded mechanical power * From b8e6b0110336d33fd330603340692ccfaff8de68 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Fri, 31 Jul 2026 20:59:15 -0500 Subject: [PATCH 04/17] Cleaning up impl and doxygen --- .../PhasorDynamics/Governor/HYGOV/Hygov.hpp | 107 +- .../Governor/HYGOV/HygovData.hpp | 85 +- .../Governor/HYGOV/HygovImpl.hpp | 458 ++++--- .../PhasorDynamics/Governor/HYGOV/README.md | 216 +-- .../SystemModelDataJSONParser.hpp | 6 +- .../Model/PhasorDynamics/SystemModelImpl.hpp | 44 +- .../PhasorDynamics/GovernorHygovTests.hpp | 1186 ++++++++++------- .../SystemSingleComponentTests.hpp | 34 + .../PhasorDynamics/runGovernorHygovTests.cpp | 1 + .../runSystemSingleComponentTests.cpp | 1 + tests/UnitTests/Utilities/CaseFormatTests.hpp | 18 +- 11 files changed, 1276 insertions(+), 880 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp index 63b45808e..29849d86d 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -28,57 +29,37 @@ namespace GridKit /// Internal variables of a `Hygov`. enum class HygovInternalVariables : size_t { - XN, ///< Speed lead-lag denominator state - XF, ///< Governor error filter output - C, ///< Desired-gate position - G, ///< Gate position - Q, ///< Turbine flow - OMEGADB, ///< Deadbanded speed deviation - EF, ///< Governor error into the filter - FC, ///< Desired-gate derivative target - RC, ///< Rate-limited desired-gate derivative target - PGV, ///< Nonlinear gate-to-power curve output - H, ///< Turbine head - PMECH, ///< Mechanical-power output + XN, ///< \f$x_n\f$ Speed lead-lag denominator state + XF, ///< \f$x_f\f$ Governor error filter output on component base + C, ///< \f$c\f$ Desired-gate position on component base + G, ///< \f$g\f$ Gate position on component base + Q, ///< \f$q\f$ Turbine flow on component base + OMEGADB, ///< \f$\omega_{\mathrm{db}}\f$ Deadbanded speed deviation + EF, ///< \f$e_f\f$ Governor error on component base + FC, ///< \f$f_c\f$ Desired-gate derivative target + RC, ///< \f$r_c\f$ Rate-limited desired-gate derivative target + PGV, ///< \f$P_{\mathrm{GV}}\f$ Gate-to-power curve output on component base + H, ///< \f$H\f$ Turbine head on component base + PMECH, ///< \f$P_{\mathrm{m}}\f$ Mechanical-power output on system base MAXIMUM, }; /// External variables of a `Hygov`. enum class HygovExternalVariables : size_t { - OMEGA, ///< Machine speed deviation - PREF, ///< Active-power/load reference - PAUX, ///< Auxiliary power input + OMEGA, ///< \f$\omega\f$ Machine speed deviation + PREF, ///< \f$P^{\mathrm{ref}}\f$ Active-power/load reference on system base + PAUX, ///< \f$P^{\mathrm{aux}}\f$ Auxiliary power input on system base MAXIMUM, }; - /// Indices into the HYGOV state, derivative, and residual vectors. - struct HygovIdx - { - static constexpr size_t XN = static_cast(HygovInternalVariables::XN); - static constexpr size_t XF = static_cast(HygovInternalVariables::XF); - static constexpr size_t C = static_cast(HygovInternalVariables::C); - static constexpr size_t G = static_cast(HygovInternalVariables::G); - static constexpr size_t Q = static_cast(HygovInternalVariables::Q); - static constexpr size_t OMEGADB = static_cast(HygovInternalVariables::OMEGADB); - static constexpr size_t EF = static_cast(HygovInternalVariables::EF); - static constexpr size_t FC = static_cast(HygovInternalVariables::FC); - static constexpr size_t RC = static_cast(HygovInternalVariables::RC); - static constexpr size_t PGV = static_cast(HygovInternalVariables::PGV); - static constexpr size_t H = static_cast(HygovInternalVariables::H); - static constexpr size_t PMECH = static_cast(HygovInternalVariables::PMECH); - static constexpr size_t MAXIMUM = static_cast(HygovInternalVariables::MAXIMUM); - }; - - /// Indices into the HYGOV external-signal buffers. - struct HygovExt - { - static constexpr size_t OMEGA = static_cast(HygovExternalVariables::OMEGA); - static constexpr size_t PREF = static_cast(HygovExternalVariables::PREF); - static constexpr size_t PAUX = static_cast(HygovExternalVariables::PAUX); - static constexpr size_t MAXIMUM = static_cast(HygovExternalVariables::MAXIMUM); - }; - + /** + * @brief Hydro turbine-governor model with temporary droop, gate servo, + * and a nonlinear single-penstock turbine. + * + * @tparam scalar_type Plain real or differentiable scalar type. + * @tparam index_type Integer index type. + */ template class Hygov : public Component { @@ -101,23 +82,25 @@ namespace GridKit using Component::yp_; public: - using ScalarT = scalar_type; - using IdxT = index_type; - using RealT = typename Component::RealT; - using SignalT = SignalNode; - using ModelDataT = HygovData; - using MonitorT = Model::VariableMonitor; + using ScalarT = scalar_type; + using IdxT = index_type; + using RealT = typename Component::RealT; + using SignalT = SignalNode; + using ModelDataT = HygovData; + using MonitorT = Model::VariableMonitor; + using InternalVariablesT = HygovInternalVariables; + using ExternalVariablesT = HygovExternalVariables; Hygov(); explicit Hygov(const ModelDataT& data); ~Hygov(); - 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; @@ -133,7 +116,11 @@ namespace GridKit const Model::VariableMonitorBase* getMonitor() const override; __attribute__((always_inline)) inline int evaluateInternalResidual( - const ScalarT*, const ScalarT*, const ScalarT*, const ScalarT*, ScalarT*); + const ScalarT* y, + const ScalarT* yp, + const ScalarT* wb, + const ScalarT* ws, + ScalarT* f); private: void initializeParameters(const ModelDataT& data); @@ -144,17 +131,23 @@ namespace GridKit /// smooth linear segments. __attribute__((always_inline)) inline ScalarT gatePower(ScalarT gate) const; - /// Solve the steady gate position that reproduces a seeded - /// component-base mechanical power at an initial speed deviation. - RealT solveInitialGate(RealT pmech, RealT omega) const; + /// Steady component-base mechanical power at a gate position, + /// composed as the runtime PGV, H, and PMECH rows compose it. + RealT initialMechanicalPower(RealT gate) const; + + /// Solve the gate position whose steady mechanical power reproduces + /// the given component-base value, exact to machine rounding. + RealT solveInitialGate(RealT pmech) const; ScalarT toComponentBase(ScalarT value) const; ScalarT toSystemBase(ScalarT value) const; - static constexpr RealT TIME_CONSTANT_MINIMUM = static_cast(1.0e-3); - static constexpr RealT INITIALIZATION_TOLERANCE = static_cast(1.0e-10); + static constexpr RealT TIME_CONSTANT_MINIMUM = static_cast(1.0e-3); + + /// Accepted seed distance beyond the achievable-power range edge. + static constexpr RealT INITIALIZATION_TOLERANCE = + static_cast(100.0) * std::numeric_limits::epsilon(); - RealT Trate_{ZERO}; RealT Rperm_{static_cast(0.04)}; RealT Rtemp_{static_cast(0.3)}; RealT Tr_{static_cast(5.0)}; diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovData.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovData.hpp index 4fdbf04c5..14ad086e5 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovData.hpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovData.hpp @@ -17,36 +17,36 @@ namespace GridKit /// Parameter keys for the HYGOV governor model. enum class HygovParameters { - Trate, ///< Turbine-rating power base - Rperm, ///< Permanent droop - Rtemp, ///< Temporary droop - Tr, ///< Temporary-droop reset time constant - Tf, ///< Governor error filter time constant - Tg, ///< Gate servo time constant - Velm, ///< Maximum desired-gate velocity magnitude - Gmax, ///< Maximum desired-gate position - Gmin, ///< Minimum desired-gate position - Tw, ///< Water inertia time constant - At, ///< Turbine gain - Dturb, ///< Turbine damping coefficient - Qnl, ///< No-load flow at nominal head - Tn, ///< Speed lead-lag numerator time constant - Tnp, ///< Speed lead-lag denominator time constant - db1, ///< Type 1 speed deadband threshold - db2, ///< Unsupported mechanical backlash deadband - Hdam, ///< Head available at dam - Gv0, ///< Gate point 0 - Gv1, ///< Gate point 1 - Gv2, ///< Gate point 2 - Gv3, ///< Gate point 3 - Gv4, ///< Gate point 4 - Gv5, ///< Gate point 5 - Pgv0, ///< Power point 0 - Pgv1, ///< Power point 1 - Pgv2, ///< Power point 2 - Pgv3, ///< Power point 3 - Pgv4, ///< Power point 4 - Pgv5 ///< Power point 5 + Trate, ///< \f$T^\mathrm{rate}\f$ Turbine-rating power base + Rperm, ///< \f$R_{\mathrm{perm}}\f$ Permanent droop + Rtemp, ///< \f$R_{\mathrm{temp}}\f$ Temporary droop + Tr, ///< \f$T_r\f$ Temporary-droop reset time constant + Tf, ///< \f$T_f\f$ Governor error filter time constant + Tg, ///< \f$T_g\f$ Gate servo time constant + Velm, ///< \f$V_{\mathrm{elm}}\f$ Maximum desired-gate velocity magnitude + Gmax, ///< \f$G^{\max}\f$ Maximum desired-gate position + Gmin, ///< \f$G^{\min}\f$ Minimum desired-gate position + Tw, ///< \f$T_w\f$ Water inertia time constant + At, ///< \f$A_t\f$ Turbine gain + Dturb, ///< \f$D_{\mathrm{turb}}\f$ Turbine damping coefficient + Qnl, ///< \f$q_{\mathrm{NL}}\f$ No-load flow at nominal head + Tn, ///< \f$T_n\f$ Speed lead-lag numerator time constant + Tnp, ///< \f$T_{\mathrm{np}}\f$ Speed lead-lag denominator time constant + db1, ///< \f$D_{\omega}\f$ Type 1 speed deadband threshold + db2, ///< \f$D_2\f$ Mechanical backlash deadband + Hdam, ///< \f$H_{\mathrm{dam}}\f$ Head available at dam + Gv0, ///< \f$G_V^{(0)}\f$ Gate point 0 + Gv1, ///< \f$G_V^{(1)}\f$ Gate point 1 + Gv2, ///< \f$G_V^{(2)}\f$ Gate point 2 + Gv3, ///< \f$G_V^{(3)}\f$ Gate point 3 + Gv4, ///< \f$G_V^{(4)}\f$ Gate point 4 + Gv5, ///< \f$G_V^{(5)}\f$ Gate point 5 + Pgv0, ///< \f$P_{\mathrm{GV}}^{(0)}\f$ Power point 0 + Pgv1, ///< \f$P_{\mathrm{GV}}^{(1)}\f$ Power point 1 + Pgv2, ///< \f$P_{\mathrm{GV}}^{(2)}\f$ Power point 2 + Pgv3, ///< \f$P_{\mathrm{GV}}^{(3)}\f$ Power point 3 + Pgv4, ///< \f$P_{\mathrm{GV}}^{(4)}\f$ Power point 4 + Pgv5 ///< \f$P_{\mathrm{GV}}^{(5)}\f$ Power point 5 }; /// Buses for the HYGOV governor model. @@ -58,7 +58,7 @@ namespace GridKit /// Signal inputs for the HYGOV governor model. enum class HygovSignalInputs : size_t { - speed, ///< Machine speed-deviation signal ID + speed, ///< Optional machine speed-deviation signal ID pref, ///< Optional active-power/load reference signal ID paux, ///< Optional auxiliary power input signal ID SIZE @@ -67,21 +67,30 @@ namespace GridKit /// Signal outputs for the HYGOV governor model. enum class HygovSignalOutputs : size_t { - pmech, ///< Mechanical-power output signal ID + pmech, ///< Required mechanical-power output signal ID SIZE }; /// Variables available through the monitor interface. enum class HygovMonitorableVariables { - pmech, ///< Mechanical power output - filter, ///< Governor error filter output - desiredgate, ///< Desired-gate position - gate, ///< Gate position - flow, ///< Turbine flow - head ///< Turbine head + pmech, ///< Mechanical power output on system base + filter, ///< Governor error filter output on component base + desiredgate, ///< Desired-gate position on component base + gate, ///< Gate position on component base + flow, ///< Turbine flow on component base + head ///< Turbine head on component base }; + /** + * @brief Model data for HYGOV: parameters, optional input signals, the + * required mechanical-power output, and monitored variables. + * + * @tparam real_type Real parameter value type. + * @tparam index_type Integer index type. + * + * @see Hygov + */ template struct HygovData : public ComponentData Hygov::Hygov() { - size_ = static_cast(HygovIdx::MAXIMUM); + size_ = static_cast(HygovInternalVariables::MAXIMUM); } /** @@ -50,7 +48,7 @@ namespace GridKit { initializeParameters(data); initializeMonitor(); - size_ = static_cast(HygovIdx::MAXIMUM); + size_ = static_cast(HygovInternalVariables::MAXIMUM); } template @@ -61,10 +59,8 @@ namespace GridKit /** * @brief Resolve the parameter-derived constants * - * Raises each governor lag to the well-posedness floor, sizes the - * component power base, and derives the speed lead-lag gain from the - * floored denominator so the residual keeps a fixed structure for - * sparse automatic differentiation. + * Raises each governor lag to the well-posedness floor and derives the + * speed lead-lag gain from the floored denominator. */ template void Hygov::setDerivedParameters() @@ -102,8 +98,6 @@ namespace GridKit Tw_ = std::max(Tw_, TIME_CONSTANT_MINIMUM); Tnp_ = std::max(Tnp_, TIME_CONSTANT_MINIMUM); - va_component_base_ = Trate_ * static_cast(1.0e6); - leadlag_gain_ = Tn_ / Tnp_; } @@ -130,61 +124,108 @@ namespace GridKit } /** - * @brief Solve the steady gate position for a seeded mechanical power + * @brief Steady component-base mechanical power at a gate position * - * At the steady state the head rests at the dam head, the flow rides - * the gate curve, and the turbine power less the speed-damping loss - * reproduces the seed: + * At the steady state the head equals the dam head and the flow + * follows the gate curve, so the PGV, H, and PMECH rows collapse to * @f[ - * A_t H_0 \left(\sqrt{H_0}\,N_{\mathrm{GV}}(g) - q_{\mathrm{NL}}\right) - * - D_{\mathrm{turb}}\,\omega_0\, g = P_{\mathrm{m},0}. + * P_{\mathrm{m}}(g) + * = A_t H_{\mathrm{dam}} + * \left(\sqrt{H_{\mathrm{dam}}}\,N_{\mathrm{GV}}(g) + * - q_{\mathrm{NL}}\right). * @f] - * The curve is linear on each rising segment, so the equation is solved - * segment by segment and the lowest admissible gate is selected. Flat - * segments carry no power information and are skipped. - * - * @param[in] pmech Seeded mechanical power on the component base. - * @param[in] omega Initial machine speed deviation. - * @return The gate position, or a quiet NaN when no rising segment - * reproduces the seed. + * The expression is composed exactly as those rows compose it, so a + * gate solved against it zeros the implemented residual at machine + * rounding. + * + * @param[in] gate Gate position. + * @return Steady mechanical power on the component base. */ template typename Hygov::RealT - Hygov::solveInitialGate(RealT pmech, RealT omega) const + Hygov::initialMechanicalPower(RealT gate) const { - const RealT h0 = Hdam_; - const RealT gain = At_ * h0 * std::sqrt(h0); - const RealT damping = Dturb_ * omega; - const RealT target = pmech + At_ * h0 * Qnl_; + const RealT pgv = static_cast(gatePower(static_cast(gate))); + const RealT q = std::sqrt(Hdam_) * pgv; + return At_ * Hdam_ * (q - Qnl_); + } - if (std::abs(gain * Pgv_[0] - damping * Gv_[0] - target) <= INITIALIZATION_TOLERANCE) + /** + * @brief Solve the steady gate position for a given mechanical power + * + * Initialization requires a zero speed deviation and verify() requires + * the steady power to rise across [Gmin, Gmax], so the endpoint + * residuals decide feasibility and bisection converges to a root of + * the nondecreasing steady-power curve. + * + * @pre verify() reports no errors. + * + * @param[in] pmech Mechanical power on the component base. + * @return The gate position, or a quiet NaN when no gate inside + * [Gmin, Gmax] reproduces the value within the initialization + * tolerance. + */ + template + typename Hygov::RealT + Hygov::solveInitialGate(RealT pmech) const + { + // NaN is unreproducible by any gate and would otherwise slip + // through the sign tests below. + if (std::isnan(pmech)) { - return Gv_[0]; + return std::numeric_limits::quiet_NaN(); } - for (size_t i = 0; i < 5; ++i) + RealT a = Gmin_; + RealT b = Gmax_; + RealT fa = initialMechanicalPower(a) - pmech; + RealT fb = initialMechanicalPower(b) - pmech; + + // A value just outside the achievable range pins to the gate limit + // when it is within the initialization tolerance of the range edge. + if (fa > ZERO) { - if (Pgv_[i + 1] <= Pgv_[i]) + if (fa <= INITIALIZATION_TOLERANCE) { - continue; + return a; } - - const RealT slope = (Pgv_[i + 1] - Pgv_[i]) / (Gv_[i + 1] - Gv_[i]); - const RealT denominator = gain * slope - damping; - if (std::abs(denominator) <= INITIALIZATION_TOLERANCE) + return std::numeric_limits::quiet_NaN(); + } + if (fb < ZERO) + { + if (-fb <= INITIALIZATION_TOLERANCE) { - continue; + return b; } + return std::numeric_limits::quiet_NaN(); + } - const RealT gate = (target - gain * (Pgv_[i] - slope * Gv_[i])) / denominator; - if (Gv_[i] - INITIALIZATION_TOLERANCE <= gate - && gate <= Gv_[i + 1] + INITIALIZATION_TOLERANCE) + // Bisect until no representable midpoint remains, then keep the + // endpoint with the smaller residual. + while (true) + { + const RealT mid = HALF * (a + b); + if (mid <= a || b <= mid) { - return gate; + break; + } + const RealT fmid = initialMechanicalPower(mid) - pmech; + if (fmid <= ZERO) + { + a = mid; + fa = fmid; + } + else + { + b = mid; + fb = fmid; } } - - return std::numeric_limits::quiet_NaN(); + if (std::abs(fa) <= std::abs(fb)) + { + return a; + } + return b; } /** @@ -214,12 +255,10 @@ namespace GridKit /** * @brief Read the parameters out of the model data * - * Only the turbine-rating power base is required; every other parameter - * keeps the default documented in the model README when omitted. A - * missing required key or a non-numeric value is counted and reported - * by verify() rather than throwing. Integer JSON values are accepted - * for real parameters. All-zero `Gv` and `Pgv` source points select the - * identity gate curve. + * Every omitted parameter keeps the default documented in the model + * README. A non-numeric value is counted and reported by verify() rather + * than throwing. Integer JSON values are accepted for real parameters. + * All-zero `Gv` and `Pgv` source points select the identity gate curve. * * @param[in] data Parameters and monitored-variable selections. */ @@ -230,35 +269,39 @@ namespace GridKit parameter_error_count_ = 0; - auto load_real = [&](auto key, RealT& target, const char* name) + auto load_real = [&](auto key, RealT& target, const char* name) -> bool { if (!data.parameters.contains(key)) { - return; + return false; } const auto& value = data.parameters.at(key); if (const auto* real_value = std::get_if(&value)) { target = *real_value; + return true; } - else if (const auto* index_value = std::get_if(&value)) + if (const auto* index_value = std::get_if(&value)) { target = static_cast(*index_value); + return true; } - else - { - Log::error() << "Hygov: parameter '" << name << "' must be numeric\n"; - ++parameter_error_count_; - } + + Log::error() << "Hygov: parameter '" << name << "' must be numeric\n"; + ++parameter_error_count_; + return false; }; - if (!data.parameters.contains(Params::Trate)) + if (load_real(Params::Trate, va_component_base_, "Trate")) { - Log::error() << "Hygov: missing required parameter 'Trate'\n"; - ++parameter_error_count_; + if (!(va_component_base_ > ZERO) ) + { + Log::error() << "Hygov: Trate must be positive when provided\n"; + ++parameter_error_count_; + } + va_component_base_ *= static_cast(1.0e6); } - load_real(Params::Trate, Trate_, "Trate"); load_real(Params::Rperm, Rperm_, "Rperm"); load_real(Params::Rtemp, Rtemp_, "Rtemp"); load_real(Params::Tr, Tr_, "Tr"); @@ -330,21 +373,20 @@ namespace GridKit template void Hygov::initializeMonitor() { - using I = HygovIdx; using Variable = typename ModelDataT::MonitorableVariables; monitor_->set(Variable::pmech, [this] - { return y_.getData()[I::PMECH]; }); + { return y_.getData()[static_cast(HygovInternalVariables::PMECH)]; }); monitor_->set(Variable::filter, [this] - { return y_.getData()[I::XF]; }); + { return y_.getData()[static_cast(HygovInternalVariables::XF)]; }); monitor_->set(Variable::desiredgate, [this] - { return y_.getData()[I::C]; }); + { return y_.getData()[static_cast(HygovInternalVariables::C)]; }); monitor_->set(Variable::gate, [this] - { return y_.getData()[I::G]; }); + { return y_.getData()[static_cast(HygovInternalVariables::G)]; }); monitor_->set(Variable::flow, [this] - { return y_.getData()[I::Q]; }); + { return y_.getData()[static_cast(HygovInternalVariables::Q)]; }); monitor_->set(Variable::head, [this] - { return y_.getData()[I::H]; }); + { return y_.getData()[static_cast(HygovInternalVariables::H)]; }); } /** @@ -363,10 +405,10 @@ namespace GridKit /** * @brief Allocate the model vectors and wire the mechanical-power output * - * Sizes the state, residual, and signal-interface buffers, seeds the - * identity index maps, and points the assigned `pmech` node at the + * Sizes the state, residual, and signal-interface buffers, initializes + * the identity index maps, and points the assigned `pmech` node at the * internal state it publishes. That node aliases HYGOV storage from - * here on, which is how initialize() reads the seed the machine wrote. + * here on, which is how initialize() reads the machine value. * HYGOV attaches to no bus, so the bus-interface buffer stays empty. * Repeated calls reuse the allocated vectors. * @@ -375,8 +417,7 @@ namespace GridKit template int Hygov::allocate() { - using I = HygovIdx; - using E = HygovExt; + const auto PMECH = static_cast(HygovInternalVariables::PMECH); if (!allocated_) { @@ -390,7 +431,7 @@ namespace GridKit wb_.clear(); - auto signal_size = E::MAXIMUM; + const auto signal_size = static_cast(HygovExternalVariables::MAXIMUM); ws_.assign(signal_size, ScalarT{0}); ws_indices_.assign(signal_size, INVALID_INDEX); @@ -404,8 +445,8 @@ namespace GridKit { auto* y = y_.getData(); signals_.template getSignalNode()->set( - &y[I::PMECH], - &(this->getVariableIndex(static_cast(I::PMECH)))); + &y[PMECH], + &(this->getVariableIndex(static_cast(PMECH)))); } allocated_ = true; @@ -416,9 +457,10 @@ namespace GridKit * @brief Validate the HYGOV configuration * * Checks parameter-loading errors, static parameter relationships, the - * gate-curve monotonicity, and attached external signals. Seed - * feasibility is operating-point dependent and is checked by - * initialize(). + * gate-curve shape, the gate-limit domain, the assigned + * mechanical-power output, and attached external signals. + * Mechanical-power feasibility is operating-point dependent and is + * checked by initialize(). * * @return int Number of configuration errors; zero when valid. */ @@ -436,21 +478,51 @@ namespace GridKit } }; - check(Trate_ > ZERO, "Trate must be positive"); check(Rtemp_ != ZERO, "Rtemp must be nonzero"); check(Tn_ >= ZERO, "Tn must be non-negative"); check(Velm_ >= ZERO, "Velm must be non-negative"); - check(Gmin_ <= Gmax_, "Gmin must be less than or equal to Gmax"); + check(Gmin_ < Gmax_, "Gmin must be less than Gmax"); check(At_ > ZERO, "At must be positive"); check(Dturb_ >= ZERO, "Dturb must be non-negative"); check(db1_ >= ZERO, "db1 must be non-negative"); check(Hdam_ > ZERO, "Hdam must be positive"); + bool curve_shape_is_valid = true; for (size_t i = 1; i < Gv_.size(); ++i) { - check(Gv_[i - 1] < Gv_[i], "Gv points must be strictly increasing"); - check(Pgv_[i - 1] <= Pgv_[i], "Pgv points must be non-decreasing"); + const bool gate_points_increase = Gv_[i - 1] < Gv_[i]; + const bool power_points_increase = Pgv_[i - 1] <= Pgv_[i]; + + check(gate_points_increase, "Gv points must be strictly increasing"); + check(power_points_increase, "Pgv points must be non-decreasing"); + + if (!gate_points_increase || !power_points_increase) + { + curve_shape_is_valid = false; + } } + const bool minimum_gate_is_valid = Gv_[0] <= Gmin_; + const bool maximum_gate_is_valid = Gmax_ <= Gv_[5]; + check(minimum_gate_is_valid, "Gmin must be at or above the first Gv point"); + check(maximum_gate_is_valid, "Gmax must be at or below the last Gv point"); + + if (curve_shape_is_valid + && Gmin_ < Gmax_ + && minimum_gate_is_valid + && maximum_gate_is_valid + && At_ > ZERO + && Hdam_ > ZERO) + { + // A rise no wider than the tolerance that pins a seed to a range + // edge leaves the gate undetermined by the mechanical power. + const RealT minimum_power = initialMechanicalPower(Gmin_); + const RealT maximum_power = initialMechanicalPower(Gmax_); + check(maximum_power - minimum_power > INITIALIZATION_TOLERANCE, + "mechanical power must rise across [Gmin, Gmax]"); + } + + check(signals_.template isAssigned(), + "pmech output signal must be assigned"); // An attached port must resolve to readable signal storage. The // enumerator is a template argument, so each port names itself once. @@ -473,25 +545,41 @@ namespace GridKit } /** - * @brief Initialize HYGOV from the seeded mechanical-power port + * @brief Initialize HYGOV from the mechanical-power port * * Reads the assigned system-base `pmech` node and the attached speed * and auxiliary-power inputs, solves the component-base steady state - * that preserves the seed, and publishes the resolved load reference to - * an attached `pref` signal. All operating-point checks are completed - * before model or signal storage is modified. + * that preserves the given value, and publishes the resolved load reference + * to an attached `pref` signal. * * @pre allocate() has completed. - * @pre The machine model has seeded the assigned `pmech` node. + * @pre The machine model has initialized the assigned `pmech` node. * - * @return int 0 on success; nonzero when the configuration is invalid, - * no rising segment of the gate curve reproduces the seeded - * power, or the resulting gate is outside Gmin/Gmax. + * @post On success the state zeros every residual row at machine + * rounding; a value clipped to the achievable-power range edge + * leaves a mechanical-power residual up to the initialization + * tolerance. + * @post On failure no state or signal storage has changed. + * + * @return int 0 on success; nonzero when the configuration is + * invalid, the initial speed deviation is nonzero, or no + * gate inside [Gmin, Gmax] reproduces the given power. */ template int Hygov::initialize() { - using I = HygovIdx; + const auto XN = static_cast(HygovInternalVariables::XN); + const auto XF = static_cast(HygovInternalVariables::XF); + const auto C = static_cast(HygovInternalVariables::C); + const auto G = static_cast(HygovInternalVariables::G); + const auto Q = static_cast(HygovInternalVariables::Q); + const auto OMEGADB = static_cast(HygovInternalVariables::OMEGADB); + const auto EF = static_cast(HygovInternalVariables::EF); + const auto FC = static_cast(HygovInternalVariables::FC); + const auto RC = static_cast(HygovInternalVariables::RC); + const auto PGV = static_cast(HygovInternalVariables::PGV); + const auto H = static_cast(HygovInternalVariables::H); + const auto PMECH = static_cast(HygovInternalVariables::PMECH); if (verify() > 0) { @@ -499,11 +587,16 @@ namespace GridKit return 1; } + if (!(va_component_base_ > ZERO) ) + { + va_component_base_ = va_system_base_; + } + auto* y = y_.getData(); // The assigned pmech node aliases this entry after allocate(). Its - // system-base seed remains untouched throughout initialization. - const ScalarT pmech0 = toComponentBase(y[I::PMECH]); + // system-base value remains untouched throughout initialization. + const ScalarT pmech0 = toComponentBase(y[PMECH]); ScalarT omega0{ZERO}; if (signals_.template isAttached()) @@ -511,6 +604,15 @@ namespace GridKit omega0 = signals_.template readExternalVariable(); } + // Synchronous machines provide an exactly zero speed deviation. A + // moving machine would need a multi-root gate search, which this + // model does not support. + if (static_cast(omega0) != ZERO) + { + Log::error() << "Hygov: initialization requires zero speed deviation\n"; + return 1; + } + ScalarT paux0_system{ZERO}; if (signals_.template isAttached()) { @@ -518,17 +620,11 @@ namespace GridKit } const ScalarT paux0 = toComponentBase(paux0_system); - const RealT gate0 = solveInitialGate(static_cast(pmech0), - static_cast(omega0)); + const RealT gate0 = solveInitialGate(static_cast(pmech0)); if (std::isnan(gate0)) { - Log::error() << "Hygov: initial mechanical power is outside the invertible gate curve\n"; - return 1; - } - if (gate0 < Gmin_ - INITIALIZATION_TOLERANCE - || gate0 > Gmax_ + INITIALIZATION_TOLERANCE) - { - Log::error() << "Hygov: initialized gate is outside Gmin/Gmax\n"; + Log::error() + << "Hygov: no gate inside [Gmin, Gmax] reproduces the given mechanical power\n"; return 1; } @@ -540,17 +636,17 @@ namespace GridKit const ScalarT yomega0 = xn0 + leadlag_gain_ * (omegadb0 - xn0); const ScalarT pref0 = toSystemBase(yomega0 + Rperm_ * gate0 - paux0); - y[I::XN] = xn0; - y[I::XF] = ZERO; - y[I::C] = gate0; - y[I::G] = gate0; - y[I::Q] = q0; - y[I::OMEGADB] = omegadb0; - y[I::EF] = ZERO; - y[I::FC] = ZERO; - y[I::RC] = ZERO; - y[I::PGV] = pgv0; - y[I::H] = h0; + y[XN] = xn0; + y[XF] = ZERO; + y[C] = gate0; + y[G] = gate0; + y[Q] = q0; + y[OMEGADB] = omegadb0; + y[EF] = ZERO; + y[FC] = ZERO; + y[RC] = ZERO; + y[PGV] = pgv0; + y[H] = h0; pref_set_ = pref0; paux_set_ = paux0_system; @@ -577,14 +673,18 @@ namespace GridKit template int Hygov::tagDifferentiable() { - using I = HygovIdx; + const auto XN = static_cast(HygovInternalVariables::XN); + const auto XF = static_cast(HygovInternalVariables::XF); + const auto C = static_cast(HygovInternalVariables::C); + const auto G = static_cast(HygovInternalVariables::G); + const auto Q = static_cast(HygovInternalVariables::Q); std::fill(tag_.begin(), tag_.end(), false); - tag_[I::XN] = true; - tag_[I::XF] = true; - tag_[I::C] = true; - tag_[I::G] = true; - tag_[I::Q] = true; + tag_[XN] = true; + tag_[XF] = true; + tag_[C] = true; + tag_[G] = true; + tag_[Q] = true; return 0; } @@ -630,46 +730,60 @@ namespace GridKit const ScalarT* ws, ScalarT* f) { - using I = HygovIdx; - using E = HygovExt; - - const ScalarT xn = y[I::XN]; - const ScalarT xf = y[I::XF]; - const ScalarT c = y[I::C]; - const ScalarT g = y[I::G]; - const ScalarT q = y[I::Q]; - const ScalarT omegadb = y[I::OMEGADB]; - const ScalarT ef = y[I::EF]; - const ScalarT fc = y[I::FC]; - const ScalarT rc = y[I::RC]; - const ScalarT pgv = y[I::PGV]; - const ScalarT head = y[I::H]; - const ScalarT pmech = y[I::PMECH]; - - const ScalarT xn_dot = yp[I::XN]; - const ScalarT xf_dot = yp[I::XF]; - const ScalarT c_dot = yp[I::C]; - const ScalarT g_dot = yp[I::G]; - const ScalarT q_dot = yp[I::Q]; - - const ScalarT omega = ws[E::OMEGA]; - const ScalarT pref = toComponentBase(ws[E::PREF]); - const ScalarT paux = toComponentBase(ws[E::PAUX]); + const auto XN = static_cast(HygovInternalVariables::XN); + const auto XF = static_cast(HygovInternalVariables::XF); + const auto C = static_cast(HygovInternalVariables::C); + const auto G = static_cast(HygovInternalVariables::G); + const auto Q = static_cast(HygovInternalVariables::Q); + const auto OMEGADB = static_cast(HygovInternalVariables::OMEGADB); + const auto EF = static_cast(HygovInternalVariables::EF); + const auto FC = static_cast(HygovInternalVariables::FC); + const auto RC = static_cast(HygovInternalVariables::RC); + const auto PGV = static_cast(HygovInternalVariables::PGV); + const auto H = static_cast(HygovInternalVariables::H); + const auto PMECH = static_cast(HygovInternalVariables::PMECH); + + const auto OMEGA = static_cast(HygovExternalVariables::OMEGA); + const auto PREF = static_cast(HygovExternalVariables::PREF); + const auto PAUX = static_cast(HygovExternalVariables::PAUX); + + const ScalarT xn = y[XN]; + const ScalarT xf = y[XF]; + const ScalarT c = y[C]; + const ScalarT g = y[G]; + const ScalarT q = y[Q]; + const ScalarT omegadb = y[OMEGADB]; + const ScalarT ef = y[EF]; + const ScalarT fc = y[FC]; + const ScalarT rc = y[RC]; + const ScalarT pgv = y[PGV]; + const ScalarT head = y[H]; + const ScalarT pmech = y[PMECH]; + + const ScalarT xn_dot = yp[XN]; + const ScalarT xf_dot = yp[XF]; + const ScalarT c_dot = yp[C]; + const ScalarT g_dot = yp[G]; + const ScalarT q_dot = yp[Q]; + + const ScalarT omega = ws[OMEGA]; + const ScalarT pref = ws[PREF]; + const ScalarT paux = ws[PAUX]; const ScalarT yomega = xn + leadlag_gain_ * (omegadb - xn); - f[I::XN] = -xn_dot + (omegadb - xn) / Tnp_; - f[I::XF] = -xf_dot + (ef - xf) / Tf_; - f[I::C] = -c_dot + Math::antiwindup(c, rc, Gmin_, Gmax_); - f[I::G] = -g_dot + (c - g) / Tg_; - f[I::Q] = -q_dot + (Hdam_ - head) / Tw_; - f[I::OMEGADB] = -omegadb + Math::deadband1(omega, -db1_, db1_); - f[I::EF] = -ef + pref + paux - yomega - Rperm_ * c; - f[I::FC] = -fc + (xf / Tr_ + (ef - xf) / Tf_) / Rtemp_; - f[I::RC] = -rc + Math::clamp(fc, -Velm_, Velm_); - f[I::PGV] = -pgv + gatePower(g); - f[I::H] = -q * q + head * pgv * pgv; - f[I::PMECH] = -toComponentBase(pmech) + At_ * head * (q - Qnl_) - Dturb_ * omega * g; + f[XN] = -xn_dot + (omegadb - xn) / Tnp_; + f[XF] = -xf_dot + (ef - xf) / Tf_; + f[C] = -c_dot + Math::antiwindup(c, rc, Gmin_, Gmax_); + f[G] = -g_dot + (c - g) / Tg_; + f[Q] = -q_dot + (Hdam_ - head) / Tw_; + f[OMEGADB] = -omegadb + Math::deadband1(omega, -db1_, db1_); + f[EF] = -ef + toComponentBase(pref + paux) - yomega - Rperm_ * c; + f[FC] = -Rtemp_ * fc + xf / Tr_ + (ef - xf) / Tf_; + f[RC] = -rc + Math::clamp(fc, -Velm_, Velm_); + f[PGV] = -pgv + gatePower(g); + f[H] = -q * q + head * pgv * pgv; + f[PMECH] = -toComponentBase(pmech) + At_ * head * (q - Qnl_) - Dturb_ * omega * g; return 0; } @@ -688,29 +802,31 @@ namespace GridKit template int Hygov::evaluateResidual() { - using E = HygovExt; + const auto OMEGA = static_cast(HygovExternalVariables::OMEGA); + const auto PREF = static_cast(HygovExternalVariables::PREF); + const auto PAUX = static_cast(HygovExternalVariables::PAUX); - ws_[E::OMEGA] = ZERO; - ws_[E::PREF] = pref_set_; - ws_[E::PAUX] = paux_set_; + ws_[OMEGA] = ZERO; + ws_[PREF] = pref_set_; + ws_[PAUX] = paux_set_; std::fill(ws_indices_.begin(), ws_indices_.end(), INVALID_INDEX); if (signals_.template isAttached()) { - ws_[E::OMEGA] = signals_.template readExternalVariable(); - ws_indices_[E::OMEGA] = + ws_[OMEGA] = signals_.template readExternalVariable(); + ws_indices_[OMEGA] = signals_.template readExternalVariableIndex(); } if (signals_.template isAttached()) { - ws_[E::PREF] = signals_.template readExternalVariable(); - ws_indices_[E::PREF] = + ws_[PREF] = signals_.template readExternalVariable(); + ws_indices_[PREF] = signals_.template readExternalVariableIndex(); } if (signals_.template isAttached()) { - ws_[E::PAUX] = signals_.template readExternalVariable(); - ws_indices_[E::PAUX] = + ws_[PAUX] = signals_.template readExternalVariable(); + ws_indices_[PAUX] = signals_.template readExternalVariableIndex(); } diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md b/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md index 25535e2a9..b73c47320 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md @@ -5,12 +5,6 @@ a nonlinear single-penstock turbine. ## Notes -- Power signals and the `pmech` monitor output are on system base. -- Internal load-reference, gate, flow, and head quantities are on HYGOV - component base. -- HYGOV uses $T^\mathrm{rate}$, loaded from `Trate`, as its component power base. -- PowerWorld uses the connected machine base when `Trate = 0`; GridKit requires - `Trate` to be set explicitly. - HYGOVD `dbL`/`dbH`, `db2` backlash, and Kaplan blade-servo fields are not modeled. The `db2` JSON field is accepted only for source-format compatibility. @@ -25,28 +19,29 @@ Figure 1: HYGOV governor model. Figure courtesy of the Symbol | Units | JSON | Description | Typical Value | Note ------------------------|----------|---------------|------------------------------------------|---------------|------ -$T^\mathrm{rate}$ | [MW] | `Trate` | Turbine-rating power base | 100.0 | Required positive value +$T^\mathrm{rate}$ | [MW] | `Trate` | Turbine-rating power base | 100.0 | System power base when omitted $R_{\mathrm{perm}}$ | [p.u.] | `Rperm` | Permanent droop | 0.04 | Source label: `R` $R_{\mathrm{temp}}$ | [p.u.] | `Rtemp` | Temporary droop | 0.3 | Source label: `r` -$T_r$ | [sec] | `Tr` | Temporary-droop reset time constant | 5.0 | Raised to the minimum-time floor -$T_f$ | [sec] | `Tf` | Governor error filter time constant | 0.05 | State 1; raised to the minimum-time floor -$T_g$ | [sec] | `Tg` | Gate servo time constant | 0.5 | State 3; raised to the minimum-time floor -$V_{\mathrm{elm}}$ | [p.u./s] | `Velm` | Maximum desired-gate velocity magnitude | 0.2 | Symmetric rate limit on State 2 +$T_r$ | [sec] | `Tr` | Temporary-droop reset time constant | 5.0 | +$T_f$ | [sec] | `Tf` | Governor error filter time constant | 0.05 | +$T_g$ | [sec] | `Tg` | Gate servo time constant | 0.5 | +$V_{\mathrm{elm}}$ | [p.u./s] | `Velm` | Maximum desired-gate velocity magnitude | 0.2 | $G^{\max}$ | [p.u.] | `Gmax` | Maximum desired-gate position | 1.0 | $G^{\min}$ | [p.u.] | `Gmin` | Minimum desired-gate position | 0.0 | -$T_w$ | [sec] | `Tw` | Water inertia time constant | 1.0 | State 4; raised to the minimum-time floor +$T_w$ | [sec] | `Tw` | Water inertia time constant | 1.0 | $A_t$ | [p.u.] | `At` | Turbine gain | 1.2 | -$D_{\mathrm{turb}}$ | [p.u.] | `Dturb` | Turbine damping coefficient | 0.5 | Multiplied by speed deviation and gate +$D_{\mathrm{turb}}$ | [p.u.] | `Dturb` | Turbine damping coefficient | 0.5 | $q_{\mathrm{NL}}$ | [p.u.] | `Qnl` | No-load flow at nominal head | 0.05 | $T_n$ | [sec] | `Tn` | Speed lead-lag numerator time constant | 0.0 | -$T_{\mathrm{np}}$ | [sec] | `Tnp` | Speed lead-lag denominator time constant | 0.0 | Raised to the minimum-time floor -$D_{\omega}$ | [p.u.] | `db1` | Type 1 speed deadband threshold | 0.0 | Uses CommonMath `deadband1` -$D_2$ | [p.u.] | `db2` | Unsupported mechanical backlash deadband | 0.0 | Accepted for source-format compatibility; not modeled +$T_{\mathrm{np}}$ | [sec] | `Tnp` | Speed lead-lag denominator time constant | 0.0 | +$D_{\omega}$ | [p.u.] | `db1` | Type 1 speed deadband threshold | 0.0 | +$D_2$ | [p.u.] | `db2` | Mechanical backlash deadband | 0.0 | $H_{\mathrm{dam}}$ | [p.u.] | `Hdam` | Head available at dam | 1.0 | $G_V^{(k)}$ | [p.u.] | `Gv0`-`Gv5` | Gate point $k$ of the gain curve | 0.0 | $k=0,\ldots,5$ $P_{\mathrm{GV}}^{(k)}$ | [p.u.] | `Pgv0`-`Pgv5` | Power point $k$ of the gain curve | 0.0 | $k=0,\ldots,5$ -All-zero `Gv` and `Pgv` source points select the identity curve. +Every parameter is optional. All-zero `Gv` and `Pgv` source points select the +identity curve. ### Parameter Validation @@ -54,7 +49,7 @@ Invalid HYGOV parameter sets are rejected by the following checks: ```math \begin{aligned} - T^\mathrm{rate} &> 0 \\ + T^\mathrm{rate} &> 0 \quad \text{when provided} \\ T_r, T_f, T_g, T_w, T_{\mathrm{np}} &\ge 0 \\ R_{\mathrm{temp}} @@ -64,7 +59,7 @@ Invalid HYGOV parameter sets are rejected by the following checks: V_{\mathrm{elm}} &\ge 0 \\ G^{\min} - &\le G^{\max} \\ + &< G^{\max} \\ A_t &> 0 \\ D_{\mathrm{turb}} @@ -78,10 +73,17 @@ Invalid HYGOV parameter sets are rejected by the following checks: \quad k\in\{0,\ldots,4\} \\ P_{\mathrm{GV}}^{(k)} &\le P_{\mathrm{GV}}^{(k+1)} - \quad k\in\{0,\ldots,4\} + \quad k\in\{0,\ldots,4\} \\ + G_V^{(0)} \le G^{\min} + &< G^{\max} \le G_V^{(5)} \\ + P_{\mathrm{m}}(G^{\max}) - P_{\mathrm{m}}(G^{\min}) + &> \epsilon_{\mathrm{init}} \end{aligned} ``` +The final condition uses the steady mechanical power and tolerance defined +under [Internal Initialization](#internal-initialization). + ### Model Derived Parameters Let $\epsilon_T=10^{-3}\ \mathrm{s}$. A time constant below $\epsilon_T$ is @@ -123,9 +125,11 @@ Name | Port | Init | Description `paux` | Input | Known | Auxiliary power input `pmech` | Output | Known | Mechanical power 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 +`pmech` output must be assigned; the signal inputs are optional. Unattached +`speed` and `paux` inputs default to zero. ## Model Variables @@ -145,13 +149,13 @@ $q$ | [p.u.] | Turbine flow | State 4 Symbol | Units | Description | Note ------------------------|----------|---------------------------------------------|------ -$\omega_{\mathrm{db}}$ | [p.u.] | Type 1 deadbanded speed deviation | Defined by CommonMath `deadband1` +$\omega_{\mathrm{db}}$ | [p.u.] | Type 1 deadbanded speed deviation | $e_f$ | [p.u.] | Governor error into the filter | Reference path less conditioned speed and permanent-droop feedback $f_c$ | [p.u./s] | Desired-gate derivative target | Before rate and position limits $r_c$ | [p.u./s] | Rate-limited desired-gate derivative target | Limited by $\pm V_{\mathrm{elm}}$ $P_{\mathrm{GV}}$ | [p.u.] | Nonlinear gate-to-power curve output | $N_{\mathrm{GV}}(g)$ $H$ | [p.u.] | Turbine head | Implicit water-column head -$P_{\mathrm{m}}$ | [p.u.] | Mechanical power to generator | System base; assigned to `pmech` +$P_{\mathrm{m}}$ | [p.u.] | Mechanical power to generator | System base ### External Variables @@ -209,18 +213,14 @@ target and smooth approximation. \left(\omega;\, -D_{\omega}, D_{\omega}\right) \\ 0 &= -e_f - + k_{\mathrm{base}}P^\mathrm{ref} - + k_{\mathrm{base}}P^\mathrm{aux} + + k_{\mathrm{base}}\left(P^\mathrm{ref} + P^\mathrm{aux}\right) - x_n - k_n\left(\omega_{\mathrm{db}} - x_n\right) - R_{\mathrm{perm}}c \\ 0 &= - -f_c - + \dfrac{1}{R_{\mathrm{temp}}} - \left[ - \dfrac{x_f}{T_r} - + \dfrac{e_f - x_f}{T_f} - \right] \\ + -R_{\mathrm{temp}}f_c + + \dfrac{x_f}{T_r} + + \dfrac{e_f - x_f}{T_f} \\ 0 &= -r_c + \text{clamp} @@ -250,7 +250,7 @@ CommonMath defines helper targets and smooth approximations for \omega &\leftarrow \text{machine speed deviation} \\ P_{\mathrm{m}} - &\leftarrow \text{machine mechanical-power seed on system base} \\ + &\leftarrow \text{machine mechanical power on system base} \\ P^\mathrm{aux} &\leftarrow \text{auxiliary power input on system base} \end{aligned} @@ -260,54 +260,51 @@ Initialization never replaces the system-base value held in $P_{\mathrm{m}}$. ### Internal Initialization -Initialization evaluates the steady-state residuals in dependency order. -Subscript $0$ denotes initial values; all internal derivatives start at zero. -The gate solves the steady turbine-power equation on the rising linear -segments of $N_{\mathrm{GV}}$; when several segments reproduce the seeded -power, the lowest admissible gate is selected: +Initialization requires an exactly zero speed deviation, $\omega = 0$; +restart initialization of a moving machine is not supported. All internal +derivatives are set to zero. + +The gate is found by bisection over the validated nondecreasing steady-power +curve using the same smooth $N_{\mathrm{GV}}$ curve as the residual: ```math \begin{aligned} - H_0 - &= H_{\mathrm{dam}} \\ - k_{\mathrm{base}}P_{\mathrm{m},0} - &= A_t H_0\left(\sqrt{H_0}\,N_{\mathrm{GV}}(g_0) - q_{\mathrm{NL}}\right) - - D_{\mathrm{turb}}\,\omega_0\, g_0 \\ - P_{\mathrm{GV},0} - &= N_{\mathrm{GV}}(g_0) \\ - q_0 - &= \sqrt{H_0}\,P_{\mathrm{GV},0} \\ - c_0 - &= g_0 \\ - \omega_{\mathrm{db},0} - &= \text{deadband1}\!\left(\omega_0;\, -D_{\omega}, D_{\omega}\right) \\ - x_{n,0} - &= \omega_{\mathrm{db},0} \\ - x_{f,0} - &= 0 \\ - e_{f,0} - &= 0 \\ - f_{c,0} - &= 0 \\ - r_{c,0} - &= 0 + H + &\leftarrow H_{\mathrm{dam}} \\ + g + &\leftarrow \text{gate in } [G^{\min}, G^{\max}] \text{ satisfying} \\ + &\qquad k_{\mathrm{base}}P_{\mathrm{m}} + = A_t H\left(\sqrt{H}\,N_{\mathrm{GV}}(g) - q_{\mathrm{NL}}\right) \\ + P_{\mathrm{GV}} + &\leftarrow N_{\mathrm{GV}}(g) \\ + q + &\leftarrow \sqrt{H}\,P_{\mathrm{GV}} \\ + c + &\leftarrow g \\ + \omega_{\mathrm{db}} + &\leftarrow \text{deadband1}\!\left(\omega;\, -D_{\omega}, D_{\omega}\right) \\ + x_n + &\leftarrow \omega_{\mathrm{db}} \\ + x_f + &\leftarrow 0 \\ + e_f + &\leftarrow 0 \\ + f_c + &\leftarrow 0 \\ + r_c + &\leftarrow 0 \end{aligned} ``` -Initialization rejects an operating point when any of the following holds: - -- no rising segment of $N_{\mathrm{GV}}$ reproduces the seeded mechanical - power; or -- the resulting gate lies outside $[G^{\min}, G^{\max}]$ by more than - $\epsilon_0 = 10^{-10}$. - -The gate is solved on the piecewise-linear gain curve while the residual -evaluates its smooth approximation, so operating points within a few -hundredths of a $G_V$ breakpoint start with a mechanical-power residual up to -$O(10^{-3})$; mid-segment points rest at $O(10^{-13})$. +Initialization rejects an operating point when no gate in +$[G^{\min}, G^{\max}]$ reproduces the given mechanical power. An in-range +value initializes with every residual at machine rounding; a value within +$\epsilon_{\mathrm{init}} = 100\,\epsilon_{\mathrm{mach}}$ of the +achievable-power range initializes at the corresponding gate limit with a +mechanical-power residual up to $\epsilon_{\mathrm{init}}$. Every check resolves before any storage is written, so a rejected -initialization leaves state, the `pmech` seed, and external signals unchanged. +initialization leaves state, `pmech`, and external signals unchanged. ### Output Initialization @@ -317,19 +314,15 @@ initialization leaves state, the `pmech` seed, and external signals unchanged. &\leftarrow \dfrac{1}{k_{\mathrm{base}}} \left[ - e_{f,0} - - k_{\mathrm{base}}P^\mathrm{aux}_0 - + x_{n,0} - + k_n\left(\omega_{\mathrm{db},0} - x_{n,0}\right) - + R_{\mathrm{perm}}c_0 + e_f + - k_{\mathrm{base}}P^\mathrm{aux} + + x_n + + k_n\left(\omega_{\mathrm{db}} - x_n\right) + + R_{\mathrm{perm}}c \right] \end{aligned} ``` -HYGOV writes the resolved active-power/load reference to an attached `pref` -signal input. If no controller is connected, that value is used as a constant -reference input. - ## Monitorable Outputs Output | Units | Description | Note @@ -340,3 +333,60 @@ Output | Units | Description | Note `gate` | [p.u.] | Gate position | $g$ (component base) `flow` | [p.u.] | Turbine flow | $q$ (component base) `head` | [p.u.] | Turbine head | $H$ (component base) + +## Testing + +- `validation()` checks construction, monitor creation, parameter + validation, signal configuration, and minimum time-constant handling. +- `initializationAndSignals()` checks initialization, base conversion, + signal publication, monitor output, and unattached-reference latching. +- `initializationDomain()` checks rejected and accepted mechanical-power, + gate-limit, and speed-deviation initialization boundaries. +- `initializationExactness()` checks that initialized steady residuals rest + at machine rounding across the gate curve. +- `residualEquations()` checks every model residual against a fixed + numerical answer key. +- `governorControl()` checks the speed deadband, the desired-gate velocity + limit, and the gate-position anti-windup. +- `turbineDynamics()` checks the gate-power curve, the water column, turbine + damping, and initialization through the nonlinear curve. +- `jacobian()` compares the dependency-tracking and Enzyme Jacobians across + the gate curve when Enzyme support is enabled. + +## Appendix A: Backlash + +Input $u$, output $y$, half-play $b$, with $|u - y| \le b$. + +```math +\begin{aligned} + \dot{y} + &= + \begin{cases} + \dot{u} & |u - y| = b \text{ and } \dot{u}\left(u - y\right) > 0 \\ + 0 & \text{otherwise} + \end{cases} +\end{aligned} +``` + +which can be written in terms of our smooth functions as + +```math +\begin{aligned} + 0 &= + -\dot{y} + + \text{ramp}(\dot{u})\,\text{above}(u - y;\, b) + - \text{ramp}(-\dot{u})\,\text{below}(u - y;\, -b) +\end{aligned} +``` + +CommonMath defines the [`ramp`](GridKit/CommonMath.md#-ramp), +[`above`](GridKit/CommonMath.md#above), and +[`below`](GridKit/CommonMath.md#below) targets and smooth approximations. This is deferred until we permit non Hessenberg forms. Once permitted we should define: + +```math +\begin{aligned} + \text{backlash}(u,\dot{u},y;b) &= + \text{ramp}(\dot{u})\,\text{above}(u - y;\, b) + - \text{ramp}(-\dot{u})\,\text{below}(u - y;\, -b) +\end{aligned} +``` diff --git a/GridKit/Model/PhasorDynamics/SystemModelDataJSONParser.hpp b/GridKit/Model/PhasorDynamics/SystemModelDataJSONParser.hpp index 5856619c0..328053288 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelDataJSONParser.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelDataJSONParser.hpp @@ -149,9 +149,9 @@ namespace GridKit } else if (kind == "Hygov") { - typename SystemModelData::HygovDataT gov; - raw_component.get_to(gov); - sm.hygov.push_back(gov); + typename SystemModelData::HygovDataT hygov; + raw_component.get_to(hygov); + sm.hygov.push_back(hygov); } else if (kind == "Ieeet1") { diff --git a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp index 775c1537f..ceaa8ed6d 100644 --- a/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp +++ b/GridKit/Model/PhasorDynamics/SystemModelImpl.hpp @@ -316,39 +316,39 @@ namespace GridKit } // Add HYGOV governors - for (const auto& govdata : data.hygov) + for (const auto& hygovdata : data.hygov) { - auto* gov = new Hygov(govdata); + auto* hygov = new Hygov(hygovdata); - if (govdata.signal_inputs.contains(HygovSignalInputs::speed)) + if (hygovdata.signal_inputs.contains(HygovSignalInputs::speed)) { - IdxT speed = govdata.signal_inputs.at(HygovSignalInputs::speed); + IdxT speed = hygovdata.signal_inputs.at(HygovSignalInputs::speed); constexpr auto OMEGA = HygovExternalVariables::OMEGA; - gov->getSignals().template attachSignalNode(getSignal(speed)); + hygov->getSignals().template attachSignalNode(getSignal(speed)); } - if (govdata.signal_outputs.contains(HygovSignalOutputs::pmech)) + if (hygovdata.signal_inputs.contains(HygovSignalInputs::pref)) { - IdxT pmech = govdata.signal_outputs.at(HygovSignalOutputs::pmech); - constexpr auto PMECH = HygovInternalVariables::PMECH; - gov->getSignals().template assignSignalNode(getSignal(pmech)); + IdxT pref = hygovdata.signal_inputs.at(HygovSignalInputs::pref); + constexpr auto PREF = HygovExternalVariables::PREF; + hygov->getSignals().template attachSignalNode(getSignal(pref)); } - if (govdata.signal_inputs.contains(HygovSignalInputs::pref)) + if (hygovdata.signal_inputs.contains(HygovSignalInputs::paux)) { - IdxT pref = govdata.signal_inputs.at(HygovSignalInputs::pref); - constexpr auto PREF = HygovExternalVariables::PREF; - gov->getSignals().template attachSignalNode(getSignal(pref)); + IdxT paux = hygovdata.signal_inputs.at(HygovSignalInputs::paux); + constexpr auto PAUX = HygovExternalVariables::PAUX; + hygov->getSignals().template attachSignalNode(getSignal(paux)); } - if (govdata.signal_inputs.contains(HygovSignalInputs::paux)) + if (hygovdata.signal_outputs.contains(HygovSignalOutputs::pmech)) { - IdxT paux = govdata.signal_inputs.at(HygovSignalInputs::paux); - constexpr auto PAUX = HygovExternalVariables::PAUX; - gov->getSignals().template attachSignalNode(getSignal(paux)); + IdxT pmech = hygovdata.signal_outputs.at(HygovSignalOutputs::pmech); + constexpr auto PMECH = HygovInternalVariables::PMECH; + hygov->getSignals().template assignSignalNode(getSignal(pmech)); } - addComponent(gov); + addComponent(hygov); } for (const auto& excitedata : data.exciter) @@ -750,20 +750,22 @@ namespace GridKit template int SystemModel::initialize() { + int status = 0; + for (const auto& bus : buses_) { - bus->initialize(); + status += bus->initialize(); } for (const auto& component : components_) { - component->initialize(); + status += component->initialize(); } y_.setDataUpdated(); yp_.setDataUpdated(); - return 0; + return status; } /** diff --git a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp index e6315c8dd..9d6353cf3 100644 --- a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp +++ b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp @@ -2,12 +2,16 @@ #include #include +#include #include #include +#include #include +#include #include #include +#include #include #include #include @@ -35,134 +39,175 @@ namespace GridKit GovernorHygovTests() = default; ~GovernorHygovTests() = default; - // HYGOV initialization solves the piecewise-linear gate curve exactly - // while the residual rides its smooth CommonMath approximation. At the - // mid-segment operating points used here the resulting steady residuals - // are O(1e-13), so behavioral comparisons use a tolerance well above - // that gap and well below every pinned answer-key digit. - 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; + // Initialization is exact and every pinned literal is recorded at full + // precision from the implemented smooth arithmetic, so one tight + // tolerance serves the whole suite. + static constexpr RealT kTol = + static_cast(100.0) * std::numeric_limits::epsilon(); /// Construction and every verify() error class, including parameter - /// types, parameter relationships, curve monotonicity, and signal - /// linkage. + /// types, parameter relationships, curve shape, gate-limit domain, + /// the required pmech assignment, and signal linkage, plus + /// differentiability tagging. TestOutcome validation() { TestStatus success = true; PhasorDynamics::Governor::Hygov empty; - success *= (empty.size() == static_cast(I::MAXIMUM)); + success *= (empty.size() == static_cast(Internal::MAXIMUM)); success *= (empty.getMonitor() == nullptr); - PhasorDynamics::Governor::Hygov configured(makeData()); - success *= (configured.size() == static_cast(I::MAXIMUM)); - success *= (configured.getMonitor() != nullptr); - success *= (configured.verify() == 0); + Fixture configured(makeData()); + success *= (configured.hygov.size() == static_cast(Internal::MAXIMUM)); + success *= (configured.hygov.getMonitor() != nullptr); + success *= (configured.hygov.verify() == 0); noteExpectedLogs("Testing HYGOV defaults and invalid configurations. " "Logged errors and time-constant warnings are expected."); - auto minimal_data = makeMinimalData(); - minimal_data.parameters[Params::Trate] = 100.0; - PhasorDynamics::Governor::Hygov minimal(minimal_data); - success *= (minimal.verify() == 0); + Fixture minimal(makeMinimalData()); + success *= (minimal.hygov.verify() == 0); success *= defaultsMatchDocumentedValues(); success *= (empty.verify() > 0); - PhasorDynamics::Governor::Hygov missing_trate(makeMinimalData()); - success *= (missing_trate.verify() > 0); - - success *= invalidParameterCase(Params::Trate, 0.0); - success *= invalidParameterCase(Params::Rtemp, 0.0); - success *= invalidParameterCase(Params::Tr, -0.1); - success *= invalidParameterCase(Params::Tf, -0.1); - success *= invalidParameterCase(Params::Tg, -0.1); - success *= invalidParameterCase(Params::Tw, -0.1); - success *= invalidParameterCase(Params::Tn, -0.1); - success *= invalidParameterCase(Params::Tnp, -0.1); - success *= invalidParameterCase(Params::Velm, -0.1); - success *= invalidParameterCase(Params::Gmin, 1.1); - success *= invalidParameterCase(Params::At, 0.0); - success *= invalidParameterCase(Params::Dturb, -0.1); - success *= invalidParameterCase(Params::db1, -0.1); - success *= invalidParameterCase(Params::Hdam, 0.0); - success *= invalidParameterCase(Params::Gv2, 0.1); - success *= invalidParameterCase(Params::Pgv2, 0.1); + // The pmech output is required, so a model without an assigned node + // is rejected even when every parameter is valid. + PhasorDynamics::Governor::Hygov unassigned(makeData()); + success *= (unassigned.verify() > 0); + + for (const auto& invalid : std::array, 19>{{ + {Params::Trate, 0.0}, + {Params::Trate, -1.0}, + {Params::Rtemp, 0.0}, + {Params::Tr, -0.1}, + {Params::Tf, -0.1}, + {Params::Tg, -0.1}, + {Params::Tw, -0.1}, + {Params::Tn, -0.1}, + {Params::Tnp, -0.1}, + {Params::Velm, -0.1}, + {Params::Gmin, 1.1}, + {Params::At, 0.0}, + {Params::Dturb, -0.1}, + {Params::db1, -0.1}, + {Params::Hdam, 0.0}, + {Params::Gv2, 0.1}, + {Params::Pgv2, 0.1}, + {Params::Gmin, -0.05}, + {Params::Gmax, 1.05}, + }}) + { + Fixture invalid_fixture(makeData(), {{invalid.first, invalid.second}}); + success *= (invalid_fixture.hygov.verify() > 0); + } + + // A curve with no rise cannot yield a unique gate. + Fixture flat_curve(makeData(), + {{Params::Pgv1, 0.0}, + {Params::Pgv2, 0.0}, + {Params::Pgv3, 0.0}, + {Params::Pgv4, 0.0}, + {Params::Pgv5, 0.0}}); + success *= (flat_curve.hygov.verify() > 0); + + // A curve that rises only outside the permitted gate range cannot + // provide a usable steady-power range for initialization. + Fixture flat_active_range(makeData(), + {{Params::Gmin, 0.0}, + {Params::Gmax, 0.2}, + {Params::Pgv0, 0.5}, + {Params::Pgv1, 0.5}, + {Params::Pgv2, 0.5}, + {Params::Pgv3, 0.5}, + {Params::Pgv4, 0.5}, + {Params::Pgv5, 1.0}}); + success *= (flat_active_range.hygov.verify() > 0); // db2 is accepted for source-format compatibility and never used. - auto backlash_data = makeData(); - backlash_data.parameters[Params::db2] = 0.5; - PhasorDynamics::Governor::Hygov backlash_model(backlash_data); - success *= (backlash_model.verify() == 0); + Fixture backlash(makeData(), {{Params::db2, 0.5}}); + success *= (backlash.hygov.verify() == 0); // Integer JSON values are accepted for real parameters; booleans are // not numeric. auto integer_real = makeData(); integer_real.parameters[Params::Tw] = static_cast(2); - PhasorDynamics::Governor::Hygov integer_real_model(integer_real); - success *= (integer_real_model.verify() == 0); + Fixture integer_model(integer_real); + success *= (integer_model.hygov.verify() == 0); auto bad_numeric_type = makeData(); bad_numeric_type.parameters[Params::Trate] = true; - PhasorDynamics::Governor::Hygov bad_numeric_model(bad_numeric_type); - success *= (bad_numeric_model.verify() > 0); + Fixture bad_numeric_model(bad_numeric_type); + success *= (bad_numeric_model.hygov.verify() > 0); - success *= unlinkedSignalRejected(); - success *= unlinkedSignalRejected(); - success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); + success *= unlinkedSignalRejected(); // All five zero time constants 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::Tf] = 0.0; - zero_time.parameters[Params::Tg] = 0.0; - zero_time.parameters[Params::Tw] = 0.0; - zero_time.parameters[Params::Tnp] = 0.0; - - Fixture fixture(zero_time); - success *= fixture.initialize(0.4); - success *= (fixture.evaluate() == 0); - success *= allResidualsZero(fixture.hygov); + Fixture floors(makeData(), + {{Params::Tr, 0.0}, + {Params::Tf, 0.0}, + {Params::Tg, 0.0}, + {Params::Tw, 0.0}, + {Params::Tnp, 0.0}}); + success *= floors.initialize(0.4); + success *= (floors.evaluate() == 0); + success *= allResidualsZero(floors.hygov); + + // The five governor states carry derivatives; the rest is algebraic. + success *= (floors.hygov.tagDifferentiable() == 0); + for (size_t i = 0; i < static_cast(floors.hygov.size()); ++i) + { + const bool differential = i <= static_cast(Internal::Q); + if (floors.hygov.tag()[i] != differential) + { + std::cout << "HYGOV differentiability tag " << i << " mismatch\n"; + success = false; + } + } return success.report(__func__); } /// A nonidentity power-base initialization with every port attached. - /// The machine-seeded pmech node must remain unchanged while HYGOV + /// The machine-provided pmech value must remain unchanged while HYGOV /// initializes and publishes its resolved load reference. TestOutcome initializationAndSignals() { TestStatus success = true; - auto data = makeData(); - data.parameters[Params::Trate] = 50.0; - - Fixture fixture(data); + Fixture fixture(makeData(), {{Params::Trate, 50.0}}); fixture.attachAllInputs(); - fixture.input(E::PAUX) = 0.02; - fixture.input(E::PREF) = 99.0; // stale value the publication must replace - success *= fixture.initialize(0.4); - success *= (fixture.hygov.tagDifferentiable() == 0); - success *= (fixture.evaluate() == 0); + fixture.input(External::PAUX) = 0.02; + fixture.input(External::PREF) = 99.0; // stale value the publication must replace + success *= fixture.initialize(0.4); + success *= (fixture.evaluate() == 0); const auto* y = fixture.hygov.y().getData(); - success *= scalarMatches(y[I::XF], 0.0, "XF at rest"); - success *= scalarMatches(y[I::C], 0.9, "C on component base"); - success *= scalarMatches(y[I::G], 0.9, "G on component base"); - success *= scalarMatches(y[I::Q], 0.9, "Q on component base"); - success *= scalarMatches(y[I::PGV], 0.9, "PGV on component base"); - success *= scalarMatches(y[I::H], 1.0, "H at the dam head"); - success *= scalarMatches(fixture.pmech(), 0.4, "preserved pmech seed"); - - success *= scalarMatches(fixture.input(E::OMEGA), 0.0, "preserved omega input"); - success *= scalarMatches(fixture.input(E::PREF), 0.0025, "published pref"); - success *= scalarMatches(fixture.input(E::PAUX), 0.02, "preserved paux input"); - + success *= scalarMatches(y[static_cast(Internal::XF)], 0.0, "XF at rest"); + success *= scalarMatches(y[static_cast(Internal::C)], + 0.9000000000001573, + "C on component base"); + success *= scalarMatches(y[static_cast(Internal::G)], + 0.9000000000001573, + "G on component base"); + success *= scalarMatches(y[static_cast(Internal::Q)], 0.9, "Q on component base"); + success *= scalarMatches(y[static_cast(Internal::PGV)], + 0.9, + "PGV on component base"); + success *= scalarMatches(y[static_cast(Internal::H)], 1.0, "H at the dam head"); + success *= scalarMatches(fixture.pmech(), 0.4, "preserved pmech value"); + + success *= scalarMatches(fixture.input(External::OMEGA), 0.0, "preserved omega input"); + success *= scalarMatches(fixture.input(External::PREF), 0.0025, "published pref"); + success *= scalarMatches(fixture.input(External::PAUX), 0.02, "preserved paux input"); + + // The monitor must expose the six documented quantities bound to + // the initialized states. The controller is the monitor's only + // public read surface; its formats are covered by infrastructure + // tests. RealT time = 0.0; Model::VariableMonitorController monitor(time); monitor.addMonitor(fixture.hygov.getMonitor()); @@ -184,8 +229,8 @@ namespace GridKit { success *= scalarMatches(monitored[1], 0.4, "monitored pmech"); success *= scalarMatches(monitored[2], 0.0, "monitored filter"); - success *= scalarMatches(monitored[3], 0.9, "monitored desiredgate"); - success *= scalarMatches(monitored[4], 0.9, "monitored gate"); + success *= scalarMatches(monitored[3], 0.9000000000001573, "monitored desiredgate"); + success *= scalarMatches(monitored[4], 0.9000000000001573, "monitored gate"); success *= scalarMatches(monitored[5], 0.9, "monitored flow"); success *= scalarMatches(monitored[6], 1.0, "monitored head"); } @@ -196,28 +241,19 @@ namespace GridKit success = false; } - for (size_t i = 0; i < static_cast(fixture.hygov.size()); ++i) - { - const bool expected = i <= I::Q; - if (fixture.hygov.tag()[i] != expected) - { - std::cout << "HYGOV differentiability tag " << i << " mismatch\n"; - success = false; - } - } success *= allResidualsZero(fixture.hygov); // A system-base reference step lands on the governor error scaled by // the base ratio. - fixture.input(E::PREF) = 0.1025; // the published 0.0025 plus a 0.1 step - success *= (fixture.evaluate() == 0); - success *= residualsMatch(fixture.hygov, - {{I::EF, 0.2}}, + fixture.input(External::PREF) = 0.1025; // the published 0.0025 plus a 0.1 step + success *= (fixture.evaluate() == 0); + success *= residualsMatch(fixture.hygov, + {{Internal::EF, 0.2}}, "reference step on the component base"); // Unattached ports fall back to the references latched by // initialize(), so the same steady state holds without a controller. - Fixture fallback(data); + Fixture fallback(makeData(), {{Params::Trate, 50.0}}); success *= fallback.initialize(0.4); success *= (fallback.evaluate() == 0); success *= allResidualsZero(fallback.hygov); @@ -225,15 +261,16 @@ namespace GridKit return success.report(__func__); } - /// Mechanical-power and gate-limit initialization domains. Every - /// rejection is atomic; exact Gmin/Gmax boundaries and a zero power - /// seed remain admissible. + /// Mechanical-power, gate-limit, and speed-deviation initialization + /// domains. Every rejection is atomic; values inside the achievable + /// range initialize at rest, and values within the initialization + /// tolerance of a range edge pin to the gate limit. TestOutcome initializationDomain() { TestStatus success = true; - noteExpectedLogs("Testing inadmissible HYGOV mechanical-power and gate " - "initialization points. Logged errors are expected."); + noteExpectedLogs("Testing inadmissible HYGOV initialization points. " + "Logged errors are expected."); struct RejectionCase { @@ -243,26 +280,30 @@ namespace GridKit RealT gmax; }; - const std::array rejected{{ - {"mechanical power above the gate curve", 1.0, 0.05, 0.95}, - {"mechanical power below the gate curve", -0.3, 0.05, 0.95}, - {"initialized gate above Gmax", 0.4, 0.05, 0.5}, - {"initialized gate below Gmin", 0.4, 0.6, 0.95}, - }}; - - for (const auto& test_case : rejected) + for (const auto& test_case : std::array{{ + {"mechanical power above the gate curve", 1.0, 0.05, 0.95}, + {"mechanical power below the gate curve", -0.3, 0.05, 0.95}, + {"mechanical power above the Gmax limit", 0.4, 0.05, 0.5}, + {"mechanical power below the Gmin limit", 0.4, 0.6, 0.95}, + }}) { - auto data = makeResidualData(); - data.parameters[Params::Gmin] = test_case.gmin; - data.parameters[Params::Gmax] = test_case.gmax; - success *= initializationRejectedAtomically( - data, test_case.pmech, test_case.label); + success *= initializationRejectedAtomically( + withParameters(makeResidualData(), + {{Params::Gmin, test_case.gmin}, + {Params::Gmax, test_case.gmax}}), + test_case.pmech, + test_case.label); } + // Initialization supports only a zero speed deviation; a moving + // machine would need a multi-root gate search. + success *= initializationRejectedAtomically(makeResidualData(), + 0.4, + "nonzero initial speed deviation", + 0.03); + // An invalid configuration is rejected before any state is written. - auto invalid_data = makeResidualData(); - invalid_data.parameters[Params::Rtemp] = 0.0; - Fixture invalid_fixture(invalid_data); + Fixture invalid_fixture(makeResidualData(), {{Params::Rtemp, 0.0}}); invalid_fixture.attachAllInputs(); success *= (invalid_fixture.hygov.allocate() == 0); poisonState(invalid_fixture, 0.4); @@ -276,31 +317,98 @@ namespace GridKit success *= vectorUnchanged(invalid_fixture.hygov.y(), invalid_y, "state"); success *= vectorUnchanged(invalid_fixture.hygov.yp(), invalid_yp, "derivative"); - // Exact gate-limit boundaries and a zero power seed stay admissible. - struct AdmissibleCase + // Zero mechanical power lands on an in-range root and initializes at rest. + Fixture zero_power_fixture(makeData()); + success *= zero_power_fixture.initialize(0.0); + success *= stateMatches( + zero_power_fixture.hygov, + {{Internal::C, 0.09999999999984271}, {Internal::G, 0.09999999999984271}}, + "zero mechanical power"); + success *= (zero_power_fixture.evaluate() == 0); + success *= allResidualsZero(zero_power_fixture.hygov); + + // The smooth identity curve leaves a ln(2)/MU knee at each end, so + // makeData()'s achievable component-base power range is + // [knee - 0.1, 0.9 - knee]. kTol equals the model's initialization + // tolerance, so values half of it beyond an edge pin to the gate + // limit and still rest within kTol; values twice beyond are + // rejected. + const RealT knee = std::log(static_cast(2.0)) / Math::MU; + const RealT p_max = static_cast(0.9) - knee; + const RealT p_min = knee - static_cast(0.1); + + struct BoundaryCase { const char* label; RealT pmech; - RealT gmin; - RealT gmax; RealT gate; }; - for (const auto& accepted : std::array{{ - {"gate landing exactly on Gmax", 0.8, 0.0, 0.9, 0.9}, - {"gate landing exactly on Gmin", 0.0, 0.1, 1.0, 0.1}, - {"zero mechanical-power seed", 0.0, 0.0, 1.0, 0.1}, + for (const auto& clipped : std::array{{ + {"half the tolerance beyond the achievable maximum", + p_max + 0.5 * kTol, + 1.0}, + {"half the tolerance below the achievable minimum", + p_min - 0.5 * kTol, + 0.0}, }}) { - auto data = makeData(); - data.parameters[Params::Gmin] = accepted.gmin; - data.parameters[Params::Gmax] = accepted.gmax; - - Fixture fixture(data); - success *= fixture.initialize(accepted.pmech); + Fixture fixture(makeData()); + success *= fixture.initialize(clipped.pmech); success *= stateMatches(fixture.hygov, - {{I::C, accepted.gate}, {I::G, accepted.gate}}, - accepted.label); + {{Internal::C, clipped.gate}, {Internal::G, clipped.gate}}, + clipped.label); + success *= scalarMatches(fixture.pmech(), clipped.pmech, "clipped pmech value"); + success *= (fixture.evaluate() == 0); + success *= allResidualsZero(fixture.hygov); + } + + success *= initializationRejectedAtomically( + makeData(), + p_max + 2.0 * kTol, + "twice the tolerance beyond the achievable maximum"); + success *= initializationRejectedAtomically( + makeData(), + p_min - 2.0 * kTol, + "twice the tolerance below the achievable minimum"); + + // NaN is unreproducible by any gate and must be rejected. + Fixture nan_power_fixture(makeData()); + success *= nan_power_fixture.prepare(std::numeric_limits::quiet_NaN()); + success *= (nan_power_fixture.hygov.initialize() != 0); + + return success.report(__func__); + } + + /// Initialization solves the smooth gate curve the residual evaluates, + /// so every steady residual rests at machine rounding even where the + /// smoothing bends the curve away from its piecewise-linear points. + TestOutcome initializationExactness() + { + TestStatus success = true; + + // Values landing mid-segment and within the smoothing knee of every + // interior curve breakpoint, where a piecewise-linear inversion + // misses the implemented curve by up to O(1e-3). The gate literal + // proves where each value lands. + struct ExactnessCase + { + const char* label; + RealT pmech; + RealT gate; + }; + + for (const auto& seed : std::array{{ + {"gate inside the Gv1 knee", 0.0556, 0.1982318164100278}, + {"gate inside the Gv2 knee", 0.2509, 0.4003865335541374}, + {"gate mid-segment", 0.4, 0.5719050089028755}, + {"gate inside the Gv3 knee", 0.4244, 0.6007061471851347}, + {"gate inside the Gv4 knee", 0.5617, 0.8006094230811988}, + }}) + { + Fixture fixture(makeResidualData()); + success *= fixture.initialize(seed.pmech); + success *= stateMatches(fixture.hygov, {{Internal::G, seed.gate}}, seed.label); success *= (fixture.evaluate() == 0); success *= allResidualsZero(fixture.hygov); } @@ -323,214 +431,214 @@ namespace GridKit // Values are pinned after an independent one-time evaluation of the // documented equations at setAnswerKeyState()/setAnswerKeyInputs(). - const std::array expected{{ - {I::XN, -0.07785714285714286}, - {I::XF, -0.7300000000000001}, - {I::C, 0.06}, - {I::G, 0.1233333333333334}, - {I::Q, 0.011538461538461414}, - {I::OMEGADB, 0.0033514666467982894}, - {I::EF, 0.5863}, - {I::FC, -1.8512500000000003}, - {I::RC, 0.029996890386450745}, - {I::PGV, -0.04600000003160343}, - {I::H, -0.033299999999999885}, - {I::PMECH, -0.012679999999999934}, - }}; - - success *= (static_cast(fixture.hygov.getResidual().getSize()) == expected.size()); - success *= residualsMatch(fixture.hygov, expected); + success *= (static_cast(fixture.hygov.getResidual().getSize()) + == static_cast(Internal::MAXIMUM)); + success *= residualsMatch(fixture.hygov, + {{Internal::XN, -0.07785714285714286}, + {Internal::XF, -0.7300000000000001}, + {Internal::C, 0.06}, + {Internal::G, 0.1233333333333334}, + {Internal::Q, 0.011538461538461414}, + {Internal::OMEGADB, 0.0033514666467982894}, + {Internal::EF, 0.5863}, + {Internal::FC, -0.7405000000000002}, + {Internal::RC, 0.029996890386450745}, + {Internal::PGV, -0.04600000003160343}, + {Internal::H, -0.033299999999999885}, + {Internal::PMECH, -0.012679999999999934}}, + "answer key"); return success.report(__func__); } - /// Speed deadband, desired-gate velocity limiting, gate-position - /// anti-windup, and turbine damping at nonzero speed deviation. + /// Speed deadband, desired-gate velocity limiting, and gate-position + /// anti-windup. TestOutcome governorControl() { TestStatus success = true; - Fixture fixture(makeResidualData()); - fixture.attachAllInputs(); - success *= fixture.initialize(0.4); - // The type-1 deadband below, inside, and above the +-0.01 band. - struct DeadbandCase - { - RealT omega; - RealT expected; - }; - - for (const auto& test_case : std::array{{ - {-0.05, -0.049996641662021946}, - {0.004, 0.0009004582873718001}, - {0.05, 0.049996641662021946}, - }}) - { - fixture.input(E::OMEGA) = test_case.omega; - setState(fixture.hygov, {{I::OMEGADB, 0.0}}); - success *= (fixture.evaluate() == 0); - success *= residualsMatch(fixture.hygov, - {{I::OMEGADB, test_case.expected}}, - "speed deadband"); - } - fixture.input(E::OMEGA) = 0.0; + success *= runResidualCases( + makeResidualData(), + 0.4, + {{"speed deadband below the band", + {{External::OMEGA, -0.05}}, + {{Internal::OMEGADB, 0.0}}, + {}, + {{Internal::OMEGADB, -0.049996641662021946}}}, + {"speed deadband inside the band", + {{External::OMEGA, 0.004}}, + {{Internal::OMEGADB, 0.0}}, + {}, + {{Internal::OMEGADB, 0.0009004582873718001}}}, + {"speed deadband above the band", + {{External::OMEGA, 0.05}}, + {{Internal::OMEGADB, 0.0}}, + {}, + {{Internal::OMEGADB, 0.049996641662021946}}}}); // The desired-gate velocity target driven below, inside, and above // the +-Velm rate limit. - struct VelocityCase - { - RealT fc; - RealT expected; - }; - - for (const auto& test_case : std::array{{ - {-0.6, -0.15}, - {0.05, 0.04999999999984272}, - {0.6, 0.15000000000000002}, - }}) - { - setState(fixture.hygov, {{I::FC, test_case.fc}, {I::RC, 0.0}}); - success *= (fixture.evaluate() == 0); - success *= residualsMatch(fixture.hygov, - {{I::RC, test_case.expected}}, - "gate velocity limit"); - } + success *= runResidualCases( + makeResidualData(), + 0.4, + {{"gate velocity below the rate limit", + {}, + {{Internal::FC, -0.6}, {Internal::RC, 0.0}}, + {}, + {{Internal::RC, -0.15}}}, + {"gate velocity inside the rate limit", + {}, + {{Internal::FC, 0.05}, {Internal::RC, 0.0}}, + {}, + {{Internal::RC, 0.04999999999984272}}}, + {"gate velocity above the rate limit", + {}, + {{Internal::FC, 0.6}, {Internal::RC, 0.0}}, + {}, + {{Internal::RC, 0.15000000000000002}}}}); // The desired-gate anti-windup at three controller directions: both // saturations block an outward rate and Gmax admits a restoring one. - struct AntiWindupCase - { - const char* label; - RealT c; - RealT rc; - RealT expected; - }; - - for (const auto& test_case : std::array{{ - {"Gmax blocks an outward desired-gate rate", 1.2, 0.2, 0.0}, - {"Gmin blocks an outward desired-gate rate", -0.2, -0.2, 0.0}, - {"Gmax admits a restoring desired-gate rate", 1.2, -0.2, -0.2}, - }}) - { - setState(fixture.hygov, {{I::C, test_case.c}, {I::RC, test_case.rc}}); - setDerivative(fixture.hygov, {{I::C, 0.0}}); - success *= (fixture.evaluate() == 0); - success *= residualsMatch(fixture.hygov, - {{I::C, test_case.expected}}, - test_case.label); - } - - // Turbine damping proportional to speed deviation and gate. - fixture.input(E::OMEGA) = 0.05; - setState(fixture.hygov, - {{I::G, 0.6}, {I::Q, 0.7}, {I::H, 1.1}, {I::PMECH, 0.5}}); - success *= (fixture.evaluate() == 0); - success *= residualsMatch(fixture.hygov, - {{I::PMECH, -0.2677999999999999}}, - "turbine damping"); + success *= runResidualCases( + makeResidualData(), + 0.4, + {{"Gmax blocks an outward desired-gate rate", + {}, + {{Internal::C, 1.2}, {Internal::RC, 0.2}}, + {{Internal::C, 0.0}}, + {{Internal::C, 0.0}}}, + {"Gmin blocks an outward desired-gate rate", + {}, + {{Internal::C, -0.2}, {Internal::RC, -0.2}}, + {{Internal::C, 0.0}}, + {{Internal::C, 0.0}}}, + {"Gmax admits a restoring desired-gate rate", + {}, + {{Internal::C, 1.2}, {Internal::RC, -0.2}}, + {{Internal::C, 0.0}}, + {{Internal::C, -0.2}}}}); return success.report(__func__); } /// The nonlinear gate-power curve on every rising segment, the water - /// column away from the dam head, and initialization through the curve - /// with and without the speed-damping term. + /// column away from the dam head, turbine damping, and initialization + /// through the nonidentity curve, including a value on a flat-segment + /// plateau. TestOutcome turbineDynamics() { TestStatus success = true; - Fixture fixture(makeResidualData()); - fixture.attachAllInputs(); - success *= fixture.initialize(0.4); - // One gate point inside each of the five curve segments. - struct CurveCase - { - RealT gate; - RealT expected; - }; - - for (const auto& test_case : std::array{{ - {0.1, 0.07500000000021236}, - {0.3, 0.28500000000007075}, - {0.5, 0.5399999999999371}, - {0.7, 0.7549999999999292}, - {0.9, 0.9249999999998506}, - }}) - { - setState(fixture.hygov, {{I::G, test_case.gate}, {I::PGV, 0.0}}); - success *= (fixture.evaluate() == 0); - success *= residualsMatch(fixture.hygov, - {{I::PGV, test_case.expected}}, - "gate-power curve"); - } - - // A head away from the dam head drives the flow and head rows. - setState(fixture.hygov, {{I::Q, 0.61}, {I::H, 0.9}, {I::PGV, 0.55}}); - setDerivative(fixture.hygov, {{I::Q, 0.05}}); - success *= (fixture.evaluate() == 0); - success *= residualsMatch(fixture.hygov, - {{I::Q, 0.18076923076923068}, {I::H, -0.09984999999999994}}, - "water column"); - - // A seed inside the third curve segment initializes through the + success *= runResidualCases( + makeResidualData(), + 0.4, + {{"gate-power curve segment 1", + {}, + {{Internal::G, 0.1}, {Internal::PGV, 0.0}}, + {}, + {{Internal::PGV, 0.07500000000021236}}}, + {"gate-power curve segment 2", + {}, + {{Internal::G, 0.3}, {Internal::PGV, 0.0}}, + {}, + {{Internal::PGV, 0.28500000000007075}}}, + {"gate-power curve segment 3", + {}, + {{Internal::G, 0.5}, {Internal::PGV, 0.0}}, + {}, + {{Internal::PGV, 0.5399999999999371}}}, + {"gate-power curve segment 4", + {}, + {{Internal::G, 0.7}, {Internal::PGV, 0.0}}, + {}, + {{Internal::PGV, 0.7549999999999292}}}, + {"gate-power curve segment 5", + {}, + {{Internal::G, 0.9}, {Internal::PGV, 0.0}}, + {}, + {{Internal::PGV, 0.9249999999998506}}}}); + + // A head away from the dam head drives the flow and head rows, and + // turbine damping scales with speed deviation and gate. + success *= runResidualCases( + makeResidualData(), + 0.4, + {{"water column", + {}, + {{Internal::Q, 0.61}, {Internal::H, 0.9}, {Internal::PGV, 0.55}}, + {{Internal::Q, 0.05}}, + {{Internal::Q, 0.18076923076923068}, {Internal::H, -0.09984999999999994}}}, + {"turbine damping", + {{External::OMEGA, 0.05}}, + {{Internal::G, 0.6}, {Internal::Q, 0.7}, {Internal::H, 1.1}, {Internal::PMECH, 0.5}}, + {}, + {{Internal::PMECH, -0.2677999999999999}}}}); + + // A value inside the third curve segment initializes through the // nonidentity curve inversion. Fixture curve_fixture(makeResidualData()); curve_fixture.attachAllInputs(); success *= curve_fixture.initialize(0.33761676); - success *= stateMatches(curve_fixture.hygov, - {{I::C, 0.5000001394782843}, {I::G, 0.5000001394782843}}, - "nonidentity curve inversion"); - success *= scalarMatches(curve_fixture.input(E::PREF), + success *= stateMatches( + curve_fixture.hygov, + {{Internal::C, 0.5000001394783365}, {Internal::G, 0.5000001394783365}}, + "nonidentity curve inversion"); + success *= scalarMatches(curve_fixture.input(External::PREF), 0.015000004184348527, "nonidentity-curve published pref"); - success *= scalarMatches(curve_fixture.pmech(), 0.33761676, "preserved pmech seed"); + success *= scalarMatches(curve_fixture.pmech(), 0.33761676, "preserved pmech value"); success *= (curve_fixture.evaluate() == 0); success *= allResidualsZero(curve_fixture.hygov); - // A nonzero initial speed deviation folds the Dturb damping loss into - // the gate solve, so the damped point still initializes at rest. - Fixture damped_fixture(makeResidualData()); - damped_fixture.attachAllInputs(); - damped_fixture.input(E::OMEGA) = 0.03; - success *= damped_fixture.initialize(0.48676047); - success *= stateMatches(damped_fixture.hygov, - {{I::G, 0.7000002496006894}, - {I::OMEGADB, 0.029757154589893794}}, - "damped initialization"); - success *= scalarMatches(damped_fixture.input(E::PREF), - 0.03587858478296758, - "damped published pref"); - success *= (damped_fixture.evaluate() == 0); - success *= allResidualsZero(damped_fixture.hygov); + // A value on a flat-segment power plateau still initializes exactly: + // the smoothing tails of the neighboring rising segments keep the + // smooth curve strictly increasing across the plateau. + Fixture flat_fixture(makeResidualData(), {{Params::Pgv3, 0.42}}); + success *= flat_fixture.initialize(0.250857385880864); + success *= scalarMatches(flat_fixture.hygov.y().getData()[static_cast(Internal::G)], + 0.4990297247065128, + "flat-segment plateau gate"); + success *= (flat_fixture.evaluate() == 0); + success *= allResidualsZero(flat_fixture.hygov); return success.report(__func__); } #ifdef GRIDKIT_ENABLE_ENZYME - /// A single rich state and all three external inputs drive both - /// sensitivity paths; every Enzyme CSR row must match dependency - /// tracking. + /// Every Enzyme CSR row must match dependency tracking at gates inside + /// each curve segment and at each breakpoint, and both paths must + /// carry the PGV row's gate dependence. TestOutcome jacobian() { TestStatus success = true; const auto data = makeResidualData(); - const auto dependency_jacobian = dependencyTrackingJacobian(data, success); - const auto enzyme_jacobian = enzymeJacobian(data, success); - - success *= (dependency_jacobian.size() == enzyme_jacobian.size()); - const auto rows = std::min(dependency_jacobian.size(), enzyme_jacobian.size()); - for (size_t row = 0; row < rows; ++row) + for (const RealT gate : std::array{ + {0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9}}) { - if (!isEqual(dependency_jacobian[row], enzyme_jacobian[row], kJacobianTol)) + const auto dependency_jacobian = dependencyTrackingJacobian(data, gate, success); + const auto enzyme_jacobian = enzymeJacobian(data, gate, success); + + success *= (dependency_jacobian.size() == enzyme_jacobian.size()); + const auto rows = std::min(dependency_jacobian.size(), enzyme_jacobian.size()); + for (size_t row = 0; row < rows; ++row) { - std::cout << "HYGOV Jacobian row " << row - << " mismatch between dependency tracking and Enzyme\n"; - success = false; + if (!isEqual(dependency_jacobian[row], enzyme_jacobian[row], kTol)) + { + std::cout << "HYGOV Jacobian row " << row << " at gate " << gate + << " mismatch between dependency tracking and Enzyme\n"; + success = false; + } } + + // The recent HYGOV Jacobian defect was a missing PGV/G entry, so + // its presence is asserted structurally in both paths. + success *= jacobianContains( + dependency_jacobian, Internal::PGV, Internal::G, "dependency-tracking"); + success *= jacobianContains(enzyme_jacobian, Internal::PGV, Internal::G, "Enzyme"); } return success.report(__func__); @@ -538,20 +646,42 @@ namespace GridKit #endif private: - using Params = PhasorDynamics::Governor::HygovParameters; - using Vars = PhasorDynamics::Governor::HygovInternalVariables; - using Ext = PhasorDynamics::Governor::HygovExternalVariables; - using Mon = PhasorDynamics::Governor::HygovMonitorableVariables; - using Data = PhasorDynamics::Governor::HygovData; - using I = PhasorDynamics::Governor::HygovIdx; - using E = PhasorDynamics::Governor::HygovExt; - - /// A vector row paired with a value: either an input to write or an - /// expected result. Rows are `HygovIdx`/`HygovExt` constants, so a - /// failure report locates itself without any name string to maintain. - using Row = std::pair; - using Rows = std::initializer_list; - using HygovT = PhasorDynamics::Governor::Hygov; + using Params = PhasorDynamics::Governor::HygovParameters; + using Internal = PhasorDynamics::Governor::HygovInternalVariables; + using External = PhasorDynamics::Governor::HygovExternalVariables; + using Mon = PhasorDynamics::Governor::HygovMonitorableVariables; + using Data = PhasorDynamics::Governor::HygovData; + using HygovT = PhasorDynamics::Governor::Hygov; + + using InternalRow = std::pair; + using InternalRows = std::vector; + using ExternalRow = std::pair; + using ExternalRows = std::vector; + + /// Failure-report names for the internal rows, ordered as `Internal`. + static constexpr std::array(Internal::MAXIMUM)> kRowNames{ + {"XN", "XF", "C", "G", "Q", "OMEGADB", "EF", "FC", "RC", "PGV", "H", "PMECH"}}; + + /// One perturbed-residual scenario evaluated on a fresh fixture. + struct ResidualCase + { + const char* label; + ExternalRows inputs; + InternalRows state; + InternalRows derivative; + InternalRows expected; + }; + + /// Copy `data` with the listed parameter overrides applied. + static Data withParameters(Data data, + std::initializer_list> overrides) + { + for (const auto& [parameter, value] : overrides) + { + data.parameters[parameter] = value; + } + return data; + } /// Owns the HYGOV model, the assigned mechanical-power node, and the /// attached input nodes. Signal storage is declared before the model so @@ -561,18 +691,22 @@ namespace GridKit class Fixture { private: - std::array input_values_{}; - std::array input_indices_{}; - std::array, E::MAXIMUM> input_nodes_{}; + std::array(External::MAXIMUM)> input_values_{}; + std::array(External::MAXIMUM)> input_indices_{}; + std::array, + static_cast(External::MAXIMUM)> + input_nodes_{}; PhasorDynamics::SignalNode pmech_node_; public: - explicit Fixture(const Data& data, RealT system_va_base = 100.0e6) - : hygov(data) + explicit Fixture(const Data& data, + std::initializer_list> overrides = {}, + RealT system_va_base = 100.0e6) + : hygov(withParameters(data, overrides)) { hygov.setSystemBase(60.0, system_va_base); - hygov.getSignals().template assignSignalNode(&pmech_node_); + hygov.getSignals().template assignSignalNode(&pmech_node_); } Fixture(const Fixture&) = delete; @@ -583,7 +717,7 @@ namespace GridKit { const IdxT external_index_base = hygov.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); @@ -591,19 +725,22 @@ namespace GridKit } auto& signals = hygov.getSignals(); - signals.template attachSignalNode(&input_nodes_[E::OMEGA]); - signals.template attachSignalNode(&input_nodes_[E::PREF]); - signals.template attachSignalNode(&input_nodes_[E::PAUX]); + signals.template attachSignalNode( + &input_nodes_[static_cast(External::OMEGA)]); + signals.template attachSignalNode( + &input_nodes_[static_cast(External::PREF)]); + signals.template attachSignalNode( + &input_nodes_[static_cast(External::PAUX)]); } - /// Seed the assigned mechanical-power node on the system base. - void seedPmech(RealT pmech) + /// Set the assigned mechanical-power node on the system base. + void setPmech(RealT pmech) { pmech_node_.init(static_cast(pmech)); } /// Everything HYGOV initialization requires: allocation, - /// verification, and a machine-seeded mechanical-power node. + /// verification, and a machine-provided mechanical-power value. bool prepare(RealT pmech) { const bool success = (hygov.allocate() == 0) && (hygov.verify() == 0); @@ -613,7 +750,7 @@ namespace GridKit return false; } - seedPmech(pmech); + setPmech(pmech); return true; } @@ -642,14 +779,14 @@ namespace GridKit return pmech_node_.read(); } - T& input(size_t port) + T& input(External port) { - return input_values_[port]; + return input_values_[static_cast(port)]; } - IdxT inputIndex(size_t port) const + IdxT inputIndex(External port) const { - return input_indices_[port]; + return input_indices_[static_cast(port)]; } PhasorDynamics::Governor::Hygov hygov; @@ -671,115 +808,108 @@ namespace GridKit Data makeExplicitDefaultData() const { - auto data = makeMinimalData(); - // These are the documented defaults. The all-zero source curve // selects the identity curve, spelled out here point by point. - data.parameters[Params::Trate] = 100.0; - data.parameters[Params::Rperm] = 0.04; - data.parameters[Params::Rtemp] = 0.3; - data.parameters[Params::Tr] = 5.0; - data.parameters[Params::Tf] = 0.05; - data.parameters[Params::Tg] = 0.5; - data.parameters[Params::Velm] = 0.2; - data.parameters[Params::Gmax] = 1.0; - data.parameters[Params::Gmin] = 0.0; - data.parameters[Params::Tw] = 1.0; - data.parameters[Params::At] = 1.2; - data.parameters[Params::Dturb] = 0.5; - data.parameters[Params::Qnl] = 0.05; - data.parameters[Params::Tn] = 0.0; - data.parameters[Params::Tnp] = 0.0; - data.parameters[Params::db1] = 0.0; - data.parameters[Params::db2] = 0.0; - data.parameters[Params::Hdam] = 1.0; - data.parameters[Params::Gv0] = 0.0; - data.parameters[Params::Gv1] = 0.2; - data.parameters[Params::Gv2] = 0.4; - data.parameters[Params::Gv3] = 0.6; - data.parameters[Params::Gv4] = 0.8; - data.parameters[Params::Gv5] = 1.0; - data.parameters[Params::Pgv0] = 0.0; - data.parameters[Params::Pgv1] = 0.2; - data.parameters[Params::Pgv2] = 0.4; - data.parameters[Params::Pgv3] = 0.6; - data.parameters[Params::Pgv4] = 0.8; - data.parameters[Params::Pgv5] = 1.0; - return data; + return withParameters(makeMinimalData(), + {{Params::Rperm, 0.04}, + {Params::Rtemp, 0.3}, + {Params::Tr, 5.0}, + {Params::Tf, 0.05}, + {Params::Tg, 0.5}, + {Params::Velm, 0.2}, + {Params::Gmax, 1.0}, + {Params::Gmin, 0.0}, + {Params::Tw, 1.0}, + {Params::At, 1.2}, + {Params::Dturb, 0.5}, + {Params::Qnl, 0.05}, + {Params::Tn, 0.0}, + {Params::Tnp, 0.0}, + {Params::db1, 0.0}, + {Params::db2, 0.0}, + {Params::Hdam, 1.0}, + {Params::Gv0, 0.0}, + {Params::Gv1, 0.2}, + {Params::Gv2, 0.4}, + {Params::Gv3, 0.6}, + {Params::Gv4, 0.8}, + {Params::Gv5, 1.0}, + {Params::Pgv0, 0.0}, + {Params::Pgv1, 0.2}, + {Params::Pgv2, 0.4}, + {Params::Pgv3, 0.6}, + {Params::Pgv4, 0.8}, + {Params::Pgv5, 1.0}}); } Data makeData() const { - auto data = makeMinimalData(); - - data.parameters[Params::Trate] = 100.0; - data.parameters[Params::Rperm] = 0.05; - data.parameters[Params::Rtemp] = 0.4; - data.parameters[Params::Tr] = 5.0; - data.parameters[Params::Tf] = 0.2; - data.parameters[Params::Tg] = 0.5; - data.parameters[Params::Velm] = 0.5; - data.parameters[Params::Gmax] = 1.0; - data.parameters[Params::Gmin] = 0.0; - data.parameters[Params::Tw] = 1.0; - data.parameters[Params::At] = 1.0; - data.parameters[Params::Dturb] = 0.0; - data.parameters[Params::Qnl] = 0.1; - data.parameters[Params::Tn] = 0.0; - data.parameters[Params::Tnp] = 1.0; - data.parameters[Params::db1] = 0.0; - data.parameters[Params::db2] = 0.0; - data.parameters[Params::Hdam] = 1.0; - data.parameters[Params::Gv0] = 0.0; - data.parameters[Params::Gv1] = 0.2; - data.parameters[Params::Gv2] = 0.4; - data.parameters[Params::Gv3] = 0.6; - data.parameters[Params::Gv4] = 0.8; - data.parameters[Params::Gv5] = 1.0; - data.parameters[Params::Pgv0] = 0.0; - data.parameters[Params::Pgv1] = 0.2; - data.parameters[Params::Pgv2] = 0.4; - data.parameters[Params::Pgv3] = 0.6; - data.parameters[Params::Pgv4] = 0.8; - data.parameters[Params::Pgv5] = 1.0; - return data; + return withParameters(makeMinimalData(), + {{Params::Trate, 100.0}, + {Params::Rperm, 0.05}, + {Params::Rtemp, 0.4}, + {Params::Tr, 5.0}, + {Params::Tf, 0.2}, + {Params::Tg, 0.5}, + {Params::Velm, 0.5}, + {Params::Gmax, 1.0}, + {Params::Gmin, 0.0}, + {Params::Tw, 1.0}, + {Params::At, 1.0}, + {Params::Dturb, 0.0}, + {Params::Qnl, 0.1}, + {Params::Tn, 0.0}, + {Params::Tnp, 1.0}, + {Params::db1, 0.0}, + {Params::db2, 0.0}, + {Params::Hdam, 1.0}, + {Params::Gv0, 0.0}, + {Params::Gv1, 0.2}, + {Params::Gv2, 0.4}, + {Params::Gv3, 0.6}, + {Params::Gv4, 0.8}, + {Params::Gv5, 1.0}, + {Params::Pgv0, 0.0}, + {Params::Pgv1, 0.2}, + {Params::Pgv2, 0.4}, + {Params::Pgv3, 0.6}, + {Params::Pgv4, 0.8}, + {Params::Pgv5, 1.0}}); } Data makeResidualData() const { - auto data = makeData(); - - data.parameters[Params::Trate] = 50.0; - data.parameters[Params::Rperm] = 0.06; - data.parameters[Params::Rtemp] = 0.4; - data.parameters[Params::Tr] = 4.0; - data.parameters[Params::Tf] = 0.2; - data.parameters[Params::Tg] = 0.6; - data.parameters[Params::Velm] = 0.15; - data.parameters[Params::Gmax] = 0.95; - data.parameters[Params::Gmin] = 0.05; - data.parameters[Params::Tw] = 1.3; - data.parameters[Params::At] = 1.1; - data.parameters[Params::Dturb] = 0.6; - data.parameters[Params::Qnl] = 0.08; - data.parameters[Params::Tn] = 0.7; - data.parameters[Params::Tnp] = 1.4; - data.parameters[Params::db1] = 0.01; - data.parameters[Params::Hdam] = 1.2; - data.parameters[Params::Pgv1] = 0.15; - data.parameters[Params::Pgv2] = 0.42; - data.parameters[Params::Pgv3] = 0.66; - data.parameters[Params::Pgv4] = 0.85; - return data; + return withParameters(makeData(), + {{Params::Trate, 50.0}, + {Params::Rperm, 0.06}, + {Params::Rtemp, 0.4}, + {Params::Tr, 4.0}, + {Params::Tf, 0.2}, + {Params::Tg, 0.6}, + {Params::Velm, 0.15}, + {Params::Gmax, 0.95}, + {Params::Gmin, 0.05}, + {Params::Tw, 1.3}, + {Params::At, 1.1}, + {Params::Dturb, 0.6}, + {Params::Qnl, 0.08}, + {Params::Tn, 0.7}, + {Params::Tnp, 1.4}, + {Params::db1, 0.01}, + {Params::Hdam, 1.2}, + {Params::Pgv1, 0.15}, + {Params::Pgv2, 0.42}, + {Params::Pgv3, 0.66}, + {Params::Pgv4, 0.85}}); } /// The external inputs the residual answer key is evaluated against. template void setAnswerKeyInputs(Fixture& fixture) const { - fixture.input(E::OMEGA) = static_cast(0.02); - fixture.input(E::PREF) = static_cast(0.31); - fixture.input(E::PAUX) = static_cast(0.07); + fixture.input(External::OMEGA) = static_cast(0.02); + fixture.input(External::PREF) = static_cast(0.31); + fixture.input(External::PAUX) = static_cast(0.07); } /// The rich state shared by the residual answer key and the Jacobian @@ -788,35 +918,32 @@ namespace GridKit void setAnswerKeyState(PhasorDynamics::Governor::Hygov& hygov) const { setState(hygov, - {{I::XN, 0.11}, - {I::XF, 0.23}, - {I::C, 0.52}, - {I::G, 0.47}, - {I::Q, 0.61}, - {I::OMEGADB, 0.015}, - {I::EF, 0.08}, - {I::FC, 0.12}, - {I::RC, 0.09}, - {I::PGV, 0.55}, - {I::H, 1.12}, - {I::PMECH, 0.33}}); + {{Internal::XN, 0.11}, + {Internal::XF, 0.23}, + {Internal::C, 0.52}, + {Internal::G, 0.47}, + {Internal::Q, 0.61}, + {Internal::OMEGADB, 0.015}, + {Internal::EF, 0.08}, + {Internal::FC, 0.12}, + {Internal::RC, 0.09}, + {Internal::PGV, 0.55}, + {Internal::H, 1.12}, + {Internal::PMECH, 0.33}}); setDerivative(hygov, - {{I::XN, 0.01}, - {I::XF, -0.02}, - {I::C, 0.03}, - {I::G, -0.04}, - {I::Q, 0.05}}); + {{Internal::XN, 0.01}, + {Internal::XF, -0.02}, + {Internal::C, 0.03}, + {Internal::G, -0.04}, + {Internal::Q, 0.05}}); } /// Omitting every optional parameter must give exactly the model built /// from the defaults the README documents, at rest and under load. bool defaultsMatchDocumentedValues() const { - auto implicit_data = makeMinimalData(); - implicit_data.parameters[Params::Trate] = 100.0; - - Fixture implicit_defaults(implicit_data); - Fixture explicit_defaults(makeExplicitDefaultData()); + Fixture implicit_defaults(makeMinimalData(), {}, 200.0e6); + Fixture explicit_defaults(makeExplicitDefaultData(), {}, 200.0e6); implicit_defaults.attachAllInputs(); explicit_defaults.attachAllInputs(); @@ -852,21 +979,13 @@ namespace GridKit return success; } - bool invalidParameterCase(Params parameter, RealT value) const - { - auto data = makeData(); - data.parameters[parameter] = value; - PhasorDynamics::Governor::Hygov model(data); - return model.verify() > 0; - } - - template + template bool unlinkedSignalRejected() const { - PhasorDynamics::SignalNode unlinked_node; - PhasorDynamics::Governor::Hygov model(makeData()); - model.getSignals().template attachSignalNode(&unlinked_node); - return model.verify() > 0; + PhasorDynamics::SignalNode unlinked_node; + Fixture fixture(makeData()); + fixture.hygov.getSignals().template attachSignalNode(&unlinked_node); + return fixture.hygov.verify() > 0; } template @@ -892,7 +1011,7 @@ namespace GridKit return success; } - /// Fill the state and derivative with a recognizable ramp, then re-seed + /// Fill the state and derivative with a recognizable ramp, then restore /// the aliased pmech entry, so any write by a rejected initialization /// is visible. void poisonState(Fixture& fixture, RealT pmech) const @@ -904,19 +1023,23 @@ namespace GridKit y[i] = 0.125 + 0.01 * static_cast(i); yp[i] = -0.25 - 0.01 * static_cast(i); } - fixture.seedPmech(pmech); + fixture.setPmech(pmech); fixture.hygov.y().setDataUpdated(); fixture.hygov.yp().setDataUpdated(); } + /// Initialization must fail and leave the poisoned state, the pmech + /// value, and every attached input untouched. bool initializationRejectedAtomically(const Data& data, RealT pmech, - const char* label) const + const char* label, + RealT omega = 0.0) const { Fixture fixture(data); fixture.attachAllInputs(); - fixture.input(E::PAUX) = 0.02; - fixture.input(E::PREF) = 77.0; // must stay untouched on rejection + fixture.input(External::OMEGA) = static_cast(omega); + fixture.input(External::PAUX) = 0.02; + fixture.input(External::PREF) = 77.0; // must stay untouched on rejection if (!fixture.prepare(pmech)) { return false; @@ -933,10 +1056,11 @@ namespace GridKit success = false; } - success *= scalarMatches(fixture.pmech(), pmech, "rejected pmech seed preservation"); - success *= scalarMatches(fixture.input(E::OMEGA), 0.0, "rejected omega preservation"); - success *= scalarMatches(fixture.input(E::PREF), 77.0, "rejected pref preservation"); - success *= scalarMatches(fixture.input(E::PAUX), 0.02, "rejected paux preservation"); + success *= scalarMatches(fixture.pmech(), pmech, "rejected pmech preservation"); + success *= scalarMatches( + fixture.input(External::OMEGA), omega, "rejected omega preservation"); + success *= scalarMatches(fixture.input(External::PREF), 77.0, "rejected pref preservation"); + success *= scalarMatches(fixture.input(External::PAUX), 0.02, "rejected paux preservation"); success *= vectorUnchanged(fixture.hygov.y(), y_before, "state"); success *= vectorUnchanged(fixture.hygov.yp(), yp_before, "derivative"); return success; @@ -945,82 +1069,110 @@ 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::Governor::Hygov& hygov, Rows rows) const + void setState(PhasorDynamics::Governor::Hygov& hygov, + const InternalRows& rows) const { auto* y = hygov.y().getData(); - for (const auto& [row, value] : rows) + for (const auto& [variable, value] : rows) { - y[row] = static_cast(value); + y[static_cast(variable)] = static_cast(value); } hygov.y().setDataUpdated(); } /// setState() for the derivative vector. template - void setDerivative(PhasorDynamics::Governor::Hygov& hygov, Rows rows) const + void setDerivative(PhasorDynamics::Governor::Hygov& hygov, + const InternalRows& rows) const { auto* yp = hygov.yp().getData(); - for (const auto& [row, value] : rows) + for (const auto& [variable, value] : rows) { - yp[row] = static_cast(value); + yp[static_cast(variable)] = static_cast(value); } hygov.yp().setDataUpdated(); } + /// Evaluate each scenario on its own initialized fixture, so no + /// inputs or state leak between cases. + bool runResidualCases(const Data& data, + RealT pmech, + const std::vector& cases) const + { + bool success = true; + for (const auto& test_case : cases) + { + Fixture fixture(data); + fixture.attachAllInputs(); + success &= fixture.initialize(pmech); + for (const auto& [port, value] : test_case.inputs) + { + fixture.input(port) = static_cast(value); + } + setState(fixture.hygov, test_case.state); + setDerivative(fixture.hygov, test_case.derivative); + success &= (fixture.evaluate() == 0); + success &= residualsMatch(fixture.hygov, test_case.expected, test_case.label); + } + return success; + } + /// 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 `HygovIdx` 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) + /// in this suite reports through here, so failures share one format + /// and name their row through `kRowNames`. + bool rowMatches(RealT actual, + RealT expected, + const char* what, + size_t row, + const char* context) const { - if (isEqual(actual, expected, kBehaviorTol)) + if (isEqual(actual, expected, kTol)) { return true; } - std::cout << "HYGOV " << what << " row " << row << ' ' << context - << " mismatch: " << std::setprecision(16) << actual - << " != " << expected << '\n'; + std::cout << "HYGOV " << what << " row "; + if (row < kRowNames.size()) + { + std::cout << kRowNames[row]; + } + else + { + std::cout << row; + } + std::cout << ' ' << context << " mismatch: " << std::setprecision(16) + << actual << " != " << expected << '\n'; return false; } /// 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 rowsMatch(const VectorT& vector, + const InternalRows& 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 = static_cast(variable); + success &= rowMatches(static_cast(values[row]), expected, what, row, context); } return success; } - bool residualsMatch(const HygovT& hygov, Rows rows, const char* context = "") const + bool residualsMatch(const HygovT& hygov, + const InternalRows& rows, + const char* context = "") const { - return rowsMatch(hygov.getResidual(), rows.begin(), rows.size(), "residual", context); + return rowsMatch(hygov.getResidual(), rows, "residual", context); } - template - bool residualsMatch(const HygovT& hygov, - const std::array& rows, - const char* context = "") const + bool stateMatches(const HygovT& hygov, + const InternalRows& rows, + const char* context = "") const { - return rowsMatch(hygov.getResidual(), rows.data(), size, "residual", context); - } - - bool stateMatches(const HygovT& hygov, Rows rows, const char* context = "") const - { - return rowsMatch(hygov.y(), rows.begin(), rows.size(), "state", context); + return rowsMatch(hygov.y(), rows, "state", context); } /// The model sits at a steady state: every residual and every @@ -1041,7 +1193,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)) { @@ -1061,6 +1213,24 @@ namespace GridKit } #ifdef GRIDKIT_ENABLE_ENZYME + /// The row's dependency map must contain the column entry. + template + bool jacobianContains(const JacobianRowsT& rows, + Internal row_variable, + Internal column_variable, + const char* what) const + { + const auto row = static_cast(row_variable); + const auto column = static_cast(column_variable); + if (row < rows.size() && rows[row].count(column) == 1) + { + return true; + } + std::cout << "HYGOV " << what << " Jacobian row " << row + << " is missing column " << column << "\n"; + return false; + } + void numberVariables(Fixture& fixture) const { auto* y = fixture.hygov.y().getData(); @@ -1072,7 +1242,7 @@ namespace GridKit y[i].setVariableNumber(i); yp[i].setVariableNumber(i); } - for (size_t port = 0; port < E::MAXIMUM; ++port) + for (External port : {External::OMEGA, External::PREF, External::PAUX}) { fixture.input(port).setVariableNumber(fixture.inputIndex(port)); } @@ -1083,6 +1253,7 @@ namespace GridKit std::vector dependencyTrackingJacobian( const Data& data, + RealT gate, TestStatus& success) const { using DepVar = DependencyTracking::Variable; @@ -1092,6 +1263,7 @@ namespace GridKit success *= fixture.initialize(0.4); setAnswerKeyInputs(fixture); setAnswerKeyState(fixture.hygov); + setState(fixture.hygov, {{Internal::G, gate}}); numberVariables(fixture); success *= (fixture.evaluate() == 0); @@ -1107,6 +1279,7 @@ namespace GridKit std::vector enzymeJacobian( const Data& data, + RealT gate, TestStatus& success) const { Fixture fixture(data); @@ -1114,6 +1287,7 @@ namespace GridKit success *= fixture.initialize(0.4); setAnswerKeyInputs(fixture); setAnswerKeyState(fixture.hygov); + setState(fixture.hygov, {{Internal::G, gate}}); fixture.hygov.updateTime(0.0, 1.0); success *= (fixture.evaluate() == 0); success *= (fixture.hygov.evaluateJacobian() == 0); diff --git a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp index dae9bbb57..e4f8e351d 100644 --- a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp +++ b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp @@ -321,6 +321,40 @@ namespace GridKit return success.report(__func__); } + /// HYGOV through the production path: model data to system + /// construction to signal wiring. The declared pmech signal starts at + /// zero mechanical power, an admissible operating point. + TestOutcome hygov() + { + TestStatus success = true; + + PhasorDynamics::SystemModelData data; + data.freq_base = 60.0; + data.va_base = 100.0e6; + data.signal.resize(1); + data.signal[0].signal_id = 2; + data.signal[0].name = "Mechanical Power"; + + typename PhasorDynamics::SystemModelData::HygovDataT hygov_data; + hygov_data.device_class = "Hygov"; + hygov_data.disambiguation_string = "hygov_system"; + hygov_data.parameters[PhasorDynamics::Governor::HygovParameters::Trate] = 100.0; + hygov_data.signal_outputs[PhasorDynamics::Governor::HygovSignalOutputs::pmech] = 2; + data.hygov.push_back(hygov_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(PhasorDynamics::Governor::HygovInternalVariables::MAXIMUM); + + return success.report(__func__); + } + private: auto makeRegcaData() -> PhasorDynamics::Converter::RegcaData { diff --git a/tests/UnitTests/PhasorDynamics/runGovernorHygovTests.cpp b/tests/UnitTests/PhasorDynamics/runGovernorHygovTests.cpp index 1596c7319..3f69b4b90 100644 --- a/tests/UnitTests/PhasorDynamics/runGovernorHygovTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runGovernorHygovTests.cpp @@ -9,6 +9,7 @@ int main() result += test.validation(); result += test.initializationAndSignals(); result += test.initializationDomain(); + result += test.initializationExactness(); result += test.residualEquations(); result += test.governorControl(); result += test.turbineDynamics(); diff --git a/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp b/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp index ebf13c6fb..abb274a0f 100644 --- a/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runSystemSingleComponentTests.cpp @@ -19,6 +19,7 @@ int main() result += test.genrou(); result += test.genClassical(); result += test.tgov1(); + result += test.hygov(); // @todo The following components are not tested here because they require non-trivial constructors // PhasorDynamics::Exciter::SexsPti diff --git a/tests/UnitTests/Utilities/CaseFormatTests.hpp b/tests/UnitTests/Utilities/CaseFormatTests.hpp index ef87d6bec..b5c8dcb6b 100644 --- a/tests/UnitTests/Utilities/CaseFormatTests.hpp +++ b/tests/UnitTests/Utilities/CaseFormatTests.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -213,13 +214,15 @@ namespace GridKit { "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"} + { "signal_id": 6, "name": "Under-excitation Limiter"}, + { "signal_id": 7, "name": "Hydro Mechanical Power"} ], "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": {"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": "Hygov", "ports": {"speed": 1, "pmech": 7}, "id": "DV6", "params": {"Trate": 80.0, "Rperm": 0.05, "Rtemp": 0.35, "Tw": 1.2, "Qnl": 0.08}}, { "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} } @@ -244,6 +247,7 @@ namespace GridKit success *= result.genrou.size() == 1; success *= result.gov.size() == 1; success *= result.esdc1a.size() == 1; + success *= result.hygov.size() == 1; success *= result.loadz.size() == 0; success *= result.exciter.size() == 1; success *= result.sexspti.size() == 1; @@ -276,6 +280,8 @@ namespace GridKit success *= result.signal[4].name == "Stabilizer Signal"; success *= result.signal[5].signal_id == 6; success *= result.signal[5].name == "Under-excitation Limiter"; + success *= result.signal[6].signal_id == 7; + success *= result.signal[6].name == "Hydro Mechanical Power"; success *= std::get(result.branch[0].parameters[BranchParameters::R]) == 0.0; success *= std::get(result.branch[0].parameters[BranchParameters::X]) == 0.1; @@ -357,6 +363,16 @@ namespace GridKit success *= result.esdc1a[0].monitored_variables.contains(Esdc1aData::MonitorableVariables::se); success *= result.esdc1a[0].monitored_variables.contains(Esdc1aData::MonitorableVariables::vfe); + success *= std::get(result.hygov[0].parameters[Governor::HygovParameters::Trate]) == 80.0; + success *= std::get(result.hygov[0].parameters[Governor::HygovParameters::Rperm]) == 0.05; + success *= std::get(result.hygov[0].parameters[Governor::HygovParameters::Rtemp]) == 0.35; + success *= std::get(result.hygov[0].parameters[Governor::HygovParameters::Tw]) == 1.2; + success *= std::get(result.hygov[0].parameters[Governor::HygovParameters::Qnl]) == 0.08; + success *= result.hygov[0].signal_inputs[Governor::HygovSignalInputs::speed] == 1; + success *= result.hygov[0].signal_outputs[Governor::HygovSignalOutputs::pmech] == 7; + success *= result.hygov[0].disambiguation_string == "DV6"; + success *= result.hygov[0].monitored_variables.empty(); + 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 8a5fe4af497e20f273d6219aa649442bb321c689 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Sat, 1 Aug 2026 15:51:42 -0500 Subject: [PATCH 05/17] reword, reorganize, etc --- .../Governor/HYGOV/HygovImpl.hpp | 1219 +++++++++-------- 1 file changed, 616 insertions(+), 603 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp index 00a0b86e7..2d64eb8ac 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp @@ -57,516 +57,408 @@ namespace GridKit } /** - * @brief Resolve the parameter-derived constants + * @brief Set the component ID * - * Raises each governor lag to the well-posedness floor and derives the - * speed lead-lag gain from the floored denominator. + * @param[in] component_id Identifier assigned by the system model. + * @return int 0 on success. */ template - void Hygov::setDerivedParameters() + int Hygov::setGridKitComponentID(IdxT component_id) { - // 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() << "Hygov: " << name << " must be non-negative\n"; - ++parameter_error_count_; - } - }; - - check_non_negative(Tr_, "Tr"); - check_non_negative(Tf_, "Tf"); - check_non_negative(Tg_, "Tg"); - check_non_negative(Tw_, "Tw"); - check_non_negative(Tnp_, "Tnp"); - - if (Tr_ < TIME_CONSTANT_MINIMUM || Tf_ < TIME_CONSTANT_MINIMUM - || Tg_ < TIME_CONSTANT_MINIMUM || Tw_ < TIME_CONSTANT_MINIMUM - || Tnp_ < TIME_CONSTANT_MINIMUM) - { - Log::warning() << "Hygov: Tr, Tf, Tg, Tw, and Tnp below " - << TIME_CONSTANT_MINIMUM - << " s are raised to that floor to keep the governor lags well posed\n"; - } - - Tr_ = std::max(Tr_, TIME_CONSTANT_MINIMUM); - Tf_ = std::max(Tf_, TIME_CONSTANT_MINIMUM); - Tg_ = std::max(Tg_, TIME_CONSTANT_MINIMUM); - Tw_ = std::max(Tw_, TIME_CONSTANT_MINIMUM); - Tnp_ = std::max(Tnp_, TIME_CONSTANT_MINIMUM); - - leadlag_gain_ = Tn_ / Tnp_; + gridkit_component_id_ = component_id; + return 0; } /** - * @brief Evaluate the nonlinear gate-to-power curve + * @brief Allocate the model vectors and wire the mechanical-power output * - * Sums the five smooth CommonMath linear segments spanned by the - * `Gv`/`Pgv` points, so the same fixed expression serves the residual - * and both scalar instantiations. + * Sizes the state, residual, and signal-interface buffers, initializes + * the identity index maps, and points the assigned `pmech` node at the + * internal state it publishes. That node aliases HYGOV storage from + * here on, which is how initialize() reads the machine value. + * HYGOV attaches to no bus, so the bus-interface buffer stays empty. + * Repeated calls reuse the allocated vectors. * - * @param[in] gate Gate position. - * @return Turbine power at nominal head. + * @return int 0 on success. */ template - __attribute__((always_inline)) inline scalar_type - Hygov::gatePower(scalar_type gate) const + int Hygov::allocate() { - return ScalarT{Pgv_[0]} - + Math::linseg(gate, Gv_[0], Gv_[1], Pgv_[1] - Pgv_[0]) - + Math::linseg(gate, Gv_[1], Gv_[2], Pgv_[2] - Pgv_[1]) - + Math::linseg(gate, Gv_[2], Gv_[3], Pgv_[3] - Pgv_[2]) - + Math::linseg(gate, Gv_[3], Gv_[4], Pgv_[4] - Pgv_[3]) - + Math::linseg(gate, Gv_[4], Gv_[5], Pgv_[5] - Pgv_[4]); - } + const auto PMECH = static_cast(HygovInternalVariables::PMECH); - /** - * @brief Steady component-base mechanical power at a gate position - * - * At the steady state the head equals the dam head and the flow - * follows the gate curve, so the PGV, H, and PMECH rows collapse to - * @f[ - * P_{\mathrm{m}}(g) - * = A_t H_{\mathrm{dam}} - * \left(\sqrt{H_{\mathrm{dam}}}\,N_{\mathrm{GV}}(g) - * - q_{\mathrm{NL}}\right). - * @f] - * The expression is composed exactly as those rows compose it, so a - * gate solved against it zeros the implemented residual at machine - * rounding. - * - * @param[in] gate Gate position. - * @return Steady mechanical power on the component base. - */ - template - typename Hygov::RealT - Hygov::initialMechanicalPower(RealT gate) const - { - const RealT pgv = static_cast(gatePower(static_cast(gate))); - const RealT q = std::sqrt(Hdam_) * pgv; - return At_ * Hdam_ * (q - Qnl_); + if (!allocated_) + { + this->allocateVectors(size_); + } + auto size = static_cast(size_); + + tag_.assign(size, false); + variable_indices_.resize(size); + residual_indices_.resize(size); + + wb_.clear(); + + const auto signal_size = static_cast(HygovExternalVariables::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[PMECH], + &(this->getVariableIndex(static_cast(PMECH)))); + } + + allocated_ = true; + return 0; } /** - * @brief Solve the steady gate position for a given mechanical power - * - * Initialization requires a zero speed deviation and verify() requires - * the steady power to rise across [Gmin, Gmax], so the endpoint - * residuals decide feasibility and bisection converges to a root of - * the nondecreasing steady-power curve. + * @brief Validate the HYGOV configuration * - * @pre verify() reports no errors. + * Checks parameter-loading errors, static parameter relationships, the + * gate-curve shape, the gate-limit domain, the assigned + * mechanical-power output, and attached external signals. + * Mechanical-power feasibility is operating-point dependent and is + * checked by initialize(). * - * @param[in] pmech Mechanical power on the component base. - * @return The gate position, or a quiet NaN when no gate inside - * [Gmin, Gmax] reproduces the value within the initialization - * tolerance. + * @return int Number of configuration errors; zero when valid. */ template - typename Hygov::RealT - Hygov::solveInitialGate(RealT pmech) const + int Hygov::verify() const { - // NaN is unreproducible by any gate and would otherwise slip - // through the sign tests below. - if (std::isnan(pmech)) + int ret = static_cast(parameter_error_count_); + + auto check = [&](bool condition, const char* message) { - return std::numeric_limits::quiet_NaN(); - } + if (!condition) + { + Log::error() << "Hygov: " << message << '\n'; + ret += 1; + } + }; - RealT a = Gmin_; - RealT b = Gmax_; - RealT fa = initialMechanicalPower(a) - pmech; - RealT fb = initialMechanicalPower(b) - pmech; + check(Rtemp_ != ZERO, "Rtemp must be nonzero"); + check(Tn_ >= ZERO, "Tn must be non-negative"); + check(Velm_ >= ZERO, "Velm must be non-negative"); + check(Gmin_ < Gmax_, "Gmin must be less than Gmax"); + check(At_ > ZERO, "At must be positive"); + check(Dturb_ >= ZERO, "Dturb must be non-negative"); + check(db1_ >= ZERO, "db1 must be non-negative"); + check(Hdam_ > ZERO, "Hdam must be positive"); - // A value just outside the achievable range pins to the gate limit - // when it is within the initialization tolerance of the range edge. - if (fa > ZERO) + bool curve_shape_is_valid = true; + for (size_t i = 1; i < Gv_.size(); ++i) { - if (fa <= INITIALIZATION_TOLERANCE) + const bool gate_points_increase = Gv_[i - 1] < Gv_[i]; + const bool power_points_increase = Pgv_[i - 1] <= Pgv_[i]; + + check(gate_points_increase, "Gv points must be strictly increasing"); + check(power_points_increase, "Pgv points must be non-decreasing"); + + if (!gate_points_increase || !power_points_increase) { - return a; + curve_shape_is_valid = false; } - return std::numeric_limits::quiet_NaN(); } - if (fb < ZERO) + const bool minimum_gate_is_valid = Gv_[0] <= Gmin_; + const bool maximum_gate_is_valid = Gmax_ <= Gv_[5]; + check(minimum_gate_is_valid, "Gmin must be at or above the first Gv point"); + check(maximum_gate_is_valid, "Gmax must be at or below the last Gv point"); + + if (curve_shape_is_valid + && Gmin_ < Gmax_ + && minimum_gate_is_valid + && maximum_gate_is_valid + && At_ > ZERO + && Hdam_ > ZERO) { - if (-fb <= INITIALIZATION_TOLERANCE) - { - return b; - } - return std::numeric_limits::quiet_NaN(); + // A rise no wider than the tolerance that pins a seed to a range + // edge leaves the gate undetermined by the mechanical power. + const RealT minimum_power = initialMechanicalPower(Gmin_); + const RealT maximum_power = initialMechanicalPower(Gmax_); + check(maximum_power - minimum_power > INITIALIZATION_TOLERANCE, + "mechanical power must rise across [Gmin, Gmax]"); } - // Bisect until no representable midpoint remains, then keep the - // endpoint with the smaller residual. - while (true) + check(signals_.template isAssigned(), + "pmech output signal must be assigned"); + + // An attached port must resolve to readable signal storage. The + // enumerator is a template argument, so each port names itself once. + auto check_attached_signal = + [&](const char* name) { - const RealT mid = HALF * (a + b); - if (mid <= a || b <= mid) - { - break; - } - const RealT fmid = initialMechanicalPower(mid) - pmech; - if (fmid <= ZERO) - { - a = mid; - fa = fmid; - } - else + if (signals_.template isAttached() + && !signals_.template isLinked()) { - b = mid; - fb = fmid; + Log::error() << "Hygov: " << name << " signal attached with no linked source\n"; + ret += 1; } - } - if (std::abs(fa) <= std::abs(fb)) - { - return a; - } - return b; - } + }; - /** - * @brief Convert a system-base power to HYGOV component base - * - * @param[in] value Quantity on the system base. - * @return The same quantity on the component base. - */ - template - scalar_type Hygov::toComponentBase(scalar_type value) const - { - return value * va_system_base_ / va_component_base_; + check_attached_signal.template operator()("speed"); + check_attached_signal.template operator()("pref"); + check_attached_signal.template operator()("paux"); + + return ret; } /** - * @brief Convert a component-base power to the system base + * @brief Initialize HYGOV from the mechanical-power port * - * @param[in] value Quantity on the component base. - * @return The same quantity on the system base. - */ - template - scalar_type Hygov::toSystemBase(scalar_type value) const - { - return value / toComponentBase(static_cast(ONE)); - } - - /** - * @brief Read the parameters out of the model data + * Reads the assigned system-base `pmech` node and the attached speed + * and auxiliary-power inputs, solves the component-base steady state + * that preserves the given value, and publishes the resolved load reference + * to an attached `pref` signal. * - * Every omitted parameter keeps the default documented in the model - * README. A non-numeric value is counted and reported by verify() rather - * than throwing. Integer JSON values are accepted for real parameters. - * All-zero `Gv` and `Pgv` source points select the identity gate curve. + * @pre allocate() has completed. + * @pre The machine model has initialized the assigned `pmech` node. * - * @param[in] data Parameters and monitored-variable selections. + * @post On success the state zeros every residual row at machine + * rounding; a value clipped to the achievable-power range edge + * leaves a mechanical-power residual up to the initialization + * tolerance. + * @post On failure no state or signal storage has changed. + * + * @return int 0 on success; nonzero when the configuration is + * invalid, the initial speed deviation is nonzero, or no + * gate inside [Gmin, Gmax] reproduces the given power. */ template - void Hygov::initializeParameters(const ModelDataT& data) + int Hygov::initialize() { - using Params = typename ModelDataT::Parameters; + const auto XN = static_cast(HygovInternalVariables::XN); + const auto XF = static_cast(HygovInternalVariables::XF); + const auto C = static_cast(HygovInternalVariables::C); + const auto G = static_cast(HygovInternalVariables::G); + const auto Q = static_cast(HygovInternalVariables::Q); + const auto OMEGADB = static_cast(HygovInternalVariables::OMEGADB); + const auto EF = static_cast(HygovInternalVariables::EF); + const auto FC = static_cast(HygovInternalVariables::FC); + const auto RC = static_cast(HygovInternalVariables::RC); + const auto PGV = static_cast(HygovInternalVariables::PGV); + const auto H = static_cast(HygovInternalVariables::H); + const auto PMECH = static_cast(HygovInternalVariables::PMECH); - parameter_error_count_ = 0; + if (verify() > 0) + { + Log::error() << "Hygov: cannot initialize with invalid configuration\n"; + return 1; + } - auto load_real = [&](auto key, RealT& target, const char* name) -> bool + if (!(va_component_base_ > ZERO) ) { - if (!data.parameters.contains(key)) - { - return false; - } + va_component_base_ = va_system_base_; + } - const auto& value = data.parameters.at(key); - if (const auto* real_value = std::get_if(&value)) - { - target = *real_value; - return true; - } - if (const auto* index_value = std::get_if(&value)) - { - target = static_cast(*index_value); - return true; - } + auto* y = y_.getData(); - Log::error() << "Hygov: parameter '" << name << "' must be numeric\n"; - ++parameter_error_count_; - return false; - }; + // The assigned pmech node aliases this entry after allocate(). Its + // system-base value remains untouched throughout initialization. + const ScalarT pmech0 = toComponentBase(y[PMECH]); - if (load_real(Params::Trate, va_component_base_, "Trate")) + ScalarT omega0{ZERO}; + if (signals_.template isAttached()) { - if (!(va_component_base_ > ZERO) ) - { - Log::error() << "Hygov: Trate must be positive when provided\n"; - ++parameter_error_count_; - } - va_component_base_ *= static_cast(1.0e6); + omega0 = signals_.template readExternalVariable(); } - load_real(Params::Rperm, Rperm_, "Rperm"); - load_real(Params::Rtemp, Rtemp_, "Rtemp"); - load_real(Params::Tr, Tr_, "Tr"); - load_real(Params::Tf, Tf_, "Tf"); - load_real(Params::Tg, Tg_, "Tg"); - load_real(Params::Velm, Velm_, "Velm"); - load_real(Params::Gmax, Gmax_, "Gmax"); - load_real(Params::Gmin, Gmin_, "Gmin"); - load_real(Params::Tw, Tw_, "Tw"); - load_real(Params::At, At_, "At"); - load_real(Params::Dturb, Dturb_, "Dturb"); - load_real(Params::Qnl, Qnl_, "Qnl"); - load_real(Params::Tn, Tn_, "Tn"); - load_real(Params::Tnp, Tnp_, "Tnp"); - load_real(Params::db1, db1_, "db1"); - load_real(Params::db2, db2_, "db2"); - load_real(Params::Hdam, Hdam_, "Hdam"); - load_real(Params::Gv0, Gv_[0], "Gv0"); - load_real(Params::Gv1, Gv_[1], "Gv1"); - load_real(Params::Gv2, Gv_[2], "Gv2"); - load_real(Params::Gv3, Gv_[3], "Gv3"); - load_real(Params::Gv4, Gv_[4], "Gv4"); - load_real(Params::Gv5, Gv_[5], "Gv5"); - load_real(Params::Pgv0, Pgv_[0], "Pgv0"); - load_real(Params::Pgv1, Pgv_[1], "Pgv1"); - load_real(Params::Pgv2, Pgv_[2], "Pgv2"); - load_real(Params::Pgv3, Pgv_[3], "Pgv3"); - load_real(Params::Pgv4, Pgv_[4], "Pgv4"); - load_real(Params::Pgv5, Pgv_[5], "Pgv5"); - const bool source_default_curve = - std::all_of(Gv_.begin(), Gv_.end(), [](RealT value) - { return value == ZERO; }) - && std::all_of(Pgv_.begin(), Pgv_.end(), [](RealT value) - { return value == ZERO; }); - if (source_default_curve) + // Synchronous machines provide an exactly zero speed deviation. A + // moving machine would need a multi-root gate search, which this + // model does not support. + if (static_cast(omega0) != ZERO) { - Gv_ = {ZERO, - static_cast(0.2), - static_cast(0.4), - static_cast(0.6), - static_cast(0.8), - ONE}; - Pgv_ = Gv_; + Log::error() << "Hygov: initialization requires zero speed deviation\n"; + return 1; } - setDerivedParameters(); - } + ScalarT paux0_system{ZERO}; + if (signals_.template isAttached()) + { + paux0_system = signals_.template readExternalVariable(); + } + const ScalarT paux0 = toComponentBase(paux0_system); - /** - * @brief Access the monitor - * - * @return Monitor for this model, or nullptr when the model was - * constructed without data. - */ - template - const Model::VariableMonitorBase* Hygov::getMonitor() const - { - return monitor_.get(); + const RealT gate0 = solveInitialGate(static_cast(pmech0)); + if (std::isnan(gate0)) + { + Log::error() + << "Hygov: no gate inside [Gmin, Gmax] reproduces the given mechanical power\n"; + return 1; + } + + const ScalarT h0 = static_cast(Hdam_); + const ScalarT pgv0 = gatePower(static_cast(gate0)); + const ScalarT q0 = std::sqrt(Hdam_) * pgv0; + const ScalarT omegadb0 = Math::deadband1(omega0, -db1_, db1_); + const ScalarT xn0 = omegadb0; + const ScalarT yomega0 = xn0 + leadlag_gain_ * (omegadb0 - xn0); + const ScalarT pref0 = toSystemBase(yomega0 + Rperm_ * gate0 - paux0); + + y[XN] = xn0; + y[XF] = ZERO; + y[C] = gate0; + y[G] = gate0; + y[Q] = q0; + y[OMEGADB] = omegadb0; + y[EF] = ZERO; + y[FC] = ZERO; + y[RC] = ZERO; + y[PGV] = pgv0; + y[H] = h0; + + pref_set_ = pref0; + paux_set_ = paux0_system; + + if (signals_.template isAttached()) + { + signals_.template writeExternalVariable(pref_set_); + } + + y_.setDataUpdated(); + yp_.setToConst(static_cast(ZERO)); + return 0; } /** - * @brief Bind the monitorable variables to their internal states + * @brief Identify the differential variables * - * The mechanical-power output is published on the system base and the - * remaining outputs on the component base, as documented in the model - * README. + * The speed lead-lag state, the governor error filter, the desired + * gate, the gate servo, and the turbine flow carry derivatives; every + * other internal variable is algebraic. + * + * @return int 0 on success. */ template - void Hygov::initializeMonitor() + int Hygov::tagDifferentiable() { - using Variable = typename ModelDataT::MonitorableVariables; + const auto XN = static_cast(HygovInternalVariables::XN); + const auto XF = static_cast(HygovInternalVariables::XF); + const auto C = static_cast(HygovInternalVariables::C); + const auto G = static_cast(HygovInternalVariables::G); + const auto Q = static_cast(HygovInternalVariables::Q); - monitor_->set(Variable::pmech, [this] - { return y_.getData()[static_cast(HygovInternalVariables::PMECH)]; }); - monitor_->set(Variable::filter, [this] - { return y_.getData()[static_cast(HygovInternalVariables::XF)]; }); - monitor_->set(Variable::desiredgate, [this] - { return y_.getData()[static_cast(HygovInternalVariables::C)]; }); - monitor_->set(Variable::gate, [this] - { return y_.getData()[static_cast(HygovInternalVariables::G)]; }); - monitor_->set(Variable::flow, [this] - { return y_.getData()[static_cast(HygovInternalVariables::Q)]; }); - monitor_->set(Variable::head, [this] - { return y_.getData()[static_cast(HygovInternalVariables::H)]; }); + std::fill(tag_.begin(), tag_.end(), false); + tag_[XN] = true; + tag_[XF] = true; + tag_[C] = true; + tag_[G] = true; + tag_[Q] = true; + return 0; } /** - * @brief Set the component ID + * @brief Compute the absolute tolerance for each variable in the model * - * @param[in] component_id Identifier assigned by the system model. + * All HYGOV variables are per-unit speeds, gates, flows, heads, and + * powers of the same order, so their absolute and relative tolerance + * have the same value. + * + * @param[in] rel_tol Solver relative tolerance. * @return int 0 on success. */ template - int Hygov::setGridKitComponentID(IdxT component_id) + int Hygov::setAbsoluteTolerance(RealT rel_tol) { - gridkit_component_id_ = component_id; + abs_tol_.setToConst(static_cast(rel_tol)); return 0; } /** - * @brief Allocate the model vectors and wire the mechanical-power output + * @brief Residuals of system equations * - * Sizes the state, residual, and signal-interface buffers, initializes - * the identity index maps, and points the assigned `pmech` node at the - * internal state it publishes. That node aliases HYGOV storage from - * here on, which is how initialize() reads the machine value. - * HYGOV attaches to no bus, so the bus-interface buffer stays empty. - * Repeated calls reuse the allocated vectors. + * Refreshes the signal interface buffers and evaluates the internal + * residual. HYGOV attaches to no bus, so there is no bus interface to + * refresh. An unattached reference or auxiliary port falls back to the + * value latched by initialize(); an unattached speed port reads zero + * deviation. * * @return int 0 on success. */ template - int Hygov::allocate() + int Hygov::evaluateResidual() { - const auto PMECH = static_cast(HygovInternalVariables::PMECH); + const auto OMEGA = static_cast(HygovExternalVariables::OMEGA); + const auto PREF = static_cast(HygovExternalVariables::PREF); + const auto PAUX = static_cast(HygovExternalVariables::PAUX); - if (!allocated_) + ws_[OMEGA] = ZERO; + ws_[PREF] = pref_set_; + ws_[PAUX] = paux_set_; + std::fill(ws_indices_.begin(), ws_indices_.end(), INVALID_INDEX); + + if (signals_.template isAttached()) { - this->allocateVectors(size_); + ws_[OMEGA] = signals_.template readExternalVariable(); + ws_indices_[OMEGA] = + signals_.template readExternalVariableIndex(); } - auto size = static_cast(size_); - - tag_.assign(size, false); - variable_indices_.resize(size); - residual_indices_.resize(size); - - wb_.clear(); - - const auto signal_size = static_cast(HygovExternalVariables::MAXIMUM); - ws_.assign(signal_size, ScalarT{0}); - ws_indices_.assign(signal_size, INVALID_INDEX); - - for (IdxT j = 0; j < size_; ++j) + if (signals_.template isAttached()) { - this->setVariableIndex(j, j); - this->setResidualIndex(j, j); + ws_[PREF] = signals_.template readExternalVariable(); + ws_indices_[PREF] = + signals_.template readExternalVariableIndex(); } - - if (signals_.template isAssigned()) + if (signals_.template isAttached()) { - auto* y = y_.getData(); - signals_.template getSignalNode()->set( - &y[PMECH], - &(this->getVariableIndex(static_cast(PMECH)))); + ws_[PAUX] = signals_.template readExternalVariable(); + ws_indices_[PAUX] = + signals_.template readExternalVariableIndex(); } - allocated_ = true; + 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; } /** - * @brief Validate the HYGOV configuration - * - * Checks parameter-loading errors, static parameter relationships, the - * gate-curve shape, the gate-limit domain, the assigned - * mechanical-power output, and attached external signals. - * Mechanical-power feasibility is operating-point dependent and is - * checked by initialize(). + * @brief Access the monitor * - * @return int Number of configuration errors; zero when valid. + * @return Monitor for this model, or nullptr when the model was + * constructed without data. */ template - int Hygov::verify() const + const Model::VariableMonitorBase* Hygov::getMonitor() const { - int ret = static_cast(parameter_error_count_); - - auto check = [&](bool condition, const char* message) - { - if (!condition) - { - Log::error() << "Hygov: " << message << '\n'; - ret += 1; - } - }; - - check(Rtemp_ != ZERO, "Rtemp must be nonzero"); - check(Tn_ >= ZERO, "Tn must be non-negative"); - check(Velm_ >= ZERO, "Velm must be non-negative"); - check(Gmin_ < Gmax_, "Gmin must be less than Gmax"); - check(At_ > ZERO, "At must be positive"); - check(Dturb_ >= ZERO, "Dturb must be non-negative"); - check(db1_ >= ZERO, "db1 must be non-negative"); - check(Hdam_ > ZERO, "Hdam must be positive"); - - bool curve_shape_is_valid = true; - for (size_t i = 1; i < Gv_.size(); ++i) - { - const bool gate_points_increase = Gv_[i - 1] < Gv_[i]; - const bool power_points_increase = Pgv_[i - 1] <= Pgv_[i]; - - check(gate_points_increase, "Gv points must be strictly increasing"); - check(power_points_increase, "Pgv points must be non-decreasing"); - - if (!gate_points_increase || !power_points_increase) - { - curve_shape_is_valid = false; - } - } - const bool minimum_gate_is_valid = Gv_[0] <= Gmin_; - const bool maximum_gate_is_valid = Gmax_ <= Gv_[5]; - check(minimum_gate_is_valid, "Gmin must be at or above the first Gv point"); - check(maximum_gate_is_valid, "Gmax must be at or below the last Gv point"); - - if (curve_shape_is_valid - && Gmin_ < Gmax_ - && minimum_gate_is_valid - && maximum_gate_is_valid - && At_ > ZERO - && Hdam_ > ZERO) - { - // A rise no wider than the tolerance that pins a seed to a range - // edge leaves the gate undetermined by the mechanical power. - const RealT minimum_power = initialMechanicalPower(Gmin_); - const RealT maximum_power = initialMechanicalPower(Gmax_); - check(maximum_power - minimum_power > INITIALIZATION_TOLERANCE, - "mechanical power must rise across [Gmin, Gmax]"); - } - - check(signals_.template isAssigned(), - "pmech output signal must be assigned"); - - // An attached port must resolve to readable 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() << "Hygov: " << name << " signal attached with no linked source\n"; - ret += 1; - } - }; - - check_attached_signal.template operator()("speed"); - check_attached_signal.template operator()("pref"); - check_attached_signal.template operator()("paux"); - - return ret; + return monitor_.get(); } /** - * @brief Initialize HYGOV from the mechanical-power port - * - * Reads the assigned system-base `pmech` node and the attached speed - * and auxiliary-power inputs, solves the component-base steady state - * that preserves the given value, and publishes the resolved load reference - * to an attached `pref` signal. - * - * @pre allocate() has completed. - * @pre The machine model has initialized the assigned `pmech` node. + * @brief Internal residual * - * @post On success the state zeros every residual row at machine - * rounding; a value clipped to the achievable-power range edge - * leaves a mechanical-power residual up to the initialization - * tolerance. - * @post On failure no state or signal storage has changed. + * Evaluates the five governor states and the seven 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 gate curve enters as a fixed sum of smooth linear + * segments. * - * @return int 0 on success; nonzero when the configuration is - * invalid, the initial speed deviation is nonzero, or no - * gate inside [Gmin, Gmax] reproduces the given power. + * @param[in] y Internal variables. + * @param[in] yp Internal variable derivatives. + * @param[in] wb Bus voltage components; unused, HYGOV attaches to no bus. + * @param[in] ws External signal values on system base. + * @param[out] f Internal residuals. + * @return int 0 on success. */ template - int Hygov::initialize() + __attribute__((always_inline)) inline int + Hygov::evaluateInternalResidual( + const ScalarT* y, + const ScalarT* yp, + [[maybe_unused]] const ScalarT* wb, + const ScalarT* ws, + ScalarT* f) { const auto XN = static_cast(HygovInternalVariables::XN); const auto XF = static_cast(HygovInternalVariables::XF); @@ -581,263 +473,384 @@ namespace GridKit const auto H = static_cast(HygovInternalVariables::H); const auto PMECH = static_cast(HygovInternalVariables::PMECH); - if (verify() > 0) - { - Log::error() << "Hygov: cannot initialize with invalid configuration\n"; - return 1; - } + const auto OMEGA = static_cast(HygovExternalVariables::OMEGA); + const auto PREF = static_cast(HygovExternalVariables::PREF); + const auto PAUX = static_cast(HygovExternalVariables::PAUX); - if (!(va_component_base_ > ZERO) ) - { - va_component_base_ = va_system_base_; - } + const ScalarT xn = y[XN]; + const ScalarT xf = y[XF]; + const ScalarT c = y[C]; + const ScalarT g = y[G]; + const ScalarT q = y[Q]; + const ScalarT omegadb = y[OMEGADB]; + const ScalarT ef = y[EF]; + const ScalarT fc = y[FC]; + const ScalarT rc = y[RC]; + const ScalarT pgv = y[PGV]; + const ScalarT head = y[H]; + const ScalarT pmech = y[PMECH]; - auto* y = y_.getData(); + const ScalarT xn_dot = yp[XN]; + const ScalarT xf_dot = yp[XF]; + const ScalarT c_dot = yp[C]; + const ScalarT g_dot = yp[G]; + const ScalarT q_dot = yp[Q]; - // The assigned pmech node aliases this entry after allocate(). Its - // system-base value remains untouched throughout initialization. - const ScalarT pmech0 = toComponentBase(y[PMECH]); + const ScalarT omega = ws[OMEGA]; + const ScalarT pref = ws[PREF]; + const ScalarT paux = ws[PAUX]; - ScalarT omega0{ZERO}; - if (signals_.template isAttached()) - { - omega0 = signals_.template readExternalVariable(); - } + const ScalarT yomega = xn + leadlag_gain_ * (omegadb - xn); - // Synchronous machines provide an exactly zero speed deviation. A - // moving machine would need a multi-root gate search, which this - // model does not support. - if (static_cast(omega0) != ZERO) - { - Log::error() << "Hygov: initialization requires zero speed deviation\n"; - return 1; - } + f[XN] = -xn_dot + (omegadb - xn) / Tnp_; + f[XF] = -xf_dot + (ef - xf) / Tf_; + f[C] = -c_dot + Math::antiwindup(c, rc, Gmin_, Gmax_); + f[G] = -g_dot + (c - g) / Tg_; + f[Q] = -q_dot + (Hdam_ - head) / Tw_; + f[OMEGADB] = -omegadb + Math::deadband1(omega, -db1_, db1_); + f[EF] = -ef + toComponentBase(pref + paux) - yomega - Rperm_ * c; + f[FC] = -Rtemp_ * fc + xf / Tr_ + (ef - xf) / Tf_; + f[RC] = -rc + Math::clamp(fc, -Velm_, Velm_); + f[PGV] = -pgv + gatePower(g); + f[H] = -q * q + head * pgv * pgv; + f[PMECH] = -toComponentBase(pmech) + At_ * head * (q - Qnl_) - Dturb_ * omega * g; - ScalarT paux0_system{ZERO}; - if (signals_.template isAttached()) - { - paux0_system = signals_.template readExternalVariable(); - } - const ScalarT paux0 = toComponentBase(paux0_system); + return 0; + } - const RealT gate0 = solveInitialGate(static_cast(pmech0)); - if (std::isnan(gate0)) - { - Log::error() - << "Hygov: no gate inside [Gmin, Gmax] reproduces the given mechanical power\n"; - return 1; - } + // + // Private methods + // - const ScalarT h0 = static_cast(Hdam_); - const ScalarT pgv0 = gatePower(static_cast(gate0)); - const ScalarT q0 = std::sqrt(Hdam_) * pgv0; - const ScalarT omegadb0 = Math::deadband1(omega0, -db1_, db1_); - const ScalarT xn0 = omegadb0; - const ScalarT yomega0 = xn0 + leadlag_gain_ * (omegadb0 - xn0); - const ScalarT pref0 = toSystemBase(yomega0 + Rperm_ * gate0 - paux0); + /** + * @brief Read the parameters out of the model data + * + * Every omitted parameter keeps the default documented in the model + * README. A non-numeric value is counted and reported by verify() rather + * than throwing. Integer JSON values are accepted for real parameters. + * All-zero `Gv` and `Pgv` source points select the identity gate curve. + * + * @param[in] data Parameters and monitored-variable selections. + */ + template + void Hygov::initializeParameters(const ModelDataT& data) + { + using Params = typename ModelDataT::Parameters; - y[XN] = xn0; - y[XF] = ZERO; - y[C] = gate0; - y[G] = gate0; - y[Q] = q0; - y[OMEGADB] = omegadb0; - y[EF] = ZERO; - y[FC] = ZERO; - y[RC] = ZERO; - y[PGV] = pgv0; - y[H] = h0; + parameter_error_count_ = 0; - pref_set_ = pref0; - paux_set_ = paux0_system; + auto load_real = [&](auto key, RealT& target, const char* name) -> bool + { + if (!data.parameters.contains(key)) + { + return false; + } - if (signals_.template isAttached()) + const auto& value = data.parameters.at(key); + if (const auto* real_value = std::get_if(&value)) + { + target = *real_value; + return true; + } + if (const auto* index_value = std::get_if(&value)) + { + target = static_cast(*index_value); + return true; + } + + Log::error() << "Hygov: parameter '" << name << "' must be numeric\n"; + ++parameter_error_count_; + return false; + }; + + if (load_real(Params::Trate, va_component_base_, "Trate")) { - signals_.template writeExternalVariable(pref_set_); + if (!(va_component_base_ > ZERO) ) + { + Log::error() << "Hygov: Trate must be positive when provided\n"; + ++parameter_error_count_; + } + va_component_base_ *= static_cast(1.0e6); } + load_real(Params::Rperm, Rperm_, "Rperm"); + load_real(Params::Rtemp, Rtemp_, "Rtemp"); + load_real(Params::Tr, Tr_, "Tr"); + load_real(Params::Tf, Tf_, "Tf"); + load_real(Params::Tg, Tg_, "Tg"); + load_real(Params::Velm, Velm_, "Velm"); + load_real(Params::Gmax, Gmax_, "Gmax"); + load_real(Params::Gmin, Gmin_, "Gmin"); + load_real(Params::Tw, Tw_, "Tw"); + load_real(Params::At, At_, "At"); + load_real(Params::Dturb, Dturb_, "Dturb"); + load_real(Params::Qnl, Qnl_, "Qnl"); + load_real(Params::Tn, Tn_, "Tn"); + load_real(Params::Tnp, Tnp_, "Tnp"); + load_real(Params::db1, db1_, "db1"); + load_real(Params::db2, db2_, "db2"); + load_real(Params::Hdam, Hdam_, "Hdam"); + load_real(Params::Gv0, Gv_[0], "Gv0"); + load_real(Params::Gv1, Gv_[1], "Gv1"); + load_real(Params::Gv2, Gv_[2], "Gv2"); + load_real(Params::Gv3, Gv_[3], "Gv3"); + load_real(Params::Gv4, Gv_[4], "Gv4"); + load_real(Params::Gv5, Gv_[5], "Gv5"); + load_real(Params::Pgv0, Pgv_[0], "Pgv0"); + load_real(Params::Pgv1, Pgv_[1], "Pgv1"); + load_real(Params::Pgv2, Pgv_[2], "Pgv2"); + load_real(Params::Pgv3, Pgv_[3], "Pgv3"); + load_real(Params::Pgv4, Pgv_[4], "Pgv4"); + load_real(Params::Pgv5, Pgv_[5], "Pgv5"); - y_.setDataUpdated(); - yp_.setToConst(static_cast(ZERO)); - return 0; + const bool source_default_curve = + std::all_of(Gv_.begin(), Gv_.end(), [](RealT value) + { return value == ZERO; }) + && std::all_of(Pgv_.begin(), Pgv_.end(), [](RealT value) + { return value == ZERO; }); + if (source_default_curve) + { + Gv_ = {ZERO, + static_cast(0.2), + static_cast(0.4), + static_cast(0.6), + static_cast(0.8), + ONE}; + Pgv_ = Gv_; + } + + setDerivedParameters(); } /** - * @brief Identify the differential variables + * @brief Bind the monitorable variables to their internal states * - * The speed lead-lag state, the governor error filter, the desired - * gate, the gate servo, and the turbine flow carry derivatives; every - * other internal variable is algebraic. + * The mechanical-power output is published on the system base and the + * remaining outputs on the component base, as documented in the model + * README. + */ + template + void Hygov::initializeMonitor() + { + using Variable = typename ModelDataT::MonitorableVariables; + + monitor_->set(Variable::pmech, [this] + { return y_.getData()[static_cast(HygovInternalVariables::PMECH)]; }); + monitor_->set(Variable::filter, [this] + { return y_.getData()[static_cast(HygovInternalVariables::XF)]; }); + monitor_->set(Variable::desiredgate, [this] + { return y_.getData()[static_cast(HygovInternalVariables::C)]; }); + monitor_->set(Variable::gate, [this] + { return y_.getData()[static_cast(HygovInternalVariables::G)]; }); + monitor_->set(Variable::flow, [this] + { return y_.getData()[static_cast(HygovInternalVariables::Q)]; }); + monitor_->set(Variable::head, [this] + { return y_.getData()[static_cast(HygovInternalVariables::H)]; }); + } + + /** + * @brief Resolve the parameter-derived constants * - * @return int 0 on success. + * Floors each governor time constant so the residual equations retain + * Hessenberg form, then derives the speed lead-lag gain. */ template - int Hygov::tagDifferentiable() + void Hygov::setDerivedParameters() { - const auto XN = static_cast(HygovInternalVariables::XN); - const auto XF = static_cast(HygovInternalVariables::XF); - const auto C = static_cast(HygovInternalVariables::C); - const auto G = static_cast(HygovInternalVariables::G); - const auto Q = static_cast(HygovInternalVariables::Q); + // 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() << "Hygov: " << name << " must be non-negative\n"; + ++parameter_error_count_; + } + }; - std::fill(tag_.begin(), tag_.end(), false); - tag_[XN] = true; - tag_[XF] = true; - tag_[C] = true; - tag_[G] = true; - tag_[Q] = true; - return 0; + check_non_negative(Tr_, "Tr"); + check_non_negative(Tf_, "Tf"); + check_non_negative(Tg_, "Tg"); + check_non_negative(Tw_, "Tw"); + check_non_negative(Tnp_, "Tnp"); + + if (Tr_ < TIME_CONSTANT_MINIMUM || Tf_ < TIME_CONSTANT_MINIMUM + || Tg_ < TIME_CONSTANT_MINIMUM || Tw_ < TIME_CONSTANT_MINIMUM + || Tnp_ < TIME_CONSTANT_MINIMUM) + { + Log::warning() << "Hygov: Tr, Tf, Tg, Tw, and Tnp below " + << TIME_CONSTANT_MINIMUM + << " s are raised to preserve Hessenberg form\n"; + } + + // HYGOV residuals solve explicitly for the state derivatives to preserve + // Hessenberg form. A zero time constant would instead require an implicit + // residual formulation, so enforce a strictly positive lower bound. + Tr_ = std::max(Tr_, TIME_CONSTANT_MINIMUM); + Tf_ = std::max(Tf_, TIME_CONSTANT_MINIMUM); + Tg_ = std::max(Tg_, TIME_CONSTANT_MINIMUM); + Tw_ = std::max(Tw_, TIME_CONSTANT_MINIMUM); + Tnp_ = std::max(Tnp_, TIME_CONSTANT_MINIMUM); + + leadlag_gain_ = Tn_ / Tnp_; } /** - * @brief Compute the absolute tolerance for each variable in the model + * @brief Evaluate the nonlinear gate-to-power curve * - * All HYGOV variables are per-unit speeds, gates, flows, heads, and - * powers of the same order, so they share the relative tolerance as - * their absolute floor. + * Sums the five smooth CommonMath linear segments spanned by the + * `Gv`/`Pgv` points, so the same fixed expression serves the residual + * and both scalar instantiations. * - * @param[in] rel_tol Solver relative tolerance. - * @return int 0 on success. + * @param[in] gate Gate position. + * @return Turbine power at nominal head. */ template - int Hygov::setAbsoluteTolerance(RealT rel_tol) + __attribute__((always_inline)) inline scalar_type + Hygov::gatePower(scalar_type gate) const { - abs_tol_.setToConst(static_cast(rel_tol)); - return 0; + ScalarT retval = Pgv_[0] + + Math::linseg(gate, Gv_[0], Gv_[1], Pgv_[1] - Pgv_[0]) + + Math::linseg(gate, Gv_[1], Gv_[2], Pgv_[2] - Pgv_[1]) + + Math::linseg(gate, Gv_[2], Gv_[3], Pgv_[3] - Pgv_[2]) + + Math::linseg(gate, Gv_[3], Gv_[4], Pgv_[4] - Pgv_[3]) + + Math::linseg(gate, Gv_[4], Gv_[5], Pgv_[5] - Pgv_[4]); + + return retval; } /** - * @brief Internal residual + * @brief Steady component-base mechanical power at a gate position * - * Evaluates the five governor states and the seven 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 gate curve enters as a fixed sum of smooth linear - * segments. + * At the steady state the head equals the dam head and the flow + * follows the gate curve, so the PGV, H, and PMECH rows collapse to + * @f[ + * P_{\mathrm{m}}(g) + * = A_t H_{\mathrm{dam}} + * \left(\sqrt{H_{\mathrm{dam}}}\,N_{\mathrm{GV}}(g) + * - q_{\mathrm{NL}}\right). + * @f] + * The expression is composed exactly as those rows compose it, so a + * gate solved against it zeros the implemented residual at machine + * rounding. * - * @param[in] y Internal variables. - * @param[in] yp Internal variable derivatives. - * @param[in] wb Bus voltage components; unused, HYGOV attaches to no bus. - * @param[in] ws External signal values on system base. - * @param[out] f Internal residuals. - * @return int 0 on success. + * @param[in] gate Gate position. + * @return Steady mechanical power on the component base. */ template - __attribute__((always_inline)) inline int - Hygov::evaluateInternalResidual( - const ScalarT* y, - const ScalarT* yp, - [[maybe_unused]] const ScalarT* wb, - const ScalarT* ws, - ScalarT* f) + typename Hygov::RealT + Hygov::initialMechanicalPower(RealT gate) const { - const auto XN = static_cast(HygovInternalVariables::XN); - const auto XF = static_cast(HygovInternalVariables::XF); - const auto C = static_cast(HygovInternalVariables::C); - const auto G = static_cast(HygovInternalVariables::G); - const auto Q = static_cast(HygovInternalVariables::Q); - const auto OMEGADB = static_cast(HygovInternalVariables::OMEGADB); - const auto EF = static_cast(HygovInternalVariables::EF); - const auto FC = static_cast(HygovInternalVariables::FC); - const auto RC = static_cast(HygovInternalVariables::RC); - const auto PGV = static_cast(HygovInternalVariables::PGV); - const auto H = static_cast(HygovInternalVariables::H); - const auto PMECH = static_cast(HygovInternalVariables::PMECH); - - const auto OMEGA = static_cast(HygovExternalVariables::OMEGA); - const auto PREF = static_cast(HygovExternalVariables::PREF); - const auto PAUX = static_cast(HygovExternalVariables::PAUX); - - const ScalarT xn = y[XN]; - const ScalarT xf = y[XF]; - const ScalarT c = y[C]; - const ScalarT g = y[G]; - const ScalarT q = y[Q]; - const ScalarT omegadb = y[OMEGADB]; - const ScalarT ef = y[EF]; - const ScalarT fc = y[FC]; - const ScalarT rc = y[RC]; - const ScalarT pgv = y[PGV]; - const ScalarT head = y[H]; - const ScalarT pmech = y[PMECH]; - - const ScalarT xn_dot = yp[XN]; - const ScalarT xf_dot = yp[XF]; - const ScalarT c_dot = yp[C]; - const ScalarT g_dot = yp[G]; - const ScalarT q_dot = yp[Q]; - - const ScalarT omega = ws[OMEGA]; - const ScalarT pref = ws[PREF]; - const ScalarT paux = ws[PAUX]; - - const ScalarT yomega = xn + leadlag_gain_ * (omegadb - xn); - - f[XN] = -xn_dot + (omegadb - xn) / Tnp_; - f[XF] = -xf_dot + (ef - xf) / Tf_; - f[C] = -c_dot + Math::antiwindup(c, rc, Gmin_, Gmax_); - f[G] = -g_dot + (c - g) / Tg_; - f[Q] = -q_dot + (Hdam_ - head) / Tw_; - f[OMEGADB] = -omegadb + Math::deadband1(omega, -db1_, db1_); - f[EF] = -ef + toComponentBase(pref + paux) - yomega - Rperm_ * c; - f[FC] = -Rtemp_ * fc + xf / Tr_ + (ef - xf) / Tf_; - f[RC] = -rc + Math::clamp(fc, -Velm_, Velm_); - f[PGV] = -pgv + gatePower(g); - f[H] = -q * q + head * pgv * pgv; - f[PMECH] = -toComponentBase(pmech) + At_ * head * (q - Qnl_) - Dturb_ * omega * g; - - return 0; + const RealT pgv = static_cast(gatePower(static_cast(gate))); + const RealT q = std::sqrt(Hdam_) * pgv; + return At_ * Hdam_ * (q - Qnl_); } /** - * @brief Residuals of system equations + * @brief Solve the steady gate position for a given mechanical power * - * Refreshes the signal interface buffers and evaluates the internal - * residual. HYGOV attaches to no bus, so there is no bus interface to - * refresh. An unattached reference or auxiliary port falls back to the - * value latched by initialize(); an unattached speed port reads zero - * deviation. + * Initialization requires a zero speed deviation and verify() requires + * the steady power to rise across [Gmin, Gmax], so the endpoint + * residuals decide feasibility and bisection converges to a root of + * the nondecreasing steady-power curve. * - * @return int 0 on success. + * @pre verify() reports no errors. + * + * @param[in] pmech Mechanical power on the component base. + * @return The gate position, or a quiet NaN when no gate inside + * [Gmin, Gmax] reproduces the value within the initialization + * tolerance. + * + * @warning This function contains conditional branching and may be used + * during initialization, but not during residual evaluation. */ template - int Hygov::evaluateResidual() + typename Hygov::RealT + Hygov::solveInitialGate(RealT pmech) const { - const auto OMEGA = static_cast(HygovExternalVariables::OMEGA); - const auto PREF = static_cast(HygovExternalVariables::PREF); - const auto PAUX = static_cast(HygovExternalVariables::PAUX); + // NaN is unreproducible by any gate and would otherwise slip + // through the sign tests below. + if (std::isnan(pmech)) + { + return std::numeric_limits::quiet_NaN(); + } - ws_[OMEGA] = ZERO; - ws_[PREF] = pref_set_; - ws_[PAUX] = paux_set_; - std::fill(ws_indices_.begin(), ws_indices_.end(), INVALID_INDEX); + RealT a = Gmin_; + RealT b = Gmax_; + RealT fa = initialMechanicalPower(a) - pmech; + RealT fb = initialMechanicalPower(b) - pmech; - if (signals_.template isAttached()) + // A value just outside the achievable range pins to the gate limit + // when it is within the initialization tolerance of the range edge. + if (fa > ZERO) { - ws_[OMEGA] = signals_.template readExternalVariable(); - ws_indices_[OMEGA] = - signals_.template readExternalVariableIndex(); + if (fa <= INITIALIZATION_TOLERANCE) + { + return a; + } + return std::numeric_limits::quiet_NaN(); } - if (signals_.template isAttached()) + if (fb < ZERO) { - ws_[PREF] = signals_.template readExternalVariable(); - ws_indices_[PREF] = - signals_.template readExternalVariableIndex(); + if (-fb <= INITIALIZATION_TOLERANCE) + { + return b; + } + return std::numeric_limits::quiet_NaN(); } - if (signals_.template isAttached()) + + // Bisect until no representable midpoint remains, then keep the + // endpoint with the smaller residual. + while (true) { - ws_[PAUX] = signals_.template readExternalVariable(); - ws_indices_[PAUX] = - signals_.template readExternalVariableIndex(); + const RealT mid = HALF * (a + b); + if (mid <= a || b <= mid) + { + break; + } + const RealT fmid = initialMechanicalPower(mid) - pmech; + if (fmid <= ZERO) + { + a = mid; + fa = fmid; + } + else + { + b = mid; + fb = fmid; + } } + if (std::abs(fa) <= std::abs(fb)) + { + return a; + } + return b; + } - const auto* y = y_.getData(); - const auto* yp = yp_.getData(); - auto* f = f_.getData(); + /** + * @brief Convert a system-base power to HYGOV component base + * + * @param[in] value Quantity on the system base. + * @return The same quantity on the component base. + */ + template + scalar_type Hygov::toComponentBase(scalar_type value) const + { + return value * va_system_base_ / va_component_base_; + } - evaluateInternalResidual(y, yp, wb_.data(), ws_.data(), f); - f_.setDataUpdated(); - return 0; + /** + * @brief Convert a component-base power to the system base + * + * @param[in] value Quantity on the component base. + * @return The same quantity on the system base. + */ + template + scalar_type Hygov::toSystemBase(scalar_type value) const + { + return value / toComponentBase(static_cast(ONE)); } + } // namespace Governor } // namespace PhasorDynamics } // namespace GridKit From 4720e36b6f361726248ce76fe7b7682998ea2916 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Sat, 1 Aug 2026 16:26:27 -0500 Subject: [PATCH 06/17] REGCA patch fix --- tests/UnitTests/PhasorDynamics/ConverterRegcaTests.hpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/UnitTests/PhasorDynamics/ConverterRegcaTests.hpp b/tests/UnitTests/PhasorDynamics/ConverterRegcaTests.hpp index 57b477298..a42731f1b 100644 --- a/tests/UnitTests/PhasorDynamics/ConverterRegcaTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ConverterRegcaTests.hpp @@ -719,7 +719,9 @@ namespace GridKit for (size_t i = 0; i < nrows; ++i) { - if (!isEqual(dependency_tracking_jacobian[i], enzyme_jacobian[i])) + if (!isEqual(dependency_tracking_jacobian[i], + enzyme_jacobian[i], + kTol)) { std::cout << "Jacobian row " << i << " mismatch between dependency tracking and Enzyme" From 8380cefa6ff64fa8c677a36e33d52637de0f7466 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Mon, 3 Aug 2026 01:35:04 -0500 Subject: [PATCH 07/17] Finite gaurds --- .../Governor/HYGOV/HygovImpl.hpp | 184 +++++++++++++----- .../PhasorDynamics/Governor/HYGOV/README.md | 30 +-- .../PhasorDynamics/GovernorHygovTests.hpp | 106 ++++++++-- 3 files changed, 250 insertions(+), 70 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp index 2d64eb8ac..f0f3c509a 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp @@ -124,10 +124,10 @@ namespace GridKit * @brief Validate the HYGOV configuration * * Checks parameter-loading errors, static parameter relationships, the - * gate-curve shape, the gate-limit domain, the assigned - * mechanical-power output, and attached external signals. - * Mechanical-power feasibility is operating-point dependent and is - * checked by initialize(). + * power-base domain, gate-curve shape, gate-limit domain, the assigned + * mechanical-power output, and attached external signals. Mechanical- + * power feasibility is operating-point dependent and is checked by + * initialize(). * * @return int Number of configuration errors; zero when valid. */ @@ -145,6 +145,34 @@ namespace GridKit } }; + RealT component_power_base = va_component_base_; + const bool component_base_is_omitted = + !(component_power_base > ZERO); + if (component_base_is_omitted) + { + component_power_base = va_system_base_; + } + + const bool valid_component_base = std::isfinite(component_power_base) + && component_power_base > ZERO; + const bool valid_system_base = std::isfinite(va_system_base_) + && va_system_base_ > ZERO; + check(valid_component_base, + "component power base must be finite and positive"); + check(valid_system_base, + "system power base must be finite and positive"); + if (valid_component_base && valid_system_base) + { + const RealT system_to_component = va_system_base_ / component_power_base; + const RealT component_to_system = component_power_base / va_system_base_; + const bool valid_base_ratios = std::isfinite(system_to_component) + && system_to_component > ZERO + && std::isfinite(component_to_system) + && component_to_system > ZERO; + check(valid_base_ratios, + "system/component power-base conversion ratios must be finite and positive"); + } + check(Rtemp_ != ZERO, "Rtemp must be nonzero"); check(Tn_ >= ZERO, "Tn must be non-negative"); check(Velm_ >= ZERO, "Velm must be non-negative"); @@ -173,19 +201,29 @@ namespace GridKit check(minimum_gate_is_valid, "Gmin must be at or above the first Gv point"); check(maximum_gate_is_valid, "Gmax must be at or below the last Gv point"); - if (curve_shape_is_valid - && Gmin_ < Gmax_ - && minimum_gate_is_valid - && maximum_gate_is_valid - && At_ > ZERO - && Hdam_ > ZERO) + const bool can_check_power_range = curve_shape_is_valid + && Gmin_ < Gmax_ + && minimum_gate_is_valid + && maximum_gate_is_valid + && At_ > ZERO + && Hdam_ > ZERO; + if (can_check_power_range) { // A rise no wider than the tolerance that pins a seed to a range // edge leaves the gate undetermined by the mechanical power. - const RealT minimum_power = initialMechanicalPower(Gmin_); - const RealT maximum_power = initialMechanicalPower(Gmax_); - check(maximum_power - minimum_power > INITIALIZATION_TOLERANCE, - "mechanical power must rise across [Gmin, Gmax]"); + const RealT minimum_power = initialMechanicalPower(Gmin_); + const RealT maximum_power = initialMechanicalPower(Gmax_); + const RealT power_range = maximum_power - minimum_power; + const bool finite_power_range = std::isfinite(minimum_power) + && std::isfinite(maximum_power) + && std::isfinite(power_range); + check(finite_power_range, + "mechanical-power range must be finite"); + if (finite_power_range) + { + check(power_range > INITIALIZATION_TOLERANCE, + "mechanical power must rise across [Gmin, Gmax]"); + } } check(signals_.template isAssigned(), @@ -228,9 +266,10 @@ namespace GridKit * tolerance. * @post On failure no state or signal storage has changed. * - * @return int 0 on success; nonzero when the configuration is - * invalid, the initial speed deviation is nonzero, or no - * gate inside [Gmin, Gmax] reproduces the given power. + * @return int 0 on success; nonzero when the configuration or initial + * values are invalid, the initial speed deviation is + * nonzero, or no gate inside [Gmin, Gmax] reproduces the + * given power. */ template int Hygov::initialize() @@ -248,13 +287,15 @@ namespace GridKit const auto H = static_cast(HygovInternalVariables::H); const auto PMECH = static_cast(HygovInternalVariables::PMECH); - if (verify() > 0) + bool ret = verify() == 0; + if (!ret) { Log::error() << "Hygov: cannot initialize with invalid configuration\n"; return 1; } - if (!(va_component_base_ > ZERO) ) + ret = va_component_base_ > ZERO; + if (!ret) { va_component_base_ = va_system_base_; } @@ -263,7 +304,7 @@ namespace GridKit // The assigned pmech node aliases this entry after allocate(). Its // system-base value remains untouched throughout initialization. - const ScalarT pmech0 = toComponentBase(y[PMECH]); + const ScalarT pmech0_system = y[PMECH]; ScalarT omega0{ZERO}; if (signals_.template isAttached()) @@ -271,24 +312,48 @@ namespace GridKit omega0 = signals_.template readExternalVariable(); } + ScalarT paux0_system{ZERO}; + if (signals_.template isAttached()) + { + paux0_system = signals_.template readExternalVariable(); + } + + auto is_finite = [](ScalarT value) + { + return std::isfinite(static_cast(value)); + }; + ret = is_finite(pmech0_system) + && is_finite(omega0) + && is_finite(paux0_system); + if (!ret) + { + Log::error() << "Hygov: initial pmech, speed, and paux values must be finite\n"; + return 1; + } + + const ScalarT pmech0 = toComponentBase(pmech0_system); + const ScalarT paux0 = toComponentBase(paux0_system); + ret = is_finite(pmech0) + && is_finite(paux0); + if (!ret) + { + Log::error() << "Hygov: initial power-base conversions must be finite\n"; + return 1; + } + // Synchronous machines provide an exactly zero speed deviation. A // moving machine would need a multi-root gate search, which this // model does not support. - if (static_cast(omega0) != ZERO) + ret = static_cast(omega0) == ZERO; + if (!ret) { Log::error() << "Hygov: initialization requires zero speed deviation\n"; return 1; } - ScalarT paux0_system{ZERO}; - if (signals_.template isAttached()) - { - paux0_system = signals_.template readExternalVariable(); - } - const ScalarT paux0 = toComponentBase(paux0_system); - const RealT gate0 = solveInitialGate(static_cast(pmech0)); - if (std::isnan(gate0)) + ret = std::isfinite(gate0); + if (!ret) { Log::error() << "Hygov: no gate inside [Gmin, Gmax] reproduces the given mechanical power\n"; @@ -303,6 +368,19 @@ namespace GridKit const ScalarT yomega0 = xn0 + leadlag_gain_ * (omegadb0 - xn0); const ScalarT pref0 = toSystemBase(yomega0 + Rperm_ * gate0 - paux0); + ret = is_finite(h0) + && is_finite(pgv0) + && is_finite(q0) + && is_finite(omegadb0) + && is_finite(xn0) + && is_finite(yomega0) + && is_finite(pref0); + if (!ret) + { + Log::error() << "Hygov: initialization produced a nonfinite value\n"; + return 1; + } + y[XN] = xn0; y[XF] = ZERO; y[C] = gate0; @@ -526,9 +604,10 @@ namespace GridKit * @brief Read the parameters out of the model data * * Every omitted parameter keeps the default documented in the model - * README. A non-numeric value is counted and reported by verify() rather - * than throwing. Integer JSON values are accepted for real parameters. - * All-zero `Gv` and `Pgv` source points select the identity gate curve. + * README. A non-numeric or nonfinite value is counted and reported by + * verify() rather than throwing. Integer JSON values are accepted for + * real parameters. All-zero `Gv` and `Pgv` source points select the + * identity gate curve. * * @param[in] data Parameters and monitored-variable selections. */ @@ -547,25 +626,39 @@ namespace GridKit } const auto& value = data.parameters.at(key); + RealT parsed_value{}; if (const auto* real_value = std::get_if(&value)) { - target = *real_value; - return true; + parsed_value = *real_value; + } + else if (const auto* index_value = std::get_if(&value)) + { + parsed_value = static_cast(*index_value); } - if (const auto* index_value = std::get_if(&value)) + else { - target = static_cast(*index_value); - return true; + Log::error() << "Hygov: parameter '" << name << "' must be numeric\n"; + ++parameter_error_count_; + return false; + } + + const bool ret = std::isfinite(parsed_value); + if (!ret) + { + Log::error() << "Hygov: parameter '" << name << "' must be finite\n"; + ++parameter_error_count_; + return false; } - Log::error() << "Hygov: parameter '" << name << "' must be numeric\n"; - ++parameter_error_count_; - return false; + target = parsed_value; + return true; }; - if (load_real(Params::Trate, va_component_base_, "Trate")) + bool ret = load_real(Params::Trate, va_component_base_, "Trate"); + if (ret) { - if (!(va_component_base_ > ZERO) ) + ret = va_component_base_ > ZERO; + if (!ret) { Log::error() << "Hygov: Trate must be positive when provided\n"; ++parameter_error_count_; @@ -768,9 +861,10 @@ namespace GridKit typename Hygov::RealT Hygov::solveInitialGate(RealT pmech) const { - // NaN is unreproducible by any gate and would otherwise slip - // through the sign tests below. - if (std::isnan(pmech)) + // A nonfinite seed is unreproducible by any gate and would otherwise + // slip through the sign tests below. + const bool ret = std::isfinite(pmech); + if (!ret) { return std::numeric_limits::quiet_NaN(); } diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md b/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md index b73c47320..864a62598 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md @@ -40,12 +40,14 @@ $H_{\mathrm{dam}}$ | [p.u.] | `Hdam` | Head available at dam $G_V^{(k)}$ | [p.u.] | `Gv0`-`Gv5` | Gate point $k$ of the gain curve | 0.0 | $k=0,\ldots,5$ $P_{\mathrm{GV}}^{(k)}$ | [p.u.] | `Pgv0`-`Pgv5` | Power point $k$ of the gain curve | 0.0 | $k=0,\ldots,5$ -Every parameter is optional. All-zero `Gv` and `Pgv` source points select the -identity curve. +Every parameter is optional. Real-valued parameters accept real or integer +JSON values. All-zero `Gv` and `Pgv` source points select the identity curve. ### Parameter Validation -Invalid HYGOV parameter sets are rejected by the following checks: +Real-valued parameters, `Known` initial values, power bases, and base-conversion +ratios must be finite. The bases and ratios must also be positive. Invalid +HYGOV parameter sets are rejected by the following checks: ```math \begin{aligned} @@ -128,7 +130,7 @@ Name | Port | Init | Description `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 -`pmech` output must be assigned; the signal inputs are optional. Unattached +`pmech` output must be assigned. The signal inputs are optional. Unattached `speed` and `paux` inputs default to zero. ## Model Variables @@ -139,7 +141,7 @@ attached signal storage, or retained as constant inputs when unattached. The Symbol | Units | Description | Note ------------------------|--------|-------------------------------------|------ -$x_n$ | [p.u.] | Speed lead-lag denominator state | Not circled in Fig. 1; realizes the `Tn`/`Tnp` block +$x_n$ | [p.u.] | Speed lead-lag denominator state | Not circled in Fig. 1. Realizes the `Tn`/`Tnp` block $x_f$ | [p.u.] | Governor error filter output | State 1 in Fig. 1 $c$ | [p.u.] | Desired-gate position | State 2 in Fig. 1 $g$ | [p.u.] | Gate position | State 3 in Fig. 1 @@ -167,9 +169,9 @@ None. Symbol | Units | Init | Description | Note ------------------|--------|---------|-----------------------------|------ -$\omega$ | [p.u.] | Known | Machine speed deviation | Optional signal port `speed`; defaults to zero -$P^\mathrm{ref}$ | [p.u.] | Unknown | Active-power/load reference | Optional signal port `pref`; system base -$P^\mathrm{aux}$ | [p.u.] | Known | Auxiliary power input | Optional signal port `paux`; system base; defaults to zero +$\omega$ | [p.u.] | Known | Machine speed deviation | Optional signal port `speed`. Defaults to zero +$P^\mathrm{ref}$ | [p.u.] | Unknown | Active-power/load reference | Optional signal port `pref`, system base +$P^\mathrm{aux}$ | [p.u.] | Known | Auxiliary power input | Optional signal port `paux`, system base, defaults to zero ## Model Equations @@ -260,8 +262,8 @@ Initialization never replaces the system-base value held in $P_{\mathrm{m}}$. ### Internal Initialization -Initialization requires an exactly zero speed deviation, $\omega = 0$; -restart initialization of a moving machine is not supported. All internal +Initialization requires an exactly zero speed deviation, $\omega = 0$. +Restart initialization of a moving machine is not supported. All internal derivatives are set to zero. The gate is found by bisection over the validated nondecreasing steady-power @@ -298,7 +300,7 @@ curve using the same smooth $N_{\mathrm{GV}}$ curve as the residual: Initialization rejects an operating point when no gate in $[G^{\min}, G^{\max}]$ reproduces the given mechanical power. An in-range -value initializes with every residual at machine rounding; a value within +value initializes with every residual at machine rounding. A value within $\epsilon_{\mathrm{init}} = 100\,\epsilon_{\mathrm{mach}}$ of the achievable-power range initializes at the corresponding gate limit with a mechanical-power residual up to $\epsilon_{\mathrm{init}}$. @@ -336,12 +338,12 @@ Output | Units | Description | Note ## Testing -- `validation()` checks construction, monitor creation, parameter - validation, signal configuration, and minimum time-constant handling. +- `validation()` checks construction, monitor creation, parameter validation, + signal configuration, and minimum time-constant handling. - `initializationAndSignals()` checks initialization, base conversion, signal publication, monitor output, and unattached-reference latching. - `initializationDomain()` checks rejected and accepted mechanical-power, - gate-limit, and speed-deviation initialization boundaries. + gate-limit, speed-deviation, and input initialization boundaries. - `initializationExactness()` checks that initialized steady residuals rest at machine rounding across the gate curve. - `residualEquations()` checks every model residual against a fixed diff --git a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp index 9d6353cf3..85f0cf766 100644 --- a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp +++ b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp @@ -46,9 +46,9 @@ namespace GridKit static_cast(100.0) * std::numeric_limits::epsilon(); /// Construction and every verify() error class, including parameter - /// types, parameter relationships, curve shape, gate-limit domain, - /// the required pmech assignment, and signal linkage, plus - /// differentiability tagging. + /// types and finiteness, parameter relationships, power bases, curve + /// shape, gate-limit domain, the required pmech assignment, and signal + /// linkage, plus differentiability tagging. TestOutcome validation() { TestStatus success = true; @@ -71,6 +71,49 @@ namespace GridKit success *= (empty.verify() > 0); + const RealT nan = std::numeric_limits::quiet_NaN(); + const RealT infinity = std::numeric_limits::infinity(); + + for (const Params parameter : std::array{{ + Params::Trate, + Params::Rperm, + Params::Rtemp, + Params::Tr, + Params::Tf, + Params::Tg, + Params::Velm, + Params::Gmax, + Params::Gmin, + Params::Tw, + Params::At, + Params::Dturb, + Params::Qnl, + Params::Tn, + Params::Tnp, + Params::db1, + Params::db2, + Params::Hdam, + Params::Gv0, + Params::Gv1, + Params::Gv2, + Params::Gv3, + Params::Gv4, + Params::Gv5, + Params::Pgv0, + Params::Pgv1, + Params::Pgv2, + Params::Pgv3, + Params::Pgv4, + Params::Pgv5, + }}) + { + for (const RealT value : std::array{{nan, infinity, -infinity}}) + { + Fixture invalid_fixture(makeData(), {{parameter, value}}); + success *= (invalid_fixture.hygov.verify() > 0); + } + } + // The pmech output is required, so a model without an assigned node // is rejected even when every parameter is valid. PhasorDynamics::Governor::Hygov unassigned(makeData()); @@ -140,6 +183,29 @@ namespace GridKit Fixture bad_numeric_model(bad_numeric_type); success *= (bad_numeric_model.hygov.verify() > 0); + Fixture overflowing_component_base( + makeData(), + {{Params::Trate, std::numeric_limits::max()}}); + success *= (overflowing_component_base.hygov.verify() > 0); + + Fixture overflowing_base_ratio( + makeData(), + {{Params::Trate, std::numeric_limits::min()}}); + success *= (overflowing_base_ratio.hygov.verify() > 0); + + for (const RealT system_base : std::array{{ + 0.0, + -1.0, + nan, + infinity, + -infinity, + std::numeric_limits::min(), + }}) + { + Fixture invalid_base(makeData(), {}, system_base); + success *= (invalid_base.hygov.verify() > 0); + } + success *= unlinkedSignalRejected(); success *= unlinkedSignalRejected(); success *= unlinkedSignalRejected(); @@ -261,10 +327,10 @@ namespace GridKit return success.report(__func__); } - /// Mechanical-power, gate-limit, and speed-deviation initialization - /// domains. Every rejection is atomic; values inside the achievable - /// range initialize at rest, and values within the initialization - /// tolerance of a range edge pin to the gate limit. + /// Mechanical-power, gate-limit, speed-deviation, and finite-input + /// initialization domains. Every rejection is atomic; values inside the + /// achievable range initialize at rest, and values within the + /// initialization tolerance of a range edge pin to the gate limit. TestOutcome initializationDomain() { TestStatus success = true; @@ -372,10 +438,28 @@ namespace GridKit p_min - 2.0 * kTol, "twice the tolerance below the achievable minimum"); - // NaN is unreproducible by any gate and must be rejected. - Fixture nan_power_fixture(makeData()); - success *= nan_power_fixture.prepare(std::numeric_limits::quiet_NaN()); - success *= (nan_power_fixture.hygov.initialize() != 0); + const RealT nan = std::numeric_limits::quiet_NaN(); + const RealT infinity = std::numeric_limits::infinity(); + + for (const RealT value : std::array{{nan, infinity, -infinity}}) + { + Fixture pmech_fixture(makeData()); + pmech_fixture.attachAllInputs(); + success *= pmech_fixture.prepare(value); + success *= (pmech_fixture.hygov.initialize() != 0); + + Fixture omega_fixture(makeData()); + omega_fixture.attachAllInputs(); + success *= omega_fixture.prepare(0.4); + omega_fixture.input(External::OMEGA) = value; + success *= (omega_fixture.hygov.initialize() != 0); + + Fixture paux_fixture(makeData()); + paux_fixture.attachAllInputs(); + success *= paux_fixture.prepare(0.4); + paux_fixture.input(External::PAUX) = value; + success *= (paux_fixture.hygov.initialize() != 0); + } return success.report(__func__); } From 72ef61027556d6f896a30a3cb3308681d3cd1f70 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Mon, 3 Aug 2026 11:40:23 -0500 Subject: [PATCH 08/17] TestOutcome consistancy --- .../PhasorDynamics/GovernorHygovTests.hpp | 124 +++++++++++++----- 1 file changed, 93 insertions(+), 31 deletions(-) diff --git a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp index 85f0cf766..0e9aac143 100644 --- a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp +++ b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp @@ -1039,27 +1039,51 @@ namespace GridKit return false; } - success *= (implicit_defaults.evaluate() == 0); - success *= (explicit_defaults.evaluate() == 0); - success *= vectorUnchanged(implicit_defaults.hygov.y(), - copyVector(explicit_defaults.hygov.y()), - "documented-default state"); - success *= vectorUnchanged(implicit_defaults.hygov.yp(), - copyVector(explicit_defaults.hygov.yp()), - "documented-default derivative"); - success *= vectorUnchanged(implicit_defaults.hygov.getResidual(), - copyVector(explicit_defaults.hygov.getResidual()), - "documented-default residual"); + if (implicit_defaults.evaluate() != 0) + { + success = false; + } + if (explicit_defaults.evaluate() != 0) + { + success = false; + } + if (!vectorUnchanged(implicit_defaults.hygov.y(), + copyVector(explicit_defaults.hygov.y()), + "documented-default state")) + { + success = false; + } + if (!vectorUnchanged(implicit_defaults.hygov.yp(), + copyVector(explicit_defaults.hygov.yp()), + "documented-default derivative")) + { + success = false; + } + if (!vectorUnchanged(implicit_defaults.hygov.getResidual(), + copyVector(explicit_defaults.hygov.getResidual()), + "documented-default residual")) + { + success = false; + } setAnswerKeyInputs(implicit_defaults); setAnswerKeyInputs(explicit_defaults); setAnswerKeyState(implicit_defaults.hygov); setAnswerKeyState(explicit_defaults.hygov); - success *= (implicit_defaults.evaluate() == 0); - success *= (explicit_defaults.evaluate() == 0); - success *= vectorUnchanged(implicit_defaults.hygov.getResidual(), - copyVector(explicit_defaults.hygov.getResidual()), - "documented-default dynamic residual"); + if (implicit_defaults.evaluate() != 0) + { + success = false; + } + if (explicit_defaults.evaluate() != 0) + { + success = false; + } + if (!vectorUnchanged(implicit_defaults.hygov.getResidual(), + copyVector(explicit_defaults.hygov.getResidual()), + "documented-default dynamic residual")) + { + success = false; + } return success; } @@ -1090,7 +1114,10 @@ namespace GridKit 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 success; } @@ -1140,13 +1167,30 @@ namespace GridKit success = false; } - success *= scalarMatches(fixture.pmech(), pmech, "rejected pmech preservation"); - success *= scalarMatches( - fixture.input(External::OMEGA), omega, "rejected omega preservation"); - success *= scalarMatches(fixture.input(External::PREF), 77.0, "rejected pref preservation"); - success *= scalarMatches(fixture.input(External::PAUX), 0.02, "rejected paux preservation"); - success *= vectorUnchanged(fixture.hygov.y(), y_before, "state"); - success *= vectorUnchanged(fixture.hygov.yp(), yp_before, "derivative"); + if (!scalarMatches(fixture.pmech(), pmech, "rejected pmech preservation")) + { + success = false; + } + if (!scalarMatches(fixture.input(External::OMEGA), omega, "rejected omega preservation")) + { + success = false; + } + if (!scalarMatches(fixture.input(External::PREF), 77.0, "rejected pref preservation")) + { + success = false; + } + if (!scalarMatches(fixture.input(External::PAUX), 0.02, "rejected paux preservation")) + { + success = false; + } + if (!vectorUnchanged(fixture.hygov.y(), y_before, "state")) + { + success = false; + } + if (!vectorUnchanged(fixture.hygov.yp(), yp_before, "derivative")) + { + success = false; + } return success; } @@ -1188,15 +1232,24 @@ namespace GridKit { Fixture fixture(data); fixture.attachAllInputs(); - success &= fixture.initialize(pmech); + if (!fixture.initialize(pmech)) + { + success = false; + } for (const auto& [port, value] : test_case.inputs) { fixture.input(port) = static_cast(value); } setState(fixture.hygov, test_case.state); setDerivative(fixture.hygov, test_case.derivative); - success &= (fixture.evaluate() == 0); - success &= residualsMatch(fixture.hygov, test_case.expected, test_case.label); + if (fixture.evaluate() != 0) + { + success = false; + } + if (!residualsMatch(fixture.hygov, test_case.expected, test_case.label)) + { + success = false; + } } return success; } @@ -1239,8 +1292,11 @@ namespace GridKit 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 success; } @@ -1268,8 +1324,14 @@ namespace GridKit const auto* yp = hygov.yp().getData(); for (size_t row = 0; row < static_cast(hygov.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 success; } From 5c06dc77a06c4851fb28de6056e184954d2c43d2 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Mon, 3 Aug 2026 11:51:10 -0500 Subject: [PATCH 09/17] inequalities instead of zero compare --- .../Governor/HYGOV/HygovImpl.hpp | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp index f0f3c509a..7b73eb96d 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp @@ -173,7 +173,7 @@ namespace GridKit "system/component power-base conversion ratios must be finite and positive"); } - check(Rtemp_ != ZERO, "Rtemp must be nonzero"); + check(Rtemp_ < ZERO || Rtemp_ > ZERO, "Rtemp must be nonzero"); check(Tn_ >= ZERO, "Tn must be non-negative"); check(Velm_ >= ZERO, "Velm must be non-negative"); check(Gmin_ < Gmax_, "Gmin must be less than Gmax"); @@ -343,8 +343,11 @@ namespace GridKit // Synchronous machines provide an exactly zero speed deviation. A // moving machine would need a multi-root gate search, which this - // model does not support. - ret = static_cast(omega0) == ZERO; + // model does not support. The speed was verified finite above, so + // the two-sided sign test is exact. + const RealT speed0 = static_cast(omega0); + + ret = !(speed0 < ZERO) && !(speed0 > ZERO); if (!ret) { Log::error() << "Hygov: initialization requires zero speed deviation\n"; @@ -695,12 +698,13 @@ namespace GridKit load_real(Params::Pgv4, Pgv_[4], "Pgv4"); load_real(Params::Pgv5, Pgv_[5], "Pgv5"); - const bool source_default_curve = - std::all_of(Gv_.begin(), Gv_.end(), [](RealT value) - { return value == ZERO; }) - && std::all_of(Pgv_.begin(), Pgv_.end(), [](RealT value) - { return value == ZERO; }); - if (source_default_curve) + auto deviates_from_zero = [](RealT value) + { return value < ZERO || value > ZERO; }; + + const bool curve_supplied = + std::any_of(Gv_.begin(), Gv_.end(), deviates_from_zero) + || std::any_of(Pgv_.begin(), Pgv_.end(), deviates_from_zero); + if (!curve_supplied) { Gv_ = {ZERO, static_cast(0.2), From bef232d60c49fde5b3642917e3d2b3ae4a86eb9f Mon Sep 17 00:00:00 2001 From: lukelowry Date: Mon, 3 Aug 2026 11:56:52 -0500 Subject: [PATCH 10/17] clarify loop termination --- GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp index 7b73eb96d..61b48a4c8 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -898,10 +899,13 @@ namespace GridKit } // Bisect until no representable midpoint remains, then keep the - // endpoint with the smaller residual. + // endpoint with the smaller residual. Termination is guaranteed: + // std::midpoint cannot overflow and lands inside [a, b], so every + // accepted step strictly shrinks the finite set of representable + // values between the endpoints. while (true) { - const RealT mid = HALF * (a + b); + const RealT mid = std::midpoint(a, b); if (mid <= a || b <= mid) { break; From b0b4ce119316ec3c369a0574a7cd895b132bf799 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Tue, 4 Aug 2026 19:35:39 -0500 Subject: [PATCH 11/17] style consistancy --- .../Governor/HYGOV/HygovImpl.hpp | 17 +- .../ComponentConnectionTests.hpp | 51 ++++ .../PhasorDynamics/GovernorHygovTests.hpp | 266 ++++++++++++------ .../SystemSingleComponentTests.hpp | 34 ++- .../runComponentConnectionTests.cpp | 1 + tests/UnitTests/Utilities/CaseFormatTests.hpp | 63 ++++- 6 files changed, 314 insertions(+), 118 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp index 61b48a4c8..e68b8def0 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp @@ -174,7 +174,7 @@ namespace GridKit "system/component power-base conversion ratios must be finite and positive"); } - check(Rtemp_ < ZERO || Rtemp_ > ZERO, "Rtemp must be nonzero"); + check(Rtemp_ > ZERO, "Rtemp must be nonzero"); check(Tn_ >= ZERO, "Tn must be non-negative"); check(Velm_ >= ZERO, "Velm must be non-negative"); check(Gmin_ < Gmax_, "Gmin must be less than Gmax"); @@ -345,10 +345,10 @@ namespace GridKit // Synchronous machines provide an exactly zero speed deviation. A // moving machine would need a multi-root gate search, which this // model does not support. The speed was verified finite above, so - // the two-sided sign test is exact. + // this is an exact comparison by intent rather than a tolerance test. const RealT speed0 = static_cast(omega0); - ret = !(speed0 < ZERO) && !(speed0 > ZERO); + ret = speed0 == ZERO; if (!ret) { Log::error() << "Hygov: initialization requires zero speed deviation\n"; @@ -699,12 +699,15 @@ namespace GridKit load_real(Params::Pgv4, Pgv_[4], "Pgv4"); load_real(Params::Pgv5, Pgv_[5], "Pgv5"); - auto deviates_from_zero = [](RealT value) - { return value < ZERO || value > ZERO; }; + // Model data uses an all-exact-zero curve to mean "no curve supplied", + // so this is an exact comparison by intent rather than a tolerance + // test. Any nonzero point selects the given curve. + auto is_nonzero = [](RealT value) + { return value != ZERO; }; const bool curve_supplied = - std::any_of(Gv_.begin(), Gv_.end(), deviates_from_zero) - || std::any_of(Pgv_.begin(), Pgv_.end(), deviates_from_zero); + std::any_of(Gv_.begin(), Gv_.end(), is_nonzero) + || std::any_of(Pgv_.begin(), Pgv_.end(), is_nonzero); if (!curve_supplied) { Gv_ = {ZERO, diff --git a/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp b/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp index b973b830e..3b50a9f0b 100644 --- a/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ComponentConnectionTests.hpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include #include @@ -80,6 +82,55 @@ namespace GridKit return success.report(__func__); } + + /// GENROU initializes first and writes the mechanical power it needs to + /// the shared node. HYGOV then initializes around that value and must + /// leave it unchanged at a steady state. The speed port is left + /// unattached, so HYGOV reads the exactly zero deviation its + /// initialization requires. + TestOutcome genrouHygov() + { + using MachineExternal = PhasorDynamics::GenrouExternalVariables; + using GovernorInternal = PhasorDynamics::Governor::HygovInternalVariables; + using GovernorParams = PhasorDynamics::Governor::HygovParameters; + + TestStatus success = true; + + PhasorDynamics::SystemModel system; + PhasorDynamics::BusInfinite bus( + static_cast(1.0), + static_cast(0.0)); + PhasorDynamics::SignalNode pmech; + PhasorDynamics::Genrou machine(&bus); + + PhasorDynamics::Governor::HygovData governor_data; + governor_data.parameters[GovernorParams::Tnp] = static_cast(1.0); + + PhasorDynamics::Governor::Hygov governor(governor_data); + + machine.getSignals().template attachSignalNode(&pmech); + governor.getSignals().template assignSignalNode(&pmech); + + system.addBus(&bus); + system.addComponent(&machine); + system.addComponent(&governor); + + success *= system.allocate() == 0; + success *= pmech.linked(); + success *= system.initialize() == 0; + success *= system.evaluateResidual() == 0; + + // At zero machine power the required mechanical power is zero. + success *= isEqual(pmech.read(), static_cast(0.0), kTol); + + const auto* residual = governor.getResidual().getData(); + for (IdxT row = 0; row < governor.size(); ++row) + { + success *= isEqual(residual[row], static_cast(0.0), kTol); + } + + return success.report(__func__); + } }; } // namespace Testing diff --git a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp index 0e9aac143..49e4e2ed3 100644 --- a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp +++ b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp @@ -39,9 +39,6 @@ namespace GridKit GovernorHygovTests() = default; ~GovernorHygovTests() = default; - // Initialization is exact and every pinned literal is recorded at full - // precision from the implemented smooth arithmetic, so one tight - // tolerance serves the whole suite. static constexpr RealT kTol = static_cast(100.0) * std::numeric_limits::epsilon(); @@ -222,18 +219,6 @@ namespace GridKit success *= (floors.evaluate() == 0); success *= allResidualsZero(floors.hygov); - // The five governor states carry derivatives; the rest is algebraic. - success *= (floors.hygov.tagDifferentiable() == 0); - for (size_t i = 0; i < static_cast(floors.hygov.size()); ++i) - { - const bool differential = i <= static_cast(Internal::Q); - if (floors.hygov.tag()[i] != differential) - { - std::cout << "HYGOV differentiability tag " << i << " mismatch\n"; - success = false; - } - } - return success.report(__func__); } @@ -249,6 +234,7 @@ namespace GridKit fixture.input(External::PAUX) = 0.02; fixture.input(External::PREF) = 99.0; // stale value the publication must replace success *= fixture.initialize(0.4); + success *= (fixture.hygov.tagDifferentiable() == 0); success *= (fixture.evaluate() == 0); const auto* y = fixture.hygov.y().getData(); @@ -307,6 +293,17 @@ namespace GridKit success = false; } + // The five governor states carry derivatives; the rest is algebraic. + for (size_t i = 0; i < static_cast(fixture.hygov.size()); ++i) + { + const bool differential = i <= static_cast(Internal::Q); + if (fixture.hygov.tag()[i] != differential) + { + std::cout << "HYGOV differentiability tag " << i << " mismatch\n"; + success = false; + } + } + success *= allResidualsZero(fixture.hygov); // A system-base reference step lands on the governor error scaled by @@ -358,6 +355,7 @@ namespace GridKit {{Params::Gmin, test_case.gmin}, {Params::Gmax, test_case.gmax}}), test_case.pmech, + {{External::OMEGA, 0.0}, {External::PREF, 77.0}, {External::PAUX, 0.02}}, test_case.label); } @@ -365,8 +363,10 @@ namespace GridKit // machine would need a multi-root gate search. success *= initializationRejectedAtomically(makeResidualData(), 0.4, - "nonzero initial speed deviation", - 0.03); + {{External::OMEGA, 0.03}, + {External::PREF, 77.0}, + {External::PAUX, 0.02}}, + "nonzero initial speed deviation"); // An invalid configuration is rejected before any state is written. Fixture invalid_fixture(makeResidualData(), {{Params::Rtemp, 0.0}}); @@ -432,33 +432,53 @@ namespace GridKit success *= initializationRejectedAtomically( makeData(), p_max + 2.0 * kTol, + {{External::OMEGA, 0.0}, {External::PREF, 77.0}, {External::PAUX, 0.02}}, "twice the tolerance beyond the achievable maximum"); success *= initializationRejectedAtomically( makeData(), p_min - 2.0 * kTol, + {{External::OMEGA, 0.0}, {External::PREF, 77.0}, {External::PAUX, 0.02}}, "twice the tolerance below the achievable minimum"); const RealT nan = std::numeric_limits::quiet_NaN(); const RealT infinity = std::numeric_limits::infinity(); + // A non-finite input is rejected atomically, NaN included: the + // exact-preservation check states what a tolerance comparison of a + // NaN input never could. for (const RealT value : std::array{{nan, infinity, -infinity}}) { + success *= initializationRejectedAtomically(makeData(), + 0.4, + {{External::OMEGA, value}, + {External::PREF, 77.0}, + {External::PAUX, 0.02}}, + "non-finite speed input"); + success *= initializationRejectedAtomically(makeData(), + 0.4, + {{External::OMEGA, 0.0}, + {External::PREF, 77.0}, + {External::PAUX, value}}, + "non-finite auxiliary-power input"); + + // A non-finite seed lands in the aliased pmech state itself, so the + // poisoned-state comparison cannot express its preservation. The + // inputs still must survive untouched. Fixture pmech_fixture(makeData()); pmech_fixture.attachAllInputs(); - success *= pmech_fixture.prepare(value); - success *= (pmech_fixture.hygov.initialize() != 0); - - Fixture omega_fixture(makeData()); - omega_fixture.attachAllInputs(); - success *= omega_fixture.prepare(0.4); - omega_fixture.input(External::OMEGA) = value; - success *= (omega_fixture.hygov.initialize() != 0); - - Fixture paux_fixture(makeData()); - paux_fixture.attachAllInputs(); - success *= paux_fixture.prepare(0.4); - paux_fixture.input(External::PAUX) = value; - success *= (paux_fixture.hygov.initialize() != 0); + pmech_fixture.input(External::PREF) = 77.0; + pmech_fixture.input(External::PAUX) = 0.02; + success *= pmech_fixture.prepare(value); + success *= (pmech_fixture.hygov.initialize() != 0); + success *= scalarPreserved( + static_cast(pmech_fixture.input(External::PREF)), + 77.0, + "external input", + static_cast(External::PREF)); + success *= scalarPreserved(static_cast(pmech_fixture.input(External::PAUX)), + 0.02, + "external input", + static_cast(External::PAUX)); } return success.report(__func__); @@ -515,22 +535,23 @@ namespace GridKit // Values are pinned after an independent one-time evaluation of the // documented equations at setAnswerKeyState()/setAnswerKeyInputs(). - success *= (static_cast(fixture.hygov.getResidual().getSize()) - == static_cast(Internal::MAXIMUM)); - success *= residualsMatch(fixture.hygov, - {{Internal::XN, -0.07785714285714286}, - {Internal::XF, -0.7300000000000001}, - {Internal::C, 0.06}, - {Internal::G, 0.1233333333333334}, - {Internal::Q, 0.011538461538461414}, - {Internal::OMEGADB, 0.0033514666467982894}, - {Internal::EF, 0.5863}, - {Internal::FC, -0.7405000000000002}, - {Internal::RC, 0.029996890386450745}, - {Internal::PGV, -0.04600000003160343}, - {Internal::H, -0.033299999999999885}, - {Internal::PMECH, -0.012679999999999934}}, - "answer key"); + const std::array(Internal::MAXIMUM)> expected{{ + {Internal::XN, -0.07785714285714286}, + {Internal::XF, -0.7300000000000001}, + {Internal::C, 0.06}, + {Internal::G, 0.1233333333333334}, + {Internal::Q, 0.011538461538461414}, + {Internal::OMEGADB, 0.0033514666467982894}, + {Internal::EF, 0.5863}, + {Internal::FC, -0.7405000000000002}, + {Internal::RC, 0.029996890386450745}, + {Internal::PGV, -0.04600000003160343}, + {Internal::H, -0.033299999999999885}, + {Internal::PMECH, -0.012679999999999934}, + }}; + + success *= (static_cast(fixture.hygov.getResidual().getSize()) == expected.size()); + success *= residualsMatch(fixture.hygov, expected, "answer key"); return success.report(__func__); } @@ -582,8 +603,8 @@ namespace GridKit {}, {{Internal::RC, 0.15000000000000002}}}}); - // The desired-gate anti-windup at three controller directions: both - // saturations block an outward rate and Gmax admits a restoring one. + // The desired-gate anti-windup at all four controller directions: + // both saturations block an outward rate and admit a restoring one. success *= runResidualCases( makeResidualData(), 0.4, @@ -601,7 +622,37 @@ namespace GridKit {}, {{Internal::C, 1.2}, {Internal::RC, -0.2}}, {{Internal::C, 0.0}}, - {{Internal::C, -0.2}}}}); + {{Internal::C, -0.2}}}, + {"Gmin admits a restoring desired-gate rate", + {}, + {{Internal::C, -0.2}, {Internal::RC, 0.2}}, + {{Internal::C, 0.0}}, + {{Internal::C, 0.2}}}}); + + // At a blocked gate limit, pin the assembled alpha = 1 desired-gate + // row independently of either Jacobian backend. The row is + // -c_dot + antiwindup(c, rc, Gmin, Gmax): the derivative contributes + // -1, and the closed gate leaves the rate with no influence, so a + // leaking anti-windup would show up as a nonzero RC entry. + { + using DepVar = DependencyTracking::Variable; + + Fixture blocked(makeResidualData()); + blocked.attachAllInputs(); + success *= blocked.initialize(0.4); + setState(blocked.hygov, {{Internal::C, 1.2}, {Internal::RC, 0.2}}); + setDerivative(blocked.hygov, {{Internal::C, 0.0}}); + numberVariables(blocked); + success *= (blocked.evaluate() == 0); + + const auto& dependencies = + blocked.hygov.getResidual().getData()[static_cast(Internal::C)].getDependencies(); + const DepVar::DependencyMap expected{{ + {static_cast(Internal::C), -1.0}, + {static_cast(Internal::RC), 0.0}, + }}; + success *= isEqual(dependencies, expected, kTol); + } return success.report(__func__); } @@ -928,6 +979,8 @@ namespace GridKit Data makeData() const { + // The documented typical values with the floored time constants + // raised above the floor, so routine fixtures log no warnings. return withParameters(makeMinimalData(), {{Params::Trate, 100.0}, {Params::Rperm, 0.05}, @@ -1122,6 +1175,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 << "HYGOV " << what << " row " << row + << " changed mismatch: " << actual << " != " << expected << "\n"; + } + return ret; + } + /// Fill the state and derivative with a recognizable ramp, then restore /// the aliased pmech entry, so any write by a rejected initialization /// is visible. @@ -1139,18 +1212,19 @@ namespace GridKit fixture.hygov.yp().setDataUpdated(); } - /// Initialization must fail and leave the poisoned state, the pmech - /// value, and every attached input untouched. - bool initializationRejectedAtomically(const Data& data, - RealT pmech, - const char* label, - RealT omega = 0.0) const + /// Initialization must fail and leave the poisoned state, the seeded + /// pmech value, and every supplied input untouched. + bool initializationRejectedAtomically(const Data& data, + RealT pmech, + const ExternalRows& inputs, + const char* label) const { Fixture fixture(data); fixture.attachAllInputs(); - fixture.input(External::OMEGA) = static_cast(omega); - fixture.input(External::PAUX) = 0.02; - fixture.input(External::PREF) = 77.0; // must stay untouched on rejection + for (const auto& [port, value] : inputs) + { + fixture.input(port) = static_cast(value); + } if (!fixture.prepare(pmech)) { return false; @@ -1171,17 +1245,15 @@ namespace GridKit { success = false; } - if (!scalarMatches(fixture.input(External::OMEGA), omega, "rejected omega preservation")) + for (const auto& [port, value] : inputs) { - success = false; - } - if (!scalarMatches(fixture.input(External::PREF), 77.0, "rejected pref preservation")) - { - success = false; - } - if (!scalarMatches(fixture.input(External::PAUX), 0.02, "rejected paux preservation")) - { - success = false; + if (!scalarPreserved(static_cast(fixture.input(port)), + value, + "external input", + static_cast(port))) + { + success = false; + } } if (!vectorUnchanged(fixture.hygov.y(), y_before, "state")) { @@ -1282,11 +1354,11 @@ namespace GridKit } /// Check selected rows of a model vector against expected values. - template - bool rowsMatch(const VectorT& vector, - const InternalRows& rows, - const char* what, - const char* context) const + template + bool rowsMatch(const VectorT& vector, + const RowsT& rows, + const char* what, + const char* context) const { bool success = true; const auto* values = vector.getData(); @@ -1308,6 +1380,14 @@ namespace GridKit return rowsMatch(hygov.getResidual(), rows, "residual", context); } + template + bool residualsMatch(const HygovT& hygov, + const std::array& rows, + const char* context = "") const + { + return rowsMatch(hygov.getResidual(), rows, "residual", context); + } + bool stateMatches(const HygovT& hygov, const InternalRows& rows, const char* context = "") const @@ -1358,25 +1438,6 @@ namespace GridKit Log::setVerbosity(previous_verbosity); } -#ifdef GRIDKIT_ENABLE_ENZYME - /// The row's dependency map must contain the column entry. - template - bool jacobianContains(const JacobianRowsT& rows, - Internal row_variable, - Internal column_variable, - const char* what) const - { - const auto row = static_cast(row_variable); - const auto column = static_cast(column_variable); - if (row < rows.size() && rows[row].count(column) == 1) - { - return true; - } - std::cout << "HYGOV " << what << " Jacobian row " << row - << " is missing column " << column << "\n"; - return false; - } - void numberVariables(Fixture& fixture) const { auto* y = fixture.hygov.y().getData(); @@ -1397,6 +1458,25 @@ namespace GridKit fixture.hygov.yp().setDataUpdated(); } +#ifdef GRIDKIT_ENABLE_ENZYME + /// The row's dependency map must contain the column entry. + template + bool jacobianContains(const JacobianRowsT& rows, + Internal row_variable, + Internal column_variable, + const char* what) const + { + const auto row = static_cast(row_variable); + const auto column = static_cast(column_variable); + if (row < rows.size() && rows[row].count(column) == 1) + { + return true; + } + std::cout << "HYGOV " << what << " Jacobian row " << row + << " is missing column " << column << "\n"; + return false; + } + std::vector dependencyTrackingJacobian( const Data& data, RealT gate, diff --git a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp index e4f8e351d..de98a440f 100644 --- a/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp +++ b/tests/UnitTests/PhasorDynamics/SystemSingleComponentTests.hpp @@ -326,20 +326,28 @@ namespace GridKit /// zero mechanical power, an admissible operating point. TestOutcome hygov() { + using Data = PhasorDynamics::Governor::HygovData; + using Outputs = typename Data::SignalOutputs; + using Params = typename Data::Parameters; + using Vars = PhasorDynamics::Governor::HygovInternalVariables; + + constexpr IdxT pmech_id = static_cast(2); + TestStatus success = true; PhasorDynamics::SystemModelData data; data.freq_base = 60.0; data.va_base = 100.0e6; data.signal.resize(1); - data.signal[0].signal_id = 2; + data.signal[0].signal_id = pmech_id; data.signal[0].name = "Mechanical Power"; - typename PhasorDynamics::SystemModelData::HygovDataT hygov_data; - hygov_data.device_class = "Hygov"; - hygov_data.disambiguation_string = "hygov_system"; - hygov_data.parameters[PhasorDynamics::Governor::HygovParameters::Trate] = 100.0; - hygov_data.signal_outputs[PhasorDynamics::Governor::HygovSignalOutputs::pmech] = 2; + Data hygov_data; + hygov_data.device_class = "Hygov"; + hygov_data.disambiguation_string = "hygov_system"; + hygov_data.parameters[Params::Trate] = static_cast(100.0); + hygov_data.parameters[Params::Tnp] = static_cast(1.0); + hygov_data.signal_outputs[Outputs::pmech] = pmech_id; data.hygov.push_back(hygov_data); PhasorDynamics::SystemModel system(data); @@ -349,8 +357,18 @@ namespace GridKit success *= system.tagDifferentiable() == 0; success *= system.evaluateResidual() == 0; success *= system.evaluateJacobian() == 0; - success *= system.size() - == static_cast(PhasorDynamics::Governor::HygovInternalVariables::MAXIMUM); + success *= system.size() == static_cast(Vars::MAXIMUM); + + auto* pmech = system.getSignal(pmech_id); + success *= pmech->linked(); + success *= pmech->getVariableIndex() == static_cast(Vars::PMECH); + + auto missing_output_data = data; + missing_output_data.hygov[0].signal_outputs.clear(); + + PhasorDynamics::SystemModel missing_output_system(missing_output_data); + std::cout << "Testing expected HYGOV missing-output configuration error.\n"; + success *= missing_output_system.verify() > 0; return success.report(__func__); } diff --git a/tests/UnitTests/PhasorDynamics/runComponentConnectionTests.cpp b/tests/UnitTests/PhasorDynamics/runComponentConnectionTests.cpp index b9e9253d1..127b5107b 100644 --- a/tests/UnitTests/PhasorDynamics/runComponentConnectionTests.cpp +++ b/tests/UnitTests/PhasorDynamics/runComponentConnectionTests.cpp @@ -8,6 +8,7 @@ int main() GridKit::Testing::ComponentConnectionTests test; result += test.genrouEsdc1a(); + result += test.genrouHygov(); return result.summary(); } diff --git a/tests/UnitTests/Utilities/CaseFormatTests.hpp b/tests/UnitTests/Utilities/CaseFormatTests.hpp index b5c8dcb6b..8416d2e1b 100644 --- a/tests/UnitTests/Utilities/CaseFormatTests.hpp +++ b/tests/UnitTests/Utilities/CaseFormatTests.hpp @@ -190,6 +190,7 @@ namespace GridKit using BusData = BusData; using BusType = typename BusData::BusType; using Esdc1aData = Exciter::Esdc1aData; + using HygovData = Governor::HygovData; const char data[] = R"({ @@ -215,14 +216,19 @@ namespace GridKit { "signal_id": 4, "name": "Voltage Reference"}, { "signal_id": 5, "name": "Stabilizer Signal"}, { "signal_id": 6, "name": "Under-excitation Limiter"}, - { "signal_id": 7, "name": "Hydro Mechanical Power"} + { "signal_id": 7, "name": "Hydro Mechanical Power"}, + { "signal_id": 8, "name": "Governor Load Reference"}, + { "signal_id": 9, "name": "Governor Auxiliary Power"} ], "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": {"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": "Hygov", "ports": {"speed": 1, "pmech": 7}, "id": "DV6", "params": {"Trate": 80.0, "Rperm": 0.05, "Rtemp": 0.35, "Tw": 1.2, "Qnl": 0.08}}, + { "class": "Hygov", "ports": {"speed": 1, "pmech": 7, "pref": 8, "paux": 9}, "id": "DV6", "params": {"Trate": 80.0, "Rperm": 0.05, "Rtemp": 0.35, "Tr": 5.0, "Tf": 0.05, "Tg": 0.5, + "Velm": 0.2, "Gmax": 0.98, "Gmin": 0.02, "Tw": 1.2, "At": 1.1, "Dturb": 0.4, "Qnl": 0.08, "Tn": 0.7, "Tnp": 1.4, "db1": 0.01, "db2": 0.02, "Hdam": 1.05, + "Gv0": 0.0, "Gv1": 0.2, "Gv2": 0.4, "Gv3": 0.6, "Gv4": 0.8, "Gv5": 1.0, + "Pgv0": 0.0, "Pgv1": 0.15, "Pgv2": 0.42, "Pgv3": 0.66, "Pgv4": 0.85, "Pgv5": 1.0}, "mon": ["pmech", "filter", "desiredgate", "gate", "flow", "head"]}, { "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} } @@ -282,6 +288,10 @@ namespace GridKit success *= result.signal[5].name == "Under-excitation Limiter"; success *= result.signal[6].signal_id == 7; success *= result.signal[6].name == "Hydro Mechanical Power"; + success *= result.signal[7].signal_id == 8; + success *= result.signal[7].name == "Governor Load Reference"; + success *= result.signal[8].signal_id == 9; + success *= result.signal[8].name == "Governor Auxiliary Power"; success *= std::get(result.branch[0].parameters[BranchParameters::R]) == 0.0; success *= std::get(result.branch[0].parameters[BranchParameters::X]) == 0.1; @@ -363,15 +373,48 @@ namespace GridKit success *= result.esdc1a[0].monitored_variables.contains(Esdc1aData::MonitorableVariables::se); success *= result.esdc1a[0].monitored_variables.contains(Esdc1aData::MonitorableVariables::vfe); - success *= std::get(result.hygov[0].parameters[Governor::HygovParameters::Trate]) == 80.0; - success *= std::get(result.hygov[0].parameters[Governor::HygovParameters::Rperm]) == 0.05; - success *= std::get(result.hygov[0].parameters[Governor::HygovParameters::Rtemp]) == 0.35; - success *= std::get(result.hygov[0].parameters[Governor::HygovParameters::Tw]) == 1.2; - success *= std::get(result.hygov[0].parameters[Governor::HygovParameters::Qnl]) == 0.08; - success *= result.hygov[0].signal_inputs[Governor::HygovSignalInputs::speed] == 1; - success *= result.hygov[0].signal_outputs[Governor::HygovSignalOutputs::pmech] == 7; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Trate]) == 80.0; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Rperm]) == 0.05; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Rtemp]) == 0.35; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Tr]) == 5.0; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Tf]) == 0.05; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Tg]) == 0.5; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Velm]) == 0.2; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Gmax]) == 0.98; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Gmin]) == 0.02; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Tw]) == 1.2; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::At]) == 1.1; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Dturb]) == 0.4; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Qnl]) == 0.08; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Tn]) == 0.7; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Tnp]) == 1.4; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::db1]) == 0.01; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::db2]) == 0.02; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Hdam]) == 1.05; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Gv0]) == 0.0; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Gv1]) == 0.2; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Gv2]) == 0.4; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Gv3]) == 0.6; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Gv4]) == 0.8; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Gv5]) == 1.0; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Pgv0]) == 0.0; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Pgv1]) == 0.15; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Pgv2]) == 0.42; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Pgv3]) == 0.66; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Pgv4]) == 0.85; + success *= std::get(result.hygov[0].parameters[HygovData::Parameters::Pgv5]) == 1.0; + success *= result.hygov[0].signal_inputs[HygovData::SignalInputs::speed] == 1; + success *= result.hygov[0].signal_inputs[HygovData::SignalInputs::pref] == 8; + success *= result.hygov[0].signal_inputs[HygovData::SignalInputs::paux] == 9; + success *= result.hygov[0].signal_outputs[HygovData::SignalOutputs::pmech] == 7; success *= result.hygov[0].disambiguation_string == "DV6"; - success *= result.hygov[0].monitored_variables.empty(); + success *= result.hygov[0].monitored_variables.contains(HygovData::MonitorableVariables::pmech); + success *= result.hygov[0].monitored_variables.contains(HygovData::MonitorableVariables::filter); + success *= result.hygov[0].monitored_variables.contains( + HygovData::MonitorableVariables::desiredgate); + success *= result.hygov[0].monitored_variables.contains(HygovData::MonitorableVariables::gate); + success *= result.hygov[0].monitored_variables.contains(HygovData::MonitorableVariables::flow); + success *= result.hygov[0].monitored_variables.contains(HygovData::MonitorableVariables::head); success *= std::get(result.exciter[0].parameters[Exciter::Ieeet1Parameters::Tr]) == 0.0; success *= std::get(result.exciter[0].parameters[Exciter::Ieeet1Parameters::Ka]) == 50.0; From 270e3c72f560388cad2b382bc684644a2619a561 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Wed, 5 Aug 2026 12:42:33 -0500 Subject: [PATCH 12/17] address toelarnace issue poorly defined test --- .../PhasorDynamics/GovernorHygovTests.hpp | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp index 49e4e2ed3..8500efdae 100644 --- a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp +++ b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp @@ -727,14 +727,27 @@ namespace GridKit success *= (curve_fixture.evaluate() == 0); success *= allResidualsZero(curve_fixture.hygov); - // A value on a flat-segment power plateau still initializes exactly: - // the smoothing tails of the neighboring rising segments keep the - // smooth curve strictly increasing across the plateau. - Fixture flat_fixture(makeResidualData(), {{Params::Pgv3, 0.42}}); - success *= flat_fixture.initialize(0.250857385880864); - success *= scalarMatches(flat_fixture.hygov.y().getData()[static_cast(Internal::G)], - 0.4990297247065128, - "flat-segment plateau gate"); + // A flat source-curve segment must initialize to a gate on that segment. + // makeData() uses equal power bases, At = Hdam = 1, and Qnl = 0.1, + // so a 0.5 plateau maps to pmech = 0.4 without encoding Math::MU. + const RealT flat_gate_minimum = static_cast(0.4); + const RealT flat_gate_maximum = static_cast(0.6); + const RealT plateau_power = static_cast(0.5); + const RealT plateau_pmech = static_cast(0.4); + Fixture flat_fixture(makeData(), + {{Params::Pgv2, plateau_power}, + {Params::Pgv3, plateau_power}}); + success *= flat_fixture.initialize(plateau_pmech); + const RealT flat_gate = + flat_fixture.hygov.y().getData()[static_cast(Internal::G)]; + if (flat_gate < flat_gate_minimum || flat_gate > flat_gate_maximum) + { + std::cout << "flat-segment plateau gate " + << std::setprecision(std::numeric_limits::max_digits10) + << flat_gate << " is outside [" << flat_gate_minimum + << ", " << flat_gate_maximum << "]\n"; + success = false; + } success *= (flat_fixture.evaluate() == 0); success *= allResidualsZero(flat_fixture.hygov); From 37ef8a7bc877778d69c5dd37634a34abd6042439 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Wed, 5 Aug 2026 12:47:47 -0500 Subject: [PATCH 13/17] Added kTol to test comparison --- tests/UnitTests/PhasorDynamics/ConverterRegcaTests.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/UnitTests/PhasorDynamics/ConverterRegcaTests.hpp b/tests/UnitTests/PhasorDynamics/ConverterRegcaTests.hpp index a42731f1b..427648e1d 100644 --- a/tests/UnitTests/PhasorDynamics/ConverterRegcaTests.hpp +++ b/tests/UnitTests/PhasorDynamics/ConverterRegcaTests.hpp @@ -1001,7 +1001,7 @@ namespace GridKit bool scalarMatches(ScalarT actual, ScalarT expected, const char* label) const { - if (isEqual(actual, expected)) + if (isEqual(actual, expected, kTol)) { return true; } From 44c8f00e04dba22b5ae5a88cb6399612adabf581 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Wed, 5 Aug 2026 12:49:56 -0500 Subject: [PATCH 14/17] cleaner comment --- GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp index e68b8def0..7c42f7bcd 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp @@ -902,13 +902,13 @@ namespace GridKit } // Bisect until no representable midpoint remains, then keep the - // endpoint with the smaller residual. Termination is guaranteed: - // std::midpoint cannot overflow and lands inside [a, b], so every - // accepted step strictly shrinks the finite set of representable - // values between the endpoints. + // endpoint with the smaller residual. Termination is guaranteed. while (true) { const RealT mid = std::midpoint(a, b); + + // Once the midpoint rounds to an endpoint, the interval cannot be + // reduced any further. if (mid <= a || b <= mid) { break; From 7ebf2e1f91d7eaafd15dc063a5d4e750cf02d06b Mon Sep 17 00:00:00 2001 From: lukelowry Date: Wed, 5 Aug 2026 13:11:39 -0500 Subject: [PATCH 15/17] containers and arrays --- .../PhasorDynamics/GovernorHygovTests.hpp | 474 +++++++++--------- 1 file changed, 234 insertions(+), 240 deletions(-) diff --git a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp index 8500efdae..4ec60d033 100644 --- a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp +++ b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp @@ -71,40 +71,43 @@ namespace GridKit const RealT nan = std::numeric_limits::quiet_NaN(); const RealT infinity = std::numeric_limits::infinity(); - for (const Params parameter : std::array{{ - Params::Trate, - Params::Rperm, - Params::Rtemp, - Params::Tr, - Params::Tf, - Params::Tg, - Params::Velm, - Params::Gmax, - Params::Gmin, - Params::Tw, - Params::At, - Params::Dturb, - Params::Qnl, - Params::Tn, - Params::Tnp, - Params::db1, - Params::db2, - Params::Hdam, - Params::Gv0, - Params::Gv1, - Params::Gv2, - Params::Gv3, - Params::Gv4, - Params::Gv5, - Params::Pgv0, - Params::Pgv1, - Params::Pgv2, - Params::Pgv3, - Params::Pgv4, - Params::Pgv5, - }}) + const std::array real_parameters{{ + Params::Trate, + Params::Rperm, + Params::Rtemp, + Params::Tr, + Params::Tf, + Params::Tg, + Params::Velm, + Params::Gmax, + Params::Gmin, + Params::Tw, + Params::At, + Params::Dturb, + Params::Qnl, + Params::Tn, + Params::Tnp, + Params::db1, + Params::db2, + Params::Hdam, + Params::Gv0, + Params::Gv1, + Params::Gv2, + Params::Gv3, + Params::Gv4, + Params::Gv5, + Params::Pgv0, + Params::Pgv1, + Params::Pgv2, + Params::Pgv3, + Params::Pgv4, + Params::Pgv5, + }}; + const std::array nonfinite_values{{nan, infinity, -infinity}}; + + for (const Params parameter : real_parameters) { - for (const RealT value : std::array{{nan, infinity, -infinity}}) + for (const RealT value : nonfinite_values) { Fixture invalid_fixture(makeData(), {{parameter, value}}); success *= (invalid_fixture.hygov.verify() > 0); @@ -116,29 +119,31 @@ namespace GridKit PhasorDynamics::Governor::Hygov unassigned(makeData()); success *= (unassigned.verify() > 0); - for (const auto& invalid : std::array, 19>{{ - {Params::Trate, 0.0}, - {Params::Trate, -1.0}, - {Params::Rtemp, 0.0}, - {Params::Tr, -0.1}, - {Params::Tf, -0.1}, - {Params::Tg, -0.1}, - {Params::Tw, -0.1}, - {Params::Tn, -0.1}, - {Params::Tnp, -0.1}, - {Params::Velm, -0.1}, - {Params::Gmin, 1.1}, - {Params::At, 0.0}, - {Params::Dturb, -0.1}, - {Params::db1, -0.1}, - {Params::Hdam, 0.0}, - {Params::Gv2, 0.1}, - {Params::Pgv2, 0.1}, - {Params::Gmin, -0.05}, - {Params::Gmax, 1.05}, - }}) + const std::array, 19> invalid_parameter_values{{ + {Params::Trate, 0.0}, + {Params::Trate, -1.0}, + {Params::Rtemp, 0.0}, + {Params::Tr, -0.1}, + {Params::Tf, -0.1}, + {Params::Tg, -0.1}, + {Params::Tw, -0.1}, + {Params::Tn, -0.1}, + {Params::Tnp, -0.1}, + {Params::Velm, -0.1}, + {Params::Gmin, 1.1}, + {Params::At, 0.0}, + {Params::Dturb, -0.1}, + {Params::db1, -0.1}, + {Params::Hdam, 0.0}, + {Params::Gv2, 0.1}, + {Params::Pgv2, 0.1}, + {Params::Gmin, -0.05}, + {Params::Gmax, 1.05}, + }}; + + for (const auto& [parameter, value] : invalid_parameter_values) { - Fixture invalid_fixture(makeData(), {{invalid.first, invalid.second}}); + Fixture invalid_fixture(makeData(), {{parameter, value}}); success *= (invalid_fixture.hygov.verify() > 0); } @@ -190,14 +195,16 @@ namespace GridKit {{Params::Trate, std::numeric_limits::min()}}); success *= (overflowing_base_ratio.hygov.verify() > 0); - for (const RealT system_base : std::array{{ - 0.0, - -1.0, - nan, - infinity, - -infinity, - std::numeric_limits::min(), - }}) + const std::array invalid_system_bases{{ + 0.0, + -1.0, + nan, + infinity, + -infinity, + std::numeric_limits::min(), + }}; + + for (const RealT system_base : invalid_system_bases) { Fixture invalid_base(makeData(), {}, system_base); success *= (invalid_base.hygov.verify() > 0); @@ -256,10 +263,7 @@ namespace GridKit success *= scalarMatches(fixture.input(External::PREF), 0.0025, "published pref"); success *= scalarMatches(fixture.input(External::PAUX), 0.02, "preserved paux input"); - // The monitor must expose the six documented quantities bound to - // the initialized states. The controller is the monitor's only - // public read surface; its formats are covered by infrastructure - // tests. + // Verify the six documented outputs through the public monitor controller. RealT time = 0.0; Model::VariableMonitorController monitor(time); monitor.addMonitor(fixture.hygov.getMonitor()); @@ -343,12 +347,14 @@ namespace GridKit RealT gmax; }; - for (const auto& test_case : std::array{{ - {"mechanical power above the gate curve", 1.0, 0.05, 0.95}, - {"mechanical power below the gate curve", -0.3, 0.05, 0.95}, - {"mechanical power above the Gmax limit", 0.4, 0.05, 0.5}, - {"mechanical power below the Gmin limit", 0.4, 0.6, 0.95}, - }}) + const std::array rejection_cases{{ + {"mechanical power above the gate curve", 1.0, 0.05, 0.95}, + {"mechanical power below the gate curve", -0.3, 0.05, 0.95}, + {"mechanical power above the Gmax limit", 0.4, 0.05, 0.5}, + {"mechanical power below the Gmin limit", 0.4, 0.6, 0.95}, + }}; + + for (const auto& test_case : rejection_cases) { success *= initializationRejectedAtomically( withParameters(makeResidualData(), @@ -410,14 +416,16 @@ namespace GridKit RealT gate; }; - for (const auto& clipped : std::array{{ - {"half the tolerance beyond the achievable maximum", - p_max + 0.5 * kTol, - 1.0}, - {"half the tolerance below the achievable minimum", - p_min - 0.5 * kTol, - 0.0}, - }}) + const std::array boundary_cases{{ + {"half the tolerance beyond the achievable maximum", + p_max + 0.5 * kTol, + 1.0}, + {"half the tolerance below the achievable minimum", + p_min - 0.5 * kTol, + 0.0}, + }}; + + for (const auto& clipped : boundary_cases) { Fixture fixture(makeData()); success *= fixture.initialize(clipped.pmech); @@ -446,7 +454,9 @@ namespace GridKit // A non-finite input is rejected atomically, NaN included: the // exact-preservation check states what a tolerance comparison of a // NaN input never could. - for (const RealT value : std::array{{nan, infinity, -infinity}}) + const std::array nonfinite_inputs{{nan, infinity, -infinity}}; + + for (const RealT value : nonfinite_inputs) { success *= initializationRejectedAtomically(makeData(), 0.4, @@ -502,13 +512,15 @@ namespace GridKit RealT gate; }; - for (const auto& seed : std::array{{ - {"gate inside the Gv1 knee", 0.0556, 0.1982318164100278}, - {"gate inside the Gv2 knee", 0.2509, 0.4003865335541374}, - {"gate mid-segment", 0.4, 0.5719050089028755}, - {"gate inside the Gv3 knee", 0.4244, 0.6007061471851347}, - {"gate inside the Gv4 knee", 0.5617, 0.8006094230811988}, - }}) + const std::array exactness_cases{{ + {"gate inside the Gv1 knee", 0.0556, 0.1982318164100278}, + {"gate inside the Gv2 knee", 0.2509, 0.4003865335541374}, + {"gate mid-segment", 0.4, 0.5719050089028755}, + {"gate inside the Gv3 knee", 0.4244, 0.6007061471851347}, + {"gate inside the Gv4 knee", 0.5617, 0.8006094230811988}, + }}; + + for (const auto& seed : exactness_cases) { Fixture fixture(makeResidualData()); success *= fixture.initialize(seed.pmech); @@ -533,8 +545,6 @@ namespace GridKit setAnswerKeyState(fixture.hygov); success *= (fixture.evaluate() == 0); - // Values are pinned after an independent one-time evaluation of the - // documented equations at setAnswerKeyState()/setAnswerKeyInputs(). const std::array(Internal::MAXIMUM)> expected{{ {Internal::XN, -0.07785714285714286}, {Internal::XF, -0.7300000000000001}, @@ -561,83 +571,77 @@ namespace GridKit TestOutcome governorControl() { TestStatus success = true; + const auto data = makeResidualData(); + + // Exercise both sides and the interior of the type-1 +/-0.01 deadband. + const std::array deadband_cases{{ + {"speed deadband below the band", + {{External::OMEGA, -0.05}}, + {{Internal::OMEGADB, 0.0}}, + {}, + {{Internal::OMEGADB, -0.049996641662021946}}}, + {"speed deadband inside the band", + {{External::OMEGA, 0.004}}, + {{Internal::OMEGADB, 0.0}}, + {}, + {{Internal::OMEGADB, 0.0009004582873718001}}}, + {"speed deadband above the band", + {{External::OMEGA, 0.05}}, + {{Internal::OMEGADB, 0.0}}, + {}, + {{Internal::OMEGADB, 0.049996641662021946}}}, + }}; + success *= runResidualCases(data, 0.4, deadband_cases); + + const std::array gate_velocity_cases{{ + {"gate velocity below the rate limit", + {}, + {{Internal::FC, -0.6}, {Internal::RC, 0.0}}, + {}, + {{Internal::RC, -0.15}}}, + {"gate velocity inside the rate limit", + {}, + {{Internal::FC, 0.05}, {Internal::RC, 0.0}}, + {}, + {{Internal::RC, 0.04999999999984272}}}, + {"gate velocity above the rate limit", + {}, + {{Internal::FC, 0.6}, {Internal::RC, 0.0}}, + {}, + {{Internal::RC, 0.15000000000000002}}}, + }}; + success *= runResidualCases(data, 0.4, gate_velocity_cases); + + const std::array gate_antiwindup_cases{{ + {"Gmax blocks an outward desired-gate rate", + {}, + {{Internal::C, 1.2}, {Internal::RC, 0.2}}, + {{Internal::C, 0.0}}, + {{Internal::C, 0.0}}}, + {"Gmin blocks an outward desired-gate rate", + {}, + {{Internal::C, -0.2}, {Internal::RC, -0.2}}, + {{Internal::C, 0.0}}, + {{Internal::C, 0.0}}}, + {"Gmax admits a restoring desired-gate rate", + {}, + {{Internal::C, 1.2}, {Internal::RC, -0.2}}, + {{Internal::C, 0.0}}, + {{Internal::C, -0.2}}}, + {"Gmin admits a restoring desired-gate rate", + {}, + {{Internal::C, -0.2}, {Internal::RC, 0.2}}, + {{Internal::C, 0.0}}, + {{Internal::C, 0.2}}}, + }}; + success *= runResidualCases(data, 0.4, gate_antiwindup_cases); - // The type-1 deadband below, inside, and above the +-0.01 band. - success *= runResidualCases( - makeResidualData(), - 0.4, - {{"speed deadband below the band", - {{External::OMEGA, -0.05}}, - {{Internal::OMEGADB, 0.0}}, - {}, - {{Internal::OMEGADB, -0.049996641662021946}}}, - {"speed deadband inside the band", - {{External::OMEGA, 0.004}}, - {{Internal::OMEGADB, 0.0}}, - {}, - {{Internal::OMEGADB, 0.0009004582873718001}}}, - {"speed deadband above the band", - {{External::OMEGA, 0.05}}, - {{Internal::OMEGADB, 0.0}}, - {}, - {{Internal::OMEGADB, 0.049996641662021946}}}}); - - // The desired-gate velocity target driven below, inside, and above - // the +-Velm rate limit. - success *= runResidualCases( - makeResidualData(), - 0.4, - {{"gate velocity below the rate limit", - {}, - {{Internal::FC, -0.6}, {Internal::RC, 0.0}}, - {}, - {{Internal::RC, -0.15}}}, - {"gate velocity inside the rate limit", - {}, - {{Internal::FC, 0.05}, {Internal::RC, 0.0}}, - {}, - {{Internal::RC, 0.04999999999984272}}}, - {"gate velocity above the rate limit", - {}, - {{Internal::FC, 0.6}, {Internal::RC, 0.0}}, - {}, - {{Internal::RC, 0.15000000000000002}}}}); - - // The desired-gate anti-windup at all four controller directions: - // both saturations block an outward rate and admit a restoring one. - success *= runResidualCases( - makeResidualData(), - 0.4, - {{"Gmax blocks an outward desired-gate rate", - {}, - {{Internal::C, 1.2}, {Internal::RC, 0.2}}, - {{Internal::C, 0.0}}, - {{Internal::C, 0.0}}}, - {"Gmin blocks an outward desired-gate rate", - {}, - {{Internal::C, -0.2}, {Internal::RC, -0.2}}, - {{Internal::C, 0.0}}, - {{Internal::C, 0.0}}}, - {"Gmax admits a restoring desired-gate rate", - {}, - {{Internal::C, 1.2}, {Internal::RC, -0.2}}, - {{Internal::C, 0.0}}, - {{Internal::C, -0.2}}}, - {"Gmin admits a restoring desired-gate rate", - {}, - {{Internal::C, -0.2}, {Internal::RC, 0.2}}, - {{Internal::C, 0.0}}, - {{Internal::C, 0.2}}}}); - - // At a blocked gate limit, pin the assembled alpha = 1 desired-gate - // row independently of either Jacobian backend. The row is - // -c_dot + antiwindup(c, rc, Gmin, Gmax): the derivative contributes - // -1, and the closed gate leaves the rate with no influence, so a - // leaking anti-windup would show up as a nonzero RC entry. + // At alpha = 1, a blocked desired-gate row has derivative coefficient + // -1 and no RC dependence, independently of either Jacobian backend. { using DepVar = DependencyTracking::Variable; - Fixture blocked(makeResidualData()); + Fixture blocked(data); blocked.attachAllInputs(); success *= blocked.initialize(0.4); setState(blocked.hygov, {{Internal::C, 1.2}, {Internal::RC, 0.2}}); @@ -657,63 +661,62 @@ namespace GridKit return success.report(__func__); } - /// The nonlinear gate-power curve on every rising segment, the water - /// column away from the dam head, turbine damping, and initialization - /// through the nonidentity curve, including a value on a flat-segment - /// plateau. + /// Gate-power, water-column, damping, and curve-inversion behavior, + /// including a flat segment. TestOutcome turbineDynamics() { TestStatus success = true; - - // One gate point inside each of the five curve segments. - success *= runResidualCases( - makeResidualData(), - 0.4, - {{"gate-power curve segment 1", - {}, - {{Internal::G, 0.1}, {Internal::PGV, 0.0}}, - {}, - {{Internal::PGV, 0.07500000000021236}}}, - {"gate-power curve segment 2", - {}, - {{Internal::G, 0.3}, {Internal::PGV, 0.0}}, - {}, - {{Internal::PGV, 0.28500000000007075}}}, - {"gate-power curve segment 3", - {}, - {{Internal::G, 0.5}, {Internal::PGV, 0.0}}, - {}, - {{Internal::PGV, 0.5399999999999371}}}, - {"gate-power curve segment 4", - {}, - {{Internal::G, 0.7}, {Internal::PGV, 0.0}}, - {}, - {{Internal::PGV, 0.7549999999999292}}}, - {"gate-power curve segment 5", - {}, - {{Internal::G, 0.9}, {Internal::PGV, 0.0}}, - {}, - {{Internal::PGV, 0.9249999999998506}}}}); + const auto data = makeResidualData(); + + const std::array gate_power_cases{{ + {"gate-power curve segment 1", + {}, + {{Internal::G, 0.1}, {Internal::PGV, 0.0}}, + {}, + {{Internal::PGV, 0.07500000000021236}}}, + {"gate-power curve segment 2", + {}, + {{Internal::G, 0.3}, {Internal::PGV, 0.0}}, + {}, + {{Internal::PGV, 0.28500000000007075}}}, + {"gate-power curve segment 3", + {}, + {{Internal::G, 0.5}, {Internal::PGV, 0.0}}, + {}, + {{Internal::PGV, 0.5399999999999371}}}, + {"gate-power curve segment 4", + {}, + {{Internal::G, 0.7}, {Internal::PGV, 0.0}}, + {}, + {{Internal::PGV, 0.7549999999999292}}}, + {"gate-power curve segment 5", + {}, + {{Internal::G, 0.9}, {Internal::PGV, 0.0}}, + {}, + {{Internal::PGV, 0.9249999999998506}}}, + }}; + success *= runResidualCases(data, 0.4, gate_power_cases); // A head away from the dam head drives the flow and head rows, and // turbine damping scales with speed deviation and gate. - success *= runResidualCases( - makeResidualData(), - 0.4, - {{"water column", - {}, - {{Internal::Q, 0.61}, {Internal::H, 0.9}, {Internal::PGV, 0.55}}, - {{Internal::Q, 0.05}}, - {{Internal::Q, 0.18076923076923068}, {Internal::H, -0.09984999999999994}}}, - {"turbine damping", - {{External::OMEGA, 0.05}}, - {{Internal::G, 0.6}, {Internal::Q, 0.7}, {Internal::H, 1.1}, {Internal::PMECH, 0.5}}, - {}, - {{Internal::PMECH, -0.2677999999999999}}}}); - - // A value inside the third curve segment initializes through the - // nonidentity curve inversion. - Fixture curve_fixture(makeResidualData()); + const std::array turbine_cases{{ + {"water column", + {}, + {{Internal::Q, 0.61}, {Internal::H, 0.9}, {Internal::PGV, 0.55}}, + {{Internal::Q, 0.05}}, + {{Internal::Q, 0.18076923076923068}, {Internal::H, -0.09984999999999994}}}, + {"turbine damping", + {{External::OMEGA, 0.05}}, + {{Internal::G, 0.6}, + {Internal::Q, 0.7}, + {Internal::H, 1.1}, + {Internal::PMECH, 0.5}}, + {}, + {{Internal::PMECH, -0.2677999999999999}}}, + }}; + success *= runResidualCases(data, 0.4, turbine_cases); + + Fixture curve_fixture(data); curve_fixture.attachAllInputs(); success *= curve_fixture.initialize(0.33761676); success *= stateMatches( @@ -762,10 +765,10 @@ namespace GridKit { TestStatus success = true; - const auto data = makeResidualData(); + const auto data = makeResidualData(); + const std::array gate_points{{0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9}}; - for (const RealT gate : std::array{ - {0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9}}) + for (const RealT gate : gate_points) { const auto dependency_jacobian = dependencyTrackingJacobian(data, gate, success); const auto enzyme_jacobian = enzymeJacobian(data, gate, success); @@ -782,8 +785,7 @@ namespace GridKit } } - // The recent HYGOV Jacobian defect was a missing PGV/G entry, so - // its presence is asserted structurally in both paths. + // Guard the required PGV/G dependency even if both paths agree. success *= jacobianContains( dependency_jacobian, Internal::PGV, Internal::G, "dependency-tracking"); success *= jacobianContains(enzyme_jacobian, Internal::PGV, Internal::G, "Enzyme"); @@ -810,7 +812,6 @@ namespace GridKit static constexpr std::array(Internal::MAXIMUM)> kRowNames{ {"XN", "XF", "C", "G", "Q", "OMEGADB", "EF", "FC", "RC", "PGV", "H", "PMECH"}}; - /// One perturbed-residual scenario evaluated on a fresh fixture. struct ResidualCase { const char* label; @@ -820,7 +821,6 @@ namespace GridKit InternalRows expected; }; - /// Copy `data` with the listed parameter overrides applied. static Data withParameters(Data data, std::initializer_list> overrides) { @@ -860,7 +860,6 @@ namespace GridKit 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 = hygov.size(); @@ -902,7 +901,6 @@ namespace GridKit return true; } - /// prepare() plus successful HYGOV initialization. bool initialize(RealT pmech) { if (!prepare(pmech)) @@ -1053,7 +1051,6 @@ namespace GridKit {Params::Pgv4, 0.85}}); } - /// The external inputs the residual answer key is evaluated against. template void setAnswerKeyInputs(Fixture& fixture) const { @@ -1170,7 +1167,6 @@ namespace GridKit values + static_cast(vector.getSize())); } - /// Every row of a vector still holds its snapshot value. template bool vectorUnchanged(const VectorT& vector, const std::vector& snapshot, @@ -1293,7 +1289,6 @@ namespace GridKit hygov.y().setDataUpdated(); } - /// setState() for the derivative vector. template void setDerivative(PhasorDynamics::Governor::Hygov& hygov, const InternalRows& rows) const @@ -1306,11 +1301,11 @@ namespace GridKit hygov.yp().setDataUpdated(); } - /// Evaluate each scenario on its own initialized fixture, so no - /// inputs or state leak between cases. - bool runResidualCases(const Data& data, - RealT pmech, - const std::vector& cases) const + /// Evaluate each scenario on a fresh fixture to prevent state leakage. + template + bool runResidualCases(const Data& data, + RealT pmech, + const std::array& cases) const { bool success = true; for (const auto& test_case : cases) @@ -1320,6 +1315,7 @@ namespace GridKit if (!fixture.initialize(pmech)) { success = false; + continue; } for (const auto& [port, value] : test_case.inputs) { @@ -1339,9 +1335,7 @@ namespace GridKit return success; } - /// Compare one vector row against its expected value. Every row check - /// in this suite reports through here, so failures share one format - /// and name their row through `kRowNames`. + /// Compare one named row and report mismatches consistently. bool rowMatches(RealT actual, RealT expected, const char* what, @@ -1361,12 +1355,12 @@ namespace GridKit { std::cout << row; } - std::cout << ' ' << context << " mismatch: " << std::setprecision(16) + std::cout << ' ' << context << " mismatch: " + << std::setprecision(std::numeric_limits::max_digits10) << actual << " != " << expected << '\n'; return false; } - /// Check selected rows of a model vector against expected values. template bool rowsMatch(const VectorT& vector, const RowsT& rows, @@ -1438,8 +1432,9 @@ namespace GridKit { return true; } - std::cout << label << " mismatch: " << std::setprecision(16) << actual - << " != " << expected << "\n"; + std::cout << label << " mismatch: " + << std::setprecision(std::numeric_limits::max_digits10) + << actual << " != " << expected << "\n"; return false; } @@ -1472,7 +1467,6 @@ namespace GridKit } #ifdef GRIDKIT_ENABLE_ENZYME - /// The row's dependency map must contain the column entry. template bool jacobianContains(const JacobianRowsT& rows, Internal row_variable, From 38010aac2b65009fd55ef0af1c4ec97b08456cc2 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Wed, 5 Aug 2026 17:02:31 -0500 Subject: [PATCH 16/17] power world specific edge case behaviour and final polishing --- .../PhasorDynamics/Governor/HYGOV/Hygov.hpp | 22 +- .../Governor/HYGOV/HygovImpl.hpp | 259 +++++++++++++----- .../PhasorDynamics/Governor/HYGOV/README.md | 38 ++- .../PhasorDynamics/GovernorHygovTests.hpp | 142 +++++++--- 4 files changed, 323 insertions(+), 138 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp index 29849d86d..9370e1486 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp @@ -131,14 +131,23 @@ namespace GridKit /// smooth linear segments. __attribute__((always_inline)) inline ScalarT gatePower(ScalarT gate) const; - /// Steady component-base mechanical power at a gate position, - /// composed as the runtime PGV, H, and PMECH rows compose it. - RealT initialMechanicalPower(RealT gate) const; - - /// Solve the gate position whose steady mechanical power reproduces - /// the given component-base value, exact to machine rounding. + /// Steady component-base mechanical power at a gate and dam head. + RealT initialMechanicalPower(RealT gate, RealT Hdam) const; + + /// Bisect a bracketed initialization residual to machine rounding. + template + static RealT bisectInitialRoot(RealT a, + RealT b, + RealT fa, + RealT fb, + FuncT residual); + + /// Solve the gate at the configured dam head. RealT solveInitialGate(RealT pmech) const; + /// Solve the dam head that reproduces mechanical power at Gmax. + RealT solveInitialDamHead(RealT pmech) const; + ScalarT toComponentBase(ScalarT value) const; ScalarT toSystemBase(ScalarT value) const; @@ -173,6 +182,7 @@ namespace GridKit IdxT parameter_error_count_{0}; + RealT Hdam_eff_{Hdam_}; ScalarT pref_set_{0}; ScalarT paux_set_{0}; diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp index 7c42f7bcd..3c33763a5 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp @@ -212,8 +212,8 @@ namespace GridKit { // A rise no wider than the tolerance that pins a seed to a range // edge leaves the gate undetermined by the mechanical power. - const RealT minimum_power = initialMechanicalPower(Gmin_); - const RealT maximum_power = initialMechanicalPower(Gmax_); + const RealT minimum_power = initialMechanicalPower(Gmin_, Hdam_); + const RealT maximum_power = initialMechanicalPower(Gmax_, Hdam_); const RealT power_range = maximum_power - minimum_power; const bool finite_power_range = std::isfinite(minimum_power) && std::isfinite(maximum_power) @@ -265,12 +265,12 @@ namespace GridKit * rounding; a value clipped to the achievable-power range edge * leaves a mechanical-power residual up to the initialization * tolerance. - * @post On failure no state or signal storage has changed. + * @post On failure state, effective Hdam, and signal storage are unchanged. * * @return int 0 on success; nonzero when the configuration or initial * values are invalid, the initial speed deviation is - * nonzero, or no gate inside [Gmin, Gmax] reproduces the - * given power. + * nonzero, or the initial mechanical power cannot be + * reproduced. */ template int Hygov::initialize() @@ -355,18 +355,33 @@ namespace GridKit return 1; } - const RealT gate0 = solveInitialGate(static_cast(pmech0)); - ret = std::isfinite(gate0); - if (!ret) + const RealT pmech0_value = static_cast(pmech0); + const RealT maximum_power = initialMechanicalPower(Gmax_, Hdam_); + RealT Hdam0 = Hdam_; + RealT gate0 = Gmax_; + + if (pmech0_value > maximum_power) { - Log::error() - << "Hygov: no gate inside [Gmin, Gmax] reproduces the given mechanical power\n"; - return 1; + Hdam0 = solveInitialDamHead(pmech0_value); + if (!std::isfinite(Hdam0)) + { + Log::error() << "Hygov: no finite Hdam reproduces the initial mechanical power\n"; + return 1; + } + } + else + { + gate0 = solveInitialGate(pmech0_value); + if (!std::isfinite(gate0)) + { + Log::error() << "Hygov: initial mechanical power is below the Gmin endpoint\n"; + return 1; + } } - const ScalarT h0 = static_cast(Hdam_); + const ScalarT h0 = static_cast(Hdam0); const ScalarT pgv0 = gatePower(static_cast(gate0)); - const ScalarT q0 = std::sqrt(Hdam_) * pgv0; + const ScalarT q0 = std::sqrt(Hdam0) * pgv0; const ScalarT omegadb0 = Math::deadband1(omega0, -db1_, db1_); const ScalarT xn0 = omegadb0; const ScalarT yomega0 = xn0 + leadlag_gain_ * (omegadb0 - xn0); @@ -397,6 +412,7 @@ namespace GridKit y[PGV] = pgv0; y[H] = h0; + Hdam_eff_ = Hdam0; pref_set_ = pref0; paux_set_ = paux0_system; @@ -405,6 +421,11 @@ namespace GridKit signals_.template writeExternalVariable(pref_set_); } + if (Hdam_eff_ > Hdam_) + { + Log::warning() << "Hygov: effective Hdam raised to match initial mechanical power\n"; + } + y_.setDataUpdated(); yp_.setToConst(static_cast(ZERO)); return 0; @@ -588,7 +609,7 @@ namespace GridKit f[XF] = -xf_dot + (ef - xf) / Tf_; f[C] = -c_dot + Math::antiwindup(c, rc, Gmin_, Gmax_); f[G] = -g_dot + (c - g) / Tg_; - f[Q] = -q_dot + (Hdam_ - head) / Tw_; + f[Q] = -q_dot + (Hdam_eff_ - head) / Tw_; f[OMEGADB] = -omegadb + Math::deadband1(omega, -db1_, db1_); f[EF] = -ef + toComponentBase(pref + paux) - yomega - Rperm_ * c; f[FC] = -Rtemp_ * fc + xf / Tr_ + (ef - xf) / Tf_; @@ -699,26 +720,6 @@ namespace GridKit load_real(Params::Pgv4, Pgv_[4], "Pgv4"); load_real(Params::Pgv5, Pgv_[5], "Pgv5"); - // Model data uses an all-exact-zero curve to mean "no curve supplied", - // so this is an exact comparison by intent rather than a tolerance - // test. Any nonzero point selects the given curve. - auto is_nonzero = [](RealT value) - { return value != ZERO; }; - - const bool curve_supplied = - std::any_of(Gv_.begin(), Gv_.end(), is_nonzero) - || std::any_of(Pgv_.begin(), Pgv_.end(), is_nonzero); - if (!curve_supplied) - { - Gv_ = {ZERO, - static_cast(0.2), - static_cast(0.4), - static_cast(0.6), - static_cast(0.8), - ONE}; - Pgv_ = Gv_; - } - setDerivedParameters(); } @@ -751,12 +752,32 @@ namespace GridKit /** * @brief Resolve the parameter-derived constants * - * Floors each governor time constant so the residual equations retain - * Hessenberg form, then derives the speed lead-lag gain. + * Resolves the default gate curve, floors each governor time constant, + * derives the speed lead-lag gain, and initializes the effective head. */ template void Hygov::setDerivedParameters() { + // Model data uses an all-exact-zero curve to mean "no curve supplied", + // so this is an exact comparison by intent rather than a tolerance + // test. Any nonzero point selects the given curve. + auto is_nonzero = [](RealT value) + { return value != ZERO; }; + + const bool curve_supplied = + std::any_of(Gv_.begin(), Gv_.end(), is_nonzero) + || std::any_of(Pgv_.begin(), Pgv_.end(), is_nonzero); + if (!curve_supplied) + { + Gv_ = {ZERO, + static_cast(0.2), + static_cast(0.4), + static_cast(0.6), + static_cast(0.8), + ONE}; + Pgv_ = Gv_; + } + // 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. @@ -794,6 +815,7 @@ namespace GridKit Tnp_ = std::max(Tnp_, TIME_CONSTANT_MINIMUM); leadlag_gain_ = Tn_ / Tnp_; + Hdam_eff_ = Hdam_; } /** @@ -821,12 +843,12 @@ namespace GridKit } /** - * @brief Steady component-base mechanical power at a gate position + * @brief Steady component-base mechanical power at a gate and dam head * - * At the steady state the head equals the dam head and the flow + * At the steady state the head equals the given dam head and the flow * follows the gate curve, so the PGV, H, and PMECH rows collapse to * @f[ - * P_{\mathrm{m}}(g) + * P_{\mathrm{m}}(g,H_{\mathrm{dam}}) * = A_t H_{\mathrm{dam}} * \left(\sqrt{H_{\mathrm{dam}}}\,N_{\mathrm{GV}}(g) * - q_{\mathrm{NL}}\right). @@ -836,15 +858,76 @@ namespace GridKit * rounding. * * @param[in] gate Gate position. + * @param[in] Hdam Dam head. * @return Steady mechanical power on the component base. */ template typename Hygov::RealT - Hygov::initialMechanicalPower(RealT gate) const + Hygov::initialMechanicalPower(RealT gate, + RealT Hdam) const { const RealT pgv = static_cast(gatePower(static_cast(gate))); - const RealT q = std::sqrt(Hdam_) * pgv; - return At_ * Hdam_ * (q - Qnl_); + const RealT q = std::sqrt(Hdam) * pgv; + return At_ * Hdam * (q - Qnl_); + } + + /** + * @brief Bisect a bracketed initialization residual + * + * Each iteration replaces one endpoint with a representable midpoint + * strictly inside the interval. A finite floating-point interval has a + * finite number of representable values, so the loop terminates when no + * interior midpoint remains. + * + * @tparam FuncT Residual callable. + * @param[in] a Lower endpoint. + * @param[in] b Upper endpoint. + * @param[in] fa Residual at the lower endpoint. + * @param[in] fb Residual at the upper endpoint. + * @param[in] residual Residual callable. + * @pre The endpoints are finite, @f$a < b@f$, and @f$f(a) \le 0 \le f(b)@f$. + * @return The endpoint with the smaller residual magnitude, or a quiet + * NaN if an interior residual is NaN. + */ + template + template + typename Hygov::RealT + Hygov::bisectInitialRoot(RealT a, + RealT b, + RealT fa, + RealT fb, + FuncT residual) + { + while (true) + { + const RealT mid = std::midpoint(a, b); + if (mid <= a || b <= mid) + { + break; + } + + const RealT fmid = residual(mid); + if (std::isnan(fmid)) + { + return std::numeric_limits::quiet_NaN(); + } + if (fmid <= ZERO) + { + a = mid; + fa = fmid; + } + else + { + b = mid; + fb = fmid; + } + } + + if (std::abs(fa) <= std::abs(fb)) + { + return a; + } + return b; } /** @@ -853,7 +936,7 @@ namespace GridKit * Initialization requires a zero speed deviation and verify() requires * the steady power to rise across [Gmin, Gmax], so the endpoint * residuals decide feasibility and bisection converges to a root of - * the nondecreasing steady-power curve. + * the nondecreasing steady-power curve at the configured dam head. * * @pre verify() reports no errors. * @@ -869,18 +952,13 @@ namespace GridKit typename Hygov::RealT Hygov::solveInitialGate(RealT pmech) const { - // A nonfinite seed is unreproducible by any gate and would otherwise - // slip through the sign tests below. - const bool ret = std::isfinite(pmech); - if (!ret) - { - return std::numeric_limits::quiet_NaN(); - } + const auto residual = [this, pmech](RealT gate) + { return initialMechanicalPower(gate, Hdam_) - pmech; }; RealT a = Gmin_; RealT b = Gmax_; - RealT fa = initialMechanicalPower(a) - pmech; - RealT fb = initialMechanicalPower(b) - pmech; + RealT fa = residual(a); + RealT fb = residual(b); // A value just outside the achievable range pins to the gate limit // when it is within the initialization tolerance of the range edge. @@ -894,42 +972,73 @@ namespace GridKit } if (fb < ZERO) { - if (-fb <= INITIALIZATION_TOLERANCE) - { - return b; - } return std::numeric_limits::quiet_NaN(); } - // Bisect until no representable midpoint remains, then keep the - // endpoint with the smaller residual. Termination is guaranteed. - while (true) + return bisectInitialRoot(a, b, fa, fb, residual); + } + + /** + * @brief Solve the effective dam head for a high initial power + * + * Starting from the configured dam head, brackets a higher value whose + * steady mechanical power at Gmax reaches the initial value, then + * bisects to machine rounding. + * + * @pre verify() reports no errors. + * @pre The initial mechanical power exceeds the configured Gmax endpoint. + * + * @param[in] pmech Mechanical power on the component base. + * @return Effective dam head, or a quiet NaN when no finite value is found. + * + * @warning This function contains conditional branching and may be used + * during initialization, but not during residual evaluation. + */ + template + typename Hygov::RealT + Hygov::solveInitialDamHead(RealT pmech) const + { + const RealT nan = std::numeric_limits::quiet_NaN(); + const auto residual = [this, pmech](RealT Hdam) + { return initialMechanicalPower(Gmax_, Hdam) - pmech; }; + + RealT a = Hdam_; + RealT b = Hdam_; + RealT fa = residual(a); + RealT fb = fa; + + if (!std::isfinite(fa) || !(fa < ZERO) ) { - const RealT mid = std::midpoint(a, b); + return nan; + } - // Once the midpoint rounds to an endpoint, the interval cannot be - // reduced any further. - if (mid <= a || b <= mid) + const RealT maximum_head = std::numeric_limits::max(); + while (fb < ZERO) + { + a = b; + fa = fb; + + if (b >= maximum_head) { - break; + return nan; } - const RealT fmid = initialMechanicalPower(mid) - pmech; - if (fmid <= ZERO) + if (b > maximum_head / TWO) { - a = mid; - fa = fmid; + b = maximum_head; } else { - b = mid; - fb = fmid; + b *= TWO; + } + + fb = residual(b); + if (std::isnan(fb)) + { + return nan; } } - if (std::abs(fa) <= std::abs(fb)) - { - return a; - } - return b; + + return bisectInitialRoot(a, b, fa, fb, residual); } /** diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md b/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md index 864a62598..0bd3d97bc 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md @@ -36,7 +36,7 @@ $T_n$ | [sec] | `Tn` | Speed lead-lag numerator ti $T_{\mathrm{np}}$ | [sec] | `Tnp` | Speed lead-lag denominator time constant | 0.0 | $D_{\omega}$ | [p.u.] | `db1` | Type 1 speed deadband threshold | 0.0 | $D_2$ | [p.u.] | `db2` | Mechanical backlash deadband | 0.0 | -$H_{\mathrm{dam}}$ | [p.u.] | `Hdam` | Head available at dam | 1.0 | +$H_{\mathrm{dam}}$ | [p.u.] | `Hdam` | Configured dam head | 1.0 | Lower bound on effective head $G_V^{(k)}$ | [p.u.] | `Gv0`-`Gv5` | Gate point $k$ of the gain curve | 0.0 | $k=0,\ldots,5$ $P_{\mathrm{GV}}^{(k)}$ | [p.u.] | `Pgv0`-`Pgv5` | Power point $k$ of the gain curve | 0.0 | $k=0,\ldots,5$ @@ -177,6 +177,9 @@ $P^\mathrm{aux}$ | [p.u.] | Known | Auxiliary power input | Optional si ### Differential Equations +The effective dam head $H_{\mathrm{dam}}^{\mathrm{eff}}$ is resolved during +initialization. + ```math \begin{aligned} 0 &= @@ -198,7 +201,7 @@ $P^\mathrm{aux}$ | [p.u.] | Known | Auxiliary power input | Optional si 0 &= -\dot{q} + \dfrac{1}{T_w} - \left(H_{\mathrm{dam}} - H\right) + \left(H_{\mathrm{dam}}^{\mathrm{eff}} - H\right) \end{aligned} ``` @@ -266,13 +269,19 @@ Initialization requires an exactly zero speed deviation, $\omega = 0$. Restart initialization of a moving machine is not supported. All internal derivatives are set to zero. -The gate is found by bisection over the validated nondecreasing steady-power -curve using the same smooth $N_{\mathrm{GV}}$ curve as the residual: +Initialization solves the gate at the configured dam head unless the required +mechanical power exceeds the value at $G^{\max}$. In that case it pins the gate +at $G^{\max}$ and raises an effective dam head +$H_{\mathrm{dam}}^{\mathrm{eff}} \ge H_{\mathrm{dam}}$ to reproduce the +operating point. Both searches use the same smooth $N_{\mathrm{GV}}$ curve as +the residual. No upper limit is applied to this adjustment. The raised head +remains the water-column setpoint during simulation, and the gate has no +initial upward margin. ```math \begin{aligned} H - &\leftarrow H_{\mathrm{dam}} \\ + &\leftarrow H_{\mathrm{dam}}^{\mathrm{eff}} \\ g &\leftarrow \text{gate in } [G^{\min}, G^{\max}] \text{ satisfying} \\ &\qquad k_{\mathrm{base}}P_{\mathrm{m}} @@ -298,15 +307,14 @@ curve using the same smooth $N_{\mathrm{GV}}$ curve as the residual: \end{aligned} ``` -Initialization rejects an operating point when no gate in -$[G^{\min}, G^{\max}]$ reproduces the given mechanical power. An in-range -value initializes with every residual at machine rounding. A value within -$\epsilon_{\mathrm{init}} = 100\,\epsilon_{\mathrm{mach}}$ of the -achievable-power range initializes at the corresponding gate limit with a -mechanical-power residual up to $\epsilon_{\mathrm{init}}$. +A value within $\epsilon_{\mathrm{init}} = 100\,\epsilon_{\mathrm{mach}}$ +below the $G^{\min}$ endpoint initializes at $G^{\min}$ with a mechanical-power +residual up to $\epsilon_{\mathrm{init}}$. A lower value, or a high-side value +without a finite effective dam head, is rejected. All other accepted values +initialize with every residual at machine rounding. -Every check resolves before any storage is written, so a rejected -initialization leaves state, `pmech`, and external signals unchanged. +Every check resolves before state, the effective dam head, or signals are +written, so a rejected initialization leaves them unchanged. ### Output Initialization @@ -342,8 +350,8 @@ Output | Units | Description | Note signal configuration, and minimum time-constant handling. - `initializationAndSignals()` checks initialization, base conversion, signal publication, monitor output, and unattached-reference latching. -- `initializationDomain()` checks rejected and accepted mechanical-power, - gate-limit, speed-deviation, and input initialization boundaries. +- `initializationDomain()` checks effective-head initialization, rejection + atomicity, and initialization boundaries. - `initializationExactness()` checks that initialized steady residuals rest at machine rounding across the gate curve. - `residualEquations()` checks every model residual against a fixed diff --git a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp index 4ec60d033..110f256c4 100644 --- a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp +++ b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp @@ -329,15 +329,14 @@ namespace GridKit } /// Mechanical-power, gate-limit, speed-deviation, and finite-input - /// initialization domains. Every rejection is atomic; values inside the - /// achievable range initialize at rest, and values within the - /// initialization tolerance of a range edge pin to the gate limit. + /// initialization domains. High mechanical power raises the effective + /// dam head; every rejected initialization is atomic. TestOutcome initializationDomain() { TestStatus success = true; - noteExpectedLogs("Testing inadmissible HYGOV initialization points. " - "Logged errors are expected."); + noteExpectedLogs("Testing HYGOV initialization boundaries. " + "Logged errors and dam-head warnings are expected."); struct RejectionCase { @@ -347,10 +346,8 @@ namespace GridKit RealT gmax; }; - const std::array rejection_cases{{ - {"mechanical power above the gate curve", 1.0, 0.05, 0.95}, + const std::array rejection_cases{{ {"mechanical power below the gate curve", -0.3, 0.05, 0.95}, - {"mechanical power above the Gmax limit", 0.4, 0.05, 0.5}, {"mechanical power below the Gmin limit", 0.4, 0.6, 0.95}, }}; @@ -365,6 +362,77 @@ namespace GridKit test_case.label); } + const auto no_finite_head = withParameters( + makeData(), + {{Params::Pgv0, -1.0}, + {Params::Pgv1, -0.8}, + {Params::Pgv2, -0.6}, + {Params::Pgv3, -0.4}, + {Params::Pgv4, -0.2}, + {Params::Pgv5, 0.0}}); + success *= initializationRejectedAtomically( + no_finite_head, + 0.0, + {{External::OMEGA, 0.0}, {External::PREF, 77.0}, {External::PAUX, 0.02}}, + "no finite effective Hdam"); + + // 4.5 MW on the system base is 2.5 pu on a 1.8 MW turbine base. + Fixture effective_fixture( + makeData(), + {{Params::Trate, 1.8}, {Params::At, 1.25}, {Params::Qnl, 0.07}}); + effective_fixture.attachAllInputs(); + success *= effective_fixture.initialize(0.045); + success *= stateMatches( + effective_fixture.hygov, + {{Internal::C, 1.0}, + {Internal::G, 1.0}, + {Internal::Q, 1.2812656647316965}, + {Internal::PGV, 0.9971118867476669}, + {Internal::H, 1.6511654364800423}}, + "effective dam head"); + success *= scalarMatches(effective_fixture.pmech(), 0.045, "preserved pmech value"); + success *= scalarMatches(effective_fixture.input(External::PREF), + 0.0009, + "published pref"); + success *= (effective_fixture.evaluate() == 0); + success *= allResidualsZero(effective_fixture.hygov); + + Fixture limited_fixture(makeResidualData(), {{Params::Gmax, 0.5}}); + success *= limited_fixture.initialize(0.4); + success *= stateMatches( + limited_fixture.hygov, + {{Internal::C, 0.5}, + {Internal::G, 0.5}, + {Internal::Q, 0.6242359695868803}, + {Internal::PGV, 0.5399999999999371}, + {Internal::H, 1.3363187439168838}}, + "effective head at Gmax"); + success *= (limited_fixture.evaluate() == 0); + success *= allResidualsZero(limited_fixture.hygov); + + // A failed retry preserves the effective head from the prior success. + const auto effective_y = copyVector(effective_fixture.hygov.y()); + const auto effective_yp = copyVector(effective_fixture.hygov.yp()); + effective_fixture.input(External::OMEGA) = 0.03; + success *= (effective_fixture.hygov.initialize() != 0); + success *= vectorUnchanged(effective_fixture.hygov.y(), effective_y, "state"); + success *= vectorUnchanged(effective_fixture.hygov.yp(), effective_yp, "derivative"); + success *= scalarMatches(effective_fixture.input(External::PREF), + 0.0009, + "preserved pref"); + effective_fixture.input(External::OMEGA) = 0.0; + success *= (effective_fixture.evaluate() == 0); + success *= allResidualsZero(effective_fixture.hygov); + + // A later feasible initialization starts again from configured Hdam. + effective_fixture.setPmech(0.009); + success *= (effective_fixture.hygov.initialize() == 0); + success *= stateMatches(effective_fixture.hygov, + {{Internal::H, 1.0}}, + "configured dam head after reinitialization"); + success *= (effective_fixture.evaluate() == 0); + success *= allResidualsZero(effective_fixture.hygov); + // Initialization supports only a zero speed deviation; a moving // machine would need a multi-root gate search. success *= initializationRejectedAtomically(makeResidualData(), @@ -401,47 +469,37 @@ namespace GridKit // The smooth identity curve leaves a ln(2)/MU knee at each end, so // makeData()'s achievable component-base power range is - // [knee - 0.1, 0.9 - knee]. kTol equals the model's initialization - // tolerance, so values half of it beyond an edge pin to the gate - // limit and still rest within kTol; values twice beyond are - // rejected. + // [knee - 0.1, 0.9 - knee]. const RealT knee = std::log(static_cast(2.0)) / Math::MU; const RealT p_max = static_cast(0.9) - knee; const RealT p_min = knee - static_cast(0.1); - struct BoundaryCase + Fixture lower_edge(makeData()); + success *= lower_edge.initialize(p_min - 0.5 * kTol); + success *= stateMatches(lower_edge.hygov, + {{Internal::C, 0.0}, {Internal::G, 0.0}}, + "half the tolerance below the achievable minimum"); + success *= scalarMatches(lower_edge.pmech(), + p_min - 0.5 * kTol, + "clipped pmech value"); + success *= (lower_edge.evaluate() == 0); + success *= allResidualsZero(lower_edge.hygov); + + Fixture effective_edge(makeData()); + success *= effective_edge.initialize(p_max + 0.5 * kTol); + success *= stateMatches(effective_edge.hygov, + {{Internal::C, 1.0}, {Internal::G, 1.0}}, + "half the tolerance beyond the achievable maximum"); + const RealT effective_edge_head = static_cast( + effective_edge.hygov.y().getData()[static_cast(Internal::H)]); + if (!(effective_edge_head > 1.0)) { - const char* label; - RealT pmech; - RealT gate; - }; - - const std::array boundary_cases{{ - {"half the tolerance beyond the achievable maximum", - p_max + 0.5 * kTol, - 1.0}, - {"half the tolerance below the achievable minimum", - p_min - 0.5 * kTol, - 0.0}, - }}; - - for (const auto& clipped : boundary_cases) - { - Fixture fixture(makeData()); - success *= fixture.initialize(clipped.pmech); - success *= stateMatches(fixture.hygov, - {{Internal::C, clipped.gate}, {Internal::G, clipped.gate}}, - clipped.label); - success *= scalarMatches(fixture.pmech(), clipped.pmech, "clipped pmech value"); - success *= (fixture.evaluate() == 0); - success *= allResidualsZero(fixture.hygov); + std::cout << "effective head was not raised above configured Hdam\n"; + success = false; } + success *= (effective_edge.evaluate() == 0); + success *= allResidualsZero(effective_edge.hygov); - success *= initializationRejectedAtomically( - makeData(), - p_max + 2.0 * kTol, - {{External::OMEGA, 0.0}, {External::PREF, 77.0}, {External::PAUX, 0.02}}, - "twice the tolerance beyond the achievable maximum"); success *= initializationRejectedAtomically( makeData(), p_min - 2.0 * kTol, From 0864fb73accd4de8bb38a788980043798ef6a0cf Mon Sep 17 00:00:00 2001 From: lukelowry Date: Thu, 6 Aug 2026 12:04:36 -0500 Subject: [PATCH 17/17] Cleaned/styled and adjust comments/doc better wording --- .../PhasorDynamics/Governor/HYGOV/Hygov.hpp | 4 +- .../Governor/HYGOV/HygovData.hpp | 6 +- .../Governor/HYGOV/HygovImpl.hpp | 71 +++++--- .../PhasorDynamics/Governor/HYGOV/README.md | 62 ++++--- .../PhasorDynamics/GovernorHygovTests.hpp | 165 ++++++++++++------ 5 files changed, 196 insertions(+), 112 deletions(-) diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp index 9370e1486..9844dffd3 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/Hygov.hpp @@ -145,7 +145,7 @@ namespace GridKit /// Solve the gate at the configured dam head. RealT solveInitialGate(RealT pmech) const; - /// Solve the dam head that reproduces mechanical power at Gmax. + /// Solve the dam head that reproduces mechanical power at Gv5. RealT solveInitialDamHead(RealT pmech) const; ScalarT toComponentBase(ScalarT value) const; @@ -182,6 +182,8 @@ namespace GridKit IdxT parameter_error_count_{0}; + RealT Gmin_response_{Gmin_}; + RealT Gmax_response_{Gmax_}; RealT Hdam_eff_{Hdam_}; ScalarT pref_set_{0}; ScalarT paux_set_{0}; diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovData.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovData.hpp index 14ad086e5..733d39135 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovData.hpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovData.hpp @@ -24,8 +24,8 @@ namespace GridKit Tf, ///< \f$T_f\f$ Governor error filter time constant Tg, ///< \f$T_g\f$ Gate servo time constant Velm, ///< \f$V_{\mathrm{elm}}\f$ Maximum desired-gate velocity magnitude - Gmax, ///< \f$G^{\max}\f$ Maximum desired-gate position - Gmin, ///< \f$G^{\min}\f$ Minimum desired-gate position + Gmax, ///< \f$G^{\max}\f$ Configured upper gate response limit + Gmin, ///< \f$G^{\min}\f$ Configured lower gate response limit Tw, ///< \f$T_w\f$ Water inertia time constant At, ///< \f$A_t\f$ Turbine gain Dturb, ///< \f$D_{\mathrm{turb}}\f$ Turbine damping coefficient @@ -33,7 +33,7 @@ namespace GridKit Tn, ///< \f$T_n\f$ Speed lead-lag numerator time constant Tnp, ///< \f$T_{\mathrm{np}}\f$ Speed lead-lag denominator time constant db1, ///< \f$D_{\omega}\f$ Type 1 speed deadband threshold - db2, ///< \f$D_2\f$ Mechanical backlash deadband + db2, ///< \f$D_2\f$ Unsupported mechanical backlash. Nonzero values warn and are ignored Hdam, ///< \f$H_{\mathrm{dam}}\f$ Head available at dam Gv0, ///< \f$G_V^{(0)}\f$ Gate point 0 Gv1, ///< \f$G_V^{(1)}\f$ Gate point 1 diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp index 3c33763a5..9761ca9c6 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/HygovImpl.hpp @@ -212,8 +212,8 @@ namespace GridKit { // A rise no wider than the tolerance that pins a seed to a range // edge leaves the gate undetermined by the mechanical power. - const RealT minimum_power = initialMechanicalPower(Gmin_, Hdam_); - const RealT maximum_power = initialMechanicalPower(Gmax_, Hdam_); + const RealT minimum_power = initialMechanicalPower(Gv_[0], Hdam_); + const RealT maximum_power = initialMechanicalPower(Gv_[5], Hdam_); const RealT power_range = maximum_power - minimum_power; const bool finite_power_range = std::isfinite(minimum_power) && std::isfinite(maximum_power) @@ -223,7 +223,7 @@ namespace GridKit if (finite_power_range) { check(power_range > INITIALIZATION_TOLERANCE, - "mechanical power must rise across [Gmin, Gmax]"); + "mechanical power must rise across [Gv0, Gv5]"); } } @@ -265,7 +265,8 @@ namespace GridKit * rounding; a value clipped to the achievable-power range edge * leaves a mechanical-power residual up to the initialization * tolerance. - * @post On failure state, effective Hdam, and signal storage are unchanged. + * @post On failure state, effective response limits, effective Hdam, + * and signal storage are unchanged. * * @return int 0 on success; nonzero when the configuration or initial * values are invalid, the initial speed deviation is @@ -356,9 +357,9 @@ namespace GridKit } const RealT pmech0_value = static_cast(pmech0); - const RealT maximum_power = initialMechanicalPower(Gmax_, Hdam_); + const RealT maximum_power = initialMechanicalPower(Gv_[5], Hdam_); RealT Hdam0 = Hdam_; - RealT gate0 = Gmax_; + RealT gate0 = Gv_[5]; if (pmech0_value > maximum_power) { @@ -374,11 +375,14 @@ namespace GridKit gate0 = solveInitialGate(pmech0_value); if (!std::isfinite(gate0)) { - Log::error() << "Hygov: initial mechanical power is below the Gmin endpoint\n"; + Log::error() << "Hygov: initial mechanical power is below the first Gv endpoint\n"; return 1; } } + const RealT Gmin_response = std::min(Gmin_, gate0); + const RealT Gmax_response = std::max(Gmax_, gate0); + const ScalarT h0 = static_cast(Hdam0); const ScalarT pgv0 = gatePower(static_cast(gate0)); const ScalarT q0 = std::sqrt(Hdam0) * pgv0; @@ -412,9 +416,11 @@ namespace GridKit y[PGV] = pgv0; y[H] = h0; - Hdam_eff_ = Hdam0; - pref_set_ = pref0; - paux_set_ = paux0_system; + Gmin_response_ = Gmin_response; + Gmax_response_ = Gmax_response; + Hdam_eff_ = Hdam0; + pref_set_ = pref0; + paux_set_ = paux0_system; if (signals_.template isAttached()) { @@ -425,6 +431,11 @@ namespace GridKit { Log::warning() << "Hygov: effective Hdam raised to match initial mechanical power\n"; } + if (gate0 < Gmin_ || gate0 > Gmax_) + { + Log::warning() << "Hygov: initial gate is outside [Gmin, Gmax]; " + "response limits are adjusted to include the initialized value\n"; + } y_.setDataUpdated(); yp_.setToConst(static_cast(ZERO)); @@ -607,7 +618,7 @@ namespace GridKit f[XN] = -xn_dot + (omegadb - xn) / Tnp_; f[XF] = -xf_dot + (ef - xf) / Tf_; - f[C] = -c_dot + Math::antiwindup(c, rc, Gmin_, Gmax_); + f[C] = -c_dot + Math::antiwindup(c, rc, Gmin_response_, Gmax_response_); f[G] = -g_dot + (c - g) / Tg_; f[Q] = -q_dot + (Hdam_eff_ - head) / Tw_; f[OMEGADB] = -omegadb + Math::deadband1(omega, -db1_, db1_); @@ -705,7 +716,11 @@ namespace GridKit load_real(Params::Tn, Tn_, "Tn"); load_real(Params::Tnp, Tnp_, "Tnp"); load_real(Params::db1, db1_, "db1"); - load_real(Params::db2, db2_, "db2"); + if (load_real(Params::db2, db2_, "db2") && db2_ != ZERO) + { + Log::warning() << "Hygov: nonzero db2 requests mechanical backlash, " + "but backlash is not implemented and db2 is ignored\n"; + } load_real(Params::Hdam, Hdam_, "Hdam"); load_real(Params::Gv0, Gv_[0], "Gv0"); load_real(Params::Gv1, Gv_[1], "Gv1"); @@ -753,7 +768,8 @@ namespace GridKit * @brief Resolve the parameter-derived constants * * Resolves the default gate curve, floors each governor time constant, - * derives the speed lead-lag gain, and initializes the effective head. + * derives the speed lead-lag gain, and initializes the effective + * response limits and head. */ template void Hygov::setDerivedParameters() @@ -814,8 +830,10 @@ namespace GridKit Tw_ = std::max(Tw_, TIME_CONSTANT_MINIMUM); Tnp_ = std::max(Tnp_, TIME_CONSTANT_MINIMUM); - leadlag_gain_ = Tn_ / Tnp_; - Hdam_eff_ = Hdam_; + leadlag_gain_ = Tn_ / Tnp_; + Gmin_response_ = Gmin_; + Gmax_response_ = Gmax_; + Hdam_eff_ = Hdam_; } /** @@ -934,15 +952,16 @@ namespace GridKit * @brief Solve the steady gate position for a given mechanical power * * Initialization requires a zero speed deviation and verify() requires - * the steady power to rise across [Gmin, Gmax], so the endpoint - * residuals decide feasibility and bisection converges to a root of - * the nondecreasing steady-power curve at the configured dam head. + * the steady power to rise across [Gv0, Gv5], so the full gate-curve + * endpoint residuals decide feasibility and bisection converges to a + * root of the nondecreasing steady-power curve at the configured dam + * head. * * @pre verify() reports no errors. * * @param[in] pmech Mechanical power on the component base. * @return The gate position, or a quiet NaN when no gate inside - * [Gmin, Gmax] reproduces the value within the initialization + * [Gv0, Gv5] reproduces the value within the initialization * tolerance. * * @warning This function contains conditional branching and may be used @@ -955,13 +974,13 @@ namespace GridKit const auto residual = [this, pmech](RealT gate) { return initialMechanicalPower(gate, Hdam_) - pmech; }; - RealT a = Gmin_; - RealT b = Gmax_; + RealT a = Gv_[0]; + RealT b = Gv_[5]; RealT fa = residual(a); RealT fb = residual(b); - // A value just outside the achievable range pins to the gate limit - // when it is within the initialization tolerance of the range edge. + // A value just below the achievable range pins to the first gate + // point when it is within the initialization tolerance of the edge. if (fa > ZERO) { if (fa <= INITIALIZATION_TOLERANCE) @@ -982,11 +1001,11 @@ namespace GridKit * @brief Solve the effective dam head for a high initial power * * Starting from the configured dam head, brackets a higher value whose - * steady mechanical power at Gmax reaches the initial value, then + * steady mechanical power at Gv5 reaches the initial value, then * bisects to machine rounding. * * @pre verify() reports no errors. - * @pre The initial mechanical power exceeds the configured Gmax endpoint. + * @pre The initial mechanical power exceeds the last gate-curve endpoint. * * @param[in] pmech Mechanical power on the component base. * @return Effective dam head, or a quiet NaN when no finite value is found. @@ -1000,7 +1019,7 @@ namespace GridKit { const RealT nan = std::numeric_limits::quiet_NaN(); const auto residual = [this, pmech](RealT Hdam) - { return initialMechanicalPower(Gmax_, Hdam) - pmech; }; + { return initialMechanicalPower(Gv_[5], Hdam) - pmech; }; RealT a = Hdam_; RealT b = Hdam_; diff --git a/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md b/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md index 0bd3d97bc..31330168e 100644 --- a/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md +++ b/GridKit/Model/PhasorDynamics/Governor/HYGOV/README.md @@ -6,7 +6,8 @@ a nonlinear single-penstock turbine. ## Notes - HYGOVD `dbL`/`dbH`, `db2` backlash, and Kaplan blade-servo fields are not - modeled. The `db2` JSON field is accepted only for source-format compatibility. + modeled. The `db2` JSON field is accepted for source-format compatibility. + A nonzero value logs a warning and is ignored. ## Block Diagram @@ -26,8 +27,8 @@ $T_r$ | [sec] | `Tr` | Temporary-droop reset time $T_f$ | [sec] | `Tf` | Governor error filter time constant | 0.05 | $T_g$ | [sec] | `Tg` | Gate servo time constant | 0.5 | $V_{\mathrm{elm}}$ | [p.u./s] | `Velm` | Maximum desired-gate velocity magnitude | 0.2 | -$G^{\max}$ | [p.u.] | `Gmax` | Maximum desired-gate position | 1.0 | -$G^{\min}$ | [p.u.] | `Gmin` | Minimum desired-gate position | 0.0 | +$G^{\max}$ | [p.u.] | `Gmax` | Configured upper gate response limit | 1.0 | +$G^{\min}$ | [p.u.] | `Gmin` | Configured lower gate response limit | 0.0 | $T_w$ | [sec] | `Tw` | Water inertia time constant | 1.0 | $A_t$ | [p.u.] | `At` | Turbine gain | 1.2 | $D_{\mathrm{turb}}$ | [p.u.] | `Dturb` | Turbine damping coefficient | 0.5 | @@ -35,7 +36,7 @@ $q_{\mathrm{NL}}$ | [p.u.] | `Qnl` | No-load flow at nominal hea $T_n$ | [sec] | `Tn` | Speed lead-lag numerator time constant | 0.0 | $T_{\mathrm{np}}$ | [sec] | `Tnp` | Speed lead-lag denominator time constant | 0.0 | $D_{\omega}$ | [p.u.] | `db1` | Type 1 speed deadband threshold | 0.0 | -$D_2$ | [p.u.] | `db2` | Mechanical backlash deadband | 0.0 | +$D_2$ | [p.u.] | `db2` | Unsupported mechanical backlash deadband | 0.0 | Nonzero values warn and are ignored $H_{\mathrm{dam}}$ | [p.u.] | `Hdam` | Configured dam head | 1.0 | Lower bound on effective head $G_V^{(k)}$ | [p.u.] | `Gv0`-`Gv5` | Gate point $k$ of the gain curve | 0.0 | $k=0,\ldots,5$ $P_{\mathrm{GV}}^{(k)}$ | [p.u.] | `Pgv0`-`Pgv5` | Power point $k$ of the gain curve | 0.0 | $k=0,\ldots,5$ @@ -78,7 +79,7 @@ HYGOV parameter sets are rejected by the following checks: \quad k\in\{0,\ldots,4\} \\ G_V^{(0)} \le G^{\min} &< G^{\max} \le G_V^{(5)} \\ - P_{\mathrm{m}}(G^{\max}) - P_{\mathrm{m}}(G^{\min}) + P_{\mathrm{m}}(G_V^{(5)}) - P_{\mathrm{m}}(G_V^{(0)}) &> \epsilon_{\mathrm{init}} \end{aligned} ``` @@ -177,8 +178,9 @@ $P^\mathrm{aux}$ | [p.u.] | Known | Auxiliary power input | Optional si ### Differential Equations -The effective dam head $H_{\mathrm{dam}}^{\mathrm{eff}}$ is resolved during -initialization. +The effective desired-gate response limits +$G_{\mathrm{resp}}^{\min}$ and $G_{\mathrm{resp}}^{\max}$ and the effective +dam head $H_{\mathrm{dam}}^{\mathrm{eff}}$ are resolved during initialization. ```math \begin{aligned} @@ -193,7 +195,8 @@ initialization. 0 &= -\dot{c} + \text{antiwindup} - \left(c, r_c;\, G^{\min}, G^{\max}\right) \\ + \left(c, r_c;\, G_{\mathrm{resp}}^{\min}, + G_{\mathrm{resp}}^{\max}\right) \\ 0 &= -\dot{g} + \dfrac{1}{T_g} @@ -269,23 +272,31 @@ Initialization requires an exactly zero speed deviation, $\omega = 0$. Restart initialization of a moving machine is not supported. All internal derivatives are set to zero. -Initialization solves the gate at the configured dam head unless the required -mechanical power exceeds the value at $G^{\max}$. In that case it pins the gate -at $G^{\max}$ and raises an effective dam head -$H_{\mathrm{dam}}^{\mathrm{eff}} \ge H_{\mathrm{dam}}$ to reproduce the -operating point. Both searches use the same smooth $N_{\mathrm{GV}}$ curve as -the residual. No upper limit is applied to this adjustment. The raised head -remains the water-column setpoint during simulation, and the gate has no -initial upward margin. +Initialization first solves the gate at the configured dam head over the full +$[G_V^{(0)},G_V^{(5)}]$ gate curve. If that gate lies outside the configured +$[G^{\min},G^{\max}]$ interval, the corresponding response limit is expanded +to include it. The configured parameters are unchanged. + +If the required mechanical power exceeds the value at $G_V^{(5)}$, the gate is +pinned there and an effective dam head +$H_{\mathrm{dam}}^{\mathrm{eff}} \ge H_{\mathrm{dam}}$ is raised to reproduce +the operating point. Both searches use the same smooth $N_{\mathrm{GV}}$ curve +as the residual. No upper limit is applied to the head adjustment. The +effective values remain the response limits and water-column setpoint during +simulation. ```math \begin{aligned} H &\leftarrow H_{\mathrm{dam}}^{\mathrm{eff}} \\ g - &\leftarrow \text{gate in } [G^{\min}, G^{\max}] \text{ satisfying} \\ + &\leftarrow \text{gate in } [G_V^{(0)},G_V^{(5)}] \text{ satisfying} \\ &\qquad k_{\mathrm{base}}P_{\mathrm{m}} = A_t H\left(\sqrt{H}\,N_{\mathrm{GV}}(g) - q_{\mathrm{NL}}\right) \\ + G_{\mathrm{resp}}^{\min} + &\leftarrow \min\!\left(G^{\min},g\right) \\ + G_{\mathrm{resp}}^{\max} + &\leftarrow \max\!\left(G^{\max},g\right) \\ P_{\mathrm{GV}} &\leftarrow N_{\mathrm{GV}}(g) \\ q @@ -308,13 +319,14 @@ initial upward margin. ``` A value within $\epsilon_{\mathrm{init}} = 100\,\epsilon_{\mathrm{mach}}$ -below the $G^{\min}$ endpoint initializes at $G^{\min}$ with a mechanical-power -residual up to $\epsilon_{\mathrm{init}}$. A lower value, or a high-side value -without a finite effective dam head, is rejected. All other accepted values -initialize with every residual at machine rounding. +below the $G_V^{(0)}$ endpoint initializes at $G_V^{(0)}$ with a +mechanical-power residual up to $\epsilon_{\mathrm{init}}$. A lower value, or +a high-side value without a finite effective dam head, is rejected. All other +accepted values initialize with every residual at machine rounding. -Every check resolves before state, the effective dam head, or signals are -written, so a rejected initialization leaves them unchanged. +Every check resolves before state, the effective response limits, the effective +dam head, or signals are written, so a rejected initialization leaves them +unchanged. ### Output Initialization @@ -350,8 +362,8 @@ Output | Units | Description | Note signal configuration, and minimum time-constant handling. - `initializationAndSignals()` checks initialization, base conversion, signal publication, monitor output, and unattached-reference latching. -- `initializationDomain()` checks effective-head initialization, rejection - atomicity, and initialization boundaries. +- `initializationDomain()` checks effective-limit and effective-head + initialization, rejection atomicity, and initialization boundaries. - `initializationExactness()` checks that initialized steady residuals rest at machine rounding across the gate curve. - `residualEquations()` checks every model residual against a fixed diff --git a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp index 110f256c4..333ff02d9 100644 --- a/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp +++ b/tests/UnitTests/PhasorDynamics/GovernorHygovTests.hpp @@ -60,7 +60,8 @@ namespace GridKit success *= (configured.hygov.verify() == 0); noteExpectedLogs("Testing HYGOV defaults and invalid configurations. " - "Logged errors and time-constant warnings are expected."); + "Logged errors, time-constant warnings, and an unsupported " + "backlash warning are expected."); Fixture minimal(makeMinimalData()); success *= (minimal.hygov.verify() == 0); @@ -156,20 +157,21 @@ namespace GridKit {Params::Pgv5, 0.0}}); success *= (flat_curve.hygov.verify() > 0); - // A curve that rises only outside the permitted gate range cannot - // provide a usable steady-power range for initialization. - Fixture flat_active_range(makeData(), - {{Params::Gmin, 0.0}, - {Params::Gmax, 0.2}, - {Params::Pgv0, 0.5}, - {Params::Pgv1, 0.5}, - {Params::Pgv2, 0.5}, - {Params::Pgv3, 0.5}, - {Params::Pgv4, 0.5}, - {Params::Pgv5, 1.0}}); - success *= (flat_active_range.hygov.verify() > 0); - - // db2 is accepted for source-format compatibility and never used. + // A curve that rises only outside the configured response limits is + // valid because initialization may expand those limits. + Fixture flat_configured_range( + makeData(), + {{Params::Gmin, 0.0}, + {Params::Gmax, 0.2}, + {Params::Pgv0, 0.5}, + {Params::Pgv1, 0.5}, + {Params::Pgv2, 0.5}, + {Params::Pgv3, 0.5}, + {Params::Pgv4, 0.5}, + {Params::Pgv5, 1.0}}); + success *= (flat_configured_range.hygov.verify() == 0); + + // A requested backlash is accepted, warns, and remains inactive. Fixture backlash(makeData(), {{Params::db2, 0.5}}); success *= (backlash.hygov.verify() == 0); @@ -329,38 +331,21 @@ namespace GridKit } /// Mechanical-power, gate-limit, speed-deviation, and finite-input - /// initialization domains. High mechanical power raises the effective - /// dam head; every rejected initialization is atomic. + /// initialization domains. Response limits and high-power dam head + /// are adjusted when needed; every rejected initialization is atomic. TestOutcome initializationDomain() { TestStatus success = true; noteExpectedLogs("Testing HYGOV initialization boundaries. " - "Logged errors and dam-head warnings are expected."); - - struct RejectionCase - { - const char* label; - RealT pmech; - RealT gmin; - RealT gmax; - }; - - const std::array rejection_cases{{ - {"mechanical power below the gate curve", -0.3, 0.05, 0.95}, - {"mechanical power below the Gmin limit", 0.4, 0.6, 0.95}, - }}; + "Logged errors, response-limit warnings, and dam-head warnings " + "are expected."); - for (const auto& test_case : rejection_cases) - { - success *= initializationRejectedAtomically( - withParameters(makeResidualData(), - {{Params::Gmin, test_case.gmin}, - {Params::Gmax, test_case.gmax}}), - test_case.pmech, - {{External::OMEGA, 0.0}, {External::PREF, 77.0}, {External::PAUX, 0.02}}, - test_case.label); - } + success *= initializationRejectedAtomically( + makeResidualData(), + -0.3, + {{External::OMEGA, 0.0}, {External::PREF, 77.0}, {External::PAUX, 0.02}}, + "mechanical power below the gate curve"); const auto no_finite_head = withParameters( makeData(), @@ -379,7 +364,10 @@ namespace GridKit // 4.5 MW on the system base is 2.5 pu on a 1.8 MW turbine base. Fixture effective_fixture( makeData(), - {{Params::Trate, 1.8}, {Params::At, 1.25}, {Params::Qnl, 0.07}}); + {{Params::Trate, 1.8}, + {Params::At, 1.25}, + {Params::Qnl, 0.07}, + {Params::Gmax, 0.5}}); effective_fixture.attachAllInputs(); success *= effective_fixture.initialize(0.045); success *= stateMatches( @@ -397,20 +385,61 @@ namespace GridKit success *= (effective_fixture.evaluate() == 0); success *= allResidualsZero(effective_fixture.hygov); - Fixture limited_fixture(makeResidualData(), {{Params::Gmax, 0.5}}); - success *= limited_fixture.initialize(0.4); - success *= stateMatches( - limited_fixture.hygov, - {{Internal::C, 0.5}, - {Internal::G, 0.5}, - {Internal::Q, 0.6242359695868803}, - {Internal::PGV, 0.5399999999999371}, - {Internal::H, 1.3363187439168838}}, - "effective head at Gmax"); - success *= (limited_fixture.evaluate() == 0); - success *= allResidualsZero(limited_fixture.hygov); - - // A failed retry preserves the effective head from the prior success. + struct ResponseLimitCase + { + const char* label; + Params limit_parameter; + RealT limit; + RealT rate; + }; + + const std::array response_limit_cases{{ + {"expanded upper response limit", Params::Gmax, 0.5, 0.1}, + {"expanded lower response limit", Params::Gmin, 0.7, -0.1}, + }}; + + for (const auto& test_case : response_limit_cases) + { + Fixture fixture(makeResidualData(), + {{test_case.limit_parameter, test_case.limit}}); + success *= fixture.initialize(0.4); + const RealT gate = static_cast( + fixture.hygov.y().getData()[static_cast(Internal::C)]); + const bool gate_is_outside = test_case.rate > 0.0 + ? gate > test_case.limit + : gate < test_case.limit; + if (!gate_is_outside) + { + std::cout << test_case.label << " did not initialize outside the configured limit\n"; + success = false; + } + success *= stateMatches(fixture.hygov, + {{Internal::G, gate}, {Internal::H, 1.2}}, + test_case.label); + success *= (fixture.evaluate() == 0); + success *= allResidualsZero(fixture.hygov); + + // The effective response bound admits an outward rate between the + // configured limit and initialized gate. + setState(fixture.hygov, + {{Internal::C, 0.5 * (test_case.limit + gate)}, + {Internal::RC, test_case.rate}}); + setDerivative(fixture.hygov, {{Internal::C, 0.0}}); + success *= (fixture.evaluate() == 0); + const RealT response_rate = static_cast( + fixture.hygov.getResidual().getData()[static_cast(Internal::C)]); + const bool rate_is_admitted = test_case.rate > 0.0 + ? response_rate > 0.9 * test_case.rate + : response_rate < 0.9 * test_case.rate; + if (!rate_is_admitted) + { + std::cout << test_case.label << " did not admit the outward desired-gate rate\n"; + success = false; + } + } + + // A failed retry preserves the effective head and response bounds from + // the prior success. const auto effective_y = copyVector(effective_fixture.hygov.y()); const auto effective_yp = copyVector(effective_fixture.hygov.yp()); effective_fixture.input(External::OMEGA) = 0.03; @@ -424,7 +453,20 @@ namespace GridKit success *= (effective_fixture.evaluate() == 0); success *= allResidualsZero(effective_fixture.hygov); - // A later feasible initialization starts again from configured Hdam. + setState(effective_fixture.hygov, + {{Internal::C, 0.75}, {Internal::RC, 0.2}}); + setDerivative(effective_fixture.hygov, {{Internal::C, 0.0}}); + success *= (effective_fixture.evaluate() == 0); + const RealT preserved_rate = static_cast( + effective_fixture.hygov.getResidual().getData()[static_cast(Internal::C)]); + if (!(preserved_rate > 0.19)) + { + std::cout << "failed initialization did not preserve effective Gmax\n"; + success = false; + } + + // A later feasible initialization starts again from configured limits + // and Hdam. effective_fixture.setPmech(0.009); success *= (effective_fixture.hygov.initialize() == 0); success *= stateMatches(effective_fixture.hygov, @@ -433,6 +475,15 @@ namespace GridKit success *= (effective_fixture.evaluate() == 0); success *= allResidualsZero(effective_fixture.hygov); + setState(effective_fixture.hygov, + {{Internal::C, 0.75}, {Internal::RC, 0.2}}); + setDerivative(effective_fixture.hygov, {{Internal::C, 0.0}}); + success *= (effective_fixture.evaluate() == 0); + success *= scalarMatches( + static_cast(effective_fixture.hygov.getResidual().getData()[static_cast(Internal::C)]), + 0.0, + "configured Gmax after reinitialization"); + // Initialization supports only a zero speed deviation; a moving // machine would need a multi-root gate search. success *= initializationRejectedAtomically(makeResidualData(),