e-foc System Architecture

Field Value
Title e-foc System Architecture
Type architecture
Status draft
Version 0.1.0
Component system
Date 2026-04-07

Note — Architecture-level document: This document describes what the system is and why it is structured this way. Code must follow the architecture, not the opposite. Logical component and interface names are the vocabulary of architecture and belong here. What must be avoided: source file paths, implementation class names (e.g. FooImpl), and code-level algorithmic details.

Diagrams: All visuals must be either a Mermaid fenced code block or ASCII art inline in the document. External image references (![alt](/home/runner/work/e-foc/e-foc/documentation/architecture/path)) are not allowed.

Sequence diagrams, block diagrams, component diagrams, and state machines (Mermaid syntax) are allowed and encouraged.


Assumptions & Constraints


System Overview

e-foc is a Field-Oriented Control (FOC) firmware for BLDC and PMSM motors. Its purpose is to transform three-phase stator currents into a rotating reference frame so that torque and flux can be regulated independently with standard linear PIDs, then synthesise the three-phase PWM voltages via Space Vector Modulation.

The system is structured in four tiers:

  1. Infrastructure tier — external repositories providing general-purpose utilities, numerical algorithms, and vendor HAL drivers. These are consumed as submodules and are never modified by this project.
  2. Core / Library tier — the FOC algorithm library (core/foc/), service library (core/services/), and the Platform Abstraction Layer interface (core/platform_abstraction/). These are pure, portable libraries with no target-specific knowledge.
  3. Targets tier — target-specific code that is NOT reusable across all environments: platform implementations (concrete PlatformFactory for TI, ST, and Host) and application entry points (hardware_test, sync_foc_sensored). Lives under targets/.
  4. Tools tier — host-only desktop tools (simulator, CAN commander) that run on development machines only. Lives under tools/.
Block diagram

The simulator implements the same PAL contracts as real hardware. Control logic never needs to distinguish between running on a microcontroller or on the host; this enables full-fidelity closed-loop validation without hardware.


Component Decomposition

1. FOC Core

The heart of the system. Decomposed into sub-layers following a strict separation of vocabulary, generic numerics, transform algorithms, orchestration, and wiring. Dependencies flow in one direction only — from wiring down towards vocabulary; no lower layer may depend on a higher one:

Sub-component Responsibility
Interfaces Define the FOC vocabulary (units, phase and frame signal types) and the abstract contracts for control modes (Torque, Speed, Position) and execution (low-priority interrupt, controllable). No algorithms. Hardware ports live in the platform abstraction layer, not here.
Math Generic numerical helpers that are not specific to field-oriented control — fast trigonometry by lookup table, angle wrap-around. Header-only, dependency-free.
Transforms The field-oriented control mathematics proper: the Clarke and Park transforms in both directions, and Space Vector Modulation.
Loop algorithms Interchangeable control laws for each loop, under current_loop/, speed_loop/ and position_loop/, together with their plant models and gain design.
Selection The ControllerSelector template that stores the active algorithm of a loop in a fixed-size std::variant and dispatches to it without allocation.
Cascade Orchestration of the control cascade — the per-mode control loops that combine the current, speed and position algorithms.
Instantiations Wiring that combines a control-mode cascade with the execution runner to produce a ready-to-use FOC controller.

Because Interfaces, Math, and Transforms carry no dependency on the control cascade, the identification, alignment, and simulation components consume them directly without pulling in the control loops.

The three control modes are available in the same binary and are selected at runtime through ControlModeStateMachine. Only one mode is active at a time; switching is only permitted from Idle or Ready state and persists the new choice to NVM via defaultControlMode:

Interrupt Priority Plan

On Cortex-M, equal priority means no preemption: a handler at the same level as the FOC ISR cannot be interrupted by it, so the FOC loop’s worst-case latency is the duration of the longest other handler at or above its level. Leaving every source at the default therefore leaves the 20 kHz deadline hostage to a CAN receive or a UART DMA completion.

The TI target assigns priorities explicitly in application::InterruptPriorities:

Source Priority Rationale
Phase-current ADC highest Carries the 20 kHz control deadline; must preempt everything else
PWM fault highest Hardware overcurrent/overvoltage trip; nothing may delay the response
CAN normal Command and telemetry traffic; may be preempted by the control loop
UART low Terminal and tracing; the least time-critical source on the board
PendSV (outer loop) lowest Set by hal::cortex::LowPriorityInterrupt; the 1 kHz outer loop must never preempt the current loop

The outer loop runs on PendSV at the lowest configurable priority. It carries a 20 000-cycle budget against the inner loop’s 4 500, so running it above the current loop would insert up to a full outer-loop execution of duty-update jitter once per millisecond.

