Skip to content

Charging Control

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 are covered on Alternator Health and Analytics and Advanced Features.


What the regulator 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.

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

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 on every pass of the main loop. The protection and shutdown checks at its top run every pass; the closed-loop body below them runs once per fresh alternator-current sample.


Charge stages in plain English

A battery is charged in stages: deliver maximum current while it can absorb it, hold a fixed voltage while it tops off, then drop to a maintenance voltage (or rest). The stage logic is 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 loop is already running against the bulk target, but with the bus still below it, its output is held at the current ceiling, so the ceilings set the output. 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 0.05 V below target per 12 V of system voltage, reset only well below it) keeps millivolt-level noise from resetting the timer.

Absorption — the voltage-holding phase (CV phase)

The voltage loop (voltageControlActive, on since bulk) now has the bus at its target and holds it there, regulating 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 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 reduces the current from ending absorption on a half-charged bank; another charge source holding the bus above target still counts. The current comes from the regulator's own battery shunt or, on an install without one, from a network battery monitor (stageBattCurrentA()); with neither, 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. The backstop pauses while a followed battery charge-voltage limit is what holds the bus below target.

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 dashboard calls it No Float (idle)) — the field turns off entirely and the regulator rests, monitoring for the battery to sag.
  • Float (UseFloat = 1, Voltage Float) — the voltage loop holds a lower maintenance target (FloatVoltage).
  • Zero-current hold (UseFloat = 2, Zero-Current Float) — 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 degrades to plain float.

Float and idle both monitor 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 are applied in addition: re-bulk is blocked at or above SOC_BlockRebulk_percent and force-allowed at or below SOC_AllowRebulk_percent, so a nearly-full battery is not cycled unnecessarily. Voltage float also returns to bulk when Float Duration runs out (FLOAT_DURATION, 8 hours by default); idle and zero-current hold have no such timer.

Entering Auto mode always starts in bulk (enter_sys_auto()); if the battery is already up at voltage, the stage machine advances to absorption within seconds.

