main
Name Last commit message Last updated
Core Update 20 file(s) · TylerK 1mo ago
test Update 20 file(s) · TylerK 1mo ago
Makefile Update 20 file(s) · TylerK 1mo ago
README.md Update 20 file(s) · TylerK 1mo ago

MPPT Power System Firmware (STM32G031F8Px)

Application-layer firmware for the MCU/IC-based solar MPPT power system board (see mppt-mcu-src/ for the schematic/PCB generator). Implements P&O MPPT, a CC/CV battery charge state machine, and the arbitration between them, driving the LM5175 buck-boost stage's FB summing node through a single PWM+RC-filtered analog reference (PA4).

What's here vs. what CubeMX generates

This directory holds the application layer only — the control algorithms, sensor scaling, and the state machine. It is meant to be dropped into an STM32CubeIDE project generated from STM32CubeMX (.ioc file), which supplies the boilerplate this code assumes exists: main.h, SystemClock_Config(), peripheral init functions, HAL/CMSIS, the linker script, and startup code. None of that is regenerated here since it's mechanically produced by CubeMX from the pin configuration below, not something worth hand-authoring or duplicating.

To build: create a new STM32CubeIDE project for STM32G031F8Px, import an .ioc configured per the table below, copy this repo's Core/Inc/*.h and Core/Src/*.c (except don't overwrite CubeMX's own generated main.c) into the project, and add one line to CubeMX's generated main.c inside the while (1) loop:

#include "app_main.h"   /* or just declare `void app_main(void);` */
...
int main(void) {
    /* ...CubeMX-generated init calls... */
    app_main();   /* never returns */
}

CubeMX pin/peripheral configuration

Pin Peripheral Configuration
PA0 ADC1_IN0 VBATT_SENSE, scan rank 1
PA1 ADC1_IN1 IIN_SENSE, scan rank 2
PA2 ADC1_IN2 ICHG_SENSE, scan rank 3
PA4 TIM14_CH1 PWM, ~50kHz (e.g. 64MHz timer clock, PSC=0, ARR=1279)
PA6 GPIO_Output LED1 (green, charge status), push-pull, low speed
PA7 GPIO_Output LED2 (red, fault), push-pull, low speed
PA8 GPIO_Input MPPT_PGOOD, no pull (external pull-up on board, R18)
PB3 GPIO_Input BIAS_PGOOD, no pull
PA9 USART1_TX 115200 8N1
PA10 USART1_RX 115200 8N1
PA13 SYS_JTMS-SWDIO SWD
PA14 SYS_JTCK-SWCLK SWD