Block diagram

The Runner is the only component that interacts with the PAL inverter and encoder at interrupt time. It registers the ADC-sampling callback and drives the Calculate() dispatch into the active control-mode implementation.

The dispatch carries no validation of the sample. Nothing in the 20 kHz path inspects the phase currents for plausibility or for finiteness: at 4500 cycles a frame the budget is spent on the control law, and a failed shunt or ADC channel is the hardware’s fault detection to catch, not the control loop’s. foc::ToDutyCycles clamps each modulator output into [0, 1] and converts to percent, and does nothing else.

foc::IsFiniteValue survives for the places where validation is affordable and worth having — CurrentPlantModel::IsUsable when the plant is configured, and the plausibility band the mechanical estimator applies before publishing. Both run outside the hot path. It tests the float’s exponent bits rather than calling std::isnan, which -ffinite-math-only licenses the compiler to fold to a constant; the embedded builds keep -fno-finite-math-only, but the check does not depend on it.

2. Services

Higher-level, non-real-time services that support commissioning and runtime operation. Each service is independently usable:

Service Responsibility
Alignment Forces a known electrical angle on the rotor at startup so the FOC reference frame is correctly initialised before normal operation.
Electrical System Identification Estimates phase resistance, d/q inductances, and pole pairs by injecting test signals and measuring the response.
Mechanical System Identification Estimates rotor inertia and viscous friction coefficient from closed-loop speed response data.
Non-Volatile Memory (NVM) Persists calibration data (R, L, pole pairs, encoder offset, PID gains) and configuration (including defaultControlMode) across power cycles.
CLI A terminal-based command interface for triggering services, querying state, and setting parameters from a serial console.
Control Mode Owns the three FocStateMachine instances (torque, speed, position) as a std::variant; only one is live at a time. Exposes Select(mode, onDone) which enforces the motor-stopped precondition, persists the selection to NVM, then switches the active state machine.

3. CAN Service

The CAN service layer (core/can/) provides the FOC motor CAN category as a server/client pair and the bridge that connects the server to the ControlModeStateMachine. It is split into two halves:

Server half (runs on the embedded target): - FocMotorCategoryServer — a CanCategoryServer subclass. Decodes incoming CAN frames into typed observer callbacks. Encodes typed results back into response frames. Wire scaling (int16 ↔︎ physical unit) is contained entirely here. - FocMotorCanBridge — implements the server observer interface. Translates CAN motor commands (Start, Stop, SelectControlMode, SetTorqueSetpoint, SetSpeedSetpoint, SetPositionSetpoint, ClearFault, EmergencyStop) into the corresponding ControlModeStateMachine calls. It also implements the PID bandwidth, identification, telemetry, and encoder-resolution commands (OnIdentifyElectrical, OnIdentifyMechanical, OnRequestTelemetry, OnSetEncoderResolution, OnConfigureTelemetryRate), each reporting success or a specific FocMotorCategoryError on the response frame.

Client half (runs on the desktop host — CAN Commander, SIL tests): - FocMotorCategoryClient — a CanCategoryClient subclass. Encodes typed setpoints into CAN frames and decodes response frames into typed callbacks. - FocMotorCanClient — user-facing API that composes CanFrameTransport, CanProtocolClient, and FocMotorCategoryClient internally. Takes a hal::Can& and a node ID; exposes Start, Stop, SetTorque, SetSpeed, and SetPosition methods.

Tracing is applied at every layer boundary via the can-lite tracing decorators (TracingCan, TracingCanProtocolServerObserver, TracingCanProtocolClientObserver).

Services communicate via asynchronous callbacks (zero-allocation closures from embedded-infra-lib), not return values. This allows long-running operations (identification, alignment) to yield the CPU and complete asynchronously without blocking.

3. Platform Abstraction Layer

The PAL provides a single platform-facing abstraction that groups creation and access to the hardware services needed by the control system:

Peripheral Abstraction
Three-phase PWM + ADC triggered measurement Power-stage drive and synchronised current-sampling interface
Quadrature encoder Rotor-position sensing and calibration interface
Low-priority interrupt Deferred scheduling interface for the speed/position outer loop
CAN bus CAN 2.0B communication interface
Performance timer Cycle/timestamp measurement interface for profiling
Serial terminal Diagnostic trace and command-line interaction interface

Concrete implementations exist for: - TI Tiva (EK-TM4C1294XL, EK-TM4C123GXL): platform-specific peripheral adapters under targets/platform_implementations/ti/. - 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/.

Block diagram

4. Application / Instantiation (was §3)

