Skip to content

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 "tightest limit wins" strategy (including how the regulator runs against the temperature limit and what that costs in alternator life), 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. The alternator-wear estimates and performance-trend systems referenced below live on Alternator Health and Analytics and Advanced Features.


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 requested voltage target (ChargingVoltageTargetReq; a separate rate limiter turns that into the live ChargingVoltageTarget, so a target change is eased in rather than stepped).

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, default a quarter-second), the stage advances. A two-sided hysteresis band (enter at a hair below target, reset only well below it) keeps millivolt-level noise from resetting the timer.

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), while the bus is actually being held at the absorption voltage — the classic "battery is full" signal. Requiring the voltage as well stops a house load or an idle-RPM sag that merely starves the current from ending absorption on a half-charged bank; another charge source holding the bus above target still counts. Requires a battery shunt; without one (HAS_BATT_SHUNT false) the tail path is disabled and absorption ends only on timeout.
  • Timeout: the bank has spent a maximum duration (AbsorptionTimeoutMs) at the absorption voltage — the clock pauses whenever the bus sags below target — with a wall-clock backstop of twice that figure so a bank that never reaches target still leaves absorption.

If current is low only because thermal derating is limiting the alternator (a real 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, idle, or zero-current hold — the maintenance phase

UseFloat selects one of three behaviors after absorption completes:

  • Idle (UseFloat = 0, the default) — the field turns off entirely and the regulator rests, watching for the battery to sag.
  • Float (UseFloat = 1) — the voltage loop holds a lower maintenance target (FloatVoltage).
  • Zero-current hold (UseFloat = 2) — the voltage loop runs at the bulk voltage purely as an over-voltage guard while the current target is held at zero, so the alternator carries the house loads and the battery neither charges nor discharges. Without a battery shunt this mode has no current signal to regulate against and quietly degrades to plain float.

Float and idle both 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, and only after a minimum time in the stage. State-of-charge gates ride on top: re-bulk is blocked above SOC_BlockRebulk_percent and force-allowed at or below SOC_AllowRebulk_percent, 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)
              │ then the tighter of: user battery-current limit, protection caps
              ▼
        target current (uTargetAmps)
              │
              ▼
   voltage loop active?  yes ──► Icv = PI output − D-term trim, 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. Several independent limits are computed each tick and the most restrictive one governs the field drive; a single telemetry value (ctrlLimiter) names which one is currently binding, for the dashboard limiter banner. See Charging strategy below for why that matters.

The RPM ceiling tables

The current ceiling comes from a small lookup table indexed by engine speed, with linear interpolation between breakpoints (rpmCapCurrentTable, resolved by getCapCurrentForRPM()). The breakpoints are deliberately denser at the low end, not evenly spaced, so belt- and bearing-critical low-RPM behavior can be shaped finely. This is how users protect belts and bearings — less current allowed at low RPM, full output only once the engine is spinning. The first breakpoint defaults to zero, and below the lowest breakpoint the table clamps to that first entry, so a barely-turning engine commands little or no current. An alternate table expresses the ceiling in kilowatts instead of amps (rpmCapPowerTable, selected by capLimitMode), converted to amps using live battery voltage.

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. This floor table can be filled in by hand, or auto-learned: an opt-in observer (the knee tracker, kneeLearnEnable, on by default) watches the field-onset knee — the duty at which the alternator first starts making current — in each RPM bin and rewrites rpmMinDutyTable, parking the floor a margin below the learned knee. The observer only rewrites the floor table; the control path itself is unchanged. (The lowest bin is permanently pinned to zero.)

A "Low Charge Rate" switch (HiLow) swaps in a parallel, gentler set of cap tables at load time (loadCapTablesForMode()), defaulting to half 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, default every sample).

The feedback signal is selectable (OutputPIDSigSrc, default an exponentially smoothed value): the smoothed value (EMA, time constant OutputPIDFilterTC), a moving average (MA), or the raw sample — letting users trade noise rejection against response speed without recompiling. The derivative gain ships at zero (PidKd = 0) because current-loop derivative mostly adds noise here.

