Skip to content

Field Control

The three nested control loops that turn sensor data into a PWM duty cycle on the alternator field. Lives in 6_functions.ino. Single entry point: AdjustFieldLearnMode(), called every loop iteration (and gated to fire on every fresh CH1 sample after that — see System Overview → CH1 freshness gate).

Protection logic that sits alongside this control path — what fires when temperature, voltage, or current exceeds safe limits — lives in Safeties and Protections. This page only documents the steady-state control architecture.


Three Control Loops

Loop Cadence Library Units in/out Anti-windup
Output Current PID (currentPID) every CH1 sample / PidSampleDivisor (~213 Hz default) Brett Beauregard fork PID_v1_xeng (DIRECT) input: amps · output: duty % TrackAppliedOutput() called every tick with lastAppliedDuty
Voltage CV Loop (cv_I + VoltageKp) VoltageLoopInterval (default 100 ms) Custom position-form PI input: volts error · output: Icv amps Custom — saturation freeze + slope-aware bleed (SlopeBleedK) + AW cap with bleed (AwBleedRate/AwRecoverRate)
Temperature PID (tempPID) TempPIDIntervalMs (5000 ms) PID_v1_xeng (REVERSE) input: filtered °F · output: thermalPenaltyAmps Implied-penalty back-calculation via TrackAppliedOutput(), outer-error gated

The outer two (CV and temperature) compute current limits. The inner one (output current PID) drives the actuator. Together they cascade: temperature reduces the table ceiling, voltage trims the request below that ceiling, current PID drives duty to make measured current match the request.


Command Path

   RPM ────────► getCapCurrentForRPM(RPM)  ◄── rpmCapCurrentTable[] (or rpmCapPowerTable[] / IBV when capLimitMode=1)
                       │
                       │ I_cap
                       ▼
   thermalPenaltyAmps ─►  subtract  ──► clamp [0, MaxTableValue] ──► I_cmd
                                                                     │
                                            warmupCeiling (optional) │
                                            MaintainMode → 0          │
                                                                     ▼
                                                                uTargetAmps
                                                                     │
                                            fastOvCurrentCap (Groups 1/2/3,
                                            iExcess, LoadDump) clamp │
                                                                     ▼
                              voltageControlActive ?  Icv = clamp(Kp·e + cv_I, 0, uTargetAmps)
                              !voltageControlActive ?  setpointCommand = uTargetAmps
                                                                     │
                              slew_limit_f(rise, fall) ◄──────────── setpointCommand
                                                                     ▼
                                                              setpointLimited
                                                                     │
                                                                     ▼
                                          currentPID.Compute() ──► pidOutput (duty %)
                                                                     │
                              MANUAL: dutyRequest = ManualDutyTarget │
                              AUTO:   dutyRequest = pidOutput        │
                                                                     ▼
                              governor_apply(lastApplied, request, govMode,
                                             rpmMinDuty, dtSec) ──► dutyNewFloat
                                                                     │
                                                          apply_pwm_float()
                                                                     ▼
                                                                  GPIO PWM

Every variable named on the diagram is a global in Xregulator.ino (some are static inside AdjustFieldLearnMode()). Grep-friendly.


RPM Tables

RPM_TABLE_SIZE = 10 evenly-spaced breakpoints at {100, 600, 1100, 1600, 2100, 2600, 3100, 3600, 4100, 4600} RPM. Linear interpolation between points; values at or below the first breakpoint use the first entry directly (no extrapolation below).

Table Variable Default Purpose
Target current (Normal mode) rpmCurrentTable[] {0, 50, 50, …, 50} User-configured operating-point target (currently unused — superseded by cap table as the operating target since Learning Mode was deprecated)
Cap current rpmCapCurrentTable[] {0, 50, 50, …, 50} Hard mechanical/electrical ceiling — getCapCurrentForRPM() returns this
Cap power (kW) rpmCapPowerTable[] all zeros Alternate ceiling expressed in kW. Active only when capLimitMode == 1. Converted to amps using live IBV
Minimum duty rpmMinDutyTable[] {5, 5, 4, 4, 3, 3, 2, 2, 1, 1} % Protects LM2907 tach signal coupling cap from harsh transitions. Applied inside governor_apply()

HiLow == 0 ("Low Charge Rate") loads a parallel set of NVS blobs (capTableLo, capPowerTableLo) via loadCapTablesForMode(0). Defaults to one-quarter of the Normal-mode current values. Switching modes calls loadCapTablesForMode() — no runtime halving.

