Solar MPPT Power System
Public Unreviewedby TylerK
A 30 W solar battery charger with regulated 5 V and 3.3 V rails: an STM32G031 running perturb & observe MPPT and a CC/CV charge state machine, steering a TI LM5175 4-switch buck-boost through a single analog control input. Four-layer KiCad board, schematic and PCB generated from Python, with an electrothermal simulation run against the published board file.
| Name | Last updated |
|---|---|
| Core | 1mo ago |
| test | 1mo ago |
| Makefile | 1mo ago |
| README.md | 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.pyat 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.