Initial state and hysteresis¶
A cell's open-circuit voltage depends not only on its state of charge but on where it came from: a cell that was just charged rests above one that was just discharged, even at the same SoC. The model tracks this with a cell-level hysteresis state h:
h = -1— the cell was last discharged and rests on the lower OCV envelopeh = +1— the cell was last charged and rests on the upper envelopeh = 0— the mean OCV, half-way between the two- anything in between — a cell that has partly relaxed from one envelope towards the other
run_sim takes it as initialHysteresis. This example shows what it does when the simulation starts from a state of charge, and when it starts from a measured voltage.
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from breathe_simulate import api_interface as api
from breathe_simulate import Cycler
from breathe_simulate import enable_notebook_plotly
enable_notebook_plotly()
The protocol is a slow C/10 charge to the upper cut-off followed by a C/10 discharge to the lower one, so the model's OCV traces the upper envelope and then the lower one whatever state it starts in. The cell's OCV table gives the two envelopes to compare against.
CELL = "Molicel P45B"
V_MAX, V_MIN = 4.2, 2.5
capacity = api.get_eqm_kpis(CELL).capacity
ocv_table = api.get_ocv(CELL)
current_a = capacity / 10
cycler = Cycler(selected_unit="A", cell_capacity=capacity).custom(
[
f"Charge at {current_a:.4g} A until {V_MAX} V",
f"Discharge at {current_a:.4g} A until {V_MIN} V",
],
period="60s",
)
Two plotting helpers used throughout. The three runs lie on top of each other once the hysteresis state has relaxed onto its branch, so each state gets its own line style; a marker shows where each run starts.
# h(0) -> (label, line dash, colour)
HYSTERESIS_CASES = {
-1: ("last discharged", "dot", "#1f77b4"),
0: ("mean OCP", "solid", "#2ca02c"),
1: ("last charged", "dash", "#d62728"),
}
def runs_by_h(batch):
"""{h(0): dynamic data} for a batch swept over initialHysteresis."""
return {
run.get_input_parameters()["hysteresisCell_0"]: next(
iter(run.get_dynamic_data().values())
)
for run in batch
}
def plot_ocv_vs_soc(batch, title):
"""Model OCV against SoC per run, over the cell's OCV envelopes."""
fig = go.Figure()
for h, data in runs_by_h(batch).items():
label, dash, colour = HYSTERESIS_CASES[h]
soc, ocv = data["SoC"], data["Cell open-circuit voltage [V]"]
fig.add_scatter(
x=soc,
y=ocv,
mode="lines",
line=dict(dash=dash, color=colour),
name=f"h(0) = {h:+g} ({label})",
)
fig.add_scatter(
x=[soc[0]],
y=[ocv[0]],
mode="markers",
marker=dict(size=10, color=colour),
showlegend=False,
)
soc_ref = ocv_table["SoC*SoH"]
charge, discharge = (
ocv_table["Charge Voltage [V]"],
ocv_table["Discharge Voltage [V]"],
)
for name, y in (("charge envelope", charge), ("discharge envelope", discharge)):
fig.add_scatter(
x=soc_ref, y=y, mode="lines", line=dict(color="lightgrey"), name=name
)
fig.add_scatter(
x=soc_ref,
y=(charge + discharge) / 2,
mode="lines",
line=dict(color="black", dash="dash", width=1),
name="mean OCV (no hysteresis)",
)
fig.update_layout(
title=title, xaxis_title="SoC", yaxis_title="Cell OCV [V]", height=500
)
return fig
def plot_time_traces(batch, title):
"""Voltage, current and SoC against time, one row each, per run."""
rows = [
("Voltage [V]", "Voltage [V]"),
("Current [A]", "Current [A] (discharge +ve)"),
("SoC", "SoC"),
]
fig = make_subplots(rows=3, cols=1, shared_xaxes=True, vertical_spacing=0.05)
for h, data in runs_by_h(batch).items():
label, dash, colour = HYSTERESIS_CASES[h]
t_min = [t / 60 for t in data["Time [s]"]]
for row, (key, _) in enumerate(rows, start=1):
fig.add_scatter(
x=t_min,
y=data[key],
mode="lines",
line=dict(dash=dash, color=colour),
name=f"h(0) = {h:+g} ({label})",
legendgroup=str(h),
showlegend=row == 1,
row=row,
col=1,
)
for row, (_, axis_title) in enumerate(rows, start=1):
fig.update_yaxes(title_text=axis_title, row=row, col=1)
fig.update_xaxes(title_text="Time [min]", row=3, col=1)
fig.update_layout(title=title, height=750)
return fig
def at_t0(batch):
"""Initial state, SoC and OCV of every run in a batch."""
return pd.DataFrame(
{
"h(0)": h,
"state": HYSTERESIS_CASES.get(h, ("partly relaxed",))[0],
"SoC(t=0)": data["SoC"][0],
"OCV(t=0) [V]": data["Cell open-circuit voltage [V]"][0],
}
for h, data in runs_by_h(batch).items()
)
Starting from a state of charge¶
With initialSoC, the SoC is taken as given and initialHysteresis sets the voltage the cell rests at. Passing a list sweeps the three states in one call.
INITIAL_SOC = 0.3
by_soc = api.run_sim(
base_battery=CELL,
cycler=cycler,
initialSoC=INITIAL_SOC,
initialHysteresis=[-1, 0, 1],
)
table = at_t0(by_soc)
envelope_mv = (table["OCV(t=0) [V]"].max() - table["OCV(t=0) [V]"].min()) * 1e3
print(f"Full hysteresis envelope at SoC {INITIAL_SOC}: {envelope_mv:.1f} mV")
table
Full hysteresis envelope at SoC 0.3: 71.4 mV
| h(0) | state | SoC(t=0) | OCV(t=0) [V] | |
|---|---|---|---|---|
| 0 | -1 | last discharged | 0.3 | 3.506559 |
| 1 | 0 | mean OCP | 0.3 | 3.542263 |
| 2 | 1 | last charged | 0.3 | 3.577967 |
fig = plot_ocv_vs_soc(
by_soc, f"{CELL} — model OCV vs SoC, C/10 cycle (initialised at SoC {INITIAL_SOC})"
)
fig.add_vline(x=INITIAL_SOC, line=dict(color="grey", width=1))
fig
All three runs start on the grey line at SoC 0.3, one hysteresis half-width apart in voltage: the +1 run on the charge envelope, the -1 run on the discharge envelope, the 0 run on the mean. The charge step then drives every run onto the upper envelope, and the discharge step onto the lower one, so the three curves merge once the state has relaxed.
plot_time_traces(
by_soc, f"{CELL} — C/10 charge / discharge, initialised at SoC {INITIAL_SOC}"
)
Starting from a voltage¶
With initialVoltage, the model has to work out the SoC from the voltage, and the answer depends on which OCV branch it reads the voltage on. initialHysteresis picks that branch. All three runs therefore start at the same voltage but at a different SoC: at a given SoC the charge envelope sits above the mean, so a fixed voltage read on it lands lower in SoC; the discharge envelope mirrors that.
The voltage is treated as a rest voltage. The inversion is pure OCV and knows nothing about overpotential, so a voltage measured under load will have that polarisation absorbed into the SoC.
INITIAL_VOLTAGE = 3.6
by_voltage = api.run_sim(
base_battery=CELL,
cycler=cycler,
initialVoltage=INITIAL_VOLTAGE,
initialHysteresis=[-1, 0, 1],
)
table = at_t0(by_voltage).set_index("h(0)")
spread = table.loc[-1, "SoC(t=0)"] - table.loc[1, "SoC(t=0)"]
print(f"SoC spread across the envelope at {INITIAL_VOLTAGE} V: {spread * 100:.1f} %")
table
SoC spread across the envelope at 3.6 V: 6.7 %
| state | SoC(t=0) | OCV(t=0) [V] | |
|---|---|---|---|
| h(0) | |||
| -1 | last discharged | 0.390887 | 3.6 |
| 0 | mean OCP | 0.361726 | 3.6 |
| 1 | last charged | 0.324132 | 3.6 |
fig = plot_ocv_vs_soc(
by_voltage,
f"{CELL} — model OCV vs SoC, C/10 cycle (initialised at {INITIAL_VOLTAGE} V)",
)
fig.add_hline(y=INITIAL_VOLTAGE, line=dict(color="grey", width=1))
fig
plot_time_traces(
by_voltage,
f"{CELL} — C/10 charge / discharge, all initialised at {INITIAL_VOLTAGE} V",
)
If you start a simulation from a voltage you measured on a real cell, choose the state that matches its history: -1 after a discharge, +1 after a charge. The SoC spread printed above is the cost of guessing the branch wrong.
Partly relaxed cells¶
The state is continuous, so a cell that has rested part-way between the envelopes can be described directly. A single value runs a single simulation.
The older initialVoltageOcvType="charge" | "mean" | "discharge" is the same thing restricted to +1 | 0 | -1; it still works but initialHysteresis replaces it.
relaxed = api.run_sim(
base_battery=CELL,
cycler=cycler,
initialSoC=INITIAL_SOC,
initialHysteresis=0.5,
)
at_t0([relaxed])
| h(0) | state | SoC(t=0) | OCV(t=0) [V] | |
|---|---|---|---|---|
| 0 | 0.5 | partly relaxed | 0.3 | 3.560115 |