ADC1: continuous=off, scan conversion mode=enabled, 3 ranks as above, 12-bit resolution, software trigger (started explicitly each control-loop iteration in adc_sense_read(), not free-running — the control loop's own 20ms cadence is the sample clock).

Unused pins (tied off per the schematic's S.noconn() calls, no CubeMX configuration needed): PB7/PB8, PB9/PC14, PC15, PF2, PA3, PA5.

Architecture

app_main() [20ms loop]
  |
  +-- adc_sense_read()        -> sensor_frame_t {vbatt, iin, ichg, pgood x2}
  +-- charge_control_update() -> commanded VBATT setpoint
  |     |
  |     +-- state machine: BULK -> ABSORPTION -> FLOAT (-> back to BULK on load)
  |     +-- BULK: CC current-limit trim + P&O MPPT perturbation, arbitrated
  |     |         by taking the MOST RESTRICTIVE (lowest) candidate setpoint
  |     +-- hard panel-side IIN overcurrent backoff, any state
  |
  +-- pwm_ref_set_vbatt_setpoint() -> PA4 PWM duty -> RC filter -> LM5175 FB
  +-- status_led_update()          -> LED1/LED2
  |
  +-- (every 500ms) uart_telemetry_send() -> USART1 CSV line

Why MPPT tracks ICHG, not panel power (V x I) directly

The board's ADC set is VBATT, IIN (panel current) and ICHG (charge current) — there's no VIN (panel voltage) sense divider on this board revision, so true P&O power tracking (P = Vin * Iin) on the input side isn't directly computable. Since the battery bus voltage moves slowly compared to a single P&O perturb-and-settle interval, maximizing ICHG into that near-constant bus is an accepted proxy for maximizing extracted panel power, and is what charge_control.c's mppt_perturb() implements. IIN is still used directly for the hard panel-side overcurrent backoff (PANEL_IIN_MAX_A). A future board revision could add a VIN divider (e.g. reusing the now-unused PA3 or PA5 pin) for true input-side power tracking; see the comparison doc for this noted as a possible follow-up.

FB summing-node transfer function (why R13/R15 were corrected)

The LM5175's FB pin is driven by three resistors summing VBATT, GND and the PWM+RC-filtered MCU reference. Working through the KCL at that node during firmware bring-up revealed the schematic's original R13=46.4k/R15=10k values could only reach a 10.28V ceiling — physically incapable of ever reaching the 14.2V CV target regardless of what the MCU commands. Corrected to R13=120k/R15=68k (R14 unchanged at 10k), giving a 14.76V ceiling down to an 8.94V floor across the PWM's full 0-3.3V range, comfortably bracketing 14.2V. See board_config.h for the exact transfer function and mppt-mcu-src/generator-scripts/build.py's comment at the R13 placement for the schematic-side fix. This was caught and fixed before it reached hardware — worth calling out in the design-calculations doc as a real example of why the FB network needs to be solved algebraically, not just pattern-matched from a similar reference design.

Battery charge profile

12V SLA/AGM, matching the old discrete-analog board for a direct comparison:

  • Bulk: CC at 2.0A (BATT_CC_BULK_LIMIT_A) until VBATT reaches 14.2V
  • Absorption: CV at 14.2V (BATT_CV_ABSORPTION_V) until current tapers below 0.1A for at least 10 minutes
  • Float: CV at 13.6V (BATT_CV_FLOAT_V) indefinitely; returns to Bulk if load pulls current back above half the bulk limit

Verification

Two things can be checked without hardware, a CubeMX install or an STM32 toolchain licence, and make runs both:

make test    # host build of the control layer + simulated panel/converter/battery
make arm     # cross-compile every source for Cortex-M0+ under strict warnings

make arm builds all six application sources with -Wall -Wextra -Wconversion -Wshadow -Wdouble-promotion against test/arm-stub/main.h, a stand-in for the header CubeMX generates. It is not a HAL and cannot be linked into a real image; its job is to make the application layer compile for the real part so that type errors, HAL signature drift and accidental double-precision maths are caught. That last one matters here: the STM32G031 has no FPU, so a single double creeping into the control maths would pull in soft-float routines and cost both flash and loop time. The build fails if any appear. Current cost: 3,096 bytes of flash, 0 bytes of RAM — all controller state lives in caller-provided structs, so there are no statics to initialise or to race on.

make test compiles charge_control.c for the host and runs it against a simulated panel, converter and battery (test/plant.c). The plant is deliberately simple — no switching ripple, no parasitics — because its job is not to be a circuit simulator but to reproduce the one relationship the controller actually steers by: charge current as a function of commanded battery-bus voltage, with a genuine maximum in the middle of the range. It models the panel collapsing past that maximum, which is the entire reason MPPT exists; a model that pins the panel at its peak makes a broken tracker look fine.

The suite asserts properties, not outputs: that no current limit is exceeded once settled, that the CV ceiling holds, that P&O finds and holds the peak, and that a dead sensor is detected rather than acted upon. Transient peaks after a step are scored separately from sustained ones, because a limiter running on a 20 ms cadence cannot prevent a sub-cycle transient — what it must do is not sustain an overshoot.

What the tests found

Every item below was a real defect in the first version of this firmware, found by running it rather than by reading it, and each is now covered by a test that fails without the fix.

# Defect Consequence
1 Current limits were implemented only inside the BULK branch Reaching absorption or float silently switched off both the 2 A charge-current limit and the 5 A panel-side ceiling. Measured 2.86 A charge and 5.64 A panel current in absorption against limits of 2.0 A and 5.0 A. The panel ceiling protects RS1 and the bridge FETs, so this was a hardware-protection hole, not a tuning issue.
2 No sensor plausibility checking The header documented FAULT as covering "PGOOD lost or a sensor out of range", but only PGOOD was ever tested. A VBATT divider failing open reads 0 V — which looks like a flat battery, so the charger would hold its CV ceiling indefinitely. Sustained 14.2 V on an SLA is precisely the condition the absorption→float transition exists to prevent.
3 A timed-out ADC conversion was reported as a reading of zero adc_sense_read() pre-set its raw values to 0 and overwrote them only on HAL_OK, so a timeout produced a confident, plausible-looking 0 V / 0 A frame. Combined with #2 this is the same overcharge path, reached from a different direction. Now propagates NaN, which the plausibility check rejects.
4 MPPT integrated while another loop held the output When the charge-current or panel-current limiter is in command, MPPT's perturbations never reach the plant, so every change it observes was caused by something else. Its reference wound to a rail and then took over abruptly when the other limit released. It is now frozen and re-seated while not in command.
5 P&O reversed direction on sensor noise The direction test had no dead-band, and at the maximum power point the gradient is zero by definition, so the decision was made by whichever way the noise fell — 597 reversals in a 600-perturbation run. Now compares the mean charge current over each perturbation window against a dead-band.
6 The absorption taper was instantaneous, not sustained The documented rule is "below 0.1 A for ten minutes". The code tested "in absorption for ten minutes and below 0.1 A right now", so a single noise dip after the tenth minute dropped it to float early, leaving the battery undercharged. Now requires the condition to hold continuously.
7 No way out of ABSORPTION except to FLOAT A load applied during absorption could drag the bus down with the state machine still commanding a CV ceiling the battery could no longer reach. Now falls back to BULK on a sustained sag.
8 Faults were neither debounced nor cleanly recovered from A single bad PGOOD sample faulted immediately, and recovery resumed instantly at the pre-fault setpoint — the one that had just caused the fault. Now 100 ms to trip, 500 ms of continuous health to clear, and the limiters soft-start from the measured battery voltage on the way back.
9 Telemetry lost the sign of negative currents fmt_milli() split a value into whole and fractional parts, and for anything between −999 mA and 0 the whole part is 0 and carries no sign. A battery discharging at 250 mA was reported as 0.250A of charge.

Two further changes came out of the test runs rather than from inspection:

  • The protection limiters now read the unfiltered current, while MPPT keeps the exponentially-filtered one. The filter that makes the MPPT gradient readable also delays it, and a limiter acting on a delayed signal permits a real overshoot proportional to that delay — measured as 2.13 A against a 2.0 A limit, which fell to 2.08 A once protection moved to the fast signal. Optimise on the smooth signal, protect on the fast one.
  • The MPPT step size is adapted against the dead-band, not against reversals. An intermediate version shrank the step on every reversal, which let it fall until one perturbation moved the current by less than the dead-band. At that point every reading said "no change", the direction froze, and the tracker walked steadily off the peak — from 9.16 W to 1.19 W of an available 9.18 W — without ever detecting that it was losing power. The step now grows whenever the observed change is too small to steer by, which makes that failure structurally impossible.

What this does not cover

The plant is a control model, not a circuit: no switching ripple, no converter dynamics, no ESR, no temperature. Loop stability across the MPPT/CC/CV transitions, the RC filter's interaction with the LM5175's own compensation, and the real panel's I-V curve all remain bench items. These tests establish that the control logic is correct on its own terms; they say nothing about whether the analogue loop around it is stable.

Known follow-ups (not blocking, tracked here rather than silently dropped)

  • R16/R17 (LM5175 VOSNS overvoltage-protection divider) reused the original (since-corrected) R13/R14 ratio without an independent derivation against the real VOSNS threshold in the LM5175 datasheet — flagged in build.py at the R16 placement, not yet re-derived.
  • No VIN (panel voltage) sense channel on this board revision — see the MPPT design note above.
  • MPPT holds ~93% of available panel power in simulation, not ~99%. The only control handle firmware has is the battery-bus setpoint, and a battery is a stiff voltage source, so the usable span between "no charge current" and "panel collapsed" is a few hundred millivolts wide. P&O therefore limit-cycles across a sharp, asymmetric peak. Tracking closer would need either a VIN sense channel (so power could be maximised directly rather than inferred from charge current) or a smarter search than fixed-direction P&O. Both are future-board items, recorded here because the number is a property of the control architecture, not a tuning failure.