Battery Life Estimation#

This tutorial demonstrates estimation of how long a system can operate from a battery. sysLoss does not include battery models as such but can interface with user supplied battery models using callback functions. We will revisit the Bluetooth sensor from the first tutorial and evaluate the battery life with different battery models.

from sysloss.components import *
from sysloss.system import System
import numpy as np
import matplotlib.pyplot as plt

System definition#

The Bluetooth Sensor System is reused, including load phases, with the addition of 2D interpolation data for converter efficiency. The system is powered by a rechargeable lithium battery with a capacity of 156mAh and nominal voltage of 3.6V. Battery voltage varies a lot depending on state-of-charge (SOC), so converter efficiencies should reflect this.

CAPACITY = .156 # battery capacity in Ah
VNOMINAL = 3.6 # nominal battery voltage (V)
buck_eff = {"vi": [3.0, 5.0], "io": [1e-6, 1e-5, 1e-4, 1e-3, 1e-2], "eff": [[0.72, 0.89, 0.92, 0.925, 0.93],[0.65, 0.84, 0.87, 0.9, 0.91]]}
boost_eff = {"vi": [3.0, 4.5], "io": [1e-6, 1e-5, 1e-4, 1e-3, 1e-2], "eff": [[0.55, 0.65, 0.72, 0.8, 0.85],[0.62, 0.72, 0.81, 0.85, 0.91]]}
bts = System("Bluetooth sensor", Source("LiPo battery", vo=VNOMINAL, rs=0.0))
bts.add_comp("LiPo battery", comp=Converter("Buck 1.8V", vo=1.8, eff=buck_eff))
bts.add_comp("Buck 1.8V", comp=PLoad("MCU", pwr=13e-3))
bts.add_comp("LiPo battery", comp=Converter("Boost 5V", vo=5.0, eff=boost_eff, iis=3e-6))
bts.add_comp("Boost 5V", comp=RLoss("RC filter", rs=6.8))
bts.add_comp("RC filter", comp=ILoad("Sensor", ii=6e-3))
bts_phases = {"sleep": 3600, "acquire": 2.5, "transmit": 2e-3}
bts.set_sys_phases(bts_phases)
bts.set_comp_phases("Boost 5V", phase_conf=["acquire"])
mcu_pwr = {"sleep": 12e-6, "acquire": 15e-3, "transmit": 35e-3}
bts.set_comp_phases("MCU", phase_conf=mcu_pwr)

Ideal battery#

We start with an ideal battery. Although not invented yet, it has a constant output voltage and zero internal resistance. The battery life is then simply the battery capacity divided by average system current.

bdf = bts.solve()
bdf.tail()
Component Type Parent Phase Vin (V) Vout (V) Iin (A) Iout (A) Power (W) Loss (W) Efficiency (%) Warnings
17 Sensor LOAD RC filter transmit 0.0 0.0 0.0 0.0 0.0 0.0 100.0
18 Buck 1.8V CONVERTER LiPo battery transmit 3.6 1.8 0.010522 0.019444 0.037879 0.002879 92.4
19 MCU LOAD Buck 1.8V transmit 1.8 0.0 0.019444 0.0 0.035 0.0 100.0
20 System total transmit 0.010525 0.03789 0.00289 92.373662
21 System average 0.000017 0.000062 0.000018 46.742804
# divide by 24 to get battery life in days
int(CAPACITY / (bdf[bdf.Component == "System average"]["Iout (A)"].values[0] * 24))
380

The ideal battery should provide power for 380 days. We can make callback functions for the .batt_life() method for an ideal battery as well, and run the battery life simulation.

Note

If the system has load phases, the battery is depleted by looping through the phases in the order they are defined.

cap = CAPACITY

def probe():
    """The probe function returns the current state of the battery"""
    global cap
    return (cap, VNOMINAL, 0.0)

def deplete(t, curr):
    """The deplete function depletes the battery with a current of duration t"""
    global cap
    cap -= t * curr / 3600.0
    if cap < 0.0:
        cap = 0.0
        return (0.0, 0.0, 0.0)
    return (cap, VNOMINAL, 0.0)

