Skip to content

Battery Management & State Tracking

Overview

The battery management system implements real-time state-of-charge (SOC) calculation using coulomb counting with Peukert correction, charge efficiency compensation, and dynamic calibration. The system tracks energy flow, runtime statistics, and provides comprehensive battery monitoring for lead-acid and lithium battery systems.

State of Charge Calculation

Core Algorithm

The SOC calculation runs every 2 seconds in UpdateBatterySOC():

void UpdateBatterySOC(unsigned long elapsedMillis) {
  float elapsedSeconds = elapsedMillis / 1000.0f;

  // Get current measurements
  float currentBatteryVoltage = getBatteryVoltage();
  float batteryCurrentForSoC = getBatteryCurrent();

  // Update scaled values for web interface
  Voltage_scaled = currentBatteryVoltage * 100;
  BatteryCurrent_scaled = batteryCurrentForSoC * 100;
  AlternatorCurrent_scaled = MeasuredAmps * 100;
  BatteryPower_scaled = (Voltage_scaled * BatteryCurrent_scaled) / 100;

  // Calculate Ah change using floating point precision
  static float coulombAccumulator_Ah = 0.0f;
  float batteryCurrent_A = BatteryCurrent_scaled / 100.0f;
  float deltaAh = (batteryCurrent_A * elapsedSeconds) / 3600.0f;

  if (BatteryCurrent_scaled >= 0) {
    // Charging: apply charge efficiency
    float batteryDeltaAh = deltaAh * (ChargeEfficiency_scaled / 100.0f);
    coulombAccumulator_Ah += batteryDeltaAh;
  } else {
    // Discharging: apply Peukert compensation
    float dischargeCurrent_A = abs(batteryCurrent_A);
    float peukertThreshold = BatteryCapacity_Ah / 100.0f;  // C/100 rate

    if (dischargeCurrent_A > peukertThreshold) {
      float peukertExponent = PeukertExponent_scaled / 100.0f;
      dischargeCurrent_A = constrain(dischargeCurrent_A, 0.1f, BatteryCapacity_Ah);

      float currentRatio = PeukertRatedCurrent_A / dischargeCurrent_A;
      float peukertFactor = pow(currentRatio, peukertExponent - 1.0f);
      peukertFactor = constrain(peukertFactor, 0.5f, 2.0f);

      float batteryDeltaAh = deltaAh * peukertFactor;
      coulombAccumulator_Ah += batteryDeltaAh;
    } else {
      coulombAccumulator_Ah += deltaAh;  // No Peukert correction below C/100
    }
  }

  // Update coulomb count when accumulator reaches 0.01Ah
  if (abs(coulombAccumulator_Ah) >= 0.01f) {
    int deltaAh_scaled = (int)(coulombAccumulator_Ah * 100.0f);
    CoulombCount_Ah_scaled += deltaAh_scaled;
    coulombAccumulator_Ah -= (deltaAh_scaled / 100.0f);
  }

  // Calculate SOC
  CoulombCount_Ah_scaled = constrain(CoulombCount_Ah_scaled, 0, BatteryCapacity_Ah * 100);
  float SoC_float = (float)CoulombCount_Ah_scaled / (BatteryCapacity_Ah * 100.0f) * 100.0f;
  SOC_percent = (int)(SoC_float * 100);  // Store as percentage × 10000 for 2 decimal precision
}

Peukert Correction

The Peukert equation compensates for capacity reduction at high discharge rates:

Standard Form: Cp = C20 * (I20/I)^(n-1)

Implementation: - Threshold: Only applied above C/100 discharge rate - Exponent: User-configurable, typically 1.05-1.25 - Current ratio: PeukertRatedCurrent_A / dischargeCurrent_A - Bounds: Factor constrained to 0.5-2.0 for safety

Example:

Battery: 300Ah, Peukert exponent: 1.12
C/20 rate: 15A, Current discharge: 60A (C/5)
Current ratio: 15/60 = 0.25
Peukert factor: (0.25)^(1.12-1) = (0.25)^0.12 = 0.74
Effective capacity reduction: 26% at C/5 rate

Charge Efficiency

Charging efficiency accounts for energy losses during charge:

