Skip to content

// ============================================================ // CONFIGURATION - change bucket boundaries here only // ============================================================

// RPM bucket boundaries - 8 buckets, finer at low end // Format: {lower_bound, lower_bound, ...}, size = NUM_RPM_BUCKETS + 1

define NUM_RPM_BUCKETS 8

const float RPM_BOUNDS[NUM_RPM_BUCKETS + 1] = { 0, 500, 1000, 1500, 2000, 2500, 3000, 4000, 99999 }; // Labels for JS display (send with grid metadata) const char* RPM_LABELS[NUM_RPM_BUCKETS] = { "0-500", "500-1k", "1k-1.5k", "1.5k-2k", "2k-2.5k", "2.5k-3k", "3k-4k", "4k+" };

// Temp bucket boundaries - 3 buckets (°F) // Easy to add more later - just change define and arrays

define NUM_TEMP_BUCKETS 3

const float TEMP_BOUNDS[NUM_TEMP_BUCKETS + 1] = { 0, 120, 170, 9999 }; const char* TEMP_LABELS[NUM_TEMP_BUCKETS] = { "Cold(<120F)", "Normal(120-170F)", "Hot(>170F)" };

define MAX_BLUE_DOTS 20

// ============================================================ // DATA STRUCTURES // ============================================================