bldf = bts.batt_life("LiPo battery", cutoff=3.0, pfunc=probe, dfunc=deplete)

The output of the depletion process is a DataFrame with all the time steps of the simulation. The “Time (s)” column is the accumulated time, and the last row then holds the battery life in seconds.

bldf.tail()
Time (s) Capacity (Ah) Voltage (V) Resistance (Ohm)
27360 3.285482e+07 0.000029 3.6 0.0
27361 3.285842e+07 0.000022 3.6 0.0
27362 3.285842e+07 0.000012 3.6 0.0
27363 3.285842e+07 0.000012 3.6 0.0
27364 3.286202e+07 0.000005 3.6 0.0
int(bldf["Time (s)"].values[-1]/(24*3600))
380

The simulation agrees with the first calculation: 380 days.

Simple battery model#

The ideal battery model is generally too optimistic. We want to add voltage variation as a function for charge level and add some internal resistance. Also, most lithium batteries have a PCM (Protection Control Module) that draws a little current, plus there is self-discharge which is typically a few percent per month. To take these effects into account, we create a battery class.

class LiPo:
    def __init__(self, capacity, nom_volt, resistance, cutoff):
        self.capacity = capacity # Ah
        self.depleted = 0.0 # Ah
        self.rs = resistance # ohm
        self.nom_volt = nom_volt # V
        self.beta = 0.75 # adapt to battery
        self.k1 = 0.01 # adapt to battery
        self.k2 = 0.00003 # adapt to battery
        self.cutoff = cutoff # V
        self.pcm_curr = 1e-6 # protection circuit module (PCM) current
        self.dc_per_month = 0.05 # 5% self-discharge per month

    def calc_res(self):
        return self.rs

    def calc_ocv(self):
        soc = (self.capacity - self.depleted)/self.capacity
        if soc > 0.0:
            ocv = self.nom_volt + self.beta * (soc - 0.5) - self.k1 / soc + self.k2 / (1.001 - soc)
            if ocv < self.cutoff:
                return 0.0
            return ocv
        return 0.0
    
    def probe(self):
        """Callback function"""
        return (self.capacity - self.depleted, self.calc_ocv(), self.calc_res())

    def deplete(self, t, i):
        """Callback function"""
        consumed = i * t
        pcm_loss = self.pcm_curr * t
        soc = (self.capacity - self.depleted)/self.capacity
        self_deplete = soc * self.dc_per_month * t / (30*24*3600)
        self.depleted = min(self.depleted + (consumed + pcm_loss + self_deplete)/ 3600.0, self.capacity)
        return self.probe()

Let’s run another battery life estimation with this simple battery model:

Note

The progress bar will stop before the full battery capacity is depleted if the cutoff voltage is reached.

batt = LiPo(CAPACITY, VNOMINAL, 0.1, 3.0)
bldf2 = bts.batt_life("LiPo battery", cutoff=3.0, pfunc=batt.probe, dfunc=batt.deplete)
int(bldf2["Time (s)"].values[-1]/(24*3600))
343

The estimated battery lifetime is now 343 days, down from 380 days on the ideal battery. We can now look at the battery voltage as a function of time:

Hide code cell source

plt.plot(bldf["Time (s)"]/(3600*24), bldf["Voltage (V)"], label="Ideal battery")
plt.plot(bldf2["Time (s)"]/(3600*24), bldf2["Voltage (V)"], label="Simple battery model")
plt.legend()
plt.grid()
plt.title("Battery life")
plt.xlabel("Time (days)")
plt.ylabel("Voltage (V)");
../_images/c172515ce4b5b095c325cb5ed296321fd67b85d26dee3d92c61cb3062975051f.png

Battery modelling with PyBaMM#

So far, the battery model hasn’t included the chemical reactions that occur inside the battery. Such battery models are complex, and the best option for including advanced battery models is to use a battery simulation package. PyBaMM (Python Battery Mathematical Modelling) is an open-source battery simulation package that sysLoss can interface with.

Start by installing PyBaMM.

%pip install pybamm -q
import pybamm

