diff --git a/.claude/agents/executor.md b/.claude/agents/executor.md index 57199e96..76672714 100644 --- a/.claude/agents/executor.md +++ b/.claude/agents/executor.md @@ -1,13 +1,8 @@ --- 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 -tools: - - Read - - Edit - - Write - - Bash - - TodoWrite +model: opus +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 d5c55868..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: claude-sonnet-4-6 -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 7595ee19..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. -model: claude-opus-4-8 -tools: - - Read - - Bash - - WebSearch - - WebFetch +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, 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 9f0d6923..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: claude-sonnet-4-6 -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/.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/.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 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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08d74b44..83183604 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 bd13dbe7..9ef0340c 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/.gitignore b/.gitignore index e3c713a8..3152229b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ install/ .venv/ output/ cucumber.toml +.claude/plans/ .megalinter_github_conf/ 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/.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", diff --git a/CLAUDE.md b/CLAUDE.md index b3cd4cc3..666b43a0 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) @@ -82,10 +82,11 @@ 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 -- **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 +177,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. diff --git a/CMakePresets.json b/CMakePresets.json index a7971d20..6da3ba7a 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -129,20 +129,15 @@ "TARGET_MCU_FAMILY": "TM4C129", "TARGET_MCU": "tm4c1294ncpdt", "E_FOC_TARGET_BOARD": "EK-TM4C1294XL", - "E_FOC_MOTOR_BOARD": "FRDM-MC-LVPMSM" + "E_FOC_MOTOR_BOARD": "E-FOC-HARDWARE" } }, { - "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", + "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": { - "TARGET_CORTEX": "m4", - "TARGET_MCU_FAMILY": "TM4C123", - "TARGET_MCU": "tm4c123gh6pm", - "E_FOC_TARGET_BOARD": "EK-TM4C123GXL", "E_FOC_MOTOR_BOARD": "FRDM-MC-LVPMSM" } }, @@ -245,14 +240,14 @@ "configurePreset": "EK-TM4C1294XL" }, { - "name": "EK-TM4C123GXL-RelWithDebInfo", + "name": "EK-TM4C1294XL-FRDM-RelWithDebInfo", "configuration": "RelWithDebInfo", - "configurePreset": "EK-TM4C123GXL" + "configurePreset": "EK-TM4C1294XL-FRDM" }, { - "name": "EK-TM4C123GXL-Debug", + "name": "EK-TM4C1294XL-FRDM-Debug", "configuration": "Debug", - "configurePreset": "EK-TM4C123GXL" + "configurePreset": "EK-TM4C1294XL-FRDM" }, { "name": "STM32F407G-DISC1-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/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/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..36f5ae5f 100644 --- a/core/services/electrical_system_ident/ElectricalParametersIdentification.hpp +++ b/core/services/electrical_system_ident/ElectricalParametersIdentification.hpp @@ -1,9 +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 @@ -20,11 +20,22 @@ namespace services public: struct ResistanceAndInductanceConfig { - hal::Percent testVoltagePercent{ 15 }; - infra::Duration settleTime{ std::chrono::seconds{ 2 } }; + 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 }; }; + struct ResistanceInductanceResult + { + foc::Ohm resistance; + foc::MilliHenry inductance; + foc::Volts inverterVoltageOffset; + float fitQuality; + }; + struct PolePairsConfig { hal::Percent testVoltagePercent{ 10 }; @@ -32,7 +43,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..9663ebb1 100644 --- a/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.cpp +++ b/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.cpp @@ -1,9 +1,15 @@ #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/CompilerOptimizations.hpp" +#include #include #include -#include + +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC optimize("O3", "fast-math") +#endif namespace { @@ -11,13 +17,14 @@ 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; - const hal::Hertz samplingFrequency{ 10000 }; - const auto samplingPeriod = 1.0f / static_cast(samplingFrequency.Value()); - ; - foc::PhasePwmDutyCycles - NormalizedDutyCycles(foc::ThreePhase voltages) + // 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) { auto offset = 50.0f; auto dutyA = static_cast(std::clamp(offset + voltages.a * 50.0f, 0.0f, 100.0f)); @@ -25,132 +32,169 @@ 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 AverageAndRemoveFront(infra::BoundedDeque& deque) +namespace services +{ + ElectricalParametersIdentificationImpl::ElectricalParametersIdentificationImpl(foc::ThreePhaseInverter& driver, foc::Encoder& encoder, foc::Volts vdc) + : driver(driver) + , encoder(encoder) + , vdc(vdc) { - float sum = 0.0f; - - for (const auto& samples : deque) - sum += samples; + } - float average = sum / static_cast(deque.size()); + void ElectricalParametersIdentificationImpl::EstimateResistanceAndInductance(const ResistanceAndInductanceConfig& config, const infra::Function)>& onDone) + { + rlConfig = config; + onResistanceAndInductanceDone = onDone; - deque.pop_front(); + const auto injectionHz = rlConfig.injectionFrequency.Value(); + if (injectionHz == 0 || samplingFrequencyHz % injectionHz != 0) + { + onResistanceAndInductanceDone(std::nullopt); + return; + } - return average; - } + const auto samplesPerPeriod = samplingFrequencyHz / injectionHz; - float GetSteadyStateCurrent(const infra::BoundedVector& samples) - { - auto lastQuarter = static_cast(static_cast(samples.size()) * 0.9f); + 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; - return std::accumulate(samples.begin() + lastQuarter, samples.end(), 0.0f) / static_cast(samples.size() - lastQuarter); - } + const auto maxCurrent = driver.MaxCurrentSupported().Value(); + rl.maxCurrentSquared = maxCurrent * maxCurrent; - std::optional GetTauFromCurrentSamples(const infra::BoundedVector& samples, float steadyStateCurrent, std::size_t averageFilter) - { - auto targetCurrent = timeConstantThreshold * steadyStateCurrent; + 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. + 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; - for (std::size_t i = 0; i < samples.size(); ++i) - { - if (samples[i] >= targetCurrent) + driver.Stop(); + driver.PhaseCurrentsReady(hal::Hertz{ static_cast(samplingFrequencyHz) }, [this](auto currentPhases) { - if (i >= averageFilter) - return static_cast(i - averageFilter); - else - return static_cast(i); - } - } - - return std::nullopt; + 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(); } - std::optional CalculateResistance(float voltage, float current) + OPTIMIZE_FOR_SPEED void ElectricalParametersIdentificationImpl::ApplyInjectionVoltage() { - return foc::Ohm{ voltage / current }; + driver.ThreePhasePwmOutput(NormalizedDutyCycles(clarke.Inverse(foc::TwoPhase{ rl.injectionModIndex * foc::FastTrigonometry::Sine(rl.phase), 0.0f }))); } - std::optional CalculateInductance(foc::Ohm resistance, float tau) + OPTIMIZE_FOR_SPEED void ElectricalParametersIdentificationImpl::OnHfSample(const foc::PhaseCurrents& currentPhases) { - return foc::MilliHenry{ resistance.Value() * tau * samplingPeriod * 1000.0f }; - } -} + if (rl.sampleIndex >= rl.warmupSamples + rl.measurementSamples) + return; -namespace services -{ - ElectricalParametersIdentificationImpl::ElectricalParametersIdentificationImpl(foc::ThreePhaseInverter& driver, foc::Encoder& encoder, foc::Volts vdc) - : driver(driver) - , encoder(encoder) - , vdc(vdc) - { - } + const float a = currentPhases.a.Value(); + const float b = currentPhases.b.Value(); + const float c = currentPhases.c.Value(); - void ElectricalParametersIdentificationImpl::EstimateResistanceAndInductance(const ResistanceAndInductanceConfig& config, const infra::Function, std::optional)>& onDone) - { - resistanceAndInductanceConfig = config; - onResistanceAndInductanceDone = onDone; - currentSamples.clear(); - filteredCurrentSample.clear(); + const float peakSquared = std::max({ a * a, b * b, c * c }); + if (peakSquared > rl.maxCurrentSquared) + { + AbortResistanceAndInductance(); + return; + } - driver.PhaseCurrentsReady(samplingFrequency, [](auto) {}); - driver.ThreePhasePwmOutput(foc::PhasePwmDutyCycles{ - hal::Percent{ neutralDuty }, - hal::Percent{ neutralDuty }, - hal::Percent{ neutralDuty } }); + ApplyInjectionVoltage(); - settleTimer.Start(resistanceAndInductanceConfig.settleTime, [this]() - { - driver.PhaseCurrentsReady(samplingFrequency, [this](auto currentPhases) - { - currentSamples.push_back(currentPhases.a.Value()); + if (rl.sampleIndex >= rl.warmupSamples) + { + const float iAlpha = clarke.Forward(foc::ThreePhase{ a, b, c }).alpha; + rl.sumSin += iAlpha * foc::FastTrigonometry::Sine(rl.demodPhase); + rl.sumCos += iAlpha * foc::FastTrigonometry::Cosine(rl.demodPhase); + rl.sumSq += iAlpha * iAlpha; + } - if (currentSamples.full()) - filteredCurrentSample.push_back(AverageAndRemoveFront(currentSamples)); + rl.phase += rl.phaseIncrement; + if (rl.phase >= twoPi) + rl.phase -= twoPi; - if (filteredCurrentSample.full()) - AnalyzeInductanceMeasures(); - }); + rl.demodPhase += rl.phaseIncrement; + if (rl.demodPhase >= twoPi) + rl.demodPhase -= twoPi; - driver.ThreePhasePwmOutput(foc::PhasePwmDutyCycles{ - hal::Percent{ resistanceAndInductanceConfig.testVoltagePercent.Value() }, - hal::Percent{ neutralDuty }, - hal::Percent{ neutralDuty } }); - }); + ++rl.sampleIndex; + if (rl.sampleIndex >= rl.warmupSamples + rl.measurementSamples) + { + driver.Stop(); + ComputeAndReport(); + } } - void ElectricalParametersIdentificationImpl::AnalyzeInductanceMeasures() + void ElectricalParametersIdentificationImpl::AbortResistanceAndInductance() { + rl.sampleIndex = rl.warmupSamples + rl.measurementSamples; driver.Stop(); + if (onResistanceAndInductanceDone) + onResistanceAndInductanceDone(std::nullopt); + } - auto steadyStateCurrent = GetSteadyStateCurrent(filteredCurrentSample); + void ElectricalParametersIdentificationImpl::ComputeAndReport() + { + if (!onResistanceAndInductanceDone) + return; - if (steadyStateCurrent <= 0.0f) - onResistanceAndInductanceDone(std::nullopt, std::nullopt); - else + 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) { - 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 amplitude = rl.injectionModIndex * voltsPerModulation * vdc.Value(); + float resistance = amplitude * iRe / 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(rl.sumSq - fundamentalEnergy) / fundamentalEnergy; + + const float correction = (rlConfig.windingConfig == WindingConfiguration::Delta) ? deltaCoefficient : 1.0f; + resistance *= correction; + inductance *= correction; - filteredCurrentSample.clear(); + if (resistance <= 0.0f || inductance <= 0.0f) + { + onResistanceAndInductanceDone(std::nullopt); + return; } + + onResistanceAndInductanceDone(ResistanceInductanceResult{ + foc::Ohm{ resistance }, + foc::MilliHenry{ inductance * 1000.0f }, + foc::Volts{ 0.0f }, + fitQuality }); } void ElectricalParametersIdentificationImpl::EstimateNumberOfPolePairs(const PolePairsConfig& config, const infra::Function)>& onDone) { polePairsConfig = config; onPolePairsDone = onDone; - currentSampleIndex = 0; - accumulatedRotation = 0.0f; + pp.currentSampleIndex = 0; + pp.accumulatedRotation = 0.0f; - initialPosition = encoder.Read(); - previousPosition = initialPosition; + pp.previousPosition = encoder.Read(); - driver.PhaseCurrentsReady(samplingFrequency, [](auto) {}); + driver.Stop(); + driver.PhaseCurrentsReady(hal::Hertz{ static_cast(samplingFrequencyHz) }, [](auto) {}); ApplyNextElectricalAngle(); } @@ -158,7 +202,7 @@ namespace services { const std::size_t totalSteps = polePairsConfig.electricalRevolutions * stepsPerRevolution; - if (currentSampleIndex < totalSteps) + if (pp.currentSampleIndex < totalSteps) RunPolePairLogic(); else CalculatePolePairs(); @@ -166,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)))); @@ -174,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(); }); } @@ -192,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 e9b45fb8..e34b3a67 100644 --- a/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp +++ b/core/services/electrical_system_ident/ElectricalParametersIdentificationImpl.hpp @@ -1,12 +1,10 @@ #pragma once -#include "infra/timer/Timer.hpp" -#include "infra/util/AutoResetFunction.hpp" -#include "infra/util/BoundedDeque.hpp" -#include "infra/util/BoundedVector.hpp" #include "core/foc/implementations/TransformsClarkePark.hpp" #include "core/foc/interfaces/Driver.hpp" #include "core/services/electrical_system_ident/ElectricalParametersIdentification.hpp" +#include "infra/timer/Timer.hpp" +#include "infra/util/AutoResetFunction.hpp" namespace services { @@ -16,33 +14,57 @@ 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 ApplyInjectionVoltage(); + void OnHfSample(const foc::PhaseCurrents& currentPhases); + void AbortResistanceAndInductance(); + 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 float deltaCoefficient = 1.5f; + 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 resistanceAndInductanceConfig; + ResistanceAndInductanceConfig rlConfig; PolePairsConfig polePairsConfig; - infra::BoundedDeque::WithMaxSize currentSamples; - infra::BoundedVector::WithMaxSize filteredCurrentSample; - std::size_t currentSampleIndex{ 0 }; - foc::Radians initialPosition{ 0.0f }; - foc::Radians previousPosition{ 0.0f }; - float accumulatedRotation{ 0.0f }; - infra::AutoResetFunction, std::optional)> onResistanceAndInductanceDone; + + 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; 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..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,25 +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) - { - auto tau = inductance / resistance; - - return (voltage / resistance) * (1.0f - std::exp(-time / tau)); - } + 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); } @@ -36,299 +25,322 @@ namespace , public infra::ClockFixture { public: - const std::size_t numberOfSamples = 127; + static constexpr float vdcValue = 24.0f; + 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{ 24.0f }; + foc::Volts vdc{ vdcValue }; + foc::Clarke clarke; 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 - }; - - EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, ::testing::_)); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(PhasePwmDutyCyclesEq(foc::PhasePwmDutyCycles{ - hal::Percent{ 1 }, - hal::Percent{ 1 }, - hal::Percent{ 1 } }))); + services::ElectricalParametersIdentification::ResistanceAndInductanceConfig DefaultConfig() const + { + 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; + } + + // 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) + { + 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; - identification.EstimateResistanceAndInductance(config, [](auto, auto) {}); + 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, estimate_resistance_and_inductance_applies_test_voltage_after_settle_time) +TEST_F(ElectricalParametersIdentificationTest, arms_phase_currents_before_pwm_output_after_stop) { - services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config{ - hal::Percent{ 20 }, - std::chrono::milliseconds{ 100 }, - services::WindingConfiguration::Wye - }; - - EXPECT_CALL(driverMock, PhaseCurrentsReady(::testing::_, ::testing::_)) - .Times(2) - .WillRepeatedly([this](auto, const auto& callback) + Sequence seq; + EXPECT_CALL(driverMock, MaxCurrentSupported()) + .WillRepeatedly(Return(foc::Ampere{ maxCurrent })); + EXPECT_CALL(driverMock, Stop()) + .InSequence(seq); + EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, _)) + .InSequence(seq) + .WillOnce([this](auto, const auto& cb) { - driverMock.StorePhaseCurrentsCallback(callback); + driverMock.StorePhaseCurrentsCallback(cb); }); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(PhasePwmDutyCyclesEq(foc::PhasePwmDutyCycles{ - hal::Percent{ 1 }, - 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 } }))); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)) + .Times(AnyNumber()) + .InSequence(seq); - ForwardTime(std::chrono::milliseconds{ 100 }); + identification.EstimateResistanceAndInductance(DefaultConfig(), [](auto) {}); } -TEST_F(ElectricalParametersIdentificationTest, estimate_resistance_and_inductance_collects_current_samples_and_calculates_parameters) +TEST_F(ElectricalParametersIdentificationTest, phase_currents_callback_is_inert_after_completion) { - 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) + 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(callback); + driverMock.StorePhaseCurrentsCallback(cb); }); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(::testing::_)) - .Times(2); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(AnyNumber()); + EXPECT_CALL(driverMock, Stop()).Times(AnyNumber()); - identification.EstimateResistanceAndInductance(config, [&](auto r, auto l) + identification.EstimateResistanceAndInductance(DefaultConfig(), [&](auto) { - resultResistance = r; - resultInductance = l; + ++completions; }); - ForwardTime(std::chrono::milliseconds{ 50 }); + FeedHfBurst(trueR, trueLs); - EXPECT_CALL(driverMock, Stop()); + ASSERT_EQ(completions, 1); - for (std::size_t i = 0; i < numberOfSamples; ++i) - { - 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 } }); - } + for (std::size_t i = 0; i < samplesPerPeriod * 4; ++i) + driverMock.TriggerPhaseCurrentsCallback({ foc::Ampere{ 100.0f }, foc::Ampere{ 0.0f }, foc::Ampere{ 0.0f } }); - 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); + EXPECT_EQ(completions, 1); } -TEST_F(ElectricalParametersIdentificationTest, estimate_resistance_and_inductance_returns_nullopt_for_zero_current) +TEST_F(ElectricalParametersIdentificationTest, estimates_resistance_and_inductance_accurately) { - services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config{ - hal::Percent{ 10 }, - std::chrono::milliseconds{ 50 }, - services::WindingConfiguration::Wye - }; + const float trueR = 1.5f; + const float trueLs = 0.002f; - std::optional resultResistance; - std::optional resultInductance; + std::optional result; - EXPECT_CALL(driverMock, PhaseCurrentsReady(::testing::_, ::testing::_)) - .Times(2) - .WillRepeatedly([this](auto, const auto& callback) + EXPECT_CALL(driverMock, MaxCurrentSupported()) + .WillRepeatedly(Return(foc::Ampere{ maxCurrent })); + EXPECT_CALL(driverMock, PhaseCurrentsReady(_, _)) + .Times(AnyNumber()) + .WillRepeatedly([this](auto, const auto& cb) { - driverMock.StorePhaseCurrentsCallback(callback); + driverMock.StorePhaseCurrentsCallback(cb); }); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(::testing::_)) - .Times(2); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(AnyNumber()); + EXPECT_CALL(driverMock, Stop()).Times(AnyNumber()); - identification.EstimateResistanceAndInductance(config, [&](auto r, auto l) + identification.EstimateResistanceAndInductance(DefaultConfig(), [&](auto r) { - resultResistance = r; - resultInductance = l; + result = r; }); - ForwardTime(std::chrono::milliseconds{ 50 }); - - EXPECT_CALL(driverMock, Stop()); + FeedHfBurst(trueR, trueLs); - for (std::size_t i = 0; i < numberOfSamples; ++i) - { - driverMock.TriggerPhaseCurrentsCallback(foc::PhaseCurrents{ - foc::Ampere{ 0.0f }, - foc::Ampere{ 0.0f }, - foc::Ampere{ 0.0f } }); - } - - 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->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, estimate_resistance_and_inductance_with_low_resistance_motor) +TEST_F(ElectricalParametersIdentificationTest, rejects_low_frequency_back_emf_disturbance) { - services::ElectricalParametersIdentification::ResistanceAndInductanceConfig config{ - hal::Percent{ 15 }, - std::chrono::milliseconds{ 50 }, - services::WindingConfiguration::Wye - }; + const float trueR = 1.5f; + const float trueLs = 0.002f; + const float backEmfAmplitude = 0.5f; + const float backEmfFrequency = 2.0f; + + 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()).Times(AnyNumber()); + + identification.EstimateResistanceAndInductance(DefaultConfig(), [&](auto r) + { + result = r; + }); - float testVoltage = 0.15f * 24.0f; - float resistance = 0.5f; - float inductance = 0.001f; + FeedHfBurst(trueR, trueLs, backEmfAmplitude, backEmfFrequency); - std::optional resultResistance; - std::optional resultInductance; + ASSERT_TRUE(result.has_value()); + EXPECT_NEAR(result->resistance.Value(), trueR, trueR * 0.05f); + EXPECT_NEAR(result->inductance.Value(), trueLs * 1000.0f, trueLs * 1000.0f * 0.10f); +} - EXPECT_CALL(driverMock, PhaseCurrentsReady(::testing::_, ::testing::_)) - .Times(2) - .WillRepeatedly([this](auto, const auto& callback) +TEST_F(ElectricalParametersIdentificationTest, applies_delta_winding_correction) +{ + const float terminalR = 1.0f; + const float terminalLs = 0.001f; + + auto config = DefaultConfig(); + 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(callback); + driverMock.StorePhaseCurrentsCallback(cb); }); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(::testing::_)) - .Times(2); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(AnyNumber()); + EXPECT_CALL(driverMock, Stop()).Times(AnyNumber()); - identification.EstimateResistanceAndInductance(config, [&](auto r, auto l) + identification.EstimateResistanceAndInductance(config, [&](auto r) { - resultResistance = r; - resultInductance = l; + result = r; }); - ForwardTime(std::chrono::milliseconds{ 50 }); - - EXPECT_CALL(driverMock, Stop()); + FeedHfBurst(terminalR, terminalLs); - for (std::size_t i = 0; i < numberOfSamples; ++i) - { - 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 } }); - } - - 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); + EXPECT_NEAR(result->inductance.Value(), terminalLs * 1000.0f * 1.5f, terminalLs * 1000.0f * 1.5f * 0.10f); } -TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_initializes_encoder_and_applies_voltages) +TEST_F(ElectricalParametersIdentificationTest, returns_nullopt_when_current_is_below_floor) { - services::ElectricalParametersIdentification::PolePairsConfig config{ - hal::Percent{ 20 }, - 5, - std::chrono::milliseconds{ 50 } - }; + 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(_)).Times(AnyNumber()); + EXPECT_CALL(driverMock, Stop()).Times(AnyNumber()); - EXPECT_CALL(encoderMock, Read()) - .WillOnce(::testing::Return(foc::Radians{ 0.0f })); + identification.EstimateResistanceAndInductance(DefaultConfig(), [&](auto r) + { + completed = true; + result = r; + }); - EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, ::testing::_)); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(::testing::_)); + 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 } }); - identification.EstimateNumberOfPolePairs(config, [](auto) {}); + ASSERT_TRUE(completed); + EXPECT_FALSE(result.has_value()); } -TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_calculates_correct_pole_pairs_for_4_pole_motor) +TEST_F(ElectricalParametersIdentificationTest, aborts_once_with_nullopt_when_peak_current_exceeds_max) { - 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]() + // A very low-impedance motor draws a steady current whose peak exceeds MaxCurrentSupported. + const float lowR = 0.05f; + const float lowLs = 0.00002f; + + 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) { - ++encoderStepIndex; - return foc::Radians{ MechanicalAngle(encoderStepIndex, totalSteps, expectedPolePairs) }; + driverMock.StorePhaseCurrentsCallback(cb); }); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(AnyNumber()); + EXPECT_CALL(driverMock, Stop()).Times(AnyNumber()); - EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, ::testing::_)); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(::testing::_)) - .Times(totalSteps); - EXPECT_CALL(driverMock, Stop()); - - identification.EstimateNumberOfPolePairs(config, [&](auto result) + identification.EstimateResistanceAndInductance(DefaultConfig(), [&](auto r) { - resultPolePairs = result; + ++completions; + result = r; }); - for (std::size_t i = 0; i < totalSteps; ++i) - ForwardTime(std::chrono::milliseconds{ 50 }); + FeedHfBurst(lowR, lowLs); - ASSERT_TRUE(resultPolePairs.has_value()); - EXPECT_EQ(*resultPolePairs, expectedPolePairs); + EXPECT_EQ(completions, 1); + EXPECT_FALSE(result.has_value()); } -TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_calculates_correct_pole_pairs_for_6_pole_motor) +TEST_F(ElectricalParametersIdentificationTest, returns_nullopt_when_injection_frequency_is_zero) { - services::ElectricalParametersIdentification::PolePairsConfig config{ - hal::Percent{ 20 }, - 5, - std::chrono::milliseconds{ 50 } - }; + std::optional result; + bool completed = false; - std::optional resultPolePairs; - constexpr std::size_t totalSteps = 5 * 12; - constexpr std::size_t expectedPolePairs = 3; + auto config = DefaultConfig(); + config.injectionFrequency = hal::Hertz{ 0 }; - 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) }; - }); + identification.EstimateResistanceAndInductance(config, [&](auto r) + { + completed = true; + result = r; + }); - EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, ::testing::_)); - EXPECT_CALL(driverMock, ThreePhasePwmOutput(::testing::_)) - .Times(totalSteps); - EXPECT_CALL(driverMock, Stop()); + ASSERT_TRUE(completed); + EXPECT_FALSE(result.has_value()); +} - identification.EstimateNumberOfPolePairs(config, [&](auto result) +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) { - resultPolePairs = result; + completed = true; + result = r; }); - for (std::size_t i = 0; i < totalSteps; ++i) - ForwardTime(std::chrono::milliseconds{ 50 }); - - ASSERT_TRUE(resultPolePairs.has_value()); - EXPECT_EQ(*resultPolePairs, expectedPolePairs); + ASSERT_TRUE(completed); + EXPECT_FALSE(result.has_value()); } -TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_returns_nullopt_for_insufficient_rotation) +TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_initializes_encoder_and_applies_voltages) { services::ElectricalParametersIdentification::PolePairsConfig config{ hal::Percent{ 20 }, @@ -336,53 +348,38 @@ TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_ret std::chrono::milliseconds{ 50 } }; - 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; - }); + .WillOnce(Return(foc::Radians{ 0.0f })); + EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, _)); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)); + EXPECT_CALL(driverMock, Stop()).Times(AnyNumber()); - for (std::size_t i = 0; i < totalSteps; ++i) - ForwardTime(std::chrono::milliseconds{ 50 }); - - EXPECT_FALSE(resultPolePairs.has_value()); + identification.EstimateNumberOfPolePairs(config, [](auto) {}); } -TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_with_different_electrical_revolutions) +TEST_F(ElectricalParametersIdentificationTest, estimate_number_of_pole_pairs_calculates_correct_pole_pairs_for_4_pole_motor) { services::ElectricalParametersIdentification::PolePairsConfig config{ hal::Percent{ 20 }, - 10, + 5, std::chrono::milliseconds{ 50 } }; std::optional resultPolePairs; - constexpr std::size_t totalSteps = 10 * 12; - constexpr std::size_t expectedPolePairs = 4; + constexpr std::size_t totalSteps = 5 * 12; + 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, Stop()); + EXPECT_CALL(driverMock, PhaseCurrentsReady(hal::Hertz{ 10000 }, _)); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(totalSteps); + EXPECT_CALL(driverMock, Stop()).Times(AnyNumber()); identification.EstimateNumberOfPolePairs(config, [&](auto result) { @@ -396,7 +393,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,21 +403,19 @@ 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, Stop()); + EXPECT_CALL(driverMock, PhaseCurrentsReady(_, _)); + EXPECT_CALL(driverMock, ThreePhasePwmOutput(_)).Times(totalSteps); + EXPECT_CALL(driverMock, Stop()).Times(AnyNumber()); identification.EstimateNumberOfPolePairs(config, [&](auto result) { 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/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(); -} 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/architecture/system.md b/documentation/architecture/system.md index 7618fd34..765cf2fb 100644 --- a/documentation/architecture/system.md +++ b/documentation/architecture/system.md @@ -157,9 +157,12 @@ 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/`. +- **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/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/design/service-electrical-ident.md b/documentation/design/service-electrical-ident.md index 024b924f..8ec317bb 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 608d1fab..598407bc 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: 3.0.0 component: "service-electrical-ident" -date: 2025-01-01 +date: 2026-07-19 --- | 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 | 3.0.0 | | Component | service-electrical-ident | -| Date | 2025-01-01 | +| Date | 2026-07-19 | ## Overview @@ -24,10 +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 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 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. --- @@ -36,152 +37,201 @@ steady-state, and inductance from the measured time constant. | 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_{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 | +| $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_{avg}$ | Moving average filter length | samples | -| $N_{buf}$ | Total sample buffer size | samples | +| $N$ | Number of accumulated samples (integer periods) | samples | +| $M$ | Number of measurement injection periods | — | --- ## Mathematical Foundation -### 1. d-Axis Alignment and Back-EMF Suppression +### 1. Fixed-Axis Injection and the SPMSM Non-Saliency Assumption -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 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 stator d-axis circuit model reduces to: +The service injects on the stationary $\alpha$-axis with $\beta = 0$: $$ -v_d = R_s\, i_d + L_s \frac{di_d}{dt} +V_\alpha(t) = A \sin(\omega t), \qquad V_\beta = 0, \qquad \omega = 2\pi f_{inj} $$ -This is a first-order linear system driven by a unit step of amplitude $V_{step}$. - -### 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 **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 $$ -\boxed{i_d(t) = \frac{V_{step}}{R_s}\!\left(1 - e^{-t/\tau}\right)}, \qquad \tau = \frac{L_s}{R_s} +V_\alpha = R_s\, i_\alpha + L_s \frac{di_\alpha}{dt} + e_\alpha(t) $$ -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}$ +where $e_\alpha(t)$ is the (low-frequency) back-EMF, treated below as an out-of-band disturbance. -See: `documentation/theory/images/rl_step_response.svg` (generated by `documentation/tools/generate_plots.gp`) +**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. -### 3. Resistance Estimation +### 2. AC Steady-State Impedance -Once the current has fully settled to steady state (after $5\tau$): +Ignoring $e_\alpha$, the linear RL circuit driven at $\omega$ has the steady-state solution $$ -\boxed{R_s = \frac{V_{step}}{I_{ss}}} +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) $$ -where $I_{ss}$ is measured from the mean of the last 10% of the sample buffer (to average out noise). +so the resistance and inductance are the real and imaginary parts of the impedance: -**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). +$$ +R_s = Z \cos\varphi, \qquad \omega L_s = Z \sin\varphi +$$ -### 4. Inductance Estimation — Time-Constant Method +### 3. Synchronous Demodulation (Online, O(1) Memory) -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: +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: $$ -i_d[n_\tau] \geq 0.6321 \cdot I_{ss} +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 time constant is then: +Using $\langle \sin^2 \rangle = \tfrac12$ and $\langle \sin\theta\cos\theta \rangle = 0$ over integer +periods: $$ -\tau = n_\tau \cdot T_s +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 $$ -and the inductance: +### 4. Closed-Form Recovery + +Substituting $A = ZI$ and the identities of Section 2: $$ -\boxed{L_s = R_s \cdot \tau = R_s \cdot n_\tau \cdot T_s} +\boxed{R_s = \frac{A\, I_{re}}{D}}, \qquad +\boxed{L_s = \frac{-\,A\, I_{im}}{\omega\, D}} $$ -#### Moving Average Filter Correction +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$. -A causal moving average filter of length $N_{avg}$ is applied to the raw current samples before -threshold detection: +**Winding topology.** For a Delta connection the terminals measure $\tfrac{2}{3}$ of the per-phase +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$. -$$ -\bar{i}[n] = \frac{1}{N_{avg}} \sum_{k=0}^{N_{avg}-1} i[n-k] -$$ +**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. + +### 6. Fit Quality (Diagnostic Only) -This FIR filter introduces a lag of $(N_{avg} - 1)/2$ samples. The threshold index is corrected: +A THD-like residual quantifies how sinusoidal the measured current was: $$ -n_\tau^{corrected} = n_\tau - \left\lfloor \frac{N_{avg} - 1}{2} \right\rfloor - 1 +\text{fitQuality} = \frac{\bigl|\,\Sigma_2 - N I^2/2\,\bigr|}{N I^2/2} $$ -Without this correction, $\tau$ is overestimated by the filter group delay, leading to an overestimate -of $L_s$. +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. -**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}$. +### 7. PWM-to-ADC Pipeline Lag Compensation -### 5. Pole Pair Estimation - -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: +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 $$ -p = \frac{C_{rev}}{12 \cdot C_{per\_step}} \quad \text{(integer, rounded)} +\varepsilon = 2\pi\,\frac{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: +($\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{2\pi}{\Delta\theta_{mech,total}} +\theta_{demod} = \theta_{inj} - d\cdot\Delta\theta \pmod{2\pi}, \qquad \Delta\theta = 2\pi f_{inj}/f_s $$ -where $\Delta\theta_{mech,total}$ is the measured total mechanical rotation during the sweep. +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. -### 6. Complete Identification Sequence +### 8. Injection-Frequency Selection -``` -1. Drive alignment: rotate field through N_steps to θ_e = 0. - Record encoder offset θ_offset (see alignment theory). +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 -2. Apply step voltage V_step on d-axis (i_q* = 0, v_d = V_step). +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 = \operatorname{round}\!\left(\frac{N_{rev}}{\Delta\theta_{mech,total} / 2\pi}\right) +$$ -3. Sample i_d at f_s = 10 kHz for N_buf samples. +where $\Delta\theta_{mech,total}$ is the measured total mechanical rotation. This procedure is purely +kinematic and is unchanged by the HF impedance method. -4. Apply moving average filter (length N_avg = 5) to samples. +### 10. Complete Identification Sequence -5. Find steady-state current I_ss from mean of last 10% of buffer. +``` +1. Compute injection parameters: modIndex = injectionVoltagePercent/100 (clamped), + omega = 2*pi*f_inj, samples/period = f_s / f_inj (must be integer). -6. Compute R_s = V_step / I_ss. +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. -7. Find first index n_τ where i_d[n] ≥ 0.6321 · 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. -8. Correct for filter delay: n_τ_corr = n_τ − ⌊(N_avg−1)/2⌋ − 1. +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. -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. +5. Report { R_s, L_s, 0 (offset), fitQuality }. ``` --- @@ -192,38 +242,31 @@ 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] - 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 - ↑ - n_τ (63.2% crossing) — filter delay correction applied +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 ``` --- @@ -232,61 +275,77 @@ 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}$) | -| $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 | +| 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 | 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$ | +|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------| +| 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: $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.5\,\Omega$, $L_s = 2\,\text{mH}$, $V_{dc} = 24\,\text{V}$, $f_{inj} = 250\,\text{Hz}$, +injection $15\%$ modulation. -**Expected results:** +**Applied amplitude:** $A = 0.15 \times 24/2 = 1.8\,\text{V}$. -$$I_{ss} = \frac{2}{1.2} \approx 1.667\ \text{A}$$ +**Impedance and current:** -$$\tau = \frac{L_s}{R_s} = \frac{0.6 \times 10^{-3}}{1.2} = 0.5\ \text{ms} = 5\ T_s$$ +$$ +\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} +$$ -At the 63.2% threshold: $i_d[n_\tau] \geq 0.6321 \times 1.667 = 1.054\ \text{A}$ +**Phase:** $\varphi = \operatorname{atan2}(3.14, 1.5) = 1.126\ \text{rad}\ (64.5°)$. -The raw crossing occurs at $n_\tau = 5$. Filter delay correction: $n_\tau^{corr} = 5 - 2 - 1 = 2$. +**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$. -$$L_{s,\mathrm{mH}} = 1.2 \times 2 \times 100 \times 10^{-6} \times 1000 = 0.24\ \text{mH}$$ +**Recovery:** + +$$ +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 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. +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 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**: $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**: $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 alignment alone. ## References 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/infra/embedded-infra-lib b/infra/embedded-infra-lib index 47a85422..5f7468bb 160000 --- a/infra/embedded-infra-lib +++ b/infra/embedded-infra-lib @@ -1 +1 @@ -Subproject commit 47a85422f9839bd2149cf1789435879de1fde609 +Subproject commit 5f7468bb46d428612e26f17ac52701a5ff007d10 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/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/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/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/scripts/install-caveman.sh b/scripts/install-caveman.sh new file mode 100755 index 00000000..8d4e8312 --- /dev/null +++ b/scripts/install-caveman.sh @@ -0,0 +1,58 @@ +#!/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" + +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]") + 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/hardware_test/components/CMakeLists.txt b/targets/hardware_test/components/CMakeLists.txt index 8a2f9d0d..81eef94a 100644 --- a/targets/hardware_test/components/CMakeLists.txt +++ b/targets/hardware_test/components/CMakeLists.txt @@ -11,6 +11,9 @@ 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 + 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 c3c5bac0..5822ca14 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" @@ -12,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) { @@ -68,6 +70,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 @@ -80,129 +91,171 @@ namespace application , hardware{ hardware } , 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 [angle ia ib ic]. Ex: foc param" }, + 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" }, + 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(SetMotorParameters(param)); - } }); + this->terminal.ProcessResult(IdentifyElectricalParameters(param)); + }); - terminal.AddCommand({ { "can_start", "cs", "Start CAN bus [bitrate [100000;1000000]] [test]. Ex: can_start 500000" }, + 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); + + 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 (runtimeState.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, ' '); @@ -218,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 }; } @@ -238,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 }; @@ -276,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 }; } @@ -297,25 +350,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." }; + 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 }; @@ -329,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() << " %"; @@ -349,12 +408,19 @@ namespace application TerminalInteractor::StatusWithMessage TerminalInteractor::Stop() { hardware.Stop(); + + if (runtimeState.speedActive) + { + foc.Disable(); + runtimeState.speedActive = false; + } + return { success }; } void TerminalInteractor::ProcessAdcSamples() { - adcActive_ = false; + pwmAdcConfig.active = false; hardware.Stop(); tracer.Trace() << " Current Phases [A;B;C] ampere"; @@ -389,30 +455,257 @@ 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." }; + + services::ElectricalParametersIdentification::ResistanceAndInductanceConfig rlConfig; + rlConfig.windingConfig = *winding; - polePairs = static_cast(*poles / 2); - foc.SetPolePairs(polePairs.value()); + if (tokenizer.Size() >= 2) + { + 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 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 }; + } + + 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." }; + motorIdentState.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." }; + motorIdentState.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." }; + motorIdentState.pendingPolePairsConfig.settleTimeBetweenSteps = std::chrono::milliseconds{ *ppSettle }; + } + + motorIdentState.results.reset(); + motorIdentState.aligned = false; + + electricalIdent.EstimateResistanceAndInductance(rlConfig, [this](std::optional result) + { + if (!result.has_value()) + { + tracer.Trace() << " Identification failed: could not estimate R and L."; + return; + } + + motorIdentState.results = IdentificationResults{ *result, 0 }; + RunPolePairEstimation(); + }); + + return { success }; + } + + TerminalInteractor::StatusWithMessage TerminalInteractor::AlignMotor(const infra::BoundedConstString& param) + { + if (!motorIdentState.results.has_value() || motorIdentState.results->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(motorIdentState.results->polePairs, config, [this](std::optional offset) + { + if (!offset.has_value()) + { + 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(); + motorIdentState.aligned = true; + tracer.Trace() << " Alignment complete. Offset: " << offset->Value() << " radians."; + }); + + return { success }; + } + + TerminalInteractor::StatusWithMessage TerminalInteractor::RunSpeedFoc(const infra::BoundedConstString& param) + { + if (!motorIdentState.results.has_value() || motorIdentState.results->polePairs == 0) + return { error, "no pole pairs identified. Run 'ident' first." }; + + if (!motorIdentState.aligned) + 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(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(motorIdentState.results->rl.resistance, motorIdentState.results->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(); + pwmAdcConfig.active = false; + hardware.ConfigureAdcAndPwm(controlFrequency, pwmAdcConfig.deadTime, pwmAdcConfig.sah); + hardware.PhaseCurrentsReady(controlFrequency, [this](foc::PhaseCurrents currentPhases) + { + auto position = hardware.Read(); + hardware.ThreePhasePwmOutput(foc.Calculate(currentPhases, position)); + }); + foc.Enable(); + hardware.Start(); + runtimeState.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 }; } + void TerminalInteractor::RunPolePairEstimation() + { + electricalIdent.EstimateNumberOfPolePairs(motorIdentState.pendingPolePairsConfig, [this](std::optional pp) + { + if (!pp.has_value()) + { + tracer.Trace() << " Identification failed: could not estimate pole pairs."; + motorIdentState.results.reset(); + return; + } + + motorIdentState.results->polePairs = *pp; + ReportIdentificationResults(); + }); + } + + void TerminalInteractor::ReportIdentificationResults() + { + tracer.Trace() << " Identification Results:"; + 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); @@ -442,7 +735,7 @@ namespace application } hardware.ConfigureCanBus(*bitRate, testMode); - canStarted = true; + runtimeState.canStarted = true; hardware.CanBus().SetOnError([this](CanBusAdapter::CanError error) { @@ -456,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, ' '); @@ -499,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) @@ -532,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; @@ -546,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(); @@ -556,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 }); @@ -581,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" }); @@ -595,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 24ef83d0..67dfa863 100644 --- a/targets/hardware_test/components/Terminal.hpp +++ b/targets/hardware_test/components/Terminal.hpp @@ -1,8 +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" @@ -18,6 +24,30 @@ namespace application private: using StatusWithMessage = services::TerminalWithStorage::StatusWithMessage; + using CommandInfo = services::TerminalWithStorage::CommandInfo; + using CommandHandler = infra::Function; + + 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 }; + }; + + // 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 + { + 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); @@ -27,7 +57,10 @@ 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 RunSpeedFoc(const infra::BoundedConstString& param); + StatusWithMessage ReportSpeedEstimates(); StatusWithMessage CanStart(const infra::BoundedConstString& param); StatusWithMessage CanStop(); StatusWithMessage CanSend(const infra::BoundedConstString& param); @@ -39,36 +72,79 @@ namespace application StatusWithMessage GetResetCauseStatus(); StatusWithMessage GetFaultStatus(); StatusWithMessage ForceHardfault(); + void RunPolePairEstimation(); + void ReportIdentificationResults(); private: 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); + 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; 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; hal::Eeprom& eeprom; - std::array eepromBuffer{}; - uint32_t eepromCurrentReadSize{ 0 }; + EepromData eepromData; + services::ElectricalParametersIdentificationImpl electricalIdent; + services::MotorAlignmentImpl motorAlignment; + MotorIdentState motorIdentState; }; } diff --git a/targets/hardware_test/components/test/TestTerminal.cpp b/targets/hardware_test/components/test/TestTerminal.cpp index c1bdfe14..4fd0f436 100644 --- a/targets/hardware_test/components/test/TestTerminal.cpp +++ b/targets/hardware_test/components/test/TestTerminal.cpp @@ -4,14 +4,27 @@ #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" +#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 { @@ -19,7 +32,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)); @@ -79,7 +96,7 @@ namespace class TestHardwareTerminal : public testing::Test - , public infra::EventDispatcherWithWeakPtrFixture + , public infra::ClockFixture { public: TestHardwareTerminal() @@ -90,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)); @@ -117,7 +135,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<24> terminal{ terminalWithCommands, tracer }; testing::StrictMock performanceTrackerMock; testing::StrictMock eepromMock; @@ -155,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 })); + } }; } @@ -356,7 +490,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)); @@ -368,7 +502,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)); @@ -396,9 +530,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 _; @@ -416,7 +568,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 _; @@ -434,7 +586,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 _; @@ -452,7 +604,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)); @@ -464,7 +616,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)); @@ -476,7 +628,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 _; @@ -491,7 +643,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)); @@ -503,7 +655,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)); @@ -515,7 +667,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)); @@ -527,7 +679,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)); @@ -539,7 +691,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)); @@ -548,7 +700,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)); @@ -560,7 +712,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)); @@ -570,29 +722,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 _; @@ -608,15 +758,15 @@ TEST_F(TestHardwareTerminal, motor_invalid_argument_count) ExecuteAllActions(); } -TEST_F(TestHardwareTerminal, motor_invalid_poles_too_low) +TEST_F(TestHardwareTerminal, ident_invalid_injection_voltage_out_of_range) { - InvokeCommand("motor 1", [this]() + InvokeCommand("ident wye 250 200", [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 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::_)); @@ -626,15 +776,15 @@ TEST_F(TestHardwareTerminal, motor_invalid_poles_too_low) ExecuteAllActions(); } -TEST_F(TestHardwareTerminal, motor_invalid_poles_too_high) +TEST_F(TestHardwareTerminal, ident_invalid_injection_frequency_too_low) { - InvokeCommand("motor 18", [this]() + InvokeCommand("ident wye 0", [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 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::_)); @@ -644,15 +794,15 @@ TEST_F(TestHardwareTerminal, motor_invalid_poles_too_high) ExecuteAllActions(); } -TEST_F(TestHardwareTerminal, motor_invalid_poles_not_a_number) +TEST_F(TestHardwareTerminal, ident_invalid_injection_frequency_too_high) { - InvokeCommand("motor invalid", [this]() + InvokeCommand("ident wye 6000", [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 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::_)); @@ -662,21 +812,63 @@ TEST_F(TestHardwareTerminal, motor_invalid_poles_not_a_number) ExecuteAllActions(); } -TEST_F(TestHardwareTerminal, motor_minimum_valid_poles) +TEST_F(TestHardwareTerminal, ident_wye_starts_identification) { - InvokeCommand("motor 2", [this]() + InvokeCommand("ident wye", [this]() { - EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + EXPECT_CALL(platformFactoryMock, Stop()); + EXPECT_CALL(platformFactoryMock, ThreePhasePwmOutput(testing::_)); }); ExecuteAllActions(); } -TEST_F(TestHardwareTerminal, motor_maximum_valid_poles) +TEST_F(TestHardwareTerminal, ident_delta_starts_identification) { - InvokeCommand("motor 16", [this]() + InvokeCommand("ident delta", [this]() { - EXPECT_CALL(streamWriterMock, Insert(testing::_, testing::_)).Times(testing::AnyNumber()); + EXPECT_CALL(platformFactoryMock, Stop()); + EXPECT_CALL(platformFactoryMock, ThreePhasePwmOutput(testing::_)); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, ident_alias) +{ + InvokeCommand("id wye", [this]() + { + EXPECT_CALL(platformFactoryMock, Stop()); + EXPECT_CALL(platformFactoryMock, ThreePhasePwmOutput(testing::_)); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, ident_with_all_optional_args) +{ + InvokeCommand("ident wye 250 15 10 5 50", [this]() + { + EXPECT_CALL(platformFactoryMock, Stop()); + EXPECT_CALL(platformFactoryMock, ThreePhasePwmOutput(testing::_)); + }); + + ExecuteAllActions(); +} + +TEST_F(TestHardwareTerminal, align_fails_without_identification) +{ + InvokeCommand("align", [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(); @@ -972,7 +1164,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 _; @@ -990,7 +1182,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 _; @@ -1761,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.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/hardware_test/instantiations/Logic.hpp b/targets/hardware_test/instantiations/Logic.hpp index ee81a5e8..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<20> terminalWithStorage; + services::TerminalWithBanner::WithMaxSize<24> terminalWithStorage; application::TerminalInteractor terminal; services::DebugLed debugLed; }; diff --git a/targets/platform_implementations/host/implementation/PlatformFactoryImpl.cpp b/targets/platform_implementations/host/implementation/PlatformFactoryImpl.cpp index 6db14765..7e4444e4 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 operationalPin; + } + + hal::GpioPin& PlatformFactoryImpl::WarningLed() + { + return warningPin; + } + + hal::GpioPin& PlatformFactoryImpl::FailureLed() + { + return failurePin; + } + + 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..a1de33a8 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; @@ -223,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/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp b/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp new file mode 100644 index 00000000..800a4551 --- /dev/null +++ b/targets/platform_implementations/motor_boards/E-FOC-HARDWARE/BoardCharacteristics.hpp @@ -0,0 +1,48 @@ +#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{ 21.25f }; + static constexpr float overvoltageThresholdVolts{ 58.0f }; + + static constexpr float voltageToCurrent{ 5.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) + { + 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)); + } + }; +} 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..98344b68 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 operationalPin; + } + + hal::GpioPin& PlatformFactoryImpl::WarningLed() + { + return warningPin; + } + + hal::GpioPin& PlatformFactoryImpl::FailureLed() + { + return failurePin; + } + + 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..47d4e998 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; @@ -214,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/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 e0a78297..00000000 --- a/targets/platform_implementations/ti/EK-TM4C123GXL/PinsAndPeripherals.hpp +++ /dev/null @@ -1,92 +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 led1{ hal::tiva::Port::F, 1 }; - - 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 }; - 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 29574c10..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" @@ -33,7 +32,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 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 }; + 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 +64,8 @@ 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; constexpr static uint8_t OvervoltageComparatorIndex = 1; @@ -77,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 9e438ea7..eae113e5 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 @@ -62,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(); @@ -82,9 +84,39 @@ namespace application return peripherals->terminalAndTracer.terminal; } - infra::MemoryRange PlatformFactoryImpl::Leds() + hal::GpioPin& PlatformFactoryImpl::OperationalLed() + { + return Pins::operationalLed; + } + + hal::GpioPin& PlatformFactoryImpl::WarningLed() + { + return Pins::warningLed; + } + + hal::GpioPin& PlatformFactoryImpl::FailureLed() + { + return Pins::failureLed; + } + + uint8_t PlatformFactoryImpl::BoardId() const { - return infra::MakeRangeFromSingleObject(application::Pins::led1); + if constexpr (!Peripheral::hasBoardIdPins) + return 0; + + 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); + } + + bool PlatformFactoryImpl::PowerStatus() const + { + if constexpr (!Peripheral::hasPowerStatusPin) + return true; + + return peripherals->powerStatus.Get(); } hal::PerformanceTracker& PlatformFactoryImpl::PerformanceTimer() @@ -106,7 +138,7 @@ namespace application foc::Ampere PlatformFactoryImpl::MaxCurrentSupported() const { - return foc::Ampere(15.0f); + return foc::Ampere(BoardCharacteristics::ratedCurrentAmps); } foc::LowPriorityInterrupt& PlatformFactoryImpl::LowPriorityInterrupt() @@ -149,8 +181,8 @@ 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); + adcCfg.interruptPriority = hal::InterruptPriority::Highest; peripherals->phaseCurrentAdc.reset(); peripherals->phaseCurrentAdc.emplace( @@ -162,8 +194,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); @@ -186,22 +216,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; } @@ -219,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( @@ -240,10 +256,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 }); @@ -252,26 +265,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 a8919b09..227ba375 100644 --- a/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp +++ b/targets/platform_implementations/ti/implementation/PlatformFactoryImpl.hpp @@ -11,7 +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" @@ -45,7 +44,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; @@ -92,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 }; @@ -123,23 +126,21 @@ 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). - // 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 0: currentPhaseA → FIFO (noComparator) - {}, // step 1: currentPhaseB → FIFO (noComparator) - {}, // step 2: currentPhaseC → FIFO (noComparator) + // Steps 0-2 (phase currents A/B/C) go to the ADC FIFO. + // 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 }, - { 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 @@ -155,20 +156,12 @@ 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) }; }; - 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) @@ -198,22 +191,27 @@ 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; 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; diff --git a/targets/sync_foc_sensored/main/cycle-analysis.json b/targets/sync_foc_sensored/main/cycle-analysis.json index f0d0f60d..6892e5c5 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\\(.*Quantity.*Quantity.*Quantity.*\\)", + "hal::tiva::Pwm::SetComparator", + "hal::tiva::Pwm::Sync" ] }, { 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 } 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..98f751bf --- /dev/null +++ b/tools/hardware_bridge/server/list_can_interfaces.py @@ -0,0 +1,262 @@ +#!/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", + "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..69c037d4 --- /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.assertTrue(result[0]["supports_fd"]) + + 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() 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)