if (BatteryCurrent_scaled >= 0) {
  // Charging: apply efficiency factor
  float batteryDeltaAh = deltaAh * (ChargeEfficiency_scaled / 100.0f);
  coulombAccumulator_Ah += batteryDeltaAh;
}

Typical Values: - Lead-acid: 85-95% efficiency - AGM: 90-98% efficiency
- LiFePO4: 95-99% efficiency - Gel: 85-92% efficiency

Current Source Selection

Battery current uses dedicated source selection for SOC accuracy:

float getBatteryCurrent() {
  float batteryCurrent = 0;

  switch (BatteryCurrentSource) {
    case 0:  // INA228 Battery Shunt (default)
      if (INADisconnected == 0 && !isnan(Bcur)) {
        batteryCurrent = Bcur;  // Includes offset and dynamic gain
      }
      break;

    case 1:  // NMEA2K Battery
      // Implementation pending
      batteryCurrent = -Bcur;  // Temporary inversion
      break;

    case 3:  // Victron Battery
      if (abs(VictronCurrent) > 0.1) {
        batteryCurrent = VictronCurrent;  // External source, no inversion
      } else {
        batteryCurrent = Bcur;  // Fallback to INA228
      }
      break;

    default:
      batteryCurrent = Bcur;
      break;
  }

  return batteryCurrent;
}

Full Charge Detection

Detection Algorithm

Full charge resets SOC to 100% when conditions are met:

// Check conditions: low current + high voltage + time
if ((abs(BatteryCurrent_scaled) <= (TailCurrent * BatteryCapacity_Ah / 100)) && 
    (Voltage_scaled >= ChargedVoltage_Scaled)) {

  FullChargeTimer += elapsedSeconds;

  if (FullChargeTimer >= ChargedDetectionTime) {
    SOC_percent = 10000;  // 100.00%
    CoulombCount_Ah_scaled = BatteryCapacity_Ah * 100;
    FullChargeDetected = true;
    coulombAccumulator_Ah = 0.0f;
    queueConsoleMessage("BATTERY: Full charge detected - SoC reset to 100%");
    applySocGainCorrection();  // Trigger dynamic calibration
  }
} else {
  FullChargeTimer = 0;
  FullChargeDetected = false;
}

Detection Parameters

Parameter Default Range Description
TailCurrent 2 1-10 % of capacity for "full" current
ChargedVoltage_Scaled 1450 1200-1600 Voltage threshold (V × 100)
ChargedDetectionTime 1000 60-3600 Time at conditions (seconds)

Example: 300Ah battery, 2% tail current = 6A threshold

Dynamic Calibration

Automatic Shunt Gain Correction

The system learns and corrects shunt measurement errors over time:

void applySocGainCorrection() {
  if (AutoShuntGainCorrection == 0 || AmpSrc != 1) return;

  unsigned long now = millis();
  if (now - lastGainCorrectionTime < MIN_GAIN_CORRECTION_INTERVAL) return;

  // Calculate capacity error
  float expectedCapacity = BatteryCapacity_Ah;
  float calculatedCapacity = CoulombCount_Ah_scaled / 100.0f;

  // Sanity checks
  if (calculatedCapacity < 10 || expectedCapacity < 10) return;

  float errorRatio = abs(expectedCapacity - calculatedCapacity) / expectedCapacity;
  if (errorRatio > MAX_REASONABLE_ERROR) return;  // 20% maximum

  // Calculate new gain factor
  float desiredCorrectionFactor = expectedCapacity / calculatedCapacity;
  float currentFactor = DynamicShuntGainFactor;
  float newFactor = currentFactor * desiredCorrectionFactor;

  // Apply rate limiting
  float maxChange = currentFactor * MAX_GAIN_ADJUSTMENT_PER_CYCLE;  // 5% max
  if (newFactor > currentFactor + maxChange) {
    newFactor = currentFactor + maxChange;
  } else if (newFactor < currentFactor - maxChange) {
    newFactor = currentFactor - maxChange;
  }

  // Apply bounds
  newFactor = constrain(newFactor, MIN_DYNAMIC_GAIN_FACTOR, MAX_DYNAMIC_GAIN_FACTOR);

  DynamicShuntGainFactor = newFactor;
  lastGainCorrectionTime = now;
}

