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), fromgetTargetAmps()orgetBatteryCurrent() - Setpoint:
pidSetpoint=setpointLimited(amps), rate-limited version ofuTargetAmps - Output:
pidOutput= duty cycle (%), fed throughgovernor_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()insideAdjustFieldLearnMode()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)ChargingVoltageTargetis set byupdateChargingStage()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 setsinBulkStage = 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;
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);
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);
}
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.