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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
#include "charge_control.h"
#include "board_config.h"
#include <math.h>
#define MPPT_PERTURB_INTERVAL_MS 200U /* >> RC filter tau (1ms) and >> converter settle time */
#define MPPT_STEP_V_INITIAL 0.08f
#define MPPT_STEP_V_MIN 0.010f
#define MPPT_STEP_V_MAX 0.25f
#define MPPT_STEP_SHRINK 0.60f
#define MPPT_STEP_GROW 1.40f
#define MPPT_ICHG_DEADBAND_A 0.020f /* below this, a change is noise, not a gradient */
#define MPPT_ICHG_STRONG_A 0.080f /* above this, the bite was too big */
#define CC_TRIM_STEP_V 0.05f /* per-iteration recovery step, all limiters */
#define CC_TRIM_HYSTERESIS_A 0.05f /* dead-band around a current limit, avoids trim chatter */
#define CC_BACKOFF_V_PER_A 1.00f /* proportional term when a limit is exceeded */
#define IIN_TRIM_HYSTERESIS_A 0.10f
#define IIN_BACKOFF_V_PER_A 1.00f
/* Fault debounce. Asymmetric on purpose: trip quickly enough to protect the
* hardware, but require a sustained good reading before resuming, so a
* PGOOD glitch during a load transient does not thrash the state machine. */
#define FAULT_TRIP_SAMPLES 5U /* 5 x 20ms = 100ms of continuous badness */
#define FAULT_CLEAR_SAMPLES 25U /* 25 x 20ms = 500ms of continuous health */
/* Sensor plausibility bands. A reading outside these is a broken divider, a
* dead INA180 or an ADC that timed out and returned zero -- not a real
* operating point, and acting on it is how a charger overcharges a battery. */
#define VBATT_PLAUSIBLE_MIN_V 7.0f
#define VBATT_PLAUSIBLE_MAX_V 17.0f
#define IIN_PLAUSIBLE_MAX_A (PANEL_IIN_MAX_A * 1.6f)
#define ICHG_PLAUSIBLE_MAX_A (BATT_CC_BULK_LIMIT_A * 3.0f)
#define I_PLAUSIBLE_MIN_A (-1.0f) /* a little negative is sensor offset, not a fault */
static float clampf(float v, float lo, float hi) {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
/* True when every sensor reading is physically possible. */
static int sensors_plausible(const sensor_frame_t *s) {
if (!isfinite(s->vbatt) || !isfinite(s->iin) || !isfinite(s->ichg)) return 0;
if (s->vbatt < VBATT_PLAUSIBLE_MIN_V || s->vbatt > VBATT_PLAUSIBLE_MAX_V) return 0;
if (!isfinite(s->iin_fast) || !isfinite(s->ichg_fast)) return 0;
if (s->iin < I_PLAUSIBLE_MIN_A || s->iin > IIN_PLAUSIBLE_MAX_A) return 0;
if (s->ichg < I_PLAUSIBLE_MIN_A || s->ichg > ICHG_PLAUSIBLE_MAX_A) return 0;
return 1;
}
/* One incremental current limiter. Backing off is proportional to the
* overshoot so it is fast; recovering is a fixed small step so it is slow.
* That asymmetry is what keeps a limit from being overshot while still
* letting the loop find the operating point smoothly. */
static float limiter_update(float ref, float measured, float limit,
float hysteresis, float backoff_gain, float hi)
{
if (measured > limit) {
ref -= (CC_TRIM_STEP_V + backoff_gain * (measured - limit));
} else if (measured < limit - hysteresis) {
/* Recover in steps proportional to the remaining headroom, so the
* approach to the limit slows down instead of arriving at full
* stride. A fixed step overshoots by whatever current that step is
* worth -- and how much current a given millivolt is worth depends
* on the battery's internal resistance, which firmware has no way to
* know. Scaling by relative headroom needs no such knowledge: near
* the limit the steps become small whatever the plant looks like. */
float headroom = (limit - measured) / limit; /* 0..1 */
if (headroom > 1.0f) headroom = 1.0f;
if (headroom < 0.1f) headroom = 0.1f;
ref += CC_TRIM_STEP_V * headroom;
}
return clampf(ref, FB_FLOOR_V, hi);
}
/* Seat every limiter just above the present battery voltage, so current
* ramps from zero instead of stepping. Used at init and whenever a charging
* state is (re-)entered, including after a fault. */
static void soft_start(charge_control_t *cc, float vbatt_now) {
float seed = clampf(vbatt_now + 0.20f, FB_FLOOR_V, BATT_CV_ABSORPTION_V);
cc->cc_ref_v = seed;
cc->iin_ref_v = seed;
cc->mppt_ref_v = seed;
cc->mppt_dir = 1;
cc->mppt_step_v = MPPT_STEP_V_INITIAL;
cc->mppt_last_ichg = 0.0f;
cc->mppt_armed = 0;
cc->mppt_ichg_sum = 0.0f;
cc->mppt_ichg_n = 0u;
}
void charge_control_init(charge_control_t *cc, uint32_t now_ms) {
cc->state = CHG_STATE_BULK;
cc->state_entry_ms = now_ms;
cc->mppt_step_v = MPPT_STEP_V_INITIAL;
cc->mppt_last_perturb_ms = now_ms;
cc->taper_start_ms = now_ms;
cc->taper_running = 0;
cc->fault_count = 0;
cc->ok_count = 0;
cc->fault_reason = "none";
/* No sensor reading exists yet, so seed at the FB network's floor --
* below any real battery voltage, hence zero charge current. The
* limiters creep up from there once real readings arrive, which makes
* the first control cycles a soft-start rather than a step into a
* discharged battery. */
soft_start(cc, FB_FLOOR_V - 1.0f);
cc->commanded_vbatt = cc->cc_ref_v;
cc->limiting_reason = "init";
}
/* P&O perturbation. Two differences from a textbook implementation, both
* of which this board needs:
*
* - a dead-band, because at the maximum power point the gradient is zero by
* definition, so without one the direction is decided by sensor noise and
* the tracker reverses on almost every step;
* - it only runs while MPPT is the constraint actually in command. When the
* charge-current or panel-current limiter is holding the output, MPPT's
* perturbations do not reach the plant, so any change it observes was
* caused by another loop. Integrating on that is windup: the reference
* walks to a rail and then takes control abruptly when the other limit
* releases. */
static void mppt_perturb(charge_control_t *cc, const sensor_frame_t *s, uint32_t now_ms) {
/* Accumulate every control cycle. Judging a perturbation on ONE sample
* taken 200ms later compares two noise draws as much as two operating
* points; averaging the whole window instead cuts the noise by roughly
* the square root of the sample count (10 here) and is what keeps the
* direction decision from being made by the sensor rather than by the
* panel. */
cc->mppt_ichg_sum += s->ichg;
if (cc->mppt_ichg_n < 0xFFFFu) cc->mppt_ichg_n++;
if ((uint32_t)(now_ms - cc->mppt_last_perturb_ms) < MPPT_PERTURB_INTERVAL_MS) {
return;
}
cc->mppt_last_perturb_ms = now_ms;
float mean_ichg = (cc->mppt_ichg_n > 0u)
? cc->mppt_ichg_sum / (float)cc->mppt_ichg_n : s->ichg;
cc->mppt_ichg_sum = 0.0f;
cc->mppt_ichg_n = 0u;
if (!cc->mppt_armed) {
/* first window after taking command: record, do not judge */
cc->mppt_last_ichg = mean_ichg;
cc->mppt_armed = 1;
return;
}
/* Reverse only on a CLEAR degradation. A change inside the dead-band is
* indistinguishable from sensor noise, so it must not flip the
* direction -- but the perturbation itself still happens every cycle,
* because a P&O that stops perturbing stops tracking, and at the floor
* (where the current is zero and so is the gradient) it would never
* start. */
float delta = mean_ichg - cc->mppt_last_ichg;
if (delta < -MPPT_ICHG_DEADBAND_A) {
cc->mppt_dir = (int8_t)(-cc->mppt_dir); /* clearly worse -- turn around */
}
/* Size the step so each perturbation produces a change big enough to
* MEASURE. The step and the dead-band have to be coupled this way round:
* an earlier version shrank the step on every reversal, which let the
* step fall until a perturbation moved the current by less than the
* dead-band -- at which point every delta read as "no change", the
* direction froze, and the tracker walked steadily off the peak without
* ever seeing that it was losing power. Growing the step whenever the
* signal is too weak to steer by makes that failure impossible: the loop
* cannot go blind, because going blind is itself what makes it take a
* bigger bite. */
float mag = delta < 0.0f ? -delta : delta;
if (mag < MPPT_ICHG_DEADBAND_A) {
cc->mppt_step_v *= MPPT_STEP_GROW;
} else if (mag > MPPT_ICHG_STRONG_A) {
cc->mppt_step_v *= MPPT_STEP_SHRINK;
}
cc->mppt_step_v = clampf(cc->mppt_step_v, MPPT_STEP_V_MIN, MPPT_STEP_V_MAX);
/* Walking into a clamp is not tracking: if the reference is already
* pinned at an end of its range and the direction pushes further into
* it, nothing changes and no gradient is ever observed. Turn around. */
if ((cc->mppt_dir > 0 && cc->mppt_ref_v >= BATT_CV_ABSORPTION_V - 1e-4f) ||
(cc->mppt_dir < 0 && cc->mppt_ref_v <= FB_FLOOR_V + 1e-4f)) {
cc->mppt_dir = (int8_t)(-cc->mppt_dir);
}
cc->mppt_ref_v += (float)cc->mppt_dir * cc->mppt_step_v;
cc->mppt_ref_v = clampf(cc->mppt_ref_v, FB_FLOOR_V, BATT_CV_ABSORPTION_V);
cc->mppt_last_ichg = mean_ichg;
}
float charge_control_update(charge_control_t *cc, const sensor_frame_t *s, uint32_t now_ms) {
/* ---- 1. health: PGOOD and sensor plausibility, both debounced ------- */
const char *bad = 0;
if (!s->bias_pgood) bad = "bias-pgood";
else if (!s->mppt_pgood) bad = "mppt-pgood";
else if (!sensors_plausible(s)) bad = "sensor-range";
if (bad) {
cc->ok_count = 0;
if (cc->fault_count < FAULT_TRIP_SAMPLES) cc->fault_count++;
if (cc->fault_count >= FAULT_TRIP_SAMPLES && cc->state != CHG_STATE_FAULT) {
cc->state = CHG_STATE_FAULT;
cc->state_entry_ms = now_ms;
cc->fault_reason = bad;
}
} else {
cc->fault_count = 0;
if (cc->ok_count < FAULT_CLEAR_SAMPLES) cc->ok_count++;
}
if (cc->state == CHG_STATE_FAULT) {
/* Command the FB network's floor: below any real battery voltage, so
* the converter stops delivering. The LEDs signal the state. */
cc->commanded_vbatt = FB_FLOOR_V;
cc->limiting_reason = "fault";
if (!bad && cc->ok_count >= FAULT_CLEAR_SAMPLES) {
cc->state = CHG_STATE_BULK;
cc->state_entry_ms = now_ms;
cc->taper_running = 0;
soft_start(cc, s->vbatt); /* never resume at the setpoint that faulted */
}
return cc->commanded_vbatt;
}
/* ---- 2. the state machine picks a voltage ceiling ------------------- */
float candidate_cv;
const char *cv_reason;
switch (cc->state) {
case CHG_STATE_BULK:
candidate_cv = BATT_CV_ABSORPTION_V;
cv_reason = "cv-ceiling";
if (s->vbatt >= BATT_CV_ABSORPTION_V - 0.05f) {
cc->state = CHG_STATE_ABSORPTION;
cc->state_entry_ms = now_ms;
cc->taper_running = 0;
}
break;
case CHG_STATE_ABSORPTION:
candidate_cv = BATT_CV_ABSORPTION_V;
cv_reason = "absorption-cv";
/* The taper must be SUSTAINED, not merely instantaneous: hold below
* the taper threshold continuously for BATT_ABSORPTION_MIN_MS. A
* single noise dip below 0.1A is not a full battery, and treating it
* as one drops to float early and leaves the battery undercharged. */
if (s->ichg < BATT_ABSORPTION_TAPER_A) {
if (!cc->taper_running) {
cc->taper_running = 1;
cc->taper_start_ms = now_ms;
} else if ((uint32_t)(now_ms - cc->taper_start_ms) >= BATT_ABSORPTION_MIN_MS) {
cc->state = CHG_STATE_FLOAT;
cc->state_entry_ms = now_ms;
cc->taper_running = 0;
}
} else {
cc->taper_running = 0;
}
/* A sustained sag well below the absorption setpoint means the
* battery is no longer nearly full -- a load was added, or it was
* swapped. Go back to bulk rather than sitting at a CV ceiling the
* battery cannot reach. */
if (s->vbatt < BATT_CV_FLOAT_V - 0.5f) {
cc->state = CHG_STATE_BULK;
cc->state_entry_ms = now_ms;
cc->taper_running = 0;
}
break;
case CHG_STATE_FLOAT:
candidate_cv = BATT_CV_FLOAT_V;
cv_reason = "float-cv";
if (s->ichg > BATT_CC_BULK_LIMIT_A * 0.5f) {
cc->state = CHG_STATE_BULK;
cc->state_entry_ms = now_ms;
soft_start(cc, s->vbatt);
}
break;
default:
cc->commanded_vbatt = FB_FLOOR_V;
cc->limiting_reason = "unknown-state";
return cc->commanded_vbatt;
}
/* ---- 3. current limits, applied in EVERY charging state -------------
* These used to live inside the bulk branch, which meant that once the
* charger reached absorption or float the charge-current limit and the
* panel-side overcurrent ceiling simply stopped existing. Both protect
* hardware (RS1 and the bridge FETs are sized for ~5A) and the battery,
* so neither may depend on which charge phase happens to be running. */
cc->cc_ref_v = limiter_update(cc->cc_ref_v, s->ichg_fast, BATT_CC_BULK_LIMIT_A,
CC_TRIM_HYSTERESIS_A, CC_BACKOFF_V_PER_A, candidate_cv);
cc->iin_ref_v = limiter_update(cc->iin_ref_v, s->iin_fast, PANEL_IIN_MAX_A,
IIN_TRIM_HYSTERESIS_A, IIN_BACKOFF_V_PER_A, candidate_cv);
/* ---- 4. MPPT, only in bulk and only while it is in command ---------- */
float candidate_mppt = candidate_cv;
if (cc->state == CHG_STATE_BULK) {
int mppt_in_command = (cc->mppt_ref_v <= cc->cc_ref_v) &&
(cc->mppt_ref_v <= cc->iin_ref_v) &&
(cc->mppt_ref_v <= candidate_cv);
if (mppt_in_command) {
mppt_perturb(cc, s, now_ms);
} else {
/* another limit is holding the output: re-seat MPPT just above
* the binding constraint instead of letting it wind to a rail */
float binding = cc->cc_ref_v < cc->iin_ref_v ? cc->cc_ref_v : cc->iin_ref_v;
if (binding > candidate_cv) binding = candidate_cv;
cc->mppt_ref_v = clampf(binding + cc->mppt_step_v, FB_FLOOR_V, BATT_CV_ABSORPTION_V);
cc->mppt_armed = 0;
cc->mppt_ichg_sum = 0.0f;
cc->mppt_ichg_n = 0u;
cc->mppt_step_v = MPPT_STEP_V_INITIAL;
cc->mppt_last_perturb_ms = now_ms;
}
candidate_mppt = cc->mppt_ref_v;
}
/* ---- 5. arbitrate: the most restrictive constraint wins ------------- */
float cmd = candidate_cv;
const char *reason = cv_reason;
if (cc->cc_ref_v < cmd) { cmd = cc->cc_ref_v; reason = "cc-limit"; }
if (cc->iin_ref_v < cmd) { cmd = cc->iin_ref_v; reason = "iin-limit"; }
if (candidate_mppt < cmd) { cmd = candidate_mppt; reason = "mppt"; }
cc->commanded_vbatt = clampf(cmd, FB_FLOOR_V, FB_CEILING_V);
cc->limiting_reason = reason;
return cc->commanded_vbatt;
}