Correction Logic: - Trigger: Every full charge detection - Error calculation: Actual vs expected capacity - Rate limiting: Maximum 5% change per cycle - Bounds: 0.8-1.2 gain factor range - Time interval: Minimum 1 hour between corrections

Automatic Current Zero Calibration

The system automatically zeros alternator current readings:

void checkAutoZeroTriggers() {
  if (AutoAltCurrentZero == 0 || autoZeroStartTime > 0 || RPM < 200) return;

  unsigned long now = millis();
  bool shouldTrigger = false;
  String triggerReason = "";

  // Time-based trigger (1 hour)
  if (now - lastAutoZeroTime >= AUTO_ZERO_INTERVAL) {
    shouldTrigger = true;
    triggerReason = "scheduled interval";
  }

  // Temperature-based trigger (20°F change)
  float currentTemp = (TempSource == 0) ? AlternatorTemperatureF : temperatureThermistor;
  if (lastAutoZeroTemp > -900 && abs(currentTemp - lastAutoZeroTemp) >= AUTO_ZERO_TEMP_DELTA) {
    shouldTrigger = true;
    triggerReason = "temperature change (" + String(abs(currentTemp - lastAutoZeroTemp), 1) + "°F)";
  }

  if (shouldTrigger) {
    startAutoZero(triggerReason);
  }
}

void processAutoZero() {
  if (autoZeroStartTime == 0) return;

  unsigned long elapsed = millis() - autoZeroStartTime;

  if (elapsed < AUTO_ZERO_DURATION) {
    // Force field to minimum and disable
    dutyCycle = MinDuty;
    setDutyPercent((int)dutyCycle);
    digitalWrite(4, 0);

    // Accumulate readings after 2 second settling
    if (elapsed > 2000) {
      autoZeroAccumulator += MeasuredAmps;
      autoZeroSampleCount++;
    }
  } else {
    // Calculate average and restore operation
    if (autoZeroSampleCount > 0) {
      DynamicAltCurrentZero = autoZeroAccumulator / autoZeroSampleCount;
    }

    lastAutoZeroTime = millis();
    lastAutoZeroTemp = (TempSource == 0) ? AlternatorTemperatureF : temperatureThermistor;
    autoZeroStartTime = 0;
    autoZeroAccumulator = 0.0;
    autoZeroSampleCount = 0;
  }
}

Auto-Zero Process: 1. Trigger conditions: 1 hour interval OR 20°F temperature change 2. Zero procedure: 10 seconds at minimum field with settling time 3. Measurement: Average readings over 8-second period 4. Application: New zero offset stored and applied 5. Frequency: Thermal drift compensation

Energy Tracking

Energy Calculation

Real-time energy tracking with precision accumulators:

// Calculate power
float batteryPower_W = BatteryPower_scaled / 100.0f;
float energyDelta_Wh = (batteryPower_W * elapsedSeconds) / 3600.0f;

float alternatorPower_W = (currentBatteryVoltage * MeasuredAmps);
float altEnergyDelta_Wh = (alternatorPower_W * elapsedSeconds) / 3600.0f;

// Energy accumulation with precision preservation
static float chargedEnergyAccumulator = 0.0f;
static float dischargedEnergyAccumulator = 0.0f;
static float alternatorEnergyAccumulator = 0.0f;

if (BatteryCurrent_scaled > 0) {
  // Charging
  chargedEnergyAccumulator += energyDelta_Wh;
  if (chargedEnergyAccumulator >= 1.0f) {
    ChargedEnergy += (int)chargedEnergyAccumulator;
    chargedEnergyAccumulator -= (int)chargedEnergyAccumulator;
  }
} else if (BatteryCurrent_scaled < 0) {
  // Discharging
  dischargedEnergyAccumulator += abs(energyDelta_Wh);
  if (dischargedEnergyAccumulator >= 1.0f) {
    DischargedEnergy += (int)dischargedEnergyAccumulator;
    dischargedEnergyAccumulator -= (int)dischargedEnergyAccumulator;
  }
}

