board
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.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
/*
* harness.c -- runs charge_control.c against the simulated plant and checks
* the invariants that matter for a charger: never exceed a current limit,
* never exceed the battery's voltage ceiling, find the maximum power point,
* and end up in the right state.
*
* Every check here is a property of the CONTROLLER, not of the plant model:
* the plant is deliberately forgiving (no switching ripple, no sensor
* failure unless a scenario injects one) so that anything this reports is a
* control defect rather than a modelling artefact.
*/
#include <stdio.h>
#include <string.h>
#include <math.h>
#include <stdlib.h>
#include "charge_control.h"
#include "board_config.h"
#include "plant.h"
#define ADC_FILTER_ALPHA 0.25f /* must match adc_sense.c */
static int failures = 0, checks = 0;
static void check(int cond, const char *scenario, const char *what) {
checks++;
if (!cond) {
failures++;
printf(" FAIL [%s] %s\n", scenario, what);
}
}
/* deterministic pseudo-noise so runs are reproducible */
static uint32_t rng = 12345u;
static float noise(float amplitude) {
rng = rng * 1664525u + 1013904223u;
return (((float)((rng >> 8) & 0xFFFF) / 65535.0f) - 0.5f) * 2.0f * amplitude;
}
typedef struct {
const char *name;
float max_ichg, max_iin, max_vbus; /* absolute peaks, transients included */
float set_ichg, set_iin; /* peaks once settled after any step */
float final_ichg;
charge_state_t final_state;
float mppt_efficiency; /* mean extracted power / true Pmax, once settled */
int reversals; /* MPPT direction changes in the settled window */
} result_t;
/* Run the controller against the plant for a while.
* adc_noise_a : noise amplitude injected on the current sensors
* fail_vbatt_adc : if set, VBATT_SENSE reads 0 from this ms onward
* load_from_ms / load_a : a load applied to the bus partway through */
static float g_panel_isc = 0.0f; /* 0 = leave plant default */
static result_t run(const char *name, uint32_t duration_ms, float adc_noise_a,
uint32_t fail_vbatt_adc_ms, uint32_t load_from_ms, float load_a,
float batt_ocv0, uint32_t pgood_drop_ms, uint32_t pgood_drop_len)
{
plant_t p; plant_init(&p);
p.batt_ocv = batt_ocv0;
if (g_panel_isc > 0.0f) p.isc = g_panel_isc;
charge_control_t cc; charge_control_init(&cc, 0);
result_t r; memset(&r, 0, sizeof r);
r.name = name; r.max_vbus = 0.0f;
const uint32_t dt = 20; /* control loop period */
float f_v = 0.0f, f_i = 0.0f, f_c = 0.0f; int ema_init = 0;
double psum = 0.0, pmaxsum = 0.0; long psamples = 0;
int8_t last_dir = cc.mppt_dir;
for (uint32_t t = 0; t < duration_ms; t += dt) {
p.load_a = (t >= load_from_ms) ? load_a : 0.0f;
int pgood_down = (pgood_drop_len && t >= pgood_drop_ms && t < pgood_drop_ms + pgood_drop_len);
p.mppt_pgood = pgood_down ? 0 : 1;
p.bias_pgood = 1;
/* the controller never sees raw ADC values -- adc_sense.c puts them
* through an exponential moving average first, so the harness must
* too, or it tests a signal chain the firmware does not have */
float raw_v = (fail_vbatt_adc_ms && t >= fail_vbatt_adc_ms) ? 0.0f : p.vbus;
float raw_i = p.iin + noise(adc_noise_a);
float raw_c = p.ichg + noise(adc_noise_a);
if (raw_c < 0.0f) raw_c = 0.0f;
if (raw_i < 0.0f) raw_i = 0.0f;
if (!ema_init) { f_v = raw_v; f_i = raw_i; f_c = raw_c; ema_init = 1; }
else {
f_v += ADC_FILTER_ALPHA * (raw_v - f_v);
f_i += ADC_FILTER_ALPHA * (raw_i - f_i);
f_c += ADC_FILTER_ALPHA * (raw_c - f_c);
}
sensor_frame_t s;
s.vbatt = f_v; s.iin = f_i; s.ichg = f_c;
s.iin_fast = raw_i; s.ichg_fast = raw_c;
s.mppt_pgood = p.mppt_pgood;
s.bias_pgood = p.bias_pgood;
float cmd = charge_control_update(&cc, &s, t);
if (getenv("TRACE") && (t % 2000u) == 0u) {
float vmp2; float pmx = plant_panel_pmax(&p, &vmp2);
fprintf(stderr, "t=%6u st=%d cmd=%6.3f mppt=%6.3f cc=%6.3f iin=%6.3f "
"step=%6.4f dir=%+d ichg=%5.3f ppanel=%5.2f/%5.2f %s\n",
t, (int)cc.state, (double)cmd, (double)cc.mppt_ref_v,
(double)cc.cc_ref_v, (double)cc.iin_ref_v, (double)cc.mppt_step_v,
cc.mppt_dir, (double)p.ichg, (double)p.ppanel, (double)pmx,
cc.limiting_reason);
}
if (cc.mppt_dir != last_dir) { r.reversals++; last_dir = cc.mppt_dir; }
plant_step(&p, cmd, dt);
if (p.ichg > r.max_ichg) r.max_ichg = p.ichg;
if (p.iin > r.max_iin) r.max_iin = p.iin;
if (p.vbus > r.max_vbus) r.max_vbus = p.vbus;
/* A digital limiter running at 20ms cannot prevent the transient
* from a step that happens between samples; what it must do is not
* SUSTAIN an overshoot. Score those two things separately: peaks
* outside a settling window after start-up, a load step or a fault
* recovery are transients, everything else is sustained. */
int settling = (t < 3000u) ||
(load_a != 0.0f && t >= load_from_ms && t < load_from_ms + 1000u) ||
(pgood_drop_len && t >= pgood_drop_ms &&
t < pgood_drop_ms + pgood_drop_len + 2000u);
if (!settling) {
if (p.ichg > r.set_ichg) r.set_ichg = p.ichg;
if (p.iin > r.set_iin) r.set_iin = p.iin;
}
/* Grade P&O only while it is the constraint actually in command.
* In a CV phase the charger is voltage-limited by design, so
* drawing less than the panel could give is correct, not a tracking
* failure -- scoring those samples would measure the wrong thing. */
if (t > duration_ms / 2 && !pgood_down &&
cc.limiting_reason && cc.limiting_reason[0] == 'm') {
float vmp; float pmax = plant_panel_pmax(&p, &vmp);
psum += p.ppanel; pmaxsum += pmax; psamples++;
}
}
r.final_ichg = p.ichg;
r.final_state = cc.state;
r.mppt_efficiency = (psamples && pmaxsum > 0.0) ? (float)(psum / pmaxsum) : 0.0f;
return r;
}
static void report(result_t r) {
printf(" %-34s ichg %4.2f/%4.2f iin %4.2f/%4.2f vbus_max=%6.2fV mppt=%5.1f%% state=%d\n",
r.name, (double)r.set_ichg, (double)r.max_ichg,
(double)r.set_iin, (double)r.max_iin, (double)r.max_vbus,
(double)(r.mppt_efficiency * 100.0f), (int)r.final_state);
}
int main(void) {
printf("FB network: ceiling %.2fV floor %.2fV slope %.4f V/V\n",
(double)FB_CEILING_V, (double)FB_FLOOR_V, (double)FB_SLOPE_V_PER_V);
printf("limits: CC %.2fA IIN %.2fA CV %.2fV\n\n",
(double)BATT_CC_BULK_LIMIT_A, (double)PANEL_IIN_MAX_A, (double)BATT_CV_ABSORPTION_V);
/* ---- 1. plain bulk charge, quiet sensors ---------------------------- */
result_t a = run("1 bulk, clean sensors", 120000, 0.0f, 0, 999999999u, 0.0f, 11.8f, 0, 0);
report(a);
check(a.set_ichg <= BATT_CC_BULK_LIMIT_A * 1.05f, a.name, "charge current held at the 2.0A bulk limit");
check(a.set_iin <= PANEL_IIN_MAX_A * 1.05f, a.name, "panel current held under the 5.0A ceiling");
check(a.max_vbus <= BATT_CV_ABSORPTION_V + 0.15f, a.name, "bus voltage stayed under the 14.2V CV ceiling");
check(a.final_ichg > BATT_CC_BULK_LIMIT_A * 0.80f, a.name, "still delivering most of the allowed charge current");
/* ---- 2. same, with realistic sensor noise --------------------------- */
result_t b = run("2 bulk, 30mA sensor noise", 120000, 0.030f, 0, 999999999u, 0.0f, 11.8f, 0, 0);
report(b);
check(b.set_ichg <= BATT_CC_BULK_LIMIT_A * 1.05f, b.name, "charge current held at the bulk limit despite noise");
check(b.final_ichg > BATT_CC_BULK_LIMIT_A * 0.80f, b.name, "still delivering most of the allowed charge current");
printf(" MPPT direction reversals: %d (clean run: %d)\n", b.reversals, a.reversals);
/* ---- 3. a load applied once the charger is in absorption ------------ */
result_t c = run("3 load step during absorption", 900000, 0.0f, 0, 400000u, 3.0f, 13.9f, 0, 0);
report(c);
check(c.set_ichg <= BATT_CC_BULK_LIMIT_A * 1.05f, c.name, "charge current held within the bulk limit");
check(c.set_iin <= PANEL_IIN_MAX_A * 1.05f, c.name, "panel current held within the 5.0A ceiling");
/* ---- 4. VBATT sense line fails (divider opens, ADC reads 0) --------- */
result_t d = run("4 VBATT sense fails open", 600000, 0.0f, 60000u, 999999999u, 0.0f, 12.6f, 0, 0);
report(d);
check(d.final_state == CHG_STATE_FAULT, d.name, "a dead VBATT sensor is detected and faults");
check(d.max_vbus <= BATT_CV_ABSORPTION_V + 0.15f, d.name, "bus voltage stayed under the CV ceiling");
/* ---- 5. brief PGOOD glitch ----------------------------------------- */
result_t e = run("5 PGOOD glitch, 100ms", 300000, 0.0f, 0, 999999999u, 0.0f, 12.2f, 100000u, 100u);
report(e);
check(e.set_ichg <= BATT_CC_BULK_LIMIT_A * 1.05f, e.name, "charge current held within the bulk limit after recovery");
/* ---- 6. strong panel: does the CC limit hold in ABSORPTION? -------- */
g_panel_isc = 6.0f; /* panel can deliver well past 2A */
result_t f = run("6 strong panel, absorption", 900000, 0.0f, 0, 999999999u, 0.0f, 13.2f, 0, 0);
report(f);
check(f.set_ichg <= BATT_CC_BULK_LIMIT_A * 1.05f, f.name, "charge current limited in ABSORPTION too");
check(f.set_iin <= PANEL_IIN_MAX_A * 1.05f, f.name, "panel current limited in ABSORPTION too");
/* ---- 7. strong panel + heavy load: panel overcurrent ceiling -------- */
result_t g = run("7 strong panel, 6A load", 900000, 0.0f, 0, 300000u, 6.0f, 13.6f, 0, 0);
report(g);
check(g.set_iin <= PANEL_IIN_MAX_A * 1.05f, g.name, "panel current ceiling held under heavy load once settled");
check(g.max_iin <= PANEL_IIN_MAX_A * 1.50f, g.name, "load-step transient stayed bounded");
g_panel_isc = 0.0f;
/* ---- 8. MPPT alone: weak panel so CC never binds -------------------- */
g_panel_isc = 0.55f; /* well under the 2A charge limit */
result_t h = run("8 MPPT isolated, weak panel", 300000, 0.0f, 0, 999999999u, 0.0f, 12.0f, 0, 0);
report(h);
check(h.mppt_efficiency > 0.90f, h.name, "P&O holds >90% of available power when it is the only loop");
printf(" MPPT direction reversals: %d\n", h.reversals);
g_panel_isc = 0.0f;
printf("\n%d checks, %d failures\n", checks, failures);
return failures ? 1 : 0;
}