The stage machine is suppressed whenever an override mode (MaintainMode, TargetVoltageMode, the voltage-loop waveform generator, manual) owns the voltage target, and while the main battery voltage reading is stale or ignored (see Operating modes and overrides 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 field from dropping fast from a working level to zero, which would interrupt the RPM reading, and is enforced inside the governor. This floor table can be filled in by hand, or auto-learned: an observer that ships enabled (the knee tracker, kneeLearnEnable, default on — turning it off returns the column to manual editing) measures the field-onset knee — the duty at which the alternator first starts making current — in each RPM bin and rewrites rpmMinDutyTable, setting 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 Charge Rate switch (High/Low, HiLow — the buttons at the top of Setup → Alternator → RPM Table, or the switch-panel wire on data Cable 3 when Physical Panel Override is on) swaps in a parallel, gentler set of cap tables at load time (loadCapTablesForMode()); the Low set defaults to a third (33%) 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, 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 sets the applied duty, TrackAppliedOutput() back-calculates against the loop's unsaturated output and pulls the integrator toward the value that matches the applied duty 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)

In every charge stage except idle (bulk, absorption, and float), and in MaintainMode and TargetVoltageMode, a custom proportional-integral controller (PI) runs: its integrator advances at a fixed cadence (VoltageLoopInterval, 100 ms by default; it fires on the first current-loop tick past that mark, so the measured spacing is about 105 ms, up to ~125 ms), while the proportional path and the derivative trim are recomputed on every current-loop tick (about every 13 ms, up to ~30 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 tracks 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 computes the rate of change of a lightly filtered battery voltage (g_cvKdFiltV, filter CvKdVoltFiltTC); when that slope exceeds a deadband while the bus is above target or less than half a volt below it (CvKdArmV, default 0.5 V), 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 applied at the loop's output, never folded into the integrator (cv_I), so it releases the instant the slope settles.

    The deadband is not one number: it is a line scheduled against the commanded current, clamp(CvKdDbFloor, CvKdDeadbandVps + CvKdDbSlope · command, CvKdDbCeil). Ripple-driven slope noise grows with output, so the deadband has to grow with it. CvKdDeadbandVps is the intercept (the value at zero amps) and CvKdDbSlope the slope; commissioning's ripple-fit step measures both and overwrites them, placing the line a safety margin above the measured ripple. The shipped intercept is the pre-commissioning fallback only, and CvKdDbFloor is the hard runtime minimum below which the evaluated deadband never drops. - 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 moving the integrator toward the value it would need 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 is above the control setpoint. The projection is present 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 is set 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 reaching the over-temperature warning ramp and cut that are set 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 make output oscillate. If temperature data becomes invalid or stale, the penalty holds its last value rather than dropping to zero, and a separate staleness protection cuts the field if the data stays invalid.

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 processes every duty request before it reaches the PWM hardware. It enforces the absolute duty bounds (MinDuty at the bottom, the field ceiling described below at the top) and the RPM minimum-duty floor, then either ramps toward the request at a limited rate (DutyRampRate) or, in bypass, steps straight to it (used during overvoltage collapse, sensor failure, and step tests). The field ceiling's two board ceilings take priority over both floors, so a board ceiling below Min Field holds the field below Min Field. 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()). Manual mode, the plant-identification tests, and an active over-voltage clamp release the floor the same way.

The field ceiling

The most field the regulator will ever command is the lowest of four ceilings, re-solved on every tick and enforced in every mode. Two are settings that describe the alternator: Max Field (%) and Max Field Volts. Two protect the regulator's own field drive: a 10 A field-current ceiling and a field-drive heat ceiling. All four are in Setup → Alternator → Basic → Output Control (the board ceilings as read-only rows), and the one that is limiting right now is marked beside its value.

Max Field (%) is a duty percentage. It is a real, per-bus number: what the box says is what the governor uses. It is the same on every bank voltage, because what it protects is the gate driver. At 100 % duty the high-side bootstrap capacitor cannot refresh, so 99 % is the most the hardware allows.

Max Field Volts is the limit expressed as volts across the field winding, which is the number on an alternator datasheet: windings are specified in volts and ohms, not as a percentage of a bus voltage. Alternator manufacturers commonly reuse one 12 V field winding across their 24 V and 48 V models, so the machine is a 48 V alternator but the part turning inside it is still a 12 V part. Field Winding Voltage in Vessel Info records the winding's rating: 12 V on every system unless it is set otherwise, and never above the system voltage. Max Field Volts follows the winding, not the bank. Setting the winding sets Max Field Volts to 1.25 times its rating (15 V for a 12 V winding, 60 V for a 48 V one), and a change of bank voltage alone leaves it where it is. With a 12 V winding on a 12 V bank the volts cap works out above 99 % and Max Field (%) is what limits. With a 12 V winding on a 24, 36, or 48 V bank the volts cap is the one limiting, at roughly 52, 35, or 26 % duty at absorption voltage. With a winding rated for the bank it works out above 99 % again. The duty it works out to at the present system voltage is shown beside its value, and the box still takes a typed value to match a different field voltage on the datasheet.

Expressing the cap in volts also means it is solved against the measured bus voltage every control tick, so the duty limit rises as the bank voltage sags. A fixed percentage cannot, and a discharged bank at the start of bulk is exactly when the field is wanted most.

The volts cap is a heating limit — it bounds the average voltage, and therefore the I²R the winding has to dissipate. It is not an insulation limit: field drive is switched, so the winding, brushes and slip rings see full system voltage on every pulse regardless of where the cap is set. And the figure is calculated from commanded duty and battery voltage rather than sensed at the field, so true field volts run above it by whatever the cable drop is between the battery sense point and the alternator.

The 10 A field-current ceiling is the field drive's rating. The board has no field-current sensor, so the regulator estimates field current from duty, the measured bus voltage, and Field Resistance: the resistance of the whole field circuit (coil, brushes and slip rings, and field wiring) at working current. The regulator measures it itself with the engine off, holding the field at a few settings and reading the field current from the battery shunt, or from clamp-meter or multimeter readings the owner types in (Initial Settings has the procedure). A 12 V system with no battery shunt can skip the test, and the regulator then uses 2 Ω (below). The ceiling is the duty at which that estimate reaches 10 A. A warm rotor has higher resistance and draws less current at the same duty, so both board ceilings and the Field Amps readout correct the resistance for alternator temperature, from the probe reading against the temperature recorded with the resistance. With no alternator temperature reading, the resistance is used as recorded. Either way the estimate carries a 12 % margin, because the rotor can warm more slowly than the probe, so the ceiling holds the real field current below 10 A while the alternator warms up.

The field-drive heat ceiling keeps the hottest part of the field drive inside its rating. It reads the regulator's own temperature at the power stage (the INA228 battery monitor's die temperature) and estimates, from field current and duty, how much hotter the part that the field wiring loads is running: the freewheel diode's heat spreader on P-type wiring, the field connector on N-type wiring. Field Wiring, in the same settings group, says which one to check; while it is not set, both are checked and the lower ceiling is used. The ceiling falls as the regulator warms and recovers as it cools. It rises as the alternator warms, because a warm field coil draws less current at the same duty. It binds at high field current in a hot location, which in practice means low-resistance coils on 24 and 48 V systems. A cooler location for the regulator raises it.

Both board ceilings depend on the measured resistance and the wiring, which is why commissioning will not continue past the Prerequisites screen until the wiring is chosen (Initial Settings), or past the Field Resistance screen until a resistance is recorded (Field resistance test). The one exception is a 12 V system with no battery shunt, which can skip the test and use 2 Ω. At 12 V that value never makes either ceiling limit the field: the 10 A ceiling is above full duty up to 16 V, and the heat ceiling starts trimming only with the regulator's power stage above 90 °C (194 °F). The Field Amps readout is then approximate. On every other system there is no default resistance. Until one is recorded, the 10 A ceiling is zero and the field stays off. The header shows NO FIELD Ω beside the duty (NO Ω on a narrow screen), the field-off line reads Field Resistance not measured: the field stays off until it is, and the console says so once. An ohmmeter reading does not belong here. At an ohmmeter's few milliamps the brush, slip-ring, and terminal contacts read as extra resistance, so the number reads high, and both ceilings would then allow more field current than they are meant to.

When a board ceiling is what holds the field, the header shows the duty pill with 10 A FIELD or DRIVE HEAT beside it, and tapping either names the limit (ctrlLimiter codes 15 and 16). Neither is an alarm. Both take priority over Min Field (%): when a board ceiling is below it, the field is held below Min Field. Live Data → Alternator → Statistics keeps the time the field has spent at each, lifetime and per session: Field Held at 10 A Limit and Field Held by Drive Heat. Alternator health's high-field, low-output alert measures field against the alternator's own ceiling (Max Field % and Max Field Volts only), so a regulator holding the field down for heat never reads as a weak alternator.

If the drive temperature stops reading. The heat ceiling has no other input, so the die temperature is a required sensor. A sample counts only when it is in range. Once the regulator has been up 15 s, a last valid sample older than 10 s, or none since boot, stops charging: the field ramps down and is cut, the header names regulator temperature sensor not responding, the sensor-fault alarm is raised, and the loss counts toward the two-failed-sensors cut. This holds in Manual and during the field resistance test as well. In Manual the header reads manual field stopped: no regulator drive temperature (INA228) reading; it resumes when the reading returns. Manual duty is still clamped by the heat ceiling, and that ceiling cannot follow the regulator's temperature without the reading, so the console line says the field ramps off in every mode, Manual included. Charging resumes once readings return. Running without the INA228 chip altogether (Main battery voltage under Setup → System → Run Without Failed Sensors), which also disables the main battery voltage, turns the cut off, and so does Limp Home. With the reading gone in either one, the heat ceiling stays at its last value.

Implementation details

ccDutyCeiling() is the single owner: it returns the least of MaxDuty, the duty that MaxFieldVolts works out to against the bus, and the two board ceilings (g_fieldCurDutyCeil, g_fieldHeatDutyCeil). Everything that asks how high duty may go reads that function, including governor_apply(), the field-curve ramp, and the field sweep. setDutyPercent() re-applies the same combined ceiling as the catch-all for the open-loop paths (manual, limp, fault) that bypass the PID, on every bank voltage. altFieldCeiling() is the alternator's share alone (MaxDuty and the volts term), and alternator health reads only that. g_fieldCeilBind records which term binds; when two terms are equal, the setting is recorded.

The bus voltage feeding the volts term is filtered over about two seconds, so the ceiling neither tracks ripple nor follows a load-dump transient into the actuator. The current PID's output limits are re-pushed only when the derived ceiling has moved more than half a duty point: SetOutputLimits() re-clamps the live output and the integrator, which is the wanted anti-windup behavior on a real ceiling change but would make the ceiling itself an actuator if it were done at tick rate. If the volts term would land below MinDuty the range would invert and the per-RPM onset floors would collapse unsatisfiable, so it is floored there and the console says so once.

When the main battery voltage is not measured (Main battery voltage ignored under Run Without Failed Sensors, Limp Home, or a stale main reading while Manual runs), the volts term and both board ceilings are solved against an assumed bus from fieldCeilAssumedBusV(): the higher of the Hardware Shutdown Voltage (VoltageHardwareLimit) and 16 V per 12 V of system class (16, 32, 48, or 64 V). A higher bus works out to a lower duty ceiling, so neither a held reading nor a low guess can loosen a cap. Before the first bus reading after boot, the board ceilings use the same assumed voltage.

updateFieldDerateCeilings() runs right after the volts term, on the same filtered bus, and caps both board ceilings at 99 %. A board ceiling that falls is applied at once; one that rises waits for the half-point deadband, which the volts term applies in both directions. The board ceilings have no MinDuty floor. boardDutyCeiling() takes priority over every duty floor in governor_apply() (Min Field and the per-RPM minimum-field floors), and applyCcOutputLimits() lowers the current PID's bottom bound with it, so a board ceiling below MinDuty holds the field below Min Field and the console says so once. The volts term keeps its MinDuty floor because it describes the alternator. The field-current estimate is (D·V − (1 − D)·0.4 V) / R, the 0.4 V being the freewheel drop over the off-time. For both board ceilings, R is corrected to the probe temperature with copper's 0.393 %/°C: in full below the temperature recorded with the resistance (FieldResTrefC, 30 °C if none was recorded), and at 90 % of the rise above it, with the credited probe temperature capped at 120 °C (248 °F). The corrected resistance is then divided by 1.12 (FD_MARGIN), so the estimate runs about 12 % above the real current: the rotor can warm more slowly than the alternator probe. The heat ceiling scans duty upward from zero in 0.1 % steps and stops at the first failing duty, because connector heating is not monotonic in duty (pin heating rises with duty while diode heating falls). The scan runs 100 duty steps per control pass on inputs latched when it starts, so a full scan finishes within ten passes and a rescan adds at most one slice to any pass. A new scan starts when the die temperature moves 0.25 °C, the bus 0.1 V, or the corrected resistance 0.1 % (about 0.3 °C of alternator temperature), at most once a second, and on the next pass after Field Resistance, its recorded temperature, or Field Wiring changes. The die temperature reaches the heat law through a limiter that passes a rise at once and lets it fall at most 2 °C per second, so a single bad low reading cannot open the ceiling. Until the first valid die sample after boot, the heat ceiling is 99 %.

With no Field Resistance stored (FieldResistance NaN), the 10 A ceiling is 0 %, which holds the field off in every mode, unless the skipped-test default applies. The test screen's skip (/fieldResTest?skip=1, offered only at the 12 V class with no battery shunt) sets FieldResDefault, and while nothing is measured and the class stays 12 V, both ceilings and the readout use a flat 2 Ω (FD_SKIP_R_OHM) with no temperature correction and no 12 % margin. A measured or typed value clears it. A class change away from 12 V leaves it unused, so the field waits for a measurement. The console says once that the resistance has not been measured, and code 15 shows as NO FIELD Ω instead of 10 A FIELD, with no duty pill. The field resistance test is the one thing that drives the field without a resistance. It holds only its own duty for each hold, which replaces the two board ceilings, under Max Field (%) and Max Field Volts. The over-voltage latch, the fast over-voltage cut, the lost-die-temperature cut, and the hardware ON/OFF wire stay active, and a reboot or a lost connection ends the test with the field off. Each hold is planned in field volts, X = D·V − (1 − D)·0.4 V, the numerator of the estimate above, and its duty follows from the live bus. R is the slope of X against field current between the top and middle holds. The intercept is the brush and contact drop. The console logs it and the field-current estimate leaves it out, so the estimate reads high, the safe direction. A test result outside 0.05–100 Ω fails the test, and a typed Field Resistance outside that range is refused with a console line. A test result is recorded with the alternator temperature at the start of the test, from a probe reading no older than 90 s, or 30 °C without one. A typed Field Resistance has to come with the alternator temperature it was measured at (−40 to 150 °C), or the write is refused. The die temperature's freshness stamp (IDX_INA_DIE_TEMP) moves only on an in-range sample past the warm-up discard, and a read whose I²C transfer failed counts as no sample. The warm-up discard drops the first three samples after boot, and again after the INA228 is re-initialized. A lost die reading is field-off reason REASON_DIE_TEMP_LOST (32) and sensor-fault bit 9. In Auto mode it is on the same critical-ramp rung as a stale alternator temperature. In Manual and during the field resistance test it is checked above both rungs (priority 1.95 in both selectFieldControlMode() and selectFieldEventReason(); the test is at 1.97 and Manual at 2). Ignoring the main battery voltage, or Limp Home, suppresses it in every mode.

System voltage and field winding scaling (12 / 24 / 36 / 48 V)

The firmware was originally 12-volt-only; it now runs on 12, 24, 36, or 48 V battery banks. The nominal bank voltage comes from SYSTEM_VOLTAGE_CLASS, set in Vessel Info, and that single value is the sole source of the system voltage class. Battery-side quantities (charge voltages, margins, amps per volt) scale with it alone. Field-side quantities also depend on the field winding's rating, Field Winding Voltage (FieldWindingV, 12, 24, 36, or 48, never above the class), because one duty percent drives a 12 V winding on a 48 V bus four times as hard as it drives a 48 V winding. The firmware combines both into one field scale, fieldScale() = winding volts / SYSTEM_VOLTAGE_CLASS: 1 for a 12 V winding on a 12 V bus or any winding rated for its bus, 0.25 for a 12 V winding on a 48 V bus.

  • Gain normalization. The typed gains are referenced to a 12 V system, and each loop has its own factor. recomputeCcGains() multiplies the current loop's PidKp, PidKi, and PidKd by fieldScale(), so one set of typed gains commands the same share of the winding's rated field per amp of error on every system. How many amps that share of field produces depends on the alternator, which is why Current Control Autotuning measures the plant; it stores its gains divided by fieldScale(), so the gain the loop applies is the one it measured. Once that step is done, a later change of the winding the field settings run on keeps that applied gain: the stored gains move by old winding / new winding, because the alternator itself did not change. Uncommissioned gains follow the winding. recomputeCvGains() multiplies the voltage loop's gains by 12 / SYSTEM_VOLTAGE_CLASS, because that loop turns volts of error into amps, and a battery's volts per amp scale with its series cell count rather than with the field. The loops read the derived "active" gains (PidKp_active, VoltageKp_active, and so on). The voltage-loop gains additionally carry a battery-temperature derate (below); and with CV Gain Source set to Measured (cvGainMode = 1) and a plant fit on record, 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, and only while that stamp came from a measured battery-temperature source (CommissionTempSrc 1 to 4). A fit stamped against the retired board stand-in, or old enough to carry no source stamp at all, makes the derate inert (a flat 1.0, reported as cvTempDerateInert) until the CV plant fit is run again. With no measured battery temperature live, the derate is a flat 1.0 as well. Where the number itself comes from is on Safeties and Protections.
  • The field caps are not class-scaled. MaxDuty is a real, per-bus percentage: the governor uses it verbatim, nothing multiplies it at run time, and it is the same on every class and winding. MaxFieldVolts follows the winding: a winding change sets it to 1.25 × the winding volts, and a class change leaves it alone unless it pulls the winding down with it. Both settings describe the alternator rather than the bank. What changes is the ceiling they produce, because the volts cap is re-solved against the new bus voltage: a 12 V winding's 15 V across a 28.8 V bus is about 52 % duty where it was 99 % across 14.4 V. applyNominalVoltageChange() rescales the charge-profile voltages, the over-voltage margins, and the hard-shutdown threshold, and leaves the two caps alone.
  • Field-duty settings follow the field scale. The firmware's field-duty defaults are 12 V-winding-on-12 V values, multiplied by fieldScale() when the setting is first created: Min Field (MinDuty), Duty Ramp Rate and Shutdown Final Ramp Rate (DutyRampRate, DutySlowRampRate), the Run Without Failed Sensors field duty cap (LimpDutyPct), the damper's Detection Amplitude (HuntTrigPct), the knee tracker's margin, probe step, maximum floor, and field steadiness band, the three alternator-health field-drive gates, the manual-mode duty, the plant-test step, and the alternator-health field sweep's rate and floor. They are stored in real duty percent for the bus, so each box shows the value in use. scaleFieldSideKnobs() converts the rescalable ones in place: by old class / new class on a class change, and by new winding / old winding on a winding change, so each keeps the same share of the winding's rated field. The firmware's own duty constants (the commissioning rest floor and ramps, the field-curve start and steps, the plant-test stabilizer, the test entry floors, the duty rate of the voltage-only Run Without Failed Sensors mode, and the voltage-loop plant fit's 1.5 % step check) carry the same factor at use. KHard, the over-current trim in amps per pack volt, is battery-side and follows the class whatever the winding.
  • The resistance check. A winding above 12 V is used only while the recorded Field Resistance is consistent with it. fieldWindingImplausible() flags the pairing when FieldWindingV / FieldResistance exceeds the field drive's 10 A rating (FD_FIELD_MAX_A): the winding would draw more at its own rating than this regulator can supply, which is what a 12 V winding entered as 24, 36, or 48 V does whenever its resistance is below 2.4, 3.6, or 4.8 Ω. Below 10 A, resistance cannot tell the two windings apart and the check passes. A flagged pairing the owner has not confirmed makes fieldWindingEffective() return 12, so the field-side settings and the field scale stay on 12 V winding values (fieldWindApplied 12 while FieldWindingV keeps the owner's rating), and the console says why. The dashboard asks once per visit; a confirmation is stored as the rating and resistance pair (fieldWindAck), and a new resistance or a new rating clears it. Each answer carries the pairing it was asked about, and the firmware refuses it once that pairing has changed or been answered, so a question left open in a second window cannot be applied later. fieldWindingReconcile() re-runs the check at boot, after a Field Resistance change, after a winding change, and after the owner's answer.
  • Config import. An imported config carrying a different Field Winding Voltage moves the field-side settings onto that winding the same way, right after the class conversion, and the import summary names both windings so the row can be unticked. Field-side values the import does not write (any row left unticked) are first converted onto the file's winding scale, so all of them use one scale. Uncommissioned current-loop gains are referenced to a 12 V winding on a 12 V bus and need no conversion. On a unit with Current Control Autotuning done, its own gains convert the same way, so the measured gain survives the import.
  • The minimum-duty floor table is reset, never rescaled. A class change, or a change of the winding the field settings are scaled for, wipes rpmMinDutyTable back to its flat 0.25 % baseline (so Min Field (%) alone sets the floor) and unlearns the knee tracker (per-bin floors, knees, freeze flags, and the commissioning fit all cleared, both blobs committed). Over one installation's own bus swing the duty knee barely moves, because the field-strength and rectifier-threshold effects cancel, but across a class or winding change both the knee and the field ceiling move, and a floor learned before the change can be at or above the new ceiling. getMinimumFieldForRPM() limits such a floor to the ceiling: it returns the lesser of the two, so what the governor is handed matches what the PWM can deliver, which means a stale high floor is silently not honored at all. It would also collapse the tach-lie arm bar (which is expressed as a fraction of the span from the floor up to the field ceiling) to something unsatisfiable, silently disarming that cut. The re-commission flag a class or winding change raises is an advisory notice that clears no commissioned value, so the reset happens inside both change paths themselves. Re-run the Keep-Alive Floor & Field Decay commissioning step afterwards (Setup → Alternator → Commissioning).

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, the event-driven protections, and (when charge-limit follow is turned on) the voltage and current limits a BMS publishes on the CAN bus, described under External Interfaces. 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, and 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 is mounted (the shipped default assumes a typical case mount). The user-facing guidance for choosing the number is on Initial Settings.

What the regulator targets

The thermal loop trims field drive to hold the measured alternator temperature a small guard band below your configured limit — the roughly 7 °F margin described under the temperature loop above. That margin is not wasted headroom: it is what keeps normal operation clear of the over-temperature ramp-down and cut that are set above the limit (the T1 and T3 tiers on the safeties page). In normal operation neither of those should ever fire — the loop holds the temperature 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 does not reduce output.

Running close to the limit — rather than staying well below 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 is 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 three wear mechanisms — winding-insulation aging (the dominant one), bearing-grease degradation, and brush wear — and their temperature laws are covered on Analytics and Advanced Features; rectifier diodes degrade in the same direction. The practical consequence: running hotter adds amp-hours roughly linearly but shortens 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.

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, bearing grease, and brushes — 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 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 detect 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 immediate field-cut function used by every protection.

Override modes within Auto:

  • MaintainMode — the regulator regulates to 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 at its present state of charge. (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. The current-loop generator offers square, manual-sine, and auto-sweep waveforms; the voltage-loop generator steps the voltage target in a square wave. Both score the response so users can tune gains methodically from the dashboard.
  • System identification (systemID_tick()): an open-loop plant test with all setpoint logic bypassed, either a sine sweep that measures the plant's gain and phase across frequency or a step test that measures its dead time and rise time. It restores the prior state when done.
  • Limp Home (LimpHome, Fixed duty, ignore every sensor under Setup → System → Run Without Failed Sensors; run by runReducedSensorControl()): a last-resort fixed-duty mode (Field duty cap (%), 30 % on a 12 V system, scaled with the system voltage and Field Winding Voltage like the other field-duty settings) that consults no sensor at all; only the INA228 hardware overvoltage alert still protects the bank. It exists for getting home on failed sensors.

There is no learning/auto-adaptive control mode in current firmware; the AdjustFieldLearnMode function name is historical. Two things do adapt themselves while running, and neither changes the mode:

  • The knee tracker above rewrites the minimum-duty floor table. It never changes a gain.
  • The oscillation damper (the hunt governor, HuntGovEnable, off by default — opt-in) derates control gains at run time. It detects a sustained 0.3–2.5 Hz hunt — the signature of the idle-knee plant, where each extra percent of duty sags engine speed and the sag cancels the field gain — tests a confirmed wobble with a single controlled cut (the current loop's integral gain, or the voltage loop's D-term when that is the participant), and keeps the cut only if the wobble measurably responds, stored permanently, indexed by engine speed. An externally forced wobble (a cyclic load, a faulty engine governor) does not respond to the regulator's gains, so that episode is reverted in full. The typed PidKi setting is never rewritten — the derate is a runtime multiplier applied at the single function recomputeCcGains(). The full system — detection, the two levers, the persistent speed map — is on its own page: Oscillation Damper.

Battery state tracking

The accounting layer is in 5_functions.ino; its main function is UpdateBatterySOC(), which runs every couple of seconds (SOCUpdateInterval). It runs on every configuration, shunt or no shunt: the alternator-side statistics it also owns (alternator on-time, alternator energy, fuel used, average bus voltage) need no battery current at all, and skipping the whole function on a shunt-less install silently froze them. The battery-side blocks — charge/discharge energy, coulomb counting and SoC, the capacity tracker, full-charge detection, and average-SoC bookkeeping — gate on HAS_BATT_SHUNT inside the function instead.

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 discharged at high current. Applied only above a minimum discharge rate, with the correction factor held between 0.5 and 2.0.

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 sets SoC to 100%. Search 5_functions.ino for FullChargeDetected.

The alternator current zero correction (zeroFitService() / zeroFitCompute(), enabled by AutoAltCurrentZero) is the one self-calibration mechanism. It is opt-in and off by default, and corrects only the alternator clamp reading. The Hall sensor's zero drifts with temperature. Once a day, with the 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). 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 main loop enforces that ordering.

Energy, fuel, and cycle counters

Parallel accumulators track charged and discharged energy, alternator-sourced energy, and solar energy, each with session and lifetime totals, 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.

Battery voltage and current come from the INA228

getBatteryVoltage() always returns the INA228 bus voltage, and getBatteryCurrent() always returns the INA228 shunt current. A network battery monitor (BattMonSrc, NMEA 2000 or VE.Direct) never feeds that count. When one is selected its own state of charge is shown and used instead (socShownX100()), and with no regulator shunt its current reaches only the two minute-scale stage decisions, the taper exit and the current rebulk (stageBattCurrentA()). Control paths that need battery current (MaintainMode, the charge-current ceiling, load-dump) always use the INA228 directly, because a network source lags by a second or two. That is fine for a stage decision and 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: 20 seconds after the field-enable line physically goes low, and during the shutdown sequence — never in the middle of active control, for the flash-stall reasons on Memory and Persistence. The 20-second gate keys on the physical gate pin, not the duty-derived "field active" signal, because duty reads zero during a live constant-voltage hold with the field still enabled. And the 20 seconds is twice the fast-over-voltage lockout cap (10 s), so repeated cuts and retries during a protection event can never trigger the commit. A second gate at 28 seconds writes the larger charging-system-health and boat-performance blobs to the filesystem, staggered so that write does not overlap the end of the key-value commit. Both re-arm on the next field-on edge. 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
Field ceiling and the board ceilings ccDutyCeiling, updateFieldDerateCeilings 6_functions.ino
Voltage-class + temp gain scaling recomputeCvGains, computeCvTempScale 6_functions.ino
Field winding scale and resistance check fieldScale, scaleFieldSideKnobs, fieldWindingReconcile, recomputeCcGains Xregulator.ino, 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