Battery Management¶
State-of-charge tracking, energy accounting, full-charge detection, auto-zero / auto-gain calibration, and charge-time prediction. Lives entirely in 5_functions.ino — UpdateBatterySOC() runs every 2 s (SOCUpdateInterval) from the periodic batch in loop().
The charging state machine (bulk / absorption / float / idle, voltage targets, re-bulk logic) lives in Field Control → Charging-Stage State Machine. This page covers only how SoC and energy are tracked, not how the regulator decides what to do next.
SoC — Coulomb Counting with Peukert and Charge-Efficiency Corrections¶
Per-call wiring (every 2 s)¶
currentBatteryVoltage = getBatteryVoltage(); // returns IBV (INA228)
batteryCurrentForSoC = getBatteryCurrent(); // INA228 Bcur, or VictronCurrent if BatteryCurrentSource == 3
Voltage_scaled = currentBatteryVoltage * 100;
BatteryCurrent_scaled = batteryCurrentForSoC * 100;
BatteryPower_scaled = Voltage_scaled * BatteryCurrent_scaled / 100; // W × 100
All scaled-to-int variables exist so SSE telemetry can pack two decimals as integers in CSV1 without per-field float formatting. Sign convention: positive = charging into the battery; negative = discharging out of it.
Charging branch¶
deltaAh = batteryCurrent_A · elapsedSeconds / 3600; // raw amp-hours
batteryDeltaAh = deltaAh · (ChargeEfficiency_scaled / 1000); // × 10 percent fixed-point
coulombAccumulator_Ah += batteryDeltaAh;
ChargeEfficiency_scaled is percent × 10 (e.g. 990 = 99.0 %). Typical defaults: lead-acid 850, AGM 950, LiFePO4 990, gel 880. User-tunable.
Discharging branch — Peukert correction¶
Threshold: only applies when dischargeCurrent_A > BatteryCapacity_Ah / 100 (i.e. above C/100 rate). Slower discharges follow nameplate capacity directly.
peukertExponent = PeukertExponent_scaled / 100; // user-set, typ 1.05–1.25
peukertThreshold = BatteryCapacity_Ah / 100; // C/100 trigger
currentRatio = PeukertRatedCurrent_A / dischargeCurrent_A;
peukertFactor = pow(currentRatio, peukertExponent - 1);
peukertFactor = constrain(peukertFactor, 0.5, 2.0); // sanity limits
batteryDeltaAh = deltaAh · peukertFactor; // charged accumulator (Peukert reduces effective capacity)
Form: Cp = C20 · (I20 / I)^(n − 1). PeukertRatedCurrent_A is the C-rate the exponent was characterized at (usually the C/20 rate). The 0.5–2.0 clamp is paranoia against user-input combinations that would otherwise apply order-of-magnitude corrections.
Accumulator → SoC¶
if (abs(coulombAccumulator_Ah) >= 0.01) { // 36-second granularity at 1 A net
deltaAh_scaled = int(coulombAccumulator_Ah · 100);
CoulombCount_Ah_scaled += deltaAh_scaled;
coulombAccumulator_Ah -= deltaAh_scaled / 100.0; // retain remainder
}
CoulombCount_Ah_scaled = constrain(0, BatteryCapacity_Ah · 100); // 0..maxAh×100
SOC_percent = int(CoulombCount_Ah_scaled / (BatteryCapacity_Ah · 100) · 10000); // percent × 100, two decimals
Display layer divides SOC_percent by 100 to get a percentage with two decimals.
Full-charge detection (sets SoC = 100 %)¶
Independent of the charging-stage state machine — works from any charging source (solar, shore, alternator).
if (|BatteryCurrent_scaled| ≤ (TailCurrent · BatteryCapacity_Ah / 100) // current ≤ tail
&& Voltage_scaled ≥ ChargedVoltage_Scaled) { // voltage high enough
FullChargeTimer += elapsedSeconds;
if (FullChargeTimer ≥ ChargedDetectionTime) {
SOC_percent = 10000; // 100.00 %
CoulombCount_Ah_scaled = BatteryCapacity_Ah · 100;
FullChargeDetected = true;
if (AutoShuntGainCorrection == 1) applySocGainCorrection();
}
} else { FullChargeTimer = 0; FullChargeDetected = false; }
TailCurrent is in percent of C-rate (e.g. 2 = 2 % of BatteryCapacity_Ah). ChargedVoltage_Scaled is V × 100. ChargedDetectionTime is in seconds. Console message throttled to one per 60 s.
Auto Shunt Gain Correction (AutoShuntGainCorrection)¶
Triggered by full-charge detection (see above). Compares calculated Ah at "full" against nameplate BatteryCapacity_Ah and adjusts DynamicShuntGainFactor. Then every INA228 read multiplies Bcur by this factor.
Guard clauses inside applySocGainCorrection()¶
| Guard | Reason |
|---|---|
AutoShuntGainCorrection == 0 |
Feature off |
now − lastGainCorrectionTime < MIN_GAIN_CORRECTION_INTERVAL |
Throttle — don't react to back-to-back full-charge detections |
calculatedCapacity < 10 || expectedCapacity < 10 |
Implausible — input data must be sane before adjusting |
errorRatio > MAX_REASONABLE_ERROR |
Discrepancy too large to be a calibration issue — likely a sensor or wiring fault |
Adjustment math¶
desiredCorrectionFactor = expectedCapacity / calculatedCapacity;
newFactor = currentFactor · desiredCorrectionFactor;
maxChange = currentFactor · MAX_GAIN_ADJUSTMENT_PER_CYCLE; // rate limit per event
newFactor = constrain(newFactor, currentFactor − maxChange, currentFactor + maxChange);
newFactor = constrain(newFactor, MIN_DYNAMIC_GAIN_FACTOR, MAX_DYNAMIC_GAIN_FACTOR);
DynamicShuntGainFactor = newFactor;
handleSocGainReset() resets DynamicShuntGainFactor = 1.0 when the user presses the reset button (sets ResetDynamicShuntGain). Cleared inside the handler — momentary flag pattern.
Auto-Zero Alternator Current (AutoAltCurrentZero)¶
Periodically forces the field off, captures the resulting Hall-sensor reading, and stores the offset as DynamicAltCurrentZero. Subtracts that on every CH1 read so a slowly-drifting Hall sensor zero (temperature, magnetization, etc.) doesn't accumulate into the alternator-current measurement.
Trigger conditions — checkAutoZeroTriggers()¶
Fires when all of these are true and any trigger condition is hit:
AutoAltCurrentZero == 1(feature on)autoZeroStartTime == 0(not already running)RPM ≥ 200(engine running)
Trigger conditions (OR):
now − lastAutoZeroTime ≥ AUTO_ZERO_INTERVAL(1 h scheduled interval)|currentTemp − lastAutoZeroTemp| ≥ AUTO_ZERO_TEMP_DELTA(20 °F temperature shift since last zero)
Execution — processAutoZero()¶
elapsed = now − autoZeroStartTime;
if (elapsed < AUTO_ZERO_DURATION) {
dutyCycle = MinDuty;
setDutyPercent(MinDuty);
digitalWrite(4, 0); // field hard-off for clean zero
if (elapsed > 2000) { // 2-second settle window
autoZeroAccumulator += MeasuredAmps;
autoZeroSampleCount++;
}
} else {
DynamicAltCurrentZero = autoZeroAccumulator / autoZeroSampleCount;
lastAutoZeroTime = now;
lastAutoZeroTemp = currentTemp;
autoZeroStartTime = 0;
}
The field is held off for the entire AUTO_ZERO_DURATION. While auto-zero is running, selectFieldControlMode() returns MODE_LOCKOUT_RAMP (tick.autoZeroActive); the normal control path doesn't compete.
handleAltZeroReset() resets DynamicAltCurrentZero = 0 on user button press.
Critical ordering note¶
checkAutoZeroTriggers() and processAutoZero() must run before AdjustFieldLearnMode() each loop iteration. The auto-zero hands GPIO4 LOW and rewrites dutyCycle; if the control loop runs first it overwrites those, the zero is invalidated, and DynamicAltCurrentZero ends up reflecting whatever field current was flowing at the time. The required order is enforced in loop():
checkAutoZeroTriggers();
processAutoZero();
TIMED_CALL(ft_AdjustFieldLearnMode, AdjustFieldLearnMode());
Energy Accumulators¶
Four parallel accumulators with float fractions and integer commits. All four exist as both session (resets at boot) and lifetime (*_AllTime, NVS-persisted) pairs.
| Accumulator | Source | Units accumulated as | Commit when |
|---|---|---|---|
ChargedEnergy / _AllTime |
batteryPower_W > 0 (charging) |
Float Wh → int Wh | ≥ 1.0 Wh |
DischargedEnergy / _AllTime |
batteryPower_W < 0 (discharging, abs) |
Float Wh → int Wh | ≥ 1.0 Wh |
AlternatorChargedEnergy / _AllTime |
currentBatteryVoltage · MeasuredAmps (only when alternatorIsOn) |
Float Wh → int Wh | ≥ 1.0 Wh |
SolarChargedEnergy / _AllTime |
VE.Direct PPV |
Float Wh → int Wh | ≥ 1.0 Wh (lives in ReadVEData, see Communication Interfaces) |
The float-fraction-retained pattern is universal here — never lose tiny amounts. Without it, a 50 mW continuous draw (sub-Wh per 2-second tick) would never accumulate; with it, the fraction carries forward across calls and eventually crosses the 1 Wh threshold.
alternatorIsOn = (MeasuredAmps > CurrentThreshold). AlternatorOnTime accumulates in ms and rolls into integer seconds the same way.
Fuel Consumption Estimation (Alternator-Specific)¶
Inside UpdateBatterySOC(), runs only while alternatorIsOn:
const float engineEfficiency = 0.30f; // diesel → mechanical
const float alternatorEfficiency = 0.50f; // mechanical → electrical
const float dieselEnergy_J_per_mL = 36000.0f;
energyJoules = altEnergyDelta_Wh · 3600; // Wh → J
fuelEnergyUsed_J = energyJoules / (engineEfficiency · alternatorEfficiency); // back-divide both stages
fuelUsed_mL = fuelEnergyUsed_J / dieselEnergy_J_per_mL;
fuelUsed_L = fuelUsed_mL / 1000;
Rolls into AlternatorFuelUsed (session L) and AlternatorFuelUsed_AllTime via floor(accumulator · 100) / 100 to retain hundredths of a litre. Hardcoded efficiencies — not user-settable. The 15 % overall efficiency is industry-typical for a small marine diesel + alternator pairing; not the only place where engine fuel use is estimated (the engine itself has its own model in UpdateEngineFuel() covering idle/cruise fuel that's not alternator-attributable).
Charge-Cycle Counter¶
nominalVoltage = (V < 16) ? 12 : (V < 32) ? 24 : 48; // auto-detect system class
batteryCapacity_Wh = BatteryCapacity_Ah · nominalVoltage;
ChargeCycles = ChargedEnergy / batteryCapacity_Wh; // float divide
ChargeCycles_AllTime = ChargedEnergy_AllTime / batteryCapacity_Wh;
One cycle = one full nominal-capacity-Wh of energy charged. Not chemistry-aware — same definition for lead vs lithium. Useful as a relative trend even when absolute values are approximate.
Time-Weighted Averages¶
Three pairs of float accumulators × elapsed-seconds running. Lifetime variants persist in NVS.
| Average | Accumulator | Sample-time | Result |
|---|---|---|---|
| Avg SoC (session) | socAccumulator |
totalSocSampleTime (session seconds) |
AvgSOC |
| Avg SoC (all-time) | socAccumulator_AllTime (NVS) |
totalSocSampleTime_AllTime (NVS) |
AvgSOC_AllTime |
| Avg battery voltage (all-time) | voltageAccumulator_AllTime (NVS) |
totalVoltageSampleTime_AllTime (NVS) |
AvgVoltage_AllTime |
| Avg SOG (session) | speedAccumulator |
totalSpeedSampleTime |
AvgSpeed (in UpdateTravelStatistics) |
| Avg SOG (all-time) | speedAccumulator_AllTime (NVS) |
totalSpeedSampleTime_AllTime (NVS) |
AvgSpeed_AllTime |
AvgSOC = socAccumulator / totalSocSampleTime. Lifetime variant survives boot — the NVS shadow pattern in loadNVSData() restores both numerator and denominator.
Charge-Time Prediction — calculateChargeTimes()¶
Throttled to once per INA_SLOW_INTERVAL_MS (1100 ms). Linear projection from current SoC at current net current — assumes constant current. Replaced every call.
currentAmps = getBatteryCurrent();
currentSoC = SOC_percent / 100.0;
if (currentAmps > 0.01) {
remainingCapacity_Ah = BatteryCapacity_Ah · (100 − currentSoC) / 100;
timeToFullChargeMin = remainingCapacity_Ah / currentAmps · 60;
timeToFullDischargeMin = -999;
} else if (currentAmps < -0.01) {
availableCapacity_Ah = BatteryCapacity_Ah · currentSoC / 100;
timeToFullDischargeMin = availableCapacity_Ah / (-currentAmps) · 60;
timeToFullChargeMin = -999;
} else {
// both -999 = "not applicable"
}
-999 is the sentinel for "irrelevant in this direction." The UI renders it as a dash. There is no curve correction here — late-stage absorption tail tapering is not modeled, so predictions get optimistic as SoC approaches 100 %.
Sources of Truth — getBatteryVoltage() / getBatteryCurrent()¶
float getBatteryVoltage() {
return IBV; // INA228 bus voltage — sole source for control AND for SoC
}
float getBatteryCurrent() {
switch (BatteryCurrentSource) {
case 3: return VictronCurrent; // VE.Direct (user opts in)
default: return Bcur; // INA228 (case 0 and fallback)
}
}
The dropdown for battery-current source affects only SoC display and accumulators — the control loop's MaintainMode feedback path always uses Bcur directly to avoid VE.Direct's 1–2 s lag destabilizing the inner PID. See note inside AdjustFieldLearnMode() MaintainMode block.
BatteryV (ADS1115) is never the SoC voltage source. It exists only for the cross-sensor disagreement check in Safeties → Voltage Sensor Failure.
Critical Zone — Why NVS Saves Aren't Periodic¶
inCriticalZone() returns true when an electrical record was set within lastElectricalRecordMs + 5000 (5 s of safety margin). safeToFlushIO() then requires 5 consecutive seconds out of the critical zone before any pendingSave* flag is allowed to drain.
Why this matters for battery management: CoulombCount_Ah_scaled, ChargedEnergy_AllTime, socAccumulator_AllTime, etc. all live in the storage NVS namespace. Commits cost ~300 ms of Core 1 stall during sector erase. If that lands during the CV loop's 100 ms cadence — especially during a battery voltage transient — the result is a missed control tick at the worst possible moment. So saves are:
- At the field-off edge + 5 s (
fieldOffFlushDone) — guaranteed safe, no current flowing. - At shutdown Phase 2 (
shutdownNVSFlushDone) — ignition off and field cut, single full commit. - At a few one-shot moments (e.g. session-end, factory reset).
- Never during normal loop activity.
Battery-management accumulators therefore live in RAM across the session and survive only via the field-off / shutdown commits. The session-vs-lifetime variable pairs mean acute mid-session data (ChargedEnergy) is still reliable from RAM, only the lifetime accumulator is at risk if power is yanked without an ignition-off transition.
Cross-references¶
- Voltage/current sensor wiring,
IBVfilter → Sensor Systems → INA228 - Charging-stage transitions, bulk/absorption/float/idle → Field Control → Charging-Stage State Machine
- VE.Direct PPV → solar Wh accumulator → Communication Interfaces → Solar Energy Accumulation
- NVS save gates, why saves are field-off-only → System Overview → Storage Strategy
MaintainModenet-zero control,BatteryCurrentSourcesemantics → Field Control → Override Modes