main
John Lauer Studio: project overview, how it works, lights, charging, battery life, firmware source, dashboard screenshots; license… c2eaf61 3d ago
/*
 * test_learn.c : host-side tests of src/learn.c against simulated faucets
 *
 * Build and run:  make -C test   (or: gcc -O2 -Wall -I../src test_learn.c ../src/learn.c)
 *
 * The faucet model (one interrogation poll at a time):
 *   IDLE     water off, polls every P_idle. A reflection -> CONFIRM.
 *   CONFIRM  polls at the active rate; a second reflection -> ACTIVE (water on,
 *            safety timer starts), otherwise back to IDLE.
 *   ACTIVE   water on. Reflection: if the timer reached T -> LOCKOUT (water off).
 *            miss_n polls in a row without a reflection -> RUNON.
 *   RUNON    water still on for D ms (run-on). A reflection -> ACTIVE with the
 *            timer restarted (a "new user"), else after D -> IDLE (water off).
 *   LOCKOUT  timed out, water off, the reflection is treated as a still object;
 *            miss_n missed polls -> IDLE.
 * Faucets "without observable rate change" poll at P_idle in every state.
 * Each poll period has +/-3 % pseudo-random jitter.
 *
 * The harness reproduces the firmware's timing: pulses are timestamped when
 * they happen but handed to the learner on the next 16 ms main-loop tick, and
 * the echo enable only changes on ticks. Each session is 10 min (the cap) with
 * a 2 s listen first; the learner's store is carried between sessions exactly
 * like the EEPROM.
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "learn.h"

#define SESSION_MS   600000u
#define TICK_MS      16u
#define N_SESSIONS   6

typedef enum { F_IDLE, F_CONFIRM, F_ACTIVE, F_RUNON, F_LOCKOUT } fstate_t;

typedef struct {
    const char *name;
    uint32_t T;         /* safety timeout, ms */
    uint32_t p_idle, p_act;
    int      rate_change;
    int      miss_n;    /* missed polls before "hand gone" */
    uint32_t D;         /* run-on after hand gone, ms */
    int      runon_fast;/* polls at the active rate during run-on */
} faucet_cfg_t;

typedef struct {
    const faucet_cfg_t *c;
    fstate_t st;
    uint32_t next_poll, t_on, t_gone;
    int hits, misses;
    int water;
} faucet_t;

static uint32_t rng = 12345;
static uint32_t rnd(void) { rng = rng * 1103515245u + 12345u; return (rng >> 8) & 0xFFFF; }
static uint32_t jit(uint32_t p) { return p - p * 3 / 100 + (uint32_t)((uint64_t)p * 6 / 100 * rnd() / 0xFFFF); }

static uint32_t period(const faucet_t *f)
{
    if (!f->c->rate_change) return f->c->p_idle;
    switch (f->st) {
    case F_CONFIRM: case F_ACTIVE: return f->c->p_act;
    case F_RUNON: return f->c->runon_fast ? f->c->p_act : f->c->p_idle;
    default: return f->c->p_idle;
    }
}

static void faucet_reset(faucet_t *f, const faucet_cfg_t *c, uint32_t t)
{
    memset(f, 0, sizeof *f);
    f->c = c; f->st = F_IDLE; f->next_poll = t + rnd() % c->p_idle;
}

/* one poll at time t, present = our echo answered it */
static void faucet_poll(faucet_t *f, uint32_t t, int present)
{
    switch (f->st) {
    case F_IDLE:
        if (present) { f->st = F_CONFIRM; f->hits = 1; }
        break;
    case F_CONFIRM:
        if (present) { f->st = F_ACTIVE; f->water = 1; f->t_on = t; f->misses = 0; }
        else f->st = F_IDLE;
        break;
    case F_ACTIVE:
        if (present) {
            f->misses = 0;
            if (t - f->t_on >= f->c->T) { f->st = F_LOCKOUT; f->water = 0; }
        } else if (++f->misses >= f->c->miss_n) {
            f->misses = 0;
            if (f->c->D) { f->st = F_RUNON; f->t_gone = t; }
            else { f->st = F_IDLE; f->water = 0; }
        }
        break;
    case F_RUNON:
        if (present) { f->st = F_ACTIVE; f->t_on = t; f->misses = 0; }
        else if (t - f->t_gone >= f->c->D) { f->st = F_IDLE; f->water = 0; }
        break;
    case F_LOCKOUT:
        if (present) f->misses = 0;
        else if (++f->misses >= f->c->miss_n) { f->st = F_IDLE; f->misses = 0; }
        break;
    }
    f->next_poll = t + jit(period(f));
}