// Alternator energy
if (altEnergyDelta_Wh > 0) {
  alternatorEnergyAccumulator += altEnergyDelta_Wh;
  if (alternatorEnergyAccumulator >= 1.0f) {
    AlternatorChargedEnergy += (int)alternatorEnergyAccumulator;
    alternatorEnergyAccumulator -= (int)alternatorEnergyAccumulator;
  }
}

Energy Variables: - ChargedEnergy: Total energy into battery (Wh) - DischargedEnergy: Total energy out of battery (Wh) - AlternatorChargedEnergy: Total alternator output (Wh)

Fuel Consumption Calculation

Estimates fuel consumption based on alternator energy output:

if (altEnergyDelta_Wh > 0) {
  // Convert energy to fuel consumption
  float energyJoules = altEnergyDelta_Wh * 3600.0f;  // Wh to J
  const float engineEfficiency = 0.30f;             // 30% thermal efficiency
  const float alternatorEfficiency = 0.50f;         // 50% mechanical efficiency
  float fuelEnergyUsed_J = energyJoules / (engineEfficiency * alternatorEfficiency);

  const float dieselEnergy_J_per_mL = 36000.0f;     // Diesel energy content
  float fuelUsed_mL = fuelEnergyUsed_J / dieselEnergy_J_per_mL;

  // Accumulate with precision
  static float fuelAccumulator = 0.0f;
  fuelAccumulator += fuelUsed_mL;
  if (fuelAccumulator >= 1.0f) {
    AlternatorFuelUsed += (int)fuelAccumulator;
    fuelAccumulator -= (int)fuelAccumulator;
  }
}

Fuel Calculation Assumptions: - Engine thermal efficiency: 30% - Alternator mechanical efficiency: 50% - Combined system efficiency: 15% - Diesel energy content: 36 kJ/mL (10 kWh/liter)

Runtime Tracking

Engine Runtime

Tracks engine operation based on RPM:

void UpdateEngineRuntime(unsigned long elapsedMillis) {
  bool engineIsRunning = (RPM > 100 && RPM < 6000);

  if (engineIsRunning) {
    engineRunAccumulator += elapsedMillis;

    if (engineRunAccumulator >= 1000) {  // Update every second
      int secondsRun = engineRunAccumulator / 1000;
      EngineRunTime += secondsRun;

      // Calculate engine cycles (RPM integration)
      EngineCycles += (RPM * secondsRun) / 60;

      engineRunAccumulator %= 1000;  // Keep remainder
    }
  }

  engineWasRunning = engineIsRunning;
}

Alternator Runtime

Tracks alternator active time based on output current:

bool alternatorIsOn = (AlternatorCurrent_scaled > CurrentThreshold * 100);

if (alternatorIsOn) {
  alternatorOnAccumulator += elapsedMillis;
  if (alternatorOnAccumulator >= 60000) {  // Update every minute
    AlternatorOnTime += alternatorOnAccumulator / 60000;
    alternatorOnAccumulator %= 60000;
  }
}

Runtime Variables: - EngineRunTime: Total engine seconds - EngineCycles: RPM × time integration - AlternatorOnTime: Active alternator minutes

Time to Full/Empty Calculation

Calculation Algorithm

Estimates time to full charge or empty discharge:

void calculateChargeTimes() {
  static unsigned long lastCalcTime = 0;
  if (millis() - lastCalcTime < AnalogInputReadInterval) return;
  lastCalcTime = millis();

  float currentAmps = getTargetAmps();

  if (currentAmps > 0.01) {  // Charging
    float currentSoC = SOC_percent / 100.0;  // Convert from scaled format
    float remainingCapacity = BatteryCapacity_Ah * (100.0 - currentSoC) / 100.0;
    timeToFullChargeMin = (int)(remainingCapacity / currentAmps * 60.0);
    timeToFullDischargeMin = -999;  // Not applicable
  } else if (currentAmps < -0.01) {  // Discharging
    float currentSoC = SOC_percent / 100.0;
    float availableCapacity = BatteryCapacity_Ah * currentSoC / 100.0;
    timeToFullDischargeMin = (int)(availableCapacity / (-currentAmps) * 60.0);
    timeToFullChargeMin = -999;  // Not applicable
  } else {
    timeToFullChargeMin = -999;  // No significant current
    timeToFullDischargeMin = -999;
  }
}

