From 8a665893aad9cba473494b7b81e9f197d9141116 Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Tue, 30 Jun 2026 20:52:17 +0000 Subject: [PATCH 01/28] feat(platform): add board identity, status LEDs, and power status to PlatformFactory Replace the array-based Leds() with three discrete GPIO accessors (OperationalLed, WarningLed, FailureLed), add BoardId() for 3-bit active-low CAN node identification (PK0-2 on TM4C1294XL), and PowerStatus() for the LM5164 open-drain PG signal (PC6). Add e-foc-hardware motor board definition and reference submodule. EK-TM4C123GXL gains capability flags (hasBoardIdPins=false, hasPowerStatusPin=false) and placeholder LED pins that avoid conflicts with CAN TX (PF3) and hall sensor inputs. Co-Authored-By: Claude Sonnet 4.6 --- .gitmodules | 3 ++ CMakePresets.json | 19 ++++++++ core/platform_abstraction/PlatformFactory.hpp | 7 ++- documentation/architecture/system.md | 3 ++ infra/e-foc-hardware | 1 + .../support/PlatformFactoryMock.hpp | 6 ++- .../components/test/TestTerminal.cpp | 6 ++- .../hardware_test/instantiations/Logic.cpp | 2 +- .../implementation/PlatformFactoryImpl.cpp | 25 ++++++++-- .../implementation/PlatformFactoryImpl.hpp | 6 ++- .../E-FOC-HARDWARE/BoardCharacteristics.hpp | 47 +++++++++++++++++++ .../E-FOC-HARDWARE/CMakeLists.txt | 14 ++++++ .../st/implementation/PlatformFactoryImpl.cpp | 25 ++++++++-- .../st/implementation/PlatformFactoryImpl.hpp | 6 ++- .../ti/EK-TM4C123GXL/PinsAndPeripherals.hpp | 7 ++- .../ti/EK-TM4C1294XL/PinsAndPeripherals.hpp | 14 +++++- .../ti/implementation/PlatformFactoryImpl.cpp | 38 +++++++++++++-- .../ti/implementation/PlatformFactoryImpl.hpp | 6 ++- .../main/instantiations/Logic.cpp | 2 +- 19 files changed, 216 insertions(+), 21 deletions(-) create mode 160000 infra/e-foc-hardware create mode 100644 targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp create mode 100644 targets/platform_implementations/motor_boards/E-FOC-HARDWARE/CMakeLists.txt diff --git a/.gitmodules b/.gitmodules index 8a501c06..89222d8e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,6 @@ [submodule "infra/can-lite"] path = infra/can-lite url = https://github.com/embedded-pro/can-lite.git +[submodule "infra/e-foc-hardware"] + path = infra/e-foc-hardware + url = https://github.com/embedded-pro/e-foc-hardware diff --git a/CMakePresets.json b/CMakePresets.json index b29b55f4..7043a8d2 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -145,6 +145,15 @@ "E_FOC_MOTOR_BOARD": "FRDM-MC-LVPMSM" } }, + { + "name": "EK-TM4C1294XL-efoc", + "displayName": "EK-TM4C1294XL with E-FOC-HARDWARE motor board", + "description": "Build for tm4c1294ncpdt with E-FOC-HARDWARE motor control board", + "inherits": "EK-TM4C1294XL", + "cacheVariables": { + "E_FOC_MOTOR_BOARD": "E-FOC-HARDWARE" + } + }, { "name": "STM32F407G-DISC1", "displayName": "STM32F407G-DISC1", @@ -253,6 +262,16 @@ "configuration": "Debug", "configurePreset": "EK-TM4C123GXL" }, + { + "name": "EK-TM4C1294XL-efoc-RelWithDebInfo", + "configuration": "RelWithDebInfo", + "configurePreset": "EK-TM4C1294XL-efoc" + }, + { + "name": "EK-TM4C1294XL-efoc-Debug", + "configuration": "Debug", + "configurePreset": "EK-TM4C1294XL-efoc" + }, { "name": "STM32F407G-DISC1-RelWithDebInfo", "configuration": "RelWithDebInfo", diff --git a/core/platform_abstraction/PlatformFactory.hpp b/core/platform_abstraction/PlatformFactory.hpp index 436eacad..209b8a0f 100644 --- a/core/platform_abstraction/PlatformFactory.hpp +++ b/core/platform_abstraction/PlatformFactory.hpp @@ -6,7 +6,6 @@ #include "hal/interfaces/Gpio.hpp" #include "infra/stream/OutputStream.hpp" #include "infra/util/BoundedString.hpp" -#include "infra/util/MemoryRange.hpp" #include "services/tracer/Tracer.hpp" #include "services/util/Terminal.hpp" #include @@ -64,7 +63,11 @@ namespace application virtual void Run() = 0; virtual services::Tracer& Tracer() = 0; virtual services::TerminalWithCommands& Terminal() = 0; - virtual infra::MemoryRange Leds() = 0; + virtual hal::GpioPin& OperationalLed() = 0; + virtual hal::GpioPin& WarningLed() = 0; + virtual hal::GpioPin& FailureLed() = 0; + virtual uint8_t BoardId() const = 0; + virtual bool PowerStatus() const = 0; virtual hal::PerformanceTracker& PerformanceTimer() = 0; virtual hal::Hertz SystemClock() const = 0; virtual foc::Volts PowerSupplyVoltage() = 0; diff --git a/documentation/architecture/system.md b/documentation/architecture/system.md index 7618fd34..aeaa602b 100644 --- a/documentation/architecture/system.md +++ b/documentation/architecture/system.md @@ -157,6 +157,9 @@ The PAL provides a single platform-facing abstraction that groups creation and a | CAN bus | CAN 2.0B communication interface | | Performance timer | Cycle/timestamp measurement interface for profiling | | Serial terminal | Diagnostic trace and command-line interaction interface | +| Status LEDs | Three discrete GPIO outputs: `OperationalLed` (heartbeat blink during normal operation), `WarningLed` (non-fatal condition), `FailureLed` (fault / safe-state) | +| Board identity | `BoardId() const` — returns a 3-bit board ID (0–7) encoded on three active-low GPIO inputs (internal pull-ups), used as the CAN node address | +| Power status | `PowerStatus() const` — returns `true` when the power-good signal from the power stage is asserted (high = good on an open-drain PG line with pull-up) | Concrete implementations exist for: - **TI Tiva (EK-TM4C1294XL, EK-TM4C123GXL)**: platform-specific peripheral adapters under `targets/platform_implementations/ti/`. diff --git a/infra/e-foc-hardware b/infra/e-foc-hardware new file mode 160000 index 00000000..76e1a1b8 --- /dev/null +++ b/infra/e-foc-hardware @@ -0,0 +1 @@ +Subproject commit 76e1a1b8b6380af448fc54e0bac3040ca1732bbb diff --git a/integration_tests/software_in_the_loop/support/PlatformFactoryMock.hpp b/integration_tests/software_in_the_loop/support/PlatformFactoryMock.hpp index 4dcc0346..915847dd 100644 --- a/integration_tests/software_in_the_loop/support/PlatformFactoryMock.hpp +++ b/integration_tests/software_in_the_loop/support/PlatformFactoryMock.hpp @@ -13,7 +13,11 @@ namespace integration MOCK_METHOD(void, Run, (), (override)); MOCK_METHOD(services::Tracer&, Tracer, (), (override)); MOCK_METHOD(services::TerminalWithCommands&, Terminal, (), (override)); - MOCK_METHOD(infra::MemoryRange, Leds, (), (override)); + MOCK_METHOD(hal::GpioPin&, OperationalLed, (), (override)); + MOCK_METHOD(hal::GpioPin&, WarningLed, (), (override)); + MOCK_METHOD(hal::GpioPin&, FailureLed, (), (override)); + MOCK_METHOD(uint8_t, BoardId, (), (const, override)); + MOCK_METHOD(bool, PowerStatus, (), (const, override)); MOCK_METHOD(hal::PerformanceTracker&, PerformanceTimer, (), (override)); MOCK_METHOD(hal::Hertz, SystemClock, (), (const, override)); MOCK_METHOD(foc::Volts, PowerSupplyVoltage, (), (override)); diff --git a/targets/hardware_test/components/test/TestTerminal.cpp b/targets/hardware_test/components/test/TestTerminal.cpp index c1bdfe14..d76dd602 100644 --- a/targets/hardware_test/components/test/TestTerminal.cpp +++ b/targets/hardware_test/components/test/TestTerminal.cpp @@ -19,7 +19,11 @@ namespace MOCK_METHOD(void, Run, (), (override)); MOCK_METHOD(services::Tracer&, Tracer, (), (override)); MOCK_METHOD(services::TerminalWithCommands&, Terminal, (), (override)); - MOCK_METHOD(infra::MemoryRange, Leds, (), (override)); + MOCK_METHOD(hal::GpioPin&, OperationalLed, (), (override)); + MOCK_METHOD(hal::GpioPin&, WarningLed, (), (override)); + MOCK_METHOD(hal::GpioPin&, FailureLed, (), (override)); + MOCK_METHOD(uint8_t, BoardId, (), (const, override)); + MOCK_METHOD(bool, PowerStatus, (), (const, override)); MOCK_METHOD(hal::PerformanceTracker&, PerformanceTimer, (), (override)); MOCK_METHOD(hal::Hertz, SystemClock, (), (const, override)); MOCK_METHOD(foc::Volts, PowerSupplyVoltage, (), (override)); diff --git a/targets/hardware_test/instantiations/Logic.cpp b/targets/hardware_test/instantiations/Logic.cpp index 5c8e421b..1e634bf8 100644 --- a/targets/hardware_test/instantiations/Logic.cpp +++ b/targets/hardware_test/instantiations/Logic.cpp @@ -5,6 +5,6 @@ namespace application Logic::Logic(application::PlatformFactory& hardware) : terminalWithStorage{ hardware.Terminal(), hardware.Tracer(), services::TerminalWithBanner::Banner{ "hardware_test", hardware.PowerSupplyVoltage(), hardware.SystemClock(), hardware.GetResetCause(), hardware.FaultStatus() } } , terminal{ terminalWithStorage, hardware } - , debugLed{ hardware.Leds().front(), std::chrono::milliseconds(50), std::chrono::milliseconds(1950) } + , debugLed{ hardware.OperationalLed(), std::chrono::milliseconds(50), std::chrono::milliseconds(1950) } {} } diff --git a/targets/platform_implementations/host/implementation/PlatformFactoryImpl.cpp b/targets/platform_implementations/host/implementation/PlatformFactoryImpl.cpp index 6db14765..1baea8be 100644 --- a/targets/platform_implementations/host/implementation/PlatformFactoryImpl.cpp +++ b/targets/platform_implementations/host/implementation/PlatformFactoryImpl.cpp @@ -1,6 +1,5 @@ #include "targets/platform_implementations/host/implementation/PlatformFactoryImpl.hpp" #include "core/platform_abstraction/AdcPhaseCurrentMeasurement.hpp" -#include "infra/util/MemoryRange.hpp" #include namespace application @@ -23,9 +22,29 @@ namespace application return terminalAndTracer.terminal; } - infra::MemoryRange PlatformFactoryImpl::Leds() + hal::GpioPin& PlatformFactoryImpl::OperationalLed() { - return infra::MakeRangeFromSingleObject(pin); + return pin; + } + + hal::GpioPin& PlatformFactoryImpl::WarningLed() + { + return pin; + } + + hal::GpioPin& PlatformFactoryImpl::FailureLed() + { + return pin; + } + + uint8_t PlatformFactoryImpl::BoardId() const + { + return 0; + } + + bool PlatformFactoryImpl::PowerStatus() const + { + return true; } hal::PerformanceTracker& PlatformFactoryImpl::PerformanceTimer() diff --git a/targets/platform_implementations/host/implementation/PlatformFactoryImpl.hpp b/targets/platform_implementations/host/implementation/PlatformFactoryImpl.hpp index 3bd4e5e5..435a6510 100644 --- a/targets/platform_implementations/host/implementation/PlatformFactoryImpl.hpp +++ b/targets/platform_implementations/host/implementation/PlatformFactoryImpl.hpp @@ -38,7 +38,11 @@ namespace application void Run() override; services::Tracer& Tracer() override; services::TerminalWithCommands& Terminal() override; - infra::MemoryRange Leds() override; + hal::GpioPin& OperationalLed() override; + hal::GpioPin& WarningLed() override; + hal::GpioPin& FailureLed() override; + uint8_t BoardId() const override; + bool PowerStatus() const override; hal::PerformanceTracker& PerformanceTimer() override; hal::Hertz SystemClock() const override; foc::Volts PowerSupplyVoltage() override; diff --git a/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp b/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp new file mode 100644 index 00000000..2f09cc42 --- /dev/null +++ b/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include + +namespace application +{ + // E-FOC-HARDWARE custom motor control board. + // Analog front-end values are identical to FRDM-MC-LVPMSM (same voltage divider ratio and + // current sensor gain). Verified correct as initial bring-up defaults; update when schematic differs. + struct BoardCharacteristics + { + static constexpr float voltageToVolts{ 18.433f }; + static constexpr float overvoltageThresholdVolts{ 58.0f }; + + static constexpr float voltageToCurrent{ 5.0f }; + static constexpr float maxCurrentAmps{ 15.0f }; + static constexpr float overcurrentThresholdAmps{ 12.0f }; + + static constexpr float AdcToVoltsFactor(float adcReferenceVoltage, float adcResolution) + { + return (adcReferenceVoltage / adcResolution) * voltageToVolts; + } + + static constexpr float AdcToAmpereSlope(float adcReferenceVoltage, float adcResolution) + { + return (adcReferenceVoltage / adcResolution) * voltageToCurrent; + } + + static constexpr float AdcToAmpereOffset(float adcReferenceVoltage) + { + return -(adcReferenceVoltage / 2.0f) * voltageToCurrent; + } + + static constexpr uint16_t OvervoltageThresholdCounts(float adcReferenceVoltage, float adcResolution) + { + return static_cast((overvoltageThresholdVolts / (adcReferenceVoltage * voltageToVolts)) * (adcResolution - 1.0f)); + } + + static constexpr uint16_t OvercurrentThresholdCounts(float adcResolution) + { + return static_cast((overcurrentThresholdAmps / maxCurrentAmps) * (adcResolution - 1.0f)); + } + }; + + static_assert(BoardCharacteristics::OvervoltageThresholdCounts(3.3f, 4096.0f) == 3904u, "E-FOC-HARDWARE overvoltage threshold mismatch"); + static_assert(BoardCharacteristics::OvercurrentThresholdCounts(4096.0f) == 3276u, "E-FOC-HARDWARE overcurrent threshold mismatch"); +} diff --git a/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/CMakeLists.txt b/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/CMakeLists.txt new file mode 100644 index 00000000..07a17989 --- /dev/null +++ b/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/CMakeLists.txt @@ -0,0 +1,14 @@ +add_library(e_foc.motor_board INTERFACE) + +target_include_directories(e_foc.motor_board INTERFACE + "$" + "$" +) + +target_compile_definitions(e_foc.motor_board INTERFACE + MOTOR_BOARD_CHARACTERISTICS_HEADER="targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp" +) + +target_sources(e_foc.motor_board INTERFACE + BoardCharacteristics.hpp +) diff --git a/targets/platform_implementations/st/implementation/PlatformFactoryImpl.cpp b/targets/platform_implementations/st/implementation/PlatformFactoryImpl.cpp index 9d54d58e..11ce7e91 100644 --- a/targets/platform_implementations/st/implementation/PlatformFactoryImpl.cpp +++ b/targets/platform_implementations/st/implementation/PlatformFactoryImpl.cpp @@ -1,5 +1,4 @@ #include "targets/platform_implementations/st/implementation/PlatformFactoryImpl.hpp" -#include "infra/util/MemoryRange.hpp" #include "targets/platform_implementations/error_handling_cortex_m/PersistentFaultData.hpp" #include DEVICE_HEADER @@ -76,9 +75,29 @@ namespace application return terminalAndTracer.terminal; } - infra::MemoryRange PlatformFactoryImpl::Leds() + hal::GpioPin& PlatformFactoryImpl::OperationalLed() { - return infra::MakeRangeFromSingleObject(pin); + return pin; + } + + hal::GpioPin& PlatformFactoryImpl::WarningLed() + { + return pin; + } + + hal::GpioPin& PlatformFactoryImpl::FailureLed() + { + return pin; + } + + uint8_t PlatformFactoryImpl::BoardId() const + { + return 0; + } + + bool PlatformFactoryImpl::PowerStatus() const + { + return true; } hal::PerformanceTracker& PlatformFactoryImpl::PerformanceTimer() diff --git a/targets/platform_implementations/st/implementation/PlatformFactoryImpl.hpp b/targets/platform_implementations/st/implementation/PlatformFactoryImpl.hpp index 7c83be8d..3513ef9a 100644 --- a/targets/platform_implementations/st/implementation/PlatformFactoryImpl.hpp +++ b/targets/platform_implementations/st/implementation/PlatformFactoryImpl.hpp @@ -35,7 +35,11 @@ namespace application void Run() override; services::Tracer& Tracer() override; services::TerminalWithCommands& Terminal() override; - infra::MemoryRange Leds() override; + hal::GpioPin& OperationalLed() override; + hal::GpioPin& WarningLed() override; + hal::GpioPin& FailureLed() override; + uint8_t BoardId() const override; + bool PowerStatus() const override; hal::PerformanceTracker& PerformanceTimer() override; hal::Hertz SystemClock() const override; foc::Volts PowerSupplyVoltage() override; diff --git a/targets/platform_implementations/ti/EK-TM4C123GXL/PinsAndPeripherals.hpp b/targets/platform_implementations/ti/EK-TM4C123GXL/PinsAndPeripherals.hpp index e0a78297..65444b04 100644 --- a/targets/platform_implementations/ti/EK-TM4C123GXL/PinsAndPeripherals.hpp +++ b/targets/platform_implementations/ti/EK-TM4C123GXL/PinsAndPeripherals.hpp @@ -32,7 +32,9 @@ namespace application static hal::tiva::GpioPin pwmPhase3a{ hal::tiva::Port::E, 4 }; static hal::tiva::GpioPin pwmPhase3b{ hal::tiva::Port::E, 5 }; - static hal::tiva::GpioPin led1{ hal::tiva::Port::F, 1 }; + static hal::tiva::GpioPin operationalLed{ hal::tiva::Port::F, 1 }; + static hal::tiva::GpioPin warningLed{ hal::tiva::Port::F, 2 }; + static hal::tiva::GpioPin failureLed{ hal::tiva::Port::F, 2 }; // PF3 = canTx; alias to blue LED (PF2) static hal::tiva::GpioPin uartTx{ hal::tiva::Port::A, 0 }; static hal::tiva::GpioPin uartRx{ hal::tiva::Port::A, 1 }; @@ -56,6 +58,9 @@ namespace application // Fault comparator support is not available on EK-TM4C123GXL. constexpr static bool hasFaultComparators{ false }; + // boardId pins (PK0-2) and powerStatus pin (PC6) are not available on EK-TM4C123GXL. + constexpr static bool hasBoardIdPins{ false }; + constexpr static bool hasPowerStatusPin{ false }; constexpr static uint8_t OvercurrentComparatorIndex{ 0 }; constexpr static uint8_t OvervoltageComparatorIndex{ 1 }; constexpr static float adcReferenceVoltage{ 3.3f }; diff --git a/targets/platform_implementations/ti/EK-TM4C1294XL/PinsAndPeripherals.hpp b/targets/platform_implementations/ti/EK-TM4C1294XL/PinsAndPeripherals.hpp index 29574c10..7cfffb67 100644 --- a/targets/platform_implementations/ti/EK-TM4C1294XL/PinsAndPeripherals.hpp +++ b/targets/platform_implementations/ti/EK-TM4C1294XL/PinsAndPeripherals.hpp @@ -33,7 +33,15 @@ namespace application static hal::tiva::GpioPin pwmPhase3a{ hal::tiva::Port::K, 4 }; static hal::tiva::GpioPin pwmPhase3b{ hal::tiva::Port::K, 5 }; - static hal::tiva::GpioPin led1{ hal::tiva::Port::N, 0 }; + static hal::tiva::GpioPin operationalLed{ hal::tiva::Port::N, 2 }; + static hal::tiva::GpioPin failureLed{ hal::tiva::Port::N, 3 }; + static hal::tiva::GpioPin warningLed{ hal::tiva::Port::P, 2 }; + + static hal::tiva::GpioPin boardId0{ hal::tiva::Port::K, 0, hal::tiva::Drive::Up }; + static hal::tiva::GpioPin boardId1{ hal::tiva::Port::K, 1, hal::tiva::Drive::Up }; + static hal::tiva::GpioPin boardId2{ hal::tiva::Port::K, 2, hal::tiva::Drive::Up }; + + static hal::tiva::GpioPin powerStatus{ hal::tiva::Port::C, 6, hal::tiva::Drive::Up }; static hal::tiva::GpioPin uartRx{ hal::tiva::Port::D, 4 }; static hal::tiva::GpioPin uartTx{ hal::tiva::Port::D, 5 }; @@ -57,7 +65,9 @@ namespace application // ADC digital comparator indices mapped to PWM FLTSRC1 lines. // DCMP0 (PB4 / ADC10) → overcurrent trip; DCMP1 (PB5 / ADC11) → overvoltage trip. - constexpr static bool hasFaultComparators = true; + constexpr static bool hasFaultComparators{ true }; + constexpr static bool hasBoardIdPins{ true }; + constexpr static bool hasPowerStatusPin{ true }; constexpr static uint8_t OvercurrentComparatorIndex = 0; constexpr static uint8_t OvervoltageComparatorIndex = 1; diff --git a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp index 9e438ea7..c47d06dc 100644 --- a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp +++ b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp @@ -1,6 +1,5 @@ #include "targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp" #include "core/platform_abstraction/PlatformFactory.hpp" -#include "infra/util/MemoryRange.hpp" #include "services/tracer/GlobalTracer.hpp" #include "targets/platform_implementations/error_handling_cortex_m/PersistentFaultData.hpp" #include DEVICE_HEADER @@ -82,9 +81,42 @@ namespace application return peripherals->terminalAndTracer.terminal; } - infra::MemoryRange PlatformFactoryImpl::Leds() + hal::GpioPin& PlatformFactoryImpl::OperationalLed() { - return infra::MakeRangeFromSingleObject(application::Pins::led1); + return Pins::operationalLed; + } + + hal::GpioPin& PlatformFactoryImpl::WarningLed() + { + return Pins::warningLed; + } + + hal::GpioPin& PlatformFactoryImpl::FailureLed() + { + return Pins::failureLed; + } + + uint8_t PlatformFactoryImpl::BoardId() const + { + if constexpr (!Peripheral::hasBoardIdPins) + return 0; + + // Switches pull pins to ground; internal pull-ups make idle state high. + // Invert to get active-low encoding: pin low → bit set. + const uint8_t bit0 = Pins::boardId0.Get() ? 0u : 1u; + const uint8_t bit1 = Pins::boardId1.Get() ? 0u : 1u; + const uint8_t bit2 = Pins::boardId2.Get() ? 0u : 1u; + return static_cast((bit2 << 2u) | (bit1 << 1u) | bit0); + } + + bool PlatformFactoryImpl::PowerStatus() const + { + if constexpr (!Peripheral::hasPowerStatusPin) + return true; + + // LM5164 open-drain PG line with internal pull-up. + // High level = power good. + return Pins::powerStatus.Get(); } hal::PerformanceTracker& PlatformFactoryImpl::PerformanceTimer() diff --git a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp index a8919b09..0b60ebf2 100644 --- a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp +++ b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp @@ -45,7 +45,11 @@ namespace application void Run() override; services::Tracer& Tracer() override; services::TerminalWithCommands& Terminal() override; - infra::MemoryRange Leds() override; + hal::GpioPin& OperationalLed() override; + hal::GpioPin& WarningLed() override; + hal::GpioPin& FailureLed() override; + uint8_t BoardId() const override; + bool PowerStatus() const override; hal::PerformanceTracker& PerformanceTimer() override; hal::Hertz SystemClock() const override; foc::Volts PowerSupplyVoltage() override; diff --git a/targets/sync_foc_sensored/main/instantiations/Logic.cpp b/targets/sync_foc_sensored/main/instantiations/Logic.cpp index d00f7fd7..997b6b9d 100644 --- a/targets/sync_foc_sensored/main/instantiations/Logic.cpp +++ b/targets/sync_foc_sensored/main/instantiations/Logic.cpp @@ -4,7 +4,7 @@ namespace application { Logic::Logic(application::PlatformFactory& hardware) : hardware{ hardware } - , debugLed{ hardware.Leds().front(), std::chrono::milliseconds(50), std::chrono::milliseconds(1950) } + , debugLed{ hardware.OperationalLed(), std::chrono::milliseconds(50), std::chrono::milliseconds(1950) } , vdc{ hardware.PowerSupplyVoltage() } , terminalWithStorage{ hardware.Terminal(), hardware.Tracer(), services::TerminalWithBanner::Banner{ "sync_foc_sensored", vdc, hardware.SystemClock(), hardware.GetResetCause(), hardware.FaultStatus() } } , calibrationRegion{ hardware.Eeprom(), calibrationRegionOffset, calibrationRegionSize } From 79361bd0c5f49523ddaf4dfdedd00ffca57c3930 Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Tue, 30 Jun 2026 20:56:22 +0000 Subject: [PATCH 02/28] refactor(presets): make EK-TM4C1294XL default to E-FOC-HARDWARE board EK-TM4C1294XL now targets E-FOC-HARDWARE as its primary motor board. EK-TM4C1294XL-FRDM added for builds against the FRDM-MC-LVPMSM shield. Removes the intermediate EK-TM4C1294XL-efoc preset. Co-Authored-By: Claude Sonnet 4.6 --- CMakePresets.json | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/CMakePresets.json b/CMakePresets.json index 7043a8d2..88af25e2 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -128,6 +128,15 @@ "TARGET_MCU_FAMILY": "TM4C129", "TARGET_MCU": "tm4c1294ncpdt", "E_FOC_TARGET_BOARD": "EK-TM4C1294XL", + "E_FOC_MOTOR_BOARD": "E-FOC-HARDWARE" + } + }, + { + "name": "EK-TM4C1294XL-FRDM", + "displayName": "EK-TM4C1294XL with FRDM-MC-LVPMSM motor board", + "description": "Build for tm4c1294ncpdt with FRDM-MC-LVPMSM motor control board", + "inherits": "EK-TM4C1294XL", + "cacheVariables": { "E_FOC_MOTOR_BOARD": "FRDM-MC-LVPMSM" } }, @@ -145,15 +154,6 @@ "E_FOC_MOTOR_BOARD": "FRDM-MC-LVPMSM" } }, - { - "name": "EK-TM4C1294XL-efoc", - "displayName": "EK-TM4C1294XL with E-FOC-HARDWARE motor board", - "description": "Build for tm4c1294ncpdt with E-FOC-HARDWARE motor control board", - "inherits": "EK-TM4C1294XL", - "cacheVariables": { - "E_FOC_MOTOR_BOARD": "E-FOC-HARDWARE" - } - }, { "name": "STM32F407G-DISC1", "displayName": "STM32F407G-DISC1", @@ -263,14 +263,14 @@ "configurePreset": "EK-TM4C123GXL" }, { - "name": "EK-TM4C1294XL-efoc-RelWithDebInfo", + "name": "EK-TM4C1294XL-FRDM-RelWithDebInfo", "configuration": "RelWithDebInfo", - "configurePreset": "EK-TM4C1294XL-efoc" + "configurePreset": "EK-TM4C1294XL-FRDM" }, { - "name": "EK-TM4C1294XL-efoc-Debug", + "name": "EK-TM4C1294XL-FRDM-Debug", "configuration": "Debug", - "configurePreset": "EK-TM4C1294XL-efoc" + "configurePreset": "EK-TM4C1294XL-FRDM" }, { "name": "STM32F407G-DISC1-RelWithDebInfo", From 6d7dbbe4462002f2b9418f3cfa00868fbc4fc531 Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Thu, 2 Jul 2026 18:05:26 +0000 Subject: [PATCH 03/28] bring up done successfully --- scripts/install-caveman.sh | 53 +++++++++++++++++++ .../ti/EK-TM4C1294XL/PinsAndPeripherals.hpp | 6 +-- .../ti/implementation/PlatformFactoryImpl.cpp | 13 ++--- .../ti/implementation/PlatformFactoryImpl.hpp | 25 +++++---- 4 files changed, 76 insertions(+), 21 deletions(-) create mode 100755 scripts/install-caveman.sh diff --git a/scripts/install-caveman.sh b/scripts/install-caveman.sh new file mode 100755 index 00000000..57640843 --- /dev/null +++ b/scripts/install-caveman.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Installs Claude Code CLI and caveman without requiring curl or pre-installed Node.js. +# Needs: wget, bash, tar, sha256sum +# Tested on: Ubuntu 26.04 (devcontainer) +set -euo pipefail + +NODEJS_VER="20.20.2" +NODE_SHA256="df770b2a6f130ed8627c9782c988fda9669fa23898329a61a871e32f965e007d" +NODE_TAR="node-v${NODEJS_VER}-linux-x64.tar.xz" +NODE_URL="https://nodejs.org/dist/v${NODEJS_VER}/${NODE_TAR}" + +CLAUDE_CODE_VER="2.1.167" + +CAVEMAN_COMMIT="63a91ecadbf4c4719a4602a5abb00883f9966034" +CAVEMAN_SHA256="8ddef49c15f089c26affed3c31d97142c683e1d37a1499ae557281ca09c2712c" +CAVEMAN_URL="https://raw.githubusercontent.com/JuliusBrussee/caveman/${CAVEMAN_COMMIT}/install.sh" + +# ── Node.js ──────────────────────────────────────────────────────────────── +if command -v node >/dev/null 2>&1; then + NODE_MAJOR=$(node -p "process.versions.node.split('.')[0]") + if [ "$NODE_MAJOR" -ge 18 ]; then + echo "node $(node --version) already installed, skipping download." + else + echo "node $(node --version) is too old (need ≥18). Aborting." >&2 + exit 1 + fi +else + echo "Downloading Node.js v${NODEJS_VER}..." + wget -q --show-progress -O "/tmp/${NODE_TAR}" "${NODE_URL}" + echo "${NODE_SHA256} /tmp/${NODE_TAR}" | sha256sum --check + echo "Extracting Node.js to /usr/local..." + tar -xJf "/tmp/${NODE_TAR}" -C /usr/local --strip-components=1 + rm "/tmp/${NODE_TAR}" + echo "node $(node --version) installed." +fi + +# ── Claude Code CLI ──────────────────────────────────────────────────────── +if command -v claude >/dev/null 2>&1; then + echo "claude $(claude --version 2>/dev/null || true) already installed, skipping." +else + echo "Installing Claude Code CLI v${CLAUDE_CODE_VER}..." + npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VER}" + echo "claude $(claude --version 2>/dev/null || true) installed." +fi + +# ── caveman ──────────────────────────────────────────────────────────────── +echo "Downloading caveman installer..." +wget -q -O /tmp/caveman-install.sh "${CAVEMAN_URL}" +echo "${CAVEMAN_SHA256} /tmp/caveman-install.sh" | sha256sum --check +echo "Running caveman installer..." +bash /tmp/caveman-install.sh --non-interactive --only claude +rm /tmp/caveman-install.sh +echo "Done. Start any Claude Code session and say 'caveman mode', or run /caveman." diff --git a/targets/platform_implementations/ti/EK-TM4C1294XL/PinsAndPeripherals.hpp b/targets/platform_implementations/ti/EK-TM4C1294XL/PinsAndPeripherals.hpp index 7cfffb67..bb18c5b5 100644 --- a/targets/platform_implementations/ti/EK-TM4C1294XL/PinsAndPeripherals.hpp +++ b/targets/platform_implementations/ti/EK-TM4C1294XL/PinsAndPeripherals.hpp @@ -33,9 +33,9 @@ namespace application static hal::tiva::GpioPin pwmPhase3a{ hal::tiva::Port::K, 4 }; static hal::tiva::GpioPin pwmPhase3b{ hal::tiva::Port::K, 5 }; - static hal::tiva::GpioPin operationalLed{ hal::tiva::Port::N, 2 }; - static hal::tiva::GpioPin failureLed{ hal::tiva::Port::N, 3 }; - static hal::tiva::GpioPin warningLed{ hal::tiva::Port::P, 2 }; + static hal::tiva::GpioPin warningLed{ hal::tiva::Port::N, 2 }; + static hal::tiva::GpioPin operationalLed{ hal::tiva::Port::N, 3 }; + static hal::tiva::GpioPin failureLed{ hal::tiva::Port::P, 2 }; static hal::tiva::GpioPin boardId0{ hal::tiva::Port::K, 0, hal::tiva::Drive::Up }; static hal::tiva::GpioPin boardId1{ hal::tiva::Port::K, 1, hal::tiva::Drive::Up }; diff --git a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp index c47d06dc..ff338a6f 100644 --- a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp +++ b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp @@ -101,11 +101,10 @@ namespace application if constexpr (!Peripheral::hasBoardIdPins) return 0; - // Switches pull pins to ground; internal pull-ups make idle state high. - // Invert to get active-low encoding: pin low → bit set. - const uint8_t bit0 = Pins::boardId0.Get() ? 0u : 1u; - const uint8_t bit1 = Pins::boardId1.Get() ? 0u : 1u; - const uint8_t bit2 = Pins::boardId2.Get() ? 0u : 1u; + const uint8_t bit0 = peripherals->boardId.boardId0.Get() ? 0u : 1u; + const uint8_t bit1 = peripherals->boardId.boardId1.Get() ? 0u : 1u; + const uint8_t bit2 = peripherals->boardId.boardId2.Get() ? 0u : 1u; + return static_cast((bit2 << 2u) | (bit1 << 1u) | bit0); } @@ -114,9 +113,7 @@ namespace application if constexpr (!Peripheral::hasPowerStatusPin) return true; - // LM5164 open-drain PG line with internal pull-up. - // High level = power good. - return Pins::powerStatus.Get(); + return peripherals->powerStatus.Get(); } hal::PerformanceTracker& PlatformFactoryImpl::PerformanceTimer() diff --git a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp index 0b60ebf2..a71ad86b 100644 --- a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp +++ b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp @@ -128,22 +128,20 @@ namespace application static constexpr auto currentSensingOversampling = hal::tiva::Adc::Oversampling::oversampling2; // Steps 0–2 go to the ADC FIFO (phase currents A/B/C). - // Steps 3–4 are redirected to DCMP units 0 and 1 via the SSOP register and - // do NOT appear in the FIFO, so AdcPhaseCurrentMeasurementImpl still receives - // exactly 3 samples. DCMP0/1 outputs connect to PWM FLTSRC1 bits 0/1 and - // tristate all motor PWM outputs instantly when a threshold is exceeded. - static constexpr std::array digitalComparators{ { + // Step 3 is redirected to DCMP0 via the SSOP register and does NOT appear in + // the FIFO. DCMP0 connects to PWM FLTSRC1 bit 0 and tristates all motor PWM + // outputs instantly when the overcurrent threshold is exceeded. + // Overvoltage is detected in software via PowerSupplyVoltage() on ADC1. + static constexpr std::array digitalComparators{ { {}, // step 0: currentPhaseA → FIFO (noComparator) {}, // step 1: currentPhaseB → FIFO (noComparator) {}, // step 2: currentPhaseC → FIFO (noComparator) { Peripheral::OvercurrentComparatorIndex, 0, Peripheral::overcurrentThresholdCounts, hal::tiva::Adc::ComparatorCondition::highBand, hal::tiva::Adc::ComparatorMode::always }, - { Peripheral::OvervoltageComparatorIndex, 0, Peripheral::overvoltageThresholdCounts, - hal::tiva::Adc::ComparatorCondition::highBand, hal::tiva::Adc::ComparatorMode::always }, } }; hal::tiva::Adc::Config adcConfig{ false, 0, Peripheral::adcTrigger, hal::tiva::Adc::SampleAndHold::sampleAndHold8, std::make_optional(currentSensingOversampling), phaseDelay }; - std::array currentPhaseAnalogPins{ { hal::tiva::AnalogPin{ Pins::currentPhaseA }, hal::tiva::AnalogPin{ Pins::currentPhaseB }, hal::tiva::AnalogPin{ Pins::currentPhaseC }, hal::tiva::AnalogPin{ Pins::currentTotal }, hal::tiva::AnalogPin{ Pins::powerSupplyVoltage } } }; + std::array currentPhaseAnalogPins{ { hal::tiva::AnalogPin{ Pins::currentPhaseA }, hal::tiva::AnalogPin{ Pins::currentPhaseB }, hal::tiva::AnalogPin{ Pins::currentPhaseC }, hal::tiva::AnalogPin{ Pins::currentTotal } } }; }; struct AsyncPwmConfig @@ -202,11 +200,17 @@ namespace application } } - struct Peripherals + struct BoardIdentificationPins { - Peripherals() {}; + hal::InputPin boardId0{ Pins::boardId0 }; + hal::InputPin boardId1{ Pins::boardId1 }; + hal::InputPin boardId2{ Pins::boardId2 }; + }; + struct Peripherals + { hal::OutputPin performance{ Pins::performance }; + hal::InputPin powerStatus{ Pins::powerStatus }; Cortex cortex; TerminalAndTracer terminalAndTracer; AdcForPowerSupplyMeasurementImpl adcForPowerSupplyMeasurementImpl; @@ -214,6 +218,7 @@ namespace application AsyncPwmConfig asyncPwmConfig; SyncPwmConfig syncPwmConfig; hal::tiva::Eeprom eepromPeripheral; + BoardIdentificationPins boardId; std::optional> phaseCurrentAdc; std::optional asyncPwm; From 8f979f25bed922be176cded4e1f99feafb138472 Mon Sep 17 00:00:00 2001 From: gfs Date: Thu, 2 Jul 2026 20:07:26 +0200 Subject: [PATCH 04/28] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- documentation/architecture/system.md | 4 ++-- .../ti/EK-TM4C123GXL/PinsAndPeripherals.hpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/documentation/architecture/system.md b/documentation/architecture/system.md index aeaa602b..75c0b961 100644 --- a/documentation/architecture/system.md +++ b/documentation/architecture/system.md @@ -158,8 +158,8 @@ The PAL provides a single platform-facing abstraction that groups creation and a | Performance timer | Cycle/timestamp measurement interface for profiling | | Serial terminal | Diagnostic trace and command-line interaction interface | | Status LEDs | Three discrete GPIO outputs: `OperationalLed` (heartbeat blink during normal operation), `WarningLed` (non-fatal condition), `FailureLed` (fault / safe-state) | -| Board identity | `BoardId() const` — returns a 3-bit board ID (0–7) encoded on three active-low GPIO inputs (internal pull-ups), used as the CAN node address | -| Power status | `PowerStatus() const` — returns `true` when the power-good signal from the power stage is asserted (high = good on an open-drain PG line with pull-up) | +| Board identity | `BoardId() const` — returns a 3-bit board ID (0–7) encoded on three active-low GPIO inputs (internal pull-ups), used as the CAN node address | +| Power status | `PowerStatus() const` — returns `true` when the power-good signal from the power stage is asserted (high = good on an open-drain PG line with pull-up) | Concrete implementations exist for: - **TI Tiva (EK-TM4C1294XL, EK-TM4C123GXL)**: platform-specific peripheral adapters under `targets/platform_implementations/ti/`. diff --git a/targets/platform_implementations/ti/EK-TM4C123GXL/PinsAndPeripherals.hpp b/targets/platform_implementations/ti/EK-TM4C123GXL/PinsAndPeripherals.hpp index 65444b04..29490d3e 100644 --- a/targets/platform_implementations/ti/EK-TM4C123GXL/PinsAndPeripherals.hpp +++ b/targets/platform_implementations/ti/EK-TM4C123GXL/PinsAndPeripherals.hpp @@ -34,7 +34,7 @@ namespace application static hal::tiva::GpioPin operationalLed{ hal::tiva::Port::F, 1 }; static hal::tiva::GpioPin warningLed{ hal::tiva::Port::F, 2 }; - static hal::tiva::GpioPin failureLed{ hal::tiva::Port::F, 2 }; // PF3 = canTx; alias to blue LED (PF2) + static hal::tiva::GpioPin failureLed{ hal::tiva::Port::F, 1 }; // PF3 = canTx; alias to red LED (PF1) static hal::tiva::GpioPin uartTx{ hal::tiva::Port::A, 0 }; static hal::tiva::GpioPin uartRx{ hal::tiva::Port::A, 1 }; From c9485ae16cb3cdeb83d4a1a03e9bef04dfc06d92 Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Sun, 5 Jul 2026 16:53:04 +0000 Subject: [PATCH 05/28] refactor(platform): remove EK-TM4C123GXL and consolidate on async PWM TM4C123 silicon lacks PWM FLTSRC1 digital comparator routing, making hardware fault protection impossible on that board. EK-TM4C1294XL is the only supported TI target going forward. - Delete EK-TM4C123GXL platform implementation - Collapse all hasFaultComparators branches in PlatformFactoryImpl to the async (fault-comparator) path unconditionally - Remove SyncPwmConfig, syncPwm optional, and related includes - Remove syncPwmPhases stubs and hasFaultComparators from EK-TM4C1294XL - Remove EK-TM4C123GXL from CMake presets and CI build matrices - Update CLAUDE.md and architecture doc to reflect single supported board Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 1 - .github/workflows/release-please.yml | 2 +- CLAUDE.md | 2 +- CMakePresets.json | 24 ----- documentation/architecture/system.md | 2 +- .../ti/EK-TM4C123GXL/CMakeLists.txt | 15 --- .../ti/EK-TM4C123GXL/PinsAndPeripherals.hpp | 97 ------------------- .../ti/EK-TM4C1294XL/PinsAndPeripherals.hpp | 9 -- .../ti/implementation/PlatformFactoryImpl.cpp | 42 ++------ .../ti/implementation/PlatformFactoryImpl.hpp | 12 --- 10 files changed, 9 insertions(+), 197 deletions(-) delete mode 100644 targets/platform_implementations/ti/EK-TM4C123GXL/CMakeLists.txt delete mode 100644 targets/platform_implementations/ti/EK-TM4C123GXL/PinsAndPeripherals.hpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf65d6b6..fb36b6c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,7 +156,6 @@ jobs: target: [ "EK-TM4C1294XL", - "EK-TM4C123GXL", "STM32F407G-DISC1", "NUCLEO-H563ZI" ] diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 881c20be..b7f71c14 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -162,7 +162,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - target: ["EK-TM4C1294XL", "EK-TM4C123GXL"] + target: ["EK-TM4C1294XL"] configuration: ["RelWithDebInfo"] gcc: ["13.2.Rel1"] steps: diff --git a/CLAUDE.md b/CLAUDE.md index b3cd4cc3..7ab3d04f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ cmake --preset coverage cmake --build --preset coverage ``` -All presets are defined in `CMakePresets.json`. Available configure presets: `host`, `coverage`, `EK-TM4C1294XL`, `EK-TM4C123GXL`, `STM32F407G-DISC1`, `NUCLEO-H563ZI`. +All presets are defined in `CMakePresets.json`. Available configure presets: `host`, `coverage`, `EK-TM4C1294XL`, `STM32F407G-DISC1`, `NUCLEO-H563ZI`. ## 3. Project-Specific Constraints (must follow) diff --git a/CMakePresets.json b/CMakePresets.json index 88af25e2..b89c0ad6 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -140,20 +140,6 @@ "E_FOC_MOTOR_BOARD": "FRDM-MC-LVPMSM" } }, - { - "name": "EK-TM4C123GXL", - "displayName": "EK-TM4C123GXL", - "description": "Build for tm4c123gh6pm", - "inherits": "tiva", - "toolchainFile": "${sourceDir}/infra/embedded-infra-lib/cmake/toolchain-arm-gcc-m4-fpv4-sp-d16.cmake", - "cacheVariables": { - "TARGET_CORTEX": "m4", - "TARGET_MCU_FAMILY": "TM4C123", - "TARGET_MCU": "tm4c123gh6pm", - "E_FOC_TARGET_BOARD": "EK-TM4C123GXL", - "E_FOC_MOTOR_BOARD": "FRDM-MC-LVPMSM" - } - }, { "name": "STM32F407G-DISC1", "displayName": "STM32F407G-DISC1", @@ -252,16 +238,6 @@ "configuration": "Debug", "configurePreset": "EK-TM4C1294XL" }, - { - "name": "EK-TM4C123GXL-RelWithDebInfo", - "configuration": "RelWithDebInfo", - "configurePreset": "EK-TM4C123GXL" - }, - { - "name": "EK-TM4C123GXL-Debug", - "configuration": "Debug", - "configurePreset": "EK-TM4C123GXL" - }, { "name": "EK-TM4C1294XL-FRDM-RelWithDebInfo", "configuration": "RelWithDebInfo", diff --git a/documentation/architecture/system.md b/documentation/architecture/system.md index 75c0b961..765cf2fb 100644 --- a/documentation/architecture/system.md +++ b/documentation/architecture/system.md @@ -162,7 +162,7 @@ The PAL provides a single platform-facing abstraction that groups creation and a | Power status | `PowerStatus() const` — returns `true` when the power-good signal from the power stage is asserted (high = good on an open-drain PG line with pull-up) | Concrete implementations exist for: -- **TI Tiva (EK-TM4C1294XL, EK-TM4C123GXL)**: platform-specific peripheral adapters under `targets/platform_implementations/ti/`. +- **TI Tiva (EK-TM4C1294XL)**: platform-specific peripheral adapters under `targets/platform_implementations/ti/`. The TI target uses asynchronous PWM with hardware fault comparators as the only supported PWM path. - **ST STM32 (STM32F407G-DISC1, NUCLEO-H563ZI)**: platform-specific peripheral adapters under `targets/platform_implementations/st/`. - **Host / Simulator**: a software-backed platform implementation that emulates the motor-control I/O needed to run the closed-loop algorithm on a development machine, located under `targets/platform_implementations/host/`. diff --git a/targets/platform_implementations/ti/EK-TM4C123GXL/CMakeLists.txt b/targets/platform_implementations/ti/EK-TM4C123GXL/CMakeLists.txt deleted file mode 100644 index e03fc178..00000000 --- a/targets/platform_implementations/ti/EK-TM4C123GXL/CMakeLists.txt +++ /dev/null @@ -1,15 +0,0 @@ -add_library(e_foc.platform_abstraction_${E_FOC_TARGET_BOARD} INTERFACE) - -target_include_directories(e_foc.platform_abstraction_${E_FOC_TARGET_BOARD} INTERFACE - "$" - "$" -) - -target_link_libraries(e_foc.platform_abstraction_${E_FOC_TARGET_BOARD} INTERFACE - hal_tiva.tiva - hal_tiva.synchronous_tiva -) - -target_sources(e_foc.platform_abstraction_${E_FOC_TARGET_BOARD} INTERFACE - PinsAndPeripherals.hpp -) diff --git a/targets/platform_implementations/ti/EK-TM4C123GXL/PinsAndPeripherals.hpp b/targets/platform_implementations/ti/EK-TM4C123GXL/PinsAndPeripherals.hpp deleted file mode 100644 index 29490d3e..00000000 --- a/targets/platform_implementations/ti/EK-TM4C123GXL/PinsAndPeripherals.hpp +++ /dev/null @@ -1,97 +0,0 @@ -#pragma once - -#include "hal_tiva/synchronous_tiva/SynchronousPwm.hpp" -#include "hal_tiva/tiva/Adc.hpp" -#include "hal_tiva/tiva/ClockTm4c123.hpp" -#include "hal_tiva/tiva/Gpio.hpp" -#include "hal_tiva/tiva/PinoutTableDefaultTm4c123.hpp" -#include "hal_tiva/tiva/Pwm.hpp" - -namespace application -{ - namespace Pins - { - static hal::tiva::GpioPin currentPhaseA{ hal::tiva::Port::E, 3 }; - static hal::tiva::GpioPin currentPhaseB{ hal::tiva::Port::E, 2 }; - static hal::tiva::GpioPin currentPhaseC{ hal::tiva::Port::E, 1 }; - static hal::tiva::GpioPin powerSupplyVoltage{ hal::tiva::Port::E, 0 }; - static hal::tiva::GpioPin currentTotal{ hal::tiva::Port::E, 0 }; // alias — no separate current-total pin on this board - - static hal::tiva::GpioPin hallSensorA{ hal::tiva::Port::A, 4 }; - static hal::tiva::GpioPin hallSensorB{ hal::tiva::Port::A, 5 }; - static hal::tiva::GpioPin hallSensorC{ hal::tiva::Port::A, 6 }; - - static hal::tiva::GpioPin encoderA{ hal::tiva::Port::D, 6 }; - static hal::tiva::GpioPin encoderB{ hal::tiva::Port::D, 7 }; - static hal::tiva::GpioPin encoderZ{ hal::tiva::Port::D, 3 }; - - static hal::tiva::GpioPin pwmPhase1a{ hal::tiva::Port::B, 6 }; - static hal::tiva::GpioPin pwmPhase1b{ hal::tiva::Port::B, 7 }; - static hal::tiva::GpioPin pwmPhase2a{ hal::tiva::Port::B, 4 }; - static hal::tiva::GpioPin pwmPhase2b{ hal::tiva::Port::B, 5 }; - static hal::tiva::GpioPin pwmPhase3a{ hal::tiva::Port::E, 4 }; - static hal::tiva::GpioPin pwmPhase3b{ hal::tiva::Port::E, 5 }; - - static hal::tiva::GpioPin operationalLed{ hal::tiva::Port::F, 1 }; - static hal::tiva::GpioPin warningLed{ hal::tiva::Port::F, 2 }; - static hal::tiva::GpioPin failureLed{ hal::tiva::Port::F, 1 }; // PF3 = canTx; alias to red LED (PF1) - - static hal::tiva::GpioPin uartTx{ hal::tiva::Port::A, 0 }; - static hal::tiva::GpioPin uartRx{ hal::tiva::Port::A, 1 }; - - static hal::tiva::GpioPin canRx{ hal::tiva::Port::F, 0 }; - static hal::tiva::GpioPin canTx{ hal::tiva::Port::F, 3 }; - - static hal::tiva::GpioPin performance{ hal::tiva::Port::A, 2 }; - } - - namespace Peripheral - { - using hal_pwm = hal::tiva::SynchronousPwm; - - constexpr static uint8_t QeiIndex = 0; - constexpr static uint8_t AdcIndex = 0; - constexpr static uint8_t AdcSequencerIndex = 0; - constexpr static uint8_t UartIndex = 0; - constexpr static uint8_t PwmIndex = 0; - constexpr static uint8_t CanIndex = 0; - - // Fault comparator support is not available on EK-TM4C123GXL. - constexpr static bool hasFaultComparators{ false }; - // boardId pins (PK0-2) and powerStatus pin (PC6) are not available on EK-TM4C123GXL. - constexpr static bool hasBoardIdPins{ false }; - constexpr static bool hasPowerStatusPin{ false }; - constexpr static uint8_t OvercurrentComparatorIndex{ 0 }; - constexpr static uint8_t OvervoltageComparatorIndex{ 1 }; - constexpr static float adcReferenceVoltage{ 3.3f }; - constexpr static float adcResolution{ 4096.0f }; - // Hardware comparators absent — trip counts not used; set to 0 explicitly. - constexpr static uint16_t overvoltageThresholdCounts{ 0 }; - constexpr static uint16_t overcurrentThresholdCounts{ 0 }; - - static hal::tiva::Adc::Trigger adcTrigger = hal::tiva::Adc::Trigger::pwmGenerator0; - - static hal_pwm::PinChannel syncPwmPhase1{ hal_pwm::GeneratorIndex::generator0, Pins::pwmPhase1a, Pins::pwmPhase1b, true, true, std::make_optional(hal::tiva::SynchronousPwm::PinChannel::Trigger::countLoad) }; - static hal_pwm::PinChannel syncPwmPhase2{ hal_pwm::GeneratorIndex::generator1, Pins::pwmPhase2a, Pins::pwmPhase2b, true, true, std::nullopt }; - static hal_pwm::PinChannel syncPwmPhase3{ hal_pwm::GeneratorIndex::generator2, Pins::pwmPhase3a, Pins::pwmPhase3b, true, true, std::nullopt }; - - static std::array syncPwmPhases{ { syncPwmPhase1, syncPwmPhase2, syncPwmPhase3 } }; - - // Async PWM stubs — not used on this board; required for compilation only. - static hal::tiva::Pwm::PinChannel asyncPwmPhase1{ hal::tiva::Pwm::GeneratorIndex::generator0, Pins::pwmPhase1a, Pins::pwmPhase1b, true, true, std::nullopt }; - static hal::tiva::Pwm::PinChannel asyncPwmPhase2{ hal::tiva::Pwm::GeneratorIndex::generator1, Pins::pwmPhase2a, Pins::pwmPhase2b, true, true, std::nullopt }; - static hal::tiva::Pwm::PinChannel asyncPwmPhase3{ hal::tiva::Pwm::GeneratorIndex::generator2, Pins::pwmPhase3a, Pins::pwmPhase3b, true, true, std::nullopt }; - - static std::array asyncPwmPhases{ { asyncPwmPhase1, asyncPwmPhase2, asyncPwmPhase3 } }; - } - - namespace Clocks - { - inline void Initialize() - { - hal::tiva::systemClockDivider systemClockDivisor{ 2, 5 }; - bool usesPll = true; - hal::tiva::ConfigureClock(hal::tiva::crystalFrequency::_16_MHz, hal::tiva::oscillatorSource::main); - } - } -} diff --git a/targets/platform_implementations/ti/EK-TM4C1294XL/PinsAndPeripherals.hpp b/targets/platform_implementations/ti/EK-TM4C1294XL/PinsAndPeripherals.hpp index bb18c5b5..31d56e2e 100644 --- a/targets/platform_implementations/ti/EK-TM4C1294XL/PinsAndPeripherals.hpp +++ b/targets/platform_implementations/ti/EK-TM4C1294XL/PinsAndPeripherals.hpp @@ -1,6 +1,5 @@ #pragma once -#include "hal_tiva/synchronous_tiva/SynchronousPwm.hpp" #include "hal_tiva/tiva/Adc.hpp" #include "hal_tiva/tiva/ClockTm4c129.hpp" #include "hal_tiva/tiva/Gpio.hpp" @@ -65,7 +64,6 @@ namespace application // ADC digital comparator indices mapped to PWM FLTSRC1 lines. // DCMP0 (PB4 / ADC10) → overcurrent trip; DCMP1 (PB5 / ADC11) → overvoltage trip. - constexpr static bool hasFaultComparators{ true }; constexpr static bool hasBoardIdPins{ true }; constexpr static bool hasPowerStatusPin{ true }; constexpr static uint8_t OvercurrentComparatorIndex = 0; @@ -87,13 +85,6 @@ namespace application static hal_pwm::PinChannel asyncPwmPhase3{ hal_pwm::GeneratorIndex::generator3, Pins::pwmPhase3a, Pins::pwmPhase3b, true, true, std::nullopt }; static std::array asyncPwmPhases{ { asyncPwmPhase1, asyncPwmPhase2, asyncPwmPhase3 } }; - - // Synchronous PWM stubs — not used on this board; required for compilation only. - static hal::tiva::SynchronousPwm::PinChannel syncPwmPhase1{ hal::tiva::SynchronousPwm::GeneratorIndex::generator0, Pins::pwmPhase1a, Pins::pwmPhase1b, true, true, std::nullopt }; - static hal::tiva::SynchronousPwm::PinChannel syncPwmPhase2{ hal::tiva::SynchronousPwm::GeneratorIndex::generator1, Pins::pwmPhase2a, Pins::pwmPhase2b, true, true, std::nullopt }; - static hal::tiva::SynchronousPwm::PinChannel syncPwmPhase3{ hal::tiva::SynchronousPwm::GeneratorIndex::generator2, Pins::pwmPhase3a, Pins::pwmPhase3b, true, true, std::nullopt }; - - static std::array syncPwmPhases{ { syncPwmPhase1, syncPwmPhase2, syncPwmPhase3 } }; } namespace Clocks diff --git a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp index ff338a6f..226ae4dc 100644 --- a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp +++ b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp @@ -178,8 +178,7 @@ namespace application auto& adcCfg = impl.adcConfig; adcCfg.sampleAndHold = impl.toSampleAndHold.at(static_cast(sampleAndHold)); - if constexpr (Peripheral::hasFaultComparators) - adcCfg.digitalComparators = infra::MakeRange(impl.digitalComparators); + adcCfg.digitalComparators = infra::MakeRange(impl.digitalComparators); peripherals->phaseCurrentAdc.reset(); peripherals->phaseCurrentAdc.emplace( @@ -191,8 +190,6 @@ namespace application adcCfg); peripherals->asyncPwm.reset(); - peripherals->syncPwm.reset(); - if (Peripheral::hasFaultComparators) { auto& cfg = peripherals->asyncPwmConfig; cfg.deadTimeConfig.fallInClockCycles = hal::tiva::Pwm::CalculateDeadTimeCycles(deadTime, cfg.clockDivisor); @@ -215,22 +212,7 @@ namespace application onFaultCallback(PlatformFactory::BoardProtectionReason::overVoltage); }); } - else - { - auto& cfg = peripherals->syncPwmConfig; - cfg.deadTimeConfig.fallInClockCycles = hal::tiva::SynchronousPwm::CalculateDeadTimeCycles(deadTime, cfg.clockDivisor); - cfg.deadTimeConfig.riseInClockCycles = hal::tiva::SynchronousPwm::CalculateDeadTimeCycles(deadTime, cfg.clockDivisor); - cfg.pwmConfig.deadTime = std::make_optional(cfg.deadTimeConfig); - - peripherals->syncPwm.emplace( - Peripheral::PwmIndex, - infra::MakeRange(Peripheral::syncPwmPhases), - cfg.pwmConfig); - } - if (Peripheral::hasFaultComparators) - peripherals->asyncPwm->SetBaseFrequency(baseFrequency); - else - peripherals->syncPwm->SetBaseFrequency(baseFrequency); + peripherals->asyncPwm->SetBaseFrequency(baseFrequency); pwmBaseFrequency = baseFrequency; } @@ -269,10 +251,7 @@ namespace application OPTIMIZE_FOR_SPEED void PlatformFactoryImpl::PhaseCurrentsReady(hal::Hertz baseFrequency, const infra::Function& onDone) { onPhaseCurrentsReady = onDone; - if (Peripheral::hasFaultComparators) - peripherals->asyncPwm->SetBaseFrequency(baseFrequency); - else - peripherals->syncPwm->SetBaseFrequency(baseFrequency); + peripherals->asyncPwm->SetBaseFrequency(baseFrequency); peripherals->phaseCurrentAdc->Measure([this](foc::Ampere a, foc::Ampere b, foc::Ampere c) { onPhaseCurrentsReady(foc::PhaseCurrents{ a, b, c }); @@ -281,26 +260,17 @@ namespace application OPTIMIZE_FOR_SPEED void PlatformFactoryImpl::ThreePhasePwmOutput(const foc::PhasePwmDutyCycles& dutyPhases) { - if (Peripheral::hasFaultComparators) - peripherals->asyncPwm->Start(dutyPhases.a, dutyPhases.b, dutyPhases.c); - else - peripherals->syncPwm->Start(dutyPhases.a, dutyPhases.b, dutyPhases.c); + peripherals->asyncPwm->Start(dutyPhases.a, dutyPhases.b, dutyPhases.c); } void PlatformFactoryImpl::Start() { - if (Peripheral::hasFaultComparators) - peripherals->asyncPwm->Start(hal::Percent{ 1 }, hal::Percent{ 1 }, hal::Percent{ 1 }); - else - peripherals->syncPwm->Start(hal::Percent{ 1 }, hal::Percent{ 1 }, hal::Percent{ 1 }); + peripherals->asyncPwm->Start(hal::Percent{ 1 }, hal::Percent{ 1 }, hal::Percent{ 1 }); } void PlatformFactoryImpl::Stop() { - if (Peripheral::hasFaultComparators) - peripherals->asyncPwm->Stop(); - else - peripherals->syncPwm->Stop(); + peripherals->asyncPwm->Stop(); } hal::Hertz PlatformFactoryImpl::BaseFrequency() const diff --git a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp index a71ad86b..cbd59724 100644 --- a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp +++ b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp @@ -11,8 +11,6 @@ #include "hal_tiva/cortex/DataWatchpointAndTrace.hpp" #include "hal_tiva/cortex/SystemTickTimerService.hpp" #include "hal_tiva/synchronous_tiva/SynchronousAdc.hpp" -#include "hal_tiva/synchronous_tiva/SynchronousPwm.hpp" -#include "hal_tiva/synchronous_tiva/SynchronousQuadratureEncoder.hpp" #include "hal_tiva/tiva/Adc.hpp" #include "hal_tiva/tiva/Can.hpp" #include "hal_tiva/tiva/Dma.hpp" @@ -163,14 +161,6 @@ namespace application hal::tiva::Pwm::Config pwmConfig{ false, false, controlConfig, clockDivisor, std::make_optional(deadTimeConfig), std::make_optional(interruptConfig) }; }; - struct SyncPwmConfig - { - hal::tiva::SynchronousPwm::Config::ClockDivisor clockDivisor{ hal::tiva::SynchronousPwm::Config::ClockDivisor::divisor8 }; - hal::tiva::SynchronousPwm::Config::Control controlConfig{ hal::tiva::SynchronousPwm::Config::Control::Mode::centerAligned, hal::tiva::SynchronousPwm::Config::Control::UpdateMode::globally, false }; - hal::tiva::SynchronousPwm::Config::DeadTime deadTimeConfig{ hal::tiva::SynchronousPwm::CalculateDeadTimeCycles(1000ns, clockDivisor), hal::tiva::SynchronousPwm::CalculateDeadTimeCycles(1000ns, clockDivisor) }; - hal::tiva::SynchronousPwm::Config pwmConfig{ false, false, controlConfig, clockDivisor, std::make_optional(deadTimeConfig) }; - }; - static CanBusAdapter::CanError ToAdapterError(hal::tiva::Can::Error error) { switch (error) @@ -216,13 +206,11 @@ namespace application AdcForPowerSupplyMeasurementImpl adcForPowerSupplyMeasurementImpl; AdcForPhaseCurrentMeasurementImpl adcForPhaseCurrentMeasurementImpl; AsyncPwmConfig asyncPwmConfig; - SyncPwmConfig syncPwmConfig; hal::tiva::Eeprom eepromPeripheral; BoardIdentificationPins boardId; std::optional> phaseCurrentAdc; std::optional asyncPwm; - std::optional syncPwm; std::optional> encoder; std::optional>> canBus; From 82ca5cbad7d385bdc7cf088e62f6d5446e811f0d Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Sun, 5 Jul 2026 16:55:18 +0000 Subject: [PATCH 06/28] update agents --- .claude/agents/executor.md | 2 +- .claude/agents/orchestrator.md | 2 +- .claude/agents/planner.md | 2 +- .claude/agents/reviewer.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude/agents/executor.md b/.claude/agents/executor.md index 57199e96..a30f6794 100644 --- a/.claude/agents/executor.md +++ b/.claude/agents/executor.md @@ -1,7 +1,7 @@ --- name: executor description: Use when implementing code changes in e-foc. Writes production code and tests following all project constraints: no heap allocation in embedded code, bounded containers, real-time determinism, FOC theory correctness, motor control best practices, SOLID principles, and documentation alignment. -model: claude-sonnet-4-6 +model: opus tools: - Read - Edit diff --git a/.claude/agents/orchestrator.md b/.claude/agents/orchestrator.md index d5c55868..a65bd3ad 100644 --- a/.claude/agents/orchestrator.md +++ b/.claude/agents/orchestrator.md @@ -1,7 +1,7 @@ --- name: orchestrator description: Use when starting a new development task in e-foc. Triages requests and routes to the appropriate specialist agent: planner for design, executor for implementation, or reviewer for code review. This agent should be invoked first for any non-trivial task. -model: claude-sonnet-4-6 +model: opus tools: - Read - Bash diff --git a/.claude/agents/planner.md b/.claude/agents/planner.md index 7595ee19..3aaf641a 100644 --- a/.claude/agents/planner.md +++ b/.claude/agents/planner.md @@ -1,7 +1,7 @@ --- name: planner description: Use when a detailed implementation plan is needed before writing code in e-foc. Produces structured, actionable plans that follow all e-foc constraints: no heap allocation, real-time determinism, FOC theory correctness, motor control best practices, SOLID principles, and documentation alignment. Does NOT write or edit code. -model: claude-opus-4-8 +model: opus tools: - Read - Bash diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md index 9f0d6923..fd255b17 100644 --- a/.claude/agents/reviewer.md +++ b/.claude/agents/reviewer.md @@ -1,7 +1,7 @@ --- name: reviewer description: Use when reviewing code changes in e-foc. Performs structured code review against all project standards: memory safety (no heap in embedded code), real-time determinism, FOC theory correctness, motor control best practices, embedded optimizations, documentation alignment, SOLID principles, and test coverage. Does NOT modify files. -model: claude-sonnet-4-6 +model: opus tools: - Read - Bash From d7632c8f478d5837cdd755c034661e3bbf589d09 Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Fri, 10 Jul 2026 19:21:50 +0000 Subject: [PATCH 07/28] fix(platform): resolve PR #205 Copilot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Host/ST: return distinct operationalPin/warningPin/failurePin stubs from OperationalLed(), WarningLed(), FailureLed() so callers cannot accidentally observe interference between the three signals - TI: restore hardware overvoltage protection via DCMP1 — step 4 (powerSupplyVoltage) is redirected to digital comparator 1 which feeds PWM FLTSRC1 bit 1 and tristates all motor PWM outputs instantly when the overvoltage threshold is exceeded; also expand currentPhaseAnalogPins array from 4 to 5 elements accordingly - TI: restore SynchronousQuadratureEncoder.hpp include dropped in c9485ae (refactor), fixing EK-TM4C1294XL build failure - scripts/install-caveman.sh: guard Node.js /usr/local extraction with an early root check so non-root users get a clear error --- scripts/install-caveman.sh | 5 +++++ .../host/implementation/PlatformFactoryImpl.cpp | 6 +++--- .../host/implementation/PlatformFactoryImpl.hpp | 4 +++- .../st/implementation/PlatformFactoryImpl.cpp | 6 +++--- .../st/implementation/PlatformFactoryImpl.hpp | 4 +++- .../ti/implementation/PlatformFactoryImpl.hpp | 11 ++++++++--- 6 files changed, 25 insertions(+), 11 deletions(-) diff --git a/scripts/install-caveman.sh b/scripts/install-caveman.sh index 57640843..8d4e8312 100755 --- a/scripts/install-caveman.sh +++ b/scripts/install-caveman.sh @@ -15,6 +15,11 @@ CAVEMAN_COMMIT="63a91ecadbf4c4719a4602a5abb00883f9966034" CAVEMAN_SHA256="8ddef49c15f089c26affed3c31d97142c683e1d37a1499ae557281ca09c2712c" CAVEMAN_URL="https://raw.githubusercontent.com/JuliusBrussee/caveman/${CAVEMAN_COMMIT}/install.sh" +if [ "$(id -u)" -ne 0 ]; then + echo "Error: This script must be run as root (use sudo)." >&2 + exit 1 +fi + # ── Node.js ──────────────────────────────────────────────────────────────── if command -v node >/dev/null 2>&1; then NODE_MAJOR=$(node -p "process.versions.node.split('.')[0]") diff --git a/targets/platform_implementations/host/implementation/PlatformFactoryImpl.cpp b/targets/platform_implementations/host/implementation/PlatformFactoryImpl.cpp index 1baea8be..7e4444e4 100644 --- a/targets/platform_implementations/host/implementation/PlatformFactoryImpl.cpp +++ b/targets/platform_implementations/host/implementation/PlatformFactoryImpl.cpp @@ -24,17 +24,17 @@ namespace application hal::GpioPin& PlatformFactoryImpl::OperationalLed() { - return pin; + return operationalPin; } hal::GpioPin& PlatformFactoryImpl::WarningLed() { - return pin; + return warningPin; } hal::GpioPin& PlatformFactoryImpl::FailureLed() { - return pin; + return failurePin; } uint8_t PlatformFactoryImpl::BoardId() const diff --git a/targets/platform_implementations/host/implementation/PlatformFactoryImpl.hpp b/targets/platform_implementations/host/implementation/PlatformFactoryImpl.hpp index 435a6510..a1de33a8 100644 --- a/targets/platform_implementations/host/implementation/PlatformFactoryImpl.hpp +++ b/targets/platform_implementations/host/implementation/PlatformFactoryImpl.hpp @@ -227,7 +227,9 @@ namespace application private: infra::Function onInitialized; SimpleLowPriorityInterrupt simpleLowPriorityInterrupt; - GpioPinStub pin; + GpioPinStub operationalPin; + GpioPinStub warningPin; + GpioPinStub failurePin; SerialCommunicationStub serial; TerminalAndTracer terminalAndTracer{ serial }; std::optional> phaseCurrentAdc; diff --git a/targets/platform_implementations/st/implementation/PlatformFactoryImpl.cpp b/targets/platform_implementations/st/implementation/PlatformFactoryImpl.cpp index 11ce7e91..98344b68 100644 --- a/targets/platform_implementations/st/implementation/PlatformFactoryImpl.cpp +++ b/targets/platform_implementations/st/implementation/PlatformFactoryImpl.cpp @@ -77,17 +77,17 @@ namespace application hal::GpioPin& PlatformFactoryImpl::OperationalLed() { - return pin; + return operationalPin; } hal::GpioPin& PlatformFactoryImpl::WarningLed() { - return pin; + return warningPin; } hal::GpioPin& PlatformFactoryImpl::FailureLed() { - return pin; + return failurePin; } uint8_t PlatformFactoryImpl::BoardId() const diff --git a/targets/platform_implementations/st/implementation/PlatformFactoryImpl.hpp b/targets/platform_implementations/st/implementation/PlatformFactoryImpl.hpp index 3513ef9a..47d4e998 100644 --- a/targets/platform_implementations/st/implementation/PlatformFactoryImpl.hpp +++ b/targets/platform_implementations/st/implementation/PlatformFactoryImpl.hpp @@ -218,7 +218,9 @@ namespace application infra::Function onInitialized; PendSvLowPriorityInterrupt pendSvLowPriorityInterrupt; static constexpr uint32_t timerId = 1; - GpioPinStub pin; + GpioPinStub operationalPin; + GpioPinStub warningPin; + GpioPinStub failurePin; SerialCommunicationStub serial; TerminalAndTracer terminalAndTracer{ serial }; std::optional> phaseCurrentAdc; diff --git a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp index cbd59724..ac396f1a 100644 --- a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp +++ b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp @@ -11,6 +11,7 @@ #include "hal_tiva/cortex/DataWatchpointAndTrace.hpp" #include "hal_tiva/cortex/SystemTickTimerService.hpp" #include "hal_tiva/synchronous_tiva/SynchronousAdc.hpp" +#include "hal_tiva/synchronous_tiva/SynchronousQuadratureEncoder.hpp" #include "hal_tiva/tiva/Adc.hpp" #include "hal_tiva/tiva/Can.hpp" #include "hal_tiva/tiva/Dma.hpp" @@ -129,17 +130,21 @@ namespace application // Step 3 is redirected to DCMP0 via the SSOP register and does NOT appear in // the FIFO. DCMP0 connects to PWM FLTSRC1 bit 0 and tristates all motor PWM // outputs instantly when the overcurrent threshold is exceeded. - // Overvoltage is detected in software via PowerSupplyVoltage() on ADC1. - static constexpr std::array digitalComparators{ { + // Step 4 is likewise redirected to DCMP1 and tristates all motor PWM outputs + // instantly when the overvoltage threshold is exceeded — same hardware + // mechanism as DCMP0 for overcurrent. + static constexpr std::array digitalComparators{ { {}, // step 0: currentPhaseA → FIFO (noComparator) {}, // step 1: currentPhaseB → FIFO (noComparator) {}, // step 2: currentPhaseC → FIFO (noComparator) { Peripheral::OvercurrentComparatorIndex, 0, Peripheral::overcurrentThresholdCounts, hal::tiva::Adc::ComparatorCondition::highBand, hal::tiva::Adc::ComparatorMode::always }, + { Peripheral::OvervoltageComparatorIndex, 0, Peripheral::overvoltageThresholdCounts, + hal::tiva::Adc::ComparatorCondition::highBand, hal::tiva::Adc::ComparatorMode::always }, } }; hal::tiva::Adc::Config adcConfig{ false, 0, Peripheral::adcTrigger, hal::tiva::Adc::SampleAndHold::sampleAndHold8, std::make_optional(currentSensingOversampling), phaseDelay }; - std::array currentPhaseAnalogPins{ { hal::tiva::AnalogPin{ Pins::currentPhaseA }, hal::tiva::AnalogPin{ Pins::currentPhaseB }, hal::tiva::AnalogPin{ Pins::currentPhaseC }, hal::tiva::AnalogPin{ Pins::currentTotal } } }; + std::array currentPhaseAnalogPins{ { hal::tiva::AnalogPin{ Pins::currentPhaseA }, hal::tiva::AnalogPin{ Pins::currentPhaseB }, hal::tiva::AnalogPin{ Pins::currentPhaseC }, hal::tiva::AnalogPin{ Pins::currentTotal }, hal::tiva::AnalogPin{ Pins::powerSupplyVoltage } } }; }; struct AsyncPwmConfig From e7dfe467e398ff3ffad70c2be43264efba18f91d Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Fri, 10 Jul 2026 19:33:40 +0000 Subject: [PATCH 08/28] fix(platform): revert erroneous DCMP1 overvoltage entry in phase-current ADC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overvoltage is monitored by AdcForPowerSupplyMeasurementImpl (synchronous ADC1, sequencer 0) via Pins::powerSupplyVoltage. Adding that pin as step 4 of the phase-current ADC sequencer with a digital comparator was wrong: - DCMP1 (Peripheral::OvervoltageComparatorIndex) was not needed there - It caused Pins::powerSupplyVoltage to be passed to hal::tiva::AnalogPin twice (also in powerSupplyAnalogPins), configuring the GPIO twice Revert digitalComparators and currentPhaseAnalogPins to 4 entries each (steps 0–2: phase currents to FIFO; step 3: currentTotal → DCMP0 overcurrent). --- .../ti/implementation/PlatformFactoryImpl.hpp | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp index ac396f1a..5a75b8e5 100644 --- a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp +++ b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp @@ -130,21 +130,18 @@ namespace application // Step 3 is redirected to DCMP0 via the SSOP register and does NOT appear in // the FIFO. DCMP0 connects to PWM FLTSRC1 bit 0 and tristates all motor PWM // outputs instantly when the overcurrent threshold is exceeded. - // Step 4 is likewise redirected to DCMP1 and tristates all motor PWM outputs - // instantly when the overvoltage threshold is exceeded — same hardware - // mechanism as DCMP0 for overcurrent. - static constexpr std::array digitalComparators{ { + // Overvoltage is monitored separately by AdcForPowerSupplyMeasurementImpl + // (synchronous ADC1, sequencer 0) — powerSupplyVoltage is not sampled here. + static constexpr std::array digitalComparators{ { {}, // step 0: currentPhaseA → FIFO (noComparator) {}, // step 1: currentPhaseB → FIFO (noComparator) {}, // step 2: currentPhaseC → FIFO (noComparator) { Peripheral::OvercurrentComparatorIndex, 0, Peripheral::overcurrentThresholdCounts, hal::tiva::Adc::ComparatorCondition::highBand, hal::tiva::Adc::ComparatorMode::always }, - { Peripheral::OvervoltageComparatorIndex, 0, Peripheral::overvoltageThresholdCounts, - hal::tiva::Adc::ComparatorCondition::highBand, hal::tiva::Adc::ComparatorMode::always }, } }; hal::tiva::Adc::Config adcConfig{ false, 0, Peripheral::adcTrigger, hal::tiva::Adc::SampleAndHold::sampleAndHold8, std::make_optional(currentSensingOversampling), phaseDelay }; - std::array currentPhaseAnalogPins{ { hal::tiva::AnalogPin{ Pins::currentPhaseA }, hal::tiva::AnalogPin{ Pins::currentPhaseB }, hal::tiva::AnalogPin{ Pins::currentPhaseC }, hal::tiva::AnalogPin{ Pins::currentTotal }, hal::tiva::AnalogPin{ Pins::powerSupplyVoltage } } }; + std::array currentPhaseAnalogPins{ { hal::tiva::AnalogPin{ Pins::currentPhaseA }, hal::tiva::AnalogPin{ Pins::currentPhaseB }, hal::tiva::AnalogPin{ Pins::currentPhaseC }, hal::tiva::AnalogPin{ Pins::currentTotal } } }; }; struct AsyncPwmConfig From f85ae42381f2d3ab19fecdb0f09b323fabf4cd4c Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Fri, 10 Jul 2026 20:45:39 +0000 Subject: [PATCH 09/28] fix(debug): prevent debuginfod stall in cppdbg sessions GDB 17.1 has debuginfod enabled and MIEngine turns it on at debug start. The container's default DEBUGINFOD_URLS points at debuginfod.ubuntu.com, which is unreachable offline, so GDB blocks during shared-library loading and the debug session stalls (GUI never appears, Pause/Stop do nothing). Clear GDB's debuginfod URL list via miDebuggerArgs (-iex "set debuginfod urls") in each cppdbg config, and empty DEBUGINFOD_URLS in the devcontainer containerEnv as the root-cause fix for all container tooling. --- .devcontainer/devcontainer.json | 6 +++++- .vscode/launch.json | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 778f307d..4fc1dba6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -13,7 +13,11 @@ "containerEnv": { "DISPLAY": "host.docker.internal:0.0", "QT_X11_NO_MITSHM": "1", - "LIBGL_ALWAYS_INDIRECT": "1" + "LIBGL_ALWAYS_INDIRECT": "1", + // GDB 17.1 has debuginfod enabled; the container's default DEBUGINFOD_URLS + // points at debuginfod.ubuntu.com, which is unreachable offline and stalls + // debug sessions during shared-library loading. Empty it so debuginfod is a no-op. + "DEBUGINFOD_URLS": "" }, "runArgs": [ "--add-host=host.docker.internal:host-gateway", diff --git a/.vscode/launch.json b/.vscode/launch.json index f65af053..b8c0a2a3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,6 +18,7 @@ "externalConsole": false, "MIMode": "gdb", "miDebuggerPath": "/usr/bin/gdb", + "miDebuggerArgs": "-iex \"set debuginfod urls\"", "setupCommands": [ { "description": "Enable pretty-printing for gdb", @@ -43,6 +44,7 @@ "externalConsole": false, "MIMode": "gdb", "miDebuggerPath": "/usr/bin/gdb", + "miDebuggerArgs": "-iex \"set debuginfod urls\"", "setupCommands": [ { "description": "Enable pretty-printing for gdb", @@ -68,6 +70,7 @@ "externalConsole": false, "MIMode": "gdb", "miDebuggerPath": "/usr/bin/gdb", + "miDebuggerArgs": "-iex \"set debuginfod urls\"", "setupCommands": [ { "description": "Enable pretty-printing for gdb", @@ -93,6 +96,7 @@ "externalConsole": false, "MIMode": "gdb", "miDebuggerPath": "/usr/bin/gdb", + "miDebuggerArgs": "-iex \"set debuginfod urls\"", "setupCommands": [ { "description": "Enable pretty-printing for gdb", @@ -147,6 +151,7 @@ "externalConsole": false, "MIMode": "gdb", "miDebuggerPath": "/usr/bin/gdb", + "miDebuggerArgs": "-iex \"set debuginfod urls\"", "setupCommands": [ { "description": "Enable pretty-printing for gdb", From 7215ec3b6d604015f36f5a4284ae45429388a6f1 Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Fri, 10 Jul 2026 20:45:47 +0000 Subject: [PATCH 10/28] feat(hardware_bridge): list available CAN interfaces and channels Add list_can_interfaces.py to discover CAN adapters via python-can, candle_driver, and serial ports, with table/JSON output. Wire a --list-can (and --json) flag into bridge_server.py, and add unit tests. --- tools/hardware_bridge/server/bridge_server.py | 25 ++ .../server/list_can_interfaces.py | 263 ++++++++++++ .../server/test/test_list_can_interfaces.py | 402 ++++++++++++++++++ 3 files changed, 690 insertions(+) create mode 100644 tools/hardware_bridge/server/list_can_interfaces.py create mode 100644 tools/hardware_bridge/server/test/test_list_can_interfaces.py diff --git a/tools/hardware_bridge/server/bridge_server.py b/tools/hardware_bridge/server/bridge_server.py index 8b18dde6..90f4e383 100644 --- a/tools/hardware_bridge/server/bridge_server.py +++ b/tools/hardware_bridge/server/bridge_server.py @@ -24,6 +24,10 @@ # CAN with CANable (Candle API, no WinUSB driver swap needed, Windows) python bridge_server.py --can-interface candle --can-channel 0 + # List all CAN interfaces/channels detected on this machine + python bridge_server.py --list-can + python bridge_server.py --list-can --json + # Both serial and CAN (SocketCAN on Linux) python bridge_server.py \\ --serial-port /dev/ttyACM0 --serial-baudrate 921600 \\ @@ -82,6 +86,17 @@ def parse_args() -> argparse.Namespace: can_group.add_argument( "--can-tcp-port", type=int, default=5001, help="TCP port for CAN bridge (default: 5001)" ) + can_group.add_argument( + "--list-can", + action="store_true", + help="List all CAN interfaces and channels available on this machine, then exit.", + ) + can_group.add_argument( + "--json", + action="store_true", + dest="output_json", + help="With --list-can: output as JSON array instead of a table.", + ) parser.add_argument( "--log-level", @@ -100,6 +115,16 @@ async def main() -> None: format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) + if getattr(args, "list_can", False): + from list_can_interfaces import gather_all, format_table + import json as _json + configs = gather_all() + if args.output_json: + print(_json.dumps(configs, indent=2)) + else: + print(format_table(configs)) + return + if args.serial_port is None and args.can_interface is None: logger.error("At least one of --serial-port or --can-interface must be specified.") sys.exit(1) diff --git a/tools/hardware_bridge/server/list_can_interfaces.py b/tools/hardware_bridge/server/list_can_interfaces.py new file mode 100644 index 00000000..d73e6de6 --- /dev/null +++ b/tools/hardware_bridge/server/list_can_interfaces.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +""" +list_can_interfaces — discover CAN adapters and channels available on this machine. + +Covers three detection sources: + 1. python-can's built-in detector (socketcan, pcan, gs_usb, slcan, …) + 2. Candle API / candle_driver (candleLight firmware, e.g. CANable 2.0 on Windows) + 3. Serial ports as slcan candidates (any USB-serial device could be a CANable in slcan mode) + +Usage examples: + python list_can_interfaces.py + python list_can_interfaces.py --json + python list_can_interfaces.py --interface socketcan +""" + +from __future__ import annotations + +import argparse +import json +import logging + +logger = logging.getLogger(__name__) + +# python-can interface backends that support detect_available_configs(). +# Restrict the probe list so backends that hang or raise on import-only machines +# are not attempted unless explicitly requested. +_PROBED_INTERFACES = [ + "socketcan", + "pcan", + "gs_usb", + "slcan", + "kvaser", + "ixxat", + "nican", + "vector", + "virtual", + "udp_multicast", + "neousys", + "etas", + "cantact", + "seeedstudio", + "robotell", + "usb2can", + "iscan", + "nixnet", + "pcan", + "systec", +] + + +def detect_python_can_configs(interfaces: list[str] | None = None) -> list[dict]: + """Return configs detected by python-can's built-in enumerator. + + Parameters + ---------- + interfaces: + If given, only these backend names are probed. If None, the default + set :data:`_PROBED_INTERFACES` is used. + + Returns + ------- + list of dicts with keys ``interface``, ``channel``, ``source``, and any + extra keys returned by the backend. + """ + try: + import can + except ImportError: + logger.warning("python-can is not installed; skipping python-can detection.") + return [] + + probe_list = interfaces if interfaces is not None else _PROBED_INTERFACES + # Deduplicate while preserving order. + probe_list = list(dict.fromkeys(probe_list)) + + results: list[dict] = [] + for iface in probe_list: + try: + configs = can.detect_available_configs(interfaces=[iface]) + except Exception as exc: + logger.debug("detect_available_configs(%s) raised: %s", iface, exc) + continue + for cfg in configs: + entry = { + "interface": cfg.get("interface", iface), + "channel": str(cfg.get("channel", "")), + "source": "python-can", + } + # Carry along any extra metadata the backend reports. + for key, value in cfg.items(): + if key not in ("interface", "channel"): + entry[key] = value + results.append(entry) + + return results + + +def detect_candle_devices() -> list[dict]: + """Return one entry per Candle USB device found via candle_driver. + + The ``channel`` field is the zero-based device index that maps directly to + ``--can-channel`` when using ``--can-interface candle``. + + Returns ``[]`` when the library is unavailable (e.g. on Linux without it). + """ + try: + import candle_driver + except ImportError: + logger.debug("candle_driver not installed; skipping Candle device detection.") + return [] + + try: + devices = candle_driver.list_devices() + except Exception as exc: + logger.warning("candle_driver.list_devices() raised: %s", exc) + return [] + + results: list[dict] = [] + for idx, device in enumerate(devices): + name: str = "" + try: + name = str(device.name()) if callable(getattr(device, "name", None)) else str(device) + except Exception: + name = f"device-{idx}" + results.append( + { + "interface": "candle", + "channel": str(idx), + "details": name, + "source": "candle_driver", + } + ) + return results + + +def detect_slcan_serial_ports() -> list[dict]: + """Return serial ports that could be slcan adapters (e.g. CANable in slcan mode). + + These are *candidate* channels — any USB-serial device is listed. The user + must verify which one is their CAN adapter. + + Returns ``[]`` when pyserial is unavailable. + """ + try: + from serial.tools.list_ports import comports + except ImportError: + logger.debug("pyserial not installed; skipping serial port detection.") + return [] + + results: list[dict] = [] + for port in comports(): + results.append( + { + "interface": "slcan", + "channel": port.device, + "details": port.description or "", + "source": "serial-ports", + } + ) + return results + + +def gather_all(interfaces: list[str] | None = None) -> list[dict]: + """Aggregate all detection sources, deduplicating on (interface, channel). + + Parameters + ---------- + interfaces: + Optional list of python-can backend names to restrict probing. + + Returns + ------- + Deduplicated list of config dicts. + """ + seen: set[tuple[str, str]] = set() + results: list[dict] = [] + + for entry in ( + detect_python_can_configs(interfaces) + + detect_candle_devices() + + detect_slcan_serial_ports() + ): + key = (entry.get("interface", ""), entry.get("channel", "")) + if key in seen: + continue + seen.add(key) + results.append(entry) + + return results + + +def format_table(configs: list[dict]) -> str: + """Render *configs* as a human-readable aligned text table. + + Returns a plain string with a header row, separator, and one data row per + config. Returns a single "none detected" line when the list is empty. + """ + if not configs: + return "No CAN interfaces detected." + + headers = ("Interface", "Channel", "Details", "Source") + rows: list[tuple[str, str, str, str]] = [] + for cfg in configs: + rows.append( + ( + cfg.get("interface", ""), + cfg.get("channel", ""), + cfg.get("details", ""), + cfg.get("source", ""), + ) + ) + + col_widths = [ + max(len(h), max(len(r[i]) for r in rows)) + for i, h in enumerate(headers) + ] + + def _row(cells: tuple[str, ...]) -> str: + return " ".join(cell.ljust(col_widths[i]) for i, cell in enumerate(cells)).rstrip() + + sep = " ".join("-" * w for w in col_widths) + lines = [_row(headers), sep] + [_row(r) for r in rows] + return "\n".join(lines) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="List CAN interfaces and channels available on this machine." + ) + parser.add_argument( + "--interface", + metavar="IFACE", + help="Probe only this python-can backend (e.g. socketcan, pcan, gs_usb).", + ) + parser.add_argument( + "--json", + action="store_true", + dest="output_json", + help="Output as JSON array instead of a table.", + ) + parser.add_argument( + "--log-level", + default="WARNING", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + help="Logging level (default: WARNING)", + ) + args = parser.parse_args() + + logging.basicConfig( + level=getattr(logging, args.log_level), + format="%(levelname)s: %(message)s", + ) + + interfaces = [args.interface] if args.interface else None + configs = gather_all(interfaces) + + if args.output_json: + print(json.dumps(configs, indent=2)) + else: + print(format_table(configs)) + + +if __name__ == "__main__": + main() diff --git a/tools/hardware_bridge/server/test/test_list_can_interfaces.py b/tools/hardware_bridge/server/test/test_list_can_interfaces.py new file mode 100644 index 00000000..d39cc3a8 --- /dev/null +++ b/tools/hardware_bridge/server/test/test_list_can_interfaces.py @@ -0,0 +1,402 @@ +"""Tests for list_can_interfaces — all external dependencies are stubbed per test class.""" + +import json +import pathlib +import sys +import types +import unittest +from unittest import mock + + +SERVER_DIR = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SERVER_DIR)) + +# --------------------------------------------------------------------------- +# Minimal module-level stubs are required only to allow imports of modules +# that transitively import can / candle_driver / serial at load time. +# list_can_interfaces.py imports those lazily (inside functions), so these +# stubs are just a safety net and are NOT relied on by the test assertions. +# --------------------------------------------------------------------------- +if "can" not in sys.modules: + _stub_can_bus = types.ModuleType("can.bus") + _stub_can = types.ModuleType("can") + _stub_can.BusABC = object + _stub_can.detect_available_configs = mock.Mock(return_value=[]) + _stub_can.bus = _stub_can_bus + sys.modules["can"] = _stub_can + sys.modules["can.bus"] = _stub_can_bus + +if "candle_driver" not in sys.modules: + _stub_candle = types.ModuleType("candle_driver") + _stub_candle.CANDLE_ID_EXTENDED = 0x80000000 + _stub_candle.list_devices = mock.Mock(return_value=[]) + sys.modules["candle_driver"] = _stub_candle + +if "serial" not in sys.modules: + _stub_serial_lp = types.ModuleType("serial.tools.list_ports") + _stub_serial_lp.comports = mock.Mock(return_value=[]) + _stub_serial_tools = types.ModuleType("serial.tools") + _stub_serial_tools.list_ports = _stub_serial_lp + _stub_serial = types.ModuleType("serial") + _stub_serial.tools = _stub_serial_tools + sys.modules["serial"] = _stub_serial + sys.modules["serial.tools"] = _stub_serial_tools + sys.modules["serial.tools.list_ports"] = _stub_serial_lp + +import list_can_interfaces + + +# --------------------------------------------------------------------------- +# Helpers to build isolated stubs for each test class +# --------------------------------------------------------------------------- + +def _make_can_stub(return_value=None): + """Return a fresh (can, can.bus) stub pair with detect_available_configs mocked.""" + stub_bus = types.ModuleType("can.bus") + stub_bus_state = mock.Mock(name="BusState") + stub_bus_state.ACTIVE = mock.sentinel.BUS_STATE_ACTIVE + stub_bus.BusState = stub_bus_state + + stub_can = types.ModuleType("can") + stub_can.BusABC = object + stub_can.Bus = mock.Mock(name="Bus") + stub_can.detect_available_configs = mock.Mock(return_value=return_value or []) + stub_can.bus = stub_bus + return stub_can, stub_bus + + +def _make_candle_stub(devices=None): + stub = types.ModuleType("candle_driver") + stub.CANDLE_ID_EXTENDED = 0x80000000 + stub.list_devices = mock.Mock(return_value=devices or []) + return stub + + +def _make_serial_stub(ports=None): + stub_lp = types.ModuleType("serial.tools.list_ports") + stub_lp.comports = mock.Mock(return_value=ports or []) + stub_tools = types.ModuleType("serial.tools") + stub_tools.list_ports = stub_lp + stub_serial = types.ModuleType("serial") + stub_serial.tools = stub_tools + return stub_serial, stub_tools, stub_lp + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestDetectPythonCanConfigs(unittest.TestCase): + def setUp(self): + self._can, self._can_bus = _make_can_stub() + self._patcher = mock.patch.dict( + sys.modules, {"can": self._can, "can.bus": self._can_bus} + ) + self._patcher.start() + + def tearDown(self): + self._patcher.stop() + + def test_returns_normalized_entries_for_found_configs(self): + self._can.detect_available_configs.return_value = [ + {"interface": "socketcan", "channel": "can0"}, + {"interface": "socketcan", "channel": "can1"}, + ] + + result = list_can_interfaces.detect_python_can_configs(interfaces=["socketcan"]) + + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["interface"], "socketcan") + self.assertEqual(result[0]["channel"], "can0") + self.assertEqual(result[0]["source"], "python-can") + self.assertEqual(result[1]["channel"], "can1") + + def test_propagates_extra_backend_metadata(self): + self._can.detect_available_configs.return_value = [ + {"interface": "pcan", "channel": "PCAN_USBBUS1", "supports_fd": True}, + ] + + result = list_can_interfaces.detect_python_can_configs(interfaces=["pcan"]) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["supports_fd"], True) + + def test_returns_empty_list_when_no_configs_found(self): + self._can.detect_available_configs.return_value = [] + + result = list_can_interfaces.detect_python_can_configs(interfaces=["socketcan"]) + + self.assertEqual(result, []) + + def test_skips_backend_on_exception_and_continues(self): + def side_effect(interfaces): + if "pcan" in interfaces: + raise RuntimeError("PCAN driver not found") + return [{"interface": "socketcan", "channel": "can0"}] + + self._can.detect_available_configs.side_effect = side_effect + + result = list_can_interfaces.detect_python_can_configs( + interfaces=["pcan", "socketcan"] + ) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["interface"], "socketcan") + + def test_returns_empty_list_when_can_not_installed(self): + with mock.patch.dict(sys.modules, {"can": None}): + result = list_can_interfaces.detect_python_can_configs(interfaces=["socketcan"]) + + self.assertEqual(result, []) + + +class TestDetectCandleDevices(unittest.TestCase): + def setUp(self): + self._candle = _make_candle_stub() + self._patcher = mock.patch.dict(sys.modules, {"candle_driver": self._candle}) + self._patcher.start() + + def tearDown(self): + self._patcher.stop() + + def test_returns_one_entry_per_device(self): + device_a = mock.Mock() + device_a.name = mock.Mock(return_value="CANable-v2 #0") + device_b = mock.Mock() + device_b.name = mock.Mock(return_value="CANable-v2 #1") + self._candle.list_devices.return_value = [device_a, device_b] + + result = list_can_interfaces.detect_candle_devices() + + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["interface"], "candle") + self.assertEqual(result[0]["channel"], "0") + self.assertEqual(result[0]["source"], "candle_driver") + self.assertEqual(result[1]["channel"], "1") + + def test_returns_empty_list_when_no_devices(self): + self._candle.list_devices.return_value = [] + + result = list_can_interfaces.detect_candle_devices() + + self.assertEqual(result, []) + + def test_handles_list_devices_exception_gracefully(self): + self._candle.list_devices.side_effect = OSError("USB error") + + result = list_can_interfaces.detect_candle_devices() + + self.assertEqual(result, []) + + def test_returns_empty_when_candle_driver_not_installed(self): + with mock.patch.dict(sys.modules, {"candle_driver": None}): + result = list_can_interfaces.detect_candle_devices() + + self.assertEqual(result, []) + + +class TestDetectSlcanSerialPorts(unittest.TestCase): + def setUp(self): + self._serial, self._serial_tools, self._list_ports = _make_serial_stub() + self._patcher = mock.patch.dict( + sys.modules, + { + "serial": self._serial, + "serial.tools": self._serial_tools, + "serial.tools.list_ports": self._list_ports, + }, + ) + self._patcher.start() + + def tearDown(self): + self._patcher.stop() + + def test_returns_one_entry_per_serial_port(self): + port_a = mock.Mock() + port_a.device = "/dev/ttyACM0" + port_a.description = "CANable USB to CAN adapter" + port_b = mock.Mock() + port_b.device = "/dev/ttyACM1" + port_b.description = "USB Serial" + self._list_ports.comports.return_value = [port_a, port_b] + + result = list_can_interfaces.detect_slcan_serial_ports() + + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["interface"], "slcan") + self.assertEqual(result[0]["channel"], "/dev/ttyACM0") + self.assertEqual(result[0]["details"], "CANable USB to CAN adapter") + self.assertEqual(result[0]["source"], "serial-ports") + self.assertEqual(result[1]["channel"], "/dev/ttyACM1") + + def test_returns_empty_list_when_no_ports(self): + self._list_ports.comports.return_value = [] + + result = list_can_interfaces.detect_slcan_serial_ports() + + self.assertEqual(result, []) + + def test_returns_empty_when_pyserial_not_installed(self): + with mock.patch.dict(sys.modules, {"serial.tools.list_ports": None}): + result = list_can_interfaces.detect_slcan_serial_ports() + + self.assertEqual(result, []) + + +class TestGatherAll(unittest.TestCase): + def test_aggregates_all_three_sources(self): + with ( + mock.patch.object( + list_can_interfaces, + "detect_python_can_configs", + return_value=[{"interface": "socketcan", "channel": "can0", "source": "python-can"}], + ), + mock.patch.object( + list_can_interfaces, + "detect_candle_devices", + return_value=[{"interface": "candle", "channel": "0", "source": "candle_driver"}], + ), + mock.patch.object( + list_can_interfaces, + "detect_slcan_serial_ports", + return_value=[{"interface": "slcan", "channel": "/dev/ttyACM0", "source": "serial-ports"}], + ), + ): + result = list_can_interfaces.gather_all() + + self.assertEqual(len(result), 3) + interfaces = {r["interface"] for r in result} + self.assertEqual(interfaces, {"socketcan", "candle", "slcan"}) + + def test_deduplicates_on_interface_and_channel(self): + duplicate_entry = {"interface": "socketcan", "channel": "can0", "source": "python-can"} + with ( + mock.patch.object( + list_can_interfaces, + "detect_python_can_configs", + return_value=[duplicate_entry, duplicate_entry], + ), + mock.patch.object(list_can_interfaces, "detect_candle_devices", return_value=[]), + mock.patch.object(list_can_interfaces, "detect_slcan_serial_ports", return_value=[]), + ): + result = list_can_interfaces.gather_all() + + self.assertEqual(len(result), 1) + + def test_passes_interfaces_filter_to_python_can(self): + with ( + mock.patch.object( + list_can_interfaces, + "detect_python_can_configs", + return_value=[], + ) as mock_detect, + mock.patch.object(list_can_interfaces, "detect_candle_devices", return_value=[]), + mock.patch.object(list_can_interfaces, "detect_slcan_serial_ports", return_value=[]), + ): + list_can_interfaces.gather_all(interfaces=["socketcan"]) + + mock_detect.assert_called_once_with(["socketcan"]) + + +class TestFormatTable(unittest.TestCase): + def test_returns_no_detected_message_for_empty_list(self): + result = list_can_interfaces.format_table([]) + + self.assertIn("No CAN interfaces detected", result) + + def test_table_contains_header_and_separator(self): + configs = [{"interface": "socketcan", "channel": "can0", "source": "python-can"}] + + result = list_can_interfaces.format_table(configs) + + lines = result.splitlines() + self.assertIn("Interface", lines[0]) + self.assertIn("Channel", lines[0]) + self.assertIn("Source", lines[0]) + self.assertRegex(lines[1], r"^-+") + + def test_table_contains_config_data(self): + configs = [ + {"interface": "socketcan", "channel": "can0", "source": "python-can"}, + {"interface": "candle", "channel": "0", "details": "CANable", "source": "candle_driver"}, + ] + + result = list_can_interfaces.format_table(configs) + + self.assertIn("socketcan", result) + self.assertIn("can0", result) + self.assertIn("candle", result) + self.assertIn("CANable", result) + + def test_columns_are_aligned(self): + configs = [ + {"interface": "socketcan", "channel": "can0", "source": "python-can"}, + {"interface": "gs_usb", "channel": "0", "source": "python-can"}, + ] + + result = list_can_interfaces.format_table(configs) + + lines = result.splitlines() + header_channel_pos = lines[0].index("Channel") + data_row_pos = lines[2].index(configs[0]["channel"]) + self.assertEqual(header_channel_pos, data_row_pos) + + +class TestBridgeServerListCan(unittest.IsolatedAsyncioTestCase): + """Verify --list-can causes bridge_server to print & exit without starting servers.""" + + async def test_list_can_prints_table_and_returns(self): + import bridge_server as bs + + fake_configs = [{"interface": "socketcan", "channel": "can0", "source": "python-can"}] + fake_table = "Interface Channel Details Source\n-----\nsocketcan can0 python-can" + + with ( + mock.patch("list_can_interfaces.gather_all", return_value=fake_configs), + mock.patch("list_can_interfaces.format_table", return_value=fake_table), + mock.patch("sys.argv", ["bridge_server.py", "--list-can"]), + mock.patch("builtins.print") as mock_print, + ): + await bs.main() + + mock_print.assert_called_once_with(fake_table) + + async def test_list_can_json_outputs_json(self): + import bridge_server as bs + + fake_configs = [{"interface": "socketcan", "channel": "can0", "source": "python-can"}] + + with ( + mock.patch("list_can_interfaces.gather_all", return_value=fake_configs), + mock.patch("sys.argv", ["bridge_server.py", "--list-can", "--json"]), + mock.patch("builtins.print") as mock_print, + ): + await bs.main() + + printed = mock_print.call_args[0][0] + parsed = json.loads(printed) + self.assertEqual(parsed, fake_configs) + + async def test_list_can_does_not_require_serial_or_can_interface(self): + """--list-can must bypass the 'specify at least one interface' guard.""" + import bridge_server as bs + + with ( + mock.patch("list_can_interfaces.gather_all", return_value=[]), + mock.patch( + "list_can_interfaces.format_table", + return_value="No CAN interfaces detected.", + ), + mock.patch("sys.argv", ["bridge_server.py", "--list-can"]), + mock.patch("builtins.print"), + mock.patch.object(bs, "CanBusOverTcpServer") as mock_can_srv, + mock.patch.object(bs, "SerialOverTcpServer") as mock_serial_srv, + ): + await bs.main() + + mock_can_srv.assert_not_called() + mock_serial_srv.assert_not_called() + + +if __name__ == "__main__": + unittest.main() From b94dff3c03030707a897330f8343a5a417f13f7e Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Fri, 10 Jul 2026 21:04:13 +0000 Subject: [PATCH 11/28] fix(cycle-analysis): update PWM/encoder patterns after PlatformAdapter removal The board-identity refactor removed PlatformAdapter and switched to the async hal::tiva::Pwm driver, so the cycle-analysis PWM Output stage matched no functions and the analysis failed. Update the PWM Output patterns to PlatformFactoryImpl::ThreePhasePwmOutput and hal::tiva::Pwm::{Start,SetComparator,Sync}, and the Encoder Read pattern to PlatformFactoryImpl::Read. Verified all required stages match against the RelWithDebInfo ELF. --- targets/sync_foc_sensored/main/cycle-analysis.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/targets/sync_foc_sensored/main/cycle-analysis.json b/targets/sync_foc_sensored/main/cycle-analysis.json index f0d0f60d..de053b37 100644 --- a/targets/sync_foc_sensored/main/cycle-analysis.json +++ b/targets/sync_foc_sensored/main/cycle-analysis.json @@ -21,7 +21,7 @@ { "label": "Encoder Read", "patterns": [ - "PlatformAdapter::Read", + "PlatformFactoryImpl::Read", "QuadratureEncoderDecorator.*::Read", "QuadratureEncoder::Position", "QuadratureEncoder::Resolution" @@ -84,10 +84,10 @@ { "label": "PWM Output", "patterns": [ - "PlatformAdapter::ThreePhasePwmOutput", - "SynchronousPwm::Start.*Percent.*Percent.*Percent", - "SynchronousPwm::SetComparator", - "SynchronousPwm::Sync" + "PlatformFactoryImpl::ThreePhasePwmOutput", + "hal::tiva::Pwm::Start\\(infra::Quantity >, infra::StaticRationalBase<0ull, 1ull> >, unsigned char>, infra::Quantity >, infra::StaticRationalBase<0ull, 1ull> >, unsigned char>, infra::Quantity >, infra::StaticRationalBase<0ull, 1ull> >, unsigned char>\\)", + "hal::tiva::Pwm::SetComparator", + "hal::tiva::Pwm::Sync" ] }, { From f759ef4cc3a79c7f5343e7d21e16bd6b3451ffeb Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Fri, 10 Jul 2026 21:09:51 +0000 Subject: [PATCH 12/28] refactor(cycle-analysis): simplify PWM Start pattern with wildcards Replace the verbose fully-qualified hal::Percent (infra::Quantity<...>) triple signature with a readable Quantity-based wildcard that matches the 3-phase Start overload using only basic regex (engine-agnostic). --- targets/sync_foc_sensored/main/cycle-analysis.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/targets/sync_foc_sensored/main/cycle-analysis.json b/targets/sync_foc_sensored/main/cycle-analysis.json index de053b37..6892e5c5 100644 --- a/targets/sync_foc_sensored/main/cycle-analysis.json +++ b/targets/sync_foc_sensored/main/cycle-analysis.json @@ -85,7 +85,7 @@ "label": "PWM Output", "patterns": [ "PlatformFactoryImpl::ThreePhasePwmOutput", - "hal::tiva::Pwm::Start\\(infra::Quantity >, infra::StaticRationalBase<0ull, 1ull> >, unsigned char>, infra::Quantity >, infra::StaticRationalBase<0ull, 1ull> >, unsigned char>, infra::Quantity >, infra::StaticRationalBase<0ull, 1ull> >, unsigned char>\\)", + "hal::tiva::Pwm::Start\\(.*Quantity.*Quantity.*Quantity.*\\)", "hal::tiva::Pwm::SetComparator", "hal::tiva::Pwm::Sync" ] From 1d5bd15af9aaa3b67838867c833341181fbac2e2 Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Sat, 11 Jul 2026 20:41:00 +0000 Subject: [PATCH 13/28] feat(electrical-ident): rewrite R/L estimation with multi-point fit and integral method Replace the single-point V/I resistance and 63.2%-threshold inductance estimation, which baked dead-time, switch-drop and current-sensor offsets into the result and quantised tau to one sample. - Resistance: multi-point differential fit (V_j = R*I_ss_j + V_err) via numerical LinearRegression; slope is offset-immune, intercept exports the inverter voltage error as diagnostic data. - Inductance: integral method over the probe transient (offset cancels in the integrand, sub-sample resolution). - Auto-scale excitation levels to target current fractions of the drive maximum using a coarse pre-probe, with a fit-quality residual gate. - Apply the Delta winding correction (1.5x) to both R and L. - Return a single ResistanceInductanceResult struct (R, L, V_err, quality) instead of two optionals; propagate the new callback through the state machine, SIL fixtures, simulator wiring and mocks. - Remove the redundant TerminalElectricalParametersIdentification helper. - Update the theory and integration-testing documentation to match. --- .../electrical_system_ident/CMakeLists.txt | 3 +- .../ElectricalParametersIdentification.hpp | 16 +- ...ElectricalParametersIdentificationImpl.cpp | 217 ++++++--- ...ElectricalParametersIdentificationImpl.hpp | 45 +- ...inalElectricalParametersIdentification.cpp | 78 --- ...inalElectricalParametersIdentification.hpp | 24 - .../test/CMakeLists.txt | 1 - ...TestElectricalParametersIdentification.cpp | 458 +++++++----------- ...inalElectricalParametersIdentification.cpp | 236 --------- ...ElectricalParametersIdentificationMock.hpp | 2 +- core/state_machine/FocStateMachineCommon.cpp | 10 +- .../test/TestControlModeStateMachine.cpp | 6 +- .../test/TestFocStateMachinePosition.cpp | 74 ++- .../test/TestFocStateMachineSpeed.cpp | 64 +-- .../test/TestFocStateMachineTorque.cpp | 54 +-- documentation/design/integration-testing.md | 2 +- .../resistance-inductance-estimation.md | 253 +++++----- .../support/FocIntegrationFixture.cpp | 2 +- .../support/FocIntegrationFixture.hpp | 2 +- .../support/PositionIntegrationFixture.cpp | 2 +- .../support/PositionIntegrationFixture.hpp | 2 +- .../support/SpeedIntegrationFixture.cpp | 2 +- .../support/SpeedIntegrationFixture.hpp | 2 +- tools/simulator/app/CalibrationsWiring.hpp | 4 +- 24 files changed, 594 insertions(+), 965 deletions(-) delete mode 100644 core/services/electrical_system_ident/TerminalElectricalParametersIdentification.cpp delete mode 100644 core/services/electrical_system_ident/TerminalElectricalParametersIdentification.hpp delete mode 100644 core/services/electrical_system_ident/test/TestTerminalElectricalParametersIdentification.cpp diff --git a/core/services/electrical_system_ident/CMakeLists.txt b/core/services/electrical_system_ident/CMakeLists.txt index bd1eba5b..61eaa1e3 100644 --- a/core/services/electrical_system_ident/CMakeLists.txt +++ b/core/services/electrical_system_ident/CMakeLists.txt @@ -11,6 +11,7 @@ target_link_libraries(e_foc.services.electrical_system_ident PUBLIC e_foc.foc.interfaces e_foc.foc.implementations numerical.estimators.online + numerical.estimators.offline ) target_sources(e_foc.services.electrical_system_ident PRIVATE @@ -19,8 +20,6 @@ target_sources(e_foc.services.electrical_system_ident PRIVATE ElectricalParametersIdentification.hpp RealTimeResistanceAndInductanceEstimator.cpp RealTimeResistanceAndInductanceEstimator.hpp - TerminalElectricalParametersIdentification.cpp - TerminalElectricalParametersIdentification.hpp ) add_subdirectory(test) diff --git a/core/services/electrical_system_ident/ElectricalParametersIdentification.hpp b/core/services/electrical_system_ident/ElectricalParametersIdentification.hpp index 36477ef4..d36b099e 100644 --- a/core/services/electrical_system_ident/ElectricalParametersIdentification.hpp +++ b/core/services/electrical_system_ident/ElectricalParametersIdentification.hpp @@ -4,6 +4,7 @@ #include "infra/timer/Timer.hpp" #include "infra/util/Function.hpp" #include "core/foc/interfaces/Units.hpp" +#include #include #include @@ -20,11 +21,20 @@ namespace services public: struct ResistanceAndInductanceConfig { - hal::Percent testVoltagePercent{ 15 }; - infra::Duration settleTime{ std::chrono::seconds{ 2 } }; + std::array targetCurrentFractions{ 0.3f, 0.5f, 0.7f }; + hal::Percent probeVoltagePercent{ 5 }; + infra::Duration settlePerLevel{ std::chrono::milliseconds{ 300 } }; WindingConfiguration windingConfig{ WindingConfiguration::Wye }; }; + struct ResistanceInductanceResult + { + foc::Ohm resistance; + foc::MilliHenry inductance; + foc::Volts inverterVoltageOffset; + float fitQuality; + }; + struct PolePairsConfig { hal::Percent testVoltagePercent{ 10 }; @@ -32,7 +42,7 @@ namespace services infra::Duration settleTimeBetweenSteps{ std::chrono::milliseconds{ 50 } }; }; - virtual void EstimateResistanceAndInductance(const ResistanceAndInductanceConfig& config, const infra::Function, std::optional)>& onDone) = 0; + virtual void EstimateResistanceAndInductance(const ResistanceAndInductanceConfig& config, const infra::Function)>& onDone) = 0; virtual void EstimateNumberOfPolePairs(const PolePairsConfig& config, const infra::Function)>& onDone) = 0; }; } diff --git a/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.cpp b/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.cpp index a5f9fa00..3134c763 100644 --- a/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.cpp +++ b/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.cpp @@ -1,9 +1,9 @@ #include "core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp" #include "core/foc/interfaces/Driver.hpp" #include "core/foc/interfaces/Units.hpp" +#include "numerical/math/Matrix.hpp" #include #include -#include namespace { @@ -11,13 +11,13 @@ namespace constexpr std::size_t stepsPerRevolution = 12; constexpr auto anglePerStep = twoPi / static_cast(stepsPerRevolution); constexpr float minRotationThreshold = std::numbers::pi_v / 2.0f; - constexpr float timeConstantThreshold = 0.632f; + constexpr float minSteadyStateCurrent = 0.001f; + constexpr float safeMinDutyPercent = 5.0f; + constexpr float safeMaxDutyPercent = 80.0f; const hal::Hertz samplingFrequency{ 10000 }; const auto samplingPeriod = 1.0f / static_cast(samplingFrequency.Value()); - ; - foc::PhasePwmDutyCycles - NormalizedDutyCycles(foc::ThreePhase voltages) + foc::PhasePwmDutyCycles NormalizedDutyCycles(foc::ThreePhase voltages) { auto offset = 50.0f; auto dutyA = static_cast(std::clamp(offset + voltages.a * 50.0f, 0.0f, 100.0f)); @@ -26,53 +26,29 @@ namespace return foc::PhasePwmDutyCycles{ hal::Percent{ dutyA }, hal::Percent{ dutyB }, hal::Percent{ dutyC } }; } - float AverageAndRemoveFront(infra::BoundedDeque& deque) + float MeanMagnitude(const infra::BoundedVector& samples) { float sum = 0.0f; - - for (const auto& samples : deque) - sum += samples; - - float average = sum / static_cast(deque.size()); - - deque.pop_front(); - - return average; - } - - float GetSteadyStateCurrent(const infra::BoundedVector& samples) - { - auto lastQuarter = static_cast(static_cast(samples.size()) * 0.9f); - - return std::accumulate(samples.begin() + lastQuarter, samples.end(), 0.0f) / static_cast(samples.size() - lastQuarter); - } - - std::optional GetTauFromCurrentSamples(const infra::BoundedVector& samples, float steadyStateCurrent, std::size_t averageFilter) - { - auto targetCurrent = timeConstantThreshold * steadyStateCurrent; - - for (std::size_t i = 0; i < samples.size(); ++i) - { - if (samples[i] >= targetCurrent) - { - if (i >= averageFilter) - return static_cast(i - averageFilter); - else - return static_cast(i); - } - } - - return std::nullopt; + for (const auto& v : samples) + sum += std::abs(v); + return sum / static_cast(samples.size()); } - std::optional CalculateResistance(float voltage, float current) + float SteadyStateMagnitude(const infra::BoundedVector& transient) { - return foc::Ohm{ voltage / current }; + const auto start = static_cast(static_cast(transient.size()) * 0.9f); + float sum = 0.0f; + for (std::size_t i = start; i < transient.size(); ++i) + sum += transient[i]; + return sum / static_cast(transient.size() - start); } - std::optional CalculateInductance(foc::Ohm resistance, float tau) + float IntegralInductance(const infra::BoundedVector& transient, float steadyState, float resistance) { - return foc::MilliHenry{ resistance.Value() * tau * samplingPeriod * 1000.0f }; + float integral = 0.0f; + for (const auto& v : transient) + integral += (steadyState - v) * samplingPeriod; + return resistance * integral / steadyState; } } @@ -85,59 +61,152 @@ namespace services { } - void ElectricalParametersIdentificationImpl::EstimateResistanceAndInductance(const ResistanceAndInductanceConfig& config, const infra::Function, std::optional)>& onDone) + void ElectricalParametersIdentificationImpl::EstimateResistanceAndInductance(const ResistanceAndInductanceConfig& config, const infra::Function)>& onDone) { - resistanceAndInductanceConfig = config; + rlConfig = config; onResistanceAndInductanceDone = onDone; - currentSamples.clear(); - filteredCurrentSample.clear(); + probeBuffer.clear(); + StartProbeStep(); + } + + void ElectricalParametersIdentificationImpl::StartProbeStep() + { driver.PhaseCurrentsReady(samplingFrequency, [](auto) {}); driver.ThreePhasePwmOutput(foc::PhasePwmDutyCycles{ + hal::Percent{ rlConfig.probeVoltagePercent.Value() }, hal::Percent{ neutralDuty }, + hal::Percent{ neutralDuty } }); + + driver.PhaseCurrentsReady(samplingFrequency, [this](auto currentPhases) + { + probeBuffer.push_back(std::abs(currentPhases.a.Value())); + if (probeBuffer.full()) + OnProbeBufferFull(); + }); + } + + void ElectricalParametersIdentificationImpl::OnProbeBufferFull() + { + const float probeCurrent = SteadyStateMagnitude(probeBuffer); + if (probeCurrent < minSteadyStateCurrent) + { + driver.Stop(); + onResistanceAndInductanceDone(std::nullopt); + return; + } + + const float probeVoltage = static_cast(rlConfig.probeVoltagePercent.Value()) / 100.0f * vdc.Value(); + rCoarse = probeVoltage / probeCurrent; + + StartLevel(0); + } + + void ElectricalParametersIdentificationImpl::StartLevel(std::size_t level) + { + levelBatch.clear(); + + const float targetCurrent = rlConfig.targetCurrentFractions[level] * driver.MaxCurrentSupported().Value(); + const float rawDuty = (targetCurrent * rCoarse / vdc.Value()) * 100.0f + static_cast(neutralDuty); + const auto duty = static_cast(std::clamp(rawDuty, safeMinDutyPercent, safeMaxDutyPercent)); + + levelVoltages[level] = (static_cast(duty) - static_cast(neutralDuty)) / 100.0f * vdc.Value(); + + driver.PhaseCurrentsReady(samplingFrequency, [](auto) {}); + driver.ThreePhasePwmOutput(foc::PhasePwmDutyCycles{ + hal::Percent{ duty }, hal::Percent{ neutralDuty }, hal::Percent{ neutralDuty } }); - settleTimer.Start(resistanceAndInductanceConfig.settleTime, [this]() + settleTimer.Start(rlConfig.settlePerLevel, [this, level]() { - driver.PhaseCurrentsReady(samplingFrequency, [this](auto currentPhases) + driver.PhaseCurrentsReady(samplingFrequency, [this, level](auto currentPhases) { - currentSamples.push_back(currentPhases.a.Value()); + levelBatch.push_back(std::abs(currentPhases.a.Value())); + if (levelBatch.full()) + OnLevelBatchFull(level); + }); + }); + } - if (currentSamples.full()) - filteredCurrentSample.push_back(AverageAndRemoveFront(currentSamples)); + void ElectricalParametersIdentificationImpl::OnLevelBatchFull(std::size_t level) + { + levelSteadyStateCurrents[level] = MeanMagnitude(levelBatch); - if (filteredCurrentSample.full()) - AnalyzeInductanceMeasures(); - }); + if (level + 1 < numLevels) + StartLevel(level + 1); + else + { + driver.Stop(); + ComputeAndReport(); + } + } - driver.ThreePhasePwmOutput(foc::PhasePwmDutyCycles{ - hal::Percent{ resistanceAndInductanceConfig.testVoltagePercent.Value() }, - hal::Percent{ neutralDuty }, - hal::Percent{ neutralDuty } }); - }); + bool ElectricalParametersIdentificationImpl::FitResistance() + { + math::Matrix currents; + math::Matrix voltages; + for (std::size_t j = 0; j < numLevels; ++j) + { + if (levelSteadyStateCurrents[j] < minSteadyStateCurrent) + return false; + currents.at(j, 0) = levelSteadyStateCurrents[j]; + voltages.at(j, 0) = levelVoltages[j]; + } + + estimators::LinearRegression regression; + regression.Fit(currents, voltages); + + fittedVoltageOffset = regression.Coefficients().at(0, 0); + fittedResistance = regression.Coefficients().at(1, 0); + + return fittedResistance > 0.0f; } - void ElectricalParametersIdentificationImpl::AnalyzeInductanceMeasures() + float ElectricalParametersIdentificationImpl::ResistanceFitResidual() const { - driver.Stop(); + float maxResidual = 0.0f; + for (std::size_t j = 0; j < numLevels; ++j) + { + const float predicted = fittedResistance * levelSteadyStateCurrents[j] + fittedVoltageOffset; + maxResidual = std::max(maxResidual, std::abs(levelVoltages[j] - predicted)); + } + return maxResidual / fittedResistance; + } - auto steadyStateCurrent = GetSteadyStateCurrent(filteredCurrentSample); + void ElectricalParametersIdentificationImpl::ComputeAndReport() + { + if (!FitResistance()) + { + onResistanceAndInductanceDone(std::nullopt); + return; + } - if (steadyStateCurrent <= 0.0f) - onResistanceAndInductanceDone(std::nullopt, std::nullopt); - else + const float fitQuality = ResistanceFitResidual(); + if (fitQuality > maxAcceptableFitResidual) { - auto tau = GetTauFromCurrentSamples(filteredCurrentSample, steadyStateCurrent, averageFilter); - auto resistance = CalculateResistance(resistanceAndInductanceConfig.testVoltagePercent.Value() * vdc.Value() / 100.0f, steadyStateCurrent); + onResistanceAndInductanceDone(std::nullopt); + return; + } - if (resistance.has_value() && tau.has_value()) - onResistanceAndInductanceDone(resistance, CalculateInductance(resistance.value(), tau.value_or(0.0f))); - else - onResistanceAndInductanceDone(std::nullopt, std::nullopt); + const float steadyState = SteadyStateMagnitude(probeBuffer); + const float inductance = IntegralInductance(probeBuffer, steadyState, fittedResistance); + + const float correction = (rlConfig.windingConfig == WindingConfiguration::Delta) ? deltaCoefficient : 1.0f; + const float resistancePhase = fittedResistance * correction; + const float inductancePhase = inductance * correction; - filteredCurrentSample.clear(); + if (inductancePhase <= 0.0f) + { + onResistanceAndInductanceDone(std::nullopt); + return; } + + onResistanceAndInductanceDone(ResistanceInductanceResult{ + foc::Ohm{ resistancePhase }, + foc::MilliHenry{ inductancePhase * 1000.0f }, + foc::Volts{ fittedVoltageOffset }, + fitQuality }); } void ElectricalParametersIdentificationImpl::EstimateNumberOfPolePairs(const PolePairsConfig& config, const infra::Function)>& onDone) diff --git a/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp b/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp index e9b45fb8..0661571a 100644 --- a/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp +++ b/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp @@ -2,11 +2,13 @@ #include "infra/timer/Timer.hpp" #include "infra/util/AutoResetFunction.hpp" -#include "infra/util/BoundedDeque.hpp" #include "infra/util/BoundedVector.hpp" +#include "numerical/estimators/offline/LinearRegression.hpp" #include "core/foc/implementations/TransformsClarkePark.hpp" #include "core/foc/interfaces/Driver.hpp" #include "core/services/electrical_system_ident/ElectricalParametersIdentification.hpp" +#include +#include namespace services { @@ -16,33 +18,52 @@ namespace services public: ElectricalParametersIdentificationImpl(foc::ThreePhaseInverter& driver, foc::Encoder& encoder, foc::Volts vdc); - void EstimateResistanceAndInductance(const ResistanceAndInductanceConfig& config, const infra::Function, std::optional)>& onDone) override; + void EstimateResistanceAndInductance(const ResistanceAndInductanceConfig& config, const infra::Function)>& onDone) override; void EstimateNumberOfPolePairs(const PolePairsConfig& config, const infra::Function)>& onDone) override; private: - void AnalyzeInductanceMeasures(); - void CalculatePolePairs(); + void StartProbeStep(); + void OnProbeBufferFull(); + void StartLevel(std::size_t level); + void OnLevelBatchFull(std::size_t level); + bool FitResistance(); + float ResistanceFitResidual() const; + void ComputeAndReport(); void ApplyNextElectricalAngle(); void RunPolePairLogic(); + void CalculatePolePairs(); - constexpr static uint8_t neutralDuty = 1; - constexpr static float deltaCoefficient = 1.5f; - constexpr static std::size_t inductanceSamplesSize = 128; - constexpr static std::size_t averageFilter = 5; + static constexpr uint8_t neutralDuty = 1; + static constexpr float deltaCoefficient = 1.5f; + static constexpr std::size_t numLevels = 3; + static constexpr std::size_t probeBufferSize = 512; + static constexpr std::size_t steadyStateSamples = 32; + static constexpr float maxAcceptableFitResidual = 0.1f; + + static_assert(numLevels == std::tuple_size::value, "numLevels must match the size of ResistanceAndInductanceConfig::targetCurrentFractions"); foc::ThreePhaseInverter& driver; foc::Encoder& encoder; foc::Volts vdc; [[no_unique_address]] foc::ClarkePark transforms; - ResistanceAndInductanceConfig resistanceAndInductanceConfig; + ResistanceAndInductanceConfig rlConfig; PolePairsConfig polePairsConfig; - infra::BoundedDeque::WithMaxSize currentSamples; - infra::BoundedVector::WithMaxSize filteredCurrentSample; + + infra::BoundedVector::WithMaxSize probeBuffer; + infra::BoundedVector::WithMaxSize levelBatch; + + float rCoarse{ 0.0f }; + std::array levelVoltages{}; + std::array levelSteadyStateCurrents{}; + float fittedResistance{ 0.0f }; + float fittedVoltageOffset{ 0.0f }; + std::size_t currentSampleIndex{ 0 }; foc::Radians initialPosition{ 0.0f }; foc::Radians previousPosition{ 0.0f }; float accumulatedRotation{ 0.0f }; - infra::AutoResetFunction, std::optional)> onResistanceAndInductanceDone; + + infra::AutoResetFunction)> onResistanceAndInductanceDone; infra::AutoResetFunction)> onPolePairsDone; infra::TimerSingleShot settleTimer; diff --git a/core/services/electrical_system_ident/TerminalElectricalParametersIdentification.cpp b/core/services/electrical_system_ident/TerminalElectricalParametersIdentification.cpp deleted file mode 100644 index 4151501a..00000000 --- a/core/services/electrical_system_ident/TerminalElectricalParametersIdentification.cpp +++ /dev/null @@ -1,78 +0,0 @@ -#include "core/services/electrical_system_ident/TerminalElectricalParametersIdentification.hpp" -#include "core/services/electrical_system_ident/ElectricalParametersIdentification.hpp" -#include "infra/util/Tokenizer.hpp" - -namespace -{ - std::optional ParseStringInput(const infra::BoundedConstString& input) - { - if (input == "star" || input == "wye") - return services::WindingConfiguration::Wye; - else if (input == "delta") - return services::WindingConfiguration::Delta; - else - return std::nullopt; - } -} - -namespace services -{ - TerminalElectricalParametersIdentification::TerminalElectricalParametersIdentification(services::TerminalWithStorage& terminal, services::Tracer& tracer, ElectricalParametersIdentification& identification) - : terminal(terminal) - , tracer(tracer) - , identification(identification) - { - terminal.AddCommand({ { "estimate_r_and_l", "estrl", "Estimate motor resistance and inductance. estrl . Ex: estrl star" }, - [this](const auto& params) - { - this->terminal.ProcessResult(EstimateResistanceAndInductance(params)); - } }); - - terminal.AddCommand({ { "estimate_pole_pairs", "estpp", "Estimate number of pole pairs. estpp . Ex: estpp 100.0 50.0" }, - [this](const auto& params) - { - this->terminal.ProcessResult(EstimateNumberOfPolePairs(params)); - } }); - } - - TerminalElectricalParametersIdentification::StatusWithMessage TerminalElectricalParametersIdentification::EstimateResistanceAndInductance(const infra::BoundedConstString& param) - { - ElectricalParametersIdentification::ResistanceAndInductanceConfig config; - infra::Tokenizer tokenizer(param, ' '); - - if (tokenizer.Size() != 1) - return { services::TerminalWithStorage::Status::error, "invalid number of arguments" }; - - auto winding = ParseStringInput(tokenizer.Token(0)); - if (!winding.has_value()) - return { services::TerminalWithStorage::Status::error, "invalid value. It should be an 'star', 'wye' or 'delta'." }; - - config.windingConfig = *winding; - - identification.EstimateResistanceAndInductance(config, [this](auto resistance, auto inductance) - { - if (!resistance.has_value()) - tracer.Trace() << "Resistance estimation failed."; - else if (!inductance.has_value()) - tracer.Trace() << "Inductance estimation failed."; - else - tracer.Trace() << "Estimated Resistance: " << resistance->Value() << " Ohms, Inductance: " << inductance->Value() << " mH"; - }); - return TerminalElectricalParametersIdentification::StatusWithMessage(); - } - - TerminalElectricalParametersIdentification::StatusWithMessage TerminalElectricalParametersIdentification::EstimateNumberOfPolePairs(const infra::BoundedConstString&) - { - ElectricalParametersIdentification::PolePairsConfig config; - - identification.EstimateNumberOfPolePairs(config, [this](auto polePairs) - { - if (!polePairs.has_value()) - tracer.Trace() << "Pole pairs estimation failed."; - else - tracer.Trace() << "Estimated Pole Pairs: " << *polePairs; - }); - - return TerminalElectricalParametersIdentification::StatusWithMessage(); - } -} diff --git a/core/services/electrical_system_ident/TerminalElectricalParametersIdentification.hpp b/core/services/electrical_system_ident/TerminalElectricalParametersIdentification.hpp deleted file mode 100644 index 3c47170a..00000000 --- a/core/services/electrical_system_ident/TerminalElectricalParametersIdentification.hpp +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include "services/util/TerminalWithStorage.hpp" -#include "core/services/electrical_system_ident/ElectricalParametersIdentification.hpp" - -namespace services -{ - class TerminalElectricalParametersIdentification - { - public: - TerminalElectricalParametersIdentification(services::TerminalWithStorage& terminal, services::Tracer& tracer, ElectricalParametersIdentification& identification); - - private: - using StatusWithMessage = services::TerminalWithStorage::StatusWithMessage; - - StatusWithMessage EstimateResistanceAndInductance(const infra::BoundedConstString& param); - StatusWithMessage EstimateNumberOfPolePairs(const infra::BoundedConstString& param); - - private: - services::TerminalWithStorage& terminal; - services::Tracer& tracer; - ElectricalParametersIdentification& identification; - }; -} diff --git a/core/services/electrical_system_ident/test/CMakeLists.txt b/core/services/electrical_system_ident/test/CMakeLists.txt index a1731159..58df01bc 100644 --- a/core/services/electrical_system_ident/test/CMakeLists.txt +++ b/core/services/electrical_system_ident/test/CMakeLists.txt @@ -14,6 +14,5 @@ target_link_libraries(e_foc.services.electrical_system_ident_test PUBLIC target_sources(e_foc.services.electrical_system_ident_test PRIVATE TestElectricalParametersIdentification.cpp - TestTerminalElectricalParametersIdentification.cpp TestRealTimeResistanceAndInductanceEstimator.cpp ) diff --git a/core/services/electrical_system_ident/test/TestElectricalParametersIdentification.cpp b/core/services/electrical_system_ident/test/TestElectricalParametersIdentification.cpp index 5f035a65..59a01da7 100644 --- a/core/services/electrical_system_ident/test/TestElectricalParametersIdentification.cpp +++ b/core/services/electrical_system_ident/test/TestElectricalParametersIdentification.cpp @@ -18,9 +18,7 @@ namespace float SimulateRLModelCurrent(float voltage, float resistance, float inductance, float time) { - auto tau = inductance / resistance; - - return (voltage / resistance) * (1.0f - std::exp(-time / tau)); + return (voltage / resistance) * (1.0f - std::exp(-time / (inductance / resistance))); } float MechanicalAngle(std::size_t stepIndex, std::size_t totalSteps, std::size_t expectedPolePairs) @@ -36,261 +34,223 @@ namespace , public infra::ClockFixture { public: - const std::size_t numberOfSamples = 127; + static constexpr float vdcValue = 24.0f; + static constexpr float maxCurrent = 5.0f; + static constexpr float probeVoltage = 5.0f / 100.0f * vdcValue; + static constexpr std::size_t probeBufferSize = 512; + static constexpr std::size_t steadyStateSamples = 32; + static constexpr std::size_t numLevels = 3; + static constexpr float samplingPeriod = 0.0001f; + std::size_t encoderStepIndex = 0; StrictMock driverMock; StrictMock encoderMock; - foc::Volts vdc{ 24.0f }; + foc::Volts vdc{ vdcValue }; services::ElectricalParametersIdentificationImpl identification{ driverMock, encoderMock, vdc }; - }; -} -TEST_F(ElectricalParametersIdentificationTest, estimate_resistance_and_inductance_starts_with_neutral_duty_and_settles) -{ - services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config{ - hal::Percent{ 15 }, - std::chrono::seconds{ 1 }, - services::WindingConfiguration::Wye - }; + void FeedProbeTransient(float resistance, float inductance) + { + for (std::size_t i = 0; i < probeBufferSize; ++i) + { + float t = static_cast(i) * samplingPeriod; + float current = SimulateRLModelCurrent(probeVoltage, resistance, inductance, t); + driverMock.TriggerPhaseCurrentsCallback({ foc::Ampere{ current }, foc::Ampere{ 0.0f }, foc::Ampere{ 0.0f } }); + } + } - EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, ::testing::_)); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(PhasePwmDutyCyclesEq(foc::PhasePwmDutyCycles{ - hal::Percent{ 1 }, - hal::Percent{ 1 }, - hal::Percent{ 1 } }))); + void FeedLevelSteadyState(float iSs) + { + ForwardTime(std::chrono::milliseconds{ 300 }); + for (std::size_t s = 0; s < steadyStateSamples; ++s) + driverMock.TriggerPhaseCurrentsCallback({ foc::Ampere{ iSs }, foc::Ampere{ 0.0f }, foc::Ampere{ 0.0f } }); + } - identification.EstimateResistanceAndInductance(config, [](auto, auto) {}); + float ComputeLevelDuty(float rCoarse, float targetFraction) const + { + constexpr float neutralDuty = 1.0f; + float rawDuty = targetFraction * maxCurrent * rCoarse / vdcValue * 100.0f + neutralDuty; + return std::clamp(rawDuty, 5.0f, 80.0f); + } + + float ComputeLevelVoltage(float duty) const + { + return (duty - 1.0f) / 100.0f * vdcValue; + } + }; } -TEST_F(ElectricalParametersIdentificationTest, estimate_resistance_and_inductance_applies_test_voltage_after_settle_time) +TEST_F(ElectricalParametersIdentificationTest, probe_step_sets_probe_duty_and_starts_collecting_immediately) { - services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config{ - hal::Percent{ 20 }, - std::chrono::milliseconds{ 100 }, - services::WindingConfiguration::Wye - }; + services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config; - EXPECT_CALL(driverMock, PhaseCurrentsReady(::testing::_, ::testing::_)) + EXPECT_CALL(driverMock, MaxCurrentSupported()) + .WillRepeatedly(Return(foc::Ampere{ maxCurrent })); + EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, _)) .Times(2) - .WillRepeatedly([this](auto, const auto& callback) - { - driverMock.StorePhaseCurrentsCallback(callback); - }); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(PhasePwmDutyCyclesEq(foc::PhasePwmDutyCycles{ - hal::Percent{ 1 }, - hal::Percent{ 1 }, - hal::Percent{ 1 } }))); + .WillRepeatedly([this](auto, const auto& cb) { driverMock.StorePhaseCurrentsCallback(cb); }); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(PhasePwmDutyCyclesEq( + foc::PhasePwmDutyCycles{ hal::Percent{ 5 }, hal::Percent{ 1 }, hal::Percent{ 1 } }))); - identification.EstimateResistanceAndInductance(config, [](auto, auto) {}); - - EXPECT_CALL(driverMock, ThreePhasePwmOutput(PhasePwmDutyCyclesEq(foc::PhasePwmDutyCycles{ - hal::Percent{ 20 }, - hal::Percent{ 1 }, - hal::Percent{ 1 } }))); - - ForwardTime(std::chrono::milliseconds{ 100 }); + identification.EstimateResistanceAndInductance(config, [](auto) {}); } -TEST_F(ElectricalParametersIdentificationTest, estimate_resistance_and_inductance_collects_current_samples_and_calculates_parameters) +TEST_F(ElectricalParametersIdentificationTest, estimates_resistance_and_inductance_accurately) { - services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config{ - hal::Percent{ 15 }, - std::chrono::milliseconds{ 50 }, - services::WindingConfiguration::Wye - }; - - float testVoltage = 0.15f * vdc.Value(); - float resistance = 1.5f; - float inductance = 0.002f; - - std::optional resultResistance; - std::optional resultInductance; - - EXPECT_CALL(driverMock, PhaseCurrentsReady(::testing::_, ::testing::_)) - .Times(2) - .WillRepeatedly([this](auto, const auto& callback) - { - driverMock.StorePhaseCurrentsCallback(callback); - }); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(::testing::_)) - .Times(2); - - identification.EstimateResistanceAndInductance(config, [&](auto r, auto l) - { - resultResistance = r; - resultInductance = l; - }); + const float trueR = 1.5f; + const float trueL = 0.002f; + const std::array fractions{ 0.3f, 0.5f, 0.7f }; + + services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config; + std::optional result; + + EXPECT_CALL(driverMock, MaxCurrentSupported()) + .WillRepeatedly(Return(foc::Ampere{ maxCurrent })); + EXPECT_CALL(driverMock, PhaseCurrentsReady(_, _)) + .Times(AnyNumber()) + .WillRepeatedly([this](auto, const auto& cb) { driverMock.StorePhaseCurrentsCallback(cb); }); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(4); + EXPECT_CALL(driverMock, Stop()); - ForwardTime(std::chrono::milliseconds{ 50 }); + identification.EstimateResistanceAndInductance(config, [&](auto r) { result = r; }); - EXPECT_CALL(driverMock, Stop()); + FeedProbeTransient(trueR, trueL); - for (std::size_t i = 0; i < numberOfSamples; ++i) + const float rCoarse = trueR; + for (std::size_t j = 0; j < numLevels; ++j) { - float time = static_cast(i) * 0.0001f; - float current = SimulateRLModelCurrent(testVoltage, resistance, inductance, time); - driverMock.TriggerPhaseCurrentsCallback(foc::PhaseCurrents{ - foc::Ampere{ current }, - foc::Ampere{ 0.0f }, - foc::Ampere{ 0.0f } }); + float duty = ComputeLevelDuty(rCoarse, fractions[j]); + float vj = ComputeLevelVoltage(duty); + FeedLevelSteadyState(vj / trueR); } - ASSERT_TRUE(resultResistance.has_value()); - ASSERT_TRUE(resultInductance.has_value()); - EXPECT_NEAR(resultResistance->Value(), resistance, 0.1f); - EXPECT_NEAR(resultInductance->Value(), inductance * 1000.0f, 1.0f); + ASSERT_TRUE(result.has_value()); + EXPECT_NEAR(result->resistance.Value(), trueR, trueR * 0.05f); + EXPECT_NEAR(result->inductance.Value(), trueL * 1000.0f, trueL * 1000.0f * 0.10f); + EXPECT_NEAR(result->inverterVoltageOffset.Value(), 0.0f, 0.05f); + EXPECT_LT(result->fitQuality, 0.05f); } -TEST_F(ElectricalParametersIdentificationTest, estimate_resistance_and_inductance_returns_nullopt_for_zero_current) +TEST_F(ElectricalParametersIdentificationTest, r_fit_cancels_constant_inverter_voltage_offset) { - services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config{ - hal::Percent{ 10 }, - std::chrono::milliseconds{ 50 }, - services::WindingConfiguration::Wye - }; - - std::optional resultResistance; - std::optional resultInductance; - - EXPECT_CALL(driverMock, PhaseCurrentsReady(::testing::_, ::testing::_)) - .Times(2) - .WillRepeatedly([this](auto, const auto& callback) - { - driverMock.StorePhaseCurrentsCallback(callback); - }); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(::testing::_)) - .Times(2); + const float trueR = 1.5f; + const float trueL = 0.002f; + const float vOffset = 0.3f; + const std::array fractions{ 0.3f, 0.5f, 0.7f }; + + services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config; + std::optional result; + + EXPECT_CALL(driverMock, MaxCurrentSupported()) + .WillRepeatedly(Return(foc::Ampere{ maxCurrent })); + EXPECT_CALL(driverMock, PhaseCurrentsReady(_, _)) + .Times(AnyNumber()) + .WillRepeatedly([this](auto, const auto& cb) { driverMock.StorePhaseCurrentsCallback(cb); }); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(4); + EXPECT_CALL(driverMock, Stop()); - identification.EstimateResistanceAndInductance(config, [&](auto r, auto l) - { - resultResistance = r; - resultInductance = l; - }); + identification.EstimateResistanceAndInductance(config, [&](auto r) { result = r; }); - ForwardTime(std::chrono::milliseconds{ 50 }); + FeedProbeTransient(trueR, trueL); - EXPECT_CALL(driverMock, Stop()); - - for (std::size_t i = 0; i < numberOfSamples; ++i) + const float rCoarse = trueR; + for (std::size_t j = 0; j < numLevels; ++j) { - driverMock.TriggerPhaseCurrentsCallback(foc::PhaseCurrents{ - foc::Ampere{ 0.0f }, - foc::Ampere{ 0.0f }, - foc::Ampere{ 0.0f } }); + float duty = ComputeLevelDuty(rCoarse, fractions[j]); + float vj = ComputeLevelVoltage(duty); + FeedLevelSteadyState((vj - vOffset) / trueR); } - EXPECT_FALSE(resultResistance.has_value()); - EXPECT_FALSE(resultInductance.has_value()); + ASSERT_TRUE(result.has_value()); + EXPECT_NEAR(result->resistance.Value(), trueR, trueR * 0.05f); + EXPECT_NEAR(result->inverterVoltageOffset.Value(), vOffset, 0.1f); } -TEST_F(ElectricalParametersIdentificationTest, estimate_resistance_and_inductance_with_low_resistance_motor) +TEST_F(ElectricalParametersIdentificationTest, applies_delta_winding_correction) { - services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config{ - hal::Percent{ 15 }, - std::chrono::milliseconds{ 50 }, - services::WindingConfiguration::Wye - }; - - float testVoltage = 0.15f * 24.0f; - float resistance = 0.5f; - float inductance = 0.001f; - - std::optional resultResistance; - std::optional resultInductance; - - EXPECT_CALL(driverMock, PhaseCurrentsReady(::testing::_, ::testing::_)) - .Times(2) - .WillRepeatedly([this](auto, const auto& callback) - { - driverMock.StorePhaseCurrentsCallback(callback); - }); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(::testing::_)) - .Times(2); - - identification.EstimateResistanceAndInductance(config, [&](auto r, auto l) - { - resultResistance = r; - resultInductance = l; - }); + const float terminalR = 1.0f; + const float trueL = 0.001f; + const std::array fractions{ 0.3f, 0.5f, 0.7f }; + + services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config; + config.windingConfig = services::WindingConfiguration::Delta; + std::optional result; + + EXPECT_CALL(driverMock, MaxCurrentSupported()) + .WillRepeatedly(Return(foc::Ampere{ maxCurrent })); + EXPECT_CALL(driverMock, PhaseCurrentsReady(_, _)) + .Times(AnyNumber()) + .WillRepeatedly([this](auto, const auto& cb) { driverMock.StorePhaseCurrentsCallback(cb); }); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(4); + EXPECT_CALL(driverMock, Stop()); - ForwardTime(std::chrono::milliseconds{ 50 }); + identification.EstimateResistanceAndInductance(config, [&](auto r) { result = r; }); - EXPECT_CALL(driverMock, Stop()); + FeedProbeTransient(terminalR, trueL); - for (std::size_t i = 0; i < numberOfSamples; ++i) + const float rCoarse = terminalR; + for (std::size_t j = 0; j < numLevels; ++j) { - float time = static_cast(i) * 0.0001f; - float current = SimulateRLModelCurrent(testVoltage, resistance, inductance, time); - driverMock.TriggerPhaseCurrentsCallback(foc::PhaseCurrents{ - foc::Ampere{ current }, - foc::Ampere{ 0.0f }, - foc::Ampere{ 0.0f } }); + float duty = ComputeLevelDuty(rCoarse, fractions[j]); + float vj = ComputeLevelVoltage(duty); + FeedLevelSteadyState(vj / terminalR); } - ASSERT_TRUE(resultResistance.has_value()); - ASSERT_TRUE(resultInductance.has_value()); - EXPECT_NEAR(resultResistance->Value(), resistance, 0.15f); - EXPECT_NEAR(resultInductance->Value(), inductance * 1000.0f, 0.5f); + ASSERT_TRUE(result.has_value()); + EXPECT_NEAR(result->resistance.Value(), terminalR * 1.5f, terminalR * 1.5f * 0.05f); } -TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_initializes_encoder_and_applies_voltages) +TEST_F(ElectricalParametersIdentificationTest, returns_nullopt_when_probe_current_is_zero) { - services::ElectricalParametersIdentification::PolePairsConfig config{ - hal::Percent{ 20 }, - 5, - std::chrono::milliseconds{ 50 } - }; + services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config; + std::optional result; + + EXPECT_CALL(driverMock, MaxCurrentSupported()) + .WillRepeatedly(Return(foc::Ampere{ maxCurrent })); + EXPECT_CALL(driverMock, PhaseCurrentsReady(_, _)) + .Times(AnyNumber()) + .WillRepeatedly([this](auto, const auto& cb) { driverMock.StorePhaseCurrentsCallback(cb); }); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)); + EXPECT_CALL(driverMock, Stop()); - EXPECT_CALL(encoderMock, Read()) - .WillOnce(::testing::Return(foc::Radians{ 0.0f })); + identification.EstimateResistanceAndInductance(config, [&](auto r) { result = r; }); - EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, ::testing::_)); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(::testing::_)); + for (std::size_t i = 0; i < probeBufferSize; ++i) + driverMock.TriggerPhaseCurrentsCallback({ foc::Ampere{ 0.0f }, foc::Ampere{ 0.0f }, foc::Ampere{ 0.0f } }); - identification.EstimateNumberOfPolePairs(config, [](auto) {}); + EXPECT_FALSE(result.has_value()); } -TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_calculates_correct_pole_pairs_for_4_pole_motor) +TEST_F(ElectricalParametersIdentificationTest, returns_nullopt_when_level_current_is_zero) { - services::ElectricalParametersIdentification::PolePairsConfig config{ - hal::Percent{ 20 }, - 5, - std::chrono::milliseconds{ 50 } - }; - - std::optional resultPolePairs; - constexpr std::size_t totalSteps = 5 * 12; - constexpr std::size_t expectedPolePairs = 2; - float voltage = static_cast(config.testVoltagePercent.Value()) * vdc.Value() / 100.0f; - - encoderStepIndex = 0; - EXPECT_CALL(encoderMock, Read()) - .WillOnce(::testing::Return(foc::Radians{ 0.0f })) - .WillRepeatedly([this, totalSteps, expectedPolePairs]() - { - ++encoderStepIndex; - return foc::Radians{ MechanicalAngle(encoderStepIndex, totalSteps, expectedPolePairs) }; - }); - - EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, ::testing::_)); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(::testing::_)) - .Times(totalSteps); + const float trueR = 1.5f; + const float trueL = 0.002f; + const std::array fractions{ 0.3f, 0.5f, 0.7f }; + + services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config; + std::optional result; + + EXPECT_CALL(driverMock, MaxCurrentSupported()) + .WillRepeatedly(Return(foc::Ampere{ maxCurrent })); + EXPECT_CALL(driverMock, PhaseCurrentsReady(_, _)) + .Times(AnyNumber()) + .WillRepeatedly([this](auto, const auto& cb) { driverMock.StorePhaseCurrentsCallback(cb); }); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(AnyNumber()); EXPECT_CALL(driverMock, Stop()); - identification.EstimateNumberOfPolePairs(config, [&](auto result) - { - resultPolePairs = result; - }); + identification.EstimateResistanceAndInductance(config, [&](auto r) { result = r; }); - for (std::size_t i = 0; i < totalSteps; ++i) - ForwardTime(std::chrono::milliseconds{ 50 }); + FeedProbeTransient(trueR, trueL); - ASSERT_TRUE(resultPolePairs.has_value()); - EXPECT_EQ(*resultPolePairs, expectedPolePairs); + const float rCoarse = trueR; + FeedLevelSteadyState(ComputeLevelVoltage(ComputeLevelDuty(rCoarse, fractions[0])) / trueR); + FeedLevelSteadyState(ComputeLevelVoltage(ComputeLevelDuty(rCoarse, fractions[1])) / trueR); + FeedLevelSteadyState(0.0f); + + EXPECT_FALSE(result.has_value()); } -TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_calculates_correct_pole_pairs_for_6_pole_motor) +TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_initializes_encoder_and_applies_voltages) { services::ElectricalParametersIdentification::PolePairsConfig config{ hal::Percent{ 20 }, @@ -298,37 +258,15 @@ TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_cal std::chrono::milliseconds{ 50 } }; - std::optional resultPolePairs; - constexpr std::size_t totalSteps = 5 * 12; - constexpr std::size_t expectedPolePairs = 3; - - encoderStepIndex = 0; EXPECT_CALL(encoderMock, Read()) - .WillOnce(::testing::Return(foc::Radians{ 0.0f })) - .WillRepeatedly([this, totalSteps, expectedPolePairs]() - { - ++encoderStepIndex; - return foc::Radians{ MechanicalAngle(encoderStepIndex, totalSteps, expectedPolePairs) }; - }); - - EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, ::testing::_)); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(::testing::_)) - .Times(totalSteps); - EXPECT_CALL(driverMock, Stop()); - - identification.EstimateNumberOfPolePairs(config, [&](auto result) - { - resultPolePairs = result; - }); + .WillOnce(Return(foc::Radians{ 0.0f })); + EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, _)); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)); - for (std::size_t i = 0; i < totalSteps; ++i) - ForwardTime(std::chrono::milliseconds{ 50 }); - - ASSERT_TRUE(resultPolePairs.has_value()); - EXPECT_EQ(*resultPolePairs, expectedPolePairs); + identification.EstimateNumberOfPolePairs(config, [](auto) {}); } -TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_returns_nullopt_for_insufficient_rotation) +TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_calculates_correct_pole_pairs_for_4_pole_motor) { services::ElectricalParametersIdentification::PolePairsConfig config{ hal::Percent{ 20 }, @@ -338,56 +276,21 @@ TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_ret std::optional resultPolePairs; constexpr std::size_t totalSteps = 5 * 12; - - EXPECT_CALL(encoderMock, Read()) - .WillRepeatedly(::testing::Return(foc::Radians{ 0.0f })); - - EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, ::testing::_)); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(::testing::_)) - .Times(totalSteps); - EXPECT_CALL(driverMock, Stop()); - - identification.EstimateNumberOfPolePairs(config, [&](auto result) - { - resultPolePairs = result; - }); - - for (std::size_t i = 0; i < totalSteps; ++i) - ForwardTime(std::chrono::milliseconds{ 50 }); - - EXPECT_FALSE(resultPolePairs.has_value()); -} - -TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_with_different_electrical_revolutions) -{ - services::ElectricalParametersIdentification::PolePairsConfig config{ - hal::Percent{ 20 }, - 10, - std::chrono::milliseconds{ 50 } - }; - - std::optional resultPolePairs; - constexpr std::size_t totalSteps = 10 * 12; - constexpr std::size_t expectedPolePairs = 4; + constexpr std::size_t expectedPolePairs = 2; encoderStepIndex = 0; EXPECT_CALL(encoderMock, Read()) - .WillOnce(::testing::Return(foc::Radians{ 0.0f })) + .WillOnce(Return(foc::Radians{ 0.0f })) .WillRepeatedly([this, totalSteps, expectedPolePairs]() { ++encoderStepIndex; return foc::Radians{ MechanicalAngle(encoderStepIndex, totalSteps, expectedPolePairs) }; }); - - EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, ::testing::_)); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(::testing::_)) - .Times(totalSteps); + EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, _)); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(totalSteps); EXPECT_CALL(driverMock, Stop()); - identification.EstimateNumberOfPolePairs(config, [&](auto result) - { - resultPolePairs = result; - }); + identification.EstimateNumberOfPolePairs(config, [&](auto result) { resultPolePairs = result; }); for (std::size_t i = 0; i < totalSteps; ++i) ForwardTime(std::chrono::milliseconds{ 50 }); @@ -396,7 +299,7 @@ TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_wit EXPECT_EQ(*resultPolePairs, expectedPolePairs); } -TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_with_8_pole_motor) +TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_calculates_correct_pole_pairs_for_6_pole_motor) { services::ElectricalParametersIdentification::PolePairsConfig config{ hal::Percent{ 20 }, @@ -406,26 +309,21 @@ TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_wit std::optional resultPolePairs; constexpr std::size_t totalSteps = 5 * 12; - constexpr std::size_t expectedPolePairs = 4; + constexpr std::size_t expectedPolePairs = 3; encoderStepIndex = 0; EXPECT_CALL(encoderMock, Read()) - .WillOnce(::testing::Return(foc::Radians{ 0.0f })) + .WillOnce(Return(foc::Radians{ 0.0f })) .WillRepeatedly([this, totalSteps, expectedPolePairs]() { ++encoderStepIndex; return foc::Radians{ MechanicalAngle(encoderStepIndex, totalSteps, expectedPolePairs) }; }); - - EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, ::testing::_)); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(::testing::_)) - .Times(totalSteps); + EXPECT_CALL(driverMock, PhaseCurrentsReady(_, _)); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(totalSteps); EXPECT_CALL(driverMock, Stop()); - identification.EstimateNumberOfPolePairs(config, [&](auto result) - { - resultPolePairs = result; - }); + identification.EstimateNumberOfPolePairs(config, [&](auto result) { resultPolePairs = result; }); for (std::size_t i = 0; i < totalSteps; ++i) ForwardTime(std::chrono::milliseconds{ 50 }); diff --git a/core/services/electrical_system_ident/test/TestTerminalElectricalParametersIdentification.cpp b/core/services/electrical_system_ident/test/TestTerminalElectricalParametersIdentification.cpp deleted file mode 100644 index 37ee4741..00000000 --- a/core/services/electrical_system_ident/test/TestTerminalElectricalParametersIdentification.cpp +++ /dev/null @@ -1,236 +0,0 @@ -#include "core/services/electrical_system_ident/ElectricalParametersIdentification.hpp" -#include "core/services/electrical_system_ident/TerminalElectricalParametersIdentification.hpp" -#include "hal/interfaces/test_doubles/SerialCommunicationMock.hpp" -#include "infra/event/test_helper/EventDispatcherWithWeakPtrFixture.hpp" -#include "infra/stream/test/StreamMock.hpp" -#include "infra/util/ByteRange.hpp" -#include "infra/util/test_helper/MockHelpers.hpp" -#include "services/util/TerminalWithStorage.hpp" -#include "gmock/gmock.h" - -namespace -{ - class ElectricalParametersIdentificationMock - : public services::ElectricalParametersIdentification - { - public: - MOCK_METHOD2(EstimateResistanceAndInductance, void(const ResistanceAndInductanceConfig& config, const infra::Function, std::optional)>& onDone)); - MOCK_METHOD2(EstimateNumberOfPolePairs, void(const PolePairsConfig& config, const infra::Function)>& onDone)); - }; - - class TerminalElectricalParametersIdentificationTest - : public ::testing::Test - , public infra::EventDispatcherWithWeakPtrFixture - { - public: - ::testing::StrictMock identificationMock; - ::testing::StrictMock streamWriterMock; - infra::TextOutputStream::WithErrorPolicy stream{ streamWriterMock }; - services::TracerToStream tracer{ stream }; - ::testing::StrictMock communication; - infra::Execute execute{ [this]() - { - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector{ { '>', ' ' } }), testing::_)); - } }; - services::TerminalWithCommandsImpl::WithMaxQueueAndMaxHistory<128, 5> terminalWithCommands{ communication, tracer }; - services::TerminalWithStorage::WithMaxSize<16> terminalWithStorage{ terminalWithCommands, tracer }; - services::TerminalElectricalParametersIdentification terminalIdentification{ terminalWithStorage, tracer, identificationMock }; - - void InvokeCommand(const std::string& command, const infra::Function& onCommandReceived) - { - ::testing::InSequence _; - - for (const auto& data : command) - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector{ static_cast(data) }), testing::_)); - - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector{ { '\r', '\n' } }), testing::_)); - onCommandReceived(); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector{ { '>', ' ' } }), testing::_)); - - communication.dataReceived(infra::MakeStringByteRange(command + "\r")); - } - }; -} - -TEST_F(TerminalElectricalParametersIdentificationTest, estrl_calls_identification_with_wye) -{ - InvokeCommand("estrl wye", [this]() - { - EXPECT_CALL(identificationMock, EstimateResistanceAndInductance(testing::_, testing::_)) - .WillOnce([](const auto& config, const auto&) - { - EXPECT_EQ(config.windingConfig, services::WindingConfiguration::Wye); - }); - }); - - ExecuteAllActions(); -} - -TEST_F(TerminalElectricalParametersIdentificationTest, estrl_calls_identification_with_delta) -{ - InvokeCommand("estrl delta", [this]() - { - EXPECT_CALL(identificationMock, EstimateResistanceAndInductance(testing::_, testing::_)) - .WillOnce([](const auto& config, const auto&) - { - EXPECT_EQ(config.windingConfig, services::WindingConfiguration::Delta); - }); - }); - - ExecuteAllActions(); -} - -TEST_F(TerminalElectricalParametersIdentificationTest, estrl_successful_callback) -{ - infra::Function, std::optional)> capturedCallback; - - InvokeCommand("estrl star", [this, &capturedCallback]() - { - EXPECT_CALL(identificationMock, EstimateResistanceAndInductance(testing::_, testing::_)) - .WillOnce(testing::SaveArg<1>(&capturedCallback)); - }); - - ExecuteAllActions(); - - ::testing::InSequence _; - std::string newline{ "\r\n" }; - std::string prefix{ "Estimated Resistance: " }; - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(prefix.begin(), prefix.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AtLeast(1)); - - capturedCallback(foc::Ohm{ 1.5f }, foc::MilliHenry{ 2.0f }); - ExecuteAllActions(); -} - -TEST_F(TerminalElectricalParametersIdentificationTest, estrl_resistance_failure_callback) -{ - infra::Function, std::optional)> capturedCallback; - - InvokeCommand("estimate_r_and_l wye", [this, &capturedCallback]() - { - EXPECT_CALL(identificationMock, EstimateResistanceAndInductance(testing::_, testing::_)) - .WillOnce(testing::SaveArg<1>(&capturedCallback)); - }); - - ExecuteAllActions(); - - ::testing::InSequence _; - std::string newline{ "\r\n" }; - std::string message{ "Resistance estimation failed." }; - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(message.begin(), message.end())), testing::_)); - - capturedCallback(std::nullopt, std::nullopt); - ExecuteAllActions(); -} - -TEST_F(TerminalElectricalParametersIdentificationTest, estpp_calls_identification) -{ - InvokeCommand("estpp", [this]() - { - EXPECT_CALL(identificationMock, EstimateNumberOfPolePairs(testing::_, testing::_)); - }); - - ExecuteAllActions(); -} - -TEST_F(TerminalElectricalParametersIdentificationTest, estpp_successful_callback) -{ - infra::Function)> capturedCallback; - - InvokeCommand("estimate_pole_pairs", [this, &capturedCallback]() - { - EXPECT_CALL(identificationMock, EstimateNumberOfPolePairs(testing::_, testing::_)) - .WillOnce(testing::SaveArg<1>(&capturedCallback)); - }); - - ExecuteAllActions(); - - ::testing::InSequence _; - std::string newline{ "\r\n" }; - std::string prefix{ "Estimated Pole Pairs: " }; - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(prefix.begin(), prefix.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AtLeast(1)); - - capturedCallback(std::make_optional(7)); - ExecuteAllActions(); -} - -TEST_F(TerminalElectricalParametersIdentificationTest, estpp_failure_callback) -{ - infra::Function)> capturedCallback; - - InvokeCommand("estpp", [this, &capturedCallback]() - { - EXPECT_CALL(identificationMock, EstimateNumberOfPolePairs(testing::_, testing::_)) - .WillOnce(testing::SaveArg<1>(&capturedCallback)); - }); - - ExecuteAllActions(); - - ::testing::InSequence _; - std::string newline{ "\r\n" }; - std::string message{ "Pole pairs estimation failed." }; - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(message.begin(), message.end())), testing::_)); - - capturedCallback(std::nullopt); - ExecuteAllActions(); -} - -TEST_F(TerminalElectricalParametersIdentificationTest, estrl_invalid_winding_type_returns_error) -{ - InvokeCommand("estrl invalid_type", [this]() - { - ::testing::InSequence _; - std::string newline{ "\r\n" }; - std::string header{ "ERROR: " }; - std::string message{ "invalid value. It should be an 'star', 'wye' or 'delta'." }; - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(message.begin(), message.end())), testing::_)); - }); - - ExecuteAllActions(); -} - -TEST_F(TerminalElectricalParametersIdentificationTest, estrl_wrong_argument_count_returns_error) -{ - InvokeCommand("estrl", [this]() - { - ::testing::InSequence _; - std::string newline{ "\r\n" }; - std::string header{ "ERROR: " }; - std::string message{ "invalid number of arguments" }; - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(message.begin(), message.end())), testing::_)); - }); - - ExecuteAllActions(); -} - -TEST_F(TerminalElectricalParametersIdentificationTest, estrl_inductance_failure_callback) -{ - infra::Function, std::optional)> capturedCallback; - - InvokeCommand("estrl wye", [this, &capturedCallback]() - { - EXPECT_CALL(identificationMock, EstimateResistanceAndInductance(testing::_, testing::_)) - .WillOnce(testing::SaveArg<1>(&capturedCallback)); - }); - - ExecuteAllActions(); - - ::testing::InSequence _; - std::string newline{ "\r\n" }; - std::string message{ "Inductance estimation failed." }; - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(message.begin(), message.end())), testing::_)); - - // Valid resistance but nullopt inductance - capturedCallback(foc::Ohm{ 1.5f }, std::nullopt); - ExecuteAllActions(); -} diff --git a/core/services/electrical_system_ident/test_doubles/ElectricalParametersIdentificationMock.hpp b/core/services/electrical_system_ident/test_doubles/ElectricalParametersIdentificationMock.hpp index cc6dbbcc..5ed941a0 100644 --- a/core/services/electrical_system_ident/test_doubles/ElectricalParametersIdentificationMock.hpp +++ b/core/services/electrical_system_ident/test_doubles/ElectricalParametersIdentificationMock.hpp @@ -11,7 +11,7 @@ namespace services public: MOCK_METHOD(void, EstimateResistanceAndInductance, (const ResistanceAndInductanceConfig& config, - const infra::Function, std::optional)>& onDone), + const infra::Function)>& onDone), (override)); MOCK_METHOD(void, EstimateNumberOfPolePairs, (const PolePairsConfig& config, diff --git a/core/state_machine/FocStateMachineCommon.cpp b/core/state_machine/FocStateMachineCommon.cpp index b9977fea..02880b14 100644 --- a/core/state_machine/FocStateMachineCommon.cpp +++ b/core/state_machine/FocStateMachineCommon.cpp @@ -225,12 +225,12 @@ namespace application calibrating.step = state_machine::CalibrationStep::resistanceAndInductance; electricalIdent.EstimateResistanceAndInductance({}, - [this](std::optional r, std::optional l) + [this](std::optional result) { if (!IsCalibrating(state_machine::CalibrationStep::resistanceAndInductance)) return; - if (!r || !l) + if (!result) { CompletePendingCommand(state_machine::CommandResult::calibrationFailed); EnterFault(state_machine::FaultCode::calibrationFailed); @@ -238,9 +238,9 @@ namespace application else { auto& cal = std::get(currentState); - cal.pendingData.rPhase = r->Value(); - cal.pendingData.lD = l->Value(); - cal.pendingData.lQ = l->Value(); + cal.pendingData.rPhase = result->resistance.Value(); + cal.pendingData.lD = result->inductance.Value(); + cal.pendingData.lQ = result->inductance.Value(); RunAlignmentStep(); } }); diff --git a/core/state_machine/test/TestControlModeStateMachine.cpp b/core/state_machine/test/TestControlModeStateMachine.cpp index 65ddc140..de87fe66 100644 --- a/core/state_machine/test/TestControlModeStateMachine.cpp +++ b/core/state_machine/test/TestControlModeStateMachine.cpp @@ -505,7 +505,7 @@ namespace { public: infra::Function)> capturedPolePairsCb; - infra::Function, std::optional)> capturedResistanceCb; + infra::Function)> capturedResistanceCb; infra::Function)> capturedAlignmentCb; infra::Function, std::optional)> @@ -550,7 +550,7 @@ namespace void CompleteCalibration_Torque() { capturedPolePairsCb(7); - capturedResistanceCb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + capturedResistanceCb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); capturedAlignmentCb(foc::Radians{ 0.0f }); capturedNvmSaveCb(services::NvmStatus::Ok); } @@ -558,7 +558,7 @@ namespace void CompleteCalibration_WithMechIdent() { capturedPolePairsCb(7); - capturedResistanceCb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + capturedResistanceCb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); capturedAlignmentCb(foc::Radians{ 0.0f }); capturedMechIdentCb(foc::NewtonMeterSecondPerRadian{ 0.005f }, foc::NewtonMeterSecondSquared{ 0.01f }); capturedNvmSaveCb(services::NvmStatus::Ok); diff --git a/core/state_machine/test/TestFocStateMachinePosition.cpp b/core/state_machine/test/TestFocStateMachinePosition.cpp index ecdd34f8..5c22deaa 100644 --- a/core/state_machine/test/TestFocStateMachinePosition.cpp +++ b/core/state_machine/test/TestFocStateMachinePosition.cpp @@ -121,19 +121,17 @@ namespace if (resistanceOk) EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); else { EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(std::nullopt, std::nullopt); + cb(std::nullopt); })); return; } @@ -341,10 +339,9 @@ TEST_F(FocStateMachinePositionCliTest, no_mech_ident_override_enters_fault) })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([](std::size_t, const auto&, @@ -750,19 +747,17 @@ namespace if (resistanceOk) EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); else { EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(std::nullopt, std::nullopt); + cb(std::nullopt); })); return; } @@ -1471,7 +1466,7 @@ TEST_F(FocStateMachinePositionCliTest, late_resistance_callback_after_fault_is_i { GivenFaultNotifierRegistered(); GivenNvmInvalid(); - infra::Function, std::optional)> capturedCb; + infra::Function)> capturedCb; EXPECT_CALL(electricalIdentMock, EstimateNumberOfPolePairs(_, _)) .WillOnce(Invoke([](const auto&, const infra::Function)>& cb) { @@ -1479,8 +1474,7 @@ TEST_F(FocStateMachinePositionCliTest, late_resistance_callback_after_fault_is_i })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([&capturedCb](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { capturedCb = cb; })); @@ -1491,7 +1485,7 @@ TEST_F(FocStateMachinePositionCliTest, late_resistance_callback_after_fault_is_i faultNotifierMock.TriggerFault(state_machine::FaultCode::overcurrent); ASSERT_TRUE(std::holds_alternative(sm.CurrentState())); - capturedCb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + capturedCb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); EXPECT_TRUE(std::holds_alternative(sm.CurrentState())); } @@ -1507,10 +1501,9 @@ TEST_F(FocStateMachinePositionCliTest, late_alignment_callback_after_fault_is_ig })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([&capturedCb](std::size_t, const auto&, @@ -1543,10 +1536,9 @@ TEST_F(FocStateMachinePositionCliTest, late_mech_ident_callback_after_fault_is_i })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([](std::size_t, const auto&, @@ -1587,10 +1579,9 @@ TEST_F(FocStateMachinePositionCliTest, late_nvm_save_callback_after_fault_is_ign })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([](std::size_t, const auto&, @@ -1743,10 +1734,9 @@ TEST_F(FocStateMachinePositionAutoTest, no_mech_ident_override_enters_fault) })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([](std::size_t, const auto&, @@ -1787,7 +1777,7 @@ TEST_F(FocStateMachinePositionAutoTest, late_resistance_callback_after_fault_is_ { GivenFaultNotifierRegistered(); GivenNvmInvalid(); - infra::Function, std::optional)> capturedCb; + infra::Function)> capturedCb; EXPECT_CALL(electricalIdentMock, EstimateNumberOfPolePairs(_, _)) .WillOnce(Invoke([](const auto&, const infra::Function)>& cb) { @@ -1795,8 +1785,7 @@ TEST_F(FocStateMachinePositionAutoTest, late_resistance_callback_after_fault_is_ })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([&capturedCb](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { capturedCb = cb; })); @@ -1807,7 +1796,7 @@ TEST_F(FocStateMachinePositionAutoTest, late_resistance_callback_after_fault_is_ faultNotifierMock.TriggerFault(state_machine::FaultCode::overcurrent); ASSERT_TRUE(std::holds_alternative(sm.CurrentState())); - capturedCb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + capturedCb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); EXPECT_TRUE(std::holds_alternative(sm.CurrentState())); } @@ -1823,10 +1812,9 @@ TEST_F(FocStateMachinePositionAutoTest, late_alignment_callback_after_fault_is_i })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([&capturedCb](std::size_t, const auto&, @@ -1859,10 +1847,9 @@ TEST_F(FocStateMachinePositionAutoTest, late_mech_ident_callback_after_fault_is_ })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([](std::size_t, const auto&, @@ -1903,10 +1890,9 @@ TEST_F(FocStateMachinePositionAutoTest, late_nvm_save_callback_after_fault_is_ig })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([](std::size_t, const auto&, diff --git a/core/state_machine/test/TestFocStateMachineSpeed.cpp b/core/state_machine/test/TestFocStateMachineSpeed.cpp index 762b6f3c..54ff33ac 100644 --- a/core/state_machine/test/TestFocStateMachineSpeed.cpp +++ b/core/state_machine/test/TestFocStateMachineSpeed.cpp @@ -121,19 +121,17 @@ namespace if (resistanceOk) EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); else { EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(std::nullopt, std::nullopt); + cb(std::nullopt); })); return; } @@ -681,10 +679,9 @@ TEST_F(FocStateMachineSpeedCliTest, late_alignment_callback_after_fault_is_ignor })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([&capturedCb](std::size_t, const auto&, @@ -717,10 +714,9 @@ TEST_F(FocStateMachineSpeedCliTest, late_mech_ident_callback_after_fault_is_igno })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([](std::size_t, const auto&, @@ -788,7 +784,7 @@ TEST_F(FocStateMachineSpeedCliTest, late_resistance_callback_after_fault_is_igno { GivenFaultNotifierRegistered(); GivenNvmInvalid(); - infra::Function, std::optional)> capturedCb; + infra::Function)> capturedCb; EXPECT_CALL(electricalIdentMock, EstimateNumberOfPolePairs(_, _)) .WillOnce(Invoke([](const auto&, const infra::Function)>& cb) { @@ -796,8 +792,7 @@ TEST_F(FocStateMachineSpeedCliTest, late_resistance_callback_after_fault_is_igno })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([&capturedCb](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { capturedCb = cb; })); @@ -808,7 +803,7 @@ TEST_F(FocStateMachineSpeedCliTest, late_resistance_callback_after_fault_is_igno faultNotifierMock.TriggerFault(state_machine::FaultCode::overcurrent); ASSERT_TRUE(std::holds_alternative(sm.CurrentState())); - capturedCb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + capturedCb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); EXPECT_TRUE(std::holds_alternative(sm.CurrentState())); } @@ -824,10 +819,9 @@ TEST_F(FocStateMachineSpeedCliTest, late_nvm_save_callback_after_fault_is_ignore })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([](std::size_t, const auto&, @@ -1112,19 +1106,17 @@ namespace if (resistanceOk) EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); else { EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(std::nullopt, std::nullopt); + cb(std::nullopt); })); return; } @@ -1456,7 +1448,7 @@ TEST_F(FocStateMachineSpeedAutoTest, late_resistance_callback_after_fault_is_ign { GivenFaultNotifierRegistered(); GivenNvmInvalid(); - infra::Function, std::optional)> capturedCb; + infra::Function)> capturedCb; EXPECT_CALL(electricalIdentMock, EstimateNumberOfPolePairs(_, _)) .WillOnce(Invoke([](const auto&, const infra::Function)>& cb) { @@ -1464,8 +1456,7 @@ TEST_F(FocStateMachineSpeedAutoTest, late_resistance_callback_after_fault_is_ign })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([&capturedCb](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { capturedCb = cb; })); @@ -1476,7 +1467,7 @@ TEST_F(FocStateMachineSpeedAutoTest, late_resistance_callback_after_fault_is_ign faultNotifierMock.TriggerFault(state_machine::FaultCode::overcurrent); ASSERT_TRUE(std::holds_alternative(sm.CurrentState())); - capturedCb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + capturedCb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); EXPECT_TRUE(std::holds_alternative(sm.CurrentState())); } @@ -1492,10 +1483,9 @@ TEST_F(FocStateMachineSpeedAutoTest, late_alignment_callback_after_fault_is_igno })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([&capturedCb](std::size_t, const auto&, @@ -1528,10 +1518,9 @@ TEST_F(FocStateMachineSpeedAutoTest, late_mech_ident_callback_after_fault_is_ign })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([](std::size_t, const auto&, @@ -1572,10 +1561,9 @@ TEST_F(FocStateMachineSpeedAutoTest, late_nvm_save_callback_after_fault_is_ignor })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([](std::size_t, const auto&, diff --git a/core/state_machine/test/TestFocStateMachineTorque.cpp b/core/state_machine/test/TestFocStateMachineTorque.cpp index 15b7ab48..02a92401 100644 --- a/core/state_machine/test/TestFocStateMachineTorque.cpp +++ b/core/state_machine/test/TestFocStateMachineTorque.cpp @@ -113,19 +113,17 @@ namespace if (resistanceOk) EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); else { EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(std::nullopt, std::nullopt); + cb(std::nullopt); })); return; } @@ -648,7 +646,7 @@ TEST_F(FocStateMachineTorqueCliTest, late_resistance_callback_after_fault_is_ign { GivenFaultNotifierRegistered(); GivenNvmInvalid(); - infra::Function, std::optional)> capturedCb; + infra::Function)> capturedCb; EXPECT_CALL(electricalIdentMock, EstimateNumberOfPolePairs(_, _)) .WillOnce(Invoke([](const auto&, const infra::Function)>& cb) { @@ -656,8 +654,7 @@ TEST_F(FocStateMachineTorqueCliTest, late_resistance_callback_after_fault_is_ign })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([&capturedCb](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { capturedCb = cb; })); @@ -668,7 +665,7 @@ TEST_F(FocStateMachineTorqueCliTest, late_resistance_callback_after_fault_is_ign faultNotifierMock.TriggerFault(state_machine::FaultCode::overcurrent); ASSERT_TRUE(std::holds_alternative(sm.CurrentState())); - capturedCb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + capturedCb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); EXPECT_TRUE(std::holds_alternative(sm.CurrentState())); } @@ -684,10 +681,9 @@ TEST_F(FocStateMachineTorqueCliTest, late_alignment_callback_after_fault_is_igno })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([&capturedCb](std::size_t, const auto&, @@ -718,10 +714,9 @@ TEST_F(FocStateMachineTorqueCliTest, late_nvm_save_callback_after_fault_is_ignor })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([](std::size_t, const auto&, @@ -915,19 +910,17 @@ namespace if (resistanceOk) EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); else { EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(std::nullopt, std::nullopt); + cb(std::nullopt); })); return; } @@ -1397,7 +1390,7 @@ TEST_F(FocStateMachineTorqueAutoTest, late_resistance_callback_after_fault_is_ig { GivenFaultNotifierRegistered(); GivenNvmInvalid(); - infra::Function, std::optional)> capturedCb; + infra::Function)> capturedCb; EXPECT_CALL(electricalIdentMock, EstimateNumberOfPolePairs(_, _)) .WillOnce(Invoke([](const auto&, const infra::Function)>& cb) { @@ -1405,8 +1398,7 @@ TEST_F(FocStateMachineTorqueAutoTest, late_resistance_callback_after_fault_is_ig })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([&capturedCb](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { capturedCb = cb; })); @@ -1416,7 +1408,7 @@ TEST_F(FocStateMachineTorqueAutoTest, late_resistance_callback_after_fault_is_ig faultNotifierMock.TriggerFault(state_machine::FaultCode::overcurrent); ASSERT_TRUE(std::holds_alternative(sm.CurrentState())); - capturedCb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + capturedCb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); EXPECT_TRUE(std::holds_alternative(sm.CurrentState())); } @@ -1432,10 +1424,9 @@ TEST_F(FocStateMachineTorqueAutoTest, late_alignment_callback_after_fault_is_ign })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([&capturedCb](std::size_t, const auto&, @@ -1465,10 +1456,9 @@ TEST_F(FocStateMachineTorqueAutoTest, late_nvm_save_callback_after_fault_is_igno })); EXPECT_CALL(electricalIdentMock, EstimateResistanceAndInductance(_, _)) .WillOnce(Invoke([](const auto&, - const infra::Function, - std::optional)>& cb) + const auto& cb) { - cb(foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }); + cb(services::ElectricalParametersIdentification::ResistanceInductanceResult{ foc::Ohm{ 0.5f }, foc::MilliHenry{ 1.0f }, foc::Volts{ 0.0f }, 0.0f }); })); EXPECT_CALL(alignmentMock, ForceAlignment(_, _, _)) .WillOnce(Invoke([](std::size_t, const auto&, diff --git a/documentation/design/integration-testing.md b/documentation/design/integration-testing.md index 9e037eae..ccda525c 100644 --- a/documentation/design/integration-testing.md +++ b/documentation/design/integration-testing.md @@ -125,7 +125,7 @@ sequenceDiagram EIM-->>Fixture: capturedRLCallback = cb Step->>Fixture: CompleteRLEstimation(R, L) - Fixture->>SM: capturedRLCallback(R, L) + Fixture->>SM: capturedRLCallback(result) SM->>AMK: ForceAlignment(polePairs, cfg, cb) AMK-->>Fixture: capturedAlignmentCallback = cb diff --git a/documentation/theory/resistance-inductance-estimation.md b/documentation/theory/resistance-inductance-estimation.md index 608d1fab..347fe5f8 100644 --- a/documentation/theory/resistance-inductance-estimation.md +++ b/documentation/theory/resistance-inductance-estimation.md @@ -2,9 +2,9 @@ title: "Electrical Parameters Identification — Resistance and Inductance" type: theory status: approved -version: 1.0.0 +version: 2.0.0 component: "service-electrical-ident" -date: 2025-01-01 +date: 2026-07-11 --- | Field | Value | @@ -12,9 +12,9 @@ date: 2025-01-01 | Title | Electrical Parameters Identification — R and L | | Type | theory | | Status | approved | -| Version | 1.0.0 | +| Version | 2.0.0 | | Component | service-electrical-ident | -| Date | 2025-01-01 | +| Date | 2026-07-11 | ## Overview @@ -24,10 +24,13 @@ parameters are used to: 2. Implement feed-forward decoupling of the dq cross-coupling terms. 3. Estimate motor temperature from measured $R_s$ (since $R_s \propto T$). -This identification procedure applies a DC voltage step to the motor aligned on the **d-axis** and -measures the current transient. Alignment to the d-axis suppresses the back-EMF (which only appears -on the q-axis), yielding a clean RL circuit step response. Resistance is derived from the DC -steady-state, and inductance from the measured time constant. +This identification procedure applies a sequence of DC voltage steps to a single stator axis and +measures the resulting current. Because the rotor is stationary throughout, back-EMF is zero and the +excited axis behaves as a first-order RL circuit. Resistance is derived from a **multi-point +differential fit** ($\Delta V / \Delta I$) that cancels constant inverter and sensor offsets, and +inductance from the **integral of the current transient**. The excitation levels are **auto-scaled** +to the motor using a coarse pre-probe so that each level reaches a target fraction of the drive's +maximum current. --- @@ -38,106 +41,105 @@ steady-state, and inductance from the measured time constant. | $R_s$ | Stator resistance per phase | Ω | | $L_s$ | Stator inductance (d-axis, $L_d$) | H | | $\tau$ | Electrical time constant = $L_s / R_s$ | s | -| $V_{step}$ | Applied step voltage (d-axis) | V | -| $I_{ss}$ | Steady-state current = $V_{step} / R_s$ | A | -| $I_\tau$ | Current at time $\tau$: $I_{ss} \cdot (1 - e^{-1})$ | A | +| $V_j$ | Applied step voltage at level $j$ | V | +| $I_{ss,j}$ | Steady-state current at level $j$ | A | +| $V_{err}$ | Inverter voltage error (fit intercept) | V | +| $V_{probe}$| Coarse pre-probe voltage | V | +| $I_{ss}$ | Steady-state current of the probe transient | A | | $f_s$ | Sampling frequency | Hz | | $T_s$ | Sampling period = $1/f_s$ | s | -| $N_{avg}$ | Moving average filter length | samples | -| $N_{buf}$ | Total sample buffer size | samples | +| $N_{levels}$ | Number of $\Delta V/\Delta I$ levels | — | +| $N_{buf}$ | Probe transient buffer size | samples | --- ## Mathematical Foundation -### 1. d-Axis Alignment and Back-EMF Suppression +### 1. Single-Axis Excitation and Back-EMF Suppression -Before applying the voltage step, the motor is aligned to $\theta_e = 0$ (rotor d-axis aligned -with stator $\alpha$-axis). In this condition: +The procedure energises one stator axis with a DC field (high duty on phase A, neutral on B and C). +Because every step is a DC level, the rotor is **stationary** at the moment of measurement, so: -- The back-EMF is $e_\alpha = -\psi_f \omega_e \sin(0) = 0$. -- The q-axis current is forced to zero: $i_q = 0$. -- Only the d-axis RL circuit is excited. +- The electrical speed is $\omega_e = 0$, hence the back-EMF $e = \psi_f \omega_e = 0$. +- Only the excited RL circuit carries current. -The stator d-axis circuit model reduces to: +The rotor is pulled into alignment with the applied field during the coarse pre-probe and the first +graduated levels. Because every level shares the **same** stator axis, the equilibrium angle never +changes between levels — so the rotor does not move during the transient used for inductance. No +explicit alignment routine is required; a poor regression fit (Section 3) flags any residual motion. + +The excited-axis circuit model reduces to: $$ -v_d = R_s\, i_d + L_s \frac{di_d}{dt} +v = R_s\, i + L_s \frac{di}{dt} $$ -This is a first-order linear system driven by a unit step of amplitude $V_{step}$. +This is a first-order linear system driven by a step of amplitude $V$. ### 2. RL Step Response -For a step input $v_d(t) = V_{step} \cdot u(t)$ with zero initial conditions ($i_d(0) = 0$): +For a step input $v(t) = V \cdot u(t)$ with zero initial conditions ($i(0) = 0$): $$ -\boxed{i_d(t) = \frac{V_{step}}{R_s}\!\left(1 - e^{-t/\tau}\right)}, \qquad \tau = \frac{L_s}{R_s} +\boxed{i(t) = \frac{V}{R_s}\!\left(1 - e^{-t/\tau}\right)}, \qquad \tau = \frac{L_s}{R_s} $$ Key properties of this response: -- At $t = \tau$: $i_d(\tau) = I_{ss}(1 - e^{-1}) \approx 0.6321 \cdot I_{ss}$ -- At $t = 5\tau$: $i_d(5\tau) \approx 0.9933 \cdot I_{ss}$ (essentially settled) -- Slope at $t = 0$: $\left.\frac{di_d}{dt}\right|_{t=0} = \frac{V_{step}}{L_s}$ - -See: `documentation/theory/images/rl_step_response.svg` (generated by `documentation/tools/generate_plots.gp`) +- At $t = \tau$: $i(\tau) = I_{ss}(1 - e^{-1}) \approx 0.6321 \cdot I_{ss}$ +- At $t = 5\tau$: $i(5\tau) \approx 0.9933 \cdot I_{ss}$ (essentially settled) +- Slope at $t = 0$: $\left.\frac{di}{dt}\right|_{t=0} = \frac{V}{L_s}$ -### 3. Resistance Estimation +### 3. Resistance Estimation — Multi-Point Differential Fit -Once the current has fully settled to steady state (after $5\tau$): +A single-point estimate $R_s = V/I_{ss}$ bakes every constant error — inverter dead-time, MOSFET and +body-diode drops, and current-sensor DC offset — directly into $R_s$. Instead, $N_{levels}$ steps are +applied and the steady-state pairs $(I_{ss,j}, V_j)$ are fit by ordinary least squares to a line: $$ -\boxed{R_s = \frac{V_{step}}{I_{ss}}} +V_j = R_s\, I_{ss,j} + V_{err} $$ -where $I_{ss}$ is measured from the mean of the last 10% of the sample buffer (to average out noise). +- The **slope** $R_s$ is immune to any constant voltage error or current offset — they cancel in the + differential $\Delta V/\Delta I$. +- The **intercept** $V_{err}$ estimates the total inverter voltage error, exported as free diagnostic + data (usable later for dead-time compensation). -**Noise considerations**: $R_s$ estimation is straightforward but requires: -- Accurate current sensor calibration (ADC offset error directly biases $I_{ss}$). -- Stable $V_{step}$ (DC bus voltage variation during measurement corrupts the result). -- Sufficient settling time in the buffer ($N_{buf} \geq 5\tau / T_s$ samples). +**Auto-scaling.** A coarse pre-probe at $V_{probe}$ yields $R_{coarse} = V_{probe}/I_{probe}$. Each +level then targets a current fraction $f_j$ of the drive maximum $I_{max}$, choosing the duty so that +$V_j \approx f_j\, I_{max}\, R_{coarse}$ (clamped to a safe duty range). This keeps the currents high +enough to escape the worst dead-time non-linearity regardless of the motor. -### 4. Inductance Estimation — Time-Constant Method +**Fit quality.** The maximum normalised residual +$\max_j |V_j - (R_s I_{ss,j} + V_{err})| / R_s$ is reported. A large value indicates the $V$–$I$ +relationship was not linear — typically rotor motion or ADC saturation — and the estimate is +rejected. -The time constant $\tau = L_s / R_s$ is found by identifying the sample index $n_\tau$ where the -current first reaches the 63.2% threshold: +**Winding topology.** For a Delta connection the terminals measure $\tfrac{2}{3}$ of the per-phase +value for both resistance and inductance; the phase quantities are recovered with +$R_\phi = R_{terminal} \cdot k_\Delta$ and $L_\phi = L_{terminal} \cdot k_\Delta$, $k_\Delta = 1.5$. -$$ -i_d[n_\tau] \geq 0.6321 \cdot I_{ss} -$$ +### 4. Inductance Estimation — Integral Method -The time constant is then: +Rather than locating the 63.2% threshold crossing (which quantises $\tau$ to one sample and is +sensitive to a single noisy point), the inductance is obtained from the integral identity of a +first-order rise. For $i(t) = I_{ss}(1 - e^{-t/\tau})$: $$ -\tau = n_\tau \cdot T_s +\int_0^\infty \bigl(I_{ss} - i(t)\bigr)\,dt = I_{ss}\,\tau +\quad\Longrightarrow\quad +\boxed{L_s = R_s \cdot \frac{\displaystyle\sum_k \bigl(I_{ss} - i[k]\bigr)\,T_s}{I_{ss}}} $$ -and the inductance: +The sum runs over the full probe transient (from step onset to plateau). Properties: -$$ -\boxed{L_s = R_s \cdot \tau = R_s \cdot n_\tau \cdot T_s} -$$ - -#### Moving Average Filter Correction - -A causal moving average filter of length $N_{avg}$ is applied to the raw current samples before -threshold detection: - -$$ -\bar{i}[n] = \frac{1}{N_{avg}} \sum_{k=0}^{N_{avg}-1} i[n-k] -$$ +- **Every sample contributes**, so noise averages out and the result has sub-sample-period + resolution — it removes both weaknesses of the threshold method. +- The integrand is a **difference** $(I_{ss} - i[k])$, so any constant current-sensor offset cancels. +- The probe step starts from near-zero current, so the denominator is the probe $I_{ss}$ and $R_s$ is + the value fitted in Section 3. -This FIR filter introduces a lag of $(N_{avg} - 1)/2$ samples. The threshold index is corrected: - -$$ -n_\tau^{corrected} = n_\tau - \left\lfloor \frac{N_{avg} - 1}{2} \right\rfloor - 1 -$$ - -Without this correction, $\tau$ is overestimated by the filter group delay, leading to an overestimate -of $L_s$. - -**Filter trade-off**: larger $N_{avg}$ reduces noise variance $\propto 1/N_{avg}$ but increases the -index correction and requires a corresponding increase in buffer size $N_{buf}$. +**Requirement**: the buffer must span the transient to a true plateau ($N_{buf} \gtrsim 5\tau/T_s$). +If $\tau$ is large relative to the buffer, increase $N_{buf}$ or the probe voltage. ### 5. Pole Pair Estimation @@ -163,25 +165,25 @@ where $\Delta\theta_{mech,total}$ is the measured total mechanical rotation duri ### 6. Complete Identification Sequence ``` -1. Drive alignment: rotate field through N_steps to θ_e = 0. - Record encoder offset θ_offset (see alignment theory). +1. Coarse pre-probe: apply V_probe on one axis, collect the full transient, + take I_probe from the last 10% -> R_coarse = V_probe / I_probe. + (This step also settles/aligns the rotor to the excited axis.) -2. Apply step voltage V_step on d-axis (i_q* = 0, v_d = V_step). +2. For each level j in [0 .. N_levels-1]: + a. Auto-scale duty so the current targets f_j * I_max (using R_coarse). + b. Settle for settlePerLevel, then average a steady-state batch -> I_ss_j. + Record the applied voltage V_j. -3. Sample i_d at f_s = 10 kHz for N_buf samples. +3. Fit V_j = R_s * I_ss_j + V_err by least squares. + Reject if any I_ss_j is near zero, if the slope R_s <= 0, or if the + normalised residual exceeds the fit-quality threshold. -4. Apply moving average filter (length N_avg = 5) to samples. +4. Inductance from the probe transient integral: + L_s = R_s * sum((I_ss - i[k]) * T_s) / I_ss. -5. Find steady-state current I_ss from mean of last 10% of buffer. +5. Apply the Delta winding correction (k = 1.5) to both R_s and L_s when configured. -6. Compute R_s = V_step / I_ss. - -7. Find first index n_τ where i_d[n] ≥ 0.6321 · I_ss. - -8. Correct for filter delay: n_τ_corr = n_τ − ⌊(N_avg−1)/2⌋ − 1. - -9. Compute τ = n_τ_corr · T_s, L_s = R_s · τ [H]. - Express L_s in mH: L_s_mH = R_s · n_τ_corr / f_s · 1000. +6. Report { R_s, L_s, V_err, fit_quality }. ``` --- @@ -192,14 +194,15 @@ where $\Delta\theta_{mech,total}$ is the measured total mechanical rotation duri ```mermaid graph TD - A[Set θ_e = 0\nAlign rotor to d-axis] --> B[Apply V_step\non d-axis\ni_q* = 0] - B --> C[Sample i_d\nf_s = 10 kHz\nN_buf samples] - C --> D[Moving Average\nFilter\nN_avg = 5] - D --> E[Find I_ss\nmean of last 10%] - D --> F[Find n_τ\nfirst sample ≥ 63.2% I_ss] - E --> G[R_s = V_step / I_ss] - F --> H[n_τ_corr = n_τ − delay] - G --> I[L_s = R_s · n_τ_corr · T_s] + A[Coarse pre-probe\nV_probe on one axis] --> B[R_coarse = V_probe / I_probe\nrotor settles on axis] + B --> C[For each level j:\nauto-scale duty to f_j * I_max] + C --> D[Settle + average\nI_ss_j] + D --> E{More levels?} + E -- yes --> C + E -- no --> F[LS fit\nV_j = R_s I_ss_j + V_err] + F --> G[Integral method\nL_s = R_s Σ(I_ss − i)Ts / I_ss] + F --> H[Fit quality\n+ Delta correction] + G --> I[Report R_s, L_s, V_err, quality] H --> I ``` @@ -222,8 +225,8 @@ i_d (normalised: I_ss = 1.0) 0.0├─────────── └───────────────────────────────── samples (n·T_s) 0 τ/T_s 2τ/T_s 5τ/T_s - ↑ - n_τ (63.2% crossing) — filter delay correction applied + +The whole shaded area between I_ss and i(t) is integrated: L_s = R_s · area / I_ss. ``` --- @@ -233,60 +236,64 @@ i_d (normalised: I_ss = 1.0) | Property | Value / Condition | |----------------------|--------------------------------------------------------------| | Sampling rate | $f_s = 10\ \text{kHz}$, $T_s = 100\ \mu\text{s}$ | -| Filter length | $N_{avg} = 5$ samples | -| Filter group delay | $(N_{avg}-1)/2 = 2$ samples = $200\ \mu\text{s}$ | -| Threshold | $0.6321 \cdot I_{ss}$ (i.e. $1 - e^{-1}$) | +| Probe buffer | $N_{buf} = 512$ samples ($51.2\ \text{ms}$) | +| Resistance levels | $N_{levels} = 3$ (default fractions $0.3, 0.5, 0.7$) | +| Steady-state batch | $32$ samples per level | +| Threshold | integral method — no fixed threshold | | $R_s$ range | Nominally $0.1\ \Omega$ to $50\ \Omega$ (ADC current range) | -| $L_s$ resolution | $R_s \cdot T_s$ (one sample step) = depends on $R_s$ | -| $L_s$ min detectable | Approx. $R_s \cdot 2 T_s$ (due to filter delay correction) | -| Trigger voltage | $V_{step}$ must be small enough to avoid magnetic saturation | +| $L_s$ resolution | sub-sample (integral of the full transient) | +| Fit-quality gate | reject if normalised residual $> 0.1$ | +| Trigger voltage | auto-scaled to target current fraction of $I_{max}$ | ### Sensitivity Analysis -| Source of Error | Effect on $R_s$ | Effect on $L_s$ | -|----------------------------|--------------------------------------|------------------------------------------| -| ADC current offset | Directly biases $I_{ss}$ | Indirect via $R_s$ error | -| $V_{dc}$ variation | Biases $V_{step}$ | Indirect via $R_s$ error | -| Thermal drift in $R_s$ | Measurement valid at $T_{meas}$ only | — | -| Filter delay not corrected | — | $L_s$ overestimated by $N_{avg}/2$ steps | -| Insufficient buffer | $I_{ss}$ underestimated | $\tau$ underestimated | -| Magnetic saturation | $R_s$ underestimated | $L_s$ underestimated (nonlinear) | +| Source of Error | Effect on $R_s$ | Effect on $L_s$ | +|----------------------------|------------------------------------------|------------------------------------------| +| ADC current offset | Cancelled by the differential fit | Cancelled in the integrand difference | +| Inverter dead-time / drops | Cancelled (lands in $V_{err}$ intercept) | Indirect via $R_s$ error | +| $V_{dc}$ variation | Biases $V_j$ (kept brief to limit drift) | Indirect via $R_s$ error | +| Thermal drift in $R_s$ | Measurement valid at $T_{meas}$ only | — | +| Rotor motion in transient | Flagged by fit-quality residual | Corrupts integral; rejected if flagged | +| Insufficient buffer | — | $\tau$ / area underestimated | +| Magnetic saturation | $R_s$ underestimated | $L_s$ underestimated (nonlinear) | --- ## Worked Example -Motor: $V_{step} = 2\ \text{V}$, $R_s = 1.2\ \Omega$, $L_s = 0.6\ \text{mH}$, -$f_s = 10\ \text{kHz}$, $N_{avg} = 5$. +Motor: $R_s = 1.2\ \Omega$, $L_s = 0.6\ \text{mH}$, $f_s = 10\ \text{kHz}$, +three levels at $V_1 = 1.2\,\text{V}$, $V_2 = 2.0\,\text{V}$, $V_3 = 2.8\,\text{V}$ with a +constant inverter error $V_{err} = 0.3\,\text{V}$. + +**Steady-state currents** ($I_{ss,j} = (V_j - V_{err})/R_s$): -**Expected results:** +$$I_{ss,1} = 0.75\,\text{A},\quad I_{ss,2} = 1.417\,\text{A},\quad I_{ss,3} = 2.083\,\text{A}$$ -$$I_{ss} = \frac{2}{1.2} \approx 1.667\ \text{A}$$ +**Resistance** — the least-squares slope through the three points: -$$\tau = \frac{L_s}{R_s} = \frac{0.6 \times 10^{-3}}{1.2} = 0.5\ \text{ms} = 5\ T_s$$ +$$R_s = \frac{\Delta V}{\Delta I} = \frac{2.8 - 1.2}{2.083 - 0.75} = 1.2\ \Omega,\qquad V_{err} = 0.3\,\text{V}$$ -At the 63.2% threshold: $i_d[n_\tau] \geq 0.6321 \times 1.667 = 1.054\ \text{A}$ +A single-point estimate at level 1 would instead give $1.2/0.75 = 1.6\ \Omega$ — a **33% error** — +showing why the differential fit matters. -The raw crossing occurs at $n_\tau = 5$. Filter delay correction: $n_\tau^{corr} = 5 - 2 - 1 = 2$. +**Inductance** — integrating the probe transient ($\tau = L_s/R_s = 0.5\,\text{ms} = 5\,T_s$): -$$L_{s,\mathrm{mH}} = 1.2 \times 2 \times 100 \times 10^{-6} \times 1000 = 0.24\ \text{mH}$$ +$$L_s = R_s \cdot \frac{\sum_k (I_{ss} - i[k])\,T_s}{I_{ss}} = R_s \cdot \tau = 1.2 \times 0.5\times10^{-3} = 0.6\ \text{mH}$$ -> The example shows that a very short $\tau$ (5 samples) combined with a 5-tap filter and a -> 2-sample delay correction can yield significant estimation error. In practice $\tau$ should be -> at least 15–20 samples for accurate identification. +The integral recovers $\tau$ with sub-sample resolution, independent of any single noisy point. --- ## Limitations & Assumptions -- **Assumes**: The rotor is aligned to the d-axis ($\theta_e = 0$, $\dot\theta = 0$). Any rotor - motion during identification overlays a back-EMF on the d-axis current. +- **Assumes**: The rotor is stationary during each transient. The pre-probe and same-axis graduated + steps ensure this; residual motion is caught by the fit-quality residual. - **Assumes**: $L_d \approx L_q$ (surface-mounted PMSM). For interior PMSM, the step must be repeated on both axes if the design requires both $L_d$ and $L_q$. -- **Assumes**: Magnetic linearity (no saturation). The identification current $I_d^{step}$ must - be kept below the saturation current. +- **Assumes**: Magnetic linearity (no saturation). The identification current must be kept below the + saturation current. - **Does not handle**: Temperature-dependent $R_s$ variation during operation. -- **Does not handle**: Identification at running speed where back-EMF cannot be zeroed by alignment alone. +- **Does not handle**: Identification at running speed where back-EMF cannot be zeroed by standstill. ## References diff --git a/integration_tests/software_in_the_loop/support/FocIntegrationFixture.cpp b/integration_tests/software_in_the_loop/support/FocIntegrationFixture.cpp index c5ae7bf9..43ec8af7 100644 --- a/integration_tests/software_in_the_loop/support/FocIntegrationFixture.cpp +++ b/integration_tests/software_in_the_loop/support/FocIntegrationFixture.cpp @@ -155,7 +155,7 @@ namespace integration capturedAlignmentCallback = cb; })); - capturedRLCallback(std::optional{ resistance }, std::optional{ inductance }); + capturedRLCallback(services::ElectricalParametersIdentification::ResistanceInductanceResult{ resistance, inductance, foc::Volts{ 0.0f }, 0.0f }); ExecuteAllActions(); } diff --git a/integration_tests/software_in_the_loop/support/FocIntegrationFixture.hpp b/integration_tests/software_in_the_loop/support/FocIntegrationFixture.hpp index aa713d3c..b5b37a6d 100644 --- a/integration_tests/software_in_the_loop/support/FocIntegrationFixture.hpp +++ b/integration_tests/software_in_the_loop/support/FocIntegrationFixture.hpp @@ -99,7 +99,7 @@ namespace integration bool calibrationExpectationsConfigured{ false }; infra::Function)> capturedPolePairsCallback; - infra::Function, std::optional)> capturedRLCallback; + infra::Function)> capturedRLCallback; infra::Function)> capturedAlignmentCallback; testing::StrictMock transportCanMock; diff --git a/integration_tests/software_in_the_loop/support/PositionIntegrationFixture.cpp b/integration_tests/software_in_the_loop/support/PositionIntegrationFixture.cpp index 48a13dc8..2658497a 100644 --- a/integration_tests/software_in_the_loop/support/PositionIntegrationFixture.cpp +++ b/integration_tests/software_in_the_loop/support/PositionIntegrationFixture.cpp @@ -159,7 +159,7 @@ namespace integration capturedAlignmentCallback = cb; })); - capturedRLCallback(std::optional{ resistance }, std::optional{ inductance }); + capturedRLCallback(services::ElectricalParametersIdentification::ResistanceInductanceResult{ resistance, inductance, foc::Volts{ 0.0f }, 0.0f }); ExecuteAllActions(); } diff --git a/integration_tests/software_in_the_loop/support/PositionIntegrationFixture.hpp b/integration_tests/software_in_the_loop/support/PositionIntegrationFixture.hpp index 4ddc4a04..2b05d41d 100644 --- a/integration_tests/software_in_the_loop/support/PositionIntegrationFixture.hpp +++ b/integration_tests/software_in_the_loop/support/PositionIntegrationFixture.hpp @@ -97,7 +97,7 @@ namespace integration bool calibrationExpectationsConfigured{ false }; infra::Function)> capturedPolePairsCallback; - infra::Function, std::optional)> capturedRLCallback; + infra::Function)> capturedRLCallback; infra::Function)> capturedAlignmentCallback; infra::Function, std::optional)> capturedMechIdentCallback; diff --git a/integration_tests/software_in_the_loop/support/SpeedIntegrationFixture.cpp b/integration_tests/software_in_the_loop/support/SpeedIntegrationFixture.cpp index a9558ee7..6865cada 100644 --- a/integration_tests/software_in_the_loop/support/SpeedIntegrationFixture.cpp +++ b/integration_tests/software_in_the_loop/support/SpeedIntegrationFixture.cpp @@ -159,7 +159,7 @@ namespace integration capturedAlignmentCallback = cb; })); - capturedRLCallback(std::optional{ resistance }, std::optional{ inductance }); + capturedRLCallback(services::ElectricalParametersIdentification::ResistanceInductanceResult{ resistance, inductance, foc::Volts{ 0.0f }, 0.0f }); ExecuteAllActions(); } diff --git a/integration_tests/software_in_the_loop/support/SpeedIntegrationFixture.hpp b/integration_tests/software_in_the_loop/support/SpeedIntegrationFixture.hpp index 4673e2d0..8c025eef 100644 --- a/integration_tests/software_in_the_loop/support/SpeedIntegrationFixture.hpp +++ b/integration_tests/software_in_the_loop/support/SpeedIntegrationFixture.hpp @@ -97,7 +97,7 @@ namespace integration bool calibrationExpectationsConfigured{ false }; infra::Function)> capturedPolePairsCallback; - infra::Function, std::optional)> capturedRLCallback; + infra::Function)> capturedRLCallback; infra::Function)> capturedAlignmentCallback; infra::Function, std::optional)> capturedMechIdentCallback; diff --git a/tools/simulator/app/CalibrationsWiring.hpp b/tools/simulator/app/CalibrationsWiring.hpp index a00beba6..7351b002 100644 --- a/tools/simulator/app/CalibrationsWiring.hpp +++ b/tools/simulator/app/CalibrationsWiring.hpp @@ -35,9 +35,9 @@ namespace simulator controller.Stop(); gui.SetState(state_machine::Calibrating{ state_machine::CalibrationStep::resistanceAndInductance }); electricalIdent.EstimateResistanceAndInductance(services::ElectricalParametersIdentification::ResistanceAndInductanceConfig{}, - [&gui, &electricalIdent](std::optional r, std::optional l) + [&gui, &electricalIdent](std::optional result) { - if (r.has_value() && l.has_value()) + if (result.has_value()) gui.SetState(state_machine::Calibrating{ state_machine::CalibrationStep::polePairs }); electricalIdent.EstimateNumberOfPolePairs(services::ElectricalParametersIdentification::PolePairsConfig{}, [&gui](std::optional p) From c87169b8ff2cc4aed8caf7f54780917d70cfc23d Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Sat, 11 Jul 2026 20:41:19 +0000 Subject: [PATCH 14/28] feat(hardware_test): add ident/align CLI commands and require pole pairs in foc - Add "ident" command to run electrical parameter identification (R, L and pole pairs) with an optional winding type and tunable config, storing the result for later use. - Add "align" command that reuses the identified pole pairs and fails fast when identification has not been run. - Make pole pairs a mandatory argument of the "foc" simulation command and remove the now-redundant "motor" command. - Wire the electrical identification and alignment services into the target, raise the terminal command capacity, and update the HIL cycle-budget step. --- .../steps/FocCycleBudgetSteps.cpp | 2 +- .../hardware_test/components/CMakeLists.txt | 2 + targets/hardware_test/components/Terminal.cpp | 199 ++++++++++++++++-- targets/hardware_test/components/Terminal.hpp | 18 +- .../components/test/TestTerminal.cpp | 141 +++++++------ .../hardware_test/instantiations/Logic.hpp | 2 +- 6 files changed, 285 insertions(+), 79 deletions(-) diff --git a/integration_tests/hardware_in_the_loop/steps/FocCycleBudgetSteps.cpp b/integration_tests/hardware_in_the_loop/steps/FocCycleBudgetSteps.cpp index b5f057a2..ce292392 100644 --- a/integration_tests/hardware_in_the_loop/steps/FocCycleBudgetSteps.cpp +++ b/integration_tests/hardware_in_the_loop/steps/FocCycleBudgetSteps.cpp @@ -13,7 +13,7 @@ static constexpr uint32_t kMaxAllowedCycles{ 4500 }; WHEN(R"(the FOC loop CPU utilisation is sampled for one control cycle)") { auto& fixture = context.Get(); - ASSERT_TRUE(fixture.SendCommand("foc 0.0 0.0 0.0 0.0", timeouts::slowCommand)) + ASSERT_TRUE(fixture.SendCommand("foc 7 0.0 0.0 0.0 0.0", timeouts::slowCommand)) << "FOC simulation command did not receive a response"; } diff --git a/targets/hardware_test/components/CMakeLists.txt b/targets/hardware_test/components/CMakeLists.txt index 8a2f9d0d..2965270a 100644 --- a/targets/hardware_test/components/CMakeLists.txt +++ b/targets/hardware_test/components/CMakeLists.txt @@ -11,6 +11,8 @@ target_link_libraries(e_foc.hardware_test.components PUBLIC services.util services.tracer e_foc.foc.implementations + e_foc.services.alignment + e_foc.services.electrical_system_ident ) target_sources(e_foc.hardware_test.components PRIVATE diff --git a/targets/hardware_test/components/Terminal.cpp b/targets/hardware_test/components/Terminal.cpp index c3c5bac0..f663cea3 100644 --- a/targets/hardware_test/components/Terminal.cpp +++ b/targets/hardware_test/components/Terminal.cpp @@ -1,4 +1,5 @@ #include "targets/hardware_test/components/Terminal.hpp" +#include "core/services/electrical_system_ident/ElectricalParametersIdentification.hpp" #include "foc/interfaces/Driver.hpp" #include "hal/interfaces/Pwm.hpp" #include "infra/stream/StringInputStream.hpp" @@ -68,6 +69,15 @@ namespace else return {}; } + + std::optional ParseWinding(const infra::BoundedConstString& value) + { + if (value == "wye") + return services::WindingConfiguration::Wye; + if (value == "delta") + return services::WindingConfiguration::Delta; + return std::nullopt; + } } namespace application @@ -83,6 +93,8 @@ namespace application , systemClock{ hardware.SystemClock() } , foc{ hardware.MaxCurrentSupported(), hal::Hertz{ 1000 }, hardware.LowPriorityInterrupt() } , eeprom{ hardware.Eeprom() } + , electricalIdent{ hardware, hardware, Vdc } + , motorAlignment{ hardware, hardware } { terminal.AddCommand({ { "enc", "e", "Read encoder. stop. Ex: enc" }, [this](const auto&) @@ -120,16 +132,22 @@ namespace application this->terminal.ProcessResult(ConfigurePid(param)); } }); - terminal.AddCommand({ { "foc", "f", "Simulate foc [angle ia ib ic]. Ex: foc param" }, + terminal.AddCommand({ { "foc", "f", "Simulate foc [pole_pairs angle ia ib ic]. Ex: foc 7 30 1 2 3" }, [this](const infra::BoundedConstString& param) { this->terminal.ProcessResult(SimulateFoc(param)); } }); - terminal.AddCommand({ { "motor", "m", "Set motor parameters [poles [2; 16]]. Ex: motor 14" }, + terminal.AddCommand({ { "ident", "id", "Identify R, L and pole pairs. ident [probe_v%] [settle_ms] [pp_v%] [pp_revs] [pp_settle_ms]. Ex: ident wye 5 300 10 5 50" }, [this](const infra::BoundedConstString& param) { - this->terminal.ProcessResult(SetMotorParameters(param)); + this->terminal.ProcessResult(IdentifyElectricalParameters(param)); + } }); + + terminal.AddCommand({ { "align", "al", "Align rotor using identified pole pairs. align [v%] [samp_hz] [max_samp] [thresh_rad] [count]. Ex: align 20 1000 500 0.001 10" }, + [this](const infra::BoundedConstString& param) + { + this->terminal.ProcessResult(AlignMotor(param)); } }); terminal.AddCommand({ { "can_start", "cs", "Start CAN bus [bitrate [100000;1000000]] [test]. Ex: can_start 500000" }, @@ -297,25 +315,31 @@ namespace application { infra::Tokenizer tokenizer(param, ' '); - if (tokenizer.Size() != 4) + if (tokenizer.Size() != 5) return { error, "invalid number of arguments" }; - auto angle = ParseInput(tokenizer.Token(0), -360.0f, 360.0f); + auto pp = ParseInput(tokenizer.Token(0), 1, 8); + if (!pp.has_value()) + return { error, "invalid value for pole pairs. It should be an integer between 1 and 8." }; + + auto angle = ParseInput(tokenizer.Token(1), -360.0f, 360.0f); if (!angle.has_value()) return { error, "invalid value for angle. It should be a float between -360 and 360." }; - auto currentA = ParseInput(tokenizer.Token(1), -1000.0f, 1000.0f); + auto currentA = ParseInput(tokenizer.Token(2), -1000.0f, 1000.0f); if (!currentA.has_value()) return { error, "invalid value for phase A current. It should be a float between -1000 and 1000." }; - auto currentB = ParseInput(tokenizer.Token(2), -1000.0f, 1000.0f); + auto currentB = ParseInput(tokenizer.Token(3), -1000.0f, 1000.0f); if (!currentB.has_value()) return { error, "invalid value for phase B current. It should be a float between -1000 and 1000." }; - auto currentC = ParseInput(tokenizer.Token(3), -1000.0f, 1000.0f); + auto currentC = ParseInput(tokenizer.Token(4), -1000.0f, 1000.0f); if (!currentC.has_value()) return { error, "invalid value for phase C current. It should be a float between -1000 and 1000." }; + polePairs = static_cast(*pp); + foc.SetPolePairs(polePairs.value()); RunFocSimulation(foc::PhaseCurrents{ foc::Ampere{ *currentA }, foc::Ampere{ *currentB }, foc::Ampere{ *currentC } }, foc::Radians{ *angle * pi_div_180 }); return { success }; @@ -389,23 +413,168 @@ namespace application return { success }; } - TerminalInteractor::StatusWithMessage TerminalInteractor::SetMotorParameters(const infra::BoundedConstString& param) + TerminalInteractor::StatusWithMessage TerminalInteractor::IdentifyElectricalParameters(const infra::BoundedConstString& param) { infra::Tokenizer tokenizer(param, ' '); - if (tokenizer.Size() != 1) + if (tokenizer.Size() < 1 || tokenizer.Size() > 6) return { error, "invalid number of arguments" }; - auto poles = ParseInput(tokenizer.Token(0), 2, 16); - if (!poles.has_value()) - return { error, "invalid value for poles. It should be an integer between 2 and 16." }; + auto winding = ParseWinding(tokenizer.Token(0)); + if (!winding.has_value()) + return { error, "invalid winding. Use wye or delta." }; - polePairs = static_cast(*poles / 2); - foc.SetPolePairs(polePairs.value()); + services::ElectricalParametersIdentification::ResistanceAndInductanceConfig rlConfig; + rlConfig.windingConfig = *winding; + + if (tokenizer.Size() >= 2) + { + auto probeVoltage = ParseInput(tokenizer.Token(1), 1, 100); + if (!probeVoltage.has_value()) + return { error, "invalid value for probe voltage. It should be an integer between 1 and 100." }; + rlConfig.probeVoltagePercent = hal::Percent{ *probeVoltage }; + } + + if (tokenizer.Size() >= 3) + { + auto settlems = ParseInput(tokenizer.Token(2), 1u, 10000u); + if (!settlems.has_value()) + return { error, "invalid value for settle time. It should be an integer between 1 and 10000 ms." }; + rlConfig.settlePerLevel = std::chrono::milliseconds{ *settlems }; + } + + pendingPolePairsConfig = {}; + + if (tokenizer.Size() >= 4) + { + auto ppVoltage = ParseInput(tokenizer.Token(3), 1, 100); + if (!ppVoltage.has_value()) + return { error, "invalid value for pole-pairs test voltage. It should be an integer between 1 and 100." }; + pendingPolePairsConfig.testVoltagePercent = hal::Percent{ *ppVoltage }; + } + + if (tokenizer.Size() >= 5) + { + auto ppRevs = ParseInput(tokenizer.Token(4), 1u, 50u); + if (!ppRevs.has_value()) + return { error, "invalid value for electrical revolutions. It should be an integer between 1 and 50." }; + pendingPolePairsConfig.electricalRevolutions = static_cast(*ppRevs); + } + + if (tokenizer.Size() >= 6) + { + auto ppSettle = ParseInput(tokenizer.Token(5), 1u, 10000u); + if (!ppSettle.has_value()) + return { error, "invalid value for pole-pairs step settle time. It should be an integer between 1 and 10000 ms." }; + pendingPolePairsConfig.settleTimeBetweenSteps = std::chrono::milliseconds{ *ppSettle }; + } + + identificationResults.reset(); + + electricalIdent.EstimateResistanceAndInductance(rlConfig, [this](std::optional result) + { + if (!result.has_value()) + { + tracer.Trace() << " Identification failed: could not estimate R and L."; + return; + } + + identificationResults = IdentificationResults{ *result, 0 }; + RunPolePairEstimation(); + }); + + return { success }; + } + + TerminalInteractor::StatusWithMessage TerminalInteractor::AlignMotor(const infra::BoundedConstString& param) + { + if (!identificationResults.has_value() || identificationResults->polePairs == 0) + return { error, "no pole pairs identified. Run 'ident' first." }; + + infra::Tokenizer tokenizer(param, ' '); + + if (tokenizer.Size() > 5) + return { error, "invalid number of arguments" }; + + services::MotorAlignment::AlignmentConfig config; + + if (tokenizer.Size() >= 1) + { + auto voltage = ParseInput(tokenizer.Token(0), 1, 100); + if (!voltage.has_value()) + return { error, "invalid value for test voltage. It should be an integer between 1 and 100." }; + config.testVoltagePercent = hal::Percent{ *voltage }; + } + + if (tokenizer.Size() >= 2) + { + auto samplingHz = ParseInput(tokenizer.Token(1), 100u, 20000u); + if (!samplingHz.has_value()) + return { error, "invalid value for sampling frequency. It should be between 100 and 20000 Hz." }; + config.samplingFrequency = hal::Hertz{ *samplingHz }; + } + + if (tokenizer.Size() >= 3) + { + auto maxSamples = ParseInput(tokenizer.Token(2), 1u, 5000u); + if (!maxSamples.has_value()) + return { error, "invalid value for max samples. It should be between 1 and 5000." }; + config.maxSamples = static_cast(*maxSamples); + } + + if (tokenizer.Size() >= 4) + { + auto threshold = ParseInput(tokenizer.Token(3), 0.0001f, 1.0f); + if (!threshold.has_value()) + return { error, "invalid value for settled threshold. It should be between 0.0001 and 1.0 radians." }; + config.settledThreshold = foc::Radians{ *threshold }; + } + + if (tokenizer.Size() >= 5) + { + auto count = ParseInput(tokenizer.Token(4), 1u, 1000u); + if (!count.has_value()) + return { error, "invalid value for settled count. It should be between 1 and 1000." }; + config.settledCount = static_cast(*count); + } + + motorAlignment.ForceAlignment(identificationResults->polePairs, config, [this](std::optional offset) + { + if (!offset.has_value()) + tracer.Trace() << " Alignment failed: rotor did not converge."; + else + tracer.Trace() << " Alignment complete. Offset: " << offset->Value() << " radians."; + }); return { success }; } + void TerminalInteractor::RunPolePairEstimation() + { + electricalIdent.EstimateNumberOfPolePairs(pendingPolePairsConfig, [this](std::optional pp) + { + if (!pp.has_value()) + { + tracer.Trace() << " Identification failed: could not estimate pole pairs."; + identificationResults.reset(); + return; + } + + identificationResults->polePairs = *pp; + ReportIdentificationResults(); + }); + } + + void TerminalInteractor::ReportIdentificationResults() + { + tracer.Trace() << " Identification Results:"; + tracer.Trace() << " Resistance: " << identificationResults->rl.resistance.Value() << " Ohm"; + tracer.Trace() << " Inductance: " << identificationResults->rl.inductance.Value() << " mH"; + tracer.Trace() << " Inverter V offset: " << identificationResults->rl.inverterVoltageOffset.Value() << " V"; + tracer.Trace() << " Fit quality: " << identificationResults->rl.fitQuality; + tracer.Trace() << " Pole Pairs: " << identificationResults->polePairs; + } + void TerminalInteractor::StartAdc(PlatformFactory::SampleAndHold sampleAndHold) { currentSah_ = sampleAndHold; diff --git a/targets/hardware_test/components/Terminal.hpp b/targets/hardware_test/components/Terminal.hpp index 24ef83d0..96e3125a 100644 --- a/targets/hardware_test/components/Terminal.hpp +++ b/targets/hardware_test/components/Terminal.hpp @@ -3,6 +3,8 @@ #include "core/foc/implementations/FocSpeedImpl.hpp" #include "core/foc/interfaces/Driver.hpp" #include "core/platform_abstraction/PlatformFactory.hpp" +#include "core/services/alignment/MotorAlignmentImpl.hpp" +#include "core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp" #include "hal/interfaces/Eeprom.hpp" #include "hal/interfaces/Pwm.hpp" #include "infra/util/BoundedDeque.hpp" @@ -19,6 +21,13 @@ namespace application private: using StatusWithMessage = services::TerminalWithStorage::StatusWithMessage; + struct IdentificationResults + { + services::ElectricalParametersIdentification::ResistanceInductanceResult rl{ + foc::Ohm{ 0.0f }, foc::MilliHenry{ 0.0f }, foc::Volts{ 0.0f }, 0.0f }; + std::size_t polePairs{ 0 }; + }; + StatusWithMessage ConfigurePwm(const infra::BoundedConstString& param); StatusWithMessage ConfigureAdc(const infra::BoundedConstString& param); StatusWithMessage SimulateFoc(const infra::BoundedConstString& param); @@ -27,7 +36,8 @@ namespace application StatusWithMessage Stop(); void ProcessAdcSamples(); StatusWithMessage SetPwmDuty(const infra::BoundedConstString& param); - StatusWithMessage SetMotorParameters(const infra::BoundedConstString& param); + StatusWithMessage IdentifyElectricalParameters(const infra::BoundedConstString& param); + StatusWithMessage AlignMotor(const infra::BoundedConstString& param); StatusWithMessage CanStart(const infra::BoundedConstString& param); StatusWithMessage CanStop(); StatusWithMessage CanSend(const infra::BoundedConstString& param); @@ -39,6 +49,8 @@ namespace application StatusWithMessage GetResetCauseStatus(); StatusWithMessage GetFaultStatus(); StatusWithMessage ForceHardfault(); + void RunPolePairEstimation(); + void ReportIdentificationResults(); private: static constexpr std::size_t averageSampleSize = 100; @@ -70,5 +82,9 @@ namespace application hal::Eeprom& eeprom; std::array eepromBuffer{}; uint32_t eepromCurrentReadSize{ 0 }; + services::ElectricalParametersIdentificationImpl electricalIdent; + services::MotorAlignmentImpl motorAlignment; + std::optional identificationResults; + services::ElectricalParametersIdentification::PolePairsConfig pendingPolePairsConfig; }; } diff --git a/targets/hardware_test/components/test/TestTerminal.cpp b/targets/hardware_test/components/test/TestTerminal.cpp index d76dd602..1032241e 100644 --- a/targets/hardware_test/components/test/TestTerminal.cpp +++ b/targets/hardware_test/components/test/TestTerminal.cpp @@ -4,6 +4,7 @@ #include "hal/interfaces/test_doubles/SerialCommunicationMock.hpp" #include "infra/event/test_helper/EventDispatcherWithWeakPtrFixture.hpp" #include "infra/stream/test/StreamMock.hpp" +#include "infra/timer/test_helper/ClockFixture.hpp" #include "infra/util/test_helper/MockHelpers.hpp" #include "services/tracer/Tracer.hpp" #include "targets/hardware_test/components/Terminal.hpp" @@ -83,7 +84,7 @@ namespace class TestHardwareTerminal : public testing::Test - , public infra::EventDispatcherWithWeakPtrFixture + , public infra::ClockFixture { public: TestHardwareTerminal() @@ -121,7 +122,7 @@ namespace EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); } }; services::TerminalWithCommandsImpl::WithMaxQueueAndMaxHistory<128, 5> terminalWithCommands{ communication, tracer }; - services::TerminalWithStorage::WithMaxSize<20> terminal{ terminalWithCommands, tracer }; + services::TerminalWithStorage::WithMaxSize<22> terminal{ terminalWithCommands, tracer }; testing::StrictMock performanceTrackerMock; testing::StrictMock eepromMock; @@ -360,7 +361,7 @@ TEST_F(TestHardwareTerminal, pid_invalid_dq_ki) TEST_F(TestHardwareTerminal, foc_command) { - InvokeCommand("foc 45.0 1.5 2.0 2.5", [this]() + InvokeCommand("foc 7 45.0 1.5 2.0 2.5", [this]() { EXPECT_CALL(performanceTrackerMock, Start()); EXPECT_CALL(performanceTrackerMock, ElapsedCycles()).WillOnce(testing::Return(1000)); @@ -372,7 +373,7 @@ TEST_F(TestHardwareTerminal, foc_command) TEST_F(TestHardwareTerminal, foc_alias) { - InvokeCommand("f 90.0 2.0 3.0 4.0", [this]() + InvokeCommand("f 7 90.0 2.0 3.0 4.0", [this]() { EXPECT_CALL(performanceTrackerMock, Start()); EXPECT_CALL(performanceTrackerMock, ElapsedCycles()).WillOnce(testing::Return(1500)); @@ -400,9 +401,27 @@ TEST_F(TestHardwareTerminal, foc_invalid_argument_count) ExecuteAllActions(); } +TEST_F(TestHardwareTerminal, foc_invalid_pole_pairs) +{ + InvokeCommand("foc 0 45.0 1.5 2.0 2.5", [this]() + { + ::testing::InSequence _; + + std::string newline{ "\r\n" }; + std::string header{ "ERROR: " }; + std::string payload{ "invalid value for pole pairs. It should be an integer between 1 and 8." }; + + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); + }); + + ExecuteAllActions(); +} + TEST_F(TestHardwareTerminal, foc_invalid_angle) { - InvokeCommand("foc 400.0 1.5 2.0 2.5", [this]() + InvokeCommand("foc 7 400.0 1.5 2.0 2.5", [this]() { ::testing::InSequence _; @@ -420,7 +439,7 @@ TEST_F(TestHardwareTerminal, foc_invalid_angle) TEST_F(TestHardwareTerminal, foc_invalid_phase_a_current) { - InvokeCommand("foc 45.0 invalid 2.0 2.5", [this]() + InvokeCommand("foc 7 45.0 invalid 2.0 2.5", [this]() { ::testing::InSequence _; @@ -438,7 +457,7 @@ TEST_F(TestHardwareTerminal, foc_invalid_phase_a_current) TEST_F(TestHardwareTerminal, foc_invalid_phase_c_current) { - InvokeCommand("foc 45.0 1.5 2.0 1500.0", [this]() + InvokeCommand("foc 7 45.0 1.5 2.0 1500.0", [this]() { ::testing::InSequence _; @@ -456,7 +475,7 @@ TEST_F(TestHardwareTerminal, foc_invalid_phase_c_current) TEST_F(TestHardwareTerminal, foc_with_negative_angle) { - InvokeCommand("foc -90.0 1.5 2.0 2.5", [this]() + InvokeCommand("foc 7 -90.0 1.5 2.0 2.5", [this]() { EXPECT_CALL(performanceTrackerMock, Start()); EXPECT_CALL(performanceTrackerMock, ElapsedCycles()).WillOnce(testing::Return(1200)); @@ -468,7 +487,7 @@ TEST_F(TestHardwareTerminal, foc_with_negative_angle) TEST_F(TestHardwareTerminal, foc_with_negative_currents) { - InvokeCommand("foc 45.0 -1.5 -2.0 -2.5", [this]() + InvokeCommand("foc 7 45.0 -1.5 -2.0 -2.5", [this]() { EXPECT_CALL(performanceTrackerMock, Start()); EXPECT_CALL(performanceTrackerMock, ElapsedCycles()).WillOnce(testing::Return(1100)); @@ -480,7 +499,7 @@ TEST_F(TestHardwareTerminal, foc_with_negative_currents) TEST_F(TestHardwareTerminal, foc_simulation_output_format) { - InvokeCommand("foc 0.0 0.0 0.0 0.0", [this]() + InvokeCommand("foc 7 0.0 0.0 0.0 0.0", [this]() { ::testing::InSequence _; @@ -495,7 +514,7 @@ TEST_F(TestHardwareTerminal, foc_simulation_output_format) TEST_F(TestHardwareTerminal, foc_with_maximum_angle) { - InvokeCommand("foc 360.0 1.0 2.0 3.0", [this]() + InvokeCommand("foc 7 360.0 1.0 2.0 3.0", [this]() { EXPECT_CALL(performanceTrackerMock, Start()); EXPECT_CALL(performanceTrackerMock, ElapsedCycles()).WillOnce(testing::Return(1300)); @@ -507,7 +526,7 @@ TEST_F(TestHardwareTerminal, foc_with_maximum_angle) TEST_F(TestHardwareTerminal, foc_with_minimum_angle) { - InvokeCommand("foc -360.0 1.0 2.0 3.0", [this]() + InvokeCommand("foc 7 -360.0 1.0 2.0 3.0", [this]() { EXPECT_CALL(performanceTrackerMock, Start()); EXPECT_CALL(performanceTrackerMock, ElapsedCycles()).WillOnce(testing::Return(1250)); @@ -519,7 +538,7 @@ TEST_F(TestHardwareTerminal, foc_with_minimum_angle) TEST_F(TestHardwareTerminal, foc_with_maximum_currents) { - InvokeCommand("foc 45.0 1000.0 1000.0 1000.0", [this]() + InvokeCommand("foc 7 45.0 1000.0 1000.0 1000.0", [this]() { EXPECT_CALL(performanceTrackerMock, Start()); EXPECT_CALL(performanceTrackerMock, ElapsedCycles()).WillOnce(testing::Return(1400)); @@ -531,7 +550,7 @@ TEST_F(TestHardwareTerminal, foc_with_maximum_currents) TEST_F(TestHardwareTerminal, foc_with_minimum_currents) { - InvokeCommand("foc 45.0 -1000.0 -1000.0 -1000.0", [this]() + InvokeCommand("foc 7 45.0 -1000.0 -1000.0 -1000.0", [this]() { EXPECT_CALL(performanceTrackerMock, Start()); EXPECT_CALL(performanceTrackerMock, ElapsedCycles()).WillOnce(testing::Return(1350)); @@ -543,7 +562,7 @@ TEST_F(TestHardwareTerminal, foc_with_minimum_currents) TEST_F(TestHardwareTerminal, foc_multiple_simulations) { - InvokeCommand("foc 30.0 1.0 2.0 3.0", [this]() + InvokeCommand("foc 7 30.0 1.0 2.0 3.0", [this]() { EXPECT_CALL(performanceTrackerMock, Start()); EXPECT_CALL(performanceTrackerMock, ElapsedCycles()).WillOnce(testing::Return(1050)); @@ -552,7 +571,7 @@ TEST_F(TestHardwareTerminal, foc_multiple_simulations) ExecuteAllActions(); - InvokeCommand("foc 60.0 2.0 3.0 4.0", [this]() + InvokeCommand("foc 7 60.0 2.0 3.0 4.0", [this]() { EXPECT_CALL(performanceTrackerMock, Start()); EXPECT_CALL(performanceTrackerMock, ElapsedCycles()).WillOnce(testing::Return(1150)); @@ -564,7 +583,7 @@ TEST_F(TestHardwareTerminal, foc_multiple_simulations) TEST_F(TestHardwareTerminal, foc_with_fractional_values) { - InvokeCommand("foc 45.5 1.234 2.567 3.891", [this]() + InvokeCommand("foc 7 45.5 1.234 2.567 3.891", [this]() { EXPECT_CALL(performanceTrackerMock, Start()); EXPECT_CALL(performanceTrackerMock, ElapsedCycles()).WillOnce(testing::Return(1075)); @@ -574,29 +593,27 @@ TEST_F(TestHardwareTerminal, foc_with_fractional_values) ExecuteAllActions(); } -TEST_F(TestHardwareTerminal, motor_command) +TEST_F(TestHardwareTerminal, ident_invalid_winding) { - InvokeCommand("motor 14", [this]() + InvokeCommand("ident foo", [this]() { - EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); - }); + ::testing::InSequence _; - ExecuteAllActions(); -} + std::string newline{ "\r\n" }; + std::string header{ "ERROR: " }; + std::string payload{ "invalid winding. Use wye or delta." }; -TEST_F(TestHardwareTerminal, motor_alias) -{ - InvokeCommand("m 8", [this]() - { - EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); }); ExecuteAllActions(); } -TEST_F(TestHardwareTerminal, motor_invalid_argument_count) +TEST_F(TestHardwareTerminal, ident_invalid_too_many_args) { - InvokeCommand("motor 14 8", [this]() + InvokeCommand("ident wye 1 2 3 4 5 6", [this]() { ::testing::InSequence _; @@ -612,15 +629,15 @@ TEST_F(TestHardwareTerminal, motor_invalid_argument_count) ExecuteAllActions(); } -TEST_F(TestHardwareTerminal, motor_invalid_poles_too_low) +TEST_F(TestHardwareTerminal, ident_invalid_probe_voltage_out_of_range) { - InvokeCommand("motor 1", [this]() + InvokeCommand("ident wye 250", [this]() { ::testing::InSequence _; std::string newline{ "\r\n" }; std::string header{ "ERROR: " }; - std::string payload{ "invalid value for poles. It should be an integer between 2 and 16." }; + std::string payload{ "invalid value for probe voltage. It should be an integer between 1 and 100." }; EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); @@ -630,57 +647,59 @@ TEST_F(TestHardwareTerminal, motor_invalid_poles_too_low) ExecuteAllActions(); } -TEST_F(TestHardwareTerminal, motor_invalid_poles_too_high) +TEST_F(TestHardwareTerminal, ident_wye_starts_identification) { - InvokeCommand("motor 18", [this]() + InvokeCommand("ident wye", [this]() { - ::testing::InSequence _; - - std::string newline{ "\r\n" }; - std::string header{ "ERROR: " }; - std::string payload{ "invalid value for poles. It should be an integer between 2 and 16." }; - - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); + EXPECT_CALL(platformFactoryMock, ThreePhasePwmOutput(testing::_)); }); ExecuteAllActions(); } -TEST_F(TestHardwareTerminal, motor_invalid_poles_not_a_number) +TEST_F(TestHardwareTerminal, ident_delta_starts_identification) { - InvokeCommand("motor invalid", [this]() + InvokeCommand("ident delta", [this]() { - ::testing::InSequence _; + EXPECT_CALL(platformFactoryMock, ThreePhasePwmOutput(testing::_)); + }); - std::string newline{ "\r\n" }; - std::string header{ "ERROR: " }; - std::string payload{ "invalid value for poles. It should be an integer between 2 and 16." }; + ExecuteAllActions(); +} - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); +TEST_F(TestHardwareTerminal, ident_alias) +{ + InvokeCommand("id wye", [this]() + { + EXPECT_CALL(platformFactoryMock, ThreePhasePwmOutput(testing::_)); }); ExecuteAllActions(); } -TEST_F(TestHardwareTerminal, motor_minimum_valid_poles) +TEST_F(TestHardwareTerminal, ident_with_all_optional_args) { - InvokeCommand("motor 2", [this]() + InvokeCommand("ident wye 15 2000 10 5 50", [this]() { - EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + EXPECT_CALL(platformFactoryMock, ThreePhasePwmOutput(testing::_)); }); ExecuteAllActions(); } -TEST_F(TestHardwareTerminal, motor_maximum_valid_poles) +TEST_F(TestHardwareTerminal, align_fails_without_identification) { - InvokeCommand("motor 16", [this]() + InvokeCommand("align", [this]() { - EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + ::testing::InSequence _; + + std::string newline{ "\r\n" }; + std::string header{ "ERROR: " }; + std::string payload{ "no pole pairs identified. Run 'ident' first." }; + + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); }); ExecuteAllActions(); @@ -976,7 +995,7 @@ TEST_F(TestHardwareTerminal, duty_invalid_phase_c) TEST_F(TestHardwareTerminal, foc_invalid_phase_b_current) { - InvokeCommand("foc 45.0 1.5 invalid 2.5", [this]() + InvokeCommand("foc 7 45.0 1.5 invalid 2.5", [this]() { ::testing::InSequence _; @@ -994,7 +1013,7 @@ TEST_F(TestHardwareTerminal, foc_invalid_phase_b_current) TEST_F(TestHardwareTerminal, foc_invalid_angle_non_numeric) { - InvokeCommand("foc invalid 1.5 2.0 2.5", [this]() + InvokeCommand("foc 7 invalid 1.5 2.0 2.5", [this]() { ::testing::InSequence _; diff --git a/targets/hardware_test/instantiations/Logic.hpp b/targets/hardware_test/instantiations/Logic.hpp index ee81a5e8..df3c5aab 100644 --- a/targets/hardware_test/instantiations/Logic.hpp +++ b/targets/hardware_test/instantiations/Logic.hpp @@ -13,7 +13,7 @@ namespace application explicit Logic(application::PlatformFactory& hardware); private: - services::TerminalWithBanner::WithMaxSize<20> terminalWithStorage; + services::TerminalWithBanner::WithMaxSize<22> terminalWithStorage; application::TerminalInteractor terminal; services::DebugLed debugLed; }; From dd5a2340aca09d7b2d4e172af78faee6c42ef498 Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Sat, 11 Jul 2026 20:41:28 +0000 Subject: [PATCH 15/28] chore(board): recalibrate E-FOC-HARDWARE voltage scaling Update voltageToVolts from 18.433 to 21.25 and drop the two bring-up threshold static_asserts that no longer match the recalibrated scaling. --- .../motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp b/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp index 2f09cc42..90a6a766 100644 --- a/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp +++ b/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp @@ -9,7 +9,7 @@ namespace application // current sensor gain). Verified correct as initial bring-up defaults; update when schematic differs. struct BoardCharacteristics { - static constexpr float voltageToVolts{ 18.433f }; + static constexpr float voltageToVolts{ 21.25f }; static constexpr float overvoltageThresholdVolts{ 58.0f }; static constexpr float voltageToCurrent{ 5.0f }; @@ -41,7 +41,4 @@ namespace application return static_cast((overcurrentThresholdAmps / maxCurrentAmps) * (adcResolution - 1.0f)); } }; - - static_assert(BoardCharacteristics::OvervoltageThresholdCounts(3.3f, 4096.0f) == 3904u, "E-FOC-HARDWARE overvoltage threshold mismatch"); - static_assert(BoardCharacteristics::OvercurrentThresholdCounts(4096.0f) == 3276u, "E-FOC-HARDWARE overcurrent threshold mismatch"); } From 649d550e06a8decff6d34da9d679cdcb86c8a73a Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Sat, 11 Jul 2026 20:41:35 +0000 Subject: [PATCH 16/28] chore(agents): bump agent model versions to Claude Opus 4.8 --- .github/agents/executor.agent.md | 2 +- .github/agents/planner.agent.md | 2 +- .github/agents/reviewer.agent.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/agents/executor.agent.md b/.github/agents/executor.agent.md index f70657cf..0fa8cb9a 100644 --- a/.github/agents/executor.agent.md +++ b/.github/agents/executor.agent.md @@ -1,7 +1,7 @@ --- description: "Use when implementing code changes in e-foc. Writes production code and tests following all project constraints: no heap allocation, bounded containers, real-time determinism, FOC theory correctness, motor control best practices, SOLID principles, and documentation alignment." tools: [read, edit, search, execute, todo] -model: "Claude Sonnet 4.6" +model: "Claude Opus 4.8" handoffs: - label: "Review Changes" agent: reviewer diff --git a/.github/agents/planner.agent.md b/.github/agents/planner.agent.md index 4df7289a..7e2082b9 100644 --- a/.github/agents/planner.agent.md +++ b/.github/agents/planner.agent.md @@ -1,7 +1,7 @@ --- description: "Use when a detailed implementation plan is needed before writing code in e-foc. Produces structured, actionable plans that follow all e-foc constraints: no heap allocation, real-time determinism, FOC theory correctness, motor control best practices, SOLID principles, and documentation alignment." tools: [execute/getTerminalOutput, execute/killTerminal, execute/sendToTerminal, execute/runTask, execute/createAndRunTask, execute/runTests, execute/testFailure, execute/runNotebookCell, execute/runInTerminal, read/terminalSelection, read/terminalLastCommand, read/getTaskOutput, read/getNotebookSummary, read/problems, read/readFile, read/viewImage, read/readNotebookCellOutput, search/changes, search/codebase, search/fileSearch, search/listDirectory, search/textSearch, search/usages, web/fetch, web/githubRepo, web/githubTextSearch] -model: "Claude Opus 4.7" +model: "Claude Opus 4.8" handoffs: - label: "Start Implementation" agent: executor diff --git a/.github/agents/reviewer.agent.md b/.github/agents/reviewer.agent.md index ba48be06..534e134a 100644 --- a/.github/agents/reviewer.agent.md +++ b/.github/agents/reviewer.agent.md @@ -1,7 +1,7 @@ --- description: "Use when reviewing code changes in e-foc. Performs structured code review against all project standards: memory safety (no heap), real-time determinism, FOC theory correctness, motor control best practices, embedded optimizations, documentation alignment, SOLID principles, and test coverage." tools: [read, search] -model: "GPT-5.4" +model: "Claude Opus 4.8" handoffs: - label: "Fix Issues" agent: executor From b83bde093d41a93fe169f308cdd3d3b1bddfa37e Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Sat, 11 Jul 2026 20:57:52 +0000 Subject: [PATCH 17/28] add instructions to save token usage --- .github/copilot-instructions.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2d46c8d3..9e1f0a71 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -75,3 +75,23 @@ This file is a concise, task-oriented guide for AI coding agents to be immediate - Target cycle budgets: FOC loop should complete in <400 cycles at 120 MHz for 20 kHz control rate. If any section appears incomplete or you want deeper coverage (build-on-target, hardware flashing steps, or CI specifics), tell me which area to expand. + +11) Response style (mandatory) +- Be maximally concise. No preamble, no recap of the request, no closing summary. +- Never restate or re-print code you haven't changed. Show only the modified + lines/functions, or a diff. Do not echo entire files back. +- When editing, make targeted edits. Never rewrite a whole file to change a few lines. +- Explanations only when asked, and max 2-3 sentences. Prefer code over prose. +- No bullet-point summaries of "what I did" after edits — the diff speaks for itself. +- Do not add code comments explaining obvious things; comment only non-trivial + invariants (fixed-point ranges, ISR constraints). +- One clarifying question max; otherwise proceed with the most reasonable assumption + and state it in one line. + +12) Context discipline +- Do not scan the whole workspace unless explicitly asked. Read only the files + named in the prompt plus their direct interfaces in core/*/interfaces/. +- Do not open infra/embedded-infra-lib/ or infra/numerical-toolbox/ sources unless + the task touches them — assume BoundedVector/BoundedString semantics are known. +- Prefer grep/symbol search over reading entire files; read the smallest relevant range. +- Do not re-read files already in context this session. From b2438e014c6dc4f867c713a25d3d0bc910fc59c2 Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Sun, 12 Jul 2026 10:32:14 +0000 Subject: [PATCH 18/28] chore(agents): fix routing model, dedup constraints, fix Clarke label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Orchestrator: convert to triage advisor (no Agent tool, model→sonnet); subagents cannot spawn subagents so routing was broken - Planner/executor: remove clarifying-questions instructions; subagents have no interactive channel — state assumptions and proceed instead - Planner: add Write tool, write plan to .claude/plans/.md for executor handoff; strip duplicated constraints checklist (→ CLAUDE.md ref) - Executor: strip all constraint sections duplicated from CLAUDE.md; add Grep/Glob tools; add plan-file reading step - Reviewer: model→sonnet; add Grep/Glob; diff-first approach; slim 15-section checklist to 10 by referencing CLAUDE.md; merge duplicate heap/real-time items from §1+§2 - CLAUDE.md: fix Clarke label power-invariant→amplitude-invariant (2/3 scaling is amplitude-invariant; power-invariant uses √(2/3)); add agent workflow note about clarifying before dispatch Co-Authored-By: Claude Sonnet 4.6 --- .claude/agents/executor.md | 162 +++------------------- .claude/agents/orchestrator.md | 63 +++++---- .claude/agents/planner.md | 95 ++----------- .claude/agents/reviewer.md | 238 +++++++++++++-------------------- CLAUDE.md | 12 +- 5 files changed, 164 insertions(+), 406 deletions(-) diff --git a/.claude/agents/executor.md b/.claude/agents/executor.md index a30f6794..76672714 100644 --- a/.claude/agents/executor.md +++ b/.claude/agents/executor.md @@ -2,12 +2,7 @@ name: executor description: Use when implementing code changes in e-foc. Writes production code and tests following all project constraints: no heap allocation in embedded code, bounded containers, real-time determinism, FOC theory correctness, motor control best practices, SOLID principles, and documentation alignment. model: opus -tools: - - Read - - Edit - - Write - - Bash - - TodoWrite +tools: Read, Edit, Write, Bash, TodoWrite, Grep, Glob --- You are the executor agent for the **e-foc** project — a Field-Oriented Control (FOC) implementation for BLDC/PMSM motors targeting resource-constrained embedded microcontrollers. You are an expert in: @@ -18,51 +13,26 @@ You are the executor agent for the **e-foc** project — a Field-Oriented Contro - **Numerical methods**: fixed-point arithmetic, trigonometric approximations, filter design for current sensing - **Embedded optimization**: `#pragma GCC optimize`, `OPTIMIZE_FOR_SPEED`, SIMD, inlining strategies -You implement code changes strictly following the project's conventions. +All project constraints (memory, real-time, FOC theory, naming, brace style, design principles, error handling, testing rules) are in **CLAUDE.md** — read and follow them exactly. -## Implementation Rules +## Before You Start -Follow these rules for EVERY change. Violations are unacceptable in this codebase. +If requirements are ambiguous, **state your assumptions explicitly at the top of your output** and proceed. Do not halt to ask questions — the main agent has already clarified with the user before dispatching you. -### Memory — Absolute Rules for Embedded/Runtime Code +If a plan file path is provided, **read it first** from `.claude/plans/.md`. -**Scope**: These rules apply to `core/foc/`, `core/platform_abstraction/`, `core/state_machine/`, `targets/`, and all ISR-reachable paths. Host-side tools (`tools/`), simulators, and test code may use normal STL/heap patterns. +## Hot-Path Code Pattern -**FORBIDDEN** in embedded/runtime code — never use: -- `new`, `delete`, `malloc`, `free` -- `std::make_unique`, `std::make_shared` -- `std::vector`, `std::string`, `std::deque`, `std::list`, `std::map`, `std::set` +Every `.cpp` or `.hpp` file with hot-path code must include at the top: -**REQUIRED** — use these instead: -- `infra::BoundedVector::WithMaxSize` instead of `std::vector` -- `infra::BoundedString::WithStorage` instead of `std::string` -- `infra::BoundedDeque::WithMaxSize` instead of `std::deque` -- `infra::BoundedList::WithMaxSize` instead of `std::list` -- `std::array` for fixed-size arrays -- Stack allocation and static allocation only -- No recursion (stack must be predictable) -- **No `virtual ~Dtor() = 0`** (pure virtual destructors) — adds flash/RAM overhead. Default: **no pure virtual destructor**. Only add one when there is a proven, documented need. - -### Real-Time — FOC Loop Rules - -The `Calculate()` method runs in the FOC interrupt at 20 kHz. Every cycle counts. - -**FORBIDDEN in the hot path:** -- Virtual dispatch — use concrete types or templates -- Heap allocation — already forbidden -- Blocking calls, `sleep`, busy-wait -- Unguarded trigonometric functions — prefer lookup tables or `TrigonometricFunctions` (from `TrigonometricImpl.hpp`) - -**REQUIRED for hot-path methods:** - -1. File-level pragma (in every `.cpp` or `.hpp` with hot-path code): ```cpp #if defined(__GNUC__) || defined(__clang__) #pragma GCC optimize("O3", "fast-math") #endif ``` -2. `OPTIMIZE_FOR_SPEED` on `Calculate()`, `Compute()`, and other hot-path methods: +Apply `OPTIMIZE_FOR_SPEED` to `Calculate()`, `Compute()`, and other hot-path methods: + ```cpp #include "numerical/math/CompilerOptimizations.hpp" @@ -73,81 +43,7 @@ OPTIMIZE_FOR_SPEED PhasePwmDutyCycles FocSpeedImpl::Calculate( } ``` -Target: FOC loop completes in <400 cycles at 120 MHz for 20 kHz control rate. - -### FOC Theory — Correctness Rules - -When implementing FOC transforms or control loops: - -- **Clarke transform** (3-phase → α-β): `Iα = (2/3)·(Ia - (Ib+Ic)/2)`, `Iβ = (Ib - Ic)/√3` (power-invariant; all 3 phases used) -- **Park transform** (α-β → d-q): `Id = Iα·cos(θ) + Iβ·sin(θ)`, `Iq = -Iα·sin(θ) + Iβ·cos(θ)` -- **Inverse Park** (d-q → α-β): Reverse transformation using same rotor angle -- **SVM**: Correct sector detection (0–5), duty cycle computation, and null vector distribution -- **Electrical angle**: Always multiply mechanical angle by pole pairs — `θe = θm · P` -- **Anti-windup**: Implement clamping or back-calculation on all PID integrators -- **Decoupling**: Add ω·Ld·Iq feedforward to Vd, subtract ω·Lq·Id from Vq where appropriate -- **Unit types**: Use the type aliases: `Ampere`, `Radians`, `Volts`, `RevPerMinute`, `PhasePwmDutyCycles`, `PhaseCurrents` - -Reuse `TransformsClarkePark` and `SpaceVectorModulation` from `core/foc/implementations/` when possible. Do not reimplement existing transforms. - -### Naming Conventions - -- **Classes**: `PascalCase` — `FocSpeedImpl`, `TransformsClarkePark`, `SpaceVectorModulation` -- **Methods**: `PascalCase` — `Calculate()`, `SetPoint()`, `Enable()` -- **Member variables**: `camelCase` — `polePairs`, `currentTunings`, `positionPid` -- **Namespaces**: lowercase — `foc`, `hardware` -- **Unit type aliases**: explicit units (`Ampere`, `Radians`, `Volts`) — not plain `float` - -### Documentation-First — Behavioral Changes - -**Before implementing any change that alters a component's observable behavior**, verify that the corresponding architecture or design document in `documentation/` reflects the new behavior. **Code must follow documentation, not the opposite.** - -- If an architecture/design document already exists for the affected component, update it BEFORE writing production code. -- If no such document exists yet, create one using `documentation/templates/architecture.md` or `documentation/templates/design.md` as a template BEFORE writing production code. -- All visuals in documentation files must be Mermaid code blocks or ASCII art — external image references (`![alt](path)`) are **not allowed**. - -### Brace Style — Allman, 4-Space Indent - -```cpp -namespace foc -{ - class FocSpeedImpl - : public FocSpeed - { - public: - void SetPoint(RevPerMinute setPoint) override; - PhasePwmDutyCycles Calculate(const PhaseCurrents& currentPhases, Radians& position) override; - - private: - controllers::PidController speedPid; - std::size_t polePairs{ 0 }; - }; -} -``` - -- Prefer `{}` initialization over `()` for all variables and member data - -### Design Principles - -- **Single Responsibility**: One class = one control concern (current loop, speed loop, position loop) -- **Dependency Injection**: All hardware (ADC, PWM, encoder) injected via `PlatformFactory` / constructor -- **Interface-driven**: New FOC modes implement the appropriate abstract interface (`FocTorque`, `FocSpeed`, `FocPosition`) -- **Small Functions**: ~30 lines max (hard limit ~50). Extract named helpers. -- **DRY**: Reuse `infra/numerical-toolbox/` PID, filters, and transforms — do not duplicate -- **`const` correctness**: Mark all non-mutating methods `const` -- **`constexpr`**: Use for motor constants and lookup tables - -### Error Handling - -- `std::optional` for functions that may not return a value -- Return error codes or status enums in embedded/runtime code — **NO EXCEPTIONS** (host tools/tests may use exceptions where appropriate) -- `assert()` or `really_assert()` for precondition checks in debug builds - -### Testing - -Test files live in `core/foc/implementations/test/Test{ComponentName}.cpp`. - -Use `TEST_F` for fixture tests with shared setup: +## Test Code Pattern ```cpp #include "core/foc/implementations/TransformsClarkePark.hpp" @@ -168,34 +64,20 @@ TEST_F(TestTransformsClarkePark, clarke_transform_produces_correct_alpha_beta) } ``` -Use `TYPED_TEST` if code is templated across numeric types. Plain `TEST()` is acceptable for simple, stateless cases when it matches existing repository patterns. - -Rules: -- Use `testing::StrictMock` for ALL mock instances — `NiceMock` and `NaggyMock` are **FORBIDDEN** -- Verify transform correctness against known mathematical reference values -- Test PID clamping and anti-windup behavior -- Test SVM duty cycles for all 6 sectors and edge cases -- Host simulation models in `tools/simulator/` for integration-level tests -- Hardware stubs in `targets/platform_implementations/host/` for unit tests that need hardware interfaces -- Use `EXPECT_NEAR` with explicit tolerance for floating-point assertions - ---- - ## Implementation Workflow -Follow the TDD Red-Green-Refactor cycle. **Ask clarifying questions before writing any code.** - -1. **Clarify requirements** — Ask focused questions: expected inputs/outputs, use cases, edge cases, control mode (torque/speed/position), hardware target, acceptance criteria. -2. **Read the plan or task** carefully. Understand the FOC theory context. -3. **Search for existing patterns** in `core/foc/` — follow them exactly -4. **Reuse `infra/numerical-toolbox/` algorithms** (PID, filters) rather than reimplementing -5. **Red** — Write failing tests first in `core/foc/implementations/test/Test{ComponentName}.cpp` for every behavior. -6. **Green** — Implement the minimum production code needed to make all tests pass, one file at a time. -7. **Add `#pragma GCC optimize` and `OPTIMIZE_FOR_SPEED`** to all hot-path code -8. **Refactor** — Clean up while keeping all tests green. -9. **Update `CMakeLists.txt`** if new files were added -10. **Update documentation** in `documentation/` for every algorithm or procedure added or changed -11. **Build and test** (host): `cmake --build --preset host-Debug` and `ctest --preset host` +Follow TDD Red-Green-Refactor: + +1. **Read the plan** from `.claude/plans/.md` if provided. Understand FOC theory context. +2. **Search for existing patterns** in `core/foc/` — follow them exactly. +3. **Reuse `infra/numerical-toolbox/` algorithms** (PID, filters) rather than reimplementing. +4. **Red** — Write failing tests first in `core/foc/implementations/test/Test{ComponentName}.cpp` for every behavior. +5. **Green** — Implement the minimum production code needed to make all tests pass, one file at a time. +6. **Add `#pragma GCC optimize` and `OPTIMIZE_FOR_SPEED`** to all hot-path code. +7. **Refactor** — Clean up while keeping all tests green. +8. **Update `CMakeLists.txt`** if new files were added. +9. **Update documentation** in `documentation/` for every algorithm or procedure added or changed. +10. **Build and test** (host): `cmake --build --preset host-Debug` and `ctest --preset host`. ## What NOT to Do diff --git a/.claude/agents/orchestrator.md b/.claude/agents/orchestrator.md index a65bd3ad..fb186922 100644 --- a/.claude/agents/orchestrator.md +++ b/.claude/agents/orchestrator.md @@ -1,46 +1,43 @@ --- name: orchestrator -description: Use when starting a new development task in e-foc. Triages requests and routes to the appropriate specialist agent: planner for design, executor for implementation, or reviewer for code review. This agent should be invoked first for any non-trivial task. -model: opus -tools: - - Read - - Bash - - WebSearch - - Agent +description: Use when starting a new development task in e-foc. Triages requests and returns a routing recommendation that the main agent acts on. Does NOT spawn other agents — only the main conversation thread can do that. +model: sonnet +tools: Read, Bash, WebSearch --- -You are the orchestrator agent for the **e-foc** project — a Field-Oriented Control (FOC) implementation for BLDC/PMSM motors with strict real-time and memory constraints targeting embedded microcontrollers. You are an expert in field-oriented control, motor control engineering, mathematical and numerical methods, and performance optimization for embedded devices. +You are the orchestrator agent for the **e-foc** project — a Field-Oriented Control (FOC) implementation for BLDC/PMSM motors with strict real-time and memory constraints targeting embedded microcontrollers. ## Your Role -You triage incoming development requests and route them to the right specialist agent. You do NOT implement code or produce detailed plans yourself. +Triage the incoming request and return a structured routing recommendation. You do NOT implement code, produce detailed plans, or spawn other agents. The main agent acts on your report. + +If requirements are ambiguous, state your assumptions explicitly at the top of your output and proceed. ## Workflow -1. **Understand the request**: Read the user's task description carefully. **Ask clarifying questions as needed** before routing. At minimum clarify: specific use cases and expected behavior, control mode (torque/speed/position), hardware target (EK-TM4C1294XL, STM32, or simulation), timing constraints, edge cases that must be handled, and acceptance criteria. -2. **Gather context**: Use Read and Bash tools to identify which modules, files, and patterns are relevant. Check the repository structure and existing code to understand the scope. -3. **Summarize scope**: Provide a brief summary of what the task involves, which modules are affected, the FOC/motor-control theory involved, and the recommended approach. -4. **Route to specialist**: Recommend the appropriate sub-agent: - - **planner** — For complex tasks, new FOC algorithms, architectural changes, new motor control modes, or multi-file changes that benefit from upfront design - - **executor** — For straightforward bug fixes, small changes, or tasks with a clear existing plan - - **reviewer** — For reviewing existing code or recent changes against project standards - -## Context to Gather Before Routing - -- Which layer does this affect? - - `core/foc/interfaces/` — abstract FOC interfaces (`FocBase`, `FocTorque`, `FocSpeed`, `FocPosition`) - - `core/foc/implementations/` — Clarke/Park transforms, SVM, current/speed/position control loops - - `core/foc/instantiations/` — concrete wiring of FOC components for specific targets - - `core/platform_abstraction/` — platform abstraction adapters (`PlatformFactory` interface, ADC, encoder, CAN adapters) - - `targets/` — platform implementations (host, ti, st) and application entry points +1. **Gather context**: Use Read and Bash to identify which modules, files, and patterns are relevant. Check repository structure and existing code to scope the work. +2. **Summarize scope**: Which layers are affected, what FOC/motor-control theory is involved, rough file count. +3. **End your report with a routing recommendation**: + - **planner** — complex tasks, new FOC algorithms, architectural changes, multi-file changes that benefit from upfront design + - **executor** — straightforward bug fixes, small changes, or tasks with a clear existing plan + - **reviewer** — reviewing existing code or recent changes against project standards + +## Context to Gather + +- Which layer is affected? + - `core/foc/interfaces/` — abstract FOC interfaces + - `core/foc/implementations/` — Clarke/Park, SVM, control loops + - `core/foc/instantiations/` — concrete target wiring + - `core/platform_abstraction/` — `PlatformFactory`, ADC, encoder, CAN adapters + - `targets/` — platform implementations and application entry points - `core/services/` — application-level services - - `tools/simulator/` — host simulation models for validation - - `infra/numerical-toolbox/` — PID, filters, fixed-point math used by FOC -- What is the control mode? Torque / speed / position loop -- What is the timing budget? (FOC loop target: <400 cycles at 120 MHz for 20 kHz rate) -- What hardware target? (EK-TM4C1294XL, STM32, or host simulation) -- Are existing tests or simulation models affected? -- Does this require documentation updates in `documentation/`? + - `tools/simulator/` — host simulation models + - `infra/numerical-toolbox/` — PID, filters, fixed-point math +- Control mode: torque / speed / position loop +- Hardware target: EK-TM4C1294XL, STM32, or host simulation +- Timing budget: FOC loop target <400 cycles at 120 MHz for 20 kHz rate +- Tests or simulation models affected? +- Documentation updates needed in `documentation/`? ## Project References @@ -48,4 +45,4 @@ You triage incoming development requests and route them to the right specialist - FOC theory: [`documentation/theory/foc.md`](../../documentation/theory/foc.md) - Performance optimization: [`documentation/performance-optimization/README.md`](../../documentation/performance-optimization/README.md) - Hardware factory: [`core/platform_abstraction/PlatformFactory.hpp`](../../core/platform_abstraction/PlatformFactory.hpp) -- Numerical toolbox guidelines: [`infra/numerical-toolbox/.github/copilot-instructions.md`](../../infra/numerical-toolbox/.github/copilot-instructions.md) +- Numerical toolbox: [`infra/numerical-toolbox/`](../../infra/numerical-toolbox/) diff --git a/.claude/agents/planner.md b/.claude/agents/planner.md index 3aaf641a..e622a5ad 100644 --- a/.claude/agents/planner.md +++ b/.claude/agents/planner.md @@ -1,12 +1,8 @@ --- name: planner -description: Use when a detailed implementation plan is needed before writing code in e-foc. Produces structured, actionable plans that follow all e-foc constraints: no heap allocation, real-time determinism, FOC theory correctness, motor control best practices, SOLID principles, and documentation alignment. Does NOT write or edit code. +description: Use when a detailed implementation plan is needed before writing code in e-foc. Produces structured, actionable plans that follow all e-foc constraints: no heap allocation, real-time determinism, FOC theory correctness, motor control best practices, SOLID principles, and documentation alignment. Does NOT write or edit code. Writes the final plan to .claude/plans/.md. model: opus -tools: - - Read - - Bash - - WebSearch - - WebFetch +tools: Read, Bash, WebSearch, WebFetch, Write --- You are the planner agent for the **e-foc** project — a Field-Oriented Control (FOC) implementation for BLDC/PMSM motors targeting resource-constrained embedded microcontrollers. You are an expert in: @@ -17,21 +13,13 @@ You are the planner agent for the **e-foc** project — a Field-Oriented Control - **Numerical methods**: fixed-point arithmetic, trigonometric approximations, filter design for current sensing - **Embedded device optimization**: ARM Cortex-M, GCC pragmas, SIMD, pipeline-friendly code -You produce detailed, actionable implementation plans. You **MUST NOT write or edit code** directly. +You produce detailed, actionable implementation plans. You **MUST NOT write or edit production or test code** directly. ## Planning Process -### 0. Clarify Requirements First +### 0. Handle Ambiguous Requirements -**Before researching or planning**, ask the user targeted questions to clarify: -- Expected use cases, inputs, and outputs for the new feature or change -- Edge cases that must be handled (zero current, maximum speed, angle wraparound, fault conditions) -- Control mode: torque / speed / position loop -- Hardware target (EK-TM4C1294XL, STM32, or host simulation only) -- Real-time timing requirements and whether this touches the FOC hot path -- What "done" looks like — explicit acceptance criteria - -Do not begin the research or planning phase until the requirements are sufficiently clear. +If requirements are ambiguous, **state your assumptions explicitly at the top of your output** and proceed. Do not halt to ask questions — the main agent has already clarified with the user before dispatching you. ### 1. Research Phase @@ -47,7 +35,7 @@ Before planning, thoroughly investigate: - **Hardware adapters**: Check `core/platform_abstraction/PlatformFactory.hpp` for peripheral creation and injection patterns - **Numerical tools**: Identify if `infra/numerical-toolbox/` algorithms (PID, filters, transforms) can be reused or need extension - **Test infrastructure**: Find existing test files in `core/foc/implementations/test/` and simulation models in `tools/simulator/` -- **Documentation**: Consult `documentation/` for domain guidance — `documentation/theory/foc.md`, `documentation/theory/alignment.md`, `documentation/performance-optimization/README.md`. Check for existing architecture/design documents under `documentation/` for the affected component. **Any behavioral change must be reflected in these documents.** +- **Documentation**: Consult `documentation/` for domain guidance. Check for existing architecture/design documents for the affected component. **Any behavioral change must be reflected in these documents.** ### 2. Plan Structure @@ -62,6 +50,8 @@ Every plan MUST include these sections: #### Motor Control Theory - Mathematical basis: relevant equations (Clarke, Park, SVM, PID tuning rules, etc.) + - Clarke (amplitude-invariant): `Iα = (2/3)·(Ia - (Ib+Ic)/2)`, `Iβ = (Ib - Ic)/√3` + - Park: `Id = Iα·cos(θ) + Iβ·sin(θ)`, `Iq = -Iα·sin(θ) + Iβ·cos(θ)` - Control loop structure: what feeds into what (current → torque → speed → position cascade) - Timing constraints: cycle budget for any hot-path changes - Numerical stability and fixed-point considerations if applicable @@ -91,12 +81,11 @@ Tests are designed **before** implementation (TDD Red-Green-Refactor): - Host simulation models for validation: `tools/simulator/` - Host hardware stubs: `targets/platform_implementations/host/` - Key test cases: correctness of transforms, PID output under known conditions, SVM duty cycles, edge cases -- Use `TEST_F` for fixture tests; `TYPED_TEST` for numeric-type-generic code #### Documentation Update -- **Behavioral changes**: Update the corresponding architecture or design document in `documentation/` **before or alongside** the code changes. If no such document exists, plan to create one using `documentation/templates/architecture.md` or `documentation/templates/design.md`. Code must follow documentation — doc updates for behavioral changes are first-class deliverables. +- **Behavioral changes**: Update or create the corresponding architecture/design document in `documentation/` **before or alongside** code changes. Use `documentation/templates/architecture.md` or `documentation/templates/design.md` as a template. - **Algorithm/theory changes**: Update `documentation/theory/` for FOC algorithm or motor model changes; update `documentation/performance-optimization/README.md` for timing-sensitive changes. -- All visuals in documents must use Mermaid code blocks or ASCII art — external image references are not allowed. +- All visuals in documents must use Mermaid code blocks or ASCII art. #### Build Integration - `CMakeLists.txt` changes needed in affected layers @@ -111,66 +100,8 @@ Tests are designed **before** implementation (TDD Red-Green-Refactor): ### 3. Plan Validation -Before finalizing, verify the plan against these constraints: - -- **No heap allocation**: Every data structure must be stack or statically allocated (in embedded/runtime code) -- **Real-time safe**: No blocking, no dynamic dispatch in the `Calculate()` hot path -- **FOC correctness**: Clarke/Park transforms use the correct convention; SVM covers the full modulation range -- **Interface alignment**: New implementations satisfy all pure virtual methods of the base interface -- **Documentation aligned**: `documentation/` entry planned for every new or modified algorithm or procedure -- **Hardware injection**: All hardware dependencies injected via constructor, not global state +Validate the plan against every constraint in CLAUDE.md §3 (Memory), §4 (FOC Theory), §5 (Naming), §8 (Testing), §9 (Documentation), and §13 (Design Principles). State explicitly which constraints are affected and how the plan satisfies them. ---- +### 4. Write Plan to File -## Critical Constraints Checklist - -**Scope**: Memory and real-time constraints apply to embedded/runtime motor-control code and hot paths (`core/foc/`, embedded `core/platform_abstraction/`, `targets/`, ISR-driven services). Host-side tools, simulators, and test infrastructure may use normal host-side STL/heap patterns. - -### Memory — No Heap in Embedded/Runtime Code -- [ ] No `new`, `delete`, `malloc`, `free`, `std::make_unique`, `std::make_shared` -- [ ] No `std::vector` → use `infra::BoundedVector::WithMaxSize` -- [ ] No `std::string` → use `infra::BoundedString::WithStorage` -- [ ] No `std::deque`, `std::list`, `std::map`, `std::set` — use bounded alternatives -- [ ] All memory is statically allocated or on the stack -- [ ] No recursion in embedded/runtime control paths - -### Real-Time — FOC Loop Constraints -- [ ] `Calculate()` hot path is free of virtual dispatch -- [ ] No blocking calls in ISR/FOC context -- [ ] Target cycle budget documented: <400 cycles at 120 MHz for 20 kHz control rate -- [ ] `#pragma GCC optimize("O3", "fast-math")` applied to implementation files (guarded by `#if defined(__GNUC__) || defined(__clang__)`) -- [ ] `OPTIMIZE_FOR_SPEED` applied to `Calculate()`, `Compute()`, and other hot-path methods - -### FOC Theory — Correctness -- [ ] Clarke transform: `Iα = (2/3)·(Ia - (Ib+Ic)/2)`, `Iβ = (Ib - Ic)/√3` (power-invariant, all 3 phases) -- [ ] Park transform: `Id = Iα·cos(θ) + Iβ·sin(θ)`, `Iq = -Iα·sin(θ) + Iβ·cos(θ)` -- [ ] Inverse Park/Clarke applied correctly for voltage reconstruction -- [ ] SVM sector detection and duty cycle computation are correct -- [ ] Electrical angle: `θe = θm · pole_pairs` -- [ ] Anti-windup implemented for current PID integrators -- [ ] Decoupling feedforward terms present in current loop where appropriate - -### Design — SOLID + DRY -- [ ] Single Responsibility: each class owns exactly one concern -- [ ] Open/Closed: extend via new implementations, not modification of existing interfaces -- [ ] Dependency Inversion: hardware dependencies injected via constructor -- [ ] DRY: no duplicated transform or PID logic — reuse from `infra/numerical-toolbox/` - -### Naming — PascalCase -- [ ] Classes: `PascalCase` (e.g., `FocSpeedImpl`) -- [ ] Methods: `PascalCase` (e.g., `Calculate()`, `SetPoint()`) -- [ ] Member variables: `camelCase` (e.g., `polePairs`, `currentTunings`) -- [ ] Namespaces: lowercase (e.g., `foc`, `hardware`) -- [ ] Units explicit in type aliases (`Ampere`, `Radians`, `Volts`, `RevPerMinute`) - -### Testing -- [ ] Unit tests for every new transform, algorithm, or mode -- [ ] Host simulation model updated if control loop is modified -- [ ] Hardware stubs in `targets/platform_implementations/host/` if new hardware interfaces are introduced -- [ ] Tests use `TEST_F` (fixture) or `TYPED_TEST` (typed) -- [ ] Tests verify numerical correctness of transforms and control outputs - -### Documentation — Always Updated -- [ ] `documentation/theory/` updated for any FOC algorithm or motor model change -- [ ] `documentation/performance-optimization/README.md` updated for any timing-critical change -- [ ] README or requirements updated if user-visible behavior changes +After completing the plan, write it to `.claude/plans/.md` using the Write tool so the executor can read it directly. Tell the main agent the exact file path. diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md index fd255b17..09505f24 100644 --- a/.claude/agents/reviewer.md +++ b/.claude/agents/reviewer.md @@ -1,188 +1,134 @@ --- name: reviewer description: Use when reviewing code changes in e-foc. Performs structured code review against all project standards: memory safety (no heap in embedded code), real-time determinism, FOC theory correctness, motor control best practices, embedded optimizations, documentation alignment, SOLID principles, and test coverage. Does NOT modify files. -model: opus -tools: - - Read - - Bash +model: sonnet +tools: Read, Bash, Grep, Glob --- You are the reviewer agent for the **e-foc** project — a Field-Oriented Control (FOC) implementation for BLDC/PMSM motors targeting resource-constrained embedded microcontrollers. You are an expert in: - **Field-Oriented Control**: Clarke/Park transforms, Id/Iq current control, Space Vector Modulation, decoupling, anti-windup - **Motor control engineering**: BLDC/PMSM modeling, rotor position, pole pairs, electrical vs mechanical angle -- **Motor parameter identification**: resistance/inductance estimation, automatic PID tuning - **Real-time embedded systems**: ISR timing budgets, deterministic execution, ARM Cortex-M optimization - **Numerical methods and fixed-point arithmetic** -You review code for compliance with project standards. You **MUST NOT modify any files**. +All project constraints are defined in **CLAUDE.md** — use it as the authoritative source. You **MUST NOT modify any files**. ## Review Process -1. **Identify changed files**: Determine which files were created or modified (`Bash` → `git diff --name-only` or as specified) -2. **Read each file** completely — do not skim -3. **Check each rule** in the checklist below -4. **Search for patterns**: Compare against existing code in the same module to verify consistency -5. **Verify FOC correctness**: Validate transforms, control loop structure, and unit-type usage -6. **Check documentation**: Verify `documentation/` files are present and aligned with code changes -7. **Output a structured review** with findings organized by severity +1. **Read the diff first**: `git diff` (or `git diff HEAD~1` for the last commit). Read full files only when the diff's correctness depends on surrounding context you cannot see in the diff. +2. **Identify changed files**: `git diff --name-only` or as specified in your prompt. +3. **Search for patterns**: Compare against existing code in the same module to verify consistency. Use Grep/Glob to find related code. +4. **Verify FOC correctness**: Validate transforms, control loop structure, and unit-type usage against CLAUDE.md §4. +5. **Check documentation**: Verify `documentation/` files are present and aligned with code changes per CLAUDE.md §9. +6. **Output a structured review** with findings organized by severity. ## Review Output Format -For each file reviewed, produce findings in this format: - +``` ### `path/to/file.hpp` **CRITICAL** — Must fix before merge: -- [C1] Description of critical issue (e.g., virtual dispatch in `Calculate()` hot path) +- [C1] Description of critical issue **WARNING** — Should fix: -- [W1] Description of warning (e.g., missing `OPTIMIZE_FOR_SPEED` on hot-path method) +- [W1] Description of warning **SUGGESTION** — Nice to have: -- [S1] Description of suggestion (e.g., could precompute constant outside loop) +- [S1] Description of suggestion -**PASS** — Rules verified: -- Memory safety, FOC correctness, real-time, naming, style, etc. +**PASS** — Rules verified: +``` -End with a summary: total criticals, warnings, suggestions, and overall verdict (APPROVE / REQUEST CHANGES). +End with a summary: total criticals, warnings, suggestions, and overall verdict (**APPROVE** / **REQUEST CHANGES**). --- ## Review Checklist -### 1. Memory Safety — Embedded Runtime / Hot Path (CRITICAL) +**Scope note**: CRITICAL findings for memory/real-time violations apply only to embedded runtime code (`core/foc/`, `core/platform_abstraction/`, `core/state_machine/`, `targets/`, ISR-reachable paths). Host-side tools, simulators, and tests may use normal STL/heap patterns — do not raise CRITICAL findings for STL/heap usage there. -**Scope**: Apply CRITICAL findings for memory violations only in embedded runtime code: `core/foc/`, `core/platform_abstraction/`, `core/state_machine/`, `targets/`, ISR-reachable paths. For host-side tools, simulators, and tests, do not raise CRITICAL findings solely for STL/heap usage — assess whether the choice is appropriate and consistent with existing patterns. +### 1. Memory + Real-Time Safety (CRITICAL for embedded runtime) -- [ ] No `new`, `delete`, `malloc`, `free` in embedded runtime code -- [ ] No `std::make_unique`, `std::make_shared` in embedded runtime code -- [ ] No `std::vector` in embedded runtime code — must use `infra::BoundedVector::WithMaxSize` -- [ ] No `std::string` in embedded runtime code — must use `infra::BoundedString::WithStorage` -- [ ] No `std::deque`, `std::list`, `std::map`, `std::set` in embedded runtime code -- [ ] Embedded runtime memory is statically allocated or stack-allocated with predictable bounds -- [ ] No recursion in embedded runtime / ISR / hot-path code -- [ ] No `virtual ~Dtor() = 0` (pure virtual destructors) — adds flash/RAM overhead. Default is **no pure virtual destructor** +All rules from CLAUDE.md §3 (Memory) and §3 (Real-Time). Key checks: +- No heap allocation (`new`, `delete`, `malloc`, `free`, `make_unique`, `make_shared`, `std::vector`, `std::string`, `std::deque`, `std::list`, `std::map`, `std::set`) in embedded runtime paths +- No recursion in embedded/runtime control paths +- No `virtual ~Dtor() = 0` (pure virtual destructors) +- `Calculate()` hot path: no virtual dispatch, no blocking calls, no heap reachable +- `TrigonometricFunctions` used for trig in hot paths (not raw `sin`/`cos`) +- `#pragma GCC optimize("O3", "fast-math")` present in files with hot-path code (guarded by `#if defined(__GNUC__) || defined(__clang__)`) +- `OPTIMIZE_FOR_SPEED` applied to `Calculate()`, `Compute()`, and other hot-path methods +- `#include "numerical/math/CompilerOptimizations.hpp"` present when `OPTIMIZE_FOR_SPEED` is used -### 2. Real-Time Safety — FOC Loop (CRITICAL) +### 2. FOC Theory Correctness (CRITICAL) -- [ ] `Calculate()` hot path contains no virtual dispatch -- [ ] No blocking calls (`sleep`, busy-wait) in ISR / FOC context -- [ ] No heap allocation anywhere reachable from `Calculate()` -- [ ] Trigonometric calls use approved implementation (`TrigonometricFunctions` from `TrigonometricImpl.hpp`, or lookup tables) — not raw `sin`/`cos` unless `fast-math` is confirmed active -- [ ] `#pragma GCC optimize("O3", "fast-math")` present in implementation files with hot-path code (guarded by `#if defined(__GNUC__) || defined(__clang__)`) -- [ ] `OPTIMIZE_FOR_SPEED` macro applied to `Calculate()`, `Compute()`, and other hot-path methods -- [ ] `#include "numerical/math/CompilerOptimizations.hpp"` present when `OPTIMIZE_FOR_SPEED` is used +Reference CLAUDE.md §4 for canonical equations: +- **Clarke** (amplitude-invariant): `Iα = (2/3)·(Ia - (Ib+Ic)/2)`, `Iβ = (Ib - Ic)/√3`, all 3 phases used +- **Park**: `Id = Iα·cos(θ) + Iβ·sin(θ)`, `Iq = -Iα·sin(θ) + Iβ·cos(θ)` — correct sign convention +- Inverse Park/Clarke applied correctly for voltage reconstruction +- SVM: sector detection (0–5), duty cycle formulas, null vector distribution correct +- Electrical angle: `θe = θm · pole_pairs` +- Anti-windup on all PID integrators (clamping or back-calculation) +- Decoupling feedforward present in current loop where appropriate +- No reimplementation of `TransformsClarkePark` or `SpaceVectorModulation` +- Unit-typed aliases used throughout (`Ampere`, `Radians`, `Volts`, `RevPerMinute`, `PhasePwmDutyCycles`, `PhaseCurrents`) -### 3. FOC Theory Correctness (CRITICAL) +### 3. Interface Compliance (CRITICAL) -- [ ] **Clarke transform**: `Iα = (2/3)·(Ia - (Ib+Ic)/2)`, `Iβ = (Ib - Ic)/√3` — power-invariant, all 3 phases used -- [ ] **Park transform**: `Id = Iα·cos(θ) + Iβ·sin(θ)`, `Iq = -Iα·sin(θ) + Iβ·cos(θ)` — correct sign convention -- [ ] **Inverse Park/Clarke**: Applied correctly for voltage reconstruction -- [ ] **SVM**: Sector detection (0–5), duty cycle formulas, and null vector distribution are correct -- [ ] **Electrical angle**: Mechanical angle multiplied by pole pairs — `θe = θm · P` -- [ ] **Anti-windup**: PID integrators have clamping or back-calculation — no unbounded integration -- [ ] **Decoupling feedforward**: ω-based cross-coupling terms present in current loop where appropriate -- [ ] No reimplementation of `TransformsClarkePark` or `SpaceVectorModulation` — existing classes reused -- [ ] Unit-typed aliases used throughout (`Ampere`, `Radians`, `Volts`, `RevPerMinute`, `PhasePwmDutyCycles`, `PhaseCurrents`) — not raw `float` +- New FOC implementations satisfy all pure virtual methods of `FocBase` +- Correct base interface for control mode: `FocTorque`, `FocSpeed`, or `FocPosition` +- Hardware dependencies injected via constructor — no global state +- `Driver` interface used for hardware abstraction -### 4. Interface Compliance (CRITICAL) +### 4. Documentation Alignment (CRITICAL) -- [ ] New FOC implementations satisfy all pure virtual methods of `FocBase` -- [ ] Correct base interface used for control mode: `FocTorque`, `FocSpeed`, or `FocPosition` -- [ ] Hardware dependencies injected via constructor — no global state, no direct peripheral access -- [ ] `Driver` interface used for hardware abstraction — not concrete hardware types +Per CLAUDE.md §9: +- `documentation/theory/` updated for FOC algorithm or motor model changes +- `documentation/performance-optimization/README.md` updated for timing-sensitive changes +- Any behavioral code change without matching doc update is a CRITICAL violation +- No markdown image references (`![alt](path)`) — all visuals must be Mermaid or ASCII art ### 5. Embedded Optimization (WARNING) -- [ ] `constexpr` used for motor constants and lookup tables -- [ ] `inline` used for small, frequently-called helpers -- [ ] Fixed-size types used (`uint8_t`, `int32_t`) — not plain `int` -- [ ] No unnecessary copies in hot path — references used -- [ ] No dynamic branching in `Calculate()` where avoidable - -### 6. Naming Conventions (WARNING) - -- [ ] Classes: `PascalCase` (e.g., `FocSpeedImpl`, `TransformsClarkePark`) -- [ ] Methods: `PascalCase` (e.g., `Calculate()`, `SetPoint()`, `Enable()`) -- [ ] Member variables: `camelCase` (e.g., `polePairs`, `currentTunings`) -- [ ] Namespaces: lowercase (`foc`, `hardware`) -- [ ] Unit-typed aliases used — not unnamed `float` parameters for motor quantities - -### 7. Code Style (WARNING) - -- [ ] Allman brace style: opening braces on new lines for classes, namespaces, functions -- [ ] 4-space indentation (no tabs) -- [ ] Consistent with `.clang-format` rules -- [ ] `public:` before `private:` in class declarations -- [ ] No trailing whitespace -- [ ] `{}` initialization used over `()` for variables and member data (e.g., `float x{0.0f}` not `float x(0.0f)`) - -### 8. Function Size (WARNING) - -- [ ] Functions are ~30 lines or less (soft limit) -- [ ] No function exceeds ~50 lines (hard limit) -- [ ] `Calculate()` extracts helper methods -- [ ] Each function does one thing - -### 9. Design Principles — SOLID (WARNING) - -- [ ] **SRP**: Each class owns exactly one control concern (current / speed / position) -- [ ] **OCP**: New modes added via new implementations, not modification of existing ones -- [ ] **LSP**: New FOC implementations are fully substitutable for their base interface -- [ ] **ISP**: Interfaces are small and focused -- [ ] **DIP**: Hardware injected via constructor, not accessed directly -- [ ] **DRY**: No reimplementation of PID, transforms, or SVM from `infra/numerical-toolbox/` - -### 10. Error Handling (WARNING) - -- [ ] `std::optional` for values that may not exist -- [ ] Error codes or status enums — no exceptions in embedded/runtime code (host tools/tests may use exceptions where appropriate) -- [ ] `assert()` or `really_assert()` for debug preconditions -- [ ] No silently swallowed errors - -### 11. Comments (SUGGESTION) - -- [ ] No comments restating what code does — code is self-documenting -- [ ] No `TODO`, `FIXME`, `HACK` in production code -- [ ] No multi-line docstrings unless API is non-obvious to a domain expert - -### 12. Testing (WARNING) - -- [ ] All mocks use `testing::StrictMock<>` — `NiceMock` and `NaggyMock` are **FORBIDDEN** -- [ ] Prefer `TEST_F` or `TYPED_TEST`; plain `TEST()` is acceptable for simple stateless cases matching existing patterns -- [ ] Test files exist at `core/foc/implementations/test/Test{ComponentName}.cpp` -- [ ] Fixture class inside anonymous `namespace {}` -- [ ] Test macros outside anonymous namespace -- [ ] Transforms verified against known mathematical reference values -- [ ] PID anti-windup and clamping behavior tested -- [ ] SVM sectors and duty cycles tested for all 6 sectors and edge cases -- [ ] Host simulation model updated if control loop changed -- [ ] Hardware stubs present in `targets/platform_implementations/host/` if new hardware interfaces added -- [ ] Tests follow Arrange-Act-Assert pattern -- [ ] `EXPECT_NEAR` used with explicit tolerance for floating-point assertions (not `EXPECT_EQ`) - -### 13. Documentation Alignment (CRITICAL) - -- [ ] `documentation/theory/` updated for any FOC algorithm or motor model change -- [ ] `documentation/performance-optimization/README.md` updated for any timing-sensitive change -- [ ] Documentation includes mathematical background (equations for transforms, PID tuning, etc.) -- [ ] Documentation includes implementation details and hardware dependencies -- [ ] README updated if user-visible behavior or interfaces change -- [ ] No markdown image references (`![alt](path)`) in architecture or design documents — all visuals must use Mermaid code blocks or ASCII art -- [ ] If this change alters any component's observable behavior, the corresponding architecture or design document in `documentation/` exists and has been updated — a behavioral code change with no matching doc update is a CRITICAL violation - -### 14. Build Integration (WARNING) - -- [ ] New files added to appropriate `CMakeLists.txt` -- [ ] No circular dependencies between targets -- [ ] Test target added via `add_subdirectory(test)` if new test directory created -- [ ] Host build verified: `cmake --preset host && cmake --build --preset host-Debug` -- [ ] Tests pass: `ctest --preset host` - -### 15. Code Quality Tools (WARNING) - -- [ ] Code complies with SonarQube quality rules (no code smells, security issues) -- [ ] Code passes Megalinter / clang-format checks -- [ ] Headers properly ordered: system includes, then project includes -- [ ] No unused includes or forward declarations -- [ ] No warnings from clang-tidy where applicable +- `constexpr` for motor constants and lookup tables +- `inline` for small, frequently-called helpers +- Fixed-size integer types (`uint8_t`, `int32_t`) — not plain `int` +- No unnecessary copies in hot path — references used +- No dynamic branching in `Calculate()` where avoidable + +### 6. Naming, Style, Design (WARNING) + +Per CLAUDE.md §5 (Naming), §6 (Brace Style), §13 (Design Principles): +- Classes/methods: `PascalCase`; member variables: `camelCase`; namespaces: lowercase +- Allman brace style, 4-space indent, `{}` initialization +- SOLID principles; DRY (no reimplemented PID/transforms/SVM) +- `const` on non-mutating methods; `constexpr` for constants + +### 7. Error Handling (WARNING) + +- `std::optional` for nullable returns +- Error codes/enums in embedded/runtime — no exceptions +- `assert()` for debug preconditions + +### 8. Testing (WARNING) + +Per CLAUDE.md §8: +- All mocks use `testing::StrictMock<>` — `NiceMock` and `NaggyMock` are FORBIDDEN +- Test files at `core/foc/implementations/test/Test{ComponentName}.cpp` +- Fixture class inside `namespace {}`, test macros outside +- Transforms verified against known reference values +- `EXPECT_NEAR` with explicit tolerance for floating-point assertions +- Host simulation model updated if control loop changed + +### 9. Build Integration (WARNING) + +- New files added to appropriate `CMakeLists.txt` +- No circular dependencies between targets +- Host build verified: `cmake --preset host && cmake --build --preset host-Debug` +- Tests pass: `ctest --preset host` + +### 10. Code Quality (WARNING) + +- Headers properly ordered: system includes, then project includes +- No unused includes or forward declarations +- Functions ~30 lines or less (soft), hard limit ~50 lines +- No comments restating what code does; no `TODO`/`FIXME` in production code diff --git a/CLAUDE.md b/CLAUDE.md index 7ab3d04f..4c4dd611 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ Apply `OPTIMIZE_FOR_SPEED` (from `numerical/math/CompilerOptimizations.hpp`) to ## 4. FOC Theory — Correctness -- **Clarke**: `Iα = (2/3)·(Ia - (Ib+Ic)/2)`, `Iβ = (Ib - Ic)/√3` (power-invariant, all 3 phases used) +- **Clarke**: `Iα = (2/3)·(Ia - (Ib+Ic)/2)`, `Iβ = (Ib - Ic)/√3` (amplitude-invariant, all 3 phases used) - **Park**: `Id = Iα·cos(θ) + Iβ·sin(θ)`, `Iq = -Iα·sin(θ) + Iβ·cos(θ)` - **Electrical angle**: `θe = θm · pole_pairs` - **Anti-windup**: All PID integrators must have clamping or back-calculation @@ -176,7 +176,9 @@ namespace foc Use the agents in `.claude/agents/` for structured development workflows: -- **orchestrator** — Triage and route incoming development tasks -- **planner** — Create detailed implementation plans before writing code -- **executor** — Implement code changes following all project constraints -- **reviewer** — Review code changes against project standards +- **orchestrator** — Returns a triage report and routing recommendation; the main agent acts on it +- **planner** — Creates a detailed implementation plan, writes it to `.claude/plans/.md` +- **executor** — Implements code changes; reads plan from `.claude/plans/.md` when available +- **reviewer** — Reviews code changes against project standards + +**Before dispatching planner or executor**: confirm with the user — control mode (torque/speed/position), hardware target (EK-TM4C1294XL, STM32, or host sim), and acceptance criteria. Subagents cannot ask clarifying questions interactively. From 88a796b1a81a5608f90f2792d74807dc84e81d31 Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Sun, 19 Jul 2026 17:23:23 +0000 Subject: [PATCH 19/28] chore(ti): drop currentTotal channel and ADC overcurrent trip, cap max current at 3A Remove the step-3 currentTotal -> DCMP0 overcurrent redirect and the currentTotal ADC channel (4 -> 3 phase channels) while investigating current-measurement bias; the hardware overcurrent trip via the ADC digital comparator is off in this configuration. Source MaxCurrentSupported() from BoardCharacteristics::maxCurrentAmps instead of a hardcoded 15 A, and lower maxCurrentAmps/overcurrentThresholdAmps from 15/12 A to 3/3 A. --- .../E-FOC-HARDWARE/BoardCharacteristics.hpp | 4 ++-- .../ti/implementation/PlatformFactoryImpl.cpp | 3 +-- .../ti/implementation/PlatformFactoryImpl.hpp | 19 +++++-------------- 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp b/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp index 90a6a766..ff471009 100644 --- a/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp +++ b/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp @@ -13,8 +13,8 @@ namespace application static constexpr float overvoltageThresholdVolts{ 58.0f }; static constexpr float voltageToCurrent{ 5.0f }; - static constexpr float maxCurrentAmps{ 15.0f }; - static constexpr float overcurrentThresholdAmps{ 12.0f }; + static constexpr float maxCurrentAmps{ 3.0f }; + static constexpr float overcurrentThresholdAmps{ 3.0f }; static constexpr float AdcToVoltsFactor(float adcReferenceVoltage, float adcResolution) { diff --git a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp index 226ae4dc..ae371da8 100644 --- a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp +++ b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp @@ -135,7 +135,7 @@ namespace application foc::Ampere PlatformFactoryImpl::MaxCurrentSupported() const { - return foc::Ampere(15.0f); + return foc::Ampere(BoardCharacteristics::maxCurrentAmps); } foc::LowPriorityInterrupt& PlatformFactoryImpl::LowPriorityInterrupt() @@ -178,7 +178,6 @@ namespace application auto& adcCfg = impl.adcConfig; adcCfg.sampleAndHold = impl.toSampleAndHold.at(static_cast(sampleAndHold)); - adcCfg.digitalComparators = infra::MakeRange(impl.digitalComparators); peripherals->phaseCurrentAdc.reset(); peripherals->phaseCurrentAdc.emplace( diff --git a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp index 5a75b8e5..a23d0cd5 100644 --- a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp +++ b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp @@ -126,22 +126,13 @@ namespace application static constexpr hal::tiva::Adc::SamplingDelay phaseDelay{ 4 }; static constexpr auto currentSensingOversampling = hal::tiva::Adc::Oversampling::oversampling2; - // Steps 0–2 go to the ADC FIFO (phase currents A/B/C). - // Step 3 is redirected to DCMP0 via the SSOP register and does NOT appear in - // the FIFO. DCMP0 connects to PWM FLTSRC1 bit 0 and tristates all motor PWM - // outputs instantly when the overcurrent threshold is exceeded. - // Overvoltage is monitored separately by AdcForPowerSupplyMeasurementImpl - // (synchronous ADC1, sequencer 0) — powerSupplyVoltage is not sampled here. - static constexpr std::array digitalComparators{ { - {}, // step 0: currentPhaseA → FIFO (noComparator) - {}, // step 1: currentPhaseB → FIFO (noComparator) - {}, // step 2: currentPhaseC → FIFO (noComparator) - { Peripheral::OvercurrentComparatorIndex, 0, Peripheral::overcurrentThresholdCounts, - hal::tiva::Adc::ComparatorCondition::highBand, hal::tiva::Adc::ComparatorMode::always }, - } }; + // Steps 0-2 (phase currents A/B/C) go to the ADC FIFO. + // NOTE: the step-3 currentTotal -> DCMP0 overcurrent redirect is temporarily + // disabled while investigating current-measurement bias — hardware overcurrent + // trip via the ADC digital comparator is OFF in this configuration. hal::tiva::Adc::Config adcConfig{ false, 0, Peripheral::adcTrigger, hal::tiva::Adc::SampleAndHold::sampleAndHold8, std::make_optional(currentSensingOversampling), phaseDelay }; - std::array currentPhaseAnalogPins{ { hal::tiva::AnalogPin{ Pins::currentPhaseA }, hal::tiva::AnalogPin{ Pins::currentPhaseB }, hal::tiva::AnalogPin{ Pins::currentPhaseC }, hal::tiva::AnalogPin{ Pins::currentTotal } } }; + std::array currentPhaseAnalogPins{ { hal::tiva::AnalogPin{ Pins::currentPhaseA }, hal::tiva::AnalogPin{ Pins::currentPhaseB }, hal::tiva::AnalogPin{ Pins::currentPhaseC } } }; }; struct AsyncPwmConfig From cd7b14273a6680441f845581563230c51a85d9e1 Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Sun, 19 Jul 2026 17:23:35 +0000 Subject: [PATCH 20/28] feat(ident): high-frequency impedance R/L identification (no rotor clamp) Replace the DC-step resistance/inductance estimation, which drove torque into a free rotor and let back-EMF corrupt the result, with high-frequency alpha-axis sinusoidal injection plus synchronous demodulation. Zero-mean AC produces no net torque and the demod rejects the low-frequency back-EMF, so no rotor clamp is needed; R and Ls fall out in one shot from the current amplitude and phase. - Guards: peak-current abort below MaxCurrentSupported, invalid-frequency and min-current return nullopt, duty clamped to keep the low-side shunt window samplable. - Compensate the PWM->ADC pipeline lag via voltageToCurrentDelaySamples. - CLI: ident [inj_freq_hz] [inj_v%] ...; docs and tests updated. Pole-pair estimation is unchanged. --- .../ElectricalParametersIdentification.hpp | 11 +- ...ElectricalParametersIdentificationImpl.cpp | 231 +++++------ ...ElectricalParametersIdentificationImpl.hpp | 48 +-- ...TestElectricalParametersIdentification.cpp | 347 ++++++++++------ .../design/service-electrical-ident.md | 134 ++++-- .../resistance-inductance-estimation.md | 382 ++++++++++-------- targets/hardware_test/components/Terminal.cpp | 18 +- targets/hardware_test/components/Terminal.hpp | 3 +- .../components/test/TestTerminal.cpp | 48 ++- 9 files changed, 722 insertions(+), 500 deletions(-) diff --git a/core/services/electrical_system_ident/ElectricalParametersIdentification.hpp b/core/services/electrical_system_ident/ElectricalParametersIdentification.hpp index d36b099e..36f5ae5f 100644 --- a/core/services/electrical_system_ident/ElectricalParametersIdentification.hpp +++ b/core/services/electrical_system_ident/ElectricalParametersIdentification.hpp @@ -1,10 +1,9 @@ #pragma once +#include "core/foc/interfaces/Units.hpp" #include "hal/synchronous_interfaces/SynchronousPwm.hpp" #include "infra/timer/Timer.hpp" #include "infra/util/Function.hpp" -#include "core/foc/interfaces/Units.hpp" -#include #include #include @@ -21,9 +20,11 @@ namespace services public: struct ResistanceAndInductanceConfig { - std::array targetCurrentFractions{ 0.3f, 0.5f, 0.7f }; - hal::Percent probeVoltagePercent{ 5 }; - infra::Duration settlePerLevel{ std::chrono::milliseconds{ 300 } }; + hal::Hertz injectionFrequency{ 250 }; // must divide the sampling frequency (10 kHz) + hal::Percent injectionVoltagePercent{ 15 }; // peak alpha modulation; clamped so duty stays samplable + std::size_t warmupPeriods{ 10 }; + std::size_t measurementPeriods{ 50 }; + std::size_t voltageToCurrentDelaySamples{ 1 }; // PWM->ADC pipeline lag; rig-calibrated (see theory doc) WindingConfiguration windingConfig{ WindingConfiguration::Wye }; }; diff --git a/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.cpp b/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.cpp index 3134c763..ce6e2d3d 100644 --- a/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.cpp +++ b/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.cpp @@ -1,21 +1,28 @@ #include "core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp" +#include "core/foc/implementations/TrigonometricImpl.hpp" #include "core/foc/interfaces/Driver.hpp" #include "core/foc/interfaces/Units.hpp" -#include "numerical/math/Matrix.hpp" +#include "numerical/math/CompilerOptimizations.hpp" +#include #include #include +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC optimize("O3", "fast-math") +#endif + namespace { constexpr float twoPi = 2.0f * std::numbers::pi_v; constexpr std::size_t stepsPerRevolution = 12; constexpr auto anglePerStep = twoPi / static_cast(stepsPerRevolution); constexpr float minRotationThreshold = std::numbers::pi_v / 2.0f; - constexpr float minSteadyStateCurrent = 0.001f; - constexpr float safeMinDutyPercent = 5.0f; - constexpr float safeMaxDutyPercent = 80.0f; - const hal::Hertz samplingFrequency{ 10000 }; - const auto samplingPeriod = 1.0f / static_cast(samplingFrequency.Value()); + + // Center-aligned half-bridge: applied alpha voltage amplitude = modIndex * Vdc / 2. + constexpr float voltsPerModulation = 0.5f; + + // Bound modulation so every leg duty stays within [20, 80] % (keeps the low-side shunt samplable). + constexpr float maxSafeModIndex = 0.6f; foc::PhasePwmDutyCycles NormalizedDutyCycles(foc::ThreePhase voltages) { @@ -25,31 +32,6 @@ namespace auto dutyC = static_cast(std::clamp(offset + voltages.c * 50.0f, 0.0f, 100.0f)); return foc::PhasePwmDutyCycles{ hal::Percent{ dutyA }, hal::Percent{ dutyB }, hal::Percent{ dutyC } }; } - - float MeanMagnitude(const infra::BoundedVector& samples) - { - float sum = 0.0f; - for (const auto& v : samples) - sum += std::abs(v); - return sum / static_cast(samples.size()); - } - - float SteadyStateMagnitude(const infra::BoundedVector& transient) - { - const auto start = static_cast(static_cast(transient.size()) * 0.9f); - float sum = 0.0f; - for (std::size_t i = start; i < transient.size(); ++i) - sum += transient[i]; - return sum / static_cast(transient.size() - start); - } - - float IntegralInductance(const infra::BoundedVector& transient, float steadyState, float resistance) - { - float integral = 0.0f; - for (const auto& v : transient) - integral += (steadyState - v) * samplingPeriod; - return resistance * integral / steadyState; - } } namespace services @@ -65,147 +47,140 @@ namespace services { rlConfig = config; onResistanceAndInductanceDone = onDone; - probeBuffer.clear(); - - StartProbeStep(); - } - void ElectricalParametersIdentificationImpl::StartProbeStep() - { - driver.PhaseCurrentsReady(samplingFrequency, [](auto) {}); - driver.ThreePhasePwmOutput(foc::PhasePwmDutyCycles{ - hal::Percent{ rlConfig.probeVoltagePercent.Value() }, - hal::Percent{ neutralDuty }, - hal::Percent{ neutralDuty } }); - - driver.PhaseCurrentsReady(samplingFrequency, [this](auto currentPhases) - { - probeBuffer.push_back(std::abs(currentPhases.a.Value())); - if (probeBuffer.full()) - OnProbeBufferFull(); - }); - } - - void ElectricalParametersIdentificationImpl::OnProbeBufferFull() - { - const float probeCurrent = SteadyStateMagnitude(probeBuffer); - if (probeCurrent < minSteadyStateCurrent) + const auto injectionHz = rlConfig.injectionFrequency.Value(); + if (injectionHz == 0 || samplingFrequencyHz % injectionHz != 0) { - driver.Stop(); onResistanceAndInductanceDone(std::nullopt); return; } - const float probeVoltage = static_cast(rlConfig.probeVoltagePercent.Value()) / 100.0f * vdc.Value(); - rCoarse = probeVoltage / probeCurrent; - - StartLevel(0); - } - - void ElectricalParametersIdentificationImpl::StartLevel(std::size_t level) - { - levelBatch.clear(); + const auto samplesPerPeriod = samplingFrequencyHz / injectionHz; - const float targetCurrent = rlConfig.targetCurrentFractions[level] * driver.MaxCurrentSupported().Value(); - const float rawDuty = (targetCurrent * rCoarse / vdc.Value()) * 100.0f + static_cast(neutralDuty); - const auto duty = static_cast(std::clamp(rawDuty, safeMinDutyPercent, safeMaxDutyPercent)); + injectionModIndex = std::min(static_cast(rlConfig.injectionVoltagePercent.Value()) / 100.0f, maxSafeModIndex); + angularFrequency = twoPi * static_cast(injectionHz); + phaseIncrement = angularFrequency / static_cast(samplingFrequencyHz); + warmupSamples = rlConfig.warmupPeriods * samplesPerPeriod; + measurementSamples = rlConfig.measurementPeriods * samplesPerPeriod; - levelVoltages[level] = (static_cast(duty) - static_cast(neutralDuty)) / 100.0f * vdc.Value(); + const auto maxCurrent = driver.MaxCurrentSupported().Value(); + maxCurrentSquared = maxCurrent * maxCurrent; - driver.PhaseCurrentsReady(samplingFrequency, [](auto) {}); - driver.ThreePhasePwmOutput(foc::PhasePwmDutyCycles{ - hal::Percent{ duty }, - hal::Percent{ neutralDuty }, - hal::Percent{ neutralDuty } }); + phase = 0.0f; + // Demod reference lags the applied phase by the PWM->ADC pipeline delay, cancelling the + // ~2*pi*f_inj/f_s phase error that would otherwise bias R. + demodPhase = std::fmod(-static_cast(rlConfig.voltageToCurrentDelaySamples) * phaseIncrement, twoPi); + if (demodPhase < 0.0f) + demodPhase += twoPi; + sampleIndex = 0; + sumSin = 0.0f; + sumCos = 0.0f; + sumSq = 0.0f; - settleTimer.Start(rlConfig.settlePerLevel, [this, level]() + driver.Stop(); + driver.PhaseCurrentsReady(hal::Hertz{ static_cast(samplingFrequencyHz) }, [this](auto currentPhases) { - driver.PhaseCurrentsReady(samplingFrequency, [this, level](auto currentPhases) - { - levelBatch.push_back(std::abs(currentPhases.a.Value())); - if (levelBatch.full()) - OnLevelBatchFull(level); - }); + OnHfSample(currentPhases); }); + // Contract: the PWM must be driven once after registering the callback, otherwise no phase + // currents are ever produced and the callback never fires. + ApplyInjectionVoltage(); } - void ElectricalParametersIdentificationImpl::OnLevelBatchFull(std::size_t level) + OPTIMIZE_FOR_SPEED void ElectricalParametersIdentificationImpl::ApplyInjectionVoltage() { - levelSteadyStateCurrents[level] = MeanMagnitude(levelBatch); + driver.ThreePhasePwmOutput(NormalizedDutyCycles(clarke.Inverse(foc::TwoPhase{ injectionModIndex * foc::FastTrigonometry::Sine(phase), 0.0f }))); + } - if (level + 1 < numLevels) - StartLevel(level + 1); - else + OPTIMIZE_FOR_SPEED void ElectricalParametersIdentificationImpl::OnHfSample(const foc::PhaseCurrents& currentPhases) + { + if (sampleIndex >= warmupSamples + measurementSamples) + return; + + const float a = currentPhases.a.Value(); + const float b = currentPhases.b.Value(); + const float c = currentPhases.c.Value(); + + const float peakSquared = std::max({ a * a, b * b, c * c }); + if (peakSquared > maxCurrentSquared) { - driver.Stop(); - ComputeAndReport(); + AbortResistanceAndInductance(); + return; } - } - bool ElectricalParametersIdentificationImpl::FitResistance() - { - math::Matrix currents; - math::Matrix voltages; - for (std::size_t j = 0; j < numLevels; ++j) + ApplyInjectionVoltage(); + + if (sampleIndex >= warmupSamples) { - if (levelSteadyStateCurrents[j] < minSteadyStateCurrent) - return false; - currents.at(j, 0) = levelSteadyStateCurrents[j]; - voltages.at(j, 0) = levelVoltages[j]; + const float iAlpha = clarke.Forward(foc::ThreePhase{ a, b, c }).alpha; + sumSin += iAlpha * foc::FastTrigonometry::Sine(demodPhase); + sumCos += iAlpha * foc::FastTrigonometry::Cosine(demodPhase); + sumSq += iAlpha * iAlpha; } - estimators::LinearRegression regression; - regression.Fit(currents, voltages); + phase += phaseIncrement; + if (phase >= twoPi) + phase -= twoPi; - fittedVoltageOffset = regression.Coefficients().at(0, 0); - fittedResistance = regression.Coefficients().at(1, 0); + demodPhase += phaseIncrement; + if (demodPhase >= twoPi) + demodPhase -= twoPi; - return fittedResistance > 0.0f; + ++sampleIndex; + if (sampleIndex >= warmupSamples + measurementSamples) + { + driver.Stop(); + ComputeAndReport(); + } } - float ElectricalParametersIdentificationImpl::ResistanceFitResidual() const + void ElectricalParametersIdentificationImpl::AbortResistanceAndInductance() { - float maxResidual = 0.0f; - for (std::size_t j = 0; j < numLevels; ++j) - { - const float predicted = fittedResistance * levelSteadyStateCurrents[j] + fittedVoltageOffset; - maxResidual = std::max(maxResidual, std::abs(levelVoltages[j] - predicted)); - } - return maxResidual / fittedResistance; + sampleIndex = warmupSamples + measurementSamples; + driver.Stop(); + if (onResistanceAndInductanceDone) + onResistanceAndInductanceDone(std::nullopt); } void ElectricalParametersIdentificationImpl::ComputeAndReport() { - if (!FitResistance()) - { - onResistanceAndInductanceDone(std::nullopt); + if (!onResistanceAndInductanceDone) return; - } - const float fitQuality = ResistanceFitResidual(); - if (fitQuality > maxAcceptableFitResidual) + const auto n = static_cast(measurementSamples); + const float iRe = 2.0f * sumSin / n; + const float iIm = 2.0f * sumCos / n; + const float magnitudeSquared = iRe * iRe + iIm * iIm; + + if (magnitudeSquared < minDemodulatedCurrent * minDemodulatedCurrent) { onResistanceAndInductanceDone(std::nullopt); return; } - const float steadyState = SteadyStateMagnitude(probeBuffer); - const float inductance = IntegralInductance(probeBuffer, steadyState, fittedResistance); + const float amplitude = injectionModIndex * voltsPerModulation * vdc.Value(); + float resistance = amplitude * iRe / magnitudeSquared; + float inductance = -amplitude * iIm / (angularFrequency * magnitudeSquared); + + // THD-like residual (0 = perfect sinusoid), diagnostic only: demod already rejects the + // low-frequency back-EMF, so a raised residual flags a disturbance without invalidating R/Ls. + const float fundamentalEnergy = n * magnitudeSquared / 2.0f; + const float fitQuality = std::abs(sumSq - fundamentalEnergy) / fundamentalEnergy; const float correction = (rlConfig.windingConfig == WindingConfiguration::Delta) ? deltaCoefficient : 1.0f; - const float resistancePhase = fittedResistance * correction; - const float inductancePhase = inductance * correction; + resistance *= correction; + inductance *= correction; - if (inductancePhase <= 0.0f) + if (resistance <= 0.0f || inductance <= 0.0f) { onResistanceAndInductanceDone(std::nullopt); return; } onResistanceAndInductanceDone(ResistanceInductanceResult{ - foc::Ohm{ resistancePhase }, - foc::MilliHenry{ inductancePhase * 1000.0f }, - foc::Volts{ fittedVoltageOffset }, + foc::Ohm{ resistance }, + foc::MilliHenry{ inductance * 1000.0f }, + foc::Volts{ 0.0f }, fitQuality }); } @@ -216,10 +191,10 @@ namespace services currentSampleIndex = 0; accumulatedRotation = 0.0f; - initialPosition = encoder.Read(); - previousPosition = initialPosition; + previousPosition = encoder.Read(); - driver.PhaseCurrentsReady(samplingFrequency, [](auto) {}); + driver.Stop(); + driver.PhaseCurrentsReady(hal::Hertz{ static_cast(samplingFrequencyHz) }, [](auto) {}); ApplyNextElectricalAngle(); } diff --git a/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp b/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp index 0661571a..df8ebbd6 100644 --- a/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp +++ b/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp @@ -1,14 +1,10 @@ #pragma once -#include "infra/timer/Timer.hpp" -#include "infra/util/AutoResetFunction.hpp" -#include "infra/util/BoundedVector.hpp" -#include "numerical/estimators/offline/LinearRegression.hpp" #include "core/foc/implementations/TransformsClarkePark.hpp" #include "core/foc/interfaces/Driver.hpp" #include "core/services/electrical_system_ident/ElectricalParametersIdentification.hpp" -#include -#include +#include "infra/timer/Timer.hpp" +#include "infra/util/AutoResetFunction.hpp" namespace services { @@ -22,44 +18,40 @@ namespace services void EstimateNumberOfPolePairs(const PolePairsConfig& config, const infra::Function)>& onDone) override; private: - void StartProbeStep(); - void OnProbeBufferFull(); - void StartLevel(std::size_t level); - void OnLevelBatchFull(std::size_t level); - bool FitResistance(); - float ResistanceFitResidual() const; + void ApplyInjectionVoltage(); + void OnHfSample(const foc::PhaseCurrents& currentPhases); + void AbortResistanceAndInductance(); void ComputeAndReport(); void ApplyNextElectricalAngle(); void RunPolePairLogic(); void CalculatePolePairs(); - static constexpr uint8_t neutralDuty = 1; static constexpr float deltaCoefficient = 1.5f; - static constexpr std::size_t numLevels = 3; - static constexpr std::size_t probeBufferSize = 512; - static constexpr std::size_t steadyStateSamples = 32; - static constexpr float maxAcceptableFitResidual = 0.1f; - - static_assert(numLevels == std::tuple_size::value, "numLevels must match the size of ResistanceAndInductanceConfig::targetCurrentFractions"); + static constexpr float minDemodulatedCurrent = 0.05f; + static constexpr std::size_t samplingFrequencyHz = 10000; foc::ThreePhaseInverter& driver; foc::Encoder& encoder; foc::Volts vdc; + [[no_unique_address]] foc::Clarke clarke; [[no_unique_address]] foc::ClarkePark transforms; ResistanceAndInductanceConfig rlConfig; PolePairsConfig polePairsConfig; - infra::BoundedVector::WithMaxSize probeBuffer; - infra::BoundedVector::WithMaxSize levelBatch; - - float rCoarse{ 0.0f }; - std::array levelVoltages{}; - std::array levelSteadyStateCurrents{}; - float fittedResistance{ 0.0f }; - float fittedVoltageOffset{ 0.0f }; + float injectionModIndex{ 0.0f }; + float phase{ 0.0f }; + float demodPhase{ 0.0f }; + float phaseIncrement{ 0.0f }; + float angularFrequency{ 0.0f }; + float maxCurrentSquared{ 0.0f }; + std::size_t sampleIndex{ 0 }; + std::size_t warmupSamples{ 0 }; + std::size_t measurementSamples{ 0 }; + float sumSin{ 0.0f }; + float sumCos{ 0.0f }; + float sumSq{ 0.0f }; std::size_t currentSampleIndex{ 0 }; - foc::Radians initialPosition{ 0.0f }; foc::Radians previousPosition{ 0.0f }; float accumulatedRotation{ 0.0f }; diff --git a/core/services/electrical_system_ident/test/TestElectricalParametersIdentification.cpp b/core/services/electrical_system_ident/test/TestElectricalParametersIdentification.cpp index 59a01da7..07713a19 100644 --- a/core/services/electrical_system_ident/test/TestElectricalParametersIdentification.cpp +++ b/core/services/electrical_system_ident/test/TestElectricalParametersIdentification.cpp @@ -1,3 +1,4 @@ +#include "core/foc/implementations/TransformsClarkePark.hpp" #include "core/foc/implementations/test_doubles/DriversMock.hpp" #include "core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp" #include "infra/timer/test_helper/ClockFixture.hpp" @@ -9,23 +10,13 @@ namespace { using namespace testing; - MATCHER_P(PhasePwmDutyCyclesEq, expected, "") - { - return arg.a.Value() == expected.a.Value() && - arg.b.Value() == expected.b.Value() && - arg.c.Value() == expected.c.Value(); - } - - float SimulateRLModelCurrent(float voltage, float resistance, float inductance, float time) - { - return (voltage / resistance) * (1.0f - std::exp(-time / (inductance / resistance))); - } + constexpr float twoPi = 2.0f * std::numbers::pi_v; float MechanicalAngle(std::size_t stepIndex, std::size_t totalSteps, std::size_t expectedPolePairs) { constexpr std::size_t stepsPerRevolution = 12; auto electricalRevolutions = totalSteps / stepsPerRevolution; - auto electricalAngle = (static_cast(stepIndex) / static_cast(totalSteps)) * (static_cast(electricalRevolutions) * 2.0f * std::numbers::pi_v); + auto electricalAngle = (static_cast(stepIndex) / static_cast(totalSteps)) * (static_cast(electricalRevolutions) * twoPi); return electricalAngle / static_cast(expectedPolePairs); } @@ -35,144 +26,196 @@ namespace { public: static constexpr float vdcValue = 24.0f; - static constexpr float maxCurrent = 5.0f; - static constexpr float probeVoltage = 5.0f / 100.0f * vdcValue; - static constexpr std::size_t probeBufferSize = 512; - static constexpr std::size_t steadyStateSamples = 32; - static constexpr std::size_t numLevels = 3; - static constexpr float samplingPeriod = 0.0001f; + static constexpr float maxCurrent = 3.0f; + static constexpr float samplingFrequency = 10000.0f; + static constexpr std::size_t injectionFrequency = 250; + static constexpr std::size_t injectionVoltagePercent = 15; + static constexpr std::size_t warmupPeriods = 10; + static constexpr std::size_t measurementPeriods = 50; + static constexpr std::size_t voltageToCurrentDelaySamples = 1; + + // Phase-to-midpoint amplitude for a center-aligned half-bridge is modIndex * Vdc / 2. + // The alpha modulation index equals injectionVoltagePercent / 100. + static constexpr float voltsPerModulation = vdcValue / 2.0f; + static constexpr float injectionAmplitude = static_cast(injectionVoltagePercent) / 100.0f * voltsPerModulation; + static constexpr float omega = twoPi * static_cast(injectionFrequency); + static constexpr float samplingPeriod = 1.0f / samplingFrequency; + static constexpr std::size_t samplesPerPeriod = static_cast(samplingFrequency) / injectionFrequency; std::size_t encoderStepIndex = 0; StrictMock driverMock; StrictMock encoderMock; foc::Volts vdc{ vdcValue }; + foc::Clarke clarke; services::ElectricalParametersIdentificationImpl identification{ driverMock, encoderMock, vdc }; - void FeedProbeTransient(float resistance, float inductance) + services::ElectricalParametersIdentification::ResistanceAndInductanceConfig DefaultConfig() const { - for (std::size_t i = 0; i < probeBufferSize; ++i) - { - float t = static_cast(i) * samplingPeriod; - float current = SimulateRLModelCurrent(probeVoltage, resistance, inductance, t); - driverMock.TriggerPhaseCurrentsCallback({ foc::Ampere{ current }, foc::Ampere{ 0.0f }, foc::Ampere{ 0.0f } }); - } + services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config; + config.injectionFrequency = hal::Hertz{ injectionFrequency }; + config.injectionVoltagePercent = hal::Percent{ injectionVoltagePercent }; + config.warmupPeriods = warmupPeriods; + config.measurementPeriods = measurementPeriods; + config.voltageToCurrentDelaySamples = voltageToCurrentDelaySamples; + return config; } - void FeedLevelSteadyState(float iSs) + // Feed the analytic AC steady-state current i_alpha[k] = I*sin(applied_phase[k - delay] - phi), + // inverse-Clarke'd to (Ia, Ib, Ic), for the full warmup + measurement window. The current at + // sample k is produced by the applied-voltage phase from `delay` samples earlier, modelling the + // one-sample PWM->ADC pipeline lag the demodulation compensates for. Before the burst starts the + // applied voltage is zero, so the first `delay` samples carry no injected current. An optional + // low-frequency back-EMF disturbance current can be superimposed to test demod rejection. + void FeedHfBurst(float resistance, float inductance, float backEmfCurrentAmplitude = 0.0f, float backEmfFrequency = 0.0f, std::size_t delaySamples = voltageToCurrentDelaySamples) { - ForwardTime(std::chrono::milliseconds{ 300 }); - for (std::size_t s = 0; s < steadyStateSamples; ++s) - driverMock.TriggerPhaseCurrentsCallback({ foc::Ampere{ iSs }, foc::Ampere{ 0.0f }, foc::Ampere{ 0.0f } }); - } - - float ComputeLevelDuty(float rCoarse, float targetFraction) const - { - constexpr float neutralDuty = 1.0f; - float rawDuty = targetFraction * maxCurrent * rCoarse / vdcValue * 100.0f + neutralDuty; - return std::clamp(rawDuty, 5.0f, 80.0f); - } + const float impedance = std::sqrt(resistance * resistance + (omega * inductance) * (omega * inductance)); + const float current = injectionAmplitude / impedance; + const float phi = std::atan2(omega * inductance, resistance); + const float backEmfOmega = twoPi * backEmfFrequency; - float ComputeLevelVoltage(float duty) const - { - return (duty - 1.0f) / 100.0f * vdcValue; + const std::size_t totalSamples = (warmupPeriods + measurementPeriods) * samplesPerPeriod; + for (std::size_t k = 0; k < totalSamples; ++k) + { + float iAlpha = 0.0f; + if (k >= delaySamples) + { + const float appliedPhase = static_cast(k - delaySamples) * omega * samplingPeriod; + iAlpha = current * std::sin(appliedPhase - phi); + } + + if (backEmfCurrentAmplitude != 0.0f) + iAlpha += backEmfCurrentAmplitude * std::sin(backEmfOmega * static_cast(k) * samplingPeriod); + + const auto phases = clarke.Inverse(foc::TwoPhase{ iAlpha, 0.0f }); + driverMock.TriggerPhaseCurrentsCallback({ foc::Ampere{ phases.a }, foc::Ampere{ phases.b }, foc::Ampere{ phases.c } }); + } } }; } -TEST_F(ElectricalParametersIdentificationTest, probe_step_sets_probe_duty_and_starts_collecting_immediately) +TEST_F(ElectricalParametersIdentificationTest, arms_phase_currents_before_pwm_output_after_stop) { - services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config; - + Sequence seq; EXPECT_CALL(driverMock, MaxCurrentSupported()) .WillRepeatedly(Return(foc::Ampere{ maxCurrent })); + EXPECT_CALL(driverMock, Stop()) + .InSequence(seq); EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, _)) - .Times(2) - .WillRepeatedly([this](auto, const auto& cb) { driverMock.StorePhaseCurrentsCallback(cb); }); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(PhasePwmDutyCyclesEq( - foc::PhasePwmDutyCycles{ hal::Percent{ 5 }, hal::Percent{ 1 }, hal::Percent{ 1 } }))); + .InSequence(seq) + .WillOnce([this](auto, const auto& cb) + { + driverMock.StorePhaseCurrentsCallback(cb); + }); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)) + .Times(AnyNumber()) + .InSequence(seq); - identification.EstimateResistanceAndInductance(config, [](auto) {}); + identification.EstimateResistanceAndInductance(DefaultConfig(), [](auto) {}); +} + +TEST_F(ElectricalParametersIdentificationTest, phase_currents_callback_is_inert_after_completion) +{ + const float trueR = 1.5f; + const float trueLs = 0.002f; + int completions = 0; + + EXPECT_CALL(driverMock, MaxCurrentSupported()) + .WillRepeatedly(Return(foc::Ampere{ maxCurrent })); + EXPECT_CALL(driverMock, PhaseCurrentsReady(_, _)) + .Times(AnyNumber()) + .WillRepeatedly([this](auto, const auto& cb) + { + driverMock.StorePhaseCurrentsCallback(cb); + }); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(AnyNumber()); + EXPECT_CALL(driverMock, Stop()).Times(AnyNumber()); + + identification.EstimateResistanceAndInductance(DefaultConfig(), [&](auto) + { + ++completions; + }); + + FeedHfBurst(trueR, trueLs); + + ASSERT_EQ(completions, 1); + + for (std::size_t i = 0; i < samplesPerPeriod * 4; ++i) + driverMock.TriggerPhaseCurrentsCallback({ foc::Ampere{ 100.0f }, foc::Ampere{ 0.0f }, foc::Ampere{ 0.0f } }); + + EXPECT_EQ(completions, 1); } TEST_F(ElectricalParametersIdentificationTest, estimates_resistance_and_inductance_accurately) { const float trueR = 1.5f; - const float trueL = 0.002f; - const std::array fractions{ 0.3f, 0.5f, 0.7f }; + const float trueLs = 0.002f; - services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config; std::optional result; EXPECT_CALL(driverMock, MaxCurrentSupported()) .WillRepeatedly(Return(foc::Ampere{ maxCurrent })); EXPECT_CALL(driverMock, PhaseCurrentsReady(_, _)) .Times(AnyNumber()) - .WillRepeatedly([this](auto, const auto& cb) { driverMock.StorePhaseCurrentsCallback(cb); }); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(4); - EXPECT_CALL(driverMock, Stop()); - - identification.EstimateResistanceAndInductance(config, [&](auto r) { result = r; }); + .WillRepeatedly([this](auto, const auto& cb) + { + driverMock.StorePhaseCurrentsCallback(cb); + }); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(AnyNumber()); + EXPECT_CALL(driverMock, Stop()).Times(AnyNumber()); - FeedProbeTransient(trueR, trueL); + identification.EstimateResistanceAndInductance(DefaultConfig(), [&](auto r) + { + result = r; + }); - const float rCoarse = trueR; - for (std::size_t j = 0; j < numLevels; ++j) - { - float duty = ComputeLevelDuty(rCoarse, fractions[j]); - float vj = ComputeLevelVoltage(duty); - FeedLevelSteadyState(vj / trueR); - } + FeedHfBurst(trueR, trueLs); ASSERT_TRUE(result.has_value()); EXPECT_NEAR(result->resistance.Value(), trueR, trueR * 0.05f); - EXPECT_NEAR(result->inductance.Value(), trueL * 1000.0f, trueL * 1000.0f * 0.10f); - EXPECT_NEAR(result->inverterVoltageOffset.Value(), 0.0f, 0.05f); + EXPECT_NEAR(result->inductance.Value(), trueLs * 1000.0f, trueLs * 1000.0f * 0.10f); + EXPECT_NEAR(result->inverterVoltageOffset.Value(), 0.0f, 1e-6f); EXPECT_LT(result->fitQuality, 0.05f); } -TEST_F(ElectricalParametersIdentificationTest, r_fit_cancels_constant_inverter_voltage_offset) +TEST_F(ElectricalParametersIdentificationTest, rejects_low_frequency_back_emf_disturbance) { const float trueR = 1.5f; - const float trueL = 0.002f; - const float vOffset = 0.3f; - const std::array fractions{ 0.3f, 0.5f, 0.7f }; + const float trueLs = 0.002f; + const float backEmfAmplitude = 0.5f; + const float backEmfFrequency = 2.0f; - services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config; std::optional result; EXPECT_CALL(driverMock, MaxCurrentSupported()) .WillRepeatedly(Return(foc::Ampere{ maxCurrent })); EXPECT_CALL(driverMock, PhaseCurrentsReady(_, _)) .Times(AnyNumber()) - .WillRepeatedly([this](auto, const auto& cb) { driverMock.StorePhaseCurrentsCallback(cb); }); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(4); - EXPECT_CALL(driverMock, Stop()); + .WillRepeatedly([this](auto, const auto& cb) + { + driverMock.StorePhaseCurrentsCallback(cb); + }); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(AnyNumber()); + EXPECT_CALL(driverMock, Stop()).Times(AnyNumber()); - identification.EstimateResistanceAndInductance(config, [&](auto r) { result = r; }); + identification.EstimateResistanceAndInductance(DefaultConfig(), [&](auto r) + { + result = r; + }); - FeedProbeTransient(trueR, trueL); - - const float rCoarse = trueR; - for (std::size_t j = 0; j < numLevels; ++j) - { - float duty = ComputeLevelDuty(rCoarse, fractions[j]); - float vj = ComputeLevelVoltage(duty); - FeedLevelSteadyState((vj - vOffset) / trueR); - } + FeedHfBurst(trueR, trueLs, backEmfAmplitude, backEmfFrequency); ASSERT_TRUE(result.has_value()); EXPECT_NEAR(result->resistance.Value(), trueR, trueR * 0.05f); - EXPECT_NEAR(result->inverterVoltageOffset.Value(), vOffset, 0.1f); + EXPECT_NEAR(result->inductance.Value(), trueLs * 1000.0f, trueLs * 1000.0f * 0.10f); } TEST_F(ElectricalParametersIdentificationTest, applies_delta_winding_correction) { const float terminalR = 1.0f; - const float trueL = 0.001f; - const std::array fractions{ 0.3f, 0.5f, 0.7f }; + const float terminalLs = 0.001f; - services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config; + auto config = DefaultConfig(); config.windingConfig = services::WindingConfiguration::Delta; std::optional result; @@ -180,73 +223,120 @@ TEST_F(ElectricalParametersIdentificationTest, applies_delta_winding_correction) .WillRepeatedly(Return(foc::Ampere{ maxCurrent })); EXPECT_CALL(driverMock, PhaseCurrentsReady(_, _)) .Times(AnyNumber()) - .WillRepeatedly([this](auto, const auto& cb) { driverMock.StorePhaseCurrentsCallback(cb); }); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(4); - EXPECT_CALL(driverMock, Stop()); - - identification.EstimateResistanceAndInductance(config, [&](auto r) { result = r; }); + .WillRepeatedly([this](auto, const auto& cb) + { + driverMock.StorePhaseCurrentsCallback(cb); + }); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(AnyNumber()); + EXPECT_CALL(driverMock, Stop()).Times(AnyNumber()); - FeedProbeTransient(terminalR, trueL); + identification.EstimateResistanceAndInductance(config, [&](auto r) + { + result = r; + }); - const float rCoarse = terminalR; - for (std::size_t j = 0; j < numLevels; ++j) - { - float duty = ComputeLevelDuty(rCoarse, fractions[j]); - float vj = ComputeLevelVoltage(duty); - FeedLevelSteadyState(vj / terminalR); - } + FeedHfBurst(terminalR, terminalLs); ASSERT_TRUE(result.has_value()); EXPECT_NEAR(result->resistance.Value(), terminalR * 1.5f, terminalR * 1.5f * 0.05f); + EXPECT_NEAR(result->inductance.Value(), terminalLs * 1000.0f * 1.5f, terminalLs * 1000.0f * 1.5f * 0.10f); } -TEST_F(ElectricalParametersIdentificationTest, returns_nullopt_when_probe_current_is_zero) +TEST_F(ElectricalParametersIdentificationTest, returns_nullopt_when_current_is_below_floor) { - services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config; std::optional result; + bool completed = false; EXPECT_CALL(driverMock, MaxCurrentSupported()) .WillRepeatedly(Return(foc::Ampere{ maxCurrent })); EXPECT_CALL(driverMock, PhaseCurrentsReady(_, _)) .Times(AnyNumber()) - .WillRepeatedly([this](auto, const auto& cb) { driverMock.StorePhaseCurrentsCallback(cb); }); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)); - EXPECT_CALL(driverMock, Stop()); + .WillRepeatedly([this](auto, const auto& cb) + { + driverMock.StorePhaseCurrentsCallback(cb); + }); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(AnyNumber()); + EXPECT_CALL(driverMock, Stop()).Times(AnyNumber()); - identification.EstimateResistanceAndInductance(config, [&](auto r) { result = r; }); + identification.EstimateResistanceAndInductance(DefaultConfig(), [&](auto r) + { + completed = true; + result = r; + }); - for (std::size_t i = 0; i < probeBufferSize; ++i) + const std::size_t totalSamples = (warmupPeriods + measurementPeriods) * samplesPerPeriod; + for (std::size_t k = 0; k < totalSamples; ++k) driverMock.TriggerPhaseCurrentsCallback({ foc::Ampere{ 0.0f }, foc::Ampere{ 0.0f }, foc::Ampere{ 0.0f } }); + ASSERT_TRUE(completed); EXPECT_FALSE(result.has_value()); } -TEST_F(ElectricalParametersIdentificationTest, returns_nullopt_when_level_current_is_zero) +TEST_F(ElectricalParametersIdentificationTest, aborts_once_with_nullopt_when_peak_current_exceeds_max) { - const float trueR = 1.5f; - const float trueL = 0.002f; - const std::array fractions{ 0.3f, 0.5f, 0.7f }; + // A very low-impedance motor draws a steady current whose peak exceeds MaxCurrentSupported. + const float lowR = 0.05f; + const float lowLs = 0.00002f; - services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config; std::optional result; + int completions = 0; EXPECT_CALL(driverMock, MaxCurrentSupported()) .WillRepeatedly(Return(foc::Ampere{ maxCurrent })); EXPECT_CALL(driverMock, PhaseCurrentsReady(_, _)) .Times(AnyNumber()) - .WillRepeatedly([this](auto, const auto& cb) { driverMock.StorePhaseCurrentsCallback(cb); }); + .WillRepeatedly([this](auto, const auto& cb) + { + driverMock.StorePhaseCurrentsCallback(cb); + }); EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(AnyNumber()); - EXPECT_CALL(driverMock, Stop()); + EXPECT_CALL(driverMock, Stop()).Times(AnyNumber()); - identification.EstimateResistanceAndInductance(config, [&](auto r) { result = r; }); + identification.EstimateResistanceAndInductance(DefaultConfig(), [&](auto r) + { + ++completions; + result = r; + }); - FeedProbeTransient(trueR, trueL); + FeedHfBurst(lowR, lowLs); - const float rCoarse = trueR; - FeedLevelSteadyState(ComputeLevelVoltage(ComputeLevelDuty(rCoarse, fractions[0])) / trueR); - FeedLevelSteadyState(ComputeLevelVoltage(ComputeLevelDuty(rCoarse, fractions[1])) / trueR); - FeedLevelSteadyState(0.0f); + EXPECT_EQ(completions, 1); + EXPECT_FALSE(result.has_value()); +} +TEST_F(ElectricalParametersIdentificationTest, returns_nullopt_when_injection_frequency_is_zero) +{ + std::optional result; + bool completed = false; + + auto config = DefaultConfig(); + config.injectionFrequency = hal::Hertz{ 0 }; + + identification.EstimateResistanceAndInductance(config, [&](auto r) + { + completed = true; + result = r; + }); + + ASSERT_TRUE(completed); + EXPECT_FALSE(result.has_value()); +} + +TEST_F(ElectricalParametersIdentificationTest, returns_nullopt_when_injection_frequency_does_not_divide_sampling) +{ + std::optional result; + bool completed = false; + + auto config = DefaultConfig(); + config.injectionFrequency = hal::Hertz{ 333 }; + + identification.EstimateResistanceAndInductance(config, [&](auto r) + { + completed = true; + result = r; + }); + + ASSERT_TRUE(completed); EXPECT_FALSE(result.has_value()); } @@ -262,6 +352,7 @@ TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_ini .WillOnce(Return(foc::Radians{ 0.0f })); EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, _)); EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)); + EXPECT_CALL(driverMock, Stop()).Times(AnyNumber()); identification.EstimateNumberOfPolePairs(config, [](auto) {}); } @@ -288,9 +379,12 @@ TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_cal }); EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, _)); EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(totalSteps); - EXPECT_CALL(driverMock, Stop()); + EXPECT_CALL(driverMock, Stop()).Times(AnyNumber()); - identification.EstimateNumberOfPolePairs(config, [&](auto result) { resultPolePairs = result; }); + identification.EstimateNumberOfPolePairs(config, [&](auto result) + { + resultPolePairs = result; + }); for (std::size_t i = 0; i < totalSteps; ++i) ForwardTime(std::chrono::milliseconds{ 50 }); @@ -321,9 +415,12 @@ TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_cal }); EXPECT_CALL(driverMock, PhaseCurrentsReady(_, _)); EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(totalSteps); - EXPECT_CALL(driverMock, Stop()); + EXPECT_CALL(driverMock, Stop()).Times(AnyNumber()); - identification.EstimateNumberOfPolePairs(config, [&](auto result) { resultPolePairs = result; }); + identification.EstimateNumberOfPolePairs(config, [&](auto result) + { + resultPolePairs = result; + }); for (std::size_t i = 0; i < totalSteps; ++i) ForwardTime(std::chrono::milliseconds{ 50 }); diff --git a/documentation/design/service-electrical-ident.md b/documentation/design/service-electrical-ident.md index 024b924f..a0c56e2f 100644 --- a/documentation/design/service-electrical-ident.md +++ b/documentation/design/service-electrical-ident.md @@ -29,9 +29,9 @@ date: 2026-04-07 ## Responsibilities **Is responsible for:** -- Automatically measuring phase resistance (R), d-axis inductance (Ld), and q-axis inductance (Lq) without external instruments, using a DC voltage injection technique followed by a transient step response +- Automatically measuring phase resistance (R) and stator inductance (Ls) without external instruments, using a high-frequency (HF) sinusoidal impedance-injection technique on a fixed stator axis - Estimating the motor's number of pole pairs by rotating an open-loop voltage vector through multiple full electrical revolutions and comparing the total electrical angle swept with the total encoder mechanical angle swept -- Protecting against heap allocation by using bounded containers for all internal buffers +- Recovering R and Ls online with O(1) memory (a small fixed set of running sums), requiring no per-sample data buffer - Delivering results exactly once per initiated procedure via a completion callback containing typed physical quantities (Ohm, MilliHenry, or size_t) - Enforcing that the two procedures (resistance/inductance and pole pairs) cannot run concurrently - Stopping the inverter cleanly before invoking any completion callback @@ -39,8 +39,10 @@ date: 2026-04-07 **Is NOT responsible for:** - Persisting the identified parameters — the caller decides what to do with the results - Encoder zero-offset calibration — that is performed by the Motor Alignment service +- Separating Ld from Lq on a salient (interior PMSM) rotor — the fixed-axis HF method reports a single Ls valid for non-salient surface PMSM; saliency separation is future work - Performing any closed-loop current control — all voltage application is open-loop - Running concurrently with the normal FOC loop — the FOC loop must be stopped before either procedure begins +- Aligning or clamping the rotor before measurement — the zero-mean HF injection exerts no net torque, so no rotor alignment is required --- @@ -48,57 +50,86 @@ date: 2026-04-07 ### Procedure 1 — Resistance and Inductance Estimation -This procedure uses two distinct phases — a DC settle phase and a transient sampling phase — each triggered by ADC callbacks from the inverter without busy-waiting. +This procedure injects a single high-frequency sinusoidal voltage on the stationary α-axis (β = 0) and recovers R and Ls from the amplitude and phase of the resulting current. It runs as one continuous burst driven by the ADC/PWM callbacks, with no busy-waiting and no per-sample data buffer. -#### Phase 1a: DC Settle and Resistance Measurement +#### Rationale — Why HF Injection Instead of a DC Step -A known DC voltage is applied to the d-axis of the motor (q-axis voltage = 0, electrical angle = 0°) at a level configured by the caller. A `TimerSingleShot` fires after the configured settle time (default 2 s) to allow transients to decay and the phase current to reach its steady-state value. +A DC field on a single stator axis exerts a constant torque on the rotor magnet. If the rotor is free to move it swings and oscillates about the alignment point, and that motion induces a low-frequency back-EMF. The DC measurement model assumes zero back-EMF, so the estimate is corrupted whenever the rotor is not clamped. -At the end of the settle period, the steady-state phase current is captured from the ADC. Because the motor is stationary and the current is DC, the only impedance in the circuit is the winding resistance: +A zero-mean sinusoid has no DC component, so it exerts **no net torque** and never pumps the rotor. On a surface PMSM (non-salient: Ld ≈ Lq = Ls) a fixed-axis injection sees a **constant** Ls independent of rotor angle, so **no alignment is required**. Any residual rotor oscillation is a low-frequency disturbance that synchronous demodulation at the injection frequency rejects. + +#### Injection and Circuit Model + +The service commands an α-axis voltage `V_alpha(t) = A · sin(ω t)` with `V_beta = 0`, where `ω = 2π · f_inj`. The command is produced by an inverse-Clarke transform of `(V_alpha, 0)` into three phase duties, all centered at 50 % duty so the low-side current shunts stay samplable and the bipolar current sense stays centered. + +At AC steady state the excited-axis behaves as a series RL impedance (the low-frequency back-EMF `e_alpha` is treated as an out-of-band disturbance): ``` -R = V_applied / I_steady_state +V_alpha = R · i_alpha + Ls · di_alpha/dt + e_alpha(t) +i_alpha(t) = I · sin(ω t − φ) +Z = A / I = sqrt(R² + (ω · Ls)²) +φ = atan2(ω · Ls, R) ``` -The result is stored internally. If the measured current is zero or below a noise floor, the procedure fails immediately and the callback is invoked with absent values. +#### Synchronous Demodulation and Closed-Form Recovery -#### Phase 1b: Inductance Estimation via Transient Step Response +The measured α-axis current (obtained by a forward Clarke transform of the three sampled phase currents) is correlated against sine and cosine references at the injection frequency. After a warm-up interval that lets the AC transient decay, the service accumulates, over an **integer** number of injection periods (N samples total), three running sums: -Immediately after the DC settle phase, an additional voltage step is applied and the current transient is sampled. Each ADC callback appends one sample to an `infra::BoundedVector` (capacity 128). A 5-sample moving-average (using an `infra::BoundedDeque` of capacity 5) is applied in-flight to each incoming sample before storage, reducing high-frequency noise on the measurement. +``` +sumSin = Σ i_alpha[k] · sin(θ_k) +sumCos = Σ i_alpha[k] · cos(θ_k) +sumSq = Σ i_alpha[k]² θ_k = ω · k / f_s (wrapped to [0, 2π)) +``` -Once the buffer is full, the inductance is derived from the first-order step-response approximation: +Only three floats are retained regardless of burst length (O(1) memory). The in-phase and quadrature current components and the closed-form parameters follow directly: ``` -L = V_step × Δt / ΔI +I_re = 2 · sumSin / N = I · cos φ +I_im = 2 · sumCos / N = −I · sin φ +D = I_re² + I_im² = I² + +R = A · I_re / D +Ls = −A · I_im / (ω · D) ``` -where Δt is the total sampling interval and ΔI is the change in current over that interval. This single-time-constant model is accurate for unsaturated surface PMSM windings. +**PWM→ADC pipeline lag.** The duty commanded in one callback drives the current sampled in the next, so the sampled current reflects a voltage commanded roughly one sample earlier. The applied voltage uses the live injection phase, but the demodulation reference uses that phase lagged by `voltageToCurrentDelaySamples` phase increments (default 1). This removes the `ε = 2π·f_inj/f_s` phase error that would otherwise bias R by `cos(φ+ε)/cos(φ)`. The delay is rig-calibrated: the operator tunes it until the measured R matches a multimeter DC-resistance reading. -For surface PMSM, Lq ≈ Ld, so both values are reported as the same measured inductance. For interior PMSM, the approximation introduces an error that must be accepted or corrected by the caller. +**Back-EMF rejection.** Because the accumulation spans an integer number of injection periods, any component at a frequency other than `f_inj` (in particular the ~1–2 Hz rotor-oscillation back-EMF) integrates toward zero in both sums. A larger measurement window drives the residual leakage lower. + +**Amplitude scaling.** For a center-aligned half-bridge the phase-to-midpoint voltage amplitude is `modIndex · Vdc / 2`, where `modIndex` is the α modulation index (`injectionVoltagePercent / 100`, internally clamped so every leg duty stays within a samplable window). The applied α voltage amplitude used in the closed form is therefore `A = modIndex · Vdc / 2`. + +**Winding topology.** For a Delta connection the terminals measure ⅔ of the per-phase value for both R and Ls; the phase quantities are recovered with `R_phi = R_terminal · 1.5` and `Ls_phi = Ls_terminal · 1.5`. + +**Injection-frequency selection.** `f_inj` must divide the sampling frequency (10 kHz) so that each measurement window is an exact integer number of samples; valid options are 200 / 250 / 500 Hz. Conditioning is best when `ω · Ls ≈ (1–3) · R` (phase 45°–70°), which keeps the current well above the shunt noise floor while separating R and Ls. For the reference rig (R ≈ 1.5 Ω, Ls ≈ 2 mH) the default is **250 Hz**, leaving a ~125× margin over the rotor-oscillation frequency. ```mermaid sequenceDiagram participant Caller participant Service participant Inverter - participant Timer Caller->>Service: EstimateResistanceAndInductance(config, onDone) - Service->>Inverter: apply Vd=test_voltage, Vq=0 - Service->>Timer: start settle timer (2 s) - Timer-->>Service: settled - Service->>Inverter: read steady-state current → compute R - Service->>Inverter: apply voltage step, start sampling - loop 128 samples - Inverter-->>Service: ADC callback → filter → buffer + Service->>Inverter: stop, then arm current sampling at f_s + loop warm-up periods + Inverter-->>Service: current sample + Service->>Inverter: apply V_alpha = A·sin(θ) (samples discarded) + end + loop measurement periods (integer) + Inverter-->>Service: current sample + Service->>Service: i_alpha = Clarke.Forward(phases) + Service->>Service: accumulate sumSin, sumCos, sumSq + Service->>Inverter: apply V_alpha = A·sin(θ) end Service->>Inverter: stop - Service-->>Caller: onDone(R, L) + Service->>Service: I_re, I_im, D → R, Ls, fitQuality + Service-->>Caller: onDone(R, Ls) or nullopt ``` -#### Error Conditions +#### Fit Quality and Error Conditions + +A THD-like residual `fitQuality = |sumSq − N · I² / 2| / (N · I² / 2)` is reported (0 = perfect sinusoid). It is a **diagnostic only**: because demodulation already rejects out-of-band content from R and Ls, a raised residual flags a disturbance (e.g., rotor motion or distortion) without invalidating the recovered parameters. The inverter-voltage-offset field is not measured by the HF method and is reported as zero for API compatibility. -If the settle timer expires but the ADC current reading is below the noise floor, the procedure fails (both output values absent). If the sample buffer fills but the current change is too small to yield a sensible inductance (e.g., ΔI < noise floor), the inductance is reported absent while resistance may still be valid. +The procedure returns absent values when the demodulated current magnitude is below the minimum-current floor (sized to the shunt/ADC SNR, ~0.05 A; compared as a squared magnitude to avoid a square root), or when the recovered R or Ls is non-positive. The configured injection frequency is validated at start: if it is zero or does not divide the sampling frequency the completion callback fires immediately with an absent result (no division is attempted). As a safety guard, every incoming sample (warm-up and measurement) is checked against the driver's maximum supported current; if the peak measured phase current exceeds it, the drive is stopped and the procedure aborts once with an absent result. ### Procedure 2 — Pole Pairs Estimation @@ -135,29 +166,62 @@ flowchart TD VALID -->|No| CB_FAIL["onDone(nullopt)"] ``` -### Internal Buffer Constraints +### Internal State Constraints -All internal state is statically allocated: +All internal state is statically allocated and O(1) in size. The HF resistance/inductance procedure keeps no per-sample data buffer at all — it retains only a fixed set of running accumulators: -| Buffer | Container | Capacity | Purpose | -|-----------------------|------------------------|-------------|-------------------------------------------------------------| -| Current samples | `infra::BoundedVector` | 128 entries | Stores filtered current transient for inductance estimation | -| Moving-average window | `infra::BoundedDeque` | 5 entries | Rolling window for in-flight noise reduction on ADC samples | +| State | Type | Purpose | +|-----------------------|---------------|-------------------------------------------------------------------------| +| `sumSin`, `sumCos` | float | In-phase / quadrature synchronous-demodulation accumulators | +| `sumSq` | float | Sum of squared current, used for the THD-like fit-quality diagnostic | +| phase, phase increment | float | Injection-oscillator state advanced once per sample | +| sample / period counts | integer | Warm-up and measurement window bookkeeping | -No heap allocation is used. Buffers are members of the service object and are reused across repeated procedure invocations. +No heap allocation is used, and memory usage is independent of the number of injection periods. The accumulators are members of the service object and are reset at the start of each procedure invocation. ### Concurrency Invariant The two procedures are independent state machines. Neither may be started while the other is in the Running state. An attempt to start one while the other is already Running causes the new request to be rejected (callback invoked immediately with absent values). The two state machines share no mutable state beyond the inverter and encoder references. +### Acquisition / Actuation Sequencing Invariant + +Each measurement phase of both procedures drives the motor through a strict ordering: + +1. **Stop the drive.** Excitation is removed first. Because current acquisition is slaved to the drive excitation, stopping the drive also stops acquisition — no separate action is needed to silence sampling. +2. **Arm acquisition.** The service prepares to receive current samples for the upcoming phase. Acquisition is only ever re-armed while the drive is stopped. +3. **Apply excitation.** The drive is energised for the phase. + +This ordering guarantees acquisition is ready before any current can flow, and that samples belonging to a previous phase can never be attributed to a new excitation. + +For the HF resistance/inductance procedure the excitation is applied inside the sampling callback itself, so a warm-up window (an integer number of injection periods) precedes measurement: while the AC transient decays, incoming samples are demodulated-but-discarded; afterwards the accumulators integrate over an integer number of measurement periods. Once the total sample budget is reached the drive is stopped and any further samples are ignored, so completion happens exactly once and remains safe even if a sample arrives just after the drive has been stopped. (The pole-pairs procedure instead uses a per-step settle timer, described in Procedure 2.) + +```mermaid +sequenceDiagram + participant Service + participant Drive as Motor Drive + Note over Service,Drive: HF resistance/inductance burst + Service->>Drive: Stop excitation (acquisition follows) + Service->>Drive: Arm acquisition + loop warm-up periods + Drive-->>Service: current sample + Service->>Drive: apply V_alpha = A·sin(θ) (sample discarded) + end + loop measurement periods (integer) + Drive-->>Service: current sample + Service->>Service: accumulate sumSin, sumCos, sumSq + Service->>Drive: apply V_alpha = A·sin(θ) + end + Service->>Drive: Stop excitation (before completion) +``` + ### State Machine (Both Procedures) ```mermaid stateDiagram-v2 [*] --> Idle Idle --> Running : procedure initiated - Running --> Complete : all samples captured,\nresult computed - Running --> Failed : noise floor violation\nor timeout + Running --> Complete : sample budget reached,\nresult computed + Running --> Failed : current below floor,\nnon-positive R/Ls,\nor peak over max current Complete --> Idle : onDone fired Failed --> Idle : onDone(nullopt) fired ``` @@ -170,7 +234,7 @@ stateDiagram-v2 | Interface | Purpose | Contract | |---------------------------------------------------|---------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| -| `EstimateResistanceAndInductance(config, onDone)` | Runs the DC settle and transient-step procedure; delivers `(optional, optional)` | Rejected (immediate failure callback) if the pole-pairs procedure is already Running; inverter stopped before callback fires; fires exactly once | +| `EstimateResistanceAndInductance(config, onDone)` | Runs the HF sinusoidal impedance-injection procedure; delivers `optional<{Ohm, MilliHenry, ...}>` | Rejected (immediate failure callback) if the pole-pairs procedure is already Running; inverter stopped before callback fires; fires exactly once | | `EstimateNumberOfPolePairs(config, onDone)` | Sweeps an open-loop rotating vector and delivers `optional` pole pairs | Rejected if the R/L procedure is already Running; inverter stopped before callback fires; fires exactly once | ### Required diff --git a/documentation/theory/resistance-inductance-estimation.md b/documentation/theory/resistance-inductance-estimation.md index 347fe5f8..2456036a 100644 --- a/documentation/theory/resistance-inductance-estimation.md +++ b/documentation/theory/resistance-inductance-estimation.md @@ -2,9 +2,9 @@ title: "Electrical Parameters Identification — Resistance and Inductance" type: theory status: approved -version: 2.0.0 +version: 3.0.0 component: "service-electrical-ident" -date: 2026-07-11 +date: 2026-07-19 --- | Field | Value | @@ -12,9 +12,9 @@ date: 2026-07-11 | Title | Electrical Parameters Identification — R and L | | Type | theory | | Status | approved | -| Version | 2.0.0 | +| Version | 3.0.0 | | Component | service-electrical-ident | -| Date | 2026-07-11 | +| Date | 2026-07-19 | ## Overview @@ -24,13 +24,11 @@ parameters are used to: 2. Implement feed-forward decoupling of the dq cross-coupling terms. 3. Estimate motor temperature from measured $R_s$ (since $R_s \propto T$). -This identification procedure applies a sequence of DC voltage steps to a single stator axis and -measures the resulting current. Because the rotor is stationary throughout, back-EMF is zero and the -excited axis behaves as a first-order RL circuit. Resistance is derived from a **multi-point -differential fit** ($\Delta V / \Delta I$) that cancels constant inverter and sensor offsets, and -inductance from the **integral of the current transient**. The excitation levels are **auto-scaled** -to the motor using a coarse pre-probe so that each level reaches a target fraction of the drive's -maximum current. +This identification procedure injects a **high-frequency (HF) sinusoidal voltage** on a fixed stator +axis (the stationary $\alpha$-axis) and recovers $R_s$ and $L_s$ from the amplitude and phase of the +resulting current using **synchronous demodulation**. Unlike a DC-step method, the injection is +zero-mean so it produces **no net torque**, the rotor is not required to be clamped or aligned, and +the demodulation **rejects** any low-frequency back-EMF caused by residual rotor motion. --- @@ -39,151 +37,201 @@ maximum current. | Symbol | Meaning | Unit | |------------|-----------------------------------------------------|---------| | $R_s$ | Stator resistance per phase | Ω | -| $L_s$ | Stator inductance (d-axis, $L_d$) | H | -| $\tau$ | Electrical time constant = $L_s / R_s$ | s | -| $V_j$ | Applied step voltage at level $j$ | V | -| $I_{ss,j}$ | Steady-state current at level $j$ | A | -| $V_{err}$ | Inverter voltage error (fit intercept) | V | -| $V_{probe}$| Coarse pre-probe voltage | V | -| $I_{ss}$ | Steady-state current of the probe transient | A | +| $L_s$ | Stator inductance ($L_d \approx L_q$ for SPMSM) | H | +| $A$ | Injected $\alpha$-axis voltage amplitude | V | +| $f_{inj}$ | Injection frequency | Hz | +| $\omega$ | Injection angular frequency = $2\pi f_{inj}$ | rad/s | +| $I$ | Steady-state current amplitude | A | +| $\varphi$ | Current phase lag | rad | +| $Z$ | Impedance magnitude $A/I$ | Ω | | $f_s$ | Sampling frequency | Hz | -| $T_s$ | Sampling period = $1/f_s$ | s | -| $N_{levels}$ | Number of $\Delta V/\Delta I$ levels | — | -| $N_{buf}$ | Probe transient buffer size | samples | +| $N$ | Number of accumulated samples (integer periods) | samples | +| $M$ | Number of measurement injection periods | — | --- ## Mathematical Foundation -### 1. Single-Axis Excitation and Back-EMF Suppression +### 1. Fixed-Axis Injection and the SPMSM Non-Saliency Assumption -The procedure energises one stator axis with a DC field (high duty on phase A, neutral on B and C). -Because every step is a DC level, the rotor is **stationary** at the moment of measurement, so: +The service injects on the stationary $\alpha$-axis with $\beta = 0$: -- The electrical speed is $\omega_e = 0$, hence the back-EMF $e = \psi_f \omega_e = 0$. -- Only the excited RL circuit carries current. - -The rotor is pulled into alignment with the applied field during the coarse pre-probe and the first -graduated levels. Because every level shares the **same** stator axis, the equilibrium angle never -changes between levels — so the rotor does not move during the transient used for inductance. No -explicit alignment routine is required; a poor regression fit (Section 3) flags any residual motion. +$$ +V_\alpha(t) = A \sin(\omega t), \qquad V_\beta = 0, \qquad \omega = 2\pi f_{inj} +$$ -The excited-axis circuit model reduces to: +For a **surface-mounted PMSM** the rotor is magnetically non-salient ($L_d \approx L_q = L_s$), so the +inductance seen along any fixed stator axis is the same constant $L_s$ **regardless of rotor angle**. +No rotor alignment is therefore required. The excited-axis voltage equation is $$ -v = R_s\, i + L_s \frac{di}{dt} +V_\alpha = R_s\, i_\alpha + L_s \frac{di_\alpha}{dt} + e_\alpha(t) $$ -This is a first-order linear system driven by a step of amplitude $V$. +where $e_\alpha(t)$ is the (low-frequency) back-EMF, treated below as an out-of-band disturbance. -### 2. RL Step Response +**Zero net torque.** A DC field on one axis exerts a constant torque that swings a free rotor, +inducing back-EMF that corrupts a DC measurement. A zero-mean sinusoid has no DC component, exerts no +net torque, and never pumps the rotor — which is why HF injection needs no clamp. -For a step input $v(t) = V \cdot u(t)$ with zero initial conditions ($i(0) = 0$): +### 2. AC Steady-State Impedance + +Ignoring $e_\alpha$, the linear RL circuit driven at $\omega$ has the steady-state solution $$ -\boxed{i(t) = \frac{V}{R_s}\!\left(1 - e^{-t/\tau}\right)}, \qquad \tau = \frac{L_s}{R_s} +i_\alpha(t) = I \sin(\omega t - \varphi), \qquad +Z = \frac{A}{I} = \sqrt{R_s^2 + (\omega L_s)^2}, \qquad +\varphi = \operatorname{atan2}(\omega L_s,\, R_s) $$ -Key properties of this response: -- At $t = \tau$: $i(\tau) = I_{ss}(1 - e^{-1}) \approx 0.6321 \cdot I_{ss}$ -- At $t = 5\tau$: $i(5\tau) \approx 0.9933 \cdot I_{ss}$ (essentially settled) -- Slope at $t = 0$: $\left.\frac{di}{dt}\right|_{t=0} = \frac{V}{L_s}$ +so the resistance and inductance are the real and imaginary parts of the impedance: -### 3. Resistance Estimation — Multi-Point Differential Fit +$$ +R_s = Z \cos\varphi, \qquad \omega L_s = Z \sin\varphi +$$ + +### 3. Synchronous Demodulation (Online, O(1) Memory) -A single-point estimate $R_s = V/I_{ss}$ bakes every constant error — inverter dead-time, MOSFET and -body-diode drops, and current-sensor DC offset — directly into $R_s$. Instead, $N_{levels}$ steps are -applied and the steady-state pairs $(I_{ss,j}, V_j)$ are fit by ordinary least squares to a line: +The measured $\alpha$-axis current (a forward Clarke transform of the three sampled phase currents) is +correlated with sine and cosine references at $\omega$. Over an **integer** number of injection +periods ($N = M \cdot f_s / f_{inj}$ samples), three running sums are accumulated — no per-sample +buffer is stored: $$ -V_j = R_s\, I_{ss,j} + V_{err} +S = \sum_{k} i_\alpha[k]\sin\theta_k, \quad +C = \sum_{k} i_\alpha[k]\cos\theta_k, \quad +\Sigma_2 = \sum_{k} i_\alpha[k]^2, \qquad \theta_k = \omega\,k/f_s \pmod{2\pi} $$ -- The **slope** $R_s$ is immune to any constant voltage error or current offset — they cancel in the - differential $\Delta V/\Delta I$. -- The **intercept** $V_{err}$ estimates the total inverter voltage error, exported as free diagnostic - data (usable later for dead-time compensation). +Using $\langle \sin^2 \rangle = \tfrac12$ and $\langle \sin\theta\cos\theta \rangle = 0$ over integer +periods: -**Auto-scaling.** A coarse pre-probe at $V_{probe}$ yields $R_{coarse} = V_{probe}/I_{probe}$. Each -level then targets a current fraction $f_j$ of the drive maximum $I_{max}$, choosing the duty so that -$V_j \approx f_j\, I_{max}\, R_{coarse}$ (clamped to a safe duty range). This keeps the currents high -enough to escape the worst dead-time non-linearity regardless of the motor. +$$ +I_{re} = \frac{2S}{N} = I\cos\varphi, \qquad +I_{im} = \frac{2C}{N} = -\,I\sin\varphi, \qquad +D = I_{re}^2 + I_{im}^2 = I^2 +$$ -**Fit quality.** The maximum normalised residual -$\max_j |V_j - (R_s I_{ss,j} + V_{err})| / R_s$ is reported. A large value indicates the $V$–$I$ -relationship was not linear — typically rotor motion or ADC saturation — and the estimate is -rejected. +### 4. Closed-Form Recovery + +Substituting $A = ZI$ and the identities of Section 2: + +$$ +\boxed{R_s = \frac{A\, I_{re}}{D}}, \qquad +\boxed{L_s = \frac{-\,A\, I_{im}}{\omega\, D}} +$$ + +Indeed $A I_{re}/D = (ZI)(I\cos\varphi)/I^2 = Z\cos\varphi = R_s$, and +$-A I_{im}/(\omega D) = (ZI)(I\sin\varphi)/(\omega I^2) = Z\sin\varphi/\omega = L_s$. **Winding topology.** For a Delta connection the terminals measure $\tfrac{2}{3}$ of the per-phase -value for both resistance and inductance; the phase quantities are recovered with -$R_\phi = R_{terminal} \cdot k_\Delta$ and $L_\phi = L_{terminal} \cdot k_\Delta$, $k_\Delta = 1.5$. +value for both quantities; the phase values are recovered with $R_\phi = R_{terminal}\,k_\Delta$ and +$L_\phi = L_{terminal}\,k_\Delta$, $k_\Delta = 1.5$. + +**Amplitude scaling.** For a center-aligned half-bridge the phase-to-midpoint voltage amplitude is +$\text{modIndex}\cdot V_{dc}/2$, where the $\alpha$ modulation index equals +$\text{injectionVoltagePercent}/100$ (inverse Clarke maps $\alpha$ to phase A one-to-one). The applied +amplitude used in the closed form is $A = \text{modIndex}\cdot V_{dc}/2$. The modulation index is +clamped so that every leg duty stays within a samplable window at the injection peaks. + +### 5. Back-EMF Rejection + +Because the sums span an **integer** number of injection periods, any spectral component at a +frequency $\neq f_{inj}$ integrates toward zero. Rotor oscillation appears as a $\sim$1–2 Hz back-EMF +(from on-rig logs); at $f_{inj} = 250\,\text{Hz}$ this is $\sim$125× away, so its contribution to +$S$ and $C$ — and hence to $R_s$ and $L_s$ — is negligible. Increasing $M$ lowers the residual +leakage further. -### 4. Inductance Estimation — Integral Method +### 6. Fit Quality (Diagnostic Only) -Rather than locating the 63.2% threshold crossing (which quantises $\tau$ to one sample and is -sensitive to a single noisy point), the inductance is obtained from the integral identity of a -first-order rise. For $i(t) = I_{ss}(1 - e^{-t/\tau})$: +A THD-like residual quantifies how sinusoidal the measured current was: $$ -\int_0^\infty \bigl(I_{ss} - i(t)\bigr)\,dt = I_{ss}\,\tau -\quad\Longrightarrow\quad -\boxed{L_s = R_s \cdot \frac{\displaystyle\sum_k \bigl(I_{ss} - i[k]\bigr)\,T_s}{I_{ss}}} +\text{fitQuality} = \frac{\bigl|\,\Sigma_2 - N I^2/2\,\bigr|}{N I^2/2} $$ -The sum runs over the full probe transient (from step onset to plateau). Properties: +The demodulated fundamental carries energy $N I^2/2$; any excess is distortion or out-of-band +disturbance (0 = perfect sinusoid). This is **reported as a diagnostic only** and does not invalidate +$R_s$ or $L_s$: synchronous demodulation has already rejected out-of-band content from the parameter +estimates, so a raised residual flags a disturbance (rotor motion, saturation, dead-time distortion) +rather than a bad measurement. -- **Every sample contributes**, so noise averages out and the result has sub-sample-period - resolution — it removes both weaknesses of the threshold method. -- The integrand is a **difference** $(I_{ss} - i[k])$, so any constant current-sensor offset cancels. -- The probe step starts from near-zero current, so the denominator is the probe $I_{ss}$ and $R_s$ is - the value fitted in Section 3. +### 7. PWM-to-ADC Pipeline Lag Compensation -**Requirement**: the buffer must span the transient to a true plateau ($N_{buf} \gtrsim 5\tau/T_s$). -If $\tau$ is large relative to the buffer, increase $N_{buf}$ or the probe voltage. +On real hardware the duty commanded in callback $k$ drives the current that is sampled in callback +$k+1$: the current measured now was produced by a voltage commanded roughly one sample earlier. +Demodulating the sampled current against the **current** injection phase therefore introduces a phase +error -### 5. Pole Pair Estimation +$$ +\varepsilon = 2\pi\,\frac{f_{inj}}{f_s} +$$ -The number of electrical cycles per mechanical revolution equals the number of pole pairs $p$. -During the multi-step alignment sweep, the motor is driven through exactly 12 electrical steps over -one full electrical revolution ($2\pi$ electrical). The encoder counts $C_{mech}$ per step multiplied -by $N_{steps} = 12$ gives the total encoder counts per full electrical cycle. Dividing by the known -encoder counts per mechanical revolution $C_{rev}$ gives: +($\approx 9°$ at the defaults $f_{inj}=250\,\text{Hz}$, $f_s=10\,\text{kHz}$). Uncompensated this biases +the recovered resistance to $R_{meas} = Z\cos(\varphi + \varepsilon)$ instead of $Z\cos\varphi$ — about +$-34\%$ at the reference rig point ($\varphi \approx 64.5°$). + +The **applied** voltage keeps using the live injection phase, $V_\alpha = A\sin(\theta_{inj})$. The +**demodulation reference** instead uses the phase that produced the sampled current, $$ -p = \frac{C_{rev}}{12 \cdot C_{per\_step}} \quad \text{(integer, rounded)} +\theta_{demod} = \theta_{inj} - d\cdot\Delta\theta \pmod{2\pi}, \qquad \Delta\theta = 2\pi f_{inj}/f_s $$ -or equivalently, the total electrical angle traversed over the full 12-step sweep spans exactly -$2\pi$ electrical = $2\pi/p$ mechanical, so: +where $d$ = `voltageToCurrentDelaySamples` (default **1**). With $d$ matched to the pipeline depth the +phase error cancels and $R_{meas} = Z\cos\varphi = R_s$. The default of 1 sample fits the async +PWM/ADC hal, but it is **rig-calibrated**: the operator can tune $d$ until the measured $R$ matches a +multimeter DC-resistance reading of the winding. + +### 8. Injection-Frequency Selection + +Three constraints pull on $f_{inj}$: + +1. **Integer samples per period** — $f_{inj}$ must divide $f_s$ so each window is an exact integer + number of periods. At $f_s = 10\,\text{kHz}$ the valid options are 200 / 250 / 500 Hz + (50 / 40 / 20 samples/period). +2. **Conditioning and current SNR** — $Z = \sqrt{R_s^2 + (\omega L_s)^2}$ becomes inductance-dominated + at high $f_{inj}$, shrinking the current and separating $R_s$ poorly. Best conditioning is at + $\omega L_s \approx (1\text{–}3) R_s$ (i.e. $\varphi \approx 45°\text{–}70°$). +3. **Back-EMF margin** — $f_{inj}$ must sit far above the rotor-oscillation frequency. + +For the reference rig ($R_s \approx 1.5\,\Omega$, $L_s \approx 2\,\text{mH}$) these give $\sim$120–350 Hz; +the default is **250 Hz** ($\omega L_s \approx 3.1\,\Omega$, $I \approx 0.3\text{–}0.5\,\text{A}$). + +### 9. Pole Pair Estimation + +The number of electrical cycles per mechanical revolution equals the number of pole pairs $p$. +An open-loop voltage vector is rotated through a known number of full electrical revolutions and the +total mechanical rotation is measured by the encoder. Over the sweep the total electrical angle spans +$2\pi N_{rev}$ electrical, which corresponds to $2\pi N_{rev}/p$ mechanical, so: $$ -p = \frac{2\pi}{\Delta\theta_{mech,total}} +p = \operatorname{round}\!\left(\frac{N_{rev}}{\Delta\theta_{mech,total} / 2\pi}\right) $$ -where $\Delta\theta_{mech,total}$ is the measured total mechanical rotation during the sweep. +where $\Delta\theta_{mech,total}$ is the measured total mechanical rotation. This procedure is purely +kinematic and is unchanged by the HF impedance method. -### 6. Complete Identification Sequence +### 10. Complete Identification Sequence ``` -1. Coarse pre-probe: apply V_probe on one axis, collect the full transient, - take I_probe from the last 10% -> R_coarse = V_probe / I_probe. - (This step also settles/aligns the rotor to the excited axis.) - -2. For each level j in [0 .. N_levels-1]: - a. Auto-scale duty so the current targets f_j * I_max (using R_coarse). - b. Settle for settlePerLevel, then average a steady-state batch -> I_ss_j. - Record the applied voltage V_j. +1. Compute injection parameters: modIndex = injectionVoltagePercent/100 (clamped), + omega = 2*pi*f_inj, samples/period = f_s / f_inj (must be integer). -3. Fit V_j = R_s * I_ss_j + V_err by least squares. - Reject if any I_ss_j is near zero, if the slope R_s <= 0, or if the - normalised residual exceeds the fit-quality threshold. +2. Arm current sampling at f_s. Each callback: + a. Emit V_alpha = A*sin(theta_inj) via inverse Clarke + centered duties (applied phase). + b. After the warm-up periods, accumulate S, C, sumSq from i_alpha = Clarke.Forward(phases), + demodulating against theta_demod = theta_inj - d*delta_theta (d = voltageToCurrentDelaySamples). + c. Advance both phases; stop after (warmup + measurement) periods. -4. Inductance from the probe transient integral: - L_s = R_s * sum((I_ss - i[k]) * T_s) / I_ss. +3. I_re = 2S/N, I_im = 2C/N, D = I_re^2 + I_im^2. + Reject if sqrt(D) is below the min-current floor. -5. Apply the Delta winding correction (k = 1.5) to both R_s and L_s when configured. +4. R_s = A*I_re/D, L_s = -A*I_im/(omega*D). + Apply the Delta winding correction (k = 1.5) when configured. + Reject if R_s <= 0 or L_s <= 0. -6. Report { R_s, L_s, V_err, fit_quality }. +5. Report { R_s, L_s, 0 (offset), fitQuality }. ``` --- @@ -194,39 +242,31 @@ where $\Delta\theta_{mech,total}$ is the measured total mechanical rotation duri ```mermaid graph TD - A[Coarse pre-probe\nV_probe on one axis] --> B[R_coarse = V_probe / I_probe\nrotor settles on axis] - B --> C[For each level j:\nauto-scale duty to f_j * I_max] - C --> D[Settle + average\nI_ss_j] - D --> E{More levels?} - E -- yes --> C - E -- no --> F[LS fit\nV_j = R_s I_ss_j + V_err] - F --> G[Integral method\nL_s = R_s Σ(I_ss − i)Ts / I_ss] - F --> H[Fit quality\n+ Delta correction] - G --> I[Report R_s, L_s, V_err, quality] - H --> I + A[Inject V_alpha = A sin(wt)\nalpha-axis, beta = 0] --> B[Sample phase currents\ni_alpha = Clarke.Forward] + B --> C[Warm-up periods:\ndemodulate, discard] + C --> D[Measurement periods:\naccumulate S, C, sumSq] + D --> E[I_re = 2S/N, I_im = 2C/N\nD = I_re^2 + I_im^2] + E --> F{sqrt(D) above\nmin-current floor?} + F -- no --> G[nullopt] + F -- yes --> H[R_s = A I_re / D\nL_s = -A I_im / (w D)] + H --> I[Delta correction\n+ fitQuality diagnostic] + I --> J[Report R_s, L_s, 0, quality] ``` -### RL Step Response — ASCII Approximation +### AC Steady-State Current — ASCII Approximation ``` -i_d (normalised: I_ss = 1.0) - │ -1.0├───────────────────────────────── I_ss = V/R (steady state) - │ ────────── -0.86├──────────────────────/ - │ / -0.63├────────────────────/ ← i(τ) = 0.632·I_ss - │ / - │ / -0.39├─────────────────/ - │ / - │ / - │ / -0.0├─────────── - └───────────────────────────────── samples (n·T_s) - 0 τ/T_s 2τ/T_s 5τ/T_s - -The whole shaded area between I_ss and i(t) is integrated: L_s = R_s · area / I_ss. +V_alpha, i_alpha (normalised) + │ V_alpha = A sin(wt) + +1├ .-''-. .-''-. + │ .' '. .' '. + │ / \ / \ + 0├--/------------\----/------------\---- wt + │ / \ / \ + │' '' ' + -1├ i_alpha = I sin(wt - phi) (lags by phi) + +phi = atan2(w Ls, R); R = Z cos phi; w Ls = Z sin phi ``` --- @@ -235,65 +275,77 @@ The whole shaded area between I_ss and i(t) is integrated: L_s = R_s · area / I | Property | Value / Condition | |----------------------|--------------------------------------------------------------| -| Sampling rate | $f_s = 10\ \text{kHz}$, $T_s = 100\ \mu\text{s}$ | -| Probe buffer | $N_{buf} = 512$ samples ($51.2\ \text{ms}$) | -| Resistance levels | $N_{levels} = 3$ (default fractions $0.3, 0.5, 0.7$) | -| Steady-state batch | $32$ samples per level | -| Threshold | integral method — no fixed threshold | -| $R_s$ range | Nominally $0.1\ \Omega$ to $50\ \Omega$ (ADC current range) | -| $L_s$ resolution | sub-sample (integral of the full transient) | -| Fit-quality gate | reject if normalised residual $> 0.1$ | -| Trigger voltage | auto-scaled to target current fraction of $I_{max}$ | +| Sampling rate | $f_s = 10\ \text{kHz}$ | +| Injection frequency | default $250\ \text{Hz}$ (must divide $f_s$) | +| Injection amplitude | default $15\%$ modulation, clamped to a safe duty window | +| Warm-up periods | $10$ (AC transient decay before accumulation) | +| Measurement periods | $50$ (integer — sets demodulation window $N$) | +| PWM→ADC delay | `voltageToCurrentDelaySamples` default $1$ (rig-calibrated) | +| Memory | O(1): three float accumulators, no sample buffer | +| Min-current floor | $\approx 0.05\ \text{A}$ demodulated magnitude | +| $R_s$ / $L_s$ recovery | closed form from in-phase / quadrature current | +| Fit-quality | THD-like residual, reported as diagnostic (not a gate) | ### Sensitivity Analysis | Source of Error | Effect on $R_s$ | Effect on $L_s$ | |----------------------------|------------------------------------------|------------------------------------------| -| ADC current offset | Cancelled by the differential fit | Cancelled in the integrand difference | -| Inverter dead-time / drops | Cancelled (lands in $V_{err}$ intercept) | Indirect via $R_s$ error | -| $V_{dc}$ variation | Biases $V_j$ (kept brief to limit drift) | Indirect via $R_s$ error | -| Thermal drift in $R_s$ | Measurement valid at $T_{meas}$ only | — | -| Rotor motion in transient | Flagged by fit-quality residual | Corrupts integral; rejected if flagged | -| Insufficient buffer | — | $\tau$ / area underestimated | -| Magnetic saturation | $R_s$ underestimated | $L_s$ underestimated (nonlinear) | +| Low-frequency back-EMF | Rejected by integer-period demodulation | Rejected by integer-period demodulation | +| ADC current offset (DC) | Rejected (orthogonal to $\sin/\cos$) | Rejected (orthogonal to $\sin/\cos$) | +| PWM→ADC pipeline lag | $\varepsilon = 2\pi f_{inj}/f_s$ biases $R$ ($\cos(\varphi+\varepsilon)/\cos\varphi$); compensated via `voltageToCurrentDelaySamples` | Compensated with the same lagged reference | +| Inverter dead-time | Small apparent-$R$ bias (in phase) | Indirect | +| $V_{dc}$ variation | Biases $A$ (kept brief to limit drift) | Biases $A$ | +| High $f_{inj}$ | Poor $R_s$ separation (low current) | Well conditioned | +| Magnetic saturation | $R_s$ / $L_s$ underestimated (nonlinear) | $L_s$ underestimated (nonlinear) | +| Rotor saliency (IPMSM) | Axis-dependent $L$ — not separated | Reports a single blended $L_s$ | --- ## Worked Example -Motor: $R_s = 1.2\ \Omega$, $L_s = 0.6\ \text{mH}$, $f_s = 10\ \text{kHz}$, -three levels at $V_1 = 1.2\,\text{V}$, $V_2 = 2.0\,\text{V}$, $V_3 = 2.8\,\text{V}$ with a -constant inverter error $V_{err} = 0.3\,\text{V}$. +Motor: $R_s = 1.5\,\Omega$, $L_s = 2\,\text{mH}$, $V_{dc} = 24\,\text{V}$, $f_{inj} = 250\,\text{Hz}$, +injection $15\%$ modulation. -**Steady-state currents** ($I_{ss,j} = (V_j - V_{err})/R_s$): +**Applied amplitude:** $A = 0.15 \times 24/2 = 1.8\,\text{V}$. -$$I_{ss,1} = 0.75\,\text{A},\quad I_{ss,2} = 1.417\,\text{A},\quad I_{ss,3} = 2.083\,\text{A}$$ +**Impedance and current:** -**Resistance** — the least-squares slope through the three points: +$$ +\omega = 2\pi \cdot 250 = 1570.8\ \text{rad/s}, \quad +\omega L_s = 3.14\,\Omega, \quad +Z = \sqrt{1.5^2 + 3.14^2} = 3.48\,\Omega, \quad +I = A/Z = 0.517\,\text{A} +$$ -$$R_s = \frac{\Delta V}{\Delta I} = \frac{2.8 - 1.2}{2.083 - 0.75} = 1.2\ \Omega,\qquad V_{err} = 0.3\,\text{V}$$ +**Phase:** $\varphi = \operatorname{atan2}(3.14, 1.5) = 1.126\ \text{rad}\ (64.5°)$. -A single-point estimate at level 1 would instead give $1.2/0.75 = 1.6\ \Omega$ — a **33% error** — -showing why the differential fit matters. +**Demodulated components:** $I_{re} = I\cos\varphi = 0.223\,\text{A}$, +$I_{im} = -I\sin\varphi = -0.467\,\text{A}$, $D = I^2 = 0.267\,\text{A}^2$. -**Inductance** — integrating the probe transient ($\tau = L_s/R_s = 0.5\,\text{ms} = 5\,T_s$): +**Recovery:** -$$L_s = R_s \cdot \frac{\sum_k (I_{ss} - i[k])\,T_s}{I_{ss}} = R_s \cdot \tau = 1.2 \times 0.5\times10^{-3} = 0.6\ \text{mH}$$ +$$ +R_s = \frac{1.8 \times 0.223}{0.267} = 1.50\,\Omega, \qquad +L_s = \frac{-1.8 \times (-0.467)}{1570.8 \times 0.267} = 2.0\times10^{-3}\,\text{H} +$$ -The integral recovers $\tau$ with sub-sample resolution, independent of any single noisy point. +both matching the true values exactly (the phase lag $\varphi$ carries the $R_s$/$L_s$ split; the +amplitude carries $Z$). --- ## Limitations & Assumptions -- **Assumes**: The rotor is stationary during each transient. The pre-probe and same-axis graduated - steps ensure this; residual motion is caught by the fit-quality residual. -- **Assumes**: $L_d \approx L_q$ (surface-mounted PMSM). For interior PMSM, the step must be - repeated on both axes if the design requires both $L_d$ and $L_q$. -- **Assumes**: Magnetic linearity (no saturation). The identification current must be kept below the +- **Assumes**: $L_d \approx L_q$ (surface-mounted PMSM), so a fixed-axis injection sees a constant + $L_s$ and no alignment is needed. For interior PMSM (IPMSM), separating $L_d$ and $L_q$ requires a + rotating HF injection or explicit q-axis excitation — this is future work; the current method reports + a single blended $L_s$. +- **Assumes**: Magnetic linearity (no saturation). The injection amplitude keeps the current below the saturation current. +- **Assumes**: $f_{inj}$ divides $f_s$ so the demodulation window is an exact integer number of periods. +- **Does not handle**: Inverter dead-time / voltage-offset identification (a small apparent-$R$ bias + remains; full dead-time compensation is future work). - **Does not handle**: Temperature-dependent $R_s$ variation during operation. -- **Does not handle**: Identification at running speed where back-EMF cannot be zeroed by standstill. ## References diff --git a/targets/hardware_test/components/Terminal.cpp b/targets/hardware_test/components/Terminal.cpp index f663cea3..1198c6e4 100644 --- a/targets/hardware_test/components/Terminal.cpp +++ b/targets/hardware_test/components/Terminal.cpp @@ -138,7 +138,7 @@ namespace application this->terminal.ProcessResult(SimulateFoc(param)); } }); - terminal.AddCommand({ { "ident", "id", "Identify R, L and pole pairs. ident [probe_v%] [settle_ms] [pp_v%] [pp_revs] [pp_settle_ms]. Ex: ident wye 5 300 10 5 50" }, + terminal.AddCommand({ { "ident", "id", "Identify R, L and pole pairs. ident [inj_freq_hz] [inj_v%] [pp_v%] [pp_revs] [pp_settle_ms]. Ex: ident wye 250 15 10 5 50" }, [this](const infra::BoundedConstString& param) { this->terminal.ProcessResult(IdentifyElectricalParameters(param)); @@ -429,18 +429,18 @@ namespace application if (tokenizer.Size() >= 2) { - auto probeVoltage = ParseInput(tokenizer.Token(1), 1, 100); - if (!probeVoltage.has_value()) - return { error, "invalid value for probe voltage. It should be an integer between 1 and 100." }; - rlConfig.probeVoltagePercent = hal::Percent{ *probeVoltage }; + auto injectionFrequency = ParseInput(tokenizer.Token(1), 1u, 5000u); + if (!injectionFrequency.has_value()) + return { error, "invalid value for injection frequency. It should be an integer between 1 and 5000 Hz." }; + rlConfig.injectionFrequency = hal::Hertz{ *injectionFrequency }; } if (tokenizer.Size() >= 3) { - auto settlems = ParseInput(tokenizer.Token(2), 1u, 10000u); - if (!settlems.has_value()) - return { error, "invalid value for settle time. It should be an integer between 1 and 10000 ms." }; - rlConfig.settlePerLevel = std::chrono::milliseconds{ *settlems }; + auto injectionVoltage = ParseInput(tokenizer.Token(2), 1, 100); + if (!injectionVoltage.has_value()) + return { error, "invalid value for injection voltage. It should be an integer between 1 and 100." }; + rlConfig.injectionVoltagePercent = hal::Percent{ *injectionVoltage }; } pendingPolePairsConfig = {}; diff --git a/targets/hardware_test/components/Terminal.hpp b/targets/hardware_test/components/Terminal.hpp index 96e3125a..d47fb2ea 100644 --- a/targets/hardware_test/components/Terminal.hpp +++ b/targets/hardware_test/components/Terminal.hpp @@ -24,7 +24,8 @@ namespace application struct IdentificationResults { services::ElectricalParametersIdentification::ResistanceInductanceResult rl{ - foc::Ohm{ 0.0f }, foc::MilliHenry{ 0.0f }, foc::Volts{ 0.0f }, 0.0f }; + foc::Ohm{ 0.0f }, foc::MilliHenry{ 0.0f }, foc::Volts{ 0.0f }, 0.0f + }; std::size_t polePairs{ 0 }; }; diff --git a/targets/hardware_test/components/test/TestTerminal.cpp b/targets/hardware_test/components/test/TestTerminal.cpp index 1032241e..14d7aee8 100644 --- a/targets/hardware_test/components/test/TestTerminal.cpp +++ b/targets/hardware_test/components/test/TestTerminal.cpp @@ -629,15 +629,51 @@ TEST_F(TestHardwareTerminal, ident_invalid_too_many_args) ExecuteAllActions(); } -TEST_F(TestHardwareTerminal, ident_invalid_probe_voltage_out_of_range) +TEST_F(TestHardwareTerminal, ident_invalid_injection_voltage_out_of_range) { - InvokeCommand("ident wye 250", [this]() + InvokeCommand("ident wye 250 200", [this]() { ::testing::InSequence _; std::string newline{ "\r\n" }; std::string header{ "ERROR: " }; - std::string payload{ "invalid value for probe voltage. It should be an integer between 1 and 100." }; + std::string payload{ "invalid value for injection voltage. It should be an integer between 1 and 100." }; + + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, ident_invalid_injection_frequency_too_low) +{ + InvokeCommand("ident wye 0", [this]() + { + ::testing::InSequence _; + + std::string newline{ "\r\n" }; + std::string header{ "ERROR: " }; + std::string payload{ "invalid value for injection frequency. It should be an integer between 1 and 5000 Hz." }; + + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, ident_invalid_injection_frequency_too_high) +{ + InvokeCommand("ident wye 6000", [this]() + { + ::testing::InSequence _; + + std::string newline{ "\r\n" }; + std::string header{ "ERROR: " }; + std::string payload{ "invalid value for injection frequency. It should be an integer between 1 and 5000 Hz." }; EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); @@ -651,6 +687,7 @@ TEST_F(TestHardwareTerminal, ident_wye_starts_identification) { InvokeCommand("ident wye", [this]() { + EXPECT_CALL(platformFactoryMock, Stop()); EXPECT_CALL(platformFactoryMock, ThreePhasePwmOutput(testing::_)); }); @@ -661,6 +698,7 @@ TEST_F(TestHardwareTerminal, ident_delta_starts_identification) { InvokeCommand("ident delta", [this]() { + EXPECT_CALL(platformFactoryMock, Stop()); EXPECT_CALL(platformFactoryMock, ThreePhasePwmOutput(testing::_)); }); @@ -671,6 +709,7 @@ TEST_F(TestHardwareTerminal, ident_alias) { InvokeCommand("id wye", [this]() { + EXPECT_CALL(platformFactoryMock, Stop()); EXPECT_CALL(platformFactoryMock, ThreePhasePwmOutput(testing::_)); }); @@ -679,8 +718,9 @@ TEST_F(TestHardwareTerminal, ident_alias) TEST_F(TestHardwareTerminal, ident_with_all_optional_args) { - InvokeCommand("ident wye 15 2000 10 5 50", [this]() + InvokeCommand("ident wye 250 15 10 5 50", [this]() { + EXPECT_CALL(platformFactoryMock, Stop()); EXPECT_CALL(platformFactoryMock, ThreePhasePwmOutput(testing::_)); }); From f7de52ebd606993b280b45434788a07d6c27cc61 Mon Sep 17 00:00:00 2001 From: gfs Date: Sun, 19 Jul 2026 19:31:55 +0200 Subject: [PATCH 21/28] Update documentation/design/service-electrical-ident.md Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- documentation/design/service-electrical-ident.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/documentation/design/service-electrical-ident.md b/documentation/design/service-electrical-ident.md index a0c56e2f..8ec317bb 100644 --- a/documentation/design/service-electrical-ident.md +++ b/documentation/design/service-electrical-ident.md @@ -170,12 +170,12 @@ flowchart TD All internal state is statically allocated and O(1) in size. The HF resistance/inductance procedure keeps no per-sample data buffer at all — it retains only a fixed set of running accumulators: -| State | Type | Purpose | -|-----------------------|---------------|-------------------------------------------------------------------------| -| `sumSin`, `sumCos` | float | In-phase / quadrature synchronous-demodulation accumulators | -| `sumSq` | float | Sum of squared current, used for the THD-like fit-quality diagnostic | -| phase, phase increment | float | Injection-oscillator state advanced once per sample | -| sample / period counts | integer | Warm-up and measurement window bookkeeping | +| State | Type | Purpose | +|------------------------|---------|----------------------------------------------------------------------| +| `sumSin`, `sumCos` | float | In-phase / quadrature synchronous-demodulation accumulators | +| `sumSq` | float | Sum of squared current, used for the THD-like fit-quality diagnostic | +| phase, phase increment | float | Injection-oscillator state advanced once per sample | +| sample / period counts | integer | Warm-up and measurement window bookkeeping | No heap allocation is used, and memory usage is independent of the number of injection periods. The accumulators are members of the service object and are reset at the start of each procedure invocation. From 3695944ceea6bc7532764ca2de72fe2307abcad8 Mon Sep 17 00:00:00 2001 From: gfs Date: Sun, 19 Jul 2026 19:32:13 +0200 Subject: [PATCH 22/28] Update documentation/theory/resistance-inductance-estimation.md Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../resistance-inductance-estimation.md | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/documentation/theory/resistance-inductance-estimation.md b/documentation/theory/resistance-inductance-estimation.md index 2456036a..598407bc 100644 --- a/documentation/theory/resistance-inductance-estimation.md +++ b/documentation/theory/resistance-inductance-estimation.md @@ -288,16 +288,16 @@ phi = atan2(w Ls, R); R = Z cos phi; w Ls = Z sin phi ### Sensitivity Analysis -| Source of Error | Effect on $R_s$ | Effect on $L_s$ | -|----------------------------|------------------------------------------|------------------------------------------| -| Low-frequency back-EMF | Rejected by integer-period demodulation | Rejected by integer-period demodulation | -| ADC current offset (DC) | Rejected (orthogonal to $\sin/\cos$) | Rejected (orthogonal to $\sin/\cos$) | -| PWM→ADC pipeline lag | $\varepsilon = 2\pi f_{inj}/f_s$ biases $R$ ($\cos(\varphi+\varepsilon)/\cos\varphi$); compensated via `voltageToCurrentDelaySamples` | Compensated with the same lagged reference | -| Inverter dead-time | Small apparent-$R$ bias (in phase) | Indirect | -| $V_{dc}$ variation | Biases $A$ (kept brief to limit drift) | Biases $A$ | -| High $f_{inj}$ | Poor $R_s$ separation (low current) | Well conditioned | -| Magnetic saturation | $R_s$ / $L_s$ underestimated (nonlinear) | $L_s$ underestimated (nonlinear) | -| Rotor saliency (IPMSM) | Axis-dependent $L$ — not separated | Reports a single blended $L_s$ | +| Source of Error | Effect on $R_s$ | Effect on $L_s$ | +|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------| +| Low-frequency back-EMF | Rejected by integer-period demodulation | Rejected by integer-period demodulation | +| ADC current offset (DC) | Rejected (orthogonal to $\sin/\cos$) | Rejected (orthogonal to $\sin/\cos$) | +| PWM→ADC pipeline lag | $\varepsilon = 2\pi f_{inj}/f_s$ biases $R$ ($\cos(\varphi+\varepsilon)/\cos\varphi$); compensated via `voltageToCurrentDelaySamples` | Compensated with the same lagged reference | +| Inverter dead-time | Small apparent-$R$ bias (in phase) | Indirect | +| $V_{dc}$ variation | Biases $A$ (kept brief to limit drift) | Biases $A$ | +| High $f_{inj}$ | Poor $R_s$ separation (low current) | Well conditioned | +| Magnetic saturation | $R_s$ / $L_s$ underestimated (nonlinear) | $L_s$ underestimated (nonlinear) | +| Rotor saliency (IPMSM) | Axis-dependent $L$ — not separated | Reports a single blended $L_s$ | --- From 1dee3c990b8ac6de0bc51a6b8938c94cb8f7c04f Mon Sep 17 00:00:00 2001 From: gfs Date: Sun, 19 Jul 2026 19:33:19 +0200 Subject: [PATCH 23/28] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tools/hardware_bridge/server/list_can_interfaces.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/hardware_bridge/server/list_can_interfaces.py b/tools/hardware_bridge/server/list_can_interfaces.py index d73e6de6..98f751bf 100644 --- a/tools/hardware_bridge/server/list_can_interfaces.py +++ b/tools/hardware_bridge/server/list_can_interfaces.py @@ -43,7 +43,6 @@ "usb2can", "iscan", "nixnet", - "pcan", "systec", ] From 59f25da5f6ec626ffb6c0137d127ee9b34b4555c Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Sun, 19 Jul 2026 17:34:10 +0000 Subject: [PATCH 24/28] refactor: remove Terminal CLI wrappers for alignment and mechanical ident TerminalMotorAlignment and TerminalMechanicalParametersIdentification were standalone CLI wrappers wired into no target; the application drives the MotorAlignment and MechanicalParametersIdentification services directly. Delete both classes and their unit tests and drop the CMake source/test entries. The underlying services are unchanged. --- core/services/alignment/CMakeLists.txt | 2 - .../alignment/TerminalMotorAlignment.cpp | 56 ---- .../alignment/TerminalMotorAlignment.hpp | 23 -- core/services/alignment/test/CMakeLists.txt | 1 - .../test/TestTerminalMotorAlignment.cpp | 301 ------------------ .../mechanical_system_ident/CMakeLists.txt | 2 - ...inalMechanicalParametersIdentification.cpp | 78 ----- ...inalMechanicalParametersIdentification.hpp | 24 -- .../test/CMakeLists.txt | 1 - ...inalMechanicalParametersIdentification.cpp | 169 ---------- 10 files changed, 657 deletions(-) delete mode 100644 core/services/alignment/TerminalMotorAlignment.cpp delete mode 100644 core/services/alignment/TerminalMotorAlignment.hpp delete mode 100644 core/services/alignment/test/TestTerminalMotorAlignment.cpp delete mode 100644 core/services/mechanical_system_ident/TerminalMechanicalParametersIdentification.cpp delete mode 100644 core/services/mechanical_system_ident/TerminalMechanicalParametersIdentification.hpp delete mode 100644 core/services/mechanical_system_ident/test/TestTerminalMechanicalParametersIdentification.cpp diff --git a/core/services/alignment/CMakeLists.txt b/core/services/alignment/CMakeLists.txt index 9fb6fe44..e34875f0 100644 --- a/core/services/alignment/CMakeLists.txt +++ b/core/services/alignment/CMakeLists.txt @@ -15,8 +15,6 @@ target_sources(e_foc.services.alignment PRIVATE MotorAlignmentImpl.cpp MotorAlignmentImpl.hpp MotorAlignment.hpp - TerminalMotorAlignment.cpp - TerminalMotorAlignment.hpp ) add_subdirectory(test) diff --git a/core/services/alignment/TerminalMotorAlignment.cpp b/core/services/alignment/TerminalMotorAlignment.cpp deleted file mode 100644 index ab372a9e..00000000 --- a/core/services/alignment/TerminalMotorAlignment.cpp +++ /dev/null @@ -1,56 +0,0 @@ -#include "core/services/alignment/TerminalMotorAlignment.hpp" -#include "infra/stream/StringInputStream.hpp" -#include "infra/util/Tokenizer.hpp" - -namespace -{ - template - inline std::optional ParseInput(const infra::BoundedConstString& data, T minValue = std::numeric_limits::min(), T maxValue = std::numeric_limits::max()) - { - T value{}; - infra::StringInputStream stream(data, infra::softFail); - stream >> value; - - if (!stream.ErrorPolicy().Failed() && value >= minValue && value <= maxValue) - return std::make_optional(value); - else - return {}; - } -} - -namespace services -{ - TerminalMotorAlignment::TerminalMotorAlignment(services::TerminalWithStorage& terminal, services::Tracer& tracer, MotorAlignment& alignment) - : terminal(terminal) - , tracer(tracer) - , alignment(alignment) - { - terminal.AddCommand({ { "force_alignment", "fa", "Force motor alignment." }, - [this](const auto& params) - { - this->terminal.ProcessResult(ForceAlignment(params)); - } }); - } - - TerminalMotorAlignment::StatusWithMessage TerminalMotorAlignment::ForceAlignment(const infra::BoundedConstString& input) - { - infra::Tokenizer tokenizer(input, ' '); - MotorAlignment::AlignmentConfig config; - - if (tokenizer.Size() != 1) - return { services::TerminalWithStorage::Status::error, "invalid number of arguments" }; - - auto polePair = ParseInput(tokenizer.Token(0)); - if (!polePair.has_value()) - return { services::TerminalWithStorage::Status::error, "invalid value. It should be an integer." }; - - alignment.ForceAlignment(*polePair, config, [this](auto result) - { - if (result.has_value()) - tracer.Trace() << "Motor aligned at position (rad): " << result->Value(); - else - tracer.Trace() << "Motor alignment failed."; - }); - return TerminalMotorAlignment::StatusWithMessage(); - } -} diff --git a/core/services/alignment/TerminalMotorAlignment.hpp b/core/services/alignment/TerminalMotorAlignment.hpp deleted file mode 100644 index a426dcc3..00000000 --- a/core/services/alignment/TerminalMotorAlignment.hpp +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include "services/util/TerminalWithStorage.hpp" -#include "core/services/alignment/MotorAlignment.hpp" - -namespace services -{ - class TerminalMotorAlignment - { - public: - TerminalMotorAlignment(services::TerminalWithStorage& terminal, services::Tracer& tracer, MotorAlignment& alignment); - - private: - using StatusWithMessage = services::TerminalWithStorage::StatusWithMessage; - - StatusWithMessage ForceAlignment(const infra::BoundedConstString& param); - - private: - services::TerminalWithStorage& terminal; - services::Tracer& tracer; - MotorAlignment& alignment; - }; -} diff --git a/core/services/alignment/test/CMakeLists.txt b/core/services/alignment/test/CMakeLists.txt index a13fae7e..8e81e88a 100644 --- a/core/services/alignment/test/CMakeLists.txt +++ b/core/services/alignment/test/CMakeLists.txt @@ -14,5 +14,4 @@ target_link_libraries(e_foc.services.alignment_test PUBLIC target_sources(e_foc.services.alignment_test PRIVATE TestMotorAlignment.cpp - TestTerminalMotorAlignment.cpp ) diff --git a/core/services/alignment/test/TestTerminalMotorAlignment.cpp b/core/services/alignment/test/TestTerminalMotorAlignment.cpp deleted file mode 100644 index 87181d82..00000000 --- a/core/services/alignment/test/TestTerminalMotorAlignment.cpp +++ /dev/null @@ -1,301 +0,0 @@ -#include "core/services/alignment/MotorAlignment.hpp" -#include "core/services/alignment/TerminalMotorAlignment.hpp" -#include "hal/interfaces/test_doubles/SerialCommunicationMock.hpp" -#include "infra/event/test_helper/EventDispatcherWithWeakPtrFixture.hpp" -#include "infra/stream/test/StreamMock.hpp" -#include "infra/util/ByteRange.hpp" -#include "infra/util/test_helper/MockHelpers.hpp" -#include "services/util/Terminal.hpp" -#include "gmock/gmock.h" - -namespace -{ - class MotorAlignmentMock - : public services::MotorAlignment - { - public: - MOCK_METHOD3(ForceAlignment, void(std::size_t polePairs, const AlignmentConfig& config, const infra::Function)>& onDone)); - }; - - class TerminalMotorAlignmentTest - : public ::testing::Test - , public infra::EventDispatcherWithWeakPtrFixture - { - public: - ::testing::StrictMock alignmentMock; - ::testing::StrictMock streamWriterMock; - infra::TextOutputStream::WithErrorPolicy stream{ streamWriterMock }; - services::TracerToStream tracer{ stream }; - ::testing::StrictMock communication; - infra::Execute execute{ [this]() - { - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector{ { '>', ' ' } }), testing::_)); - } }; - services::TerminalWithCommandsImpl::WithMaxQueueAndMaxHistory<128, 5> terminalWithCommands{ communication, tracer }; - services::TerminalWithStorage::WithMaxSize<10> terminal{ terminalWithCommands, tracer }; - services::TerminalMotorAlignment terminalAlignment{ terminal, tracer, alignmentMock }; - - void InvokeCommand(std::string command, const std::function& onCommandReceived) - { - ::testing::InSequence _; - - for (const auto& data : command) - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector{ static_cast(data) }), testing::_)); - - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector{ { '\r', '\n' } }), testing::_)); - onCommandReceived(); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector{ { '>', ' ' } }), testing::_)); - - communication.dataReceived(infra::MakeStringByteRange(command + "\r")); - } - }; -} - -TEST_F(TerminalMotorAlignmentTest, force_alignment) -{ - InvokeCommand("force_alignment 7", [this]() - { - EXPECT_CALL(alignmentMock, ForceAlignment(testing::_, testing::_, testing::_)); - }); - - ExecuteAllActions(); -} - -TEST_F(TerminalMotorAlignmentTest, force_alignment_short_command) -{ - InvokeCommand("fa 4", [this]() - { - EXPECT_CALL(alignmentMock, ForceAlignment(testing::_, testing::_, testing::_)); - }); - - ExecuteAllActions(); -} - -TEST_F(TerminalMotorAlignmentTest, force_alignment_invalid_argument_count) -{ - InvokeCommand("force_alignment", [this]() - { - ::testing::InSequence _; - - std::string header{ "ERROR: " }; - std::string payload{ "invalid number of arguments" }; - std::string newline{ "\r\n" }; - - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); - }); - - ExecuteAllActions(); -} - -TEST_F(TerminalMotorAlignmentTest, force_alignment_invalid_pole_pairs) -{ - InvokeCommand("fa abc", [this]() - { - ::testing::InSequence _; - - std::string header{ "ERROR: " }; - std::string payload{ "invalid value. It should be an integer." }; - std::string newline{ "\r\n" }; - - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); - }); - - ExecuteAllActions(); -} - -TEST_F(TerminalMotorAlignmentTest, force_alignment_too_many_arguments) -{ - InvokeCommand("fa 7 extra", [this]() - { - ::testing::InSequence _; - - std::string header{ "ERROR: " }; - std::string payload{ "invalid number of arguments" }; - std::string newline{ "\r\n" }; - - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); - }); - - ExecuteAllActions(); -} - -TEST_F(TerminalMotorAlignmentTest, force_alignment_successful_callback) -{ - infra::Function)> capturedCallback; - - InvokeCommand("fa 7", [this, &capturedCallback]() - { - EXPECT_CALL(alignmentMock, ForceAlignment(7, testing::_, testing::_)) - .WillOnce(testing::SaveArg<2>(&capturedCallback)); - }); - - ExecuteAllActions(); - - // Simulate successful alignment - ::testing::InSequence _; - std::string newline{ "\r\n" }; - std::string prefix{ "Motor aligned at position (rad): " }; - // The tracer outputs the float value, which we'll just accept any characters for - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(prefix.begin(), prefix.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AtLeast(1)); // Accept the numeric value output - - capturedCallback(foc::Radians{ 1.57f }); - ExecuteAllActions(); -} - -TEST_F(TerminalMotorAlignmentTest, force_alignment_failed_callback) -{ - infra::Function)> capturedCallback; - - InvokeCommand("fa 4", [this, &capturedCallback]() - { - EXPECT_CALL(alignmentMock, ForceAlignment(4, testing::_, testing::_)) - .WillOnce(testing::SaveArg<2>(&capturedCallback)); - }); - - ExecuteAllActions(); - - ::testing::InSequence _; - std::string message{ "Motor alignment failed." }; - std::string newline{ "\r\n" }; - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(message.begin(), message.end())), testing::_)); - - capturedCallback(std::nullopt); - ExecuteAllActions(); -} - -TEST_F(TerminalMotorAlignmentTest, force_alignment_with_zero_pole_pairs) -{ - InvokeCommand("fa 0", [this]() - { - EXPECT_CALL(alignmentMock, ForceAlignment(0, testing::_, testing::_)); - }); - - ExecuteAllActions(); -} - -TEST_F(TerminalMotorAlignmentTest, force_alignment_with_large_pole_pairs) -{ - InvokeCommand("fa 100", [this]() - { - EXPECT_CALL(alignmentMock, ForceAlignment(100, testing::_, testing::_)); - }); - - ExecuteAllActions(); -} - -TEST_F(TerminalMotorAlignmentTest, force_alignment_verifies_default_config_used) -{ - services::MotorAlignment::AlignmentConfig capturedConfig; - - InvokeCommand("fa 5", [this, &capturedConfig]() - { - EXPECT_CALL(alignmentMock, ForceAlignment(5, testing::_, testing::_)) - .WillOnce(testing::SaveArg<1>(&capturedConfig)); - }); - - ExecuteAllActions(); - - services::MotorAlignment::AlignmentConfig defaultConfig; - EXPECT_EQ(capturedConfig.testVoltagePercent, defaultConfig.testVoltagePercent); - EXPECT_EQ(capturedConfig.samplingFrequency, defaultConfig.samplingFrequency); - EXPECT_EQ(capturedConfig.maxSamples, defaultConfig.maxSamples); - EXPECT_EQ(capturedConfig.settledThreshold, defaultConfig.settledThreshold); - EXPECT_EQ(capturedConfig.settledCount, defaultConfig.settledCount); -} - -TEST_F(TerminalMotorAlignmentTest, force_alignment_with_negative_number) -{ - InvokeCommand("fa -5", [this]() - { - ::testing::InSequence _; - - std::string header{ "ERROR: " }; - std::string payload{ "invalid value. It should be an integer." }; - std::string newline{ "\r\n" }; - - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); - }); - - ExecuteAllActions(); -} - -TEST_F(TerminalMotorAlignmentTest, force_alignment_with_decimal_number) -{ - InvokeCommand("fa 3.5", [this]() - { - EXPECT_CALL(alignmentMock, ForceAlignment(3, testing::_, testing::_)); - }); - - ExecuteAllActions(); -} - -TEST_F(TerminalMotorAlignmentTest, force_alignment_with_extra_spaces_succeeds) -{ - InvokeCommand("fa 7", [this]() - { - EXPECT_CALL(alignmentMock, ForceAlignment(7, testing::_, testing::_)); - }); - - ExecuteAllActions(); -} - -TEST_F(TerminalMotorAlignmentTest, force_alignment_with_maximum_uint32_value) -{ - InvokeCommand("fa 4294967295", [this]() - { - EXPECT_CALL(alignmentMock, ForceAlignment(4294967295u, testing::_, testing::_)); - }); - - ExecuteAllActions(); -} - -TEST_F(TerminalMotorAlignmentTest, force_alignment_callback_with_zero_position) -{ - infra::Function)> capturedCallback; - - InvokeCommand("fa 3", [this, &capturedCallback]() - { - EXPECT_CALL(alignmentMock, ForceAlignment(3, testing::_, testing::_)) - .WillOnce(testing::SaveArg<2>(&capturedCallback)); - }); - - ExecuteAllActions(); - - ::testing::InSequence _; - std::string newline{ "\r\n" }; - std::string prefix{ "Motor aligned at position (rad): " }; - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(prefix.begin(), prefix.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AtLeast(1)); - - capturedCallback(foc::Radians{ 0.0f }); - ExecuteAllActions(); -} - -TEST_F(TerminalMotorAlignmentTest, force_alignment_command_can_be_called_multiple_times) -{ - InvokeCommand("fa 5", [this]() - { - EXPECT_CALL(alignmentMock, ForceAlignment(5, testing::_, testing::_)); - }); - - ExecuteAllActions(); - - InvokeCommand("force_alignment 10", [this]() - { - EXPECT_CALL(alignmentMock, ForceAlignment(10, testing::_, testing::_)); - }); - - ExecuteAllActions(); -} diff --git a/core/services/mechanical_system_ident/CMakeLists.txt b/core/services/mechanical_system_ident/CMakeLists.txt index b7cc7eac..dfc30bc9 100644 --- a/core/services/mechanical_system_ident/CMakeLists.txt +++ b/core/services/mechanical_system_ident/CMakeLists.txt @@ -20,8 +20,6 @@ target_sources(e_foc.services.mechanical_system_ident PRIVATE MechanicalParametersIdentification.hpp MechanicalParametersIdentificationImpl.cpp MechanicalParametersIdentificationImpl.hpp - TerminalMechanicalParametersIdentification.cpp - TerminalMechanicalParametersIdentification.hpp ) add_subdirectory(test) diff --git a/core/services/mechanical_system_ident/TerminalMechanicalParametersIdentification.cpp b/core/services/mechanical_system_ident/TerminalMechanicalParametersIdentification.cpp deleted file mode 100644 index 9a44d095..00000000 --- a/core/services/mechanical_system_ident/TerminalMechanicalParametersIdentification.cpp +++ /dev/null @@ -1,78 +0,0 @@ -#include "core/services/mechanical_system_ident/TerminalMechanicalParametersIdentification.hpp" -#include "infra/stream/StringInputStream.hpp" -#include "infra/util/Tokenizer.hpp" -#include "core/services/mechanical_system_ident/MechanicalParametersIdentification.hpp" -#include - -namespace -{ - template - std::optional ParseNumberInput(const infra::BoundedConstString& input) - { - T value = 0.0f; - infra::StringInputStream stream(input, infra::softFail); - stream >> value; - - if (!stream.ErrorPolicy().Failed()) - return value; - else - return std::nullopt; - } - - foc::RadiansPerSecond ToRadiansPerSecond(float rpm) - { - return foc::RadiansPerSecond{ rpm * 2.0f * std::numbers::pi_v / 60.0f }; - } -} - -namespace services -{ - TerminalMechanicalParametersIdentification::TerminalMechanicalParametersIdentification(services::TerminalWithStorage& terminal, services::Tracer& tracer, MechanicalParametersIdentification& identification) - : terminal(terminal) - , tracer(tracer) - , identification(identification) - { - terminal.AddCommand({ { "estimate_mechanical", "estmech", "Estimate friction coefficient (B). estmech . Ex: estmech 500.0 0.42 7" }, - [this](const auto& params) - { - this->terminal.ProcessResult(EstimateFrictionAndInertia(params)); - } }); - } - - TerminalMechanicalParametersIdentification::StatusWithMessage TerminalMechanicalParametersIdentification::EstimateFrictionAndInertia(const infra::BoundedConstString& param) - { - MechanicalParametersIdentification::Config config; - infra::Tokenizer tokenizer(param, ' '); - - if (tokenizer.Size() != 3) - return { services::TerminalWithStorage::Status::error, "invalid number of arguments. Usage: estmech " }; - - auto targetSpeedRpm = ParseNumberInput(tokenizer.Token(0)); - auto torqueConstant = ParseNumberInput(tokenizer.Token(1)); - auto numberOfPolePairs = ParseNumberInput(tokenizer.Token(2)); - - if (!targetSpeedRpm.has_value() || *targetSpeedRpm <= 0.0f) - return { services::TerminalWithStorage::Status::error, "invalid target speed. Must be > 0 RPM" }; - - if (!torqueConstant.has_value() || *torqueConstant <= 0.0f) - return { services::TerminalWithStorage::Status::error, "invalid torque constant. Must be > 0 Nm/A" }; - - if (!numberOfPolePairs.has_value()) - return { services::TerminalWithStorage::Status::error, "invalid number of pole pairs. Must be > 0" }; - - config.targetSpeed = ToRadiansPerSecond(*targetSpeedRpm); - - identification.EstimateFrictionAndInertia(foc::NewtonMeter{ *torqueConstant }, *numberOfPolePairs, config, [this](auto friction, auto inertia) - { - if (!friction.has_value() || !inertia.has_value()) - tracer.Trace() << "Friction and inertia estimation failed."; - else - { - tracer.Trace() << "Estimated Friction (B): " << friction->Value() << " Nm·s/rad"; - tracer.Trace() << "Estimated Inertia (J): " << inertia->Value() << " Nm·s²"; - } - }); - - return TerminalMechanicalParametersIdentification::StatusWithMessage(); - } -} diff --git a/core/services/mechanical_system_ident/TerminalMechanicalParametersIdentification.hpp b/core/services/mechanical_system_ident/TerminalMechanicalParametersIdentification.hpp deleted file mode 100644 index 4405c1a5..00000000 --- a/core/services/mechanical_system_ident/TerminalMechanicalParametersIdentification.hpp +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include "services/util/TerminalWithStorage.hpp" -#include "core/services/mechanical_system_ident/MechanicalParametersIdentification.hpp" - -namespace services -{ - class TerminalMechanicalParametersIdentification - { - public: - TerminalMechanicalParametersIdentification(services::TerminalWithStorage& terminal, services::Tracer& tracer, MechanicalParametersIdentification& identification); - - private: - using StatusWithMessage = services::TerminalWithStorage::StatusWithMessage; - - StatusWithMessage EstimateFrictionAndInertia(const infra::BoundedConstString& param); - - private: - services::TerminalWithStorage& terminal; - services::Tracer& tracer; - MechanicalParametersIdentification& identification; - std::optional lastDamping; - }; -} diff --git a/core/services/mechanical_system_ident/test/CMakeLists.txt b/core/services/mechanical_system_ident/test/CMakeLists.txt index d3ffd45d..6b1c723c 100644 --- a/core/services/mechanical_system_ident/test/CMakeLists.txt +++ b/core/services/mechanical_system_ident/test/CMakeLists.txt @@ -15,5 +15,4 @@ target_link_libraries(e_foc.services.mechanical_system_ident_test PUBLIC target_sources(e_foc.services.mechanical_system_ident_test PRIVATE TestRealTimeFrictionAndInertiaEstimator.cpp TestMechanicalParametersIdentification.cpp - TestTerminalMechanicalParametersIdentification.cpp ) diff --git a/core/services/mechanical_system_ident/test/TestTerminalMechanicalParametersIdentification.cpp b/core/services/mechanical_system_ident/test/TestTerminalMechanicalParametersIdentification.cpp deleted file mode 100644 index 7d9f1bed..00000000 --- a/core/services/mechanical_system_ident/test/TestTerminalMechanicalParametersIdentification.cpp +++ /dev/null @@ -1,169 +0,0 @@ -#include "core/services/mechanical_system_ident/MechanicalParametersIdentification.hpp" -#include "core/services/mechanical_system_ident/TerminalMechanicalParametersIdentification.hpp" -#include "hal/interfaces/test_doubles/SerialCommunicationMock.hpp" -#include "infra/event/test_helper/EventDispatcherWithWeakPtrFixture.hpp" -#include "infra/stream/test/StreamMock.hpp" -#include "infra/util/ByteRange.hpp" -#include "infra/util/test_helper/MockHelpers.hpp" -#include "services/util/TerminalWithStorage.hpp" -#include "gmock/gmock.h" - -namespace -{ - class MechanicalParametersIdentificationMock - : public services::MechanicalParametersIdentification - { - public: - MOCK_METHOD4(EstimateFrictionAndInertia, void(const foc::NewtonMeter& torqueConstant, std::size_t numberOfPolePairs, const Config& config, const infra::Function, std::optional)>& onDone)); - }; - - class TerminalMechanicalParametersIdentificationTest - : public ::testing::Test - , public infra::EventDispatcherWithWeakPtrFixture - { - public: - ::testing::StrictMock identificationMock; - ::testing::StrictMock streamWriterMock; - infra::TextOutputStream::WithErrorPolicy stream{ streamWriterMock }; - services::TracerToStream tracer{ stream }; - ::testing::StrictMock communication; - infra::Execute execute{ [this]() - { - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector{ { '>', ' ' } }), testing::_)); - } }; - services::TerminalWithCommandsImpl::WithMaxQueueAndMaxHistory<128, 5> terminalWithCommands{ communication, tracer }; - services::TerminalWithStorage::WithMaxSize<16> terminalWithStorage{ terminalWithCommands, tracer }; - services::TerminalMechanicalParametersIdentification terminalIdentification{ terminalWithStorage, tracer, identificationMock }; - - void InvokeCommand(const std::string& command, const infra::Function& onCommandReceived) - { - ::testing::InSequence _; - - for (const auto& data : command) - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector{ static_cast(data) }), testing::_)); - - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector{ { '\r', '\n' } }), testing::_)); - onCommandReceived(); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector{ { '>', ' ' } }), testing::_)); - - communication.dataReceived(infra::MakeStringByteRange(command + "\r")); - } - - void InvokeCommandExpectingError(const std::string& command, const std::string& errorMessage) - { - ::testing::InSequence _; - - for (const auto& data : command) - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector{ static_cast(data) }), testing::_)); - - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector{ { '\r', '\n' } }), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector{ { '\r', '\n' } }), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector{ { 'E', 'R', 'R', 'O', 'R', ':', ' ' } }), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(errorMessage.begin(), errorMessage.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector{ { '>', ' ' } }), testing::_)); - - communication.dataReceived(infra::MakeStringByteRange(command + "\r")); - } - }; -} - -TEST_F(TerminalMechanicalParametersIdentificationTest, estfric_calls_identification_with_correct_parameters) -{ - InvokeCommand("estmech 500.0 0.1 7", [this]() - { - EXPECT_CALL(identificationMock, EstimateFrictionAndInertia(testing::_, testing::_, testing::_, testing::_)) - .WillOnce([](const auto& torqueConstant, std::size_t polePairs, const auto& config, const auto&) - { - float expectedSpeed = 500.0f * 2.0f * 3.14159265f / 60.0f; // RPM to rad/s - EXPECT_NEAR(config.targetSpeed.Value(), expectedSpeed, 0.1f); - EXPECT_FLOAT_EQ(torqueConstant.Value(), 0.1f); - EXPECT_EQ(polePairs, 7); - }); - }); - - ExecuteAllActions(); -} - -TEST_F(TerminalMechanicalParametersIdentificationTest, estfric_returns_error_for_invalid_arguments) -{ - InvokeCommandExpectingError("estmech 500.0 0.1", "invalid number of arguments. Usage: estmech "); - - ExecuteAllActions(); -} - -TEST_F(TerminalMechanicalParametersIdentificationTest, estfric_successful_callback) -{ - infra::Function, std::optional)> capturedCallback; - - InvokeCommand("estmech 300.0 0.15 7", [this, &capturedCallback]() - { - EXPECT_CALL(identificationMock, EstimateFrictionAndInertia(testing::_, testing::_, testing::_, testing::_)) - .WillOnce(testing::SaveArg<3>(&capturedCallback)); - }); - - ExecuteAllActions(); - - ::testing::InSequence _; - std::string newline{ "\r\n" }; - std::string prefix1{ "Estimated Friction (B): " }; - std::string prefix2{ "Estimated Inertia (J): " }; - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(prefix1.begin(), prefix1.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AtLeast(1)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(prefix2.begin(), prefix2.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AtLeast(1)); - - capturedCallback(foc::NewtonMeterSecondPerRadian{ 0.05f }, foc::NewtonMeterSecondSquared{ 0.001f }); - ExecuteAllActions(); -} - -TEST_F(TerminalMechanicalParametersIdentificationTest, estfric_failure_callback) -{ - infra::Function, std::optional)> capturedCallback; - - InvokeCommand("estmech 400.0 0.1 7", [this, &capturedCallback]() - { - EXPECT_CALL(identificationMock, EstimateFrictionAndInertia(testing::_, testing::_, testing::_, testing::_)) - .WillOnce(testing::SaveArg<3>(&capturedCallback)); - }); - - ExecuteAllActions(); - - ::testing::InSequence _; - std::string newline{ "\r\n" }; - std::string message{ "Friction and inertia estimation failed." }; - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); - EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(message.begin(), message.end())), testing::_)); - - capturedCallback(std::nullopt, std::nullopt); - ExecuteAllActions(); -} - -TEST_F(TerminalMechanicalParametersIdentificationTest, estmech_zero_target_speed_returns_error) -{ - InvokeCommandExpectingError("estmech 0.0 0.1 7", "invalid target speed. Must be > 0 RPM"); - - ExecuteAllActions(); -} - -TEST_F(TerminalMechanicalParametersIdentificationTest, estmech_negative_target_speed_returns_error) -{ - InvokeCommandExpectingError("estmech -100.0 0.1 7", "invalid target speed. Must be > 0 RPM"); - - ExecuteAllActions(); -} - -TEST_F(TerminalMechanicalParametersIdentificationTest, estmech_zero_torque_constant_returns_error) -{ - InvokeCommandExpectingError("estmech 500.0 0.0 7", "invalid torque constant. Must be > 0 Nm/A"); - - ExecuteAllActions(); -} - -TEST_F(TerminalMechanicalParametersIdentificationTest, estmech_non_numeric_pole_pairs_returns_error) -{ - InvokeCommandExpectingError("estmech 500.0 0.1 abc", "invalid number of pole pairs. Must be > 0"); - - ExecuteAllActions(); -} From 1460922a6aaba31dde8f543011da2c3fdebf0b54 Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Mon, 20 Jul 2026 17:30:08 +0000 Subject: [PATCH 25/28] fix(ti): re-enable ADC overcurrent trip and restore protection headroom The prior change disabled the step-3 currentTotal -> DCMP0 overcurrent redirect, leaving the hardware overcurrent protection (ADC -> PWM fault path) off by default, which is unsafe for a motor drive (raised in review). Restore the digital-comparator config, the currentTotal ADC channel, and the ConfigureAdcAndPwm assignment. Decouple the ADC/comparator full-scale (maxCurrentAmps, 15 A) from the application limit: add ratedCurrentAmps (3 A) returned by MaxCurrentSupported(), and set the overcurrent trip to 12 A (80% of full-scale) so it sits above the rated current with real headroom instead of at ADC saturation. --- .../E-FOC-HARDWARE/BoardCharacteristics.hpp | 8 ++++++-- .../ti/implementation/PlatformFactoryImpl.cpp | 3 ++- .../ti/implementation/PlatformFactoryImpl.hpp | 16 ++++++++++++---- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp b/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp index ff471009..800a4551 100644 --- a/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp +++ b/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp @@ -13,8 +13,12 @@ namespace application static constexpr float overvoltageThresholdVolts{ 58.0f }; static constexpr float voltageToCurrent{ 5.0f }; - static constexpr float maxCurrentAmps{ 3.0f }; - static constexpr float overcurrentThresholdAmps{ 3.0f }; + // maxCurrentAmps is the ADC/comparator full-scale (the denominator of the overcurrent + // threshold); ratedCurrentAmps is the usable limit reported to the application. Keeping + // full-scale above the rated current leaves headroom for a meaningful overcurrent trip. + static constexpr float maxCurrentAmps{ 15.0f }; + static constexpr float ratedCurrentAmps{ 3.0f }; + static constexpr float overcurrentThresholdAmps{ 12.0f }; static constexpr float AdcToVoltsFactor(float adcReferenceVoltage, float adcResolution) { diff --git a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp index ae371da8..f85f490b 100644 --- a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp +++ b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp @@ -135,7 +135,7 @@ namespace application foc::Ampere PlatformFactoryImpl::MaxCurrentSupported() const { - return foc::Ampere(BoardCharacteristics::maxCurrentAmps); + return foc::Ampere(BoardCharacteristics::ratedCurrentAmps); } foc::LowPriorityInterrupt& PlatformFactoryImpl::LowPriorityInterrupt() @@ -178,6 +178,7 @@ namespace application auto& adcCfg = impl.adcConfig; adcCfg.sampleAndHold = impl.toSampleAndHold.at(static_cast(sampleAndHold)); + adcCfg.digitalComparators = infra::MakeRange(impl.digitalComparators); peripherals->phaseCurrentAdc.reset(); peripherals->phaseCurrentAdc.emplace( diff --git a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp index a23d0cd5..72fd05d5 100644 --- a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp +++ b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp @@ -127,12 +127,20 @@ namespace application static constexpr auto currentSensingOversampling = hal::tiva::Adc::Oversampling::oversampling2; // Steps 0-2 (phase currents A/B/C) go to the ADC FIFO. - // NOTE: the step-3 currentTotal -> DCMP0 overcurrent redirect is temporarily - // disabled while investigating current-measurement bias — hardware overcurrent - // trip via the ADC digital comparator is OFF in this configuration. + // Step 3 is redirected to DCMP0 via the SSOP register and does NOT appear in the FIFO. + // DCMP0 connects to PWM FLTSRC1 bit 0 and tristates all motor PWM outputs instantly + // when the overcurrent threshold is exceeded. Overvoltage is monitored separately by + // AdcForPowerSupplyMeasurementImpl (synchronous ADC1, sequencer 0). + static constexpr std::array digitalComparators{ { + {}, // step 0: currentPhaseA -> FIFO (noComparator) + {}, // step 1: currentPhaseB -> FIFO (noComparator) + {}, // step 2: currentPhaseC -> FIFO (noComparator) + { Peripheral::OvercurrentComparatorIndex, 0, Peripheral::overcurrentThresholdCounts, + hal::tiva::Adc::ComparatorCondition::highBand, hal::tiva::Adc::ComparatorMode::always }, + } }; hal::tiva::Adc::Config adcConfig{ false, 0, Peripheral::adcTrigger, hal::tiva::Adc::SampleAndHold::sampleAndHold8, std::make_optional(currentSensingOversampling), phaseDelay }; - std::array currentPhaseAnalogPins{ { hal::tiva::AnalogPin{ Pins::currentPhaseA }, hal::tiva::AnalogPin{ Pins::currentPhaseB }, hal::tiva::AnalogPin{ Pins::currentPhaseC } } }; + std::array currentPhaseAnalogPins{ { hal::tiva::AnalogPin{ Pins::currentPhaseA }, hal::tiva::AnalogPin{ Pins::currentPhaseB }, hal::tiva::AnalogPin{ Pins::currentPhaseC }, hal::tiva::AnalogPin{ Pins::currentTotal } } }; }; struct AsyncPwmConfig From bcf8f9ab4d7730711d4bf9262e66cf495edfa877 Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Fri, 24 Jul 2026 17:36:36 +0000 Subject: [PATCH 26/28] adjust interrupt priority --- .gitignore | 1 + CLAUDE.md | 1 + infra/hal/ti | 2 +- .../hardware_test/components/CMakeLists.txt | 1 + targets/hardware_test/components/Terminal.cpp | 217 +++++-- targets/hardware_test/components/Terminal.hpp | 30 + .../components/test/TestTerminal.cpp | 589 +++++++++++++++++- .../hardware_test/instantiations/Logic.hpp | 2 +- .../ti/implementation/PlatformFactoryImpl.cpp | 5 + .../ti/implementation/PlatformFactoryImpl.hpp | 4 +- 10 files changed, 801 insertions(+), 51 deletions(-) diff --git a/.gitignore b/.gitignore index 39ea1cd7..218055bf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ install/ .venv/ output/ cucumber.toml +.claude/plans/ diff --git a/CLAUDE.md b/CLAUDE.md index 4c4dd611..666b43a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,7 @@ Apply `OPTIMIZE_FOR_SPEED` (from `numerical/math/CompilerOptimizations.hpp`) to - Avoid large implementations in headers; keep non-trivial logic in `.cpp` files. Small `inline`/`constexpr` helpers in headers are allowed (and common in hot paths). - Prefer `{}` initialization over `()` for all variables and member data. - `const` on all non-mutating methods, `constexpr` for motor constants and lookup tables. +- **Comments**: add one only when the *why* is non-obvious. Never write comments that restate what the code does, narrate steps, or label the obvious — no filler. When in doubt, leave it out. ## 4. FOC Theory — Correctness diff --git a/infra/hal/ti b/infra/hal/ti index 1970f628..145d2f29 160000 --- a/infra/hal/ti +++ b/infra/hal/ti @@ -1 +1 @@ -Subproject commit 1970f628c39d2b8b661d11004579d16d96920542 +Subproject commit 145d2f297af18153901a5e5e961ff4d25343c697 diff --git a/targets/hardware_test/components/CMakeLists.txt b/targets/hardware_test/components/CMakeLists.txt index 2965270a..81eef94a 100644 --- a/targets/hardware_test/components/CMakeLists.txt +++ b/targets/hardware_test/components/CMakeLists.txt @@ -13,6 +13,7 @@ target_link_libraries(e_foc.hardware_test.components PUBLIC e_foc.foc.implementations e_foc.services.alignment e_foc.services.electrical_system_ident + e_foc.services.mechanical_system_ident ) target_sources(e_foc.hardware_test.components PRIVATE diff --git a/targets/hardware_test/components/Terminal.cpp b/targets/hardware_test/components/Terminal.cpp index 1198c6e4..fca0b920 100644 --- a/targets/hardware_test/components/Terminal.cpp +++ b/targets/hardware_test/components/Terminal.cpp @@ -13,12 +13,13 @@ #include #include +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC optimize("O3", "fast-math") +#endif + namespace { constexpr float pi_div_180 = std::numbers::pi_v / 180.0f; - const std::size_t outerInnerLoopRatio = 10; - const hal::Hertz defaultPwmFrequency{ 10000 }; - const hal::Hertz speedLoopFrequency{ static_cast(defaultPwmFrequency.Value() / outerInnerLoopRatio) }; application::PlatformFactory::SampleAndHold ToSampleAndHold(const infra::BoundedConstString& value) { @@ -91,136 +92,171 @@ namespace application , performanceTimer{ hardware.PerformanceTimer() } , Vdc{ hardware.PowerSupplyVoltage() } , systemClock{ hardware.SystemClock() } - , foc{ hardware.MaxCurrentSupported(), hal::Hertz{ 1000 }, hardware.LowPriorityInterrupt() } + , foc{ hardware.MaxCurrentSupported(), baseFrequency_, hardware.LowPriorityInterrupt() } + , onlineMechEstimator{ services::RealTimeFrictionAndInertiaEstimator::defaultForgettingFactor, foc.OuterLoopFrequency() } + , onlineElecEstimator{ services::RealTimeResistanceAndInductanceEstimator::defaultForgettingFactor, foc.OuterLoopFrequency() } , eeprom{ hardware.Eeprom() } , electricalIdent{ hardware, hardware, Vdc } , motorAlignment{ hardware, hardware } { - terminal.AddCommand({ { "enc", "e", "Read encoder. stop. Ex: enc" }, + AddCommand({ "enc", "e", "Read encoder. stop. Ex: enc" }, [this](const auto&) { this->terminal.ProcessResult(ReadEncoder()); - } }); + }, + true); - terminal.AddCommand({ { "stop", "stp", "Stop pwm. stop. Ex: stop" }, + AddCommand({ "stop", "stp", "Stop pwm. stop. Ex: stop" }, [this](const auto&) { this->terminal.ProcessResult(Stop()); - } }); + }, + true); - terminal.AddCommand({ { "duty", "d", "Set and start pwm duty. Ex: duty 0 10 25" }, + AddCommand({ "duty", "d", "Set and start pwm duty. Ex: duty 0 10 25" }, [this](const infra::BoundedConstString& param) { this->terminal.ProcessResult(SetPwmDuty(param)); - } }); + }); - terminal.AddCommand({ { "pwm", "p", "Configure pwm [dead_time ns [500; 2000]] [frequency Hz [10000; 20000]]. Ex: pwm 500 10000" }, + AddCommand({ "pwm", "p", "Configure pwm [dead_time ns [500; 2000]] [frequency Hz [10000; 20000]]. Ex: pwm 500 10000" }, [this](const infra::BoundedConstString& param) { this->terminal.ProcessResult(ConfigurePwm(param)); - } }); + }); - terminal.AddCommand({ { "adc", "a", "Configure adc and prints raw data for all three channels [sample_and_hold [short, medium, long]]. Ex: adc short" }, + AddCommand({ "adc", "a", "Configure adc and prints raw data for all three channels [sample_and_hold [short, medium, long]]. Ex: adc short" }, [this](const infra::BoundedConstString& param) { this->terminal.ProcessResult(ConfigureAdc(param)); - } }); + }); - terminal.AddCommand({ { "pid", "c", "Configure speed and DQ PIDs [spd_kp spd_ki spd_kd dq_kp dq_ki dq_kd]. Ex: pid 1 0 0 1 0 0" }, + AddCommand({ "pid", "c", "Configure speed and DQ PIDs [spd_kp spd_ki spd_kd dq_kp dq_ki dq_kd]. Ex: pid 1 0 0 1 0 0" }, [this](const infra::BoundedConstString& param) { this->terminal.ProcessResult(ConfigurePid(param)); - } }); + }); - terminal.AddCommand({ { "foc", "f", "Simulate foc [pole_pairs angle ia ib ic]. Ex: foc 7 30 1 2 3" }, + AddCommand({ "foc", "f", "Simulate foc [pole_pairs angle ia ib ic]. Ex: foc 7 30 1 2 3" }, [this](const infra::BoundedConstString& param) { this->terminal.ProcessResult(SimulateFoc(param)); - } }); + }); - terminal.AddCommand({ { "ident", "id", "Identify R, L and pole pairs. ident [inj_freq_hz] [inj_v%] [pp_v%] [pp_revs] [pp_settle_ms]. Ex: ident wye 250 15 10 5 50" }, + AddCommand({ "ident", "id", "Identify R, L and pole pairs. ident [inj_freq_hz] [inj_v%] [pp_v%] [pp_revs] [pp_settle_ms]. Ex: ident wye 250 15 10 5 50" }, [this](const infra::BoundedConstString& param) { this->terminal.ProcessResult(IdentifyElectricalParameters(param)); - } }); + }); - terminal.AddCommand({ { "align", "al", "Align rotor using identified pole pairs. align [v%] [samp_hz] [max_samp] [thresh_rad] [count]. Ex: align 20 1000 500 0.001 10" }, + AddCommand({ "align", "al", "Align rotor using identified pole pairs. align [v%] [samp_hz] [max_samp] [thresh_rad] [count]. Ex: align 20 1000 500 0.001 10" }, [this](const infra::BoundedConstString& param) { this->terminal.ProcessResult(AlignMotor(param)); - } }); + }); + + AddCommand({ "speed", "s", "Run closed-loop speed FOC (requires prior ident). speed [bandwidth_rad_s]. Ex: speed 300 0.05 150" }, + [this](const infra::BoundedConstString& param) + { + this->terminal.ProcessResult(RunSpeedFoc(param)); + }); + + AddCommand({ "speedstat", "ss", "Report live online estimates (J, b, R, L). Ex: speedstat" }, + [this](const auto&) + { + this->terminal.ProcessResult(ReportSpeedEstimates()); + }, + true); - terminal.AddCommand({ { "can_start", "cs", "Start CAN bus [bitrate [100000;1000000]] [test]. Ex: can_start 500000" }, + AddCommand({ "can_start", "cs", "Start CAN bus [bitrate [100000;1000000]] [test]. Ex: can_start 500000" }, [this](const infra::BoundedConstString& param) { this->terminal.ProcessResult(CanStart(param)); - } }); + }); - terminal.AddCommand({ { "can_stop", "cx", "Stop CAN bus. Ex: can_stop" }, + AddCommand({ "can_stop", "cx", "Stop CAN bus. Ex: can_stop" }, [this](const auto&) { this->terminal.ProcessResult(CanStop()); - } }); + }); - terminal.AddCommand({ { "can_send", "ct", "Send CAN frame [id] [b0] ... [b7]. Ex: can_send 256 1 2 3" }, + AddCommand({ "can_send", "ct", "Send CAN frame [id] [b0] ... [b7]. Ex: can_send 256 1 2 3" }, [this](const infra::BoundedConstString& param) { this->terminal.ProcessResult(CanSend(param)); - } }); + }); - terminal.AddCommand({ { "can_listen", "cl", "Listen for CAN messages. Ex: can_listen" }, + AddCommand({ "can_listen", "cl", "Listen for CAN messages. Ex: can_listen" }, [this](const auto&) { this->terminal.ProcessResult(CanListen()); - } }); + }); - terminal.AddCommand({ { "eeprom_write", "ew", "Write bytes to EEPROM. eeprom_write [b1...]. Ex: eeprom_write 0 255 170" }, + AddCommand({ "eeprom_write", "ew", "Write bytes to EEPROM. eeprom_write [b1...]. Ex: eeprom_write 0 255 170" }, [this](const infra::BoundedConstString& param) { EepromWrite(param); - } }); + }); - terminal.AddCommand({ { "eeprom_read", "er", "Read bytes from EEPROM. eeprom_read . Ex: eeprom_read 0 8" }, + AddCommand({ "eeprom_read", "er", "Read bytes from EEPROM. eeprom_read . Ex: eeprom_read 0 8" }, [this](const infra::BoundedConstString& param) { EepromRead(param); - } }); + }); - terminal.AddCommand({ { "eeprom_erase", "ee", "Erase entire EEPROM. Ex: eeprom_erase" }, + AddCommand({ "eeprom_erase", "ee", "Erase entire EEPROM. Ex: eeprom_erase" }, [this](const auto&) { EepromErase(); - } }); + }); - terminal.AddCommand({ { "reset", "rst", "Reset the device. Ex: reset" }, + AddCommand({ "reset", "rst", "Reset the device. Ex: reset" }, [this](const auto&) { this->terminal.ProcessResult(ResetDevice()); - } }); + }); - terminal.AddCommand({ { "reset_cause", "rc", "Display reset cause. Ex: reset_cause" }, + AddCommand({ "reset_cause", "rc", "Display reset cause. Ex: reset_cause" }, [this](const auto&) { this->terminal.ProcessResult(GetResetCauseStatus()); - } }); + }, + true); - terminal.AddCommand({ { "fault_status", "fs", "Display fault data from previous session. Ex: fault_status" }, + AddCommand({ "fault_status", "fs", "Display fault data from previous session. Ex: fault_status" }, [this](const auto&) { this->terminal.ProcessResult(GetFaultStatus()); - } }); + }, + true); - terminal.AddCommand({ { "force_hardfault", "fhf", "Trigger a HardFault exception for error handler validation. Ex: force_hardfault" }, + AddCommand({ "force_hardfault", "fhf", "Trigger a HardFault exception for error handler validation. Ex: force_hardfault" }, [this](const auto&) { this->terminal.ProcessResult(ForceHardfault()); - } }); + }); hardware.SetEncoderResolution(4000); hardware.ConfigureAdcAndPwm(hal::Hertz{ 10000 }, std::chrono::nanoseconds{ 500 }, PlatformFactory::SampleAndHold::shortest); StartAdc(PlatformFactory::SampleAndHold::shortest); } + void TerminalInteractor::AddCommand(const CommandInfo& info, const CommandHandler& handler, bool allowedWhileSpinning) + { + const std::size_t index = guardedCommands.size(); + guardedCommands.emplace_back(GuardedCommand{ handler, allowedWhileSpinning }); + + terminal.AddCommand({ info, + [this, index](const infra::BoundedConstString& params) + { + const auto& command = guardedCommands[index]; + if (speedActive_ && !command.allowedWhileSpinning) + terminal.ProcessResult({ error, "motor spinning. Run 'stop' first." }); + else + command.handler(params); + } }); + } + TerminalInteractor::StatusWithMessage TerminalInteractor::ConfigurePwm(const infra::BoundedConstString& param) { infra::Tokenizer tokenizer(param, ' '); @@ -373,6 +409,13 @@ namespace application TerminalInteractor::StatusWithMessage TerminalInteractor::Stop() { hardware.Stop(); + + if (speedActive_) + { + foc.Disable(); + speedActive_ = false; + } + return { success }; } @@ -470,6 +513,7 @@ namespace application } identificationResults.reset(); + motorAligned = false; electricalIdent.EstimateResistanceAndInductance(rlConfig, [this](std::optional result) { @@ -541,10 +585,91 @@ namespace application motorAlignment.ForceAlignment(identificationResults->polePairs, config, [this](std::optional offset) { if (!offset.has_value()) + { + motorAligned = false; tracer.Trace() << " Alignment failed: rotor did not converge."; - else - tracer.Trace() << " Alignment complete. Offset: " << offset->Value() << " radians."; + return; + } + + // Rotor is held at the d-axis (electrical angle 0), so zero the encoder here to lock the FOC frame. + hardware.SetZero(); + motorAligned = true; + tracer.Trace() << " Alignment complete. Offset: " << offset->Value() << " radians."; + }); + + return { success }; + } + + TerminalInteractor::StatusWithMessage TerminalInteractor::RunSpeedFoc(const infra::BoundedConstString& param) + { + if (!identificationResults.has_value() || identificationResults->polePairs == 0) + return { error, "no pole pairs identified. Run 'ident' first." }; + + if (!motorAligned) + return { error, "motor not aligned. Run 'align' first." }; + + infra::Tokenizer tokenizer(param, ' '); + + if (tokenizer.Size() < 2 || tokenizer.Size() > 3) + return { error, "invalid number of arguments" }; + + auto rpm = ParseInput(tokenizer.Token(0), -20000, 20000); + if (!rpm.has_value()) + return { error, "invalid value for target speed. It should be an integer between -20000 and 20000 RPM." }; + + auto kt = ParseInput(tokenizer.Token(1), 0.001f, 10.0f); + if (!kt.has_value()) + return { error, "invalid value for torque constant. It should be a float between 0.001 and 10." }; + + float bandwidth = defaultSpeedBandwidthRadPerSec; + if (tokenizer.Size() == 3) + { + auto parsedBandwidth = ParseInput(tokenizer.Token(2), 1, 10000); + if (!parsedBandwidth.has_value()) + return { error, "invalid value for bandwidth. It should be an integer between 1 and 10000 rad/s." }; + bandwidth = static_cast(*parsedBandwidth); + } + + const foc::NewtonMeterSecondSquared defaultInertia{ defaultInertiaValue }; + const foc::NewtonMeterSecondPerRadian defaultFriction{ defaultFrictionValue }; + const auto controlFrequency = baseFrequency_; + + foc.SetPolePairs(identificationResults->polePairs); + foc::WithAutomaticCurrentPidGains{ foc }.SetPidBasedOnResistanceAndInductance(Vdc, identificationResults->rl.resistance, identificationResults->rl.inductance, controlFrequency, currentLoopNyquistFactor); + foc::WithAutomaticSpeedPidGains{ foc }.SetPidBasedOnInertiaAndFriction(Vdc, defaultInertia, defaultFriction, bandwidth); + + onlineElecEstimator.SetInitialEstimate(identificationResults->rl.resistance, identificationResults->rl.inductance); + onlineMechEstimator.SetTorqueConstant(foc::NewtonMeter{ *kt }); + onlineMechEstimator.SetInitialEstimate(defaultInertia, defaultFriction); + foc.SetOnlineMechanicalEstimator(onlineMechEstimator); + foc.SetOnlineElectricalEstimator(onlineElecEstimator); + + foc.SetPoint(foc::RadiansPerSecond{ static_cast(*rpm) * (2.0f * std::numbers::pi_v) / 60.0f }); + + hardware.Stop(); + adcActive_ = false; + hardware.ConfigureAdcAndPwm(controlFrequency, currentPwmDeadTime_, currentSah_); + hardware.PhaseCurrentsReady(controlFrequency, [this](foc::PhaseCurrents currentPhases) + { + auto position = hardware.Read(); + hardware.ThreePhasePwmOutput(foc.Calculate(currentPhases, position)); }); + foc.Enable(); + hardware.Start(); + speedActive_ = true; + + tracer.Trace() << " Speed FOC running at " << *rpm << " RPM"; + + return { success }; + } + + TerminalInteractor::StatusWithMessage TerminalInteractor::ReportSpeedEstimates() + { + tracer.Trace() << " Online Estimates:"; + tracer.Trace() << " Inertia: " << onlineMechEstimator.CurrentInertia().Value() << " kg*m^2"; + tracer.Trace() << " Friction: " << onlineMechEstimator.CurrentFriction().Value() << " N*m*s/rad"; + tracer.Trace() << " Resistance: " << onlineElecEstimator.CurrentResistance().Value() << " Ohm"; + tracer.Trace() << " Inductance: " << onlineElecEstimator.CurrentInductance().Value() << " mH"; return { success }; } diff --git a/targets/hardware_test/components/Terminal.hpp b/targets/hardware_test/components/Terminal.hpp index d47fb2ea..9bb56c28 100644 --- a/targets/hardware_test/components/Terminal.hpp +++ b/targets/hardware_test/components/Terminal.hpp @@ -1,10 +1,14 @@ #pragma once #include "core/foc/implementations/FocSpeedImpl.hpp" +#include "core/foc/implementations/WithAutomaticCurrentPidGains.hpp" +#include "core/foc/implementations/WithAutomaticSpeedPidGains.hpp" #include "core/foc/interfaces/Driver.hpp" #include "core/platform_abstraction/PlatformFactory.hpp" #include "core/services/alignment/MotorAlignmentImpl.hpp" #include "core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp" +#include "core/services/electrical_system_ident/RealTimeResistanceAndInductanceEstimator.hpp" +#include "core/services/mechanical_system_ident/RealTimeFrictionAndInertiaEstimator.hpp" #include "hal/interfaces/Eeprom.hpp" #include "hal/interfaces/Pwm.hpp" #include "infra/util/BoundedDeque.hpp" @@ -20,6 +24,17 @@ namespace application private: using StatusWithMessage = services::TerminalWithStorage::StatusWithMessage; + using CommandInfo = services::TerminalWithStorage::CommandInfo; + using CommandHandler = infra::Function; + + struct GuardedCommand + { + CommandHandler handler; + bool allowedWhileSpinning{ false }; + }; + + // Registers a handler that is rejected while the motor is spinning, unless the command is safe to run then. + void AddCommand(const CommandInfo& info, const CommandHandler& handler, bool allowedWhileSpinning = false); struct IdentificationResults { @@ -39,6 +54,8 @@ namespace application StatusWithMessage SetPwmDuty(const infra::BoundedConstString& param); StatusWithMessage IdentifyElectricalParameters(const infra::BoundedConstString& param); StatusWithMessage AlignMotor(const infra::BoundedConstString& param); + StatusWithMessage RunSpeedFoc(const infra::BoundedConstString& param); + StatusWithMessage ReportSpeedEstimates(); StatusWithMessage CanStart(const infra::BoundedConstString& param); StatusWithMessage CanStop(); StatusWithMessage CanSend(const infra::BoundedConstString& param); @@ -57,12 +74,19 @@ namespace application static constexpr std::size_t averageSampleSize = 100; using QueueOfPhaseCurrents = infra::BoundedDeque::WithMaxSize; + // Rough bench defaults; the online estimators refine these live — retune per rig. + static constexpr float defaultInertiaValue{ 7.5e-6f }; // kg*m^2 + static constexpr float defaultFrictionValue{ 2.0e-5f }; // N*m*s/rad + static constexpr float defaultSpeedBandwidthRadPerSec{ 100.0f }; + static constexpr float currentLoopNyquistFactor{ 0.1f }; + void StartAdc(PlatformFactory::SampleAndHold sampleAndHold); bool IsAdcBufferPopulated() const; void RunFocSimulation(foc::PhaseCurrents input, foc::Radians angle); private: const infra::BoundedVector::WithMaxSize<5> acceptedAdcValues{ { "shortest", "shorter", "medium", "longer", "longest" } }; + infra::BoundedVector::WithMaxSize<24> guardedCommands; services::TerminalWithStorage& terminal; services::Tracer& tracer; @@ -79,13 +103,19 @@ namespace application controllers::PidTunings speedPidTunings; controllers::PidTunings dqPidTunings; std::optional polePairs = 0; + // foc's current-PID dt is fixed at this rate, so the live loop always reconfigures back to it. + hal::Hertz baseFrequency_{ hardware.BaseFrequency() }; foc::FocSpeedImpl foc; + services::RealTimeFrictionAndInertiaEstimator onlineMechEstimator; + services::RealTimeResistanceAndInductanceEstimator onlineElecEstimator; + bool speedActive_{ false }; hal::Eeprom& eeprom; std::array eepromBuffer{}; uint32_t eepromCurrentReadSize{ 0 }; services::ElectricalParametersIdentificationImpl electricalIdent; services::MotorAlignmentImpl motorAlignment; std::optional identificationResults; + bool motorAligned{ false }; services::ElectricalParametersIdentification::PolePairsConfig pendingPolePairsConfig; }; } diff --git a/targets/hardware_test/components/test/TestTerminal.cpp b/targets/hardware_test/components/test/TestTerminal.cpp index 14d7aee8..4fd0f436 100644 --- a/targets/hardware_test/components/test/TestTerminal.cpp +++ b/targets/hardware_test/components/test/TestTerminal.cpp @@ -8,11 +8,23 @@ #include "infra/util/test_helper/MockHelpers.hpp" #include "services/tracer/Tracer.hpp" #include "targets/hardware_test/components/Terminal.hpp" +#include "core/foc/implementations/TransformsClarkePark.hpp" #include "gmock/gmock.h" #include "gtest/gtest.h" +#include +#include namespace { + constexpr float identTwoPi = 2.0f * std::numbers::pi_v; + + float IdentMechanicalAngle(std::size_t stepIndex, std::size_t totalSteps, std::size_t expectedPolePairs) + { + constexpr std::size_t stepsPerRevolution = 12; + auto electricalRevolutions = totalSteps / stepsPerRevolution; + auto electricalAngle = (static_cast(stepIndex) / static_cast(totalSteps)) * (static_cast(electricalRevolutions) * identTwoPi); + return electricalAngle / static_cast(expectedPolePairs); + } class PlatformFactoryMock : public application::PlatformFactory { @@ -95,6 +107,7 @@ namespace EXPECT_CALL(platformFactoryMock, PowerSupplyVoltage()).WillRepeatedly(testing::Return(foc::Volts{ 24.0f })); EXPECT_CALL(platformFactoryMock, MaxCurrentSupported()).WillRepeatedly(testing::Return(foc::Ampere{ 5.0f })); EXPECT_CALL(platformFactoryMock, SystemClock()).WillRepeatedly(testing::Return(hal::Hertz{ 10000 })); + EXPECT_CALL(platformFactoryMock, BaseFrequency()).WillRepeatedly(testing::Return(hal::Hertz{ 10000 })); EXPECT_CALL(platformFactoryMock, LowPriorityInterrupt()).WillRepeatedly(testing::ReturnRef(simpleLowPriorityInterrupt)); EXPECT_CALL(platformFactoryMock, Eeprom()).WillRepeatedly(testing::ReturnRef(eepromMock)); EXPECT_CALL(platformFactoryMock, GetResetCause()).WillRepeatedly(testing::Return(application::ResetCause::powerUp)); @@ -122,7 +135,7 @@ namespace EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); } }; services::TerminalWithCommandsImpl::WithMaxQueueAndMaxHistory<128, 5> terminalWithCommands{ communication, tracer }; - services::TerminalWithStorage::WithMaxSize<22> terminal{ terminalWithCommands, tracer }; + services::TerminalWithStorage::WithMaxSize<24> terminal{ terminalWithCommands, tracer }; testing::StrictMock performanceTrackerMock; testing::StrictMock eepromMock; @@ -160,6 +173,122 @@ namespace EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(footer.begin(), footer.end())), testing::_)); } + + // Drives a full electrical identification (R/L injection + pole-pairs sweep) so the speed command's guard passes. + static constexpr float identSamplingFrequency = 10000.0f; + static constexpr std::size_t identInjectionFrequency = 250; + static constexpr std::size_t identInjectionVoltagePercent = 15; + static constexpr std::size_t identWarmupPeriods = 10; + static constexpr std::size_t identMeasurementPeriods = 50; + static constexpr std::size_t identVoltageToCurrentDelaySamples = 1; + static constexpr float identVoltsPerModulation = 24.0f / 2.0f; + static constexpr float identInjectionAmplitude = static_cast(identInjectionVoltagePercent) / 100.0f * identVoltsPerModulation; + static constexpr float identOmega = identTwoPi * static_cast(identInjectionFrequency); + static constexpr float identSamplingPeriod = 1.0f / identSamplingFrequency; + static constexpr std::size_t identSamplesPerPeriod = static_cast(identSamplingFrequency) / identInjectionFrequency; + + void FeedIdentHfBurst(float resistance, float inductance) + { + foc::Clarke clarke; + const float impedance = std::sqrt(resistance * resistance + (identOmega * inductance) * (identOmega * inductance)); + const float current = identInjectionAmplitude / impedance; + const float phi = std::atan2(identOmega * inductance, resistance); + + const std::size_t totalSamples = (identWarmupPeriods + identMeasurementPeriods) * identSamplesPerPeriod; + for (std::size_t k = 0; k < totalSamples; ++k) + { + float iAlpha = 0.0f; + if (k >= identVoltageToCurrentDelaySamples) + { + const float appliedPhase = static_cast(k - identVoltageToCurrentDelaySamples) * identOmega * identSamplingPeriod; + iAlpha = current * std::sin(appliedPhase - phi); + } + + const auto phases = clarke.Inverse(foc::TwoPhase{ iAlpha, 0.0f }); + onPhaseCurrentsReady(foc::PhaseCurrents{ foc::Ampere{ phases.a }, foc::Ampere{ phases.b }, foc::Ampere{ phases.c } }); + } + } + + void CompleteIdentification(std::size_t expectedPolePairs) + { + constexpr std::size_t totalSteps = 5 * 12; + + EXPECT_CALL(platformFactoryMock, Stop()).Times(testing::AnyNumber()); + EXPECT_CALL(platformFactoryMock, ThreePhasePwmOutput(testing::_)).Times(testing::AnyNumber()); + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + + std::size_t encoderStepIndex = 0; + EXPECT_CALL(platformFactoryMock, Read()) + .WillOnce(testing::Return(foc::Radians{ 0.0f })) + .WillRepeatedly([&encoderStepIndex, expectedPolePairs]() + { + ++encoderStepIndex; + return foc::Radians{ IdentMechanicalAngle(encoderStepIndex, totalSteps, expectedPolePairs) }; + }); + + communication.dataReceived(infra::MakeStringByteRange(std::string("ident wye\r"))); + ExecuteAllActions(); + + FeedIdentHfBurst(1.5f, 0.002f); + + for (std::size_t i = 0; i < totalSteps; ++i) + ForwardTime(std::chrono::milliseconds{ 50 }); + + ExecuteAllActions(); + + // Clear transient ident expectations so the command under test starts from a clean mock, then restore fixture stubs. + testing::Mock::VerifyAndClearExpectations(&streamWriterMock); + testing::Mock::VerifyAndClearExpectations(&platformFactoryMock); + EXPECT_CALL(platformFactoryMock, Terminal()).WillRepeatedly(testing::ReturnRef(terminalWithCommands)); + EXPECT_CALL(platformFactoryMock, Tracer()).WillRepeatedly(testing::ReturnRef(tracer)); + EXPECT_CALL(platformFactoryMock, BaseFrequency()).WillRepeatedly(testing::Return(hal::Hertz{ 10000 })); + } + + // Completes only the R/L stage, leaving polePairs at 0, to exercise the speed guard that R/L alone is insufficient. + void CompleteResistanceInductanceOnly() + { + EXPECT_CALL(platformFactoryMock, Stop()).Times(testing::AnyNumber()); + EXPECT_CALL(platformFactoryMock, ThreePhasePwmOutput(testing::_)).Times(testing::AnyNumber()); + EXPECT_CALL(platformFactoryMock, Read()).WillRepeatedly(testing::Return(foc::Radians{ 0.0f })); + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + + communication.dataReceived(infra::MakeStringByteRange(std::string("ident wye\r"))); + ExecuteAllActions(); + + FeedIdentHfBurst(1.5f, 0.002f); + ExecuteAllActions(); + + testing::Mock::VerifyAndClearExpectations(&streamWriterMock); + testing::Mock::VerifyAndClearExpectations(&platformFactoryMock); + EXPECT_CALL(platformFactoryMock, Terminal()).WillRepeatedly(testing::ReturnRef(terminalWithCommands)); + EXPECT_CALL(platformFactoryMock, Tracer()).WillRepeatedly(testing::ReturnRef(tracer)); + EXPECT_CALL(platformFactoryMock, BaseFrequency()).WillRepeatedly(testing::Return(hal::Hertz{ 10000 })); + } + + // Drives align to convergence (stable encoder, zeroed at the d-axis) so the speed command's alignment guard passes. + void CompleteAlignment() + { + EXPECT_CALL(platformFactoryMock, Stop()).Times(testing::AnyNumber()); + EXPECT_CALL(platformFactoryMock, ThreePhasePwmOutput(testing::_)).Times(testing::AnyNumber()); + EXPECT_CALL(platformFactoryMock, Read()).WillRepeatedly(testing::Return(foc::Radians{ 0.0f })); + EXPECT_CALL(platformFactoryMock, SetZero()).Times(1); + EXPECT_CALL(platformFactoryMock, PhaseCurrentsReady(testing::_, testing::_)).WillRepeatedly(testing::SaveArg<1>(&onPhaseCurrentsReady)); + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + + communication.dataReceived(infra::MakeStringByteRange(std::string("align\r"))); + ExecuteAllActions(); + + constexpr std::size_t defaultSettledCount = 10; + for (std::size_t i = 0; i < defaultSettledCount; ++i) + onPhaseCurrentsReady(foc::PhaseCurrents{ foc::Ampere{ 0.0f }, foc::Ampere{ 0.0f }, foc::Ampere{ 0.0f } }); + ExecuteAllActions(); + + testing::Mock::VerifyAndClearExpectations(&streamWriterMock); + testing::Mock::VerifyAndClearExpectations(&platformFactoryMock); + EXPECT_CALL(platformFactoryMock, Terminal()).WillRepeatedly(testing::ReturnRef(terminalWithCommands)); + EXPECT_CALL(platformFactoryMock, Tracer()).WillRepeatedly(testing::ReturnRef(tracer)); + EXPECT_CALL(platformFactoryMock, BaseFrequency()).WillRepeatedly(testing::Return(hal::Hertz{ 10000 })); + } }; } @@ -1824,3 +1953,461 @@ TEST_F(TestHardwareTerminal, eeprom_read_address_out_of_range_returns_error) ExecuteAllActions(); } + +TEST_F(TestHardwareTerminal, speed_without_identification_returns_error) +{ + InvokeCommand("speed 300 0.05", [this]() + { + ::testing::InSequence _; + + std::string newline{ "\r\n" }; + std::string header{ "ERROR: " }; + std::string payload{ "no pole pairs identified. Run 'ident' first." }; + + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, speed_invalid_argument_count) +{ + CompleteIdentification(2); + CompleteAlignment(); + + InvokeCommand("speed 300", [this]() + { + ::testing::InSequence _; + + std::string newline{ "\r\n" }; + std::string header{ "ERROR: " }; + std::string payload{ "invalid number of arguments" }; + + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, speed_invalid_rpm) +{ + CompleteIdentification(2); + CompleteAlignment(); + + InvokeCommand("speed invalid 0.05", [this]() + { + ::testing::InSequence _; + + std::string newline{ "\r\n" }; + std::string header{ "ERROR: " }; + std::string payload{ "invalid value for target speed. It should be an integer between -20000 and 20000 RPM." }; + + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, speed_invalid_torque_constant) +{ + CompleteIdentification(2); + CompleteAlignment(); + + InvokeCommand("speed 300 invalid", [this]() + { + ::testing::InSequence _; + + std::string newline{ "\r\n" }; + std::string header{ "ERROR: " }; + std::string payload{ "invalid value for torque constant. It should be a float between 0.001 and 10." }; + + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, speed_installs_live_loop_after_identification) +{ + CompleteIdentification(2); + CompleteAlignment(); + + InvokeCommand("speed 300 0.05", [this]() + { + EXPECT_CALL(platformFactoryMock, Stop()).Times(1); + EXPECT_CALL(platformFactoryMock, ConfigureAdcAndPwm(hal::Hertz{ 10000 }, testing::_, testing::_)).Times(1); + EXPECT_CALL(platformFactoryMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, testing::_)).WillOnce(testing::SaveArg<1>(&onPhaseCurrentsReady)); + EXPECT_CALL(platformFactoryMock, Start()).Times(1); + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, speed_alias_installs_live_loop) +{ + CompleteIdentification(2); + CompleteAlignment(); + + InvokeCommand("s 300 0.05 150", [this]() + { + EXPECT_CALL(platformFactoryMock, Stop()).Times(1); + EXPECT_CALL(platformFactoryMock, ConfigureAdcAndPwm(hal::Hertz{ 10000 }, testing::_, testing::_)).Times(1); + EXPECT_CALL(platformFactoryMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, testing::_)).WillOnce(testing::SaveArg<1>(&onPhaseCurrentsReady)); + EXPECT_CALL(platformFactoryMock, Start()).Times(1); + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, speed_live_loop_drives_calculate_and_pwm_output) +{ + CompleteIdentification(2); + CompleteAlignment(); + + InvokeCommand("speed 300 0.05", [this]() + { + EXPECT_CALL(platformFactoryMock, Stop()).Times(1); + EXPECT_CALL(platformFactoryMock, ConfigureAdcAndPwm(hal::Hertz{ 10000 }, testing::_, testing::_)).Times(1); + EXPECT_CALL(platformFactoryMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, testing::_)).WillOnce(testing::SaveArg<1>(&onPhaseCurrentsReady)); + EXPECT_CALL(platformFactoryMock, Start()).Times(1); + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); + + EXPECT_CALL(platformFactoryMock, Read()).WillOnce(testing::Return(foc::Radians{ 0.5f })); + EXPECT_CALL(platformFactoryMock, ThreePhasePwmOutput(testing::_)).Times(1); + + onPhaseCurrentsReady(foc::PhaseCurrents{ foc::Ampere{ 1.0f }, foc::Ampere{ -0.5f }, foc::Ampere{ -0.5f } }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, stop_disables_foc_after_speed_run) +{ + CompleteIdentification(2); + CompleteAlignment(); + + InvokeCommand("speed 300 0.05", [this]() + { + EXPECT_CALL(platformFactoryMock, Stop()).Times(1); + EXPECT_CALL(platformFactoryMock, ConfigureAdcAndPwm(hal::Hertz{ 10000 }, testing::_, testing::_)).Times(1); + EXPECT_CALL(platformFactoryMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, testing::_)).WillOnce(testing::SaveArg<1>(&onPhaseCurrentsReady)); + EXPECT_CALL(platformFactoryMock, Start()).Times(1); + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); + + InvokeCommand("stop", [this]() + { + EXPECT_CALL(platformFactoryMock, Stop()).Times(1); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, speed_after_only_resistance_inductance_returns_error) +{ + CompleteResistanceInductanceOnly(); + + InvokeCommand("speed 300 0.05", [this]() + { + ::testing::InSequence _; + + std::string newline{ "\r\n" }; + std::string header{ "ERROR: " }; + std::string payload{ "no pole pairs identified. Run 'ident' first." }; + + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, speed_without_alignment_returns_error) +{ + CompleteIdentification(2); + + InvokeCommand("speed 300 0.05", [this]() + { + ::testing::InSequence _; + + std::string newline{ "\r\n" }; + std::string header{ "ERROR: " }; + std::string payload{ "motor not aligned. Run 'align' first." }; + + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, speed_while_already_running_returns_error) +{ + CompleteIdentification(2); + CompleteAlignment(); + + InvokeCommand("speed 300 0.05", [this]() + { + EXPECT_CALL(platformFactoryMock, Stop()).Times(1); + EXPECT_CALL(platformFactoryMock, ConfigureAdcAndPwm(hal::Hertz{ 10000 }, testing::_, testing::_)).Times(1); + EXPECT_CALL(platformFactoryMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, testing::_)).WillOnce(testing::SaveArg<1>(&onPhaseCurrentsReady)); + EXPECT_CALL(platformFactoryMock, Start()).Times(1); + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); + + // A second speed while active must not re-register the ISR callback or reconfigure/restart the stage; + // StrictMock leaves ConfigureAdcAndPwm/PhaseCurrentsReady/Start/Stop unexpected so any such call fails. + InvokeCommand("speed 300 0.05", [this]() + { + ::testing::InSequence _; + + std::string newline{ "\r\n" }; + std::string header{ "ERROR: " }; + std::string payload{ "motor spinning. Run 'stop' first." }; + + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, spinning_blocks_ident_without_touching_hardware) +{ + CompleteIdentification(2); + CompleteAlignment(); + + InvokeCommand("speed 300 0.05", [this]() + { + EXPECT_CALL(platformFactoryMock, Stop()).Times(1); + EXPECT_CALL(platformFactoryMock, ConfigureAdcAndPwm(hal::Hertz{ 10000 }, testing::_, testing::_)).Times(1); + EXPECT_CALL(platformFactoryMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, testing::_)).WillOnce(testing::SaveArg<1>(&onPhaseCurrentsReady)); + EXPECT_CALL(platformFactoryMock, Start()).Times(1); + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); + + // ident must be blocked while spinning; StrictMock leaves Stop/ThreePhasePwmOutput unexpected so any hardware touch fails. + InvokeCommand("ident wye", [this]() + { + ::testing::InSequence _; + + std::string newline{ "\r\n" }; + std::string header{ "ERROR: " }; + std::string payload{ "motor spinning. Run 'stop' first." }; + + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, spinning_blocks_pwm_without_touching_hardware) +{ + CompleteIdentification(2); + CompleteAlignment(); + + InvokeCommand("speed 300 0.05", [this]() + { + EXPECT_CALL(platformFactoryMock, Stop()).Times(1); + EXPECT_CALL(platformFactoryMock, ConfigureAdcAndPwm(hal::Hertz{ 10000 }, testing::_, testing::_)).Times(1); + EXPECT_CALL(platformFactoryMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, testing::_)).WillOnce(testing::SaveArg<1>(&onPhaseCurrentsReady)); + EXPECT_CALL(platformFactoryMock, Start()).Times(1); + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); + + // pwm must be blocked while spinning; a StrictMock ConfigureAdcAndPwm/PhaseCurrentsReady would fail if reached. + InvokeCommand("pwm 500 10000", [this]() + { + ::testing::InSequence _; + + std::string newline{ "\r\n" }; + std::string header{ "ERROR: " }; + std::string payload{ "motor spinning. Run 'stop' first." }; + + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(newline.begin(), newline.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(header.begin(), header.end())), testing::_)); + EXPECT_CALL(streamWriterMock, Insert(infra::CheckByteRangeContents(std::vector(payload.begin(), payload.end())), testing::_)); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, spinning_allows_speedstat) +{ + CompleteIdentification(2); + CompleteAlignment(); + + InvokeCommand("speed 300 0.05", [this]() + { + EXPECT_CALL(platformFactoryMock, Stop()).Times(1); + EXPECT_CALL(platformFactoryMock, ConfigureAdcAndPwm(hal::Hertz{ 10000 }, testing::_, testing::_)).Times(1); + EXPECT_CALL(platformFactoryMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, testing::_)).WillOnce(testing::SaveArg<1>(&onPhaseCurrentsReady)); + EXPECT_CALL(platformFactoryMock, Start()).Times(1); + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); + + InvokeCommand("speedstat", [this]() + { + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, spinning_allows_stop) +{ + CompleteIdentification(2); + CompleteAlignment(); + + InvokeCommand("speed 300 0.05", [this]() + { + EXPECT_CALL(platformFactoryMock, Stop()).Times(1); + EXPECT_CALL(platformFactoryMock, ConfigureAdcAndPwm(hal::Hertz{ 10000 }, testing::_, testing::_)).Times(1); + EXPECT_CALL(platformFactoryMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, testing::_)).WillOnce(testing::SaveArg<1>(&onPhaseCurrentsReady)); + EXPECT_CALL(platformFactoryMock, Start()).Times(1); + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); + + InvokeCommand("stop", [this]() + { + EXPECT_CALL(platformFactoryMock, Stop()).Times(1); + }); + + ExecuteAllActions(); + + // After stop clears speedActive_, a previously blocked command runs again (pwm reconfigures the stage). + InvokeCommand("pwm 500 10000", [this]() + { + EXPECT_CALL(platformFactoryMock, ConfigureAdcAndPwm(hal::Hertz{ 10000 }, std::chrono::nanoseconds{ 500 }, testing::_)).Times(1); + EXPECT_CALL(platformFactoryMock, PhaseCurrentsReady(testing::_, testing::_)).WillRepeatedly(testing::SaveArg<1>(&onPhaseCurrentsReady)); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, spinning_allows_enc) +{ + CompleteIdentification(2); + CompleteAlignment(); + + InvokeCommand("speed 300 0.05", [this]() + { + EXPECT_CALL(platformFactoryMock, Stop()).Times(1); + EXPECT_CALL(platformFactoryMock, ConfigureAdcAndPwm(hal::Hertz{ 10000 }, testing::_, testing::_)).Times(1); + EXPECT_CALL(platformFactoryMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, testing::_)).WillOnce(testing::SaveArg<1>(&onPhaseCurrentsReady)); + EXPECT_CALL(platformFactoryMock, Start()).Times(1); + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); + + EXPECT_CALL(platformFactoryMock, Read()).WillOnce(testing::Return(foc::Radians{ 1.57f })); + + InvokeCommand("enc", [this]() + { + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, spinning_allows_reset_cause) +{ + CompleteIdentification(2); + CompleteAlignment(); + + InvokeCommand("speed 300 0.05", [this]() + { + EXPECT_CALL(platformFactoryMock, Stop()).Times(1); + EXPECT_CALL(platformFactoryMock, ConfigureAdcAndPwm(hal::Hertz{ 10000 }, testing::_, testing::_)).Times(1); + EXPECT_CALL(platformFactoryMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, testing::_)).WillOnce(testing::SaveArg<1>(&onPhaseCurrentsReady)); + EXPECT_CALL(platformFactoryMock, Start()).Times(1); + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); + + EXPECT_CALL(platformFactoryMock, GetResetCause()).WillOnce(testing::Return(application::ResetCause::powerUp)); + + InvokeCommand("reset_cause", [this]() + { + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, spinning_allows_fault_status) +{ + CompleteIdentification(2); + CompleteAlignment(); + + InvokeCommand("speed 300 0.05", [this]() + { + EXPECT_CALL(platformFactoryMock, Stop()).Times(1); + EXPECT_CALL(platformFactoryMock, ConfigureAdcAndPwm(hal::Hertz{ 10000 }, testing::_, testing::_)).Times(1); + EXPECT_CALL(platformFactoryMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, testing::_)).WillOnce(testing::SaveArg<1>(&onPhaseCurrentsReady)); + EXPECT_CALL(platformFactoryMock, Start()).Times(1); + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); + + EXPECT_CALL(platformFactoryMock, FaultStatus()).WillOnce(testing::Return(infra::BoundedConstString{})); + + InvokeCommand("fault_status", [this]() + { + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, speedstat_traces_estimates) +{ + InvokeCommand("speedstat", [this]() + { + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, speedstat_alias_traces_estimates) +{ + InvokeCommand("ss", [this]() + { + EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + }); + + ExecuteAllActions(); +} diff --git a/targets/hardware_test/instantiations/Logic.hpp b/targets/hardware_test/instantiations/Logic.hpp index df3c5aab..a3dbcd75 100644 --- a/targets/hardware_test/instantiations/Logic.hpp +++ b/targets/hardware_test/instantiations/Logic.hpp @@ -13,7 +13,7 @@ namespace application explicit Logic(application::PlatformFactory& hardware); private: - services::TerminalWithBanner::WithMaxSize<22> terminalWithStorage; + services::TerminalWithBanner::WithMaxSize<24> terminalWithStorage; application::TerminalInteractor terminal; services::DebugLed debugLed; }; diff --git a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp index f85f490b..eae113e5 100644 --- a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp +++ b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.cpp @@ -61,6 +61,9 @@ namespace application } application::Clocks::Initialize(); + + NVIC_SetPriority(PendSV_IRQn, static_cast(hal::InterruptPriority::Lowest)); + peripherals.emplace(); services::SetGlobalTracerInstance(peripherals->terminalAndTracer.tracer); this->onInitialized(); @@ -179,6 +182,7 @@ namespace application auto& adcCfg = impl.adcConfig; adcCfg.sampleAndHold = impl.toSampleAndHold.at(static_cast(sampleAndHold)); adcCfg.digitalComparators = infra::MakeRange(impl.digitalComparators); + adcCfg.interruptPriority = hal::InterruptPriority::Highest; peripherals->phaseCurrentAdc.reset(); peripherals->phaseCurrentAdc.emplace( @@ -230,6 +234,7 @@ namespace application hal::tiva::Can::Config canConfig; canConfig.timing = hal::tiva::Can::BitRate{ bitRate }; canConfig.testMode = testMode; + canConfig.interruptPriority = hal::InterruptPriority::Low; peripherals->canBus.reset(); peripherals->canBus.emplace( diff --git a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp index 72fd05d5..227ba375 100644 --- a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp +++ b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp @@ -95,7 +95,7 @@ namespace application struct TerminalAndTracer { hal::tiva::Dma dma{ infra::emptyFunction }; - hal::tiva::UartWithDma::Config uartConfig{ true, true, hal::tiva::UartWithDma::Baudrate::_921000_bps, hal::tiva::UartWithDma::FlowControl::none, hal::tiva::UartWithDma::Parity::none, hal::tiva::UartWithDma::StopBits::one, hal::tiva::UartWithDma::NumberOfBytes::_8_bytes, std::nullopt }; + hal::tiva::UartWithDma::Config uartConfig{ true, true, hal::tiva::UartWithDma::Baudrate::_921000_bps, hal::tiva::UartWithDma::FlowControl::none, hal::tiva::UartWithDma::Parity::none, hal::tiva::UartWithDma::StopBits::one, hal::tiva::UartWithDma::NumberOfBytes::_8_bytes, hal::InterruptPriority::Low }; hal::tiva::UartWithDma::WithRxBuffer<256> uart{ Peripheral::UartIndex, Pins::uartTx, Pins::uartRx, dma, uartConfig }; services::StreamWriterOnSerialCommunication::WithStorage<8192> streamWriterOnSerialCommunication{ uart }; infra::TextOutputStream::WithErrorPolicy tracerStream{ streamWriterOnSerialCommunication }; @@ -156,7 +156,7 @@ namespace application hal::tiva::Pwm::Config::InterruptConfig::FaultConfig{ hal::tiva::Pwm::GeneratorIndex::generator2, uint8_t{ 0x00 }, uint8_t{ static_cast(hal::tiva::Pwm::FaultInputComparator::comparator0) | static_cast(hal::tiva::Pwm::FaultInputComparator::comparator1) }, true, uint16_t{ 0 } }, hal::tiva::Pwm::Config::InterruptConfig::FaultConfig{ hal::tiva::Pwm::GeneratorIndex::generator3, uint8_t{ 0x00 }, uint8_t{ static_cast(hal::tiva::Pwm::FaultInputComparator::comparator0) | static_cast(hal::tiva::Pwm::FaultInputComparator::comparator1) }, true, uint16_t{ 0 } }, } }, - hal::InterruptPriority::Normal, + hal::InterruptPriority::High, }; hal::tiva::Pwm::Config pwmConfig{ false, false, controlConfig, clockDivisor, std::make_optional(deadTimeConfig), std::make_optional(interruptConfig) }; From a119ae75a89f7b034bc190d4ed1fc83c4b648728 Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Sat, 25 Jul 2026 11:43:00 +0000 Subject: [PATCH 27/28] fix sonarqube --- .github/workflows/static-analysis.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index d1e9d50a..c5551272 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest container: gabrielfrasantos/embedded-devcontainer-cpp:v7.2.0@sha256:e8a23c738637da0009ab5f9d73488c5e27e6309d4f577082368dacfab49d0cf5 env: - SONAR_SCANNER_VERSION: 5.0.1.3006 + SONAR_SCANNER_VERSION: 6.2.1.4610 SONAR_SERVER_URL: "https://sonarcloud.io" SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} steps: @@ -36,9 +36,9 @@ jobs: submodules: true - name: Install Sonar Scanner run: | - wget -qN "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${{ env.SONAR_SCANNER_VERSION }}-linux.zip" - unzip -qqo "sonar-scanner-cli-${{ env.SONAR_SCANNER_VERSION }}-linux.zip" - echo "${PWD}/sonar-scanner-${{ env.SONAR_SCANNER_VERSION }}-linux/bin" >> "$GITHUB_PATH" + wget -qN "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${{ env.SONAR_SCANNER_VERSION }}-linux-x64.zip" + unzip -qqo "sonar-scanner-cli-${{ env.SONAR_SCANNER_VERSION }}-linux-x64.zip" + echo "${PWD}/sonar-scanner-${{ env.SONAR_SCANNER_VERSION }}-linux-x64/bin" >> "$GITHUB_PATH" - uses: hendrikmuhs/ccache-action@d62db5f07c26379fc4b4e0916f098a92573c3b03 # v1.2.23 with: key: ${{ github.job }} From e1fe553634953a0c45efd1d426ab8b2e461dfec7 Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Sat, 25 Jul 2026 13:54:38 +0000 Subject: [PATCH 28/28] fix sonar findings --- ...ElectricalParametersIdentificationImpl.cpp | 92 ++++++------- ...ElectricalParametersIdentificationImpl.hpp | 41 +++--- targets/hardware_test/components/Terminal.cpp | 127 +++++++++--------- targets/hardware_test/components/Terminal.hpp | 59 +++++--- .../server/test/test_list_can_interfaces.py | 2 +- 5 files changed, 179 insertions(+), 142 deletions(-) diff --git a/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.cpp b/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.cpp index ce6e2d3d..9663ebb1 100644 --- a/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.cpp +++ b/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.cpp @@ -57,25 +57,25 @@ namespace services const auto samplesPerPeriod = samplingFrequencyHz / injectionHz; - injectionModIndex = std::min(static_cast(rlConfig.injectionVoltagePercent.Value()) / 100.0f, maxSafeModIndex); - angularFrequency = twoPi * static_cast(injectionHz); - phaseIncrement = angularFrequency / static_cast(samplingFrequencyHz); - warmupSamples = rlConfig.warmupPeriods * samplesPerPeriod; - measurementSamples = rlConfig.measurementPeriods * samplesPerPeriod; + rl.injectionModIndex = std::min(static_cast(rlConfig.injectionVoltagePercent.Value()) / 100.0f, maxSafeModIndex); + rl.angularFrequency = twoPi * static_cast(injectionHz); + rl.phaseIncrement = rl.angularFrequency / static_cast(samplingFrequencyHz); + rl.warmupSamples = rlConfig.warmupPeriods * samplesPerPeriod; + rl.measurementSamples = rlConfig.measurementPeriods * samplesPerPeriod; const auto maxCurrent = driver.MaxCurrentSupported().Value(); - maxCurrentSquared = maxCurrent * maxCurrent; + rl.maxCurrentSquared = maxCurrent * maxCurrent; - phase = 0.0f; + rl.phase = 0.0f; // Demod reference lags the applied phase by the PWM->ADC pipeline delay, cancelling the // ~2*pi*f_inj/f_s phase error that would otherwise bias R. - demodPhase = std::fmod(-static_cast(rlConfig.voltageToCurrentDelaySamples) * phaseIncrement, twoPi); - if (demodPhase < 0.0f) - demodPhase += twoPi; - sampleIndex = 0; - sumSin = 0.0f; - sumCos = 0.0f; - sumSq = 0.0f; + rl.demodPhase = std::fmod(-static_cast(rlConfig.voltageToCurrentDelaySamples) * rl.phaseIncrement, twoPi); + if (rl.demodPhase < 0.0f) + rl.demodPhase += twoPi; + rl.sampleIndex = 0; + rl.sumSin = 0.0f; + rl.sumCos = 0.0f; + rl.sumSq = 0.0f; driver.Stop(); driver.PhaseCurrentsReady(hal::Hertz{ static_cast(samplingFrequencyHz) }, [this](auto currentPhases) @@ -89,12 +89,12 @@ namespace services OPTIMIZE_FOR_SPEED void ElectricalParametersIdentificationImpl::ApplyInjectionVoltage() { - driver.ThreePhasePwmOutput(NormalizedDutyCycles(clarke.Inverse(foc::TwoPhase{ injectionModIndex * foc::FastTrigonometry::Sine(phase), 0.0f }))); + driver.ThreePhasePwmOutput(NormalizedDutyCycles(clarke.Inverse(foc::TwoPhase{ rl.injectionModIndex * foc::FastTrigonometry::Sine(rl.phase), 0.0f }))); } OPTIMIZE_FOR_SPEED void ElectricalParametersIdentificationImpl::OnHfSample(const foc::PhaseCurrents& currentPhases) { - if (sampleIndex >= warmupSamples + measurementSamples) + if (rl.sampleIndex >= rl.warmupSamples + rl.measurementSamples) return; const float a = currentPhases.a.Value(); @@ -102,7 +102,7 @@ namespace services const float c = currentPhases.c.Value(); const float peakSquared = std::max({ a * a, b * b, c * c }); - if (peakSquared > maxCurrentSquared) + if (peakSquared > rl.maxCurrentSquared) { AbortResistanceAndInductance(); return; @@ -110,24 +110,24 @@ namespace services ApplyInjectionVoltage(); - if (sampleIndex >= warmupSamples) + if (rl.sampleIndex >= rl.warmupSamples) { const float iAlpha = clarke.Forward(foc::ThreePhase{ a, b, c }).alpha; - sumSin += iAlpha * foc::FastTrigonometry::Sine(demodPhase); - sumCos += iAlpha * foc::FastTrigonometry::Cosine(demodPhase); - sumSq += iAlpha * iAlpha; + rl.sumSin += iAlpha * foc::FastTrigonometry::Sine(rl.demodPhase); + rl.sumCos += iAlpha * foc::FastTrigonometry::Cosine(rl.demodPhase); + rl.sumSq += iAlpha * iAlpha; } - phase += phaseIncrement; - if (phase >= twoPi) - phase -= twoPi; + rl.phase += rl.phaseIncrement; + if (rl.phase >= twoPi) + rl.phase -= twoPi; - demodPhase += phaseIncrement; - if (demodPhase >= twoPi) - demodPhase -= twoPi; + rl.demodPhase += rl.phaseIncrement; + if (rl.demodPhase >= twoPi) + rl.demodPhase -= twoPi; - ++sampleIndex; - if (sampleIndex >= warmupSamples + measurementSamples) + ++rl.sampleIndex; + if (rl.sampleIndex >= rl.warmupSamples + rl.measurementSamples) { driver.Stop(); ComputeAndReport(); @@ -136,7 +136,7 @@ namespace services void ElectricalParametersIdentificationImpl::AbortResistanceAndInductance() { - sampleIndex = warmupSamples + measurementSamples; + rl.sampleIndex = rl.warmupSamples + rl.measurementSamples; driver.Stop(); if (onResistanceAndInductanceDone) onResistanceAndInductanceDone(std::nullopt); @@ -147,9 +147,9 @@ namespace services if (!onResistanceAndInductanceDone) return; - const auto n = static_cast(measurementSamples); - const float iRe = 2.0f * sumSin / n; - const float iIm = 2.0f * sumCos / n; + const auto n = static_cast(rl.measurementSamples); + const float iRe = 2.0f * rl.sumSin / n; + const float iIm = 2.0f * rl.sumCos / n; const float magnitudeSquared = iRe * iRe + iIm * iIm; if (magnitudeSquared < minDemodulatedCurrent * minDemodulatedCurrent) @@ -158,14 +158,14 @@ namespace services return; } - const float amplitude = injectionModIndex * voltsPerModulation * vdc.Value(); + const float amplitude = rl.injectionModIndex * voltsPerModulation * vdc.Value(); float resistance = amplitude * iRe / magnitudeSquared; - float inductance = -amplitude * iIm / (angularFrequency * magnitudeSquared); + float inductance = -amplitude * iIm / (rl.angularFrequency * magnitudeSquared); // THD-like residual (0 = perfect sinusoid), diagnostic only: demod already rejects the // low-frequency back-EMF, so a raised residual flags a disturbance without invalidating R/Ls. const float fundamentalEnergy = n * magnitudeSquared / 2.0f; - const float fitQuality = std::abs(sumSq - fundamentalEnergy) / fundamentalEnergy; + const float fitQuality = std::abs(rl.sumSq - fundamentalEnergy) / fundamentalEnergy; const float correction = (rlConfig.windingConfig == WindingConfiguration::Delta) ? deltaCoefficient : 1.0f; resistance *= correction; @@ -188,10 +188,10 @@ namespace services { polePairsConfig = config; onPolePairsDone = onDone; - currentSampleIndex = 0; - accumulatedRotation = 0.0f; + pp.currentSampleIndex = 0; + pp.accumulatedRotation = 0.0f; - previousPosition = encoder.Read(); + pp.previousPosition = encoder.Read(); driver.Stop(); driver.PhaseCurrentsReady(hal::Hertz{ static_cast(samplingFrequencyHz) }, [](auto) {}); @@ -202,7 +202,7 @@ namespace services { const std::size_t totalSteps = polePairsConfig.electricalRevolutions * stepsPerRevolution; - if (currentSampleIndex < totalSteps) + if (pp.currentSampleIndex < totalSteps) RunPolePairLogic(); else CalculatePolePairs(); @@ -210,7 +210,7 @@ namespace services void ElectricalParametersIdentificationImpl::RunPolePairLogic() { - auto electricalAngle = static_cast(currentSampleIndex) * anglePerStep; + auto electricalAngle = static_cast(pp.currentSampleIndex) * anglePerStep; auto voltage = static_cast(polePairsConfig.testVoltagePercent.Value()) / 100.0f; driver.ThreePhasePwmOutput(NormalizedDutyCycles(transforms.Inverse(foc::RotatingFrame{ voltage, 0.0f }, std::cos(electricalAngle), std::sin(electricalAngle)))); @@ -218,14 +218,14 @@ namespace services settleTimer.Start(polePairsConfig.settleTimeBetweenSteps, [this]() { auto currentPosition = encoder.Read(); - auto delta = currentPosition.Value() - previousPosition.Value(); + auto delta = currentPosition.Value() - pp.previousPosition.Value(); delta = delta - twoPi * std::floor((delta + std::numbers::pi_v) / twoPi); - accumulatedRotation += delta; - previousPosition = currentPosition; + pp.accumulatedRotation += delta; + pp.previousPosition = currentPosition; - currentSampleIndex++; + pp.currentSampleIndex++; ApplyNextElectricalAngle(); }); } @@ -236,7 +236,7 @@ namespace services if (onPolePairsDone) { - auto mechanicalRotation = std::abs(accumulatedRotation); + auto mechanicalRotation = std::abs(pp.accumulatedRotation); if (mechanicalRotation > minRotationThreshold) { diff --git a/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp b/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp index df8ebbd6..e34b3a67 100644 --- a/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp +++ b/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp @@ -38,22 +38,31 @@ namespace services ResistanceAndInductanceConfig rlConfig; PolePairsConfig polePairsConfig; - float injectionModIndex{ 0.0f }; - float phase{ 0.0f }; - float demodPhase{ 0.0f }; - float phaseIncrement{ 0.0f }; - float angularFrequency{ 0.0f }; - float maxCurrentSquared{ 0.0f }; - std::size_t sampleIndex{ 0 }; - std::size_t warmupSamples{ 0 }; - std::size_t measurementSamples{ 0 }; - float sumSin{ 0.0f }; - float sumCos{ 0.0f }; - float sumSq{ 0.0f }; - - std::size_t currentSampleIndex{ 0 }; - foc::Radians previousPosition{ 0.0f }; - float accumulatedRotation{ 0.0f }; + struct RlMeasurementState + { + float injectionModIndex{ 0.0f }; + float phase{ 0.0f }; + float demodPhase{ 0.0f }; + float phaseIncrement{ 0.0f }; + float angularFrequency{ 0.0f }; + float maxCurrentSquared{ 0.0f }; + std::size_t sampleIndex{ 0 }; + std::size_t warmupSamples{ 0 }; + std::size_t measurementSamples{ 0 }; + float sumSin{ 0.0f }; + float sumCos{ 0.0f }; + float sumSq{ 0.0f }; + }; + + struct PolePairMeasurementState + { + std::size_t currentSampleIndex{ 0 }; + foc::Radians previousPosition{ 0.0f }; + float accumulatedRotation{ 0.0f }; + }; + + RlMeasurementState rl; + PolePairMeasurementState pp; infra::AutoResetFunction)> onResistanceAndInductanceDone; infra::AutoResetFunction)> onPolePairsDone; diff --git a/targets/hardware_test/components/Terminal.cpp b/targets/hardware_test/components/Terminal.cpp index fca0b920..5822ca14 100644 --- a/targets/hardware_test/components/Terminal.cpp +++ b/targets/hardware_test/components/Terminal.cpp @@ -91,7 +91,6 @@ namespace application , hardware{ hardware } , performanceTimer{ hardware.PerformanceTimer() } , Vdc{ hardware.PowerSupplyVoltage() } - , systemClock{ hardware.SystemClock() } , foc{ hardware.MaxCurrentSupported(), baseFrequency_, hardware.LowPriorityInterrupt() } , onlineMechEstimator{ services::RealTimeFrictionAndInertiaEstimator::defaultForgettingFactor, foc.OuterLoopFrequency() } , onlineElecEstimator{ services::RealTimeResistanceAndInductanceEstimator::defaultForgettingFactor, foc.OuterLoopFrequency() } @@ -250,7 +249,7 @@ namespace application [this, index](const infra::BoundedConstString& params) { const auto& command = guardedCommands[index]; - if (speedActive_ && !command.allowedWhileSpinning) + if (runtimeState.speedActive && !command.allowedWhileSpinning) terminal.ProcessResult({ error, "motor spinning. Run 'stop' first." }); else command.handler(params); @@ -272,11 +271,11 @@ namespace application if (!frequency.has_value()) return { error, "invalid value. It should be a float between 10000 and 20000." }; - currentPwmDeadTime_ = std::chrono::nanoseconds{ *deadTime }; - currentPwmFrequency_ = hal::Hertz{ *frequency }; - adcActive_ = false; - hardware.ConfigureAdcAndPwm(currentPwmFrequency_, currentPwmDeadTime_, currentSah_); - StartAdc(currentSah_); + pwmAdcConfig.deadTime = std::chrono::nanoseconds{ *deadTime }; + pwmAdcConfig.frequency = hal::Hertz{ *frequency }; + pwmAdcConfig.active = false; + hardware.ConfigureAdcAndPwm(pwmAdcConfig.frequency, pwmAdcConfig.deadTime, pwmAdcConfig.sah); + StartAdc(pwmAdcConfig.sah); return { success }; } @@ -292,8 +291,8 @@ namespace application if (!sampleAndHold) return { error, "invalid value. It should be one of: shortest, shorter, medium, longer, longest." }; - adcActive_ = false; - hardware.ConfigureAdcAndPwm(currentPwmFrequency_, currentPwmDeadTime_, ToSampleAndHold(*sampleAndHold)); + pwmAdcConfig.active = false; + hardware.ConfigureAdcAndPwm(pwmAdcConfig.frequency, pwmAdcConfig.deadTime, ToSampleAndHold(*sampleAndHold)); StartAdc(ToSampleAndHold(*sampleAndHold)); return { success }; @@ -330,11 +329,11 @@ namespace application if (!qKd.has_value()) return { error, "invalid value for DQ-axis Kd" }; - speedPidTunings = controllers::PidTunings{ *dKp, *dKi, *dKd }; - dqPidTunings = controllers::PidTunings{ *qKp, *qKi, *qKd }; + pidTunings.speed = controllers::PidTunings{ *dKp, *dKi, *dKd }; + pidTunings.dq = controllers::PidTunings{ *qKp, *qKi, *qKd }; - foc.SetSpeedTunings(Vdc, speedPidTunings); - foc.SetCurrentTunings(Vdc, { dqPidTunings, dqPidTunings }); + foc.SetSpeedTunings(Vdc, pidTunings.speed); + foc.SetCurrentTunings(Vdc, { pidTunings.dq, pidTunings.dq }); return { success }; } @@ -374,8 +373,8 @@ namespace application if (!currentC.has_value()) return { error, "invalid value for phase C current. It should be a float between -1000 and 1000." }; - polePairs = static_cast(*pp); - foc.SetPolePairs(polePairs.value()); + runtimeState.polePairs = static_cast(*pp); + foc.SetPolePairs(runtimeState.polePairs.value()); RunFocSimulation(foc::PhaseCurrents{ foc::Ampere{ *currentA }, foc::Ampere{ *currentB }, foc::Ampere{ *currentC } }, foc::Radians{ *angle * pi_div_180 }); return { success }; @@ -389,15 +388,15 @@ namespace application tracer.Trace() << " FOC Simulation Results:"; tracer.Trace() << " Vdc: " << Vdc.Value() << " V"; - tracer.Trace() << " Pole Pairs: " << polePairs.value_or(0); + tracer.Trace() << " Pole Pairs: " << runtimeState.polePairs.value_or(0); tracer.Trace() << " Inputs:"; tracer.Trace() << " Angle: " << angle.Value() << " degrees"; tracer.Trace() << " Phase A Current: " << input.a.Value() << " mA"; tracer.Trace() << " Phase B Current: " << input.b.Value() << " mA"; tracer.Trace() << " Phase C Current: " << input.c.Value() << " mA"; tracer.Trace() << " PID Tunings:"; - tracer.Trace() << " Speed PID: [P: " << speedPidTunings.kp << ", I: " << speedPidTunings.ki << ", D: " << speedPidTunings.kd << "]"; - tracer.Trace() << " DQ-axis PID: [P: " << dqPidTunings.kp << ", I: " << dqPidTunings.ki << ", D: " << dqPidTunings.kd << "]"; + tracer.Trace() << " Speed PID: [P: " << pidTunings.speed.kp << ", I: " << pidTunings.speed.ki << ", D: " << pidTunings.speed.kd << "]"; + tracer.Trace() << " DQ-axis PID: [P: " << pidTunings.dq.kp << ", I: " << pidTunings.dq.ki << ", D: " << pidTunings.dq.kd << "]"; tracer.Trace() << " PWM Outputs:"; tracer.Trace() << " Phase A PWM: " << result.a.Value() << " %"; tracer.Trace() << " Phase B PWM: " << result.b.Value() << " %"; @@ -410,10 +409,10 @@ namespace application { hardware.Stop(); - if (speedActive_) + if (runtimeState.speedActive) { foc.Disable(); - speedActive_ = false; + runtimeState.speedActive = false; } return { success }; @@ -421,7 +420,7 @@ namespace application void TerminalInteractor::ProcessAdcSamples() { - adcActive_ = false; + pwmAdcConfig.active = false; hardware.Stop(); tracer.Trace() << " Current Phases [A;B;C] ampere"; @@ -486,14 +485,14 @@ namespace application rlConfig.injectionVoltagePercent = hal::Percent{ *injectionVoltage }; } - pendingPolePairsConfig = {}; + motorIdentState.pendingPolePairsConfig = {}; if (tokenizer.Size() >= 4) { auto ppVoltage = ParseInput(tokenizer.Token(3), 1, 100); if (!ppVoltage.has_value()) return { error, "invalid value for pole-pairs test voltage. It should be an integer between 1 and 100." }; - pendingPolePairsConfig.testVoltagePercent = hal::Percent{ *ppVoltage }; + motorIdentState.pendingPolePairsConfig.testVoltagePercent = hal::Percent{ *ppVoltage }; } if (tokenizer.Size() >= 5) @@ -501,7 +500,7 @@ namespace application auto ppRevs = ParseInput(tokenizer.Token(4), 1u, 50u); if (!ppRevs.has_value()) return { error, "invalid value for electrical revolutions. It should be an integer between 1 and 50." }; - pendingPolePairsConfig.electricalRevolutions = static_cast(*ppRevs); + motorIdentState.pendingPolePairsConfig.electricalRevolutions = static_cast(*ppRevs); } if (tokenizer.Size() >= 6) @@ -509,11 +508,11 @@ namespace application auto ppSettle = ParseInput(tokenizer.Token(5), 1u, 10000u); if (!ppSettle.has_value()) return { error, "invalid value for pole-pairs step settle time. It should be an integer between 1 and 10000 ms." }; - pendingPolePairsConfig.settleTimeBetweenSteps = std::chrono::milliseconds{ *ppSettle }; + motorIdentState.pendingPolePairsConfig.settleTimeBetweenSteps = std::chrono::milliseconds{ *ppSettle }; } - identificationResults.reset(); - motorAligned = false; + motorIdentState.results.reset(); + motorIdentState.aligned = false; electricalIdent.EstimateResistanceAndInductance(rlConfig, [this](std::optional result) { @@ -523,7 +522,7 @@ namespace application return; } - identificationResults = IdentificationResults{ *result, 0 }; + motorIdentState.results = IdentificationResults{ *result, 0 }; RunPolePairEstimation(); }); @@ -532,7 +531,7 @@ namespace application TerminalInteractor::StatusWithMessage TerminalInteractor::AlignMotor(const infra::BoundedConstString& param) { - if (!identificationResults.has_value() || identificationResults->polePairs == 0) + if (!motorIdentState.results.has_value() || motorIdentState.results->polePairs == 0) return { error, "no pole pairs identified. Run 'ident' first." }; infra::Tokenizer tokenizer(param, ' '); @@ -582,18 +581,18 @@ namespace application config.settledCount = static_cast(*count); } - motorAlignment.ForceAlignment(identificationResults->polePairs, config, [this](std::optional offset) + motorAlignment.ForceAlignment(motorIdentState.results->polePairs, config, [this](std::optional offset) { if (!offset.has_value()) { - motorAligned = false; + motorIdentState.aligned = false; tracer.Trace() << " Alignment failed: rotor did not converge."; return; } // Rotor is held at the d-axis (electrical angle 0), so zero the encoder here to lock the FOC frame. hardware.SetZero(); - motorAligned = true; + motorIdentState.aligned = true; tracer.Trace() << " Alignment complete. Offset: " << offset->Value() << " radians."; }); @@ -602,10 +601,10 @@ namespace application TerminalInteractor::StatusWithMessage TerminalInteractor::RunSpeedFoc(const infra::BoundedConstString& param) { - if (!identificationResults.has_value() || identificationResults->polePairs == 0) + if (!motorIdentState.results.has_value() || motorIdentState.results->polePairs == 0) return { error, "no pole pairs identified. Run 'ident' first." }; - if (!motorAligned) + if (!motorIdentState.aligned) return { error, "motor not aligned. Run 'align' first." }; infra::Tokenizer tokenizer(param, ' '); @@ -634,11 +633,11 @@ namespace application const foc::NewtonMeterSecondPerRadian defaultFriction{ defaultFrictionValue }; const auto controlFrequency = baseFrequency_; - foc.SetPolePairs(identificationResults->polePairs); - foc::WithAutomaticCurrentPidGains{ foc }.SetPidBasedOnResistanceAndInductance(Vdc, identificationResults->rl.resistance, identificationResults->rl.inductance, controlFrequency, currentLoopNyquistFactor); + foc.SetPolePairs(motorIdentState.results->polePairs); + foc::WithAutomaticCurrentPidGains{ foc }.SetPidBasedOnResistanceAndInductance(Vdc, motorIdentState.results->rl.resistance, motorIdentState.results->rl.inductance, controlFrequency, currentLoopNyquistFactor); foc::WithAutomaticSpeedPidGains{ foc }.SetPidBasedOnInertiaAndFriction(Vdc, defaultInertia, defaultFriction, bandwidth); - onlineElecEstimator.SetInitialEstimate(identificationResults->rl.resistance, identificationResults->rl.inductance); + onlineElecEstimator.SetInitialEstimate(motorIdentState.results->rl.resistance, motorIdentState.results->rl.inductance); onlineMechEstimator.SetTorqueConstant(foc::NewtonMeter{ *kt }); onlineMechEstimator.SetInitialEstimate(defaultInertia, defaultFriction); foc.SetOnlineMechanicalEstimator(onlineMechEstimator); @@ -647,8 +646,8 @@ namespace application foc.SetPoint(foc::RadiansPerSecond{ static_cast(*rpm) * (2.0f * std::numbers::pi_v) / 60.0f }); hardware.Stop(); - adcActive_ = false; - hardware.ConfigureAdcAndPwm(controlFrequency, currentPwmDeadTime_, currentSah_); + pwmAdcConfig.active = false; + hardware.ConfigureAdcAndPwm(controlFrequency, pwmAdcConfig.deadTime, pwmAdcConfig.sah); hardware.PhaseCurrentsReady(controlFrequency, [this](foc::PhaseCurrents currentPhases) { auto position = hardware.Read(); @@ -656,7 +655,7 @@ namespace application }); foc.Enable(); hardware.Start(); - speedActive_ = true; + runtimeState.speedActive = true; tracer.Trace() << " Speed FOC running at " << *rpm << " RPM"; @@ -676,16 +675,16 @@ namespace application void TerminalInteractor::RunPolePairEstimation() { - electricalIdent.EstimateNumberOfPolePairs(pendingPolePairsConfig, [this](std::optional pp) + electricalIdent.EstimateNumberOfPolePairs(motorIdentState.pendingPolePairsConfig, [this](std::optional pp) { if (!pp.has_value()) { tracer.Trace() << " Identification failed: could not estimate pole pairs."; - identificationResults.reset(); + motorIdentState.results.reset(); return; } - identificationResults->polePairs = *pp; + motorIdentState.results->polePairs = *pp; ReportIdentificationResults(); }); } @@ -693,20 +692,20 @@ namespace application void TerminalInteractor::ReportIdentificationResults() { tracer.Trace() << " Identification Results:"; - tracer.Trace() << " Resistance: " << identificationResults->rl.resistance.Value() << " Ohm"; - tracer.Trace() << " Inductance: " << identificationResults->rl.inductance.Value() << " mH"; - tracer.Trace() << " Inverter V offset: " << identificationResults->rl.inverterVoltageOffset.Value() << " V"; - tracer.Trace() << " Fit quality: " << identificationResults->rl.fitQuality; - tracer.Trace() << " Pole Pairs: " << identificationResults->polePairs; + tracer.Trace() << " Resistance: " << motorIdentState.results->rl.resistance.Value() << " Ohm"; + tracer.Trace() << " Inductance: " << motorIdentState.results->rl.inductance.Value() << " mH"; + tracer.Trace() << " Inverter V offset: " << motorIdentState.results->rl.inverterVoltageOffset.Value() << " V"; + tracer.Trace() << " Fit quality: " << motorIdentState.results->rl.fitQuality; + tracer.Trace() << " Pole Pairs: " << motorIdentState.results->polePairs; } void TerminalInteractor::StartAdc(PlatformFactory::SampleAndHold sampleAndHold) { - currentSah_ = sampleAndHold; - adcActive_ = true; - hardware.PhaseCurrentsReady(currentPwmFrequency_, [this](foc::PhaseCurrents phases) + pwmAdcConfig.sah = sampleAndHold; + pwmAdcConfig.active = true; + hardware.PhaseCurrentsReady(pwmAdcConfig.frequency, [this](foc::PhaseCurrents phases) { - if (!adcActive_) + if (!pwmAdcConfig.active) return; if (!queueOfPhaseCurrents.full()) queueOfPhaseCurrents.emplace_back(phases); @@ -736,7 +735,7 @@ namespace application } hardware.ConfigureCanBus(*bitRate, testMode); - canStarted = true; + runtimeState.canStarted = true; hardware.CanBus().SetOnError([this](CanBusAdapter::CanError error) { @@ -750,14 +749,14 @@ namespace application TerminalInteractor::StatusWithMessage TerminalInteractor::CanStop() { - canStarted = false; + runtimeState.canStarted = false; tracer.Trace() << " CAN stopped"; return { success }; } TerminalInteractor::StatusWithMessage TerminalInteractor::CanSend(const infra::BoundedConstString& param) { - if (!canStarted) + if (!runtimeState.canStarted) return { error, "CAN not started. Run 'can_start' first." }; infra::Tokenizer tokenizer(param, ' '); @@ -793,7 +792,7 @@ namespace application TerminalInteractor::StatusWithMessage TerminalInteractor::CanListen() { - if (!canStarted) + if (!runtimeState.canStarted) return { error, "CAN not started. Run 'can_start' first." }; hardware.CanBus().ReceiveData([this](hal::Can::Id id, const hal::Can::Message& data) @@ -826,7 +825,7 @@ namespace application } const std::size_t byteCount = tokenizer.Size() - 1; - if (byteCount > eepromBuffer.size()) + if (byteCount > eepromData.buffer.size()) { terminal.ProcessResult({ error, "too many bytes" }); return; @@ -840,7 +839,7 @@ namespace application terminal.ProcessResult({ error, "invalid byte value" }); return; } - eepromBuffer[i] = static_cast(*byte); + eepromData.buffer[i] = static_cast(*byte); } const std::size_t eepromSize = eeprom.Size(); @@ -850,7 +849,7 @@ namespace application return; } - eeprom.WriteBuffer(infra::ConstByteRange{ eepromBuffer.data(), eepromBuffer.data() + byteCount }, *addr, [this]() + eeprom.WriteBuffer(infra::ConstByteRange{ eepromData.buffer.data(), eepromData.buffer.data() + byteCount }, *addr, [this]() { tracer.Trace() << " Written to EEPROM"; this->terminal.ProcessResult({ success }); @@ -875,7 +874,7 @@ namespace application return; } - auto size = ParseInput(tokenizer.Token(1), 1u, static_cast(eepromBuffer.size())); + auto size = ParseInput(tokenizer.Token(1), 1u, static_cast(eepromData.buffer.size())); if (!size.has_value()) { terminal.ProcessResult({ error, "invalid size" }); @@ -889,11 +888,11 @@ namespace application return; } - eepromCurrentReadSize = *size; - eeprom.ReadBuffer(infra::ByteRange{ eepromBuffer.data(), eepromBuffer.data() + eepromCurrentReadSize }, *addr, [this]() + eepromData.currentReadSize = *size; + eeprom.ReadBuffer(infra::ByteRange{ eepromData.buffer.data(), eepromData.buffer.data() + eepromData.currentReadSize }, *addr, [this]() { - for (uint32_t i = 0; i < this->eepromCurrentReadSize; ++i) - this->tracer.Trace() << " [" << i << "] = " << static_cast(this->eepromBuffer[i]); + for (uint32_t i = 0; i < this->eepromData.currentReadSize; ++i) + this->tracer.Trace() << " [" << i << "] = " << static_cast(this->eepromData.buffer[i]); this->terminal.ProcessResult({ success }); }); } diff --git a/targets/hardware_test/components/Terminal.hpp b/targets/hardware_test/components/Terminal.hpp index 9bb56c28..67dfa863 100644 --- a/targets/hardware_test/components/Terminal.hpp +++ b/targets/hardware_test/components/Terminal.hpp @@ -29,6 +29,11 @@ namespace application struct GuardedCommand { + GuardedCommand(CommandHandler h, bool allowed) + : handler(std::move(h)), allowedWhileSpinning(allowed) {} + GuardedCommand(GuardedCommand&&) noexcept = default; + GuardedCommand& operator=(GuardedCommand&&) noexcept = default; + CommandHandler handler; bool allowedWhileSpinning{ false }; }; @@ -84,6 +89,40 @@ namespace application bool IsAdcBufferPopulated() const; void RunFocSimulation(foc::PhaseCurrents input, foc::Radians angle); + struct PwmAdcConfig + { + hal::Hertz frequency{ 10000 }; + std::chrono::nanoseconds deadTime{ 500 }; + PlatformFactory::SampleAndHold sah{ PlatformFactory::SampleAndHold::shortest }; + bool active{ false }; + }; + + struct EepromData + { + std::array buffer{}; + uint32_t currentReadSize{ 0 }; + }; + + struct MotorPidTunings + { + controllers::PidTunings speed; + controllers::PidTunings dq; + }; + + struct RuntimeState + { + std::optional polePairs{ 0 }; + bool speedActive{ false }; + bool canStarted{ false }; + }; + + struct MotorIdentState + { + std::optional results; + bool aligned{ false }; + services::ElectricalParametersIdentification::PolePairsConfig pendingPolePairsConfig; + }; + private: const infra::BoundedVector::WithMaxSize<5> acceptedAdcValues{ { "shortest", "shorter", "medium", "longer", "longest" } }; infra::BoundedVector::WithMaxSize<24> guardedCommands; @@ -91,31 +130,21 @@ namespace application services::TerminalWithStorage& terminal; services::Tracer& tracer; application::PlatformFactory& hardware; - hal::Hertz currentPwmFrequency_{ 10000 }; - std::chrono::nanoseconds currentPwmDeadTime_{ 500 }; - PlatformFactory::SampleAndHold currentSah_{ PlatformFactory::SampleAndHold::shortest }; - bool adcActive_{ false }; - bool canStarted = false; + PwmAdcConfig pwmAdcConfig; QueueOfPhaseCurrents queueOfPhaseCurrents; hal::PerformanceTracker& performanceTimer; foc::Volts Vdc; - hal::Hertz systemClock; - controllers::PidTunings speedPidTunings; - controllers::PidTunings dqPidTunings; - std::optional polePairs = 0; + MotorPidTunings pidTunings; + RuntimeState runtimeState; // foc's current-PID dt is fixed at this rate, so the live loop always reconfigures back to it. hal::Hertz baseFrequency_{ hardware.BaseFrequency() }; foc::FocSpeedImpl foc; services::RealTimeFrictionAndInertiaEstimator onlineMechEstimator; services::RealTimeResistanceAndInductanceEstimator onlineElecEstimator; - bool speedActive_{ false }; hal::Eeprom& eeprom; - std::array eepromBuffer{}; - uint32_t eepromCurrentReadSize{ 0 }; + EepromData eepromData; services::ElectricalParametersIdentificationImpl electricalIdent; services::MotorAlignmentImpl motorAlignment; - std::optional identificationResults; - bool motorAligned{ false }; - services::ElectricalParametersIdentification::PolePairsConfig pendingPolePairsConfig; + MotorIdentState motorIdentState; }; } diff --git a/tools/hardware_bridge/server/test/test_list_can_interfaces.py b/tools/hardware_bridge/server/test/test_list_can_interfaces.py index d39cc3a8..69c037d4 100644 --- a/tools/hardware_bridge/server/test/test_list_can_interfaces.py +++ b/tools/hardware_bridge/server/test/test_list_can_interfaces.py @@ -119,7 +119,7 @@ def test_propagates_extra_backend_metadata(self): result = list_can_interfaces.detect_python_can_configs(interfaces=["pcan"]) self.assertEqual(len(result), 1) - self.assertEqual(result[0]["supports_fd"], True) + self.assertTrue(result[0]["supports_fd"]) def test_returns_empty_list_when_no_configs_found(self): self._can.detect_available_configs.return_value = []