First entry is always zero so the alternator commands no current at ≤ 100 RPM (effectively stopped) — guarantees field-off regardless of the rest of the chain.


Output Current PID (currentPID)

PID currentPID(&pidInput, &pidOutput, &pidSetpoint, PidKp, PidKi, PidKd, DIRECT);
currentPID.SetOutputLimits((double)MinDuty, (double)MaxDuty);
currentPID.SetSampleTime(100);                  // library's internal gate; we externally gate too
currentPID.SetTunings(PidKp, PidKi, PidKd);
currentPID.SetTrackingGain(PIDTrackingGain);    // 4.0/sec for TrackAppliedOutput

Per-tick wiring

float pidSig = (OutputPIDSigSrc == 2) ? MeasuredAmps                       // raw
             : (OutputPIDSigSrc == 1) ? g_pidMA_N                          // MA(OutputPIDMA_N)
                                      : g_pidI_filtered;                   // EMA(OutputPIDFilterTC)
targetCurrent = (MaintainMode == 1) ? Bcur : pidSig;
pidInput = targetCurrent;
pidSetpoint = setpointLimited;
currentPID.Compute();

Notes:

  • Feedback signal is selectable via OutputPIDSigSrc so the user can trade noise rejection for response time without recompiling. EMA, MA, and raw all live on the CH1 record path so all three are always populated.
  • MaintainMode ignores pidSig and uses Bcur (INA228 net battery current) — the regulator is then chasing net-zero battery flow rather than alternator output. The dropdown for battery current source does not apply here; Bcur is always INA228 to avoid the 1–2 s Victron lag.
  • Term contributions (innerTermP/I/D) are read after compute via GetPterm()/GetIterm()/GetDterm() for telemetry display.

Tracking anti-windup (TrackAppliedOutput)

After the governor returns dutyNewFloat, the firmware calls:

currentPID.TrackAppliedOutput((double)dutyNewFloat, actualDtSec);

The library's tracking gain (PIDTrackingGain = 4 / sec) pulls outputSum toward what the actuator actually applied. So when the governor clamps the output to rpmMinDuty or MaxDuty, the integrator unwinds at a known rate and the loop snaps out of saturation cleanly instead of catching up over many ticks.

In MANUAL the integrator is reset to dutyNewFloat directly (ResetIntegratorTo) every tick so re-entering AUTO is bumpless.


Voltage CV Loop (custom position-form PI)

Runs every VoltageLoopInterval ms (default 100). Computes Icv — the current setpoint that gets fed to the output-current PID as setpointCommand when voltageControlActive is true.

Variables

Variable Meaning
cv_I Integrator state (amps). Clamped to [0, uTargetAmps]
Icv Position-form output clamp(VoltageKp·e + cv_I, 0, uTargetAmps)
voltageTargetSlewed Rate-limited charging target — rises only as fast as cv_I can support given uTargetAmps. Falls instantly
VoltageLoopInterval Compute cadence (default 100 ms)
g_voltLoopActualIntervalMs Measured wall-clock interval between fires — telemetry-only
VoltageKp, VoltageKi User-tunable PI gains
cvDSlope Backward-diff of getFiltV() (V/s) — feeds SlopeBleedK only
lastVoltageLoopMs Time of last fire; 0 → loop will fire on enteringCV rising edge

Asymmetric integration

const float KiDown = 7.0f * VoltageKi;
float dI = (e >= 0.0f ? VoltageKi : KiDown) * e * dtSec;

Above target (e < 0), the integrator unwinds 7× faster than it winds up. Empirically necessary because charging behavior is asymmetric — voltage overshoot needs to clear faster than the loop pushes target up.

Slope-aware bleed

When cvDSlope (filtered V/s rate of rise) exceeds SlopeBleedThresh, an additional bleed is subtracted from cv_I:

proxGain = clamp(1 - e / SlopeBleedProxV, 0, 1);     // 0 far below target, 1 at/above
slopeBleedAmps = SlopeBleedK · (cvDSlope - SlopeBleedThresh) · dt · proxGain;
cv_I -= slopeBleedAmps;

proxGain is the critical guard — it zeros the bleed when the battery is genuinely far below target and rising fast (a legitimate fast charge into a depleted bank). Only when the battery is near target does the bleed activate.

Bumpless transfer on CV entry