Calculation Notes: - Uses current amp rate for projection - Does not account for charge taper or Peukert effects - Provides reasonable estimate for planning purposes - Updates every 2 seconds with current conditions

Charging Stage Management

Bulk/Float Stage Logic

Implements multi-stage charging algorithm:

void updateChargingStage() {
  float currentVoltage = getBatteryVoltage();

  if (inBulkStage) {
    ChargingVoltageTarget = BulkVoltage;  // Typically 13.9V for 12V system

    if (currentVoltage >= ChargingVoltageTarget) {
      if (bulkCompleteTimer == 0) {
        bulkCompleteTimer = millis();
      } else if (millis() - bulkCompleteTimer > bulkCompleteTime) {
        inBulkStage = false;
        floatStartTime = millis();
        queueConsoleMessage("CHARGING: Bulk stage complete, switching to float");
      }
    } else {
      bulkCompleteTimer = 0;
    }
  } else {
    ChargingVoltageTarget = FloatVoltage;  // Typically 13.4V for 12V system

    // Return to bulk if time expires or voltage drops
    if ((millis() - floatStartTime > FLOAT_DURATION * 1000) || 
        (currentVoltage < FloatVoltage - 0.5)) {
      inBulkStage = true;
      bulkCompleteTimer = 0;
      floatStartTime = millis();
      queueConsoleMessage("CHARGING: Returning to bulk stage");
    }
  }
}

Stage Parameters: - BulkVoltage: Higher voltage for fast charging (13.9V default) - FloatVoltage: Lower voltage for maintenance (13.4V default) - bulkCompleteTime: Time at bulk voltage before float (1 second default) - FLOAT_DURATION: Maximum float time before returning to bulk (12 hours default)

Configuration Parameters

Core Battery Parameters

Parameter Default Range Description
BatteryCapacity_Ah 300 50-2000 Battery capacity in amp-hours
PeukertExponent_scaled 105 100-130 Peukert exponent × 100 (1.05)
ChargeEfficiency_scaled 99 80-100 Charging efficiency %
ChargedVoltage_Scaled 1450 1200-1600 Full charge voltage × 100
TailCurrent 2 1-10 % of capacity for full detection
ChargedDetectionTime 1000 60-3600 Time at full conditions (seconds)

Current Measurement

Parameter Default Range Description
ShuntResistanceMicroOhm 100 50-1000 Shunt resistance (µΩ)
BatteryCOffset 0 -50-50 Battery current offset (A)
AlternatorCOffset 0 -50-50 Alternator current offset (A)
CurrentThreshold 1 0.1-10 Minimum current for tracking (A)
InvertBattAmps 0 0-1 Invert battery current polarity
InvertAltAmps 1 0-1 Invert alternator current polarity

Dynamic Calibration

Parameter Default Range Description
AutoShuntGainCorrection 0 0-1 Enable automatic gain correction
AutoAltCurrentZero 0 0-1 Enable automatic zero correction
MIN_GAIN_CORRECTION_INTERVAL 3600000 1800000-86400000 Min time between corrections (ms)
MAX_GAIN_ADJUSTMENT_PER_CYCLE 0.05 0.01-0.2 Max gain change per cycle
AUTO_ZERO_INTERVAL 3600000 1800000-86400000 Auto-zero interval (ms)
AUTO_ZERO_TEMP_DELTA 20.0 5.0-50.0 Temperature change trigger (°F)

Charging Stages

Parameter Default Range Description
BulkVoltage 13.9 12.0-16.0 Bulk charging voltage
FloatVoltage 13.4 12.0-16.0 Float charging voltage
bulkCompleteTime 1000 100-10000 Time at bulk before float (ms)
FLOAT_DURATION 43200 3600-86400 Float duration (seconds)

Data Storage and Persistence

NVS Storage

Critical battery data is stored in non-volatile storage:

