Skip to content

Session Notes — Alternator Field Controller (Xregulator)

System Overview

ESP32-based alternator field controller. PWM duty cycle on a field winding controls charging current. Two cascaded PID loops regulate current while protecting against thermal damage.

Code is split across multiple .ino files. Upload 5_optional.ino each session. The notes below cover everything that does NOT live in 5_optional.ino.


Two-Loop Control Architecture

Inner Loop — Current PID (currentPID)

  • Fast: runs every CH1 ADC sample (~16Hz, gated by ch1FreshFlag)
  • Input: pidInput = measured current (A), from getTargetAmps() or getBatteryCurrent()
  • Setpoint: pidSetpoint = setpointLimited (amps), rate-limited version of uTargetAmps
  • Output: pidOutput = duty cycle (%), fed through governor_apply()
  • Mode: P_ON_E (proportional on error), DIRECT

Outer Loop — Temperature PID (tempPID)

  • Slow: runs every TempPIDIntervalMs (5000ms), cadence controlled entirely by library's internal timer (SetSampleTime((int)TempPIDIntervalMs))
  • Input: tempPIDInput_d = IIR-filtered temperature (degF)
  • Setpoint: tempPIDSetpoint_d = TemperatureLimitF - TempPIDMarginF
  • Output: thermalPenaltyAmps_d / thermalPenaltyAmps = amps subtracted from RPM target table
  • Mode: REVERSE (rising temperature increases penalty output)
  • Called from tempPID_tick() inside AdjustFieldLearnMode() in the AUTO branch

Command Architecture (how the loops connect)

getTargetCurrentForRPM(RPM)        <- RPM target table (user-configured steady-state)
    |
    subtract thermalPenaltyAmps    <- outer temp PID penalty (0 = no restriction)
    |
    fminf(getCapCurrentForRPM(RPM)) <- RPM cap table (mechanical/electrical ceiling)
    |
    fminf(MaximumAllowedBatteryAmps)
    |
    fminf(MaxTableValue)
    = uTargetAmps
    |
    HiLow / ForceFloat overrides
    |
    voltage cap (absorption/float)  <- voltageCapAmps = vError * VoltageKp
    |
    setpoint slew limiter           <- SetpointRiseRate / SetpointFallRate
    = setpointLimited
    |
    currentPID.Compute()            <- inner loop
    |
    governor_apply()                <- clamp + slew + PWM write

Why penalty-relative instead of absolute ceiling

The outer PID output is a thermal penalty in amps, not an absolute current ceiling. This is critical for correct behavior across RPM changes.

With an absolute ceiling: if thermals have reduced current to 50A at 1000 RPM, and RPM changes to 1500 RPM (target = 150A), the ceiling of 50A is still 50A — the thermal state transfers incorrectly because the operating point shifted but the ceiling didn't.

With penalty-relative: the 50A penalty subtracts from the new 150A target, giving 100A. The system correctly carries thermal state across RPM changes because the penalty represents how much to reduce from whatever the target is, not an absolute number.

The outer PID penalty does NOT persist across power cycles. It lives in RAM and resets to zero on boot. The outer loop rebuilds naturally from temperature as the alternator heats up. Given the thermal time constants involved (tens of minutes), this is acceptable.


Three-Stage Charging Architecture — CC/CV/Float

Battery chemistry is lithium. No SOC information should be assumed (SOC is a bonus helper only, used only in rebulk decisions).

Stage Definitions

Bulk (CC): Voltage loop OFF. Push target current from RPM table. Exit when voltage has been held at BulkVoltage continuously for bulkVoltageHoldMs. Debounce prevents transient spikes from triggering early absorption entry.

Absorption (CV): Voltage loop ON, holding AbsorptionVoltage. Battery dictates accepted current — tapers naturally as battery fills. Exit when: - Bcur <= TailCurrent_A continuously for absorptionCompleteTime — battery full, OR - Time in absorption exceeds AbsorptionTimeoutMs — safety timeout

Float: Voltage loop ON, holding FloatVoltage. Rebulk on voltage sag confirmed for rebulkDebounceTime after MinFloatTime has elapsed, or when FLOAT_DURATION expires. SOC gating of rebulk unchanged.

