Skip to content

Field Bucketer (Efficiency Tracker)

A 3-D matrix that records what current the alternator actually produces as a function of RPM × temperature × field voltage, then flags it when operation drifts away from established norms.

The bucketer is alongside the control loop, not part of it — it observes only. No safety logic depends on its output. Its only side effect on loop() behavior is the optional effAnomalyAlarmActive flag that the buzzer reads. Lives in 7_functions.ino; matrix size and bucket boundaries are in Xregulator.ino.


Axes and Bucket Boundaries

Axis Variable Count Boundaries Labels
RPM RPM_BOUNDS[], RPM_LABELS[] NUM_RPM_BUCKETS = 8 0 / 500 / 1000 / 1500 / 2000 / 2500 / 3000 / 4000 / 99999 "0-500", "500-1k", … "4k+"
Temperature (°F) TEMP_BOUNDS[], TEMP_LABELS[] NUM_TEMP_BUCKETS = 7 0 / 80 / 110 / 140 / 160 / 180 / 200 / 9999 "<80F", "80-110F", … ">200F"
Field voltage (V) FIELD_BOUNDS[], FIELD_LABELS[] NUM_FIELD_BUCKETS = 7 0 / 2.14 / 4.29 / 6.43 / 8.57 / 10.71 / 12.86 / 15 "0-2.1V", … "12.9-15V"

Total cells: NUM_MATRIX_CELLS = 8 × 7 × 7 = 392. MATRIX_IDX(r,t,f) = r·NUM_TEMP_BUCKETS·NUM_FIELD_BUCKETS + t·NUM_FIELD_BUCKETS + f. MATRIX_CELL(r,t,f) = effMatrix[MATRIX_IDX(r,t,f)].

"Field voltage" is computed as dutyCycle × battV / 100 — the effective DC voltage across the rotor — not the PWM voltage measured at the gate.


Three Memory Layers

All three PSRAM-resident; only one persists.

Layer Variable Lifetime Per-cell content
Window accumulator effWindow[MAX_ACTIVE_BINS_PER_WINDOW] (12 slots) Session, 2-minute rolling r, t, f, ss_seconds, wt_avg_amps, min_amps, max_amps, active
Permanent matrix effMatrix[NUM_MATRIX_CELLS] (MatrixCell) NVS-persisted (now LittleFS-persisted) ss_seconds, avg_amps, min_amps, max_amps, ref_avg_amps, ref_min_amps, ref_max_amps, is_reference_bin
Session bin stats sessionStats[NUM_MATRIX_CELLS] Session per-bin error counters

The window layer exists so that 2 minutes of 1 Hz samples roll up into one matrix update — keeps NVS / LittleFS commits rare. The matrix layer survives reboots; the session-stats layer doesn't. The window has a hard cap of 12 active bins; if operation is so erratic that more bins fire in 2 minutes, the slot-full message logs and the overflow samples skip accumulation (anomaly scoring still runs on them).


MatrixCell Field Semantics

struct MatrixCell {
    // Everlasting layer
    uint32_t ss_seconds;      // total accumulated steady-state seconds in this bin
    float    avg_amps;        // time-weighted running mean
    float    min_amps, max_amps;
    // Reference layer — captured ONCE, never re-written
    float    ref_avg_amps;
    float    ref_min_amps, ref_max_amps;
    uint8_t  is_reference_bin;  // 1 = finalized, 0 = not
};