typedef struct {
    int drops;              /* water on -> off transitions after the first on */
    uint32_t off_ms;        /* water off time after the first on */
    uint32_t first_on;
    uint32_t drop_t[64];
} sess_stats_t;

/* run one session; returns stats, updates store */
static void run_session(const faucet_cfg_t *c, learn_store_t *store, sess_stats_t *st,
                        learn_t *L, int verbose)
{
    faucet_t f;
    learn_listen_t ls;
    uint32_t t = 0, q[64]; int nq = 0;
    memset(st, 0, sizeof *st);
    faucet_reset(&f, c, 0);

    /* listen 2 s (extend to 5 s while fewer than 3 pulses), echo off */
    learn_listen_init(&ls);
    for (t = 0; t < 5000; t++) {
        if (t >= f.next_poll) { faucet_poll(&f, t, 0); learn_listen_pulse(&ls, t); }
        if (t >= 2000 && ls.n_pulses >= 3) break;
    }
    uint16_t pid = learn_listen_period(&ls);
    learn_begin(L, store, pid, t);

    int echo = learn_echo_on(L), water_prev = 0, ever_on = 0;
    for (; t < SESSION_MS; t++) {
        if (t >= f.next_poll) {
            faucet_poll(&f, t, echo);
            if (nq < 64) q[nq++] = t;
        }
        if (t % TICK_MS == 0) {
            for (int i = 0; i < nq; i++) learn_pulse(L, q[i]);
            nq = 0;
            learn_tick(L, t);
            uint8_t ev = learn_take_events(L);
            if (ev & LEARN_EV_STORE_DIRTY) *store = *learn_store(L);
            if (verbose && (ev & (LEARN_EV_BLIP_OK | LEARN_EV_BLIP_MISS | LEARN_EV_TIMEOUT_LEARNT | LEARN_EV_MODE)))
                printf("      t=%6.1fs %s%s%s%s timeout=%u blip=%u floor=%u margin=%u\n", t / 1000.0,
                       ev & LEARN_EV_MODE ? "[mode] " : "", ev & LEARN_EV_TIMEOUT_LEARNT ? "[timeout] " : "",
                       ev & LEARN_EV_BLIP_OK ? "[blip ok] " : "", ev & LEARN_EV_BLIP_MISS ? "[blip MISS] " : "",
                       L->s.timeout_ms, L->s.blip_ms, L->s.blip_floor_ms, L->s.margin_ms);
            echo = learn_echo_on(L);
        }
        if (f.water && !ever_on) { ever_on = 1; st->first_on = t; }
        if (ever_on) {
            if (!f.water) st->off_ms++;
            if (water_prev && !f.water) { if (st->drops < 64) st->drop_t[st->drops] = t; st->drops++; }
        }
        water_prev = f.water;
    }
    *store = *learn_store(L);
}

typedef struct { int fails; int checks; } tally_t;
static void check(tally_t *ty, int ok, const char *what)
{
    ty->checks++;
    if (!ok) { ty->fails++; printf("    FAIL: %s\n", what); }
    else printf("    pass: %s\n", what);
}