Next we define the two callback functions needed by the .batt_life() method. We select the parameter set “Ecker2015” that is a 156mAh battery.

model = pybamm.lithium_ion.DFN()
parameter_values = pybamm.ParameterValues("Ecker2015") # 156mAh model
parameter_values.update({"Current function [A]": "[input]"})
sim = pybamm.Simulation(model, parameter_values=parameter_values)
sim.step(1e-6, inputs={"Current function [A]":0.0}) # dummy step to initialize variables

def probe_pb():
    sol = sim.solution
    cap = parameter_values["Nominal cell capacity [A.h]"]
    return (cap - sol["Discharge capacity [A.h]"].entries[-1], sol["Voltage [V]"].entries[-1], sol["Local ECM resistance [Ohm]"].entries[-1])

def deplete_pb(t, curr):
    sim.step(t, solver=pybamm.CasadiSolver(mode="fast"), inputs={"Current function [A]":curr})
    return probe_pb()

If we tried .batt_life() with these callback functions and the Bluetooh sensor system, the simulation would take extremely long and probably fail. PyBaMM is not really made for year-long simulations, the default discharge rate is 1C. So, for a battery that would deplete in a matter of hours or perhaps a few days the PyBaMM callbacks could be used directly.

However, we can use the PyBaMM model to obtain voltage and resistance curves, which we can include in the battery class defined previoulsy. Let’s run the PyBaMM model with a discharge rate of 0.1C and 0.01C:

cap = parameter_values["Nominal cell capacity [A.h]"]
# 0.1C discharge
sim = pybamm.Simulation(model, parameter_values=parameter_values)
sim.solve([0,40000], initial_soc = 1.0, inputs={"Current function [A]": 0.1*cap})
sol_0c1 = sim.solution
# 0.01C discharge
sim = pybamm.Simulation(model, parameter_values=parameter_values)
sim.solve([0,400000], initial_soc = 1.0, inputs={"Current function [A]": 0.01*cap})
sol_0c01 = sim.solution

Let’s take a look at the battery voltage and resistance curves:

Hide code cell source

x01 = np.linspace(0,100,len(sol_0c01["Local ECM resistance [Ohm]"].entries))
plt.plot(x01, sol_0c01["Local ECM resistance [Ohm]"].entries, label="Resistance @1.56mA")
x1 = np.linspace(0,100,len(sol_0c1["Local ECM resistance [Ohm]"].entries))
plt.plot(x1, sol_0c1["Local ECM resistance [Ohm]"].entries, label="Resistance @15.6mA")
plt.xlabel("Discharge (%)")
plt.ylabel("Resistance (Ohm)")
plt.grid()
plt.legend();
../_images/6fb9baef92c258e413bfcc4ca0c219881bb02b75021027f3224f66cf2b08b156.png

Hide code cell source

plt.plot(x01, sol_0c01["Voltage [V]"].entries, label="Voltage @1.56mA")
plt.plot(x1, sol_0c1["Voltage [V]"].entries, label="Voltage @15.6mA")
plt.xlabel("Discharge (%)")
plt.ylabel("Voltage (V)")
plt.grid()
plt.legend();
../_images/d2cedabfb47c924222351a2b742c5de96388dbf693b0bb8a846499345fc097e8.png

The curves are similar, so the 0.1C curves will be used in the battery class. A new class is made with updated methods for voltage and resistance calculations.

class LiPo2(LiPo):
    def calc_res(self):
        soc = (self.capacity - self.depleted)/self.capacity
        return np.interp(1.0 - soc, self.rs[0], self.rs[1])

    def calc_ocv(self):
        soc = (self.capacity - self.depleted)/self.capacity
        return np.interp(1.0 - soc, self.nom_volt[0], self.nom_volt[1])

Finally run .batt_life() with the updated battery class:

frx = sol_0c1["Local ECM resistance [Ohm]"].entries
fvx = sol_0c1["Voltage [V]"].entries
x = np.linspace(0.0, 1.0, len(frx), endpoint=True)
batt2 = LiPo2(cap, (x, fvx), (x, frx), 3.0)
bldf3 = bts.batt_life("LiPo battery", cutoff=3.0, pfunc=batt2.probe, dfunc=batt2.deplete)
int(bldf3["Time (s)"].values[-1]/(24*3600))
345