The everlasting layer keeps moving forever. The reference layer is the frozen snapshot of "this cell looked like this when the system was healthy" — written once when a bin first becomes a reference, then preserved against contamination by later drift (which is the very thing we're trying to detect).


Tick Schedule — efficiencyTracker_tick()

Called from loop() after AdjustFieldLearnMode(). Self-timed: pulls millis() and gates each sub-task at its own interval.

Cadence Function What it does
1 Hz updateEfficiencyRedDot() Compute current (r, t, f) bucket indices and field-volts / amps for the dashboard's live red-dot marker. Sets redDotValid per checkPointValid()
1 Hz updateEfficiencyMatrix() checkPointValid() gate, then accumulate into window slot. Anomaly score this sample
5 s sendEfficiencyData() SSE event "EffMatrix" with current cell state, change-triggered (skipped if nothing changed)
5 s sendEfficiencyRedDot() SSE event "EffRed" with the live red-dot position
5 s sendEfficiencyHistory() SSE event with the 30-session efficiency-history sparkline
20 s (gated) If sessionHealthNeedsSave and !inCriticalZone(), saveCurrentSessionHealth() (single-float NVS write)
2 min mergeWindowIntoMatrix() Roll the window accumulator into effMatrix[] and clear the window
30 s printEffDiagnostics() Serial dump of the rejection breakdown (currently commented out except the counter resets — easy to re-enable)

The 1 Hz red-dot update happens regardless of checkPointValid() — it shows where the system is, even if that point doesn't qualify for accumulation.


checkPointValid(amps, battV, fieldVolts, r, t, f) — The Universal Gate

Sample-accumulation and anomaly-scoring both go through this single function. Same criteria for both: a point that doesn't qualify for accumulation also doesn't qualify for anomaly scoring against the references.

Rejection criteria (each increments its own counter for printEffDiagnostics()):

Rejection Counter Reason
dutyCycle ≤ 5% eff_reject_duty Too low — field essentially off
amps < 2 A eff_reject_amps Below sensor noise floor
isnan(battV) eff_reject_battNaN Sensor failure
battV < EFF_MIN_BATT_V (8 V) eff_reject_battLow Implausible — system not energized
RPM Δ from prior sample > 200 eff_reject_rpmDelta Not in steady state
Any of r/t/f < 0 eff_reject_bucket Out of range (shouldn't happen with current bounds)
All passed eff_pass Accumulate

r, t, f are output parameters — populated by getRPMBucket(RPM), getTempBucket(TempToUse), getFieldBucket(fieldVolts). The function uses TempToUse (already resolved from AlternatorTemperatureF or temperatureThermistor based on TempSource).


Reference-Bin Selection — selectReferenceBins()

A bin becomes a reference when it has accumulated enough steady-state seconds and the reference set as a whole hasn't been finalized yet. Once referenceFinalized = true, no new bins are added — the snapshot is locked.

Production thresholds (the source file ships with DEV-INTERMEDIATE values for testing):

Constant Dev value Production value What
NUM_REFERENCE_BINS 3 10 How many bins to finalize before locking the reference set
REF_MIN_SS_SECONDS 15 30 Per-bin minimum SS seconds to qualify
REF_FREEZE_TOTAL_SS 180 (3 min) 6000 (100 min) Total SS across qualifying bins required before finalization
REF_SPREAD_TEMP_DEG 10 50 Temperature spread across qualifying bins (centroid range) — prevents finalizing on a thermally narrow data set
REF_SPREAD_FIELD_VOLTS 1.0 3.75 Field-voltage spread
REF_SPREAD_RPM 200 1000 RPM spread

The production values mean: "after 100 minutes of total steady-state operation spanning a representative range of temp / field / RPM, freeze 10 bins as references." Dev values shorten that to ~3 minutes for testing.

Selection runs at the end of mergeWindowIntoMatrix() — every 2 minutes. If criteria are met, is_reference_bin is set on the chosen cells, ref_avg_amps/ref_min_amps/ref_max_amps are populated from the everlasting layer, and referenceFinalized = true.


Anomaly Scoring — checkAnomaly(r, t, f, amps)

Runs every 1 Hz pass that survives checkPointValid(). Two-tier classification — only fires on bins that are already reference bins.

expected = [ref_min - anomalyMarginAmps, ref_max + anomalyMarginAmps]
delta = how much outside expected:
    above_max = amps - (ref_max + anomalyMarginAmps)
    below_min = (ref_min - anomalyMarginAmps) - amps
Tier Trigger Counter Effect
1 Mild excursion (anomalous but within some inner band) sessionTier1Errors Logged; visible on dashboard
2 Major excursion outside that band sessionTier2Errors Logged; counts toward alarm

When sessionTier1Errors + sessionTier2Errors ≥ anomalyAlarmThreshold AND anomalyAlarmEnable == true:

effAnomalyAlarmActive = true;

The buzzer (driven by CheckAlarms) then sounds — see Safeties → Alarm Buzzer. effAnomalyAlarmActive is cleared by resetEfficiencyMatrix() (the "Start Over" button) or by enable-toggle.

User-tunable thresholds (LittleFS-persisted):

  • anomalyMarginAmps (5 A) — extra tolerance on the [ref_min, ref_max] band
  • anomalyAlarmThreshold (5) — session error count before alarm fires
  • anomalyAlarmEnable (false) — master enable for the buzzer

Save / Load Cadence

Persistent layer (matrix only)

saveEfficiencyMatrix() writes the MatrixCell[] array to LittleFS. It does not ride the 20 s tick anymore — the matrix is too big to flush periodically. Saves run at:

  1. Field-off + 13 s (fieldOffMatrixFlushDone in loop()) — staggered after the NVS commit at +5 s so the two writes don't collide.
  2. Shutdown Phase 2 (pendingShutdownFlush path) — captures the most recent state on ignition-off.

loadEfficiencyMatrix() runs once in initEfficiencyTracker() at boot.

Session-health snapshot (single float)

sessionHealthSum / sessionHealthCount produces one float per session — the ratio of reference-bin samples whose amps landed within the reference band. Saved as eff_cs in NVS via saveCurrentSessionHealth(). Read into effHistory[] for the 30-session sparkline (EFF_HISTORY_SESSIONS = 30).

History rotation

effHistory.values[EFF_HISTORY_SESSIONS] is a ring of the last 30 session-health ratios. Saved to LittleFS (saveEffHistory()) when a new session ends or resetEfficiencyMatrix() is called.


SSE Output Formats

"EffMatrix" event — 5 s, change-triggered

state, rBucket, tBucket, fBucket, rLabel, tLabel, fLabel,
ss_seconds, avg_amps, min_amps, max_amps, is_reference_bin, totalErrors

state: - 0 — weak / empty (less than REF_MIN_SS_SECONDS accumulated) - 1 — populated but not a reference bin (low confidence) - 2 — finalized reference bin (full anomaly scoring active)

"EffRed" event — 5 s

redDotValid, redDot_fieldVolts, redDot_amps, activeRPMBucket, activeTempBucket, activeFieldBucket

Drives the live red-dot marker overlaid on the matrix display.

"EffHistory" event (from sendEfficiencyHistory())

Pushes the 30-session effHistory.values[] ring for the sparkline display.


HTTP Endpoints (read-only data dumps)

Endpoint Format What
GET /effmatrix.csv CSV (NUM_MATRIX_CELLS rows) Full matrix dump — chunked response, no live-mutation pause needed
GET /effmatrixstats JSON sessionStats summary stats
POST /resetEfficiencyMatrix (via /get handler) Sets pendingResetEfficiencyMatrix = true; the actual resetEfficiencyMatrix() runs from the deferred-I/O block in loop() once safeToFlushIO() is satisfied

Why "Field Voltage" Instead of "Duty Cycle" on the F Axis

A duty cycle is meaningless without the battery voltage that drives the field. At 50 % duty on a 12 V system, the field sees 6 V; at 50 % duty on a 48 V system, it sees 24 V — wildly different operating points. Bucketing on vvout = duty × battV / 100 normalizes the rotor's actual excitation across system voltages, so a 24 V cruise and a 12 V cruise at the same field voltage produce comparable output amps. (This also means switching system class without re-establishing the reference set will instantly look like an anomaly across the board — that's working as intended.)


Cross-references