The wiring layer. Assembles the concrete PAL implementation, the selected FOC mode(s), and the services into a runnable system. This is the only layer that is aware of the specific combination in use — all other layers depend only on abstractions.

The FocStateMachine is the central lifecycle authority. It owns the full motor commissioning and operation lifecycle: it enforces a formal five-state machine (Idle → Calibrating → Ready ⇄ Enabled, Fault), orchestrates the sequential calibration chain (electrical identification, alignment, and mechanical identification for speed/position modes), and responds to hardware fault notifications by immediately stopping the inverter and entering the Fault state. Only after FocStateMachine has reached Ready can the motor be enabled.

In CLI mode, ControlModeStateMachine registers the lifecycle commands calibrate, enable, disable, clear_fault, clear_cal, apply_estimates, and active_mode on the terminal. These commands delegate to the currently active state machine. The TerminalFocBaseInteractor (and its control-mode subclasses) register PID tuning and setpoint commands on the same terminal.

5. Tools (was §4)

Host-only tools that do not run on the embedded target:

Tool Responsibility
Simulator Closed-loop software simulation: real FOC control code drives a physics-based PMSM model (Euler integration of the dq electrical equations). Used for validating control loops without hardware.
CAN Commander Desktop application for sending CAN commands and logging motor telemetry.
Hardware Bridge Desktop client that bridges the target’s terminal (serial) and CAN bus to the host; the hardware-in-the-loop runner reaches the board through it.

6. Infrastructure (was §5)

External repositories consumed as Git submodules. This project does not modify them.

Submodule Purpose
infra/embedded-infra-lib (emIL) Heap-free C++ infrastructure: bounded containers (BoundedVector, BoundedString, BoundedDeque), infra::Function<> (zero-allocation closures), infra::Observer/Subject (type-safe observer pattern), timers, memory utilities, and build toolchain helpers.
infra/numerical-toolbox Numerical algorithms for control: incremental PID controllers with anti-windup, digital filters (FIR, IIR, Kalman), recursive least-squares estimators, and compiler-optimisation helpers (OPTIMIZE_FOR_SPEED).
infra/can-lite Lightweight CAN 2.0B protocol stack: client-server model, category-based message dispatch, ISO-TP segmentation. Zero heap allocation.

Upstream dependencies of the position state feedback laws

Because submodules are never patched locally, an upstream defect has to be carried here as a workaround until the fix lands. Two such defects shaped the position loop and are now resolved on numerical-toolbox main, which is what the submodule tracks:

Upstream Why it mattered
PR #288 / #289 — scale-invariant DARE convergence tolerance Without it the position LQR and LQI designs do not converge at all. The submodule must stay at or past this commit.
Issue #291 — IntegralStateFeedbackLqi had no non-aborting construction path Its constructor called really_assert(converged), so a non-convergent solve aborted the firmware. TryCreate now exists and every state feedback design here uses it.

Should a comparable defect appear again, record it in this table with the workaround and the condition for retiring it, so a workaround does not quietly become permanent.

7. Vendor HAL (was §6)

Vendor-provided hardware abstraction libraries consumed as Git submodules. They supply the low-level peripheral register access and interrupt management that the PAL concrete implementations use.

Directory Vendor / Board
infra/hal/ti Texas Instruments Tiva (Cortex-M4F)
infra/hal/st STMicroelectronics STM32

These are never used directly by the FOC core or services — only by the PAL concrete implementations.


Interfaces & Contracts

Provided Interfaces (exported by this system)

Interface Direction Purpose Invariants
FocTorque provided Torque control mode — accepts Id/Iq current setpoints, yields PWM duty cycles each FOC cycle Must be called from the PWM/ADC interrupt context only. Calculate() must return within the worst-case cycle budget.
FocSpeed provided Speed control mode — accepts an angular-velocity setpoint in rad/s, cascades into the torque loop Outer loop runs at a separate lower-priority interrupt. OuterLoopFrequency() must be queried by the caller to configure that interrupt.
FocPosition provided Position control mode — accepts a rotor angle setpoint in radians, cascades into speed, then torque Requires speed loop to be configured and running.
Controllable provided Start/Stop lifecycle for a FocController Start() arms the interrupt-driven loop. Stop() disarms it and leaves the motor coasting.

Required Interfaces (consumed from the PAL)

Interface Direction Purpose Invariants
ThreePhaseInverter required Triggers ADC phase-current sampling and applies PWM duty cycles to the three-phase bridge PhaseCurrentsReady() installs the callback invoked by the ADC interrupt. Must be called before Start().
Encoder required Reads rotor mechanical angle and supports zero-offset calibration Read must be non-blocking and complete in <= a few cycles.
LowPriorityInterrupt required Schedules periodic execution of the speed and position outer loops at a lower rate than the FOC inner loop Register() installs the callback. Trigger() is called from the FOC inner loop at the configured prescale ratio. Unregister() detaches it: after it returns, neither an already queued nor a future trigger may invoke the handler.
NonVolatileMemory required Persists and retrieves calibration and configuration data All operations are asynchronous and invoke a callback on completion.