When voltageControlActive goes false → true (enteringCV):

seed = clamp(g_pidI_filtered - VoltageKp · e, 0, uTargetAmps);
cv_I = seed;
cv_I_track = seed;
cv_I_aw_cap = MaxTableValue;          // clear any stale post-event cap
awSeedProtectStartMs = currentMillis;  // engage seed-protection window (AwSeedProtectMs)

The seed value g_pidI_filtered - VoltageKp · e makes Icv == g_pidI_filtered immediately — so setpointCommand continues smoothly from wherever current was, no step. The AwSeedProtectMs window (default 150 ms) blocks the per-tick cv_I bleed (below) from wiping the freshly-seeded value if a new protection event fires immediately after.

Tracker during !voltageControlActive (bumpless waiting)

When CV is inactive, cv_I_track is dragged toward where the integrator would need to be if CV re-engaged this tick:

e_bt = ChargingVoltageTarget - IBV;
cv_I_target = clamp(g_pidI_filtered - VoltageKp · e_bt, 0, uTargetAmps);
cv_I_track += 2.0 · (cv_I_target - cv_I_track) · dtSec;     // first-order tracker, Kt = 2/sec
cv_I = cv_I_track;

This is why re-entering CV from idle or MaintainMode is bumpless — the integrator is already in position. The AwSeedProtectMs window suppresses this tracker during seed-protection too.

voltageTargetSlewed — voltage target rise governor

Prevents the integrator from seeing a large voltage-target step when ChargingVoltageTarget jumps (bulk→absorption, override entry/exit, CVTuningMode toggles, TargetVoltageMode engagement):

e_needed = (uTargetAmps - cv_I) / VoltageKp;   // how much error the current cv_I can absorb
e_needed = max(e_needed, 0.02V);
voltageTargetSlewed = min(ChargingVoltageTarget, IBV + e_needed);

Falls are instantaneous. voltageTargetSlewed is what the PI actually compares against — ChargingVoltageTarget is the destination, voltageTargetSlewed is the moving target.

Saturation behavior

satHi = (Icv >= uTargetAmps);
satLo = (Icv <= 0);
supervisorLimiting = fastOvClampActive && (uTargetAmps < uTargetRaw_cached - 0.01f);

Three states (exported as g_awState):

  • 0 — normal integration
  • 1 — fast-OV supervisor limiting; freeze upward integration
  • 2 — standard saturation (satHi && dI > 0 or satLo && dI < 0); freeze in offending direction
  • 3 — active per-tick cv_I bleed during fastOV (5 ms cadence, not 100 ms)
  • 4!voltageControlActive (bumpless tracker owns cv_I)

The per-tick bleed (state 3) drains cv_I at AwBleedRate · MaxTableValue A/s every loop iteration during fastOvClampActive — necessary because the CV loop itself only fires every 100 ms and that's too slow to counteract a fast OV event that fires at 5 ms cadence.

cv_I_aw_cap

A second ceiling on cv_I that bleeds during fastOV events and slowly recovers afterward. Scales by MaxTableValue so the per-second amperage stays a fixed fraction of the alternator's rating:

awBleedAmpS  = AwBleedRate  · MaxTableValue;  // active bleed
awRecoverAmpS = AwRecoverRate · MaxTableValue;  // post-event recovery

AwBleedRate = 2.0 at 50 A table → 100 A/s; at 150 A → 300 A/s. Same proportional aggression regardless of alternator size. AwRecoverRate = 0.1 similarly: slow re-engagement so the battery has time to settle between OV events instead of bouncing.


Temperature PID (tempPID)

PID tempPID(&tempPIDInput_d, &thermalPenaltyAmps_d, &tempPIDSetpoint_d,
            TempPIDKp, TempPIDKi, TempPIDKd, REVERSE);
tempPID.SetMode(AUTOMATIC);
tempPID.SetSampleTime((int)TempPIDIntervalMs);    // library's own timer governs cadence

REVERSE mode means rising input (temperature) increases output (penalty). The output is subtracted from I_cap, so more penalty → less current → less heat.

Predictive process variable

Instead of using filtered temperature directly, the firmware projects forward:

projectedTempF = tempFiltered + thermalSlopeFPerSec · ThermalLookaheadSec;

