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]bandanomalyAlarmThreshold(5) — session error count before alarm firesanomalyAlarmEnable(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:
- Field-off + 13 s (
fieldOffMatrixFlushDoneinloop()) — staggered after the NVS commit at +5 s so the two writes don't collide. - Shutdown Phase 2 (
pendingShutdownFlushpath) — 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¶
- Buzzer wiring + alarm aggregation → Safeties → Alarm Buzzer
- Field-off save cadence + critical-zone gate → System Overview → Storage Strategy
- Active red-dot rendering, history sparkline → Client → JavaScript Logic