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.
← Commit history
Update 20 file(s)
12 files changed
+981−104
README.md+64−2firmware/Core/Inc/charge_control.h+25−4firmware/Core/Src/adc_sense.c+23−10firmware/Core/Src/charge_control.c+266−80firmware/Core/Src/uart_telemetry.c+8−4firmware/Makefile+53firmware/README.md+93−1firmware/test/arm-stub/main.h+57firmware/test/harness.c+217firmware/test/plant.c+113firmware/test/plant.h+58page.json+4−3README.md+64−2@@ -365,6 +365,65 @@ agreed to **zero relative difference** on every resistance, voltage drop, copper current-density figure, with the thermal solution identical to six decimal places. The numbers on this page are a property of the board file, not of a particular run. +## Firmware++The MCU runs the part of this system that cannot be done with resistors: deciding,+every 20 ms, what battery-bus voltage to ask the LM5175 for. Source, the CubeMX pin+configuration and the full design notes are in+[`firmware/README.md`](firmware/README.md).++It is an application layer, so it has no `main()` of its own — CubeMX supplies the+startup code, clock setup, HAL and linker script, and `app_main()` replaces the body+of the generated `while(1)`. That means no flashable image is produced here. Two+things can still be verified without hardware, and `make` runs both:++| | |+|---|---|+| `make arm` | Cross-compiles all six sources for Cortex-M0+ under `-Wall -Wextra -Wconversion -Wshadow -Wdouble-promotion`, against a stub `main.h`. Costs **3,096 bytes of flash and 0 bytes of RAM**, and fails the build if double-precision soft-float appears — the STM32G031 has no FPU, so one stray `double` in the control maths would cost both flash and loop time. |+| `make test` | Compiles the control layer for the host and runs it against a simulated panel, converter and battery. 16 property checks: current limits, voltage ceiling, MPPT tracking, fault detection and recovery. |++The plant model is deliberately not a circuit simulator. Its job is to reproduce the+one relationship the controller steers by — charge current against commanded bus+voltage, with a real maximum in the middle — including 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.++### What running it found++Nine defects, none of which were visible by reading the code, and two more that only+appeared while fixing the first nine. The full table is in+[`firmware/README.md`](firmware/README.md); the three that mattered most:++**Current limits existed only in 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 at 2.86 A and 5.64 A against those limits. The panel ceiling is+what protects RS1 and the bridge FETs, so this was a hardware-protection hole rather+than a tuning problem.++**Two independent paths led to holding 14.2 V on the battery indefinitely.** The+sensor plausibility checking the header documented was never implemented, and a+timed-out ADC conversion was reported as a confident reading of 0 V. Either one makes+a dead VBATT divider look like a flat battery, and sustained absorption voltage on an+SLA is exactly what the absorption→float transition exists to prevent.++**P&O steered by sensor noise.** With no dead-band on the direction test, and a+gradient that is zero at the peak by definition, the tracker reversed 597 times in a+600-perturbation run. Fixing that introduced a subtler failure worth recording: an+adaptive step that shrank on every reversal eventually fell below the point where one+perturbation moved the current more than the dead-band, at which point every reading+said "no change", the direction froze, and the tracker walked steadily off the peak —+9.16 W down to 1.19 W of an available 9.18 W — without ever detecting a loss. The step+now *grows* whenever the signal is too weak to steer by, which makes going blind+structurally impossible.++One finding is architectural rather than a bug: MPPT holds about **93%** of available+panel power in simulation, not the ~99% a well-tuned tracker reaches. The only handle+firmware has is the *output* setpoint, and a battery is a stiff voltage source, so the+usable span between no charge current and a collapsed panel is a few hundred+millivolts. Closing that gap needs a VIN sense channel — so power could be maximised+directly instead of inferred from charge current — which is a future-board item.++ ## Key design values | Parameter | Value | Basis |@@ -460,8 +519,11 @@ layout pass. - **Nothing is left unrouted.** As of revision 5 there are no links to close by hand before fabrication; the three that previously needed it are routed and verified.-- **Firmware is written and reviewed, not built or flashed** — no STM32 toolchain was- available in the environment this was produced in.+- **Firmware compiles for the target and passes its control tests, but has never+ been flashed.** `make arm` builds every application source for Cortex-M0+ and+ `make test` runs the control layer against a simulated plant, but producing a+ flashable image still needs CubeMX's startup code, linker script and HAL. Loop+ stability on real hardware remains a bench item. - **Confirm the real panel's Voc and temperature coefficient.** Input protection ratings currently assume a 30 V worst case pending the actual panel datasheet. - **R16/R17** (LM5175 VOSNS overvoltage threshold) needs independent re-derivation
firmware/Core/Inc/charge_control.h+25−4@@ -37,8 +37,15 @@ typedef enum { typedef struct { float vbatt;- float iin;- float ichg;+ float iin; /* filtered -- smooth, for the MPPT gradient */+ float ichg; /* filtered -- smooth, for the MPPT gradient */+ /* Unfiltered copies of the same two currents. Protection limits run on+ * these: the exponential 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. Optimise on the smooth+ * signal, protect on the fast one. */+ float iin_fast;+ float ichg_fast; uint8_t mppt_pgood; /* 1 = LM5175 stage reports power-good */ uint8_t bias_pgood; /* 1 = housekeeping bias reports power-good */ } sensor_frame_t;@@ -54,8 +61,22 @@ typedef struct { float mppt_last_ichg; uint32_t mppt_last_perturb_ms; - /* CC bulk-phase integral trim */- float cc_ref_v;+ uint8_t mppt_armed; /* 1 once a baseline ICHG has been taken */+ float mppt_ichg_sum; /* ICHG accumulated across the current window */+ uint16_t mppt_ichg_n; /* samples in that accumulation */++ /* incremental current limiters, active in every charging state */+ float cc_ref_v; /* charge-current limiter's candidate setpoint */+ float iin_ref_v; /* panel-current limiter's candidate setpoint */++ /* absorption taper must be sustained, so it needs its own timer */+ uint32_t taper_start_ms;+ uint8_t taper_running;++ /* fault debounce counters */+ uint8_t fault_count;+ uint8_t ok_count;+ const char *fault_reason; /* the final arbitrated output, and which constraint won */ float commanded_vbatt;
firmware/Core/Src/adc_sense.c+23−10@@ -1,6 +1,7 @@ #include "adc_sense.h" #include "board_config.h" #include "main.h" /* CubeMX-generated: hadc1 handle, GPIO pin/port defines */+#include <math.h> #define ADC_FILTER_ALPHA 0.25f @@ -19,19 +20,29 @@ void adc_sense_init(adc_filter_state_t *filt) { * PA0=rank1, PA1=rank2, PA2=rank3) and the two PGOOD GPIOs. */ void adc_sense_read(adc_filter_state_t *filt, sensor_frame_t *out) { uint32_t raw_vbatt = 0, raw_iin = 0, raw_ichg = 0;-+ int ok = 1;++ /* A conversion that does not complete must NOT be reported as a reading+ * of zero. Zero volts on VBATT_SENSE is a perfectly plausible-looking+ * number that happens to mean "battery empty, charge as hard as you+ * can" -- the most dangerous possible interpretation of a dead sensor.+ * Propagate NaN instead, which charge_control's plausibility check+ * rejects, and the charger faults rather than acting on a fiction. */ HAL_ADC_Start(&hadc1);- if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) {- raw_vbatt = HAL_ADC_GetValue(&hadc1);- }- if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) {- raw_iin = HAL_ADC_GetValue(&hadc1);- }- if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) {- raw_ichg = HAL_ADC_GetValue(&hadc1);- }+ if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) raw_vbatt = HAL_ADC_GetValue(&hadc1); else ok = 0;+ if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) raw_iin = HAL_ADC_GetValue(&hadc1); else ok = 0;+ if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) raw_ichg = HAL_ADC_GetValue(&hadc1); else ok = 0; HAL_ADC_Stop(&hadc1); + if (!ok) {+ out->vbatt = out->iin = out->ichg = (float)NAN;+ out->iin_fast = out->ichg_fast = (float)NAN;+ out->mppt_pgood = 0;+ out->bias_pgood = 0;+ filt->initialized = 0; /* re-seed the filter when readings return */+ return;+ }+ float vbatt = vbatt_from_adc(raw_vbatt); float iin = iin_from_adc(raw_iin); float ichg = ichg_from_adc(raw_ichg);@@ -50,6 +61,8 @@ void adc_sense_read(adc_filter_state_t *filt, sensor_frame_t *out) { out->vbatt = filt->vbatt_f; out->iin = filt->iin_f; out->ichg = filt->ichg_f;+ out->iin_fast = iin; /* unfiltered, for the protection limits */+ out->ichg_fast = ichg; out->mppt_pgood = (HAL_GPIO_ReadPin(MPPT_PGOOD_GPIO_Port, MPPT_PGOOD_Pin) == GPIO_PIN_SET) ? 1 : 0; out->bias_pgood = (HAL_GPIO_ReadPin(BIAS_PGOOD_GPIO_Port, BIAS_PGOOD_Pin) == GPIO_PIN_SET) ? 1 : 0; }
firmware/Core/Src/charge_control.c+266−80@@ -1,10 +1,36 @@ #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 CC_TRIM_STEP_V 0.05f /* per-iteration bulk-phase current-limit trim */-#define CC_TRIM_HYSTERESIS_A 0.05f /* dead-band around the bulk CC limit, avoids trim chatter */+#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;@@ -12,140 +38,300 @@ static float clampf(float v, float lo, float 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_ref_v = FB_CEILING_V; /* start at the top and let P&O + CC pull it down */ cc->mppt_step_v = MPPT_STEP_V_INITIAL;- cc->mppt_dir = -1;- cc->mppt_last_ichg = 0.0f; 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"; - cc->cc_ref_v = FB_CEILING_V;+ /* 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 = FB_CEILING_V;+ cc->commanded_vbatt = cc->cc_ref_v; cc->limiting_reason = "init"; } -/* P&O perturbation: nudge mppt_ref_v, observe whether ICHG (our power proxy,- * see charge_control.h) went up or down since the last perturbation, and- * keep going the same direction if it helped or reverse if it hurt. */+/* 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) {- if (now_ms - cc->mppt_last_perturb_ms < MPPT_PERTURB_INTERVAL_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; }- if (s->ichg < cc->mppt_last_ichg) {- cc->mppt_dir = -cc->mppt_dir; /* last move made things worse -- reverse */+ 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; }- cc->mppt_ref_v += cc->mppt_dir * cc->mppt_step_v;- cc->mppt_ref_v = clampf(cc->mppt_ref_v, FB_FLOOR_V, FB_CEILING_V); - cc->mppt_last_ichg = s->ichg;- cc->mppt_last_perturb_ms = now_ms;+ /* 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) {- if (!s->bias_pgood || !s->mppt_pgood) {- if (cc->state != CHG_STATE_FAULT) {+ /* ---- 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++; } - float candidate_cv; /* what the CV ceiling for this state wants */- float candidate_cc = FB_CEILING_V; /* what the current limiter wants (defaults to "no limit") */- float candidate_mppt = FB_CEILING_V; /* what MPPT wants (only meaningful in BULK) */- const char *reason = "cv";-- switch (cc->state) {- case CHG_STATE_FAULT:- /* PA6/PA7 LEDs signal this; commanding the FB-network floor keeps- * output voltage at its minimum until PGOOD recovers. */+ 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 (s->bias_pgood && s->mppt_pgood) {+ 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;-- /* current-limit trim: back off if over the bulk CC ceiling, ease- * back up (toward the CV ceiling) if comfortably under it -- a- * simple incremental integrator, not a full PI loop, since the- * plant (LM5175 + battery) is slow enough that this converges- * smoothly at the MPPT_PERTURB_INTERVAL_MS cadence. */- if (s->ichg > BATT_CC_BULK_LIMIT_A) {- cc->cc_ref_v -= CC_TRIM_STEP_V;- } else if (s->ichg < BATT_CC_BULK_LIMIT_A - CC_TRIM_HYSTERESIS_A) {- cc->cc_ref_v += CC_TRIM_STEP_V;- }- cc->cc_ref_v = clampf(cc->cc_ref_v, FB_FLOOR_V, BATT_CV_ABSORPTION_V);- candidate_cc = cc->cc_ref_v;-- /* panel-side hard backoff: never let IIN exceed the design rating,- * regardless of what CC/MPPT want -- overrides by direct scaling of- * the MPPT candidate proportionally to how far over we are. */- mppt_perturb(cc, s, now_ms);- candidate_mppt = cc->mppt_ref_v;- if (s->iin > PANEL_IIN_MAX_A) {- float overshoot = s->iin - PANEL_IIN_MAX_A;- candidate_mppt -= overshoot * 1.0f; /* 1 V per amp over -- fast, hard backoff */- candidate_mppt = clampf(candidate_mppt, FB_FLOOR_V, FB_CEILING_V);- reason = "iin-limit";- }-- if (candidate_cc <= candidate_mppt && candidate_cc <= candidate_cv) {- cc->commanded_vbatt = candidate_cc;- if (reason[0] != 'i') reason = "cc-limit";- } else if (candidate_mppt <= candidate_cv) {- cc->commanded_vbatt = candidate_mppt;- if (reason[0] != 'i') reason = "mppt";- } else {- cc->commanded_vbatt = candidate_cv;- reason = "cv-ceiling";- }- cc->limiting_reason = reason;-+ 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:- cc->commanded_vbatt = BATT_CV_ABSORPTION_V;- cc->limiting_reason = "absorption-cv";+ candidate_cv = BATT_CV_ABSORPTION_V;+ cv_reason = "absorption-cv"; - if (s->ichg < BATT_ABSORPTION_TAPER_A &&- (now_ms - cc->state_entry_ms) >= BATT_ABSORPTION_MIN_MS) {- cc->state = CHG_STATE_FLOAT;+ /* 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:- cc->commanded_vbatt = BATT_CV_FLOAT_V;- cc->limiting_reason = "float-cv";-- /* a fresh load pulling current back up means the battery is being- * discharged faster than float can sustain -- go back to bulk. */+ 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;- cc->cc_ref_v = FB_CEILING_V;- cc->mppt_ref_v = FB_CEILING_V;+ soft_start(cc, s->vbatt); } break; default: cc->commanded_vbatt = FB_FLOOR_V; cc->limiting_reason = "unknown-state";- break;+ 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; }
firmware/Core/Src/uart_telemetry.c+8−4@@ -17,10 +17,14 @@ static const char *state_name(charge_state_t s) { * (it costs several KB of flash on a part with only 64KB total) -- format * manually in millivolts/milliamps instead of linking a float formatter. */ static int fmt_milli(char *buf, int32_t milli) {- int32_t whole = milli / 1000;- int32_t frac = milli % 1000;- if (frac < 0) frac = -frac;- return sprintf(buf, "%ld.%03ld", (long)whole, (long)frac);+ /* The sign has to be emitted separately. Integer division truncates+ * toward zero, so for anything between -999 and -1 the whole part is 0+ * and carries no sign of its own -- printing whole.frac would turn+ * -0.250A of discharge into "0.250", reporting a battery that is being+ * drained as one that is being charged. */+ const char *sign = (milli < 0) ? "-" : "";+ int32_t mag = (milli < 0) ? -milli : milli;+ return sprintf(buf, "%s%ld.%03ld", sign, (long)(mag / 1000), (long)(mag % 1000)); } void uart_telemetry_init(void) {
firmware/Makefileadded+53@@ -0,0 +1,53 @@+# Two things can be checked without any STM32 hardware or a CubeMX install,+# and both are checked here:+#+# make test -- compile the control layer for the HOST and run it against a+# simulated panel, converter and battery, asserting the+# properties a charger must have (current limits respected,+# voltage ceiling respected, MPPT finds the peak, faults are+# detected and recovered from).+# make arm -- compile every application source for the real target+# (Cortex-M0+) under strict warnings, against a stub main.h+# standing in for the one CubeMX generates. Catches type and+# signature errors, accidental double-precision maths (this+# part has no FPU), and reports the flash/RAM cost.+#+# `make` runs both. Neither produces a flashable image -- that still needs+# CubeMX's startup code, linker script and HAL, per README.md.++CC ?= gcc+ARMCC ?= arm-none-eabi-gcc+SRC := $(wildcard Core/Src/*.c)+APP_SRC := $(filter-out Core/Src/app_main.c,$(SRC))+WARN := -Wall -Wextra -Wconversion -Wshadow -Wdouble-promotion+ARMFLAGS := -std=c11 -Os -mcpu=cortex-m0plus -mthumb -ffreestanding $(WARN) \+ -ICore/Inc -Itest/arm-stub+HOSTFLAGS := -std=c11 -O1 -g -Wall -Wextra -ICore/Inc -Itest+BUILD := build++.PHONY: all test arm clean+all: test arm++test: $(BUILD)/harness+ @echo "== host control-loop tests =="+ @./$(BUILD)/harness++$(BUILD)/harness: test/harness.c test/plant.c Core/Src/charge_control.c | $(BUILD)+ $(CC) $(HOSTFLAGS) $^ -o $@ -lm++arm: | $(BUILD)+ @echo "== cross-compile for STM32G031 (Cortex-M0+) =="+ @for f in $(SRC); do \+ o=$(BUILD)/arm_$$(basename $$f .c).o; \+ $(ARMCC) -c $(ARMFLAGS) $$f -o $$o || exit 1; \+ done+ @arm-none-eabi-size -t $(BUILD)/arm_*.o+ @if arm-none-eabi-nm $(BUILD)/arm_*.o | grep -qE "__aeabi_(dadd|dsub|dmul|ddiv|f2d)"; then \+ echo "ERROR: double-precision soft-float pulled in on a part with no FPU"; exit 1; \+ else echo "OK: single-precision only"; fi++$(BUILD):+ @mkdir -p $(BUILD)++clean:+ @rm -rf $(BUILD)
firmware/README.md+93−1@@ -108,13 +108,96 @@ pattern-matched from a similar reference design. ## Battery charge profile -12V SLA/AGM:+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:++```bash+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@@ -123,3 +206,12 @@ pattern-matched from a similar reference design. 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.
firmware/test/arm-stub/main.hadded+57@@ -0,0 +1,57 @@+/*+ * main.h -- a STAND-IN for the main.h STM32CubeMX generates.+ *+ * It exists for one purpose: to let the application layer be compiled for+ * the real target (Cortex-M0+, arm-none-eabi) without a CubeMX installation,+ * so that syntax, types, HAL call signatures and -- importantly on a+ * 64KB/8KB part -- code and data size can all be checked in CI.+ *+ * It is NOT a HAL implementation and must never be linked into a real+ * binary. Every declaration here matches the signature CubeMX/the STM32G0+ * HAL provides; if one drifts, this build breaks, which is the point.+ */+#ifndef MAIN_H_STUB+#define MAIN_H_STUB++#include <stdint.h>+#include <stddef.h>++typedef enum { HAL_OK = 0, HAL_ERROR, HAL_BUSY, HAL_TIMEOUT } HAL_StatusTypeDef;+typedef enum { GPIO_PIN_RESET = 0, GPIO_PIN_SET } GPIO_PinState;++typedef struct { uint32_t dummy; } GPIO_TypeDef;+typedef struct { uint32_t Instance; uint32_t dummy; } ADC_HandleTypeDef;+typedef struct { uint32_t Instance; uint32_t arr; } TIM_HandleTypeDef;+typedef struct { uint32_t Instance; uint32_t dummy; } UART_HandleTypeDef;++extern ADC_HandleTypeDef hadc1;+extern TIM_HandleTypeDef htim14;+extern UART_HandleTypeDef huart1;+extern GPIO_TypeDef *GPIOA;+extern GPIO_TypeDef *GPIOB;++#define TIM_CHANNEL_1 0x00000000U++#define LED1_GPIO_Port GPIOA+#define LED1_Pin ((uint16_t)(1U << 6))+#define LED2_GPIO_Port GPIOA+#define LED2_Pin ((uint16_t)(1U << 7))+#define MPPT_PGOOD_GPIO_Port GPIOA+#define MPPT_PGOOD_Pin ((uint16_t)(1U << 8))+#define BIAS_PGOOD_GPIO_Port GPIOB+#define BIAS_PGOOD_Pin ((uint16_t)(1U << 3))++uint32_t HAL_GetTick(void);+HAL_StatusTypeDef HAL_ADC_Start(ADC_HandleTypeDef *h);+HAL_StatusTypeDef HAL_ADC_Stop(ADC_HandleTypeDef *h);+HAL_StatusTypeDef HAL_ADC_PollForConversion(ADC_HandleTypeDef *h, uint32_t timeout);+uint32_t HAL_ADC_GetValue(const ADC_HandleTypeDef *h);+GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *port, uint16_t pin);+void HAL_GPIO_WritePin(GPIO_TypeDef *port, uint16_t pin, GPIO_PinState st);+HAL_StatusTypeDef HAL_TIM_PWM_Start(TIM_HandleTypeDef *h, uint32_t ch);+HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *h, uint8_t *d, uint16_t n, uint32_t to);++#define __HAL_TIM_SET_COMPARE(h, ch, v) ((void)((h)->arr = (uint32_t)(v)))+#define __HAL_TIM_GET_AUTORELOAD(h) ((h)->arr)++#endif /* MAIN_H_STUB */
firmware/test/harness.cadded+217@@ -0,0 +1,217 @@+/*+ * 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;+}
firmware/test/plant.cadded+113@@ -0,0 +1,113 @@+#include "plant.h"+#include <math.h>++/* Shape factor for the exponential I-V knee. 0.055 puts the maximum power+ * point at roughly 0.8*Voc, which is where a real silicon panel's sits. */+#define KNEE 0.055f++void plant_init(plant_t *p) {+ p->voc = 21.0f; /* a 30W 12V-class panel in full sun */+ p->isc = 1.9f;+ p->batt_ocv = 12.0f; /* a fairly discharged 12V SLA */+ p->batt_rint = 0.35f;+ p->batt_capacity_ah = 7.0f;+ p->efficiency = 0.92f;+ p->load_a = 0.0f;+ p->mppt_pgood = 1;+ p->bias_pgood = 1;+ p->vbus = p->batt_ocv;+ p->ichg = p->iin = p->ppanel = p->panel_v = 0.0f;+}++static float panel_current_at(const plant_t *p, float v) {+ if (v <= 0.0f) return p->isc;+ if (v >= p->voc) return 0.0f;+ float i = p->isc * (1.0f - expf((v - p->voc) / (KNEE * p->voc)));+ return i < 0.0f ? 0.0f : i;+}++float plant_panel_power_at(const plant_t *p, float v) {+ return v * panel_current_at(p, v);+}++float plant_panel_pmax(const plant_t *p, float *v_at_max) {+ float best = 0.0f, bv = 0.0f;+ for (int k = 1; k < 2000; k++) {+ float v = p->voc * (float)k / 2000.0f;+ float pw = plant_panel_power_at(p, v);+ if (pw > best) { best = pw; bv = v; }+ }+ if (v_at_max) *v_at_max = bv;+ return best;+}++/* Operating voltage on the HIGH-voltage branch of the I-V curve that+ * delivers exactly p_want, or the MPP voltage if p_want is unreachable.+ * The converter naturally settles on the high-voltage branch because that+ * is the stable side for a constant-power load. */+/* Overload collapse steepness: how fast delivered power falls once the+ * converter demands more than the panel's maximum. A real panel slides down+ * the low-voltage branch of its I-V curve, where power DECREASES as current+ * is drawn harder -- pinning it at Pmax instead would mean demanding too+ * much costs nothing, and MPPT would have no job to do. */+#define COLLAPSE_K 1.5f++static float panel_v_for_power(const plant_t *p, float p_want, float *p_got) {+ float vmp, pmax = plant_panel_pmax(p, &vmp);+ if (p_want >= pmax) {+ float overload = p_want / pmax - 1.0f;+ *p_got = pmax / (1.0f + COLLAPSE_K * overload);+ /* operating point has slid below Vmp, onto the low-voltage branch */+ return vmp * (*p_got / pmax);+ }++ float lo = vmp, hi = p->voc; /* power decreases from vmp to voc */+ for (int k = 0; k < 60; k++) {+ float mid = 0.5f * (lo + hi);+ if (plant_panel_power_at(p, mid) > p_want) lo = mid; else hi = mid;+ }+ *p_got = plant_panel_power_at(p, 0.5f * (lo + hi));+ return 0.5f * (lo + hi);+}++void plant_step(plant_t *p, float vbatt_cmd, uint32_t dt_ms) {+ /* What the converter would deliver if the panel could support it: the+ * battery accepts (Vbus - OCV)/Rint, and the load takes its own share. */+ float ichg_want = (vbatt_cmd - p->batt_ocv) / p->batt_rint;+ if (ichg_want < 0.0f) ichg_want = 0.0f; /* cannot sink current */+ float iout_want = ichg_want + p->load_a;+ float pout_want = vbatt_cmd * iout_want;+ float pin_want = pout_want / p->efficiency;++ float pin_got;+ p->panel_v = panel_v_for_power(p, pin_want, &pin_got);+ p->ppanel = pin_got;+ p->iin = (p->panel_v > 0.0f) ? pin_got / p->panel_v : 0.0f;++ float pout_got = pin_got * p->efficiency;++ if (pin_got >= pin_want - 1e-4f) {+ /* panel keeps up: the converter holds its commanded voltage */+ p->vbus = vbatt_cmd;+ p->ichg = ichg_want;+ } else {+ /* panel is the limit: the bus sags until supply and demand agree.+ * Solve Vbus*((Vbus-OCV)/Rint + load) = pout_got for Vbus. */+ float lo = p->batt_ocv, hi = vbatt_cmd;+ for (int k = 0; k < 60; k++) {+ float mid = 0.5f * (lo + hi);+ float pm = mid * ((mid - p->batt_ocv) / p->batt_rint + p->load_a);+ if (pm < pout_got) lo = mid; else hi = mid;+ }+ p->vbus = 0.5f * (lo + hi);+ p->ichg = (p->vbus - p->batt_ocv) / p->batt_rint;+ if (p->ichg < 0.0f) p->ichg = 0.0f;+ }++ /* the battery charges: OCV rises with accumulated charge */+ float hours = (float)dt_ms / 3600000.0f;+ float net_a = p->ichg - p->load_a;+ p->batt_ocv += (net_a * hours / p->batt_capacity_ah) * 2.4f;+ if (p->batt_ocv < 10.5f) p->batt_ocv = 10.5f;+ if (p->batt_ocv > 14.8f) p->batt_ocv = 14.8f;+}
firmware/test/plant.hadded+58@@ -0,0 +1,58 @@+/*+ * plant.h -- a physical model of the panel, the LM5175 converter and the+ * battery, good enough to exercise charge_control.c on a host PC.+ *+ * This is not a circuit simulator. It exists to answer control questions:+ * does the state machine reach the right states, does the arbitrator ever+ * let a current exceed its limit, does P&O converge on the maximum power+ * point and stay there. For that, what matters is that the plant reproduces+ * 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, which is what makes P&O a non-trivial problem.+ */+#ifndef PLANT_H+#define PLANT_H++#include <stdint.h>++typedef struct {+ /* panel */+ float voc; /* open-circuit voltage at present irradiance */+ float isc; /* short-circuit current at present irradiance */++ /* battery */+ float batt_ocv; /* open-circuit (rest) voltage, rises with charge */+ float batt_rint; /* internal resistance, ohms */+ float batt_capacity_ah;++ /* converter */+ float efficiency;++ /* externally applied load current drawn from the battery bus */+ float load_a;++ /* power-good inputs the controller reads */+ uint8_t mppt_pgood;+ uint8_t bias_pgood;++ /* --- outputs, updated by plant_step() --- */+ float vbus; /* actual battery-bus voltage achieved */+ float ichg; /* actual current into the battery */+ float iin; /* actual panel current */+ float ppanel; /* actual power drawn from the panel */+ float panel_v; /* panel operating voltage */+} plant_t;++void plant_init(plant_t *p);++/* Panel power available at a given operating voltage. */+float plant_panel_power_at(const plant_t *p, float v);++/* The panel's true maximum power point, found by sweep -- the value P&O is+ * supposed to discover on its own, used only by the tests to grade it. */+float plant_panel_pmax(const plant_t *p, float *v_at_max);++/* Advance the plant by dt_ms with the converter commanded to vbatt_cmd. */+void plant_step(plant_t *p, float vbatt_cmd, uint32_t dt_ms);++#endif /* PLANT_H */
page.json+4−3@@ -29,11 +29,12 @@ "ina180", "current-sense", "generated",- "electrothermal-simulation"+ "electrothermal-simulation",+ "firmware-testing" ], "slug": "mppt-power-system",- "version": "7.1.0",- "description": "A 30 W solar battery charger with regulated 5 V / 3.3 V rails. STM32G031 MCU runs P&O MPPT plus a CC/CV charge state machine and drives a TI LM5175 4-switch synchronous buck-boost via its FB pin; two INA180 shunt amplifiers close the current loops and an LM74700 provides active reverse-polarity protection. Charges a 12 V SLA/AGM battery to 14.2 V across the panel's full 6-24 V range. 189 x 129 mm 4-layer board, 5,752 track segments, generated entirely from Python. Five revisions driven by an electrothermal model and a first-build audit run against the published board file, and revision 5 is final: 0 unconnected items, 0 shorts, 0 clearance violations, 0 mechanical DRC findings, every net one continuous piece of copper, fab-capable drills and annular rings, every IC bypassed within 10 mm, all fourteen simulation cases solving, and the three remaining routes to a better board each measured and found closed.",+ "version": "7.2.0",+ "description": "A 30 W solar battery charger with regulated 5 V / 3.3 V rails. STM32G031 MCU runs P&O MPPT plus a CC/CV charge state machine and drives a TI LM5175 4-switch synchronous buck-boost via its FB pin; two INA180 shunt amplifiers close the current loops and an LM74700 provides active reverse-polarity protection. Charges a 12 V SLA/AGM battery to 14.2 V across the panel's full 6-24 V range. 189 x 129 mm 4-layer board, 5,752 track segments, generated entirely from Python. Five revisions driven by an electrothermal model and a first-build audit run against the published board file, and revision 5 is final: 0 unconnected items, 0 shorts, 0 clearance violations, 0 mechanical DRC findings, every net one continuous piece of copper, fab-capable drills and annular rings, every IC bypassed within 10 mm, all fourteen simulation cases solving, and the three remaining routes to a better board each measured and found closed. Firmware now cross-compiles for Cortex-M0+ (3,096 bytes flash, 0 RAM) and passes 16 control-loop property tests against a simulated panel, converter and battery, which found and fixed eleven defects including two independent paths to holding absorption voltage on the battery indefinitely.", "dependencies": {}, "author": { "name": "TylerK",