thermalSlopeFPerSec comes from a 60-second ring buffer (thermalSlopeBuffer[THERMAL_SLOPE_BUF = 13] × 5 s cadence = 13 × 5 s = 65 s window — close enough). ThermalLookaheadSec (default 90 s) is user-tunable. So the PID's setpoint compare is "where will I be in 90 s at current rate-of-rise?" — derating begins before the limit is reached, not in response to it.

Output slew

thermalPenaltyAmps is asymmetrically slew-limited after tempPID.Compute():

thermalPenaltyAmps = slew_limit_f(thermalPenaltyAmps, thermalPenaltyAmps_d,
                                  ThermalPenaltyRiseRate, ThermalPenaltyFallRate, dtSec);

Default ThermalPenaltyRiseRate = 60 A/s, ThermalPenaltyFallRate = 20 A/s. Penalty climbs faster than it falls so the loop doesn't bounce when temp briefly dips.

Stale-temperature safety

If tempFiltered is NaN or out of range, the PID halts and thermalPenaltyAmps holds its last value (rather than zeroing). The T5 staleness gate in Safeties cuts the field outright if temperature stays stale for 20 s.

Implied-penalty anti-windup

When non-thermal constraints (getCapCurrentForRPM, MaintainMode, etc.) are already limiting current below where the thermal penalty would put it, the integrator would wind up uselessly. The block parks it near where the constraints already imply:

outerError = (TemperatureLimitF - TempPIDMarginF) - tempFiltered;
if (impliedPenalty > thermalPenaltyAmps && outerError > 0.0f) {
    trackTarget = max(0, impliedPenalty - TempPIDAntiWindupMarginA);
    tempPID.TrackAppliedOutput(trackTarget, dtSec);
}

The outerError > 0 gate is critical — without it, when temperature is actively driving the loop (outerError ≤ 0), TrackAppliedOutput() would un-wind the integrator on every tick at ~213 Hz, faster than Compute() runs at 0.2 Hz. The loop would be unable to act.


Charging-Stage State Machine — updateChargingStage()

Drives inBulkStage, inAbsorptionStage, inIdleStage, and ChargingVoltageTarget. Suppressed during MaintainMode, TargetVoltageMode, CVTuningMode, and tick.manualMode — those override-modes set the target themselves.

BULK (CC)

  • voltageControlActive = false, ChargingVoltageTarget = BulkVoltage.
  • bulkVoltageHoldTimer arms when v ≥ BulkVoltage − BULK_V_BAND_ENTER (0.05 V); clears when v < BulkVoltage − BULK_V_BAND_EXIT (0.10 V). Two-sided hysteresis stops 30–50 mV idle noise from constantly resetting the timer.
  • Transitions to ABSORPTION when held continuously for bulkVoltageHoldMs (default 30 s).

ABSORPTION (CV)

  • voltageControlActive = true, ChargingVoltageTarget = AbsorptionVoltage.
  • Tail-current exit: Bcur ≤ TailCurrent_A continuously for absorptionCompleteTime (default 30 s).
  • Thermal constraint suppression of tail exit: if thermalPenaltyAmps > 2 A AND uTargetAmps ≤ 2 × TailCurrent_A, tail detection is suppressed — the low current is from thermal derating, not "battery full." Resumed (with throttled console log) when the constraint clears.
  • Timeout exit: now - absorptionStartTime ≥ AbsorptionTimeoutMs (default 60 min).
  • On exit: UseFloat == 0 → IDLE; otherwise → FLOAT.

FLOAT

  • voltageControlActive = true, ChargingVoltageTarget = FloatVoltage.
  • Re-bulk after MinFloatTime (default 5 min) when v < RebulkVoltage OR Bcur < −RebulkCurrent_A (significant discharge), confirmed continuously for rebulkDebounceTime (default 60 s).
  • Also re-bulks unconditionally after FLOAT_DURATION (43200 s = 12 h) elapsed in float.
  • UseFloat == 0 while already in float → immediate transition to IDLE.
  • SOC gating: if SOC_percent ≥ SOC_BlockRebulk_percent, rebulk is blocked; ≤ SOC_AllowRebulk_percent, always allowed; in between, voltage/current rules apply normally.

IDLE (UseFloat=0)

  • chargingEnabled = false (set in buildTickSnapshot). Field is off, no PWM.
  • Same rebulk logic as FLOAT (rebulks back into BULK on sag).

enter_sys_auto() always starts in BULK regardless of current battery state — updateChargingStage() then fast-forwards within a few seconds if the battery is already at the upper voltages.


Governor (governor_apply)

