main
TylerK Update 20 file(s) fb8969c 1mo ago
/*
 * 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;
}