← Commit history
README.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",