void saveNVSData() {
  nvs_handle_t nvs_handle;
  esp_err_t err = nvs_open("storage", NVS_READWRITE, &nvs_handle);

  // Battery state
  nvs_set_i32(nvs_handle, "SOC_percent", (int32_t)SOC_percent);
  nvs_set_i32(nvs_handle, "CoulombCount", (int32_t)CoulombCount_Ah_scaled);

  // Energy tracking
  nvs_set_u32(nvs_handle, "ChargedEnergy", (uint32_t)ChargedEnergy);
  nvs_set_u32(nvs_handle, "DischrgdEnergy", (uint32_t)DischargedEnergy);
  nvs_set_u32(nvs_handle, "AltChrgdEnergy", (uint32_t)AlternatorChargedEnergy);
  nvs_set_i32(nvs_handle, "AltFuelUsed", (int32_t)AlternatorFuelUsed);

  // Runtime tracking
  nvs_set_i32(nvs_handle, "EngineRunTime", (int32_t)EngineRunTime);
  nvs_set_i32(nvs_handle, "EngineCycles", (int32_t)EngineCycles);
  nvs_set_i32(nvs_handle, "AltOnTime", (int32_t)AlternatorOnTime);

  // Dynamic calibration
  nvs_set_blob(nvs_handle, "ShuntGain", &DynamicShuntGainFactor, sizeof(float));
  nvs_set_blob(nvs_handle, "AltZero", &DynamicAltCurrentZero, sizeof(float));

  nvs_commit(nvs_handle);
  nvs_close(nvs_handle);
}

Persistent Data: - SOC state and coulomb count - Energy totals (charged/discharged/alternator) - Runtime statistics - Dynamic calibration factors - Fuel consumption estimates

Update Intervals

Function Interval Purpose
UpdateBatterySOC 2000ms Core SOC calculation
calculateChargeTimes 2000ms Time to full/empty
saveNVSData 10000ms Persistent storage
applySocGainCorrection On full charge Dynamic calibration
processAutoZero 1 hour / temp change Current zero calibration

Error Handling

Sensor Validation

Battery current measurement includes extensive validation:

float getBatteryCurrent() {
  static unsigned long lastWarningTime = 0;
  const unsigned long WARNING_INTERVAL = 10000;

  switch (BatteryCurrentSource) {
    case 0:  // INA228
      if (INADisconnected == 0 && !isnan(Bcur)) {
        return Bcur;
      } else {
        if (millis() - lastWarningTime > WARNING_INTERVAL) {
          queueConsoleMessage("WARNING: INA228 unavailable, using fallback");
          lastWarningTime = millis();
        }
        return 0;  // Safe default
      }
      break;

    case 3:  // Victron
      if (abs(VictronCurrent) > 0.1) {
        return VictronCurrent;
      } else {
        queueConsoleMessage("Victron current not available, using INA228");
        return Bcur;
      }
      break;
  }
}

Bounds Checking

All SOC calculations include bounds enforcement:

// Constrain coulomb count to physical limits
CoulombCount_Ah_scaled = constrain(CoulombCount_Ah_scaled, 0, BatteryCapacity_Ah * 100);

// Constrain SOC percentage
float SoC_float = (float)CoulombCount_Ah_scaled / (BatteryCapacity_Ah * 100.0f) * 100.0f;
SOC_percent = (int)(SoC_float * 100);  // 0-10000 range (0.00-100.00%)

// Constrain Peukert factor
peukertFactor = constrain(peukertFactor, 0.5f, 2.0f);

// Constrain dynamic gain factor
DynamicShuntGainFactor = constrain(DynamicShuntGainFactor, 
                                  MIN_DYNAMIC_GAIN_FACTOR, 
                                  MAX_DYNAMIC_GAIN_FACTOR);

Recovery Procedures

System includes automatic recovery from various failure modes:

  • Sensor failure: Automatic fallback to secondary sensors
  • Invalid readings: Bounds checking and error indication
  • Calibration drift: Automatic gain and zero correction
  • Power cycle: Full state restoration from NVS storage
  • Coulomb counter overflow: Reset to known state at full charge

The battery management system provides comprehensive monitoring and state tracking with automatic calibration and error recovery, enabling accurate SOC determination across a wide range of operating conditions and battery types.