struct FieldEfficiencyPoint { float field_volts; // vvout: duty * battV / 100 float output_amps; // MeasuredAmps };

struct EfficiencyCell { FieldEfficiencyPoint points[MAX_BLUE_DOTS]; uint8_t count; // 0..MAX_BLUE_DOTS, frozen at MAX_BLUE_DOTS // No head pointer - frozen when full, no circular buffer };

// Grid lives in PSRAM - 8 * 3 * (208 + 1) bytes = ~3.9KB // Declare as pointer, allocate from ps_malloc in setup() EfficiencyCell efficiencyGrid = nullptr; // Access macro: efficiencyGrid[rpmBucket * NUM_TEMP_BUCKETS + tempBucket]

define EFF_CELL(r, t) efficiencyGrid[(r) * NUM_TEMP_BUCKETS + (t)]

// Live red dot state (updated at webgaugesinterval) float redDot_fieldVolts = 0; float redDot_amps = 0; int activeRPMBucket = -1; int activeTempBucket = -1; bool redDotValid = false; // false until alternator is actually on

// ============================================================ // SETUP // ============================================================

void initEfficiencyTracker() { // Allocate from PSRAM size_t gridSize = NUM_RPM_BUCKETS * NUM_TEMP_BUCKETS * sizeof(EfficiencyCell) efficiencyGrid = (EfficiencyCell*)ps_malloc(gridSize) if (!efficiencyGrid) { queueConsoleMessage("ERROR: efficiency grid alloc failed") return } memset(efficiencyGrid, 0, gridSize)

// Try to load persisted data
loadEfficiencyGrid()

// Log how full each cell is (useful at startup)
int totalPoints = 0
int fullCells = 0
for r in 0..NUM_RPM_BUCKETS-1:
    for t in 0..NUM_TEMP_BUCKETS-1:
        int n = EFF_CELL(r,t).count
        totalPoints += n
        if n >= MAX_BLUE_DOTS: fullCells++
queueConsoleMessageF("EffTracker loaded: %d points, %d/%d cells full",
                     totalPoints, fullCells,
                     NUM_RPM_BUCKETS * NUM_TEMP_BUCKETS)

}

// ============================================================ // BUCKET RESOLUTION // ============================================================

int getRPMBucket(float rpm) { if rpm < 0: return -1 for i in 0..NUM_RPM_BUCKETS-1: if rpm >= RPM_BOUNDS[i] && rpm < RPM_BOUNDS[i+1]: return i return -1 }

int getTempBucket(float tempF) { // If no temp sensor, default to Normal bucket // Callers should check IgnoreTemperature before calling, // but default here is safe if isnan(tempF) || IgnoreTemperature: return 1 for i in 0..NUM_TEMP_BUCKETS-1: if tempF >= TEMP_BOUNDS[i] && tempF < TEMP_BOUNDS[i+1]: return i return -1 }

// ============================================================ // STEADY STATE DETECTION // ============================================================ // Call at 1Hz. Returns true when all inputs have been // stable for REQUIRED_PASSES consecutive seconds.

static struct { float prevRPM = 0 float prevDuty = 0 float prevBattV = 0 float prevTempF = 0 int consecutivePasses = 0

const float RPM_TOL   = 75.0   // RPM
const float DUTY_TOL  = 2.0    // %
const float BATTV_TOL = 0.1    // V
const float TEMP_TOL  = 2.0    // °F
const int   REQUIRED_PASSES = 3

} ss;

bool checkSteadyState() { float currentTempF = IgnoreTemperature ? ss.prevTempF : TempToUse

bool stable = (
    abs(RPM        - ss.prevRPM)   < ss.RPM_TOL   &&
    abs(dutyCycle  - ss.prevDuty)  < ss.DUTY_TOL  &&
    abs(getBatteryVoltage() - ss.prevBattV) < ss.BATTV_TOL &&
    (IgnoreTemperature || abs(TempToUse - ss.prevTempF) < ss.TEMP_TOL)
)

ss.prevRPM   = RPM
ss.prevDuty  = dutyCycle
ss.prevBattV = getBatteryVoltage()
ss.prevTempF = IgnoreTemperature ? 0 : TempToUse

if stable:
    ss.consecutivePasses++
else:
    ss.consecutivePasses = 0

return ss.consecutivePasses >= ss.REQUIRED_PASSES

}

// ============================================================ // RED DOT UPDATE - call at webgaugesinterval from SendWifiData // ============================================================

void updateEfficiencyRedDot() { if !efficiencyGrid: return

activeRPMBucket  = getRPMBucket(RPM)
activeTempBucket = getTempBucket(TempToUse)

// Red dot valid only when alternator is meaningfully on
redDotValid = (dutyCycle > 5.0 && MeasuredAmps > 2.0 &&
               activeRPMBucket >= 0 && activeTempBucket >= 0)

if redDotValid:
    redDot_fieldVolts = vvout        // duty * battV / 100, already computed
    redDot_amps       = MeasuredAmps

}

// ============================================================ // BLUE DOT UPDATE - call at 1Hz from loop() // ============================================================

void updateEfficiencyBlueDots() { if !efficiencyGrid: return if !checkSteadyState(): return

// Gate: alternator meaningfully on
if dutyCycle < 5.0 || MeasuredAmps < 2.0: return

// Gate: valid buckets
if activeRPMBucket < 0 || activeTempBucket < 0: return

// Gate: cell not yet full (frozen-when-full policy)
EfficiencyCell &cell = EFF_CELL(activeRPMBucket, activeTempBucket)
if cell.count >= MAX_BLUE_DOTS: return

// Write new point
cell.points[cell.count].field_volts = vvout
cell.points[cell.count].output_amps = MeasuredAmps
cell.count++

if cell.count >= MAX_BLUE_DOTS:
    queueConsoleMessageF("EffTracker: RPM bucket %d / Temp bucket %d now full",
                         activeRPMBucket, activeTempBucket)

}

// ============================================================ // RESET // ============================================================

void resetEfficiencyGrid() { if !efficiencyGrid: return memset(efficiencyGrid, 0, NUM_RPM_BUCKETS * NUM_TEMP_BUCKETS * sizeof(EfficiencyCell)) saveEfficiencyGrid() queueConsoleMessage("Efficiency tracker reset - all blue dots cleared") }

// ============================================================ // PERSIST // ============================================================

void saveEfficiencyGrid() { if !efficiencyGrid: return nvs_handle_t handle if nvs_open("effgrid", NVS_READWRITE, &handle) != ESP_OK: return size_t sz = NUM_RPM_BUCKETS * NUM_TEMP_BUCKETS * sizeof(EfficiencyCell) nvs_set_blob(handle, "grid", efficiencyGrid, sz) nvs_commit(handle) nvs_close(handle) }

void loadEfficiencyGrid() { if !efficiencyGrid: return nvs_handle_t handle if nvs_open("effgrid", NVS_READONLY, &handle) != ESP_OK: return size_t sz = NUM_RPM_BUCKETS * NUM_TEMP_BUCKETS * sizeof(EfficiencyCell) esp_err_t err = nvs_get_blob(handle, "grid", efficiencyGrid, &sz) nvs_close(handle)

if err != ESP_OK || sz != NUM_RPM_BUCKETS * NUM_TEMP_BUCKETS * sizeof(EfficiencyCell):
    // Size mismatch means bucket config changed - start fresh
    memset(efficiencyGrid, 0,
           NUM_RPM_BUCKETS * NUM_TEMP_BUCKETS * sizeof(EfficiencyCell))
    queueConsoleMessage("EffTracker: NVS size mismatch - starting fresh (bucket config changed?)")

// ============================================================ // SEND TO CLIENT // ============================================================

// --- Red dot: send at webgaugesinterval, inside SendWifiData --- // Pack into existing CSVData or send as dedicated event. // Dedicated event is cleaner since this has variable validity. // // Format: "valid,field_volts,amps,rpm_bucket,temp_bucket" // JS NOTE: red dot should always render if valid=1, even if not // in steady state. Throttle rendering to maybe 4Hz in JS if // webgaugesinterval is very fast - no need to redraw faster than that.

void sendEfficiencyRedDot() { char buf[64] snprintf(buf, sizeof(buf), "%d,%.2f,%.2f,%d,%d", redDotValid ? 1 : 0, redDot_fieldVolts, redDot_amps, activeRPMBucket, activeTempBucket) events.send(buf, "EffRed") }

// --- Blue dots: send at 5-second interval, only when cell changes --- // Only send the active cell. No need to send all cells - // cell changes when RPM or temp bucket changes, which is infrequent. // Cache last sent bucket pair to avoid redundant sends. // // Format: header + points // "rpm_bucket,temp_bucket,rpm_label,temp_label,count|fv1,a1|fv2,a2|..." // // JS NOTE: // - Parse header (everything before first '|') to get bucket context // and labels for axis titles / legend // - Parse remaining '|' delimited pairs as blue dot coordinates // - On bucket change (rpm_bucket or temp_bucket differs from last render), // clear the blue dot series entirely before plotting new ones // - Blue dots are static once received for a given bucket - // no need to re-render unless bucket changes or reset received // - On "EffReset" event (send after resetEfficiencyGrid()), // clear all blue dots in JS regardless of current bucket

void sendEfficiencyBlueDots() { if !efficiencyGrid: return if activeRPMBucket < 0 || activeTempBucket < 0: return

static int lastSentRPMBucket  = -1
static int lastSentTempBucket = -1
static uint8_t lastSentCount  = 255  // force send on first call

EfficiencyCell &cell = EFF_CELL(activeRPMBucket, activeTempBucket)

// Only resend if bucket changed or new points added
bool bucketChanged = (activeRPMBucket  != lastSentRPMBucket ||
                      activeTempBucket != lastSentTempBucket)
bool newPoints     = (cell.count != lastSentCount)

if !bucketChanged && !newPoints: return

// Build payload
// Header
char buf[600]   // 20 points * ~16 chars + header ~60 chars = ~380 chars, 600 is safe
int offset = snprintf(buf, sizeof(buf),
                      "%d,%d,%s,%s,%d|",
                      activeRPMBucket,
                      activeTempBucket,
                      RPM_LABELS[activeRPMBucket],
                      TEMP_LABELS[activeTempBucket],
                      cell.count)

// Points
for i in 0..cell.count-1:
    offset += snprintf(buf + offset, sizeof(buf) - offset,
                       "%.2f,%.2f|",
                       cell.points[i].field_volts,
                       cell.points[i].output_amps)

if offset >= sizeof(buf) - 1:
    queueConsoleMessage("WARNING: EffBlue payload truncated")

events.send(buf, "EffBlue")

lastSentRPMBucket  = activeRPMBucket
lastSentTempBucket = activeTempBucket
lastSentCount      = cell.count

}

// ============================================================ // WIRE INTO EXISTING CODE - summary of touch points // ============================================================

// setup(): // initEfficiencyTracker() // after LittleFS and NVS init

// loop() 1Hz timer (same as SOC update or similar): // updateEfficiencyBlueDots()

// SendWifiData() - inside the webgaugesinterval block: // updateEfficiencyRedDot() // sendEfficiencyRedDot()

// SendWifiData() - inside a 5-second block: // sendEfficiencyBlueDots()

// saveNVSData() - piggyback on existing periodic save: // saveEfficiencyGrid()

// /get handler - new parameter: // if request->hasParam("ResetEfficiencyGrid"): // resetEfficiencyGrid() // events.send("reset", "EffReset") // JS clears blue dots on this event

// NVS size note: // sizeof(EfficiencyCell) = 20 * 8 + 1 = 161 bytes // Full grid = 8 * 3 * 161 = 3,864 bytes as NVS blob // Well within NVS limits One flag on NVS blob size Your existing NVS save uses individual keys per variable. This grid at ~3.9KB as a single blob is fine for NVS — ESP32 NVS supports blobs up to the page size which is typically 4KB per entry. You're just under that. If you ever expand to more buckets or more points, split into per-RPM-bucket blobs ("grid_0" through "grid_7") so you never hit the limit. One architectural note The size mismatch check on load is important — if you ever change NUM_RPM_BUCKETS, NUM_TEMP_BUCKETS, or MAX_BLUE_DOTS, the persisted blob will be the wrong size and the load will silently produce garbage if you don't catch it. The check as written detects this and starts fresh, which is the right behavior. Worth logging clearly so you know why your historical data disappeared after a firmware change.