Single function that sits between every duty request and the PWM hardware. Applies bounds (MinDuty, MaxDuty, rpmMinDuty), then applies slew based on govMode:

govMode Behavior
GOV_NORMAL_SLEW slew_limit_f(lastApplied, requestClamped, DutyRampRate, DutyRampRate, dtSec) — symmetric rate limit (default 50 %/s)
GOV_BYPASS_SLEW Instant — next = requestClamped. Effective bounds still enforced
GOV_HOLD next = lastAppliedDuty (no change this tick)

When GOV_BYPASS_SLEW is set

  • Major OV: currentBatteryVoltage > AlternatorHardShutdownV + 0.5 V — instant collapse during shutdown.
  • Voltage sensor failure: !voltagePlausible || voltageDisagreementCritical.
  • CV overshoot during voltageControlActive: latches when fastOvClampActive fires, releases when IBV < ChargingVoltageTarget + 0.02 V. Hysteresis stops chatter as the field collapses.
  • iExcess or LoadDump active (re-evaluated mid-loop after those supervisors vote).
  • sysIDRunning — plant-delay step test needs instant transitions.

The pre-clamp inside the governor (finalMin = max(dynMin, hardMin)) means even GOV_BYPASS_SLEW respects the rpmMinDuty floor and MinDuty/MaxDuty hard bounds. Only the descent speed is unrestricted.

Shutdown special case

runShutdownPath() calls governor_apply with effectiveMin = 0 so the duty can go below MinDuty. Phase 1 ramps to rpmMinDuty at DutyRampRate; Phase 3 ramps from there to 0 at DutySlowRampRate (default 1 %/s). Phase 4 settles at 0 for SettleTimeBeforeCut ms, then digitalWrite(4, LOW). See Safeties → Three shutdown paths.


Override Modes

All four bypass parts of the normal control architecture. Mutually exclusive in practice (the UI prevents simultaneous activation of MaintainMode / TargetVoltageMode), but the logic does not enforce that.

MaintainMode

voltageControlActive = true;            // forces CV active even from idle
ChargingVoltageTarget = BulkVoltage;    // CV runs at bulk target
uTargetAmps = 0;                        // ceiling enforcer: Icv → 0 because clamp [0, 0]

The PI runs at the bulk voltage so Groups 1/2 OV remain armed, but the current ceiling is zero so setpointCommand stays at 0. The PID then drives duty wherever needed to make Bcur ≈ 0 net battery current. Useful for: holding a battery at its present voltage indefinitely (e.g. paralleling shore power), running pure house-load draw through the alternator instead of the battery.

TargetVoltageMode

ChargingVoltageTarget = TargetVoltageSetpoint;   // user-specified
voltageControlActive = true;                     // force CV active
// all current limits (RPM cap, thermal penalty, MaxTableValue) remain active

updateChargingStage() is suppressed — stage flags are irrelevant. On exit, enter_sys_auto() resets to BULK so the resumption path is well-defined.

TuningMode (inner current loop)

Square-wave setpoint generator. uTargetAmps toggles between 5 and 5 + waveAmplitude every wavePeriod / 2 seconds. Slew-limited by SetpointRiseRate/SetpointFallRate. Scoring window opens when slew rate drops below 1 A/s (so the score is independent of the slew rate itself). voltageControlActive = false while active — the test characterizes the inner PID against the alternator, not against the battery. Commits to tuningLog[] (50 PSRAM records) on manual button press.

CVTuningMode (outer voltage loop)

Square-wave on ChargingVoltageTarget. LOW phase = normal setpoint; HIGH phase = setpoint + cvWaveAmplitudeV. Asymmetric ISE/T scoring:

  • HIGH phase overshoot weighted by cvKOvershoot, undershoot (during rise) weighted ×1. First 25 mV of overshoot is "free" (CV_HIGH_DEADBAND_V).
  • LOW phase one-sided squared error normal weight, plus undershoot ramp-up over 10 s with 1 s grace (CV_LOW_GRACE_SEC, CV_LOW_RAMP_SEC).

Settling time accumulates when |IBV − target| ≤ CV_SETTLE_V_THRESH for cvConsecutiveReads consecutive ticks. Commits to cvTuningLog[] on manual button press.

SystemID