The battery life estimate is now somewhat in the middle of the two previous estimates. Let’s look at the battery voltage for the three battery models:

Hide code cell source

plt.plot(bldf["Time (s)"]/(3600*24), bldf["Voltage (V)"], label="Ideal battery")
plt.plot(bldf2["Time (s)"]/(3600*24), bldf2["Voltage (V)"], label="Simple battery model")
plt.plot(bldf3["Time (s)"]/(3600*24), bldf3["Voltage (V)"], label="Battery with PyBaMM params")
plt.legend()
plt.grid()
plt.xlabel("Time (days)")
plt.ylabel("Voltage (V)");
../_images/791577e1f4c346a1e7de59fb8262d6d4c0d230d0d6bc7e959316329bdaf23b65.png

Improve battery life#

The current design will last for about a year. How can we improve the design to get longer battery life? Start by examining the energy consumed in each phase.

Tip

When the system is defined with load phases, the values in the “24h energy (Wh)” column are weighted with load phase duration. This allows direct comparison of energy consumption in different load phases.

bdf = bts.solve(energy=True)
bdf[bdf.Component == "System total"].plot.pie(y="24h energy (Wh)", labels=bdf.Phase.unique(), autopct='%1.1f%%');
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[22], line 2
      1 bdf = bts.solve(energy=True)
----> 2 bdf[bdf.Component == "System total"].plot.pie(y="24h energy (Wh)", labels=bdf.Phase.unique(), autopct='%1.1f%%');

File ~/checkouts/readthedocs.org/user_builds/sysloss/envs/latest/lib/python3.11/site-packages/pandas/plotting/_core.py:1959, in PlotAccessor.pie(self, y, **kwargs)
   1953 if (
   1954     isinstance(self._parent, ABCDataFrame)
   1955     and kwargs.get("y", None) is None
   1956     and not kwargs.get("subplots", False)
   1957 ):
   1958     raise ValueError("pie requires either y column or 'subplots=True'")
-> 1959 return self(kind="pie", **kwargs)

File ~/checkouts/readthedocs.org/user_builds/sysloss/envs/latest/lib/python3.11/site-packages/pandas/plotting/_core.py:1185, in PlotAccessor.__call__(self, *args, **kwargs)
   1182             label_name = label_kw or data.columns
   1183             data.columns = label_name
-> 1185 return plot_backend.plot(data, kind=kind, **kwargs)

File ~/checkouts/readthedocs.org/user_builds/sysloss/envs/latest/lib/python3.11/site-packages/pandas/plotting/_matplotlib/__init__.py:71, in plot(data, kind, **kwargs)
     69         kwargs["ax"] = getattr(ax, "left_ax", ax)
     70 plot_obj = PLOT_CLASSES[kind](data, **kwargs)
---> 71 plot_obj.generate()
     72 plt.draw_if_interactive()
     73 return plot_obj.result

File ~/checkouts/readthedocs.org/user_builds/sysloss/envs/latest/lib/python3.11/site-packages/pandas/plotting/_matplotlib/core.py:518, in MPLPlot.generate(self)
    516 self._compute_plot_data()
    517 fig = self.fig
--> 518 self._make_plot(fig)
    519 self._add_table()
    520 self._make_legend()

File ~/checkouts/readthedocs.org/user_builds/sysloss/envs/latest/lib/python3.11/site-packages/pandas/plotting/_matplotlib/core.py:2181, in PiePlot._make_plot(self, fig)
   2177 # labels is used for each wedge's labels
   2178 # Blank out labels for values of 0 so they don't overlap
   2179 # with nonzero wedges
   2180 if labels is not None:
-> 2181     blabels = [
   2182         blank_labeler(left, value)
   2183         for left, value in zip(labels, y, strict=True)
   2184     ]
   2185 else:
   2186     blabels = None

