diff --git a/.claude/agents/modernizer.md b/.claude/agents/modernizer.md deleted file mode 100644 index fea0ac4b..00000000 --- a/.claude/agents/modernizer.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -name: modernizer -description: Refactor a pre-roadmap numerical/ algorithm to reuse shared math/ utilities, add coverage-build infra, and simplify/dedupe tests — preserving existing Q15/Q31 support. Behavior-preserving. -model: claude-sonnet-4-6 -tools: [Read, Write, Edit, Bash, TodoWrite] ---- - -Canonical rules: `AGENTS.md`. You modernize ONE pre-roadmap algorithm at a time so it matches -current roadmap conventions. Refactor is **behavior-preserving** — no new features, no public-API -change unless required to remove duplication. - -## Workflow - -1. Read the target `.hpp`, its `test/Test*.cpp`, and `doc//.md`. -2. Find duplication: logic that already exists in `numerical/math/` - (`CompilerOptimizations.hpp`, `Tolerance.hpp`, `ComplexNumber.hpp`, `QNumber.hpp`, - `RecursiveBuffer.hpp`, `Statistics.hpp`, …) or scaffolding repeated across `test/` files - (e.g. `CalculateMagnitude`, twiddle-factor mocks — emulate `PowerDensitySpectrumTestSupport.hpp`). -3. Replace the duplicated logic with the shared utility; delete the local copy. -4. Add coverage-build infra if missing (per `roadmap/DEPLOYMENT.md`): guarded `extern template` - at header bottom + matching `.cpp` (`template class ;`) wired via - `numerical_add_coverage_sources()`. -5. Refactor the `test/Test*.cpp` — **mandatory, never skip**. It is a required deliverable, not - optional cleanup: apply the **Tests checklist** below on every run, even when the production - code needs no change and even when the test already builds green. -6. Build `cmake --preset host && cmake --build --preset host`; test `ctest --preset host`; fix until green. -7. Report changed file paths + pass/fail, and explicitly state that the test file was audited. - -## Tests checklist — apply every run - -The `test/Test*.cpp` is a first-class deliverable of every modernization. Audit and fix: -- [ ] `StrictMock<...>` only — never a bare mock or `NiceMock`. -- [ ] Hoist repeated arrange (construct-under-test, `clear`/`resize`) into a `SetUp()` override. -- [ ] Extract repeated mock-return / `WillOnce(Invoke(...))` tails into fixture helper methods. -- [ ] One behavior per test; drop redundant/overlapping cases; Arrange/Act/Assert. -- [ ] `EXPECT_NEAR` + `math::Tolerance()` for float comparisons. -- [ ] Anonymous-namespace fixture; macros outside; no heap; no comments. - -Keep `TYPED_TEST` where the algorithm is multi-type; behavior and assertions stay identical. - -## Preserve types — hard rule - -Keep existing `Q15`/`Q31` support and its `TYPED_TEST` where the algorithm already has it — do -**NOT** strip multi-type. The multi-type guard -(`static_assert(math::is_qnumber::value || std::is_floating_point_v, ...)`) stays for those. -Float-only migration applies ONLY to algorithms that are already float-only; those follow -`static_assert(std::is_floating_point_v)` + `TEST_F` on `float`. - -## Preserve embedded pragmas — hard rule - -The file-level `#if defined(__GNUC__) || defined(__clang__)` / `#pragma GCC optimize("O3", "fast-math")` / `#endif` -block and the per-hot-path `OPTIMIZE_FOR_SPEED` macro are **complementary, not redundant** — keep BOTH. -- Do **NOT** delete the file-level `#pragma GCC optimize` block; including `CompilerOptimizations.hpp` does not replace it. -- Every hot path (e.g. `Update`, `Predict`, `Process`) must carry `OPTIMIZE_FOR_SPEED`; add it where missing. - -## Memory — quick reference - -**Forbidden**: `new`/`delete`/`malloc`/`free`, `make_unique`/`make_shared`, -`std::vector`/`string`/`deque`/`list`/`map`/`set`. Tests too. No recursion. - -**Use instead**: `infra::BoundedVector::WithMaxSize`, `infra::BoundedString::WithStorage`, -`infra::BoundedDeque::WithMaxSize`, `infra::BoundedList::WithMaxSize`, -`std::array`, `std::optional`. - -## What NOT to do -- No behavior/API change beyond removing duplication. -- No new abstractions except extracting one that is already repeated. -- Don't touch unrelated algorithms; don't strip `Q15`/`Q31`. -- No `make_unique` anywhere, including tests. - -**Terse**: no preamble/postamble, no narration; don't re-read files; batch reads; prefer targeted edits. diff --git a/.github/agents/modernizer.agent.md b/.github/agents/modernizer.agent.md deleted file mode 100644 index 40cd20a3..00000000 --- a/.github/agents/modernizer.agent.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -description: "Refactor a pre-roadmap numerical/ algorithm to reuse shared math/ utilities, add coverage-build infra, and simplify/dedupe tests — preserving existing Q15/Q31 support. Behavior-preserving." -tools: [read, edit, search, execute, todo] -model: "Claude Sonnet 4.6" -handoffs: - - label: "Review Changes" - agent: reviewer - prompt: "Review the refactoring changes made above against numerical-toolbox project standards." ---- - -Canonical rules: `AGENTS.md`. You modernize ONE pre-roadmap algorithm at a time so it matches -current roadmap conventions. Refactor is **behavior-preserving** — no new features, no public-API -change unless required to remove duplication. - -## Workflow - -1. Read the target `.hpp`, its `test/Test*.cpp`, and `doc//.md`. -2. Find duplication: logic that already exists in `numerical/math/` - (`CompilerOptimizations.hpp`, `Tolerance.hpp`, `ComplexNumber.hpp`, `QNumber.hpp`, - `RecursiveBuffer.hpp`, `Statistics.hpp`, …) or scaffolding repeated across `test/` files - (e.g. `CalculateMagnitude`, twiddle-factor mocks — emulate `PowerDensitySpectrumTestSupport.hpp`). -3. Replace the duplicated logic with the shared utility; delete the local copy. -4. Add coverage-build infra if missing (per `roadmap/DEPLOYMENT.md`): guarded `extern template` - at header bottom + matching `.cpp` (`template class ;`) wired via - `numerical_add_coverage_sources()`. -5. Refactor the `test/Test*.cpp` — **mandatory, never skip**. It is a required deliverable, not - optional cleanup: apply the **Tests checklist** below on every run, even when the production - code needs no change and even when the test already builds green. -6. Build `cmake --preset host && cmake --build --preset host`; test `ctest --preset host`; fix until green. -7. Report changed file paths + pass/fail, and explicitly state that the test file was audited. - -## Tests checklist — apply every run - -The `test/Test*.cpp` is a first-class deliverable of every modernization. Audit and fix: -- [ ] `StrictMock<...>` only — never a bare mock or `NiceMock`. -- [ ] Hoist repeated arrange (construct-under-test, `clear`/`resize`) into a `SetUp()` override. -- [ ] Extract repeated mock-return / `WillOnce(Invoke(...))` tails into fixture helper methods. -- [ ] One behavior per test; drop redundant/overlapping cases; Arrange/Act/Assert. -- [ ] `EXPECT_NEAR` + `math::Tolerance()` for float comparisons. -- [ ] Anonymous-namespace fixture; macros outside; no heap; no comments. - -Keep `TYPED_TEST` where the algorithm is multi-type; behavior and assertions stay identical. - -## Preserve types — hard rule - -Keep existing `Q15`/`Q31` support and its `TYPED_TEST` where the algorithm already has it — do -**NOT** strip multi-type. The multi-type guard -(`static_assert(math::is_qnumber::value || std::is_floating_point_v, ...)`) stays for those. -Float-only migration applies ONLY to algorithms that are already float-only; those follow -`static_assert(std::is_floating_point_v)` + `TEST_F` on `float`. - -## Preserve embedded pragmas — hard rule - -The file-level `#if defined(__GNUC__) || defined(__clang__)` / `#pragma GCC optimize("O3", "fast-math")` / `#endif` -block and the per-hot-path `OPTIMIZE_FOR_SPEED` macro are **complementary, not redundant** — keep BOTH. -- Do **NOT** delete the file-level `#pragma GCC optimize` block; including `CompilerOptimizations.hpp` does not replace it. -- Every hot path (e.g. `Update`, `Predict`, `Process`) must carry `OPTIMIZE_FOR_SPEED`; add it where missing. - -## Memory — quick reference - -**Forbidden**: `new`/`delete`/`malloc`/`free`, `make_unique`/`make_shared`, -`std::vector`/`string`/`deque`/`list`/`map`/`set`. Tests too. No recursion. - -**Use instead**: `infra::BoundedVector::WithMaxSize`, `infra::BoundedString::WithStorage`, -`infra::BoundedDeque::WithMaxSize`, `infra::BoundedList::WithMaxSize`, -`std::array`, `std::optional`. - -## What NOT to do -- No behavior/API change beyond removing duplication. -- No new abstractions except extracting one that is already repeated. -- Don't touch unrelated algorithms; don't strip `Q15`/`Q31`. -- No `make_unique` anywhere, including tests. - -**Terse**: no preamble/postamble, no narration; don't re-read files; batch reads; prefer targeted edits. diff --git a/numerical/controllers/implementations/PidIncremental.hpp b/numerical/controllers/implementations/PidIncremental.hpp index 394d6957..1b5a4a14 100644 --- a/numerical/controllers/implementations/PidIncremental.hpp +++ b/numerical/controllers/implementations/PidIncremental.hpp @@ -14,11 +14,9 @@ namespace controllers class PidIncrementalBase { public: + void Reset(); void SetPoint(QNumberType setPoint); - void SetLimits(PidLimits limits); void SetTunings(PidTunings tunnings); - void Enable(); - void Disable(); QNumberType Process(QNumberType processVariable); protected: @@ -50,13 +48,15 @@ namespace controllers { public: PidIncrementalAsynchronous(PidDriver& driver, std::chrono::system_clock::duration sampleTime, PidTunings tunnings, PidLimits limits); + ~PidIncrementalAsynchronous(); + PidIncrementalAsynchronous(const PidIncrementalAsynchronous&) = delete; + PidIncrementalAsynchronous& operator=(const PidIncrementalAsynchronous&) = delete; + PidIncrementalAsynchronous(PidIncrementalAsynchronous&&) = delete; + PidIncrementalAsynchronous& operator=(PidIncrementalAsynchronous&&) = delete; - // Implementation of AsynchronousPidController + void Reset() override; void SetPoint(QNumberType setPoint) override; - void SetLimits(PidLimits limits) override; void SetTunings(PidTunings tunnings) override; - void Enable() override; - void Disable() override; private: PidDriver& driver; @@ -71,12 +71,9 @@ namespace controllers public: PidIncrementalSynchronous(PidTunings tunnings, PidLimits limits); - // Implementation of SynchronousPidController + void Reset() override; void SetPoint(QNumberType setPoint) override; - void SetLimits(PidLimits limits) override; void SetTunings(PidTunings tunnings) override; - void Enable() override; - void Disable() override; QNumberType Process(QNumberType processVariable) override; }; @@ -93,35 +90,20 @@ namespace controllers } template - void PidIncrementalBase::SetPoint(QNumberType _setPoint) - { - this->setPointValue = _setPoint; - this->hasSetPoint = true; - } - - template - void PidIncrementalBase::Enable() + void PidIncrementalBase::Reset() { u = QNumberType(0.0f); u_1 = QNumberType(0.0f); - e = QNumberType(0.0f); e_1 = QNumberType(0.0f); e_2 = QNumberType(0.0f); } template - void PidIncrementalBase::Disable() - { - hasSetPoint = false; - } - - template - void PidIncrementalBase::SetLimits(PidLimits _limits) + void PidIncrementalBase::SetPoint(QNumberType _setPoint) { - really_assert(_limits.max > _limits.min); - - this->limits = _limits; + this->setPointValue = _setPoint; + this->hasSetPoint = true; } template @@ -173,31 +155,25 @@ namespace controllers { this->driver.ControlAction(this->PidIncrementalBase::Process(processVariable)); }); + driver.Start(sampleTime); } - template - void PidIncrementalAsynchronous::SetPoint(QNumberType _setPoint) + template + PidIncrementalAsynchronous::~PidIncrementalAsynchronous() { - PidIncrementalBase::SetPoint(_setPoint); + driver.Stop(); } template - void PidIncrementalAsynchronous::Enable() + void PidIncrementalAsynchronous::Reset() { - PidIncrementalBase::Enable(); - driver.Start(sampleTime); - } - - template - void PidIncrementalAsynchronous::Disable() - { - driver.Stop(); + PidIncrementalBase::Reset(); } template - void PidIncrementalAsynchronous::SetLimits(PidLimits limits) + void PidIncrementalAsynchronous::SetPoint(QNumberType _setPoint) { - PidIncrementalBase::SetLimits(limits); + PidIncrementalBase::SetPoint(_setPoint); } template @@ -213,15 +189,15 @@ namespace controllers } template - void PidIncrementalSynchronous::SetPoint(QNumberType setPoint) + void PidIncrementalSynchronous::Reset() { - PidIncrementalBase::SetPoint(setPoint); + PidIncrementalBase::Reset(); } template - void PidIncrementalSynchronous::SetLimits(PidLimits limits) + void PidIncrementalSynchronous::SetPoint(QNumberType setPoint) { - PidIncrementalBase::SetLimits(limits); + PidIncrementalBase::SetPoint(setPoint); } template @@ -230,18 +206,6 @@ namespace controllers PidIncrementalBase::SetTunings(tunnings); } - template - void PidIncrementalSynchronous::Enable() - { - PidIncrementalBase::Enable(); - } - - template - void PidIncrementalSynchronous::Disable() - { - PidIncrementalBase::Disable(); - } - template OPTIMIZE_FOR_SPEED QNumberType diff --git a/numerical/controllers/implementations/test/TestBangBangHysteresis.cpp b/numerical/controllers/implementations/test/TestBangBangHysteresis.cpp index 0d0c7770..8c20bf90 100644 --- a/numerical/controllers/implementations/test/TestBangBangHysteresis.cpp +++ b/numerical/controllers/implementations/test/TestBangBangHysteresis.cpp @@ -91,3 +91,39 @@ TEST_F(TestBangBangHysteresis, custom_output_levels) EXPECT_FLOAT_EQ(customRelay.Update(0.0f), -1.0f); EXPECT_FLOAT_EQ(customRelay.Update(0.2f), 1.0f); } + +TEST_F(TestBangBangHysteresis, reset_to_high_state) +{ + relay.Reset(controllers::RelayState::High); + + EXPECT_EQ(relay.State(), controllers::RelayState::High); + EXPECT_FLOAT_EQ(relay.Update(0.0f), 1.0f); + EXPECT_EQ(relay.State(), controllers::RelayState::High); +} + +TEST_F(TestBangBangHysteresis, stays_low_inside_band) +{ + float output = relay.Update(0.1f); + + EXPECT_EQ(relay.State(), controllers::RelayState::Low); + EXPECT_FLOAT_EQ(output, 0.0f); +} + +TEST_F(TestBangBangHysteresis, does_not_switch_high_below_upper_threshold) +{ + float output = relay.Update(0.19f); + + EXPECT_EQ(relay.State(), controllers::RelayState::Low); + EXPECT_FLOAT_EQ(output, 0.0f); +} + +TEST_F(TestBangBangHysteresis, reset_then_update_matches_fresh_instance) +{ + relay.Update(0.3f); + relay.Reset(); + + controllers::BangBangHysteresis fresh{ -0.2f, 0.2f, 0.0f, 1.0f }; + + EXPECT_FLOAT_EQ(relay.Update(0.1f), fresh.Update(0.1f)); + EXPECT_EQ(relay.State(), fresh.State()); +} diff --git a/numerical/controllers/implementations/test/TestFeedforward2Dof.cpp b/numerical/controllers/implementations/test/TestFeedforward2Dof.cpp index 11966479..53d4d59a 100644 --- a/numerical/controllers/implementations/test/TestFeedforward2Dof.cpp +++ b/numerical/controllers/implementations/test/TestFeedforward2Dof.cpp @@ -46,7 +46,9 @@ TEST_F(TestFeedforward2Dof, passes_correct_error_to_feedback) EXPECT_CALL(ff, Evaluate(0.4f)).WillOnce(testing::Return(0.0f)); EXPECT_CALL(fb, Process(0.3f)).WillOnce(testing::Return(0.3f)); - controller.Compute(0.4f, 0.1f); + float result{ controller.Compute(0.4f, 0.1f) }; + + EXPECT_NEAR(result, 0.3f, math::Tolerance()); } TEST_F(TestFeedforward2Dof, passes_reference_to_feedforward) @@ -87,6 +89,26 @@ TEST_F(TestFeedforward2Dof, perfect_feedforward_zero_error) EXPECT_NEAR(result, 0.7f, math::Tolerance()); } +TEST_F(TestFeedforward2Dof, output_is_clamped_negative) +{ + EXPECT_CALL(ff, Evaluate(testing::_)).WillOnce(testing::Return(-0.9f)); + EXPECT_CALL(fb, Process(testing::_)).WillOnce(testing::Return(-0.9f)); + + float result{ controller.Compute(-1.0f, 0.0f) }; + + EXPECT_NEAR(result, -1.0f, math::Tolerance()); +} + +TEST_F(TestFeedforward2Dof, zero_feedback_reduces_to_feedforward) +{ + EXPECT_CALL(ff, Evaluate(0.5f)).WillOnce(testing::Return(0.4f)); + EXPECT_CALL(fb, Process(testing::_)).WillOnce(testing::Return(0.0f)); + + float result{ controller.Compute(0.5f, 0.1f) }; + + EXPECT_NEAR(result, 0.4f, math::Tolerance()); +} + TEST_F(TestFeedforward2Dof, reset_delegates_to_feedback) { EXPECT_CALL(fb, Reset()).Times(1); @@ -97,7 +119,7 @@ TEST_F(TestFeedforward2Dof, reset_delegates_to_feedback) TEST_F(TestFeedforward2Dof, negative_reference_handled) { EXPECT_CALL(ff, Evaluate(-0.3f)).WillOnce(testing::Return(-0.2f)); - EXPECT_CALL(fb, Process(testing::_)).WillOnce(testing::Return(-0.1f)); + EXPECT_CALL(fb, Process(-0.3f)).WillOnce(testing::Return(-0.1f)); float result{ controller.Compute(-0.3f, 0.0f) }; diff --git a/numerical/controllers/implementations/test/TestGainScheduledController.cpp b/numerical/controllers/implementations/test/TestGainScheduledController.cpp index d3cc7655..a4ac833a 100644 --- a/numerical/controllers/implementations/test/TestGainScheduledController.cpp +++ b/numerical/controllers/implementations/test/TestGainScheduledController.cpp @@ -5,44 +5,74 @@ namespace { - class TestGainScheduledController - : public ::testing::Test + using Point1 = controllers::SchedulePoint; + using Point2 = controllers::SchedulePoint; + + class TestGainScheduledController : public ::testing::Test { - public: - using Point = controllers::SchedulePoint; - std::array table{ { { 0.0f, { 1.0f } }, + protected: + std::array table{ { { 0.0f, { 1.0f } }, { 0.5f, { 2.0f } }, { 1.0f, { 4.0f } } } }; controllers::GainScheduledController scheduler{ table }; }; + + class TestGainScheduledControllerTwoPoint : public ::testing::Test + { + protected: + std::array table{ { { 0.0f, { 0.0f } }, + { 1.0f, { 1.0f } } } }; + controllers::GainScheduledController scheduler{ table }; + }; } -TEST_F(TestGainScheduledController, returns_exact_gain_at_breakpoint) +TEST_F(TestGainScheduledController, active_gains_zero_initialised_before_schedule) { - const auto& gains = scheduler.Schedule(0.5f); + const auto& gains = scheduler.ActiveGains(); - EXPECT_NEAR(gains[0], 2.0f, math::Tolerance()); + EXPECT_NEAR(gains[0], 0.0f, math::Tolerance()); } -TEST_F(TestGainScheduledController, interpolates_midway_between_points) +TEST_F(TestGainScheduledController, saturates_below_first_breakpoint) +{ + const auto& gains = scheduler.Schedule(-1.0f); + + EXPECT_NEAR(gains[0], 1.0f, math::Tolerance()); +} + +TEST_F(TestGainScheduledController, saturates_at_exact_first_breakpoint) +{ + const auto& gains = scheduler.Schedule(0.0f); + + EXPECT_NEAR(gains[0], 1.0f, math::Tolerance()); +} + +TEST_F(TestGainScheduledController, interpolates_midway_in_first_interval) { const auto& gains = scheduler.Schedule(0.25f); EXPECT_NEAR(gains[0], 1.5f, math::Tolerance()); } -TEST_F(TestGainScheduledController, interpolates_in_second_interval) +TEST_F(TestGainScheduledController, returns_exact_gain_at_interior_breakpoint) +{ + const auto& gains = scheduler.Schedule(0.5f); + + EXPECT_NEAR(gains[0], 2.0f, math::Tolerance()); +} + +TEST_F(TestGainScheduledController, interpolates_midway_in_second_interval) { const auto& gains = scheduler.Schedule(0.75f); EXPECT_NEAR(gains[0], 3.0f, math::Tolerance()); } -TEST_F(TestGainScheduledController, saturates_below_first_breakpoint) +TEST_F(TestGainScheduledController, saturates_at_exact_last_breakpoint) { - const auto& gains = scheduler.Schedule(-1.0f); + const auto& gains = scheduler.Schedule(1.0f); - EXPECT_NEAR(gains[0], 1.0f, math::Tolerance()); + EXPECT_NEAR(gains[0], 4.0f, math::Tolerance()); } TEST_F(TestGainScheduledController, saturates_above_last_breakpoint) @@ -52,33 +82,68 @@ TEST_F(TestGainScheduledController, saturates_above_last_breakpoint) EXPECT_NEAR(gains[0], 4.0f, math::Tolerance()); } -TEST_F(TestGainScheduledController, selects_correct_interval) +TEST_F(TestGainScheduledController, selects_second_interval_just_above_interior_breakpoint) +{ + const auto& gains = scheduler.Schedule(0.5001f); + + EXPECT_NEAR(gains[0], 2.0004f, math::Tolerance()); +} + +TEST_F(TestGainScheduledController, active_gains_persist_after_schedule) +{ + scheduler.Schedule(0.25f); + const auto& gains = scheduler.ActiveGains(); + + EXPECT_NEAR(gains[0], 1.5f, math::Tolerance()); +} + +TEST_F(TestGainScheduledController, sequential_schedule_calls_update_active) { - const float sJustAboveHalf{ 0.5f + 1e-4f }; - const auto& gains = scheduler.Schedule(sJustAboveHalf); + scheduler.Schedule(0.25f); + scheduler.Schedule(0.75f); + const auto& gains = scheduler.ActiveGains(); - const float expected{ 2.0f + (sJustAboveHalf - 0.5f) / (1.0f - 0.5f) * (4.0f - 2.0f) }; - EXPECT_NEAR(gains[0], expected, math::Tolerance()); + EXPECT_NEAR(gains[0], 3.0f, math::Tolerance()); +} + +TEST_F(TestGainScheduledController, identical_inputs_produce_identical_outputs) +{ + const float g1{ scheduler.Schedule(0.25f)[0] }; + const float g2{ scheduler.Schedule(0.25f)[0] }; + + EXPECT_FLOAT_EQ(g1, g2); } TEST_F(TestGainScheduledController, multi_gain_vectors_blend_componentwise) { - using Point2 = controllers::SchedulePoint; - std::array table2{ { { 0.0f, { 1.0f, 10.0f } }, + std::array t{ { { 0.0f, { 1.0f, 10.0f } }, { 0.5f, { 2.0f, 20.0f } }, { 1.0f, { 4.0f, 40.0f } } } }; - controllers::GainScheduledController scheduler2{ table2 }; + controllers::GainScheduledController sched2{ t }; - const auto& gains = scheduler2.Schedule(0.25f); + const auto& gains = sched2.Schedule(0.25f); EXPECT_NEAR(gains[0], 1.5f, math::Tolerance()); EXPECT_NEAR(gains[1], 15.0f, math::Tolerance()); } -TEST_F(TestGainScheduledController, active_gains_persist_between_calls) +TEST_F(TestGainScheduledControllerTwoPoint, interpolates_midpoint_of_minimal_table) { - scheduler.Schedule(0.25f); - const auto& gains = scheduler.ActiveGains(); + const auto& gains = scheduler.Schedule(0.5f); - EXPECT_NEAR(gains[0], 1.5f, math::Tolerance()); + EXPECT_NEAR(gains[0], 0.5f, math::Tolerance()); +} + +TEST_F(TestGainScheduledControllerTwoPoint, saturates_below_on_minimal_table) +{ + const auto& gains = scheduler.Schedule(-0.5f); + + EXPECT_NEAR(gains[0], 0.0f, math::Tolerance()); +} + +TEST_F(TestGainScheduledControllerTwoPoint, saturates_above_on_minimal_table) +{ + const auto& gains = scheduler.Schedule(1.5f); + + EXPECT_NEAR(gains[0], 1.0f, math::Tolerance()); } diff --git a/numerical/controllers/implementations/test/TestLeadLagCompensator.cpp b/numerical/controllers/implementations/test/TestLeadLagCompensator.cpp index 21dae141..7242c7ca 100644 --- a/numerical/controllers/implementations/test/TestLeadLagCompensator.cpp +++ b/numerical/controllers/implementations/test/TestLeadLagCompensator.cpp @@ -1,6 +1,8 @@ #include "numerical/controllers/implementations/LeadLagCompensator.hpp" #include "numerical/math/Tolerance.hpp" #include "gtest/gtest.h" +#include +#include namespace { @@ -20,7 +22,7 @@ TEST_F(TestLeadLagCompensator, dc_gain_matches_continuous) float steadyState{ comp.Compute(1.0f) }; float expected{ params.gain * params.zero / params.pole }; - EXPECT_NEAR(steadyState, expected, 1e-3f); + EXPECT_NEAR(steadyState, expected, math::Tolerance()); } TEST_F(TestLeadLagCompensator, lead_produces_initial_overshoot) @@ -61,7 +63,7 @@ TEST_F(TestLeadLagCompensator, impulse_response_is_stable) comp.Compute(0.0f); float tail{ comp.Compute(0.0f) }; - EXPECT_NEAR(tail, 0.0f, 1e-3f); + EXPECT_NEAR(tail, 0.0f, math::Tolerance()); } TEST_F(TestLeadLagCompensator, coefficients_match_tustin_design) @@ -95,16 +97,6 @@ TEST_F(TestLeadLagCompensator, reset_clears_history) EXPECT_NEAR(out, b0 * nextInput, 1e-5f); } -TEST_F(TestLeadLagCompensator, step_reaches_expected_steady_state) -{ - for (int i = 0; i < 5000; ++i) - comp.Compute(1.0f); - - float finalVal{ comp.Compute(1.0f) }; - float expected{ params.gain * params.zero / params.pole }; - EXPECT_NEAR(finalVal, expected, 1e-3f); -} - TEST_F(TestLeadLagCompensator, unity_when_zero_equals_pole) { controllers::LeadLagParameters unityParams{ 2.0f, 5.0f, 5.0f, 0.01f }; @@ -114,5 +106,75 @@ TEST_F(TestLeadLagCompensator, unity_when_zero_equals_pole) unityComp.Compute(1.0f); float steadyState{ unityComp.Compute(1.0f) }; - EXPECT_NEAR(steadyState, unityParams.gain, 1e-3f); + EXPECT_NEAR(steadyState, unityParams.gain, math::Tolerance()); +} + +TEST_F(TestLeadLagCompensator, determinism_same_input_same_output) +{ + std::array sequence{ 1.0f, 0.5f, -0.3f, 0.8f, 0.0f, 1.2f, -1.0f, 0.4f }; + std::array out1{}; + std::array out2{}; + + for (int i = 0; i < 8; ++i) + out1[i] = comp.Compute(sequence[i]); + + comp.Reset(0.0f); + + for (int i = 0; i < 8; ++i) + out2[i] = comp.Compute(sequence[i]); + + for (int i = 0; i < 8; ++i) + EXPECT_FLOAT_EQ(out1[i], out2[i]); +} + +TEST_F(TestLeadLagCompensator, nyquist_frequency_gain_matches_analytic) +{ + float analyticMag{ params.gain }; + + std::array signs{}; + for (int i = 0; i < 512; ++i) + signs[i] = (i % 2 == 0) ? 1.0f : -1.0f; + + for (int i = 0; i < 400; ++i) + comp.Compute(signs[i]); + + float sumSq{ 0.0f }; + for (int i = 400; i < 512; ++i) + { + float y{ comp.Compute(signs[i]) }; + sumSq += y * y; + } + + float rms{ std::sqrt(sumSq / 112.0f) }; + EXPECT_NEAR(rms, analyticMag, math::Tolerance()); +} + +TEST_F(TestLeadLagCompensator, zero_input_zero_output) +{ + for (int i = 0; i < 50; ++i) + { + float out{ comp.Compute(0.0f) }; + EXPECT_FLOAT_EQ(out, 0.0f); + } +} + +TEST_F(TestLeadLagCompensator, negative_gain_dc_and_stability) +{ + controllers::LeadLagParameters negParams{ -2.0f, 1.0f, 10.0f, 0.01f }; + controllers::LeadLagCompensator negComp{ negParams }; + + negComp.Compute(1.0f); + for (int i = 0; i < 1000; ++i) + negComp.Compute(0.0f); + + float tail{ negComp.Compute(0.0f) }; + EXPECT_NEAR(tail, 0.0f, math::Tolerance()); + + negComp.Reset(0.0f); + for (int i = 0; i < 2000; ++i) + negComp.Compute(1.0f); + + float dcSteady{ negComp.Compute(1.0f) }; + float expected{ negParams.gain * negParams.zero / negParams.pole }; + EXPECT_NEAR(dcSteady, expected, math::Tolerance()); } diff --git a/numerical/controllers/implementations/test/TestLqg.cpp b/numerical/controllers/implementations/test/TestLqg.cpp index 43d7ea73..b4ee0f9b 100644 --- a/numerical/controllers/implementations/test/TestLqg.cpp +++ b/numerical/controllers/implementations/test/TestLqg.cpp @@ -3,6 +3,7 @@ #endif #include "numerical/controllers/implementations/Lqg.hpp" +#include "numerical/math/Tolerance.hpp" #include #include @@ -11,94 +12,132 @@ namespace class TestLqg : public ::testing::Test { protected: - static constexpr std::size_t stateSize = 2; - static constexpr std::size_t inputSize = 1; - static constexpr std::size_t measurementSize = 1; + static constexpr std::size_t kState = 2; + static constexpr std::size_t kInput = 1; + static constexpr std::size_t kMeas = 1; - math::SquareMatrix A{ + static constexpr float kK0 = 7.604472f; + static constexpr float kK1 = 4.977648f; + + math::SquareMatrix A{ { 1.0f, 0.1f }, { 0.0f, 1.0f } }; - math::Matrix B{ + math::Matrix B{ { 0.0f }, { 0.1f } }; - math::Matrix C{ - { 1.0f, 0.0f } - }; - math::Matrix D{}; + math::Matrix C{ { 1.0f, 0.0f } }; + math::Matrix D{}; - math::LinearTimeInvariant plant{ A, B, C, D }; + math::LinearTimeInvariant plant{ A, B, C, D }; - controllers::LqgWeights weights{ - math::SquareMatrix{ { 10.0f, 0.0f }, { 0.0f, 1.0f } }, - math::SquareMatrix{ { 0.1f } } + controllers::LqgWeights weights{ + math::SquareMatrix{ { 10.0f, 0.0f }, { 0.0f, 1.0f } }, + math::SquareMatrix{ { 0.1f } } }; - controllers::LqgNoise noise{ - math::SquareMatrix{ { 0.01f, 0.0f }, { 0.0f, 0.01f } }, - math::SquareMatrix{ { 0.1f } } + controllers::LqgNoise noise{ + math::SquareMatrix{ { 0.01f, 0.0f }, { 0.0f, 0.01f } }, + math::SquareMatrix{ { 0.1f } } }; - math::Vector initialState{}; - math::SquareMatrix initialCovariance{ + math::Vector initialState{}; + math::SquareMatrix initialCovariance{ { 1.0f, 0.0f }, { 0.0f, 1.0f } }; - controllers::Lqg lqg{ + controllers::Lqg lqg{ plant, noise, weights, initialState, initialCovariance }; + + controllers::Lqg MakeFreshLqg() const + { + return { plant, noise, weights, initialState, initialCovariance }; + } }; } -TEST_F(TestLqg, compute_control_returns_finite_value_for_zero_measurement) +TEST_F(TestLqg, lqr_gain_matches_independent_dare_reference) { - math::Vector z{}; - auto u = lqg.ComputeControl(z); - EXPECT_TRUE(std::isfinite(u.at(0, 0))); + EXPECT_NEAR(lqg.GetGain().at(0, 0), kK0, 5e-2f); + EXPECT_NEAR(lqg.GetGain().at(0, 1), kK1, 5e-2f); } -TEST_F(TestLqg, get_gain_returns_nonzero_lqr_gain) +TEST_F(TestLqg, estimated_state_starts_at_constructor_initial_state) { - const auto& K = lqg.GetGain(); - float normSq = K.at(0, 0) * K.at(0, 0) + K.at(0, 1) * K.at(0, 1); - EXPECT_GT(normSq, 0.0f); + auto x = lqg.GetEstimatedState(); + EXPECT_NEAR(x.at(0, 0), 0.0f, math::Tolerance()); + EXPECT_NEAR(x.at(1, 0), 0.0f, math::Tolerance()); } -TEST_F(TestLqg, get_estimated_state_starts_at_initial_state) +TEST_F(TestLqg, control_output_is_finite_for_zero_measurement) { - auto x = lqg.GetEstimatedState(); - EXPECT_NEAR(x.at(0, 0), initialState.at(0, 0), 1e-6f); - EXPECT_NEAR(x.at(1, 0), initialState.at(1, 0), 1e-6f); + math::Vector z{}; + const auto u = lqg.ComputeControl(z); + EXPECT_TRUE(std::isfinite(u.at(0, 0))); } -TEST_F(TestLqg, repeated_calls_update_estimated_state) +TEST_F(TestLqg, two_fresh_instances_produce_identical_first_control_output) { - math::Vector z{ { 1.0f } }; - lqg.ComputeControl(z); + auto lqg1 = MakeFreshLqg(); + auto lqg2 = MakeFreshLqg(); + + math::Vector z{ { 1.0f } }; + const float u1 = lqg1.ComputeControl(z).at(0, 0); + const float u2 = lqg2.ComputeControl(z).at(0, 0); + + EXPECT_FLOAT_EQ(u1, u2); +} + +TEST_F(TestLqg, estimated_state_updates_after_measurement) +{ + math::Vector z{ { 1.0f } }; lqg.ComputeControl(z); - auto x = lqg.GetEstimatedState(); - EXPECT_NE(x.at(0, 0), initialState.at(0, 0)); + const auto x = lqg.GetEstimatedState(); + const float norm = std::sqrt(x.at(0, 0) * x.at(0, 0) + x.at(1, 0) * x.at(1, 0)); + EXPECT_GT(norm, math::Tolerance()); } -TEST_F(TestLqg, control_drives_state_toward_zero) +TEST_F(TestLqg, noiseless_simulation_state_norm_decays_over_horizon) { - math::Vector z{ { 5.0f } }; + math::Vector trueState{ { 1.0f }, { 0.0f } }; + + const float normInit = std::sqrt( + trueState.at(0, 0) * trueState.at(0, 0) + + trueState.at(1, 0) * trueState.at(1, 0)); + + for (std::size_t step = 0; step < 100; ++step) + { + math::Vector z{ { trueState.at(0, 0) } }; + const auto u = lqg.ComputeControl(z); - float firstU = lqg.ComputeControl(z).at(0, 0); + const float x0 = A.at(0, 0) * trueState.at(0, 0) + A.at(0, 1) * trueState.at(1, 0) + + B.at(0, 0) * u.at(0, 0); + const float x1 = A.at(1, 0) * trueState.at(0, 0) + A.at(1, 1) * trueState.at(1, 0) + + B.at(1, 0) * u.at(0, 0); + trueState = math::Vector{ { x0 }, { x1 } }; + } - math::Vector zNear{ { 0.1f } }; - float laterU = lqg.ComputeControl(zNear).at(0, 0); + const float normFinal = std::sqrt( + trueState.at(0, 0) * trueState.at(0, 0) + + trueState.at(1, 0) * trueState.at(1, 0)); - EXPECT_LT(std::abs(laterU), std::abs(firstU) + 1.0f); + EXPECT_LT(normFinal, normInit * 1e-3f); } -TEST_F(TestLqg, lqg_satisfies_output_feedback_controller_interface) +TEST_F(TestLqg, polymorphic_interface_computes_same_output_as_concrete) { - controllers::OutputFeedbackController* ptr = &lqg; - math::Vector z{}; - auto u = ptr->ComputeControl(z); - EXPECT_TRUE(std::isfinite(u.at(0, 0))); + auto lqg1 = MakeFreshLqg(); + auto lqg2 = MakeFreshLqg(); + + math::Vector z{ { 0.5f } }; + const float uConcrete = lqg1.ComputeControl(z).at(0, 0); + + controllers::OutputFeedbackController* ptr = &lqg2; + const float uInterface = ptr->ComputeControl(z).at(0, 0); + + EXPECT_FLOAT_EQ(uConcrete, uInterface); } diff --git a/numerical/controllers/implementations/test/TestLqr.cpp b/numerical/controllers/implementations/test/TestLqr.cpp index 18b46479..57708883 100644 --- a/numerical/controllers/implementations/test/TestLqr.cpp +++ b/numerical/controllers/implementations/test/TestLqr.cpp @@ -1,115 +1,174 @@ #include "numerical/controllers/implementations/Lqr.hpp" #include "numerical/math/LinearTimeInvariant.hpp" +#include "numerical/math/Tolerance.hpp" +#include #include namespace { class TestLqr : public ::testing::Test { + protected: + static constexpr float kGoldenRatio = 1.6180340f; + static constexpr float kGoldenK = 0.6180340f; + static constexpr float kK0 = 7.604472f; + static constexpr float kK1 = 4.977648f; + static constexpr float kClosedLoopRadius = 0.760447f; + + math::SquareMatrix A2{ + { 1.0f, 0.1f }, + { 0.0f, 1.0f } + }; + math::Matrix B2{ + { 0.0f }, + { 0.1f } + }; + math::SquareMatrix Q2{ + { 10.0f, 0.0f }, + { 0.0f, 1.0f } + }; + math::SquareMatrix R2{ { 0.1f } }; }; } -TEST_F(TestLqr, compute_control_returns_negative_gain_times_state) +TEST_F(TestLqr, precomputed_gain_control_law_matches_negative_gain_times_state) { - math::Matrix K{ - { 0.5f, 0.3f } - }; - - controllers::Lqr lqr(K); + math::Matrix K{ { 0.5f, 0.3f } }; + controllers::Lqr lqr{ K }; math::Vector state{ { 1.0f }, { 2.0f } }; auto u = lqr.ComputeControl(state); - EXPECT_NEAR(u.at(0, 0), -1.1f, 1e-3f); + EXPECT_NEAR(u.at(0, 0), -(0.5f * 1.0f + 0.3f * 2.0f), math::Tolerance()); } -TEST_F(TestLqr, compute_control_with_zero_state_returns_zero) +TEST_F(TestLqr, zero_state_produces_zero_control) { - math::Matrix K{ - { 0.5f, 0.3f } - }; + math::Matrix K{ { 0.5f, 0.3f } }; + controllers::Lqr lqr{ K }; - controllers::Lqr lqr(K); - - math::Vector zeroState{ { 0.0f }, { 0.0f } }; + math::Vector zeroState{}; auto u = lqr.ComputeControl(zeroState); - EXPECT_NEAR(u.at(0, 0), 0.0f, 1e-6f); + EXPECT_NEAR(u.at(0, 0), 0.0f, math::Tolerance()); } -TEST_F(TestLqr, compute_control_with_multi_input) +TEST_F(TestLqr, multi_input_precomputed_gain_applies_correctly) { math::Matrix K{ { 1.0f, 0.0f }, { 0.0f, 2.0f } }; - - controllers::Lqr lqr(K); + controllers::Lqr lqr{ K }; math::Vector state{ { 3.0f }, { 4.0f } }; auto u = lqr.ComputeControl(state); - EXPECT_NEAR(u.at(0, 0), -3.0f, 1e-3f); - EXPECT_NEAR(u.at(1, 0), -8.0f, 1e-3f); + EXPECT_NEAR(u.at(0, 0), -3.0f, math::Tolerance()); + EXPECT_NEAR(u.at(1, 0), -8.0f, math::Tolerance()); } -TEST_F(TestLqr, get_gain_returns_precomputed_gain) +TEST_F(TestLqr, scalar_dare_gain_matches_analytic_golden_ratio_root) { - math::Matrix K{ - { 0.5f, 0.3f } - }; + math::SquareMatrix A{ { 1.0f } }; + math::Matrix B{ { 1.0f } }; + math::SquareMatrix Q{ { 1.0f } }; + math::SquareMatrix R{ { 1.0f } }; - controllers::Lqr lqr(K); + controllers::Lqr lqr{ A, B, Q, R }; - EXPECT_NEAR(lqr.GetGain().at(0, 0), 0.5f, 1e-6f); - EXPECT_NEAR(lqr.GetGain().at(0, 1), 0.3f, 1e-6f); + EXPECT_NEAR(lqr.GetGain().at(0, 0), kGoldenK, 1e-4f); } -TEST_F(TestLqr, dare_constructor_produces_non_zero_gain) +TEST_F(TestLqr, scalar_dare_riccati_solution_matches_analytic_golden_ratio) { math::SquareMatrix A{ { 1.0f } }; math::Matrix B{ { 1.0f } }; math::SquareMatrix Q{ { 1.0f } }; math::SquareMatrix R{ { 1.0f } }; - controllers::Lqr lqr(A, B, Q, R); + controllers::Lqr lqr{ A, B, Q, R }; - EXPECT_GT(lqr.GetGain().at(0, 0), 0.0f); + EXPECT_NEAR(lqr.GetRiccatiSolution().at(0, 0), kGoldenRatio, 1e-4f); } -TEST_F(TestLqr, dare_constructor_stores_riccati_solution) +TEST_F(TestLqr, double_integrator_dare_gain_matches_independent_reference) { - math::SquareMatrix A{ { 1.0f } }; - math::Matrix B{ { 1.0f } }; - math::SquareMatrix Q{ { 1.0f } }; - math::SquareMatrix R{ { 1.0f } }; + controllers::Lqr lqr{ A2, B2, Q2, R2 }; + + EXPECT_NEAR(lqr.GetGain().at(0, 0), kK0, 5e-2f); + EXPECT_NEAR(lqr.GetGain().at(0, 1), kK1, 5e-2f); +} + +TEST_F(TestLqr, double_integrator_closed_loop_spectral_radius_below_one) +{ + controllers::Lqr lqr{ A2, B2, Q2, R2 }; - controllers::Lqr lqr(A, B, Q, R); + const float k0 = lqr.GetGain().at(0, 0); + const float k1 = lqr.GetGain().at(0, 1); - EXPECT_GT(lqr.GetRiccatiSolution().at(0, 0), 0.0f); + const float cl00 = 1.0f; + const float cl01 = 0.1f; + const float cl10 = -0.1f * k0; + const float cl11 = 1.0f - 0.1f * k1; + + const float trace = cl00 + cl11; + const float det = cl00 * cl11 - cl01 * cl10; + const float disc = trace * trace - 4.0f * det; + + float spectralRadius{}; + if (disc >= 0.0f) + { + const float e1 = (trace + std::sqrt(disc)) / 2.0f; + const float e2 = (trace - std::sqrt(disc)) / 2.0f; + spectralRadius = std::max(std::abs(e1), std::abs(e2)); + } + else + { + const float re = trace / 2.0f; + const float im = std::sqrt(-disc) / 2.0f; + spectralRadius = std::sqrt(re * re + im * im); + } + + EXPECT_NEAR(spectralRadius, kClosedLoopRadius, 5e-3f); + EXPECT_LT(spectralRadius, 1.0f); } -TEST_F(TestLqr, lqr_from_lti_matches_lqr_from_matrices) +TEST_F(TestLqr, lti_constructor_produces_identical_gain_to_matrix_constructor) { - math::SquareMatrix A{ - { 1.0f, 0.1f }, - { 0.0f, 1.0f } - }; - math::Matrix B{ - { 0.0f }, - { 0.1f } - }; - math::SquareMatrix Q{ - { 10.0f, 0.0f }, - { 0.0f, 1.0f } - }; - math::SquareMatrix R{ { 0.1f } }; + auto plant = math::LinearTimeInvariant::WithFullStateOutput(A2, B2); - auto plant = math::LinearTimeInvariant::WithFullStateOutput(A, B); + controllers::Lqr lqrMat{ A2, B2, Q2, R2 }; + controllers::Lqr lqrLti{ plant, Q2, R2 }; - controllers::Lqr lqrFromMatrices{ A, B, Q, R }; - controllers::Lqr lqrFromLti{ plant, Q, R }; + EXPECT_NEAR(lqrMat.GetGain().at(0, 0), lqrLti.GetGain().at(0, 0), math::Tolerance()); + EXPECT_NEAR(lqrMat.GetGain().at(0, 1), lqrLti.GetGain().at(0, 1), math::Tolerance()); +} - EXPECT_NEAR(lqrFromMatrices.GetGain().at(0, 0), lqrFromLti.GetGain().at(0, 0), 1e-4f); - EXPECT_NEAR(lqrFromMatrices.GetGain().at(0, 1), lqrFromLti.GetGain().at(0, 1), 1e-4f); +TEST_F(TestLqr, two_instances_with_same_matrices_produce_identical_gains) +{ + controllers::Lqr lqr1{ A2, B2, Q2, R2 }; + controllers::Lqr lqr2{ A2, B2, Q2, R2 }; + + EXPECT_FLOAT_EQ(lqr1.GetGain().at(0, 0), lqr2.GetGain().at(0, 0)); + EXPECT_FLOAT_EQ(lqr1.GetGain().at(0, 1), lqr2.GetGain().at(0, 1)); +} + +TEST_F(TestLqr, closed_loop_state_norm_decays_over_simulation) +{ + controllers::Lqr lqr{ A2, B2, Q2, R2 }; + + math::Vector x{ { 1.0f }, { 0.0f } }; + const float normInit = std::sqrt(x.at(0, 0) * x.at(0, 0) + x.at(1, 0) * x.at(1, 0)); + + for (std::size_t step = 0; step < 100; ++step) + { + const auto u = lqr.ComputeControl(x); + const float x0 = A2.at(0, 0) * x.at(0, 0) + A2.at(0, 1) * x.at(1, 0) + B2.at(0, 0) * u.at(0, 0); + const float x1 = A2.at(1, 0) * x.at(0, 0) + A2.at(1, 1) * x.at(1, 0) + B2.at(1, 0) * u.at(0, 0); + x = math::Vector{ { x0 }, { x1 } }; + } + + const float normFinal = std::sqrt(x.at(0, 0) * x.at(0, 0) + x.at(1, 0) * x.at(1, 0)); + EXPECT_LT(normFinal, normInit * 1e-4f); } diff --git a/numerical/controllers/implementations/test/TestLuenbergerObserver.cpp b/numerical/controllers/implementations/test/TestLuenbergerObserver.cpp index ec4cdf55..114a49af 100644 --- a/numerical/controllers/implementations/test/TestLuenbergerObserver.cpp +++ b/numerical/controllers/implementations/test/TestLuenbergerObserver.cpp @@ -1,6 +1,7 @@ #include "numerical/controllers/implementations/LuenbergerObserver.hpp" #include "numerical/math/LinearTimeInvariant.hpp" #include "numerical/math/Tolerance.hpp" +#include #include #include @@ -85,51 +86,6 @@ TEST_F(TestLuenbergerObserver, ackermann_places_requested_poles) EXPECT_NEAR(det, expectedDet, 1e-3f); } -TEST_F(TestLuenbergerObserver, faster_poles_converge_quicker) -{ - math::Vector xtrue{ { 0.0f }, { 0.0f } }; - math::Vector xhatWrong{ { 1.0f }, { 1.0f } }; - math::Vector u{ { 0.0f } }; - - std::array slowPoles{ 0.5f, 0.5f }; - std::array fastPoles{ 0.05f, 0.05f }; - - auto Lslow = Observer::AckermannGain(plant, slowPoles); - auto Lfast = Observer::AckermannGain(plant, fastPoles); - - Observer slowObs{ plant, Lslow }; - Observer fastObs{ plant, Lfast }; - slowObs.Reset(xhatWrong); - fastObs.Reset(xhatWrong); - - constexpr float tol = 0.05f; - int slowSteps = 0; - int fastSteps = 0; - - for (int k = 0; k < 100; ++k) - { - math::Vector y = plant.C * xtrue; - - slowObs.Update(u, y); - fastObs.Update(u, y); - - auto eS0 = slowObs.Estimate().at(0, 0) - xtrue.at(0, 0); - auto eS1 = slowObs.Estimate().at(1, 0) - xtrue.at(1, 0); - auto eF0 = fastObs.Estimate().at(0, 0) - xtrue.at(0, 0); - auto eF1 = fastObs.Estimate().at(1, 0) - xtrue.at(1, 0); - - float normSlow = std::sqrt(eS0 * eS0 + eS1 * eS1); - float normFast = std::sqrt(eF0 * eF0 + eF1 * eF1); - - if (slowSteps == 0 && normSlow < tol) - slowSteps = k + 1; - if (fastSteps == 0 && normFast < tol) - fastSteps = k + 1; - } - - EXPECT_LT(fastSteps, slowSteps); -} - TEST_F(TestLuenbergerObserver, rejects_initial_error_geometrically) { std::array poles{ 0.2f, 0.3f }; @@ -214,3 +170,102 @@ TEST_F(TestLuenbergerObserver, feedthrough_D_accounted_in_output) EXPECT_NEAR(xhat.at(0, 0), xNext.at(0, 0), math::Tolerance()); EXPECT_NEAR(xhat.at(1, 0), xNext.at(1, 0), math::Tolerance()); } + +TEST_F(TestLuenbergerObserver, determinism_same_sequence_identical_outputs) +{ + std::array poles{ 0.2f, 0.3f }; + auto L = Observer::AckermannGain(plant, poles); + + Observer obs1{ plant, L }; + Observer obs2{ plant, L }; + math::Vector x0{ { 1.5f }, { -0.5f } }; + obs1.Reset(x0); + obs2.Reset(x0); + + std::array uSeq{ 0.3f, -0.1f, 0.5f, 0.0f, -0.2f, 0.4f }; + std::array ySeq{ 1.0f, 0.8f, 0.6f, 0.4f, 0.2f, 0.1f }; + + for (std::size_t k = 0; k < uSeq.size(); ++k) + { + math::Vector u{ { uSeq[k] } }; + math::Vector y{ { ySeq[k] } }; + auto r1 = obs1.Update(u, y); + auto r2 = obs2.Update(u, y); + EXPECT_FLOAT_EQ(r1.at(0, 0), r2.at(0, 0)); + EXPECT_FLOAT_EQ(r1.at(1, 0), r2.at(1, 0)); + } +} + +TEST_F(TestLuenbergerObserver, two_instances_interleaved_do_not_interfere) +{ + std::array poles{ 0.2f, 0.3f }; + auto L = Observer::AckermannGain(plant, poles); + + Observer obsA{ plant, L }; + Observer obsB{ plant, L }; + math::Vector x0{ { 1.0f }, { 0.0f } }; + obsA.Reset(x0); + obsB.Reset(x0); + + std::array uSeq{ 0.2f, -0.3f, 0.1f, 0.4f }; + std::array ySeq{ 0.9f, 0.7f, 0.5f, 0.3f }; + + for (std::size_t k = 0; k < uSeq.size(); ++k) + { + math::Vector u{ { uSeq[k] } }; + math::Vector y{ { ySeq[k] } }; + auto rA = obsA.Update(u, y); + auto rB = obsB.Update(u, y); + EXPECT_FLOAT_EQ(rA.at(0, 0), rB.at(0, 0)); + EXPECT_FLOAT_EQ(rA.at(1, 0), rB.at(1, 0)); + } +} + +TEST_F(TestLuenbergerObserver, zero_input_zero_measurement_zero_state_stays_zero) +{ + observer.Reset(math::Vector{ { 0.0f }, { 0.0f } }); + math::Vector u{ { 0.0f } }; + math::Vector y{ { 0.0f } }; + + for (int k = 0; k < 10; ++k) + { + auto xhat = observer.Update(u, y); + EXPECT_FLOAT_EQ(xhat.at(0, 0), 0.0f); + EXPECT_FLOAT_EQ(xhat.at(1, 0), 0.0f); + } +} + +TEST_F(TestLuenbergerObserver, closed_loop_eigenvalues_inside_unit_disk) +{ + std::array poles{ 0.2f, 0.3f }; + auto L = Observer::AckermannGain(plant, poles); + + math::SquareMatrix AminusLC = plant.A; + for (std::size_t i = 0; i < 2; ++i) + for (std::size_t k = 0; k < 2; ++k) + AminusLC.at(i, k) -= L.at(i, 0) * plant.C.at(0, k); + + float tr = AminusLC.Trace(); + float dt = AminusLC.at(0, 0) * AminusLC.at(1, 1) - AminusLC.at(0, 1) * AminusLC.at(1, 0); + + float discriminant = tr * tr - 4.0f * dt; + float sqrtD = std::sqrt(std::abs(discriminant)); + float lambda1mag; + float lambda2mag; + + if (discriminant >= 0.0f) + { + lambda1mag = std::abs(0.5f * (tr + sqrtD)); + lambda2mag = std::abs(0.5f * (tr - sqrtD)); + } + else + { + float realPart = 0.5f * tr; + float imagPart = 0.5f * sqrtD; + lambda1mag = std::sqrt(realPart * realPart + imagPart * imagPart); + lambda2mag = lambda1mag; + } + + EXPECT_LT(lambda1mag, 1.0f); + EXPECT_LT(lambda2mag, 1.0f); +} diff --git a/numerical/controllers/implementations/test/TestMpc.cpp b/numerical/controllers/implementations/test/TestMpc.cpp index fd5845c2..6b360acb 100644 --- a/numerical/controllers/implementations/test/TestMpc.cpp +++ b/numerical/controllers/implementations/test/TestMpc.cpp @@ -1,5 +1,6 @@ #include "numerical/controllers/implementations/Mpc.hpp" #include "numerical/math/LinearTimeInvariant.hpp" +#include "numerical/math/Tolerance.hpp" #include namespace @@ -34,7 +35,7 @@ TEST_F(TestMpc, compute_control_returns_zero_for_zero_state) math::Vector zeroState{ 0.0f, 0.0f }; auto u = mpc.ComputeControl(zeroState); - EXPECT_NEAR(u.at(0, 0), 0.0f, 1e-4f); + EXPECT_NEAR(u.at(0, 0), 0.0f, math::Tolerance()); } TEST_F(TestMpc, compute_control_produces_nonzero_for_nonzero_state) @@ -199,14 +200,11 @@ TEST_F(TestMpc, precomputed_constructor_matches_online_computation) auto uOnline = mpcOnline.ComputeControl(state); auto uPrecomputed = mpcPrecomputed.ComputeControl(state); - EXPECT_NEAR(uOnline.at(0, 0), uPrecomputed.at(0, 0), 1e-4f); + EXPECT_NEAR(uOnline.at(0, 0), uPrecomputed.at(0, 0), math::Tolerance()); } TEST_F(TestMpc, double_integrator_regulation) { - // Double integrator: x1 = position, x2 = velocity - // x1[k+1] = x1[k] + dt * x2[k] - // x2[k+1] = x2[k] + dt * u[k] float dt = 0.1f; math::SquareMatrix A{ @@ -351,7 +349,7 @@ TEST_F(TestMpc, clear_reference_reverts_to_origin_regulation) mpc.ClearReference(); auto u2 = mpc.ComputeControl(state); - EXPECT_NEAR(u2.at(0, 0), 0.0f, 1e-4f); + EXPECT_NEAR(u2.at(0, 0), 0.0f, math::Tolerance()); } TEST_F(TestMpc, three_state_system_regulation) @@ -501,7 +499,7 @@ TEST_F(TestMpc, three_state_precomputed_constructor) auto uOnline = mpcOnline.ComputeControl(state); auto uPrecomputed = mpcPrecomputed.ComputeControl(state); - EXPECT_NEAR(uOnline.at(0, 0), uPrecomputed.at(0, 0), 1e-3f); + EXPECT_NEAR(uOnline.at(0, 0), uPrecomputed.at(0, 0), math::Tolerance()); } TEST_F(TestMpc, different_prediction_and_control_horizons_with_constraints) @@ -607,7 +605,7 @@ TEST_F(TestMpc, different_prediction_and_control_horizons_precomputed) auto uOnline = mpcOnline.ComputeControl(state); auto uPrecomputed = mpcPrecomputed.ComputeControl(state); - EXPECT_NEAR(uOnline.at(0, 0), uPrecomputed.at(0, 0), 1e-3f); + EXPECT_NEAR(uOnline.at(0, 0), uPrecomputed.at(0, 0), math::Tolerance()); } TEST_F(TestMpc, ten_horizon_precomputed_with_constraints) @@ -686,7 +684,7 @@ TEST_F(TestMpc, three_state_clear_reference_and_get_sequence) mpc.ClearReference(); auto u2 = mpc.ComputeControl(state); - EXPECT_NEAR(u2.at(0, 0), 0.0f, 1e-4f); + EXPECT_NEAR(u2.at(0, 0), 0.0f, math::Tolerance()); auto seq = mpc.GetControlSequence(); EXPECT_EQ(seq.size(), 10u); @@ -724,7 +722,7 @@ TEST_F(TestMpc, different_horizons_clear_reference) mpc.ClearReference(); auto u2 = mpc.ComputeControl(state); - EXPECT_NEAR(u2.at(0, 0), 0.0f, 1e-4f); + EXPECT_NEAR(u2.at(0, 0), 0.0f, math::Tolerance()); } TEST_F(TestMpc, mpc_from_lti_matches_mpc_from_matrices) @@ -754,7 +752,7 @@ TEST_F(TestMpc, mpc_from_lti_matches_mpc_from_matrices) auto uMatrices = mpcFromMatrices.ComputeControl(state); auto uLti = mpcFromLti.ComputeControl(state); - EXPECT_NEAR(uMatrices.at(0, 0), uLti.at(0, 0), 1e-4f); + EXPECT_NEAR(uMatrices.at(0, 0), uLti.at(0, 0), math::Tolerance()); } TEST_F(TestMpc, umin_only_constraint_clamps_lower_bound) @@ -774,11 +772,9 @@ TEST_F(TestMpc, umin_only_constraint_clamps_lower_bound) controllers::MpcConstraints constraints; constraints.uMin = math::Vector{ -0.3f }; - // uMax intentionally not set controllers::Mpc mpc(A, B, weights, constraints); - // Negative state drives control negative; clamp should enforce lower bound math::Vector state{ -10.0f, 0.0f }; auto u = mpc.ComputeControl(state); @@ -806,11 +802,9 @@ TEST_F(TestMpc, umax_only_constraint_clamps_upper_bound) controllers::MpcConstraints constraints; constraints.uMax = math::Vector{ 0.3f }; - // uMin intentionally not set controllers::Mpc mpc(A, B, weights, constraints); - // Positive state drives control positive; clamp should enforce upper bound math::Vector state{ 10.0f, 0.0f }; auto u = mpc.ComputeControl(state); @@ -820,3 +814,66 @@ TEST_F(TestMpc, umax_only_constraint_clamps_upper_bound) for (std::size_t k = 0; k < seq.size(); ++k) EXPECT_LE(seq[k].at(0, 0), 0.3f); } + +TEST_F(TestMpc, compute_control_is_deterministic) +{ + float dt = 0.1f; + + math::SquareMatrix A{ + { 1.0f, dt }, + { 0.0f, 1.0f } + }; + math::Matrix B{ + { 0.5f * dt * dt }, + { dt } + }; + + controllers::MpcWeights weights; + weights.Q = math::SquareMatrix{ + { 10.0f, 0.0f }, + { 0.0f, 1.0f } + }; + weights.R = math::SquareMatrix{ + { 0.1f } + }; + + controllers::Mpc mpc(A, B, weights); + + math::Vector state{ 1.5f, -0.5f }; + auto u1 = mpc.ComputeControl(state); + auto u2 = mpc.ComputeControl(state); + + EXPECT_FLOAT_EQ(u1.at(0, 0), u2.at(0, 0)); +} + +TEST_F(TestMpc, no_constraints_does_not_clamp_output) +{ + math::SquareMatrix A{ + { 1.0f, 1.0f }, + { 0.0f, 1.0f } + }; + math::Matrix B{ + { 0.5f }, + { 1.0f } + }; + + controllers::MpcWeights weights; + weights.Q = math::SquareMatrix{ + { 100.0f, 0.0f }, + { 0.0f, 100.0f } + }; + weights.R = math::SquareMatrix{ + { 0.01f } + }; + + controllers::Mpc mpcUnconstrained(A, B, weights); + + controllers::MpcConstraints constraints; + controllers::Mpc mpcDefaultConstraints(A, B, weights, constraints); + + math::Vector state{ 10.0f, 0.0f }; + auto uUnconstrained = mpcUnconstrained.ComputeControl(state); + auto uDefault = mpcDefaultConstraints.ComputeControl(state); + + EXPECT_FLOAT_EQ(uUnconstrained.at(0, 0), uDefault.at(0, 0)); +} diff --git a/numerical/controllers/implementations/test/TestPidIncremental.cpp b/numerical/controllers/implementations/test/TestPidIncremental.cpp index f194005d..9e1f46fd 100644 --- a/numerical/controllers/implementations/test/TestPidIncremental.cpp +++ b/numerical/controllers/implementations/test/TestPidIncremental.cpp @@ -1,536 +1,322 @@ #include "numerical/controllers/implementations/PidIncremental.hpp" #include "numerical/controllers/implementations/test_doubles/PidDriverMock.hpp" +#include "numerical/math/QNumber.hpp" #include "numerical/math/Tolerance.hpp" #include "gmock/gmock.h" #include "gtest/gtest.h" namespace { + using PidTypes = ::testing::Types; + template - class TestPidIncremental - : public ::testing::Test + class TestPidIncrementalSynchronous : public ::testing::Test { public: - ::testing::StrictMock> driver; - std::optional> controller; - std::chrono::system_clock::duration sampleTime{ std::chrono::milliseconds(100) }; - infra::Function readCallback; + controllers::PidIncrementalSynchronous MakeController( + controllers::PidTunings tunings, + controllers::PidLimits limits) + { + return controllers::PidIncrementalSynchronous{ tunings, limits }; + } + + controllers::PidTunings PGains(float kp) + { + return { T(kp), T(0.0f), T(0.0f) }; + } + + controllers::PidTunings IGains(float ki) + { + return { T(0.0f), T(ki), T(0.0f) }; + } - void CreateController(controllers::PidTunings tunings, controllers::PidLimits limits) + controllers::PidTunings DGains(float kd) + { + return { T(0.0f), T(0.0f), T(kd) }; + } + + controllers::PidTunings PIDGains(float kp, float ki, float kd) + { + return { T(kp), T(ki), T(kd) }; + } + + controllers::PidLimits WideLimit() + { + return { T(-0.9f), T(0.9f) }; + } + + controllers::PidLimits NarrowLimit() + { + return { T(-0.1f), T(0.1f) }; + } + }; + + TYPED_TEST_SUITE(TestPidIncrementalSynchronous, PidTypes); + + class TestPidIncrementalAsynchronous : public ::testing::Test + { + public: + ::testing::StrictMock> driver; + std::optional> controller; + std::chrono::system_clock::duration sampleTime; + infra::Function readCallback; + + TestPidIncrementalAsynchronous() + : sampleTime{ std::chrono::milliseconds(100) } + {} + + void CreateController(controllers::PidTunings tunings, controllers::PidLimits limits) { using ::testing::_; using ::testing::SaveArg; EXPECT_CALL(driver, Read(_)) .WillOnce(SaveArg<0>(&readCallback)); + EXPECT_CALL(driver, Start(_)); controller.emplace(driver, sampleTime, tunings, limits); } - T ProcessValue(T measuredValue) + void TearDown() override { - T output{}; + if (controller.has_value()) + { + EXPECT_CALL(driver, Stop()); + controller.reset(); + } + } + float ProcessValue(float measuredValue) + { + float output{}; EXPECT_CALL(driver, ControlAction(::testing::_)) .WillOnce(::testing::SaveArg<0>(&output)); - readCallback(measuredValue); return output; } }; - - using TestedTypes = ::testing::Types; - TYPED_TEST_SUITE(TestPidIncremental, TestedTypes); - - template - typename controllers::PidLimits CreateLimits() - { - if constexpr (std::is_same_v) - return { -1000, 1000 }; - else - return { T(-0.9f), T(0.9f) }; - } } -TYPED_TEST(TestPidIncremental, no_variation_input_results_in_no_action_control) +TYPED_TEST(TestPidIncrementalSynchronous, zero_error_produces_zero_increment) { - float tolerance = math::Tolerance(); - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.1f), - TypeParam(0.1f), - TypeParam(0.1f) }, - CreateLimits()); - - this->controller->SetPoint(TypeParam(0.0f)); - - EXPECT_CALL(this->driver, Start(::testing::_)); - this->controller->Enable(); - - EXPECT_NEAR(math::ToFloat(this->ProcessValue(TypeParam(0.0f))), 0, tolerance); + auto controller = this->MakeController(this->PIDGains(0.1f, 0.1f, 0.1f), this->WideLimit()); + controller.SetPoint(TypeParam(0.0f)); + EXPECT_NEAR(math::ToFloat(controller.Process(TypeParam(0.0f))), 0.0f, math::Tolerance()); } -TYPED_TEST(TestPidIncremental, proportional_action) +TYPED_TEST(TestPidIncrementalSynchronous, proportional_first_step) { - float tolerance = math::Tolerance(); - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.5f), - TypeParam(0.0f), - TypeParam(0.0f) }, - CreateLimits()); - - this->controller->SetPoint(TypeParam(0.2f)); - - EXPECT_CALL(this->driver, Start(::testing::_)); - this->controller->Enable(); - - EXPECT_NEAR(math::ToFloat(this->ProcessValue(TypeParam(0.0f))), 0.1f, tolerance); - EXPECT_NEAR(math::ToFloat(this->ProcessValue(TypeParam(0.1f))), 0.05f, tolerance); - EXPECT_NEAR(math::ToFloat(this->ProcessValue(TypeParam(-0.1f))), 0.15f, tolerance); + auto controller = this->MakeController(this->PGains(0.5f), this->WideLimit()); + controller.SetPoint(TypeParam(0.2f)); + EXPECT_NEAR(math::ToFloat(controller.Process(TypeParam(0.0f))), 0.1f, math::Tolerance()); } -TYPED_TEST(TestPidIncremental, integrative_action) +TYPED_TEST(TestPidIncrementalSynchronous, proportional_tracks_error_change) { - float tolerance = math::Tolerance(); - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.0f), - TypeParam(0.1f), - TypeParam(0.0f) }, - CreateLimits()); - - this->controller->SetPoint(TypeParam(0.2f)); - - EXPECT_CALL(this->driver, Start(::testing::_)); - this->controller->Enable(); - - EXPECT_NEAR(math::ToFloat(this->ProcessValue(TypeParam(0.0f))), 0.02f, tolerance); - EXPECT_NEAR(math::ToFloat(this->ProcessValue(TypeParam(0.0f))), 0.04f, tolerance); + auto controller = this->MakeController(this->PGains(0.5f), this->WideLimit()); + controller.SetPoint(TypeParam(0.2f)); + controller.Process(TypeParam(0.0f)); + EXPECT_NEAR(math::ToFloat(controller.Process(TypeParam(0.1f))), 0.05f, math::Tolerance()); } -TYPED_TEST(TestPidIncremental, derivative_action) +TYPED_TEST(TestPidIncrementalSynchronous, integral_accumulates_over_two_steps) { - float tolerance = math::Tolerance(); - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.0f), - TypeParam(0.0f), - TypeParam(0.1f) }, - CreateLimits()); - - this->controller->SetPoint(TypeParam(0.2f)); - - EXPECT_CALL(this->driver, Start(::testing::_)); - this->controller->Enable(); - - EXPECT_NEAR(math::ToFloat(this->ProcessValue(TypeParam(0.0f))), 0.02f, tolerance); - EXPECT_NEAR(math::ToFloat(this->ProcessValue(TypeParam(0.1f))), -0.01f, tolerance); + auto controller = this->MakeController(this->IGains(0.1f), this->WideLimit()); + controller.SetPoint(TypeParam(0.2f)); + EXPECT_NEAR(math::ToFloat(controller.Process(TypeParam(0.0f))), 0.02f, math::Tolerance()); + EXPECT_NEAR(math::ToFloat(controller.Process(TypeParam(0.0f))), 0.04f, math::Tolerance()); } -TYPED_TEST(TestPidIncremental, check_output_limits) +TYPED_TEST(TestPidIncrementalSynchronous, integral_achieves_zero_steady_state_error) { - float tolerance = math::Tolerance(); - auto limits = CreateLimits(); - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.05f), - TypeParam(0.05f), - TypeParam(0.01f) }, - limits); - - this->controller->SetPoint(TypeParam(0.8f)); - - EXPECT_CALL(this->driver, Start(::testing::_)); - this->controller->Enable(); - - for (int i = 0; i < 10; ++i) + auto controller = this->MakeController(this->PIDGains(0.3f, 0.1f, 0.0f), this->WideLimit()); + controller.SetPoint(TypeParam(0.5f)); + TypeParam pv{}; + for (int i = 0; i < 200; ++i) { - auto output = this->ProcessValue(TypeParam(0.0f)); - EXPECT_LE(math::ToFloat(output), math::ToFloat(limits.max)); - EXPECT_GE(math::ToFloat(output), math::ToFloat(limits.min)); + TypeParam output = controller.Process(pv); + pv = TypeParam(math::ToFloat(pv) + 0.05f * (math::ToFloat(output) - math::ToFloat(pv))); } + EXPECT_NEAR(math::ToFloat(pv), 0.5f, 0.05f); } -TYPED_TEST(TestPidIncremental, process_reaches_set_point) +TYPED_TEST(TestPidIncrementalSynchronous, derivative_first_step) { - float tolerance = math::Tolerance(); - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.1f), - TypeParam(0.05f), - TypeParam(0.02f) }, - CreateLimits()); - - auto setpoint = TypeParam(0.2f); - this->controller->SetPoint(setpoint); - - EXPECT_CALL(this->driver, Start(::testing::_)); - this->controller->Enable(); - - EXPECT_NEAR(math::ToFloat(this->ProcessValue(setpoint)), 0, tolerance); + auto controller = this->MakeController(this->DGains(0.1f), this->WideLimit()); + controller.SetPoint(TypeParam(0.2f)); + EXPECT_NEAR(math::ToFloat(controller.Process(TypeParam(0.0f))), 0.02f, math::Tolerance()); } -TYPED_TEST(TestPidIncremental, set_tunings_updates_behavior) +TYPED_TEST(TestPidIncrementalSynchronous, derivative_second_step_opposes_error_reduction) { - float tolerance = math::Tolerance(); - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.5f), - TypeParam(0.0f), - TypeParam(0.0f) }, - CreateLimits()); - - this->controller->SetPoint(TypeParam(0.2f)); - - EXPECT_CALL(this->driver, Start(::testing::_)); - this->controller->Enable(); - - EXPECT_NEAR(math::ToFloat(this->ProcessValue(TypeParam(0.0f))), 0.1f, tolerance); - - this->controller->SetTunings(typename controllers::PidTunings{ - TypeParam(0.25f), - TypeParam(0.0f), - TypeParam(0.0f) }); - - EXPECT_CALL(this->driver, Start(::testing::_)); - this->controller->Enable(); - - EXPECT_NEAR(math::ToFloat(this->ProcessValue(TypeParam(0.0f))), 0.05f, tolerance); + auto controller = this->MakeController(this->DGains(0.1f), this->WideLimit()); + controller.SetPoint(TypeParam(0.2f)); + controller.Process(TypeParam(0.0f)); + EXPECT_NEAR(math::ToFloat(controller.Process(TypeParam(0.1f))), -0.01f, math::Tolerance()); } -TYPED_TEST(TestPidIncremental, set_limits_constrains_output) +TYPED_TEST(TestPidIncrementalSynchronous, output_clamped_at_max_limit) { - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.5f), - TypeParam(0.1f), - TypeParam(0.0f) }, - CreateLimits()); - - this->controller->SetPoint(TypeParam(0.8f)); - - EXPECT_CALL(this->driver, Start(::testing::_)); - this->controller->Enable(); + auto limits = this->NarrowLimit(); + auto controller = this->MakeController(this->PGains(0.5f), limits); + controller.SetPoint(TypeParam(0.4f)); + EXPECT_FLOAT_EQ(math::ToFloat(controller.Process(TypeParam(0.0f))), math::ToFloat(limits.max)); +} - auto newLimits = typename controllers::PidLimits{ TypeParam(-0.05f), TypeParam(0.05f) }; - this->controller->SetLimits(newLimits); +TYPED_TEST(TestPidIncrementalSynchronous, output_clamped_at_min_limit) +{ + auto limits = this->NarrowLimit(); + auto controller = this->MakeController(this->PGains(0.5f), limits); + controller.SetPoint(TypeParam(-0.4f)); + EXPECT_FLOAT_EQ(math::ToFloat(controller.Process(TypeParam(0.0f))), math::ToFloat(limits.min)); +} - for (int i = 0; i < 5; ++i) +TYPED_TEST(TestPidIncrementalSynchronous, saturation_clamps_all_steps) +{ + auto limits = this->NarrowLimit(); + auto controller = this->MakeController(this->PIDGains(0.05f, 0.05f, 0.01f), limits); + controller.SetPoint(TypeParam(0.8f)); + for (int i = 0; i < 10; ++i) { - auto output = this->ProcessValue(TypeParam(0.0f)); - EXPECT_LE(math::ToFloat(output), math::ToFloat(newLimits.max)); - EXPECT_GE(math::ToFloat(output), math::ToFloat(newLimits.min)); + auto output = controller.Process(TypeParam(0.0f)); + EXPECT_LE(output, limits.max); + EXPECT_GE(output, limits.min); } } -TYPED_TEST(TestPidIncremental, disable_stops_controller) +TYPED_TEST(TestPidIncrementalSynchronous, anti_windup_output_recovers_after_saturation) { - float tolerance = math::Tolerance(); - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.5f), - TypeParam(0.0f), - TypeParam(0.0f) }, - CreateLimits()); - - this->controller->SetPoint(TypeParam(0.2f)); - - EXPECT_CALL(this->driver, Start(::testing::_)); - this->controller->Enable(); - - EXPECT_NEAR(math::ToFloat(this->ProcessValue(TypeParam(0.0f))), 0.1f, tolerance); - - EXPECT_CALL(this->driver, Stop()); - this->controller->Disable(); + auto limits = this->NarrowLimit(); + auto controller = this->MakeController(this->PIDGains(0.1f, 0.1f, 0.0f), limits); + controller.SetPoint(TypeParam(0.9f)); + for (int i = 0; i < 20; ++i) + controller.Process(TypeParam(0.0f)); + controller.SetPoint(TypeParam(-0.9f)); + auto output = controller.Process(TypeParam(0.0f)); + EXPECT_LE(output, limits.max); + EXPECT_GE(output, limits.min); } -TYPED_TEST(TestPidIncremental, clamp_at_max_limit) +TYPED_TEST(TestPidIncrementalSynchronous, no_setpoint_returns_process_variable) { - auto limits = typename controllers::PidLimits{ TypeParam(-0.1f), TypeParam(0.1f) }; - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.5f), - TypeParam(0.0f), - TypeParam(0.0f) }, - limits); - - this->controller->SetPoint(TypeParam(0.4f)); - - EXPECT_CALL(this->driver, Start(::testing::_)); - this->controller->Enable(); - - auto output = this->ProcessValue(TypeParam(0.0f)); - EXPECT_FLOAT_EQ(math::ToFloat(output), math::ToFloat(limits.max)); + auto controller = this->MakeController(this->PGains(0.5f), this->WideLimit()); + EXPECT_NEAR(math::ToFloat(controller.Process(TypeParam(0.3f))), 0.3f, math::Tolerance()); } -TYPED_TEST(TestPidIncremental, clamp_at_min_limit) +TYPED_TEST(TestPidIncrementalSynchronous, set_tunings_changes_gain_immediately) { - auto limits = typename controllers::PidLimits{ TypeParam(-0.1f), TypeParam(0.1f) }; - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.5f), - TypeParam(0.0f), - TypeParam(0.0f) }, - limits); - - this->controller->SetPoint(TypeParam(-0.4f)); - - EXPECT_CALL(this->driver, Start(::testing::_)); - this->controller->Enable(); - - auto output = this->ProcessValue(TypeParam(0.0f)); - EXPECT_FLOAT_EQ(math::ToFloat(output), math::ToFloat(limits.min)); + auto controller = this->MakeController(this->PGains(0.5f), this->WideLimit()); + controller.SetPoint(TypeParam(0.2f)); + controller.Process(TypeParam(0.0f)); + controller.SetTunings(this->PIDGains(0.25f, 0.0f, 0.0f)); + EXPECT_NEAR(math::ToFloat(controller.Process(TypeParam(0.2f))), 0.05f, math::Tolerance()); } -TYPED_TEST(TestPidIncremental, process_without_setpoint_returns_input) +TYPED_TEST(TestPidIncrementalSynchronous, reset_restores_state_equal_to_fresh_instance) { - float tolerance = math::Tolerance(); + auto controller = this->MakeController(this->PGains(0.5f), this->WideLimit()); + controller.SetPoint(TypeParam(0.2f)); + controller.Process(TypeParam(0.0f)); + controller.Reset(); + float afterReset = math::ToFloat(controller.Process(TypeParam(0.0f))); - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.5f), - TypeParam(0.0f), - TypeParam(0.0f) }, - CreateLimits()); + auto fresh = this->MakeController(this->PGains(0.5f), this->WideLimit()); + fresh.SetPoint(TypeParam(0.2f)); + float freshFirst = math::ToFloat(fresh.Process(TypeParam(0.0f))); - EXPECT_CALL(this->driver, Start(::testing::_)); - this->controller->Enable(); - - auto input = TypeParam(0.3f); - auto output = this->ProcessValue(input); - EXPECT_NEAR(math::ToFloat(output), math::ToFloat(input), tolerance); + EXPECT_FLOAT_EQ(afterReset, freshFirst); } -TYPED_TEST(TestPidIncremental, output_within_limits_not_clamped) +TYPED_TEST(TestPidIncrementalSynchronous, two_identical_runs_produce_same_output) { - float tolerance = math::Tolerance(); - auto limits = CreateLimits(); - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.1f), - TypeParam(0.0f), - TypeParam(0.0f) }, - limits); + auto controller = this->MakeController(this->PIDGains(0.5f, 0.1f, 0.05f), this->WideLimit()); + controller.SetPoint(TypeParam(0.3f)); + float run1 = math::ToFloat(controller.Process(TypeParam(0.1f))); - this->controller->SetPoint(TypeParam(0.1f)); + controller.Reset(); + controller.SetPoint(TypeParam(0.3f)); + float run2 = math::ToFloat(controller.Process(TypeParam(0.1f))); - EXPECT_CALL(this->driver, Start(::testing::_)); - this->controller->Enable(); - - auto output = this->ProcessValue(TypeParam(0.0f)); - EXPECT_NEAR(math::ToFloat(output), 0.01f, tolerance); - EXPECT_LT(math::ToFloat(output), math::ToFloat(limits.max)); - EXPECT_GT(math::ToFloat(output), math::ToFloat(limits.min)); + EXPECT_FLOAT_EQ(run1, run2); } -namespace +TYPED_TEST(TestPidIncrementalSynchronous, output_within_limits_not_clamped) { - template - class TestPidIncrementalSynchronous - : public ::testing::Test - { - public: - std::optional> controller; - - void CreateController(controllers::PidTunings tunings, controllers::PidLimits limits) - { - controller.emplace(tunings, limits); - } - }; - - TYPED_TEST_SUITE(TestPidIncrementalSynchronous, TestedTypes); + auto limits = this->WideLimit(); + auto controller = this->MakeController(this->PGains(0.1f), limits); + controller.SetPoint(TypeParam(0.1f)); + auto output = controller.Process(TypeParam(0.0f)); + EXPECT_NEAR(math::ToFloat(output), 0.01f, math::Tolerance()); + EXPECT_LT(output, limits.max); + EXPECT_GT(output, limits.min); } -TYPED_TEST(TestPidIncrementalSynchronous, no_variation_input_results_in_no_action_control) +TEST_F(TestPidIncrementalAsynchronous, zero_error_produces_zero_control_action) { - float tolerance = math::Tolerance(); - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.1f), - TypeParam(0.1f), - TypeParam(0.1f) }, - CreateLimits()); - - this->controller->SetPoint(TypeParam(0.0f)); - this->controller->Enable(); - - EXPECT_NEAR(math::ToFloat(this->controller->Process(TypeParam(0.0f))), 0, tolerance); + CreateController({ 0.1f, 0.1f, 0.1f }, { -0.9f, 0.9f }); + controller->SetPoint(0.0f); + EXPECT_NEAR(ProcessValue(0.0f), 0.0f, math::Tolerance()); } -TYPED_TEST(TestPidIncrementalSynchronous, proportional_action) +TEST_F(TestPidIncrementalAsynchronous, proportional_first_step) { - float tolerance = math::Tolerance(); - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.5f), - TypeParam(0.0f), - TypeParam(0.0f) }, - CreateLimits()); - - this->controller->SetPoint(TypeParam(0.2f)); - this->controller->Enable(); - - EXPECT_NEAR(math::ToFloat(this->controller->Process(TypeParam(0.0f))), 0.1f, tolerance); - EXPECT_NEAR(math::ToFloat(this->controller->Process(TypeParam(0.1f))), 0.05f, tolerance); - EXPECT_NEAR(math::ToFloat(this->controller->Process(TypeParam(-0.1f))), 0.15f, tolerance); + CreateController({ 0.5f, 0.0f, 0.0f }, { -0.9f, 0.9f }); + controller->SetPoint(0.2f); + EXPECT_NEAR(ProcessValue(0.0f), 0.1f, math::Tolerance()); } -TYPED_TEST(TestPidIncrementalSynchronous, integrative_action) +TEST_F(TestPidIncrementalAsynchronous, integral_accumulates_over_two_steps) { - float tolerance = math::Tolerance(); - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.0f), - TypeParam(0.1f), - TypeParam(0.0f) }, - CreateLimits()); - - this->controller->SetPoint(TypeParam(0.2f)); - this->controller->Enable(); - - EXPECT_NEAR(math::ToFloat(this->controller->Process(TypeParam(0.0f))), 0.02f, tolerance); - EXPECT_NEAR(math::ToFloat(this->controller->Process(TypeParam(0.0f))), 0.04f, tolerance); + CreateController({ 0.0f, 0.1f, 0.0f }, { -0.9f, 0.9f }); + controller->SetPoint(0.2f); + EXPECT_NEAR(ProcessValue(0.0f), 0.02f, math::Tolerance()); + EXPECT_NEAR(ProcessValue(0.0f), 0.04f, math::Tolerance()); } -TYPED_TEST(TestPidIncrementalSynchronous, derivative_action) +TEST_F(TestPidIncrementalAsynchronous, derivative_second_step_opposes_error_reduction) { - float tolerance = math::Tolerance(); - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.0f), - TypeParam(0.0f), - TypeParam(0.1f) }, - CreateLimits()); - - this->controller->SetPoint(TypeParam(0.2f)); - this->controller->Enable(); - - EXPECT_NEAR(math::ToFloat(this->controller->Process(TypeParam(0.0f))), 0.02f, tolerance); - EXPECT_NEAR(math::ToFloat(this->controller->Process(TypeParam(0.1f))), -0.01f, tolerance); + CreateController({ 0.0f, 0.0f, 0.1f }, { -0.9f, 0.9f }); + controller->SetPoint(0.2f); + ProcessValue(0.0f); + EXPECT_NEAR(ProcessValue(0.1f), -0.01f, math::Tolerance()); } -TYPED_TEST(TestPidIncrementalSynchronous, check_output_limits) +TEST_F(TestPidIncrementalAsynchronous, output_clamped_at_max_limit) { - auto limits = CreateLimits(); - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.05f), - TypeParam(0.05f), - TypeParam(0.01f) }, - limits); - - this->controller->SetPoint(TypeParam(0.8f)); - this->controller->Enable(); - - for (int i = 0; i < 10; ++i) - { - auto output = this->controller->Process(TypeParam(0.0f)); - EXPECT_LE(math::ToFloat(output), math::ToFloat(limits.max)); - EXPECT_GE(math::ToFloat(output), math::ToFloat(limits.min)); - } + auto limits = controllers::PidLimits{ -0.1f, 0.1f }; + CreateController({ 0.5f, 0.0f, 0.0f }, limits); + controller->SetPoint(0.4f); + EXPECT_FLOAT_EQ(ProcessValue(0.0f), limits.max); } -TYPED_TEST(TestPidIncrementalSynchronous, process_reaches_set_point) +TEST_F(TestPidIncrementalAsynchronous, output_clamped_at_min_limit) { - float tolerance = math::Tolerance(); - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.1f), - TypeParam(0.05f), - TypeParam(0.02f) }, - CreateLimits()); - - auto setpoint = TypeParam(0.2f); - this->controller->SetPoint(setpoint); - this->controller->Enable(); - - EXPECT_NEAR(math::ToFloat(this->controller->Process(setpoint)), 0, tolerance); + auto limits = controllers::PidLimits{ -0.1f, 0.1f }; + CreateController({ 0.5f, 0.0f, 0.0f }, limits); + controller->SetPoint(-0.4f); + EXPECT_FLOAT_EQ(ProcessValue(0.0f), limits.min); } -TYPED_TEST(TestPidIncrementalSynchronous, set_tunings_updates_controller_behavior) +TEST_F(TestPidIncrementalAsynchronous, no_setpoint_returns_process_variable) { - float tolerance = math::Tolerance(); - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.5f), - TypeParam(0.0f), - TypeParam(0.0f) }, - CreateLimits()); - - this->controller->SetPoint(TypeParam(0.2f)); - this->controller->Enable(); - - EXPECT_NEAR(math::ToFloat(this->controller->Process(TypeParam(0.0f))), 0.1f, tolerance); - - this->controller->SetTunings(typename controllers::PidTunings{ - TypeParam(0.25f), - TypeParam(0.0f), - TypeParam(0.0f) }); - - this->controller->Enable(); - EXPECT_NEAR(math::ToFloat(this->controller->Process(TypeParam(0.0f))), 0.05f, tolerance); + CreateController({ 0.5f, 0.0f, 0.0f }, { -0.9f, 0.9f }); + EXPECT_NEAR(ProcessValue(0.3f), 0.3f, math::Tolerance()); } -TYPED_TEST(TestPidIncrementalSynchronous, set_limits_updates_output_clamping) +TEST_F(TestPidIncrementalAsynchronous, reset_restores_state_to_initial) { - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.2f), - TypeParam(0.1f), - TypeParam(0.0f) }, - CreateLimits()); - - this->controller->SetPoint(TypeParam(0.5f)); - this->controller->Enable(); - - auto newLimits = typename controllers::PidLimits{ TypeParam(-0.1f), TypeParam(0.1f) }; - this->controller->SetLimits(newLimits); - - for (int i = 0; i < 5; ++i) - { - auto output = this->controller->Process(TypeParam(0.0f)); - EXPECT_LE(math::ToFloat(output), math::ToFloat(newLimits.max)); - EXPECT_GE(math::ToFloat(output), math::ToFloat(newLimits.min)); - } + CreateController({ 0.5f, 0.0f, 0.0f }, { -0.9f, 0.9f }); + controller->SetPoint(0.2f); + ProcessValue(0.0f); + controller->Reset(); + EXPECT_NEAR(ProcessValue(0.0f), 0.1f, math::Tolerance()); } -TYPED_TEST(TestPidIncrementalSynchronous, disable_clears_setpoint) +TEST_F(TestPidIncrementalAsynchronous, set_tunings_changes_gain_immediately) { - float tolerance = math::Tolerance(); - - this->CreateController( - typename controllers::PidTunings{ - TypeParam(0.5f), - TypeParam(0.0f), - TypeParam(0.0f) }, - CreateLimits()); - - this->controller->SetPoint(TypeParam(0.2f)); - this->controller->Enable(); - - EXPECT_NEAR(math::ToFloat(this->controller->Process(TypeParam(0.0f))), 0.1f, tolerance); - - this->controller->Disable(); - - auto input = TypeParam(0.5f); - EXPECT_NEAR(math::ToFloat(this->controller->Process(input)), math::ToFloat(input), tolerance); + CreateController({ 0.5f, 0.0f, 0.0f }, { -0.9f, 0.9f }); + controller->SetPoint(0.2f); + ProcessValue(0.0f); + controller->SetTunings({ 0.25f, 0.0f, 0.0f }); + EXPECT_NEAR(ProcessValue(0.2f), 0.05f, math::Tolerance()); } diff --git a/numerical/controllers/implementations/test/TestSaturationRateLimiter.cpp b/numerical/controllers/implementations/test/TestSaturationRateLimiter.cpp index fe5be638..ac51de81 100644 --- a/numerical/controllers/implementations/test/TestSaturationRateLimiter.cpp +++ b/numerical/controllers/implementations/test/TestSaturationRateLimiter.cpp @@ -1,6 +1,7 @@ #include "numerical/controllers/implementations/SaturationRateLimiter.hpp" #include "numerical/math/Tolerance.hpp" #include "gtest/gtest.h" +#include namespace { @@ -69,3 +70,92 @@ TEST_F(TestSlewLimitedSaturation, composition_slew_then_clamp_honours_both) EXPECT_GE(out, -0.5f); } } + +TEST_F(TestSlewLimitedSaturation, clamp_at_exact_lo_boundary) +{ + EXPECT_NEAR(sat.Clamp(-0.5f), -0.5f, 1e-6f); +} + +TEST_F(TestSlewLimitedSaturation, clamp_at_exact_hi_boundary) +{ + EXPECT_NEAR(sat.Clamp(0.5f), 0.5f, 1e-6f); +} + +TEST_F(TestSlewLimitedSaturation, clamp_symmetry_around_zero) +{ + EXPECT_NEAR(sat.Clamp(0.7f), -sat.Clamp(-0.7f), 1e-6f); +} + +TEST_F(TestSlewLimitedSaturation, rate_limiter_zero_rate_freezes_output) +{ + controllers::RateLimiter frozen{ 0.0f, 1.0f }; + frozen.Limit(1.0f); + EXPECT_NEAR(frozen.Limit(5.0f), 1.0f, 1e-6f); + EXPECT_NEAR(frozen.Limit(-3.0f), 1.0f, 1e-6f); +} + +TEST_F(TestSlewLimitedSaturation, reset_default_value_is_zero) +{ + slew.Limit(0.8f); + slew.Reset(); + EXPECT_NEAR(slew.Limit(0.05f), 0.05f, 1e-6f); +} + +TEST_F(TestSlewLimitedSaturation, reset_produces_same_output_as_fresh_instance) +{ + controllers::RateLimiter fresh{ 0.1f, 1.0f }; + slew.Limit(0.0f); + slew.Limit(1.0f); + slew.Reset(0.0f); + fresh.Limit(0.0f); + EXPECT_NEAR(slew.Limit(1.0f), fresh.Limit(1.0f), 1e-6f); +} + +TEST_F(TestSlewLimitedSaturation, two_instances_do_not_interfere) +{ + controllers::RateLimiter a{ 0.1f, 1.0f }; + controllers::RateLimiter b{ 0.1f, 1.0f }; + a.Limit(0.0f); + b.Limit(0.0f); + a.Limit(1.0f); + EXPECT_NEAR(b.Limit(1.0f), 0.1f, 1e-6f); +} + +TEST_F(TestSlewLimitedSaturation, rate_limiter_zero_crossing_negative_to_positive) +{ + controllers::RateLimiter rl{ 0.1f, 1.0f }; + rl.Limit(-0.5f); + EXPECT_NEAR(rl.Limit(1.0f), -0.4f, 1e-6f); +} + +TEST_F(TestSlewLimitedSaturation, rate_limiter_output_monotonically_approaches_target) +{ + controllers::RateLimiter rl{ 0.1f, 1.0f }; + rl.Limit(0.0f); + float prev{ 0.0f }; + for (int i = 0; i < 8; ++i) + { + float out{ rl.Limit(1.0f) }; + EXPECT_GE(out, prev); + prev = out; + } +} + +TEST_F(TestSlewLimitedSaturation, slew_limited_saturation_large_negative_input_clamps_at_lo) +{ + controllers::SlewLimitedSaturation composed{ -0.5f, 0.5f, 2.0f, 1.0f }; + composed.Apply(0.0f); + float out{ composed.Apply(-10.0f) }; + EXPECT_NEAR(out, -0.5f, 1e-6f); +} + +TEST_F(TestSlewLimitedSaturation, slew_limited_saturation_determinism) +{ + controllers::SlewLimitedSaturation a{ -0.5f, 0.5f, 0.1f, 1.0f }; + controllers::SlewLimitedSaturation b{ -0.5f, 0.5f, 0.1f, 1.0f }; + std::array inputs{ 0.0f, 1.0f, -1.0f, 0.3f, 0.9f }; + for (float u : inputs) + { + EXPECT_FLOAT_EQ(a.Apply(u), b.Apply(u)); + } +} diff --git a/numerical/controllers/implementations/test/TestStateFeedbackController.cpp b/numerical/controllers/implementations/test/TestStateFeedbackController.cpp index b9a951f1..9386a124 100644 --- a/numerical/controllers/implementations/test/TestStateFeedbackController.cpp +++ b/numerical/controllers/implementations/test/TestStateFeedbackController.cpp @@ -5,6 +5,7 @@ #include "numerical/controllers/implementations/Lqr.hpp" #include "numerical/controllers/implementations/Mpc.hpp" #include "numerical/controllers/interfaces/StateFeedbackController.hpp" +#include "numerical/math/Tolerance.hpp" #include #include @@ -47,6 +48,20 @@ TEST_F(TestStateFeedbackController, mpc_is_derived_from_state_feedback_controlle "Mpc must derive from StateFeedbackController"); } +TEST_F(TestStateFeedbackController, interface_is_abstract) +{ + static_assert( + std::is_abstract_v>, + "StateFeedbackController must be abstract"); +} + +TEST_F(TestStateFeedbackController, interface_destructor_is_virtual_not_pure) +{ + static_assert( + !std::is_abstract_v>, + "Concrete Lqr must be instantiable through base pointer"); +} + TEST_F(TestStateFeedbackController, lqr_compute_control_returns_nonzero_for_nonzero_state) { controllers::Lqr lqr{ A, B, Q, R }; @@ -57,6 +72,100 @@ TEST_F(TestStateFeedbackController, lqr_compute_control_returns_nonzero_for_nonz EXPECT_NE(u.at(0, 0), 0.0f); } +TEST_F(TestStateFeedbackController, lqr_zero_state_produces_zero_control) +{ + controllers::Lqr lqr{ A, B, Q, R }; + + math::Vector state{ { 0.0f }, { 0.0f } }; + auto u = lqr.ComputeControl(state); + + EXPECT_NEAR(u.at(0, 0), 0.0f, math::Tolerance()); +} + +TEST_F(TestStateFeedbackController, lqr_positive_position_state_yields_negative_control) +{ + controllers::Lqr lqr{ A, B, Q, R }; + + math::Vector state{ { 1.0f }, { 0.0f } }; + auto u = lqr.ComputeControl(state); + + EXPECT_LT(u.at(0, 0), 0.0f); +} + +TEST_F(TestStateFeedbackController, lqr_negative_position_state_yields_positive_control) +{ + controllers::Lqr lqr{ A, B, Q, R }; + + math::Vector state{ { -1.0f }, { 0.0f } }; + auto u = lqr.ComputeControl(state); + + EXPECT_GT(u.at(0, 0), 0.0f); +} + +TEST_F(TestStateFeedbackController, lqr_gain_matches_dare_reference) +{ + controllers::Lqr lqr{ A, B, Q, R }; + + const auto& K = lqr.GetGain(); + + EXPECT_NEAR(K.at(0, 0), 2.5853f, 1e-2f); + EXPECT_NEAR(K.at(0, 1), 3.5747f, 1e-2f); +} + +TEST_F(TestStateFeedbackController, lqr_control_matches_dare_reference_for_position_state) +{ + controllers::Lqr lqr{ A, B, Q, R }; + + math::Vector state{ { 1.0f }, { 0.0f } }; + auto u = lqr.ComputeControl(state); + + EXPECT_NEAR(u.at(0, 0), -2.5853f, 1e-2f); +} + +TEST_F(TestStateFeedbackController, lqr_control_matches_dare_reference_for_velocity_state) +{ + controllers::Lqr lqr{ A, B, Q, R }; + + math::Vector state{ { 0.0f }, { 1.0f } }; + auto u = lqr.ComputeControl(state); + + EXPECT_NEAR(u.at(0, 0), -3.5747f, 1e-2f); +} + +TEST_F(TestStateFeedbackController, lqr_precomputed_gain_agrees_with_dare_gain) +{ + controllers::Lqr lqrDare{ A, B, Q, R }; + const auto& K = lqrDare.GetGain(); + + controllers::Lqr lqrPre{ K }; + + math::Vector state{ { 1.0f }, { 1.0f } }; + auto uDare = lqrDare.ComputeControl(state); + auto uPre = lqrPre.ComputeControl(state); + + EXPECT_NEAR(uPre.at(0, 0), uDare.at(0, 0), math::Tolerance()); +} + +TEST_F(TestStateFeedbackController, lqr_input_vector_type_matches_interface) +{ + static_assert( + std::is_same_v< + controllers::Lqr::InputVector, + controllers::StateFeedbackController::InputVector>, + "Lqr::InputVector must match the interface type"); +} + +TEST_F(TestStateFeedbackController, lqr_deterministic_same_state_same_output) +{ + controllers::Lqr lqr{ A, B, Q, R }; + + math::Vector state{ { 1.0f }, { 0.5f } }; + auto u1 = lqr.ComputeControl(state); + auto u2 = lqr.ComputeControl(state); + + EXPECT_FLOAT_EQ(u1.at(0, 0), u2.at(0, 0)); +} + TEST_F(TestStateFeedbackController, mpc_compute_control_returns_nonzero_for_nonzero_state) { controllers::MpcWeights weights; @@ -70,3 +179,139 @@ TEST_F(TestStateFeedbackController, mpc_compute_control_returns_nonzero_for_nonz EXPECT_NE(u.at(0, 0), 0.0f); } + +TEST_F(TestStateFeedbackController, mpc_zero_state_produces_zero_control) +{ + controllers::MpcWeights weights; + weights.Q = Q; + weights.R = R; + + controllers::Mpc mpc{ A, B, weights }; + + math::Vector state{ { 0.0f }, { 0.0f } }; + auto u = mpc.ComputeControl(state); + + EXPECT_NEAR(u.at(0, 0), 0.0f, math::Tolerance()); +} + +TEST_F(TestStateFeedbackController, mpc_control_matches_reference_for_position_state) +{ + controllers::MpcWeights weights; + weights.Q = Q; + weights.R = R; + + controllers::Mpc mpc{ A, B, weights }; + + math::Vector state{ { 1.0f }, { 0.0f } }; + auto u = mpc.ComputeControl(state); + + EXPECT_NEAR(u.at(0, 0), -0.5877f, 1e-3f); +} + +TEST_F(TestStateFeedbackController, mpc_negative_state_produces_positive_control) +{ + controllers::MpcWeights weights; + weights.Q = Q; + weights.R = R; + + controllers::Mpc mpc{ A, B, weights }; + + math::Vector state{ { -1.0f }, { 0.0f } }; + auto u = mpc.ComputeControl(state); + + EXPECT_NEAR(u.at(0, 0), 0.5877f, 1e-3f); +} + +TEST_F(TestStateFeedbackController, mpc_umin_constraint_clamps_negative_control) +{ + controllers::MpcWeights weights; + weights.Q = Q; + weights.R = R; + + math::Vector uMin{ { -0.3f } }; + controllers::MpcConstraints constraints; + constraints.uMin = uMin; + + controllers::Mpc mpc{ A, B, weights, constraints }; + + math::Vector state{ { 1.0f }, { 0.0f } }; + auto u = mpc.ComputeControl(state); + + EXPECT_NEAR(u.at(0, 0), -0.3f, 1e-4f); +} + +TEST_F(TestStateFeedbackController, mpc_umax_constraint_clamps_positive_control) +{ + controllers::MpcWeights weights; + weights.Q = Q; + weights.R = R; + + math::Vector uMax{ { 0.2f } }; + controllers::MpcConstraints constraints; + constraints.uMax = uMax; + + controllers::Mpc mpc{ A, B, weights, constraints }; + + math::Vector state{ { -1.0f }, { 0.0f } }; + auto u = mpc.ComputeControl(state); + + EXPECT_NEAR(u.at(0, 0), 0.2f, 1e-4f); +} + +TEST_F(TestStateFeedbackController, mpc_set_reference_shifts_control_to_zero_at_reference) +{ + controllers::MpcWeights weights; + weights.Q = Q; + weights.R = R; + + controllers::Mpc mpc{ A, B, weights }; + + math::Vector ref{ { 1.0f }, { 0.0f } }; + mpc.SetReference(ref); + + auto u = mpc.ComputeControl(ref); + + EXPECT_NEAR(u.at(0, 0), 0.0f, math::Tolerance()); +} + +TEST_F(TestStateFeedbackController, mpc_clear_reference_restores_unshifted_control) +{ + controllers::MpcWeights weights; + weights.Q = Q; + weights.R = R; + + controllers::Mpc mpc{ A, B, weights }; + + math::Vector ref{ { 1.0f }, { 0.0f } }; + mpc.SetReference(ref); + mpc.ClearReference(); + + math::Vector state{ { 0.0f }, { 0.0f } }; + auto u = mpc.ComputeControl(state); + + EXPECT_NEAR(u.at(0, 0), 0.0f, math::Tolerance()); +} + +TEST_F(TestStateFeedbackController, mpc_input_vector_type_matches_interface) +{ + static_assert( + std::is_same_v< + controllers::Mpc::InputVector, + controllers::StateFeedbackController::InputVector>, + "Mpc::InputVector must match the interface type"); +} + +TEST_F(TestStateFeedbackController, mpc_deterministic_same_state_same_output) +{ + controllers::MpcWeights weights; + weights.Q = Q; + weights.R = R; + + controllers::Mpc mpc{ A, B, weights }; + + math::Vector state{ { 1.0f }, { 0.5f } }; + auto u1 = mpc.ComputeControl(state); + auto u2 = mpc.ComputeControl(state); + + EXPECT_FLOAT_EQ(u1.at(0, 0), u2.at(0, 0)); +} diff --git a/numerical/controllers/interfaces/PidController.hpp b/numerical/controllers/interfaces/PidController.hpp index 7cc21aaa..b0cb7656 100644 --- a/numerical/controllers/interfaces/PidController.hpp +++ b/numerical/controllers/interfaces/PidController.hpp @@ -26,11 +26,10 @@ namespace controllers "Pid can only be instantiated with math::QNumber types."); public: + virtual ~AsynchronousPidController() = default; + virtual void Reset() = 0; virtual void SetTunings(PidTunings tunings) = 0; - virtual void SetLimits(PidLimits limits) = 0; virtual void SetPoint(QNumberType setPoint) = 0; - virtual void Enable() = 0; - virtual void Disable() = 0; }; template diff --git a/simulator/controllers/PidController/application/PidSimulator.cpp b/simulator/controllers/PidController/application/PidSimulator.cpp index 5c92531a..6e7554a1 100644 --- a/simulator/controllers/PidController/application/PidSimulator.cpp +++ b/simulator/controllers/PidController/application/PidSimulator.cpp @@ -116,7 +116,6 @@ namespace simulator::controllers response.error.resize(numSamples, 0.0f); ::controllers::PidIncrementalSynchronous pid(configuration.tunings, configuration.limits); - pid.Enable(); plant->Reset(); diff --git a/sonar-project.properties b/sonar-project.properties index 9ce10ece..ad04f3f2 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -25,7 +25,7 @@ sonar.cfamily.threads=2 sonar.testExecutionReportPaths=execution.xml sonar.coverageReportPaths=coverage.xml -sonar.coverage.exclusions=examples,simulator,**/math/platform/** +sonar.coverage.exclusions=examples,simulator,**/math/platform/**,**/controllers/interfaces/**,**/estimators/Estimator.hpp,**/optimization/ObjectiveFunction.hpp,**/optimization/Optimizer.hpp,**/solvers/Solver.hpp,**/regularization/Regularization.hpp sonar.cpd.exclusions=**/math/platform/** # Project specific ignored rules