Plant-delay measurement test. systemID_tick() returns true while running. On rising edge: snapshot pre-test state (mode, setpoint, applied duty, cv_I, CV active). During: govMode = GOV_BYPASS_SLEW, inner PID in MANUAL with integrator pinned to sysIDDutyOut, all setpoint machinery skipped. On falling edge: legality check (must still be in normal running state), restore setpoint and CV integrator if context unchanged, re-seed inner PID to lastAppliedDuty. Records timing of the step response against MeasuredAmps for the rise-time / dead-time / slope estimates shown on the Plant Delay tab.

Limp Home

User-toggled emergency mode. handleLimpHome() ramps to a fixed 30 % duty, bypasses every protection except the INA228 hardware ALERT latch. Used when sensors have failed completely but the user needs some charging to make it home. Heavily logged (one console message per 30 s).


Mode Entry Functions (enter_sys_*)

Bumpless transfer wrappers. Called when sysMode transitions.

Function Sets PID action
enter_sys_off() sysMode = SYS_MODE_OFF, govMode = GOV_NORMAL_SLEW currentPID.SetMode(MANUAL), pidOutput = lastAppliedDuty, integrator → lastAppliedDuty
enter_sys_manual() SYS_MODE_MANUAL, GOV_NORMAL_SLEW (manual still respects tach floor) Same as off
enter_sys_auto() SYS_MODE_AUTO, GOV_NORMAL_SLEW. Always sets inBulkStage = true, inAbsorptionStage = false, inIdleStage = false, resets timers SetOutputLimits(MinDuty, MaxDuty), SetSampleTime(100), SetTunings(PidKp, PidKi, PidKd), SetTrackingGain(PIDTrackingGain), SetMode(AUTOMATIC), integrator → lastAppliedDuty. Seeds setpointLimited = max(0, getTargetAmps())
enter_sys_fault() SYS_MODE_FAULT, govMode left to caller Same as off

applyImmediateCut(tick, reason) is the universal immediate-cut entrypoint — used for INA228 hardware OV, hard overcurrent, RPM gate, and T3 critical temp. It sets GPIO4 LOW, zeros PWM, drops sysMode to FAULT, resets the PID, zeros setpointLimited, jumps shutdownPhase to PHASE_4 (so any later re-entry doesn't try to re-run the ramp), and aborts any running SystemID test.


Slew Rates and Time Constants — Quick Reference

Parameter Default Owner What it limits
DutyRampRate 50 %/s governor_apply Symmetric PWM duty slew
DutySlowRampRate 1 %/s runShutdownPath Phase 3 Slow ramp from rpmMinDuty to 0 during shutdown
SetpointRiseRate 30 A/s slew_limit_f on setpointCommand → setpointLimited How fast the current target can rise
SetpointFallRate 50 A/s same How fast the current target can fall — overridden to 1e9 A/s when fastOvClampActive
StartupRiseRate 3 A/s same Override for the very first ticks after field turn-on; inStartupRamp clears when setpointLimited catches up
FastSetpointRiseRate (multiplier) same Multiplies SetpointRiseRate during the post-protection-clear window (FastSetpointRiseWindowMs, gated by FastSetpointRiseHeadroomV)
VoltageLoopInterval 100 ms CV loop Compute cadence for cv_I
TempPIDIntervalMs 5000 ms tempPID library timer Compute cadence for thermalPenaltyAmps_d
ThermalPenaltyRiseRate 60 A/s slew_limit_f after tempPID.Compute() Penalty up-slew
ThermalPenaltyFallRate 20 A/s same Penalty down-slew
ThermalLookaheadSec 90 s projectedTempF Predictive horizon for tempPID setpoint compare
TempPIDFilterAlpha 0.2 tempFiltered EMA Temperature input smoothing
InputFilterTC (ms) MeasuredAmps_filtered EMA iExcess and CV input filter
OutputPIDFilterTC (ms) g_pidI_filtered EMA Inner PID input filter
DvdtTC 58 ms fastOV dvdt EMA Smooths INA228 rate-of-rise into Group 1
VoltageFilterTC (ms) IBV_filtered EMA Used by getFiltV() — display + slope bleed only, never safety
AwSeedProtectMs 150 ms CV seed-protection window Suppresses per-tick cv_I bleed for this long after a fresh seed
FIELD_COLLAPSE_DELAY 30000 ms Lockout cooldown Post-shutdown wait before next attempt
SettleTimeBeforeCut 1000 ms Phase 4 Duty must be ≤ 0 % continuously for this long before GPIO4 LOW

Cross-references