static void run_faucet(const faucet_cfg_t *c, learn_store_t *store, tally_t *ty, int fresh, int verbose)
{
    learn_t L;
    sess_stats_t st;
    learn_store_t s0;
    int late_drops = 0;
    int floor_session = -1;
    if (fresh) learn_store_defaults(store);
    s0 = *store;
    (void)s0;

    printf("\n=== %s: T=%us idle=%ums active=%ums rate_change=%s release=%d polls run-on=%ums ===\n",
           c->name, c->T / 1000, c->p_idle, c->p_act, c->rate_change ? "yes" : "no", c->miss_n, c->D);
    for (int s = 0; s < N_SESSIONS; s++) {
        uint16_t floor_before = store->blip_floor_ms;
        run_session(c, store, &st, &L, verbose && s == 0);
        printf("  session %d: mode=%s drops=%d water-off=%.1fs (after first on at %.1fs) blips=%u ok=%u miss=%u"
               " -> timeout=%.2fs blip=%ums floor=%ums margin=%ums\n",
               s + 1, L.mode == LEARN_MODE_TRACK ? "TRACK" : L.mode == LEARN_MODE_FALLBACK ? "FALLBACK" : "CLASSIFY",
               st.drops, st.off_ms / 1000.0, st.first_on / 1000.0, L.n_blips, L.n_ok, L.n_miss,
               store->timeout_ms / 1000.0, store->blip_ms, store->blip_floor_ms, store->margin_ms);
        if (st.drops) {
            printf("      drops at:");
            for (int i = 0; i < st.drops && i < 12; i++) printf(" %.1fs", st.drop_t[i] / 1000.0);
            printf("\n");
        }
        if (floor_session < 0 && store->blip_floor_ms) floor_session = s;
        if (s >= 3) late_drops += st.drops;
        else if (floor_before && s > floor_session) late_drops += st.drops;
    }

    if (c->rate_change) {
        int32_t err = (int32_t)store->timeout_ms - (int32_t)c->T;
        uint32_t tol = 2 * c->p_act + 150;
        char buf[200];
        check(ty, L.mode == LEARN_MODE_TRACK, "rate change detected (TRACK mode)");
        snprintf(buf, sizeof buf, "timeout estimate %.2f s within %.2f s of the true %.0f s (error %+d ms)",
                 store->timeout_ms / 1000.0, tol / 1000.0, c->T / 1000.0, err);
        check(ty, (uint32_t)abs(err) <= tol, buf);
        uint32_t rel = (uint32_t)c->miss_n * c->p_act;     /* release threshold in time */
        uint32_t hi = rel + 2 * c->p_act + 100;
        snprintf(buf, sizeof buf, "blip %u ms converged near the release threshold %u ms (window %u..%u)",
                 store->blip_ms, rel, rel, hi);
        check(ty, store->blip_ms >= rel && store->blip_ms <= hi, buf);
        snprintf(buf, sizeof buf, "no water drop after learning (sessions after the floor was found, and 4..%d): %d",
                 N_SESSIONS, late_drops);
        check(ty, late_drops == 0, buf);
    } else {
        char buf[160];
        check(ty, L.mode == LEARN_MODE_FALLBACK, "no rate change -> FALLBACK (re-arm every 8 s)");
        snprintf(buf, sizeof buf, "fallback keeps the water on: drops in sessions 4..%d = %d", N_SESSIONS, late_drops);
        check(ty, late_drops == 0, buf);
        snprintf(buf, sizeof buf, "last session water off %.1f s of %.0f s", st.off_ms / 1000.0, (SESSION_MS - st.first_on) / 1000.0);
        check(ty, st.off_ms * 50 < SESSION_MS - st.first_on, buf);
    }
}

int main(int argc, char **argv)
{
    int verbose = argc > 1 && !strcmp(argv[1], "-v");
    tally_t ty = { 0, 0 };
    learn_store_t store;

    static const faucet_cfg_t faucets[] = {
        { "TOTO-like 10 s",            10000,  500, 100, 1, 2, 1000, 1 },
        { "Moen-like 30 s",            30000,  800,  80, 1, 3,  800, 1 },
        { "Selectronic-like 55 s",     55000, 1500, 125, 1, 2, 1500, 1 },
        { "60 s, 2 s idle, 50 ms act", 60000, 2000,  50, 1, 4, 1000, 1 },
        { "30 s, run-on at idle rate", 30000,  600, 100, 1, 2, 1200, 0 },
        { "30 s, no rate change",      30000,  250, 250, 0, 2, 1000, 1 },
        { "10 s, no rate change",      10000,  300, 300, 0, 2, 1200, 1 },
    };
    for (unsigned i = 0; i < sizeof faucets / sizeof faucets[0]; i++)
        run_faucet(&faucets[i], &store, &ty, 1, verbose);

    /* moved to another faucet with the same idle period (fingerprint matches):
     * the stored 55 s estimate must come down after one drop */
    static const faucet_cfg_t a = { "swap: learnt on 55 s ...",   55000, 500, 100, 1, 2, 1000, 1 };
    static const faucet_cfg_t b = { "... then moved to a 10 s faucet", 10000, 500, 100, 1, 2, 1000, 1 };
    run_faucet(&a, &store, &ty, 1, 0);
    run_faucet(&b, &store, &ty, 0, verbose);

    /* EEPROM image round trip */
    learn_store_t cp = store;
    check(&ty, learn_store_valid(&cp), "store CRC valid");
    cp.blip_ms ^= 1;
    check(&ty, !learn_store_valid(&cp), "corrupted store rejected");

    printf("\n%d checks, %d failed\n", ty.checks, ty.fails);
    return ty.fails ? 1 : 0;
}