File ~/checkouts/readthedocs.org/user_builds/sysloss/envs/latest/lib/python3.11/site-packages/pandas/plotting/_matplotlib/core.py:2181, in <listcomp>(.0)
   2177 # labels is used for each wedge's labels
   2178 # Blank out labels for values of 0 so they don't overlap
   2179 # with nonzero wedges
   2180 if labels is not None:
-> 2181     blabels = [
   2182         blank_labeler(left, value)
   2183         for left, value in zip(labels, y, strict=True)
   2184     ]
   2185 else:
   2186     blabels = None

ValueError: zip() argument 2 is shorter than argument 1
../_images/b116d57a62f73100763cfd98956c774aff52846104e7aa97c2cb75189e8d31a0.png

The acquire phase consumes the most energy. Let’s say it is acceptable to increase the sensor reading interval from 1 hour to 2 hours. That will save some energy - and now it is the sleep phase that consumes the most energy.

bts_phases = {"sleep": 7200, "acquire": 2.5, "transmit": 2e-3}
bts.set_sys_phases(bts_phases)
bdf2 = bts.solve(energy=True)
bdf2[bdf2.Component == "System total"].plot.pie(y="24h energy (Wh)", labels=bdf2.Phase.unique(), autopct='%1.1f%%');
../_images/b41c05f621cb60981b467f5556a45b23820ec074c7cc9ff77fc80a392bea52a2.png
batt2 = LiPo2(cap, (x, fvx), (x, frx), 3.0)
bldf4 = bts.batt_life("LiPo battery", cutoff=3.0, pfunc=batt2.probe, dfunc=batt2.deplete)

Battery life has been extended to about 500 days by increasing the sensor reading interval to 2 hours.

Hide code cell source

plt.plot(bldf3["Time (s)"]/(3600*24), bldf3["Voltage (V)"], label="Original design")
plt.plot(bldf4["Time (s)"]/(3600*24), bldf4["Voltage (V)"], label="2h sensor reading")
plt.legend()
plt.grid()
plt.xlabel("Time (days)")
plt.ylabel("Voltage (V)");
../_images/a4ffac71db5a2705f81a0417f95c3188c1ea69d67bbe071bf3b8e912512830dd.png

To address the energy consumption in the sleep phase, we could maybe shut down all peripherals in the MCU except for the timer and enable a deep sleep mode. Let’s say this reduces the MCU power from 12uW to 0.85uW.

mcu_pwr = {"sleep": 0.85e-6, "acquire": 15e-3, "transmit": 35e-3}
bts.set_comp_phases("MCU", phase_conf=mcu_pwr)
bdf3 = bts.solve(energy=True)
bdf3[bdf3.Component == "System total"].plot.pie(y="24h energy (Wh)", labels=bdf3.Phase.unique(), autopct='%1.1f%%');
../_images/12da27874ead18604fbcc83204a4c663a5855e642f106fb4bca59ae66ffb8b36.png
batt2 = LiPo2(cap, (x, fvx), (x, frx), 3.0)
bldf5 = bts.batt_life("LiPo battery", cutoff=3.0, pfunc=batt2.probe, dfunc=batt2.deplete)

Hide code cell source

plt.plot(bldf3["Time (s)"]/(3600*24), bldf3["Voltage (V)"], label="Original design")
plt.plot(bldf4["Time (s)"]/(3600*24), bldf4["Voltage (V)"], label="2h sensor reading")
plt.plot(bldf5["Time (s)"]/(3600*24), bldf5["Voltage (V)"], label="2h sensor reading, deep sleep")
plt.legend()
plt.legend()
plt.grid()
plt.xlabel("Time (days)")
plt.ylabel("Voltage (V)");
../_images/efba90ba1658332cfd139153fe02fe3777d4ae02f219eb6a49c3c9d52a2a92df.png

The battery life has now been extended to almost 700 days - quite an improvement.

Summary#

This tutorial explores the .batt_life() method in sysLoss for estimating battery life of the Bluetooth sensor system. Using callback functions sysLoss can integrate with just about any user supplied battery model, from ideal batteries to advanced chemistry models. sysLoss also identifies where the most energy is consumed, which makes system optimization for battery life much simpler.

References#

Python Battery Mathematical Modelling (PyBaMM): [SMT+21]