Anti-windup is done by output tracking: after the governor decides what duty was actually applied, TrackAppliedOutput() back-calculates against the loop's unsaturated output and pulls the integrator toward reality at a fixed rate (PIDTrackingGain). When the output saturates against a limit, the integrator unwinds at a known speed and the loop exits saturation cleanly. (Tracking is skipped when the integral gain is zero.) 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, with a derivative trim)

When a stage needs a voltage held (absorption, float, and some overrides), a custom proportional-integral controller (PI) runs at a fixed cadence (VoltageLoopInterval, 100 ms). Its output is not duty — it is a current setpoint:

Icv = clamp( VoltageKp_active · e  +  cv_I  −  (D-term trim),  0,  current ceiling )

where e = voltageTargetSlewed − IBV. The current loop then chases Icv. 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. Overshoot must clear quickly; ramp-up can be slower. (This and the derivative trim below are active only when the CV helpers are enabled, cvHelpersEnabled, the default; disabled gives a plain symmetric PI.)
  • Derivative trim on voltage slope (the CV D-term, VoltageKd). This replaced an earlier integrator-bleed "brake." It watches the rate of change of a lightly filtered battery voltage (g_cvKdFiltV, filter CvKdVoltFiltTC); when that slope exceeds a deadband (CvKdDeadbandVps) while within about a volt of target (CvKdArmV), it trims the commanded current — braking a fast rise, and (unless one-sided, CvKdOneSided) boosting a fast fall while below target. By default (CvKdExcessMode) the trim is proportional to how far the slope exceeds the deadband, so it grows continuously from zero at the threshold; the older mode, kept for bench comparison, applied the full slope the moment the deadband was crossed, and that step behaved like a relay behind the field's lag — enough to sustain its own slow oscillation. The trim is slope-ceilinged (CvKdSlopeCeil), capped in amps (CvKdMaxTrimA), and — crucially — applied at the loop's output, never folded into the integrator (cv_I), so it releases the instant the slope settles.
  • 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 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. The full post-trip recovery sequence is on the safeties page.

The temperature loop (thermal derating)

The slowest loop computes a current penalty in amps (thermalPenaltyAmps) that is subtracted from the RPM-table ceiling. It is a feed-forward-plus-integral controller, not a textbook PID — there is no derivative term:

  • A predictive feed-forward term, proportional to how far a projected temperature sits above the control setpoint. The projection is present filtered temperature plus its measured rate of rise times a lookahead horizon (ThermalLookaheadSec), so derating begins before the limit is reached, based on where temperature is heading.
  • A holding integral on the present temperature error, with an asymmetric bleed (TempPIDKiDownFrac) so it unwinds gently as the alternator cools.

Its control setpoint is not the damage limit itself: it sits a few degrees below it — about 7 °F under TemperatureLimitF in normal running, and further below during a cold-start warm-up before the rate-of-rise estimate is trustworthy. That guard band keeps normal temperature ripple from nudging up into the over-temperature warning ramp and cut that live above it (see safeties page). The penalty is derate-only (never negative) and 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.

Gains: TempPIDKp, TempPIDKi, TempPIDKiDownFrac, ThermalLookaheadSec, ThermalSlopeWindowSec. Why the regulator runs against this limit at all, and what raising or lowering it costs, is the subject of Charging strategy below.

The governor (governor_apply)

One function sits between every duty request and the PWM hardware. It enforces the absolute duty bounds (MinDutyMaxDuty) and the RPM minimum-duty floor, then applies one of three slew behaviors: normal rate-limited ramping (DutyRampRate), 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()).

System voltage scaling (12 / 24 / 48 V)