SOLID Principles Applied

The interface design reflects explicit SOLID choices:


Data Flow

The primary data flow during closed-loop motor control is the 20 kHz FOC interrupt cycle:

Sequence diagram

The commissioning data flow (run once at startup or on command):

Sequence diagram

Observer and Callback Patterns

The system uses two complementary asynchronous communication patterns, both supplied by infra/embedded-infra-lib:

These patterns eliminate polling, decouple producers from consumers, and allow the application to remain single-threaded and non-blocking.


Cross-Cutting Concerns

Concern Policy / Approach
Memory Absolutely no heap on the embedded target. All objects are statically or stack-allocated. Bounded containers from embedded-infra-lib replace STL heap-based containers. Host-side tools and tests may use the standard heap freely.
Real-time determinism The FOC Calculate() path contains no virtual dispatch, no blocking calls, and no unpredictable branches. The outer loops run at lower-priority interrupts on a deterministic prescale ratio.
Unit safety All physical quantities use typed unit aliases (Ampere, Radians, Volts, RadiansPerSecond, …) from embedded-infra-lib. Raw floating-point with no unit context is not used for motor quantities.
Compiler optimisation Embedded builds compile all of e-foc with one option set: the configuration’s level plus -ffast-math -fno-finite-math-only. No per-function pragmas; they block inlining. Debug builds use -Og for debuggability.
Error handling No C++ exceptions. Synchronous errors use std::optional (absent = error). Asynchronous errors are delivered as typed status codes in callbacks.
Portability The control core has no knowledge of the underlying MCU. Portability to a new board is achieved by implementing a platform factory for that board and providing the PAL concrete implementations.
Testability All modules depend on abstract interfaces, enabling host-build unit tests with mock or stub implementations. The simulator provides closed-loop integration testing without hardware.

Open Questions & Decisions

# Question / Decision Status Options Considered Rationale
1 Rename source/hardware/ and extract platform targets resolved Keep current name vs rename to PAL vs full extraction Renamed to core/platform_abstraction/ (interface + adapters only). Platform implementations (TI, ST, Host) and application targets moved to top-level targets/. Host-only tools moved to top-level tools/. Core tier is now purely libraries. source/ directory renamed to core/.
2 Multi-motor support open Single instantiation per motor vs shared PAL with multiple FOC controllers Current architecture supports one motor per binary. A shared PAL with multiple FocController instances is architecturally feasible but not yet required.
3 Field-weakening open Extend FocTorque interface with flux-weakening setpoint vs separate FocTorqueFieldWeakening interface Required for operation above base speed. Separate interface preferred (ISP) but not yet scoped.

Integration Testing

Integration testing drives the real firmware as a black box over the product’s own CAN command set. Nothing inside the firmware is mocked or reached into; the suite starts a target, sends commands, and asserts on telemetry and traces.

Two targets implement scenarios, selected by tag:

  1. Software-in-the-loop — the firmware built for an emulated Cortex-M4, running under an emulator against a simulated three-phase motor the firmware itself owns. Only the motor, the power stage and the board protection are simulated, because there is no hardware. The motor is described per scenario and handed to the firmware at boot, and non-volatile memory is pre-seeded so a scenario starts from a chosen calibration and configuration.
  2. Hardware-in-the-loop — the same firmware on a real board, reached through the hardware bridge over serial and CAN.

Design detail: documentation/design/integration-testing.md for the shared structure, and documentation/design/software-in-the-loop.md for the simulated target.

Block diagram

Integration Boundaries

Boundary Software-in-the-loop Hardware-in-the-loop
Motor and power stage Simulated plant, described per scenario Real motor and inverter
Board protection Simulated comparators, armed with the inverter Real comparators
Non-volatile memory Host-written image, pre-seeded or deliberately damaged Real device
State machine, cascades, calibration, NVM stack, CAN server Real firmware Real firmware
Transport CAN frames as text on the emulated UART Bridge over serial and CAN

Requirements Traceability

Each scenario carries the requirement identifiers it verifies as tags, alongside the tag naming the target that implements it. The traceability matrix is generated from those tags at release time by reading the feature directory, so the mapping lives with the scenarios rather than in a table here that would drift away from them.

Requirements with no scenario are verified by unit tests under the owning library, or are structural constraints discharged by design review; the generated matrix reports both.