Charging Control¶
How the regulator turns sensor readings into one number — the alternator field drive — and how it decides what the battery needs from moment to moment. This page covers the charge-stage logic, the three nested control loops, the override modes, and the battery bookkeeping (state of charge, energy counters, self-calibration) that feeds them.
Protection behavior — what happens when voltage, current, or temperature exceeds safe limits — is on the companion page Safeties and Protections. Sensor drivers are on Sensors.
What the Regulator Actually Controls¶
The only actuator is the alternator field winding. The firmware drives it with a pulse-width-modulated signal (PWM duty cycle): more duty means more field current, a stronger rotor magnet, and more alternator output. Everything on this page exists to choose that one duty-cycle number well.
A second output, a hardware enable line, can cut power to the field driver entirely — that is the protection system's kill switch, covered on the safeties page.
The inputs that matter for charging control:
| Input | Sensor | Used for |
|---|---|---|
Battery voltage (IBV) |
INA228 battery monitor | Charge-stage decisions and the voltage-holding loop |
Net battery current (Bcur) |
INA228 shunt measurement | Tail-current detection, state of charge, MaintainMode |
Alternator output current (MeasuredAmps) |
Hall-effect clamp on an ADS1115 channel | Feedback for the current loop — the fastest signal in the system |
Engine speed (RPM) |
Tach signal via frequency-to-voltage converter | Current-ceiling lookup table, minimum-duty floor |
| Alternator temperature | DS18B20 probe (or thermistor) | Thermal current derating |
Each fresh alternator-current sample triggers one pass of the control path. The single entry point is AdjustFieldLearnMode() in 6_functions.ino — the name is a leftover from a deprecated learning feature; today it is the main control function, called from the main loop.
Charge Stages in Plain English¶
A battery is charged in stages: push hard while it can absorb current, hold a fixed voltage while it tops off, then drop to a maintenance voltage (or rest). The stage logic lives in updateChargingStage() in 6_functions.ino, which drives the flags inBulkStage, inAbsorptionStage, inIdleStage and sets the active voltage target (ChargingVoltageTarget).
Bulk — the current-limited phase (CC phase)¶
The regulator delivers as much current as the ceilings allow (RPM table, thermal derating, user limits). Voltage rises on its own as the battery fills. The voltage-holding loop is not yet in charge. When measured voltage has stayed at the bulk target (BulkVoltage) continuously for a hold time (bulkVoltageHoldMs), with a small two-sided hysteresis band so millivolt-level noise cannot keep resetting the timer, the stage advances.
Absorption — the voltage-holding phase (CV phase)¶
The voltage-holding loop (voltageControlActive) engages and regulates to the absorption target (AbsorptionVoltage). Current tapers naturally as the battery fills. Absorption ends two ways:
- Tail current: net battery current stays at or below the tail threshold (
TailCurrent_A) continuously for a completion time (absorptionCompleteTime) — the classic "battery is full" signal. - Timeout: a maximum absorption duration (
AbsorptionTimeoutMs) elapses regardless.
If current is low only because thermal derating is limiting the alternator (large thermal penalty and a current ceiling near the tail threshold), tail detection is suppressed — low current caused by a hot alternator does not mean the battery is full.
Float — the maintenance phase (or Idle)¶
If float is enabled (UseFloat), the voltage loop holds a lower maintenance target (FloatVoltage). If float is disabled, the regulator instead enters an idle rest stage with the field off entirely.
Both float and idle watch for the battery sagging back down. Re-entering bulk ("re-bulk") happens when voltage drops below a re-bulk threshold (RebulkVoltage) or sustained discharge current is seen (RebulkCurrent_A), confirmed over a debounce period — plus state-of-charge gating so a nearly-full battery is not cycled unnecessarily.
Entering automatic mode always starts in bulk (enter_sys_auto()); if the battery is already up at voltage, the stage machine fast-forwards within seconds.
The stage machine is suppressed whenever an override mode (MaintainMode, TargetVoltageMode, tuning modes, manual) owns the voltage target — see Override Modes below.
The Control Loops — Three Limits, One Duty Command¶
Three loops run at different speeds and cascade into a single setpoint chain: temperature lowers the ceiling, voltage trims the request under that ceiling, and the current loop drives the field to hit the request.
RPM ──► current ceiling from table (getCapCurrentForRPM)
│ minus thermal penalty (thermalPenaltyAmps)
▼
target current (uTargetAmps), clamped by protection caps
│
▼
voltage loop active? yes ──► Icv = PI output, capped at uTargetAmps
no ──► use uTargetAmps directly
│
▼ slew-rate limit
setpointLimited ──► current PID ──► duty request
│
▼
governor (governor_apply) ──► PWM hardware
Every name on the diagram is a global variable or function — all greppable in Xregulator.ino and 6_functions.ino.
The RPM ceiling tables¶
The current ceiling comes from a small lookup table indexed by engine speed: evenly spaced RPM breakpoints with linear interpolation between them (rpmCapCurrentTable, resolved by getCapCurrentForRPM()). This is how users protect belts and bearings — less current allowed at low RPM, full output only once the engine is spinning. An alternate table expresses the ceiling in kilowatts instead of amps (rpmCapPowerTable, selected by capLimitMode), converted to amps using live battery voltage. The first table entry is always zero so a stopped engine commands no current no matter what the rest of the chain wants.
A companion table (rpmMinDutyTable) sets a small minimum duty floor per RPM — it keeps the tach signal path alive through harsh transitions and is enforced inside the governor.
A "Low Charge Rate" switch (HiLow) swaps in a parallel, gentler set of cap tables (loaded by loadCapTablesForMode()), defaulting to a fraction of the normal values.
The current loop (output-current PID, currentPID)¶
The innermost and fastest loop: a proportional-integral-derivative controller (PID, a Brett Beauregard-style library fork) that compares measured alternator current against the setpoint and moves the field duty to close the gap. It runs on every fresh current sample, optionally downsampled (PidSampleDivisor).
The feedback signal is selectable (OutputPIDSigSrc): an exponentially smoothed value (EMA), a moving average (MA), or the raw sample — letting users trade noise rejection against response speed without recompiling.
Anti-windup is done by output tracking: after the governor decides what duty was actually applied, TrackAppliedOutput() pulls the integrator toward that reality at a fixed rate. When the output saturates against a limit, the integrator unwinds at a known speed and the loop exits saturation cleanly. In manual mode the integrator is pinned to the applied duty every tick, so switching back to automatic is bumpless.
The voltage loop (custom position-form PI)¶
When a stage needs a voltage held (absorption, float, and some overrides), a custom proportional-integral controller (PI) runs at a fixed cadence (VoltageLoopInterval). Its output is not duty — it is a current setpoint (Icv, built from gain VoltageKp and integrator state cv_I), clamped between zero and the prevailing current ceiling. The current loop then chases that. Design features, all visible in 6_functions.ino around cv_I:
- Asymmetric integration — above target, the integrator unwinds several times faster than it winds up (the
KiDownmultiplier). Overshoot must clear quickly; ramp-up can be slower. - Slope-aware bleed — if voltage is rising fast and is already near target, an extra bleed (
SlopeBleedK) drains the integrator preemptively. A proximity gain zeroes this out when the battery is genuinely low and charging hard, which is legitimate fast rise. - Bumpless engagement — on entry to voltage control, the integrator is seeded so the commanded current continues exactly from where it was (no step). While voltage control is inactive, a background tracker keeps dragging the integrator toward where it would need to be if control re-engaged this instant.
- A slewed voltage target (
voltageTargetSlewed) — when the destination target jumps (bulk to absorption, override entry), the loop compares against a rate-limited moving target rather than the full step, sized to what the integrator can absorb. Downward steps apply instantly. - Saturation and event handling — during fast-overvoltage events the integrator is frozen or actively bled at rates proportional to the alternator's rated current, so the same settings behave consistently on a small or large machine. Details on the safeties page.
The temperature loop (thermal PID, tempPID)¶
The slowest loop: a reverse-acting PID (rising temperature raises its output) computing a current penalty in amps (thermalPenaltyAmps) that is subtracted from the RPM-table ceiling. Its defining feature is prediction: the process variable is not present temperature but a projection — current filtered temperature plus its measured rate of rise times a lookahead horizon (ThermalLookaheadSec). Derating begins before the limit is reached, based on where temperature is heading.
The penalty is asymmetrically slew-limited (climbs faster than it falls) so brief temperature dips do not cause output bouncing. If temperature data goes bad, the penalty holds its last value rather than vanishing — and a separate staleness protection cuts the field if data stays bad (see safeties page).
Why the regulator holds the alternator at the limit instead of a margin below it — and what that choice costs in alternator life — is covered in Charging Strategy: Temperature-Limited Output.
The governor (governor_apply)¶
One function sits between every duty request and the PWM hardware. It enforces the absolute duty bounds and the RPM minimum-duty floor, then applies one of three slew behaviors: normal rate-limited ramping, instant bypass (used during overvoltage collapse, sensor failure, and step tests), or hold. Even in bypass, the bounds still apply — only the speed of change is unrestricted. Shutdown ramps reuse the same function with the floor released so duty can reach zero (runShutdownPath()).
Operating Modes and Overrides¶
The basic system modes are Off, Manual (user commands a duty directly), and Auto (everything above). Transitions go through enter_sys_off() / enter_sys_manual() / enter_sys_auto() in 6_functions.ino, which reset and re-seed the PID state so every transition is bumpless. applyImmediateCut() is the universal emergency exit used by the protection system.
Override modes within Auto:
- MaintainMode — the regulator chases zero net battery current instead of an output target: the alternator carries the house loads while the battery neither charges nor discharges. Implemented by forcing the voltage loop active with a zero current ceiling and feeding the current PID net battery current as its input. Useful alongside shore power, or to hold a bank where it is.
- TargetVoltageMode — holds an arbitrary user-specified voltage (
TargetVoltageSetpoint) with all current ceilings still in force. The stage machine is suppressed; exit returns cleanly to bulk. - Tuning modes (
TuningMode,CVTuningMode) — built-in square-wave test generators that exercise the current loop and the voltage loop respectively, scoring the response so users can tune gains methodically from the dashboard. - System identification (
systemID_tick()) — a step test that measures the plant's dead time and rise time with all setpoint machinery bypassed, then restores the prior state. - Limp Home (
handleLimpHome()) — last-resort fixed-duty mode that bypasses nearly all protections (the hardware overvoltage backup remains) for getting home on failed sensors.
There is no learning/auto-adaptive mode in current firmware; the AdjustFieldLearnMode function name is historical.
Battery State Tracking¶
The accounting layer lives in 5_functions.ino, headlined by UpdateBatterySOC(), which runs every couple of seconds (SOCUpdateInterval).
State of charge (coulomb counting)¶
State of charge (SoC) is tracked by integrating net battery current over time, with two physics corrections:
- Charge efficiency — amp-hours going in are discounted by a chemistry-dependent efficiency setting (
ChargeEfficiency_scaled), since not every amp-hour pushed in is stored. - Peukert correction — amp-hours going out are inflated at high discharge rates (
PeukertExponent_scaled), reflecting that a battery delivers less than nameplate capacity when drained hard. Applied only above a minimum discharge rate, with sanity clamps on the correction factor.
The accumulator carries fractional amp-hours forward between updates so tiny currents are never lost to rounding.
Full-charge detection and self-calibration¶
Independently of the charge stages (so it works on solar or shore charging too), the firmware declares the battery full when current stays below a tail threshold while voltage stays above a charged threshold for a detection time — then snaps SoC to 100%. Search 5_functions.ino for FullChargeDetected.
Two self-calibration mechanisms ride on this:
- Shunt gain correction (
applySocGainCorrection()) — on each full-charge event, compares counted capacity against nameplate capacity and nudges a gain factor applied to every current reading. Guarded by rate limits, plausibility checks, and a maximum step per event so it cannot run away. - Alternator current auto-zero (
checkAutoZeroTriggers()/processAutoZero()) — periodically (on a schedule, or after a large temperature shift) the field is briefly forced off and the Hall sensor's resting reading is captured as the new zero offset, cancelling slow sensor drift. This must run before the control function each loop pass, since it temporarily owns the field — the ordering is enforced in the main loop.
Energy, fuel, and cycle counters¶
Parallel accumulators track charged and discharged energy, alternator-sourced energy, and solar energy, each in session and lifetime flavors, using the same keep-the-fraction pattern. From alternator energy, a rough fuel-burn estimate is derived using fixed typical efficiencies for a small marine diesel and alternator. A charge-cycle counter divides lifetime charged energy by nominal battery energy. A simple linear projection (calculateChargeTimes()) estimates time-to-full or time-to-empty at the present current — deliberately naive, with no tail-taper modeling.
One source of truth¶
getBatteryVoltage() always returns the INA228 bus voltage; getBatteryCurrent() returns the INA228 shunt current unless the user opts into a Victron VE.Direct source for display and accounting only. Control paths that need battery current (MaintainMode) always use the INA228 directly, because the external source lags by a second or two — fine for bookkeeping, destabilizing for a control loop.
Why lifetime counters save when they do¶
User settings persist immediately via the NVS settings layer (settingWrite() / settingRead()), but the constantly-changing accumulators (SoC count, lifetime energy) are committed to flash deliberately rarely: a few seconds after the field turns off, and during the shutdown sequence — never in the middle of active charging, where a flash commit's stall could land on a control tick at the worst moment. Search Xregulator.ino for fieldOffFlushDone and 5_functions.ino for saveNVSData.
Where to Look in the Code¶
| Topic | Anchor | File |
|---|---|---|
| Whole control path, every tick | AdjustFieldLearnMode |
6_functions.ino |
| Charge-stage state machine | updateChargingStage |
6_functions.ino |
| RPM ceiling lookup | getCapCurrentForRPM |
6_functions.ino |
| Current loop and anti-windup | currentPID, TrackAppliedOutput |
6_functions.ino |
| Voltage loop | cv_I, voltageTargetSlewed |
6_functions.ino |
| Temperature loop | tempPID, thermalPenaltyAmps |
6_functions.ino |
| Duty governor and shutdown ramp | governor_apply, runShutdownPath |
6_functions.ino |
| Mode transitions, emergency cut | enter_sys_auto, applyImmediateCut |
6_functions.ino |
| SoC and energy accounting | UpdateBatterySOC |
5_functions.ino |
| Self-calibration | applySocGainCorrection, processAutoZero |
5_functions.ino |
| Charge-time estimates | calculateChargeTimes |
5_functions.ino |