The firmware was originally 12-volt-only; it now runs on 12, 24, or 48 V battery banks. The nominal bank voltage comes from BATTERY_VOLTAGE, set in Vessel Info, and that single value is the sole source of the system voltage class.

  • Gain normalization. Control-loop gains are normalized by the factor 12 / BATTERY_VOLTAGE. The user enters gains as if for a 12 V system; recomputeCcGains() (the current loop) and recomputeCvGains() (the voltage loop) derive the normalized "active" gains the loops actually read (PidKp_active, VoltageKp_active, and so on). A gain tuned at 12 V therefore gives the same closed-loop response at 24 or 48 V. The voltage-loop gains additionally carry a battery-temperature derate (below); and if auto-gain mode is selected (cvGainMode = 1), the voltage-loop gains are derived from the measured plant stiffness rather than the typed values.
  • Battery-temperature gain derate (battTempDerateEnable, on by default). Because a cold battery presents a stiffer, higher-impedance load, the voltage-loop gains (VoltageKp_active, VoltageKi_active, VoltageKd_active) are scaled by the ratio of the pack's resistance at the commissioning temperature to its resistance now. It only takes effect once a commissioning temperature has been captured.
  • Duty-ceiling scaling. MaxDuty is a real, per-bus percentage — the governor uses it verbatim, and nothing multiplies it by 12 / BATTERY_VOLTAGE at run time. When the nominal voltage class changes, applyNominalVoltageChange() rescales the stored MaxDuty once by the inverse voltage ratio (a higher bus reaches full field current at a lower duty) and persists it, along with the charge-profile voltages and the hard-shutdown threshold.

Charging strategy: running at the temperature limit

The control loops above compute several output limits continuously and in parallel — the charge-stage voltage target, the user current ceilings, the RPM-speed ceiling, the temperature derate, and the event-driven protections. At any instant the most restrictive one governs the field drive (ctrlLimiter reports which). As the battery fills, the voltage target becomes the binding limit and current tapers off naturally. But early in a charge on a depleted bank, the voltage and current limits usually permit more output than the alternator can thermally sustain — so the temperature limit becomes the binding constraint, and how the regulator handles it sets both your charge rate and your alternator's lifespan.

The temperature limit applies to the measured temperature. The probe mounts to the alternator case, which runs well below the internal winding temperature, so the right value for TemperatureLimitF depends on where your sensor sits (the shipped default assumes a typical case mount).

What the regulator targets

The thermal loop trims field drive to hold the measured alternator temperature a small margin below your configured limit — about 7 °F under it in steady operation. That margin is a guard band, not wasted headroom: it leaves room for normal temperature ripple without nudging into the over-temperature ramp-down and cut that sit above the limit (the T1 and T3 tiers on the safeties page). In normal operation neither of those should ever fire — the loop rides just under the limit without overshoot. When some other limit is binding (battery nearly full, current cap reached), the alternator simply runs cooler and the thermal loop stays out of the way.

Running close to the limit — rather than backing well off it — is what maximizes energy delivered over a session. Sustainable output is set by heat balance: the alternator can only make as much heat as it can shed, and heat shedding scales with how far its temperature sits above the engine room around it. Targeting a much lower temperature sacrifices cooling headroom, which costs output current. (A cooler machine is slightly more efficient — copper resistance falls with temperature — but that effect is several times smaller than the cooling headroom it costs, so a much colder target always delivers less total energy, never more.)

The durability trade-off

Alternator damage does not begin at a sharp threshold — it accumulates continuously, and the rate scales steeply with temperature. The dominant mechanism is winding-insulation aging, which follows the standard electrical-machine rule of thumb: insulation life roughly halves for every 18 °F (10 °C) of additional temperature. Bearing grease and rectifier diodes degrade in the same direction. The practical consequence: running hotter buys amp-hours roughly linearly, but spends alternator life exponentially.

Choosing TemperatureLimitF is therefore a genuine trade-off between energy per session and cumulative wear:

  • Raise it if you have verified your sensor placement reads close to true alternator temperature and you need maximum charge rate. Every degree of limit is usable output.
  • Lower it if longevity matters more than charge speed. The heat-balance model predicts that backing off 20–30 °F costs roughly five to ten percent of thermally-limited output while cutting the thermal-aging rate by half or more — the same charge takes somewhat longer, on a machine that lasts substantially longer. The energy-cost side of this prediction has not yet been validated empirically; controlled back-to-back session testing is planned, and this section will be updated with measured results.