Key Behavior

  • voltageControlActive = (!inBulkStage || inAbsorptionStage)
  • ChargingVoltageTarget is set by updateChargingStage() every tick for all three stages
  • Any fault that interrupts charging resets to bulk on AUTO re-entry via enter_sys_auto()
  • enter_sys_auto() always sets inBulkStage = true, inAbsorptionStage = false

Table Architecture

RPM Target Table (rpmCurrentTable)

  • User-configured steady-state current target per RPM bucket
  • Represents how hard the user's specific alternator can be driven at each RPM under normal thermal conditions
  • This is the operating point the system aims for when penalty is zero
  • Saved to NVS, persists across power cycles

RPM Cap Table (rpmCapCurrentTable)

  • User-configured hard current ceiling per RPM bucket
  • Represents installation mechanical/electrical limits: belt load, shaft stress, ratings
  • Applied after thermal penalty — cannot be exceeded regardless of other state
  • Set once based on installation limits, rarely changed
  • Saved to NVS, persists across power cycles

RPM Minimum Duty Table (rpmMinDutyTable)

  • RPM-dependent minimum duty cycle
  • Protects tachometer signal coupling capacitor
  • Applied inside governor_apply()

PID Library — PID_v1_xeng (XENG fork of Brett Beauregard's library)

Files: PID_v1_xeng.cpp / PID_v1_xeng.h

Key behavioral facts

Compute() timing: No external gate needed. Library has internal timer controlled by SetSampleTime(). Returns false until interval elapsed, then fires and returns true. Do NOT add an external gate — causes double-timer bug.

Outer loop mode is REVERSE: Rising temperature = rising input = output (penalty) increases. This is the correct sense for a penalty: more heat → more penalty → less current.

Derivative is on measurement, not error:

double dInput = (input - lastInput);
output += outputSum - effectiveKd * dInput;
For the outer loop in REVERSE mode, rising temperature increases penalty naturally.

Integral is outputSum directly. outputSum is the running accumulator. P + I + D == lastUnsatOutput (before output clamping).

dt scaling: ki and kd are pre-baked to SampleTime at SetTunings() time. Do NOT call SetSampleTime() repeatedly — rescales pre-baked gains each call.

timeChange > 2000 guard: Compute() rejects calls where dt > 2000ms. Outer loop uses SetSampleTime(TempPIDIntervalMs) so the library fires itself at the right time.

Back-calculation anti-windup (TrackAppliedOutput): Called in tempPID_tick() anti-windup block. Compares applied output against lastUnsatOutput and adjusts outputSum. Gated by outer error sign — see anti-windup section below.

Term accessors:

double GetPterm();   // kp * error
double GetIterm();   // outputSum (integral accumulator)
double GetDterm();   // -effectiveKd * dInput (derivative on measurement)

Constructor calls (in main .ino)

// Inner current loop
PID currentPID(&pidInput, &pidOutput, &pidSetpoint, PidKp, PidKi, PidKd, DIRECT);

// Outer temperature loop — REVERSE mode, output is penalty amps
PID tempPID(&tempPIDInput_d, &thermalPenaltyAmps_d, &tempPIDSetpoint_d,
            TempPIDKp, TempPIDKi, TempPIDKd, REVERSE);

Globals NOT in 5_optional.ino

Inner loop PID

double pidInput = 0.0;
double pidOutput = 0.0;
double pidSetpoint = 0.0;
PID currentPID(&pidInput, &pidOutput, &pidSetpoint, PidKp, PidKi, PidKd, DIRECT);
bool pidInitialized = false;
float pidError = 0.0f;         // setpointLimited - targetCurrent, for display

Outer temperature loop

// thermalPenaltyAmps_d / thermalPenaltyAmps MUST be declared BEFORE the PID constructor
double thermalPenaltyAmps_d = 0.0;   // PID library output variable (double)
float  thermalPenaltyAmps   = 0.0f;  // float copy used in command architecture

PID tempPID(&tempPIDInput_d, &thermalPenaltyAmps_d, &tempPIDSetpoint_d,
            TempPIDKp, TempPIDKi, TempPIDKd, REVERSE);

double tempPIDInput_d    = 0.0;
double tempPIDSetpoint_d = 0.0;
bool   tempPIDActive     = false;
bool   tempFilterNeedsReseed = false;

float TempPIDKp             = 0.5f;
float TempPIDKi             = 0.05f;
float TempPIDKd             = 0.0f;    // library Kd — keep 0, use external D instead
float TempPIDKdExternal     = 0.0f;   // external 20s window derivative gain
float TempPIDMarginF        = 15.0f;  // setpoint = TemperatureLimitF - TempPIDMarginF
uint32_t TempPIDIntervalMs  = 5000;
float TempPIDFilterAlpha    = 0.2f;
uint32_t TempPIDStaleMs     = 15000;
float TempPIDAntiWindupMarginA = 5.0f;

PID term contributions

// Inner loop (duty cycle %)
float innerTermP = 0.0f;
float innerTermI = 0.0f;
float innerTermD = 0.0f;

// Outer loop (penalty amps) — only updated when tempPID.Compute() fires
float outerTermP = 0.0f;
float outerTermI = 0.0f;
float outerTermD = 0.0f;
float outerTermDExternal = 0.0f;
float outerImpliedPenalty = 0.0f;
bool  outerAntiWindupFired = false;

Charging stage

bool     inBulkStage            = true;
bool     inAbsorptionStage      = false;
uint32_t absorptionStartTime    = 0;
uint32_t absorptionTailTimer    = 0;
uint32_t bulkVoltageHoldTimer   = 0;

float    BulkVoltage            = 14.90f;
float    AbsorptionVoltage      = 14.4f;
float    FloatVoltage           = 13.60f;
float    ChargingVoltageTarget  = 0.0f;   // set by updateChargingStage() every tick
float    TailCurrent_A          = 5.0f;
uint32_t bulkVoltageHoldMs      = 30000UL;   // ms; UI in seconds
uint32_t absorptionCompleteTime = 30000UL;   // ms; UI in seconds
uint32_t AbsorptionTimeoutMs    = 3600000UL; // ms; UI in minutes
float    RebulkVoltage          = 13.20f;
uint32_t rebulkDebounceTime     = 60000UL;
uint32_t MinFloatTime           = 300000UL;
uint32_t FLOAT_DURATION         = 43200;     // seconds
uint32_t floatStartTime         = 0;
uint32_t rebulkTimer            = 0;

Learning table

const int RPM_TABLE_SIZE = 10;

float rpmCurrentTable[RPM_TABLE_SIZE];       // target current per bucket (user-configured)
float rpmCapCurrentTable[RPM_TABLE_SIZE];    // hard current ceiling per bucket (mechanical limits)
float rpmMinDutyTable[RPM_TABLE_SIZE];       // minimum duty per bucket
int   rpmTableRPMPoints[RPM_TABLE_SIZE];     // RPM breakpoints

int currentRPMTableIndex = -1;               // active bucket index

uint32_t cumulativeNoOverheatTime[RPM_TABLE_SIZE];
uint32_t overheatCount[RPM_TABLE_SIZE];
uint32_t lastOverheatTime[RPM_TABLE_SIZE];

unsigned long totalLearningEvents = 0;
unsigned long totalOverheats = 0;
uint64_t  totalSafeMs = 0;
float     totalSafeHours = 0.0f;
unsigned long timeSinceLastOverheat = 0;

Temperature

float TempToUse = NAN;          // set by buildTickSnapshot() from selected source
float TemperatureLimitF = 150.0f;

Telemetry indices (CSVData payloads — partial reference)

payload2:
  absorptionCompleteTime           -> index 19    (ms, JS /1000 for seconds)
  tempPIDActive                    -> index 204
  tempPIDInput_d                   -> index 205   (x100)
  tempPIDSetpoint_d                -> index 206   (x100)
  thermalPenaltyAmps               -> index 207   (x100)
  innerTermP/I/D                   -> indices 208-210
  outerTermP/I/D                   -> indices 211-213
  outerTermDExternal               -> index 214
  AbsorptionVoltage                -> index 215   (x100, JS /100 for volts)
  AbsorptionTimeoutMs              -> index 216   (ms, JS /60000 for minutes)
  bulkVoltageHoldMs                -> index 217   (ms, JS /1000 for seconds)
  inAbsorptionStage                -> index 218   (0 or 1)

JS length check: values.length !== 219

payload3:
  overheatCount[0..9]              -> indices 78-87   (no scaling)
  cumulativeNoOverheatTime[0..9]   -> indices 88-97   (divided by 1000, sends seconds)
  currentRPMTableIndex             -> index 57
  totalSafeHours                   -> index 100
  timeSinceLastOverheat / 1000     -> index 102

Log flags bytes

thermalLog flags:

bit 0 = tempPIDActive
bit 1 = voltageControlActive
bit 2 = inBulkStage (CC only — false during absorption)
bit 3 = inAbsorptionStage
bit 4 = sysMode AUTO
bit 5 = shutdownPhase active

pidLog flags:

bit 0 = sysMode AUTO
bit 1 = voltageControlActive
bit 2 = inBulkStage (CC only — false during absorption)
bit 3 = inAbsorptionStage
bit 4 = govMode non-normal
bit 5 = tempPIDActive


Anti-Windup Architecture

What it does

When non-thermal constraints (cap table, MaximumAllowedBatteryAmps, voltage cap) are tighter than the current thermal penalty, the outer PID integrator would otherwise wind up uselessly — accumulating penalty against a constraint that isn't thermal. The anti-windup block parks the integrator near the implied penalty from those constraints so it's ready to engage immediately if temperature also tightens.

Implied penalty

float I_target = getTargetCurrentForRPM(RPM);
float bindingConstraint = getCapCurrentForRPM(RPM);
// ... fminf chain with hard limits and voltage cap ...
float impliedPenalty = fmaxf(0.0f, I_target - bindingConstraint);
This is how much current the non-thermal constraints are already removing from the target — expressed as a penalty so it's in the same units as the PID output.

Outer error gate (critical — do not remove)

float outerError = (TemperatureLimitF - TempPIDMarginF) - tempFiltered;

if (impliedPenalty > thermalPenaltyAmps && outerError > 0.0f) {
    double trackTarget = fmax(0.0, (double)(impliedPenalty - TempPIDAntiWindupMarginA));
    tempPID.TrackAppliedOutput(trackTarget, (double)actualDtSec);
}
When outerError <= 0 (temp at or above setpoint), Compute() is actively driving penalty upward and TrackAppliedOutput must not interfere. Without this gate, TrackAppliedOutput fires ~80 times per outer compute tick at 16Hz inner loop rate and winds the integrator back down before Compute() gets another turn.


External 20s Derivative

Library Kd is kept at 0.0. A separate derivative term is computed in tempPID_tick() from a 4-sample ring buffer at 5s intervals (= 20s window) and added directly to thermalPenaltyAmps after Compute().

Why 20s: The thermal time constant is tens of seconds. A 5s derivative window measures quantization noise from the sensor LSB. At 20s, the window captures real thermal momentum.

Gain: TempPIDKdExternal — default 0.0 (disabled). Enable by setting nonzero via web UI. Buffer always advances regardless of gain so it's current when enabled.

Sign convention: thermalPenaltyAmps += TempPIDKdExternal * dTdt where dTdt = (tempFiltered - oldest) / 20.0f. Rising temperature (positive dTdt) increases penalty. This is the correct sense for REVERSE mode.


Current Tuning Values

TempPIDKp         = 0.5
TempPIDKi         = 0.05
TempPIDKd         = 0.0   (library — keep at 0)
TempPIDKdExternal = 0.0   (external 20s window — start at 0, tune separately)

Next Session Plan

Priority 1: First real-world run under 3-stage charging. Confirm: - Bulk → absorption transition fires at correct voltage hold time - Absorption → float on tail current exit - Absorption → float on timeout - Fault during absorption resets to bulk on recovery (not mid-absorption) - Banner shows BULK / ABSORPTION / FLOAT correctly - CSVData2 length 219 confirmed in browser console — no mismatch warnings - All four new settings echo correctly in UI after save - thermalLog and pidLog flags column decodes correctly with new bit layout

Priority 2: HTML/JS echo pipeline cleanup. Two overlapping systems currently exist: updateAllEchosOptimized() with per-field transform, and a broader unit adjustment block. Formatting ownership is split. Many settings also have a redundant hidden span + hidden input block at page bottom whose necessity is unclear. Normalize to one owner per variable, remove dead elements, update Claude.md to reflect final pattern.