Auditing the trade-off

Two on-board systems let you see the consequences of your choice, and they are complementary by design:

  • A continuous physics wear model ages the three components that fail first — winding insulation (Arrhenius thermal-aging law), bearing grease (half-life per 18 °F, accelerated by shaft speed), and brushes (wear proportional to shaft speed) — from measured temperature and speed, and reports remaining life per component. These are honest estimates from a single case sensor plus assumed offsets, not measurements; treat them as relative — a way to compare one limit setting against another. Full mechanics are on Analytics and Advanced Features.
  • A performance high-water-mark record separately measures whether degradation has actually happened: it compares present output against the best the alternator has ever delivered under matching conditions, producing a live charging-system-health percentage and a long-term trend. See Alternator Health.

The model projects wear from temperature and speed but cannot see a slipping belt, a failed diode, or a shorted turn; the high-water-mark comparison catches exactly those. A healthy wear estimate alongside a declining performance trend points to a mechanical fault the thermal model does not cover — which is itself diagnostic.


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: 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 (the bulk voltage acts only as an over-voltage guard) and feeding the current PID net battery current as its input. Useful alongside shore power, or to hold a bank where it is. (The zero-current-hold float mode described above applies this same control law automatically, while leaving the stage machine and re-bulk logic live.)
  • 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, shown as Waveform Generator in the UI) — built-in test-signal generators that exercise the current loop and the voltage loop respectively, offering square, manual-sine, and auto-sweep waveforms and 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 (30 %) that bypasses nearly all protections (the hardware overvoltage backup remains) for getting home on failed sensors.

There is no learning/auto-adaptive control mode in current firmware; the AdjustFieldLearnMode function name is historical. (The knee tracker above does adapt the minimum-duty floor table, but never the control gains or mode.)


Battery state tracking

The accounting layer lives in 5_functions.ino, headlined by UpdateBatterySOC(), which runs every couple of seconds (SOCUpdateInterval) when a battery shunt is present.

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 (ChargedVoltage_Scaled) for a detection time (ChargedDetectionTime) — then snaps SoC to 100%. Search 5_functions.ino for FullChargeDetected.

Two self-calibration mechanisms ride on this. Both are opt-in and off by default, and each corrects exactly one signal:

  • Shunt gain correction (applySocGainCorrection(), enabled by AutoShuntGainCorrection) — on each full-charge event, compares counted capacity against nameplate capacity and nudges a gain factor (DynamicShuntGainFactor) applied to the battery-current reading only. Guarded by a rate limit, plausibility checks, and a maximum step per event so it cannot run away.
  • Alternator current zero correction (zeroFitService() / zeroFitCompute(), enabled by AutoAltCurrentZero) — the Hall sensor's zero drifts with temperature. Once a day (engine and field both off) the firmware fits a line zero(T) = c + b·(T − T_ref) to the field-off zero-drift log, auto-picks whichever temperature — board or alternator — the drift best correlates with, blends it slowly across days, and subtracts the live-temperature value (DynamicAltCurrentZero, hard-clamped to ±3 A) from the alternator clamp reading only. This replaced the old active auto-zero (which briefly forced the field off to capture a resting reading). It runs before the control function each loop pass so the freshly recomputed correction applies the same pass — 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 (BatteryCurrentSource = 3) 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 saveNVSDataFull.


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
Knee-floor learner kneeLearnObserve 6_functions.ino
Current loop and anti-windup currentPID, TrackAppliedOutput 6_functions.ino
Voltage loop and D-term trim cv_I, Icv, VoltageKd 6_functions.ino
Temperature derating tempPID_tick, thermalPenaltyAmps 6_functions.ino
Which limit is binding ctrlLimiter 6_functions.ino
Voltage-class + temp gain scaling recomputeCvGains, computeCvTempScale 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, zeroFitService 5_functions.ino
Charge-time estimates calculateChargeTimes 5_functions.ino