Lab Notebook Entry #12

30 cycles, 133 hours, and now a UPS!
lab notebook
research
flow batteries
doi
Author

Kirk Pollard Smith

Published

March 31, 2026

Continuing from Lab Notebook Entry 11. I am getting annoyed, the power cut out again so the cell stopped. Thankfully the UPS has arrived and I’ve plugged it in and restarted the test for a fourth time! Another two files to add to this long test. I am getting bored and annoyed with it, because of the power cuts. Which is a good sign, since it’s the longest test I’ve done since my PhD!

Up to 30 cycles and 130 hours now. I have changed Figure 1 to have a color gradient and fewer legend entries to make it easier to read and see the direction of change in potentials with cycling.

Code
import pandas as pd
from tqdm import tqdm, notebook
import numpy as np
import scipy
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import kaleido
from IPython.display import Image

tqdm_disabled = False  # True for website, change to False for local work

sampling = True

MIN_POINTS0 = 500
DIFF_LIMIT = 0.1


# electrolyte component masses, in g
MASS_ZnCl2  = 1.40
MASS_NH4Cl  = 1.08
MASS_KI     = 3.32
MASS_H2O    = 8.51
MASS_TriEG  = 0.58

total_mass_kg = (MASS_ZnCl2 + MASS_KI + MASS_H2O + MASS_TriEG)/1000.

TOTAL_VOLUME = 11  # electrolyte volume in mL, approx, measured by taking as much electrolyte as possible up into a 12 mL syringe

MASS_TO_RESERVOIRS = 14.75 # g of electrolyte actually loaded into system, based on weighing syringe before/after loading reservoirs
# molecular weights in g/mol

density = MASS_TO_RESERVOIRS/TOTAL_VOLUME

MW_ZnCl2  = 136.315
MW_NH4Cl  = 53.49
MW_KI     = 166.0028 
MW_H2O    = 18.01528
MW_TriEG  = 150.174

molality_ZnCl2 = MASS_ZnCl2/MW_ZnCl2/total_mass_kg
molality_NH4Cl = MASS_NH4Cl/MW_NH4Cl/total_mass_kg
molality_KI = MASS_KI/MW_KI/total_mass_kg
molality_TriEG = MASS_TriEG/MW_TriEG/total_mass_kg
molality_H2O = MASS_H2O/MW_H2O/total_mass_kg

molarity_ZnCl2 = MASS_ZnCl2/MW_ZnCl2/TOTAL_VOLUME*1000.
molarity_NH4Cl = MASS_NH4Cl/MW_NH4Cl/TOTAL_VOLUME*1000.
molarity_KI = MASS_KI/MW_KI/TOTAL_VOLUME*1000.
molarity_TriEG = MASS_TriEG/MW_TriEG/TOTAL_VOLUME*1000.
molarity_H2O = MASS_H2O/MW_H2O/TOTAL_VOLUME*1000.



filenames = ["../lab-notebook-9/23-03-2026-KPS-5.zip", "../lab-notebook-11/23-03-2026-KPS-7.zip", "29-03-2026-KPS-8.zip", "29-03-2026-KPS-9.zip"]



all_data = []
for f in filenames:
    if len(all_data) == 0:
        all_data.append(pd.read_csv(f, delimiter="\t").dropna())
    else:
        df0 = pd.read_csv(f, delimiter="\t").dropna()
        df0["Elapsed time(s)"] += all_data[-1]["Elapsed time(s)"].iat[-1]
        all_data.append(df0)


df = pd.concat(all_data, ignore_index=True)

if not tqdm_disabled:
    print("Electrolyte Composition:")


    print("Molarities (moles/L solution): {:.2f} M ZnCl~2~, {:.2f} M NH~4~Cl, {:.2f} M KI, {:.2f} M triethylene glycol, {:.2f} M H~2~O\n".format(molarity_ZnCl2, molarity_NH4Cl, molarity_KI, molarity_TriEG, molarity_H2O))
    print("Molalities (moles/kg solution): {:.2f} m ZnCl~2~, {:.2f} m NH~4~Cl, {:.2f} m KI, {:.2f} m triethylene glycol, {:.2f} m H~2~O\n".format(molality_ZnCl2, molality_NH4Cl, molality_KI, molality_TriEG, molality_H2O))
    print("Density approx. {:.1f} g/mL\n".format(density))
    print("Experiment length: {:.1f} hours".format(all_data[-1]["Elapsed time(s)"].iat[-1]/3600.))


df["mean_current"] = df["Current(A)"].rolling(3).mean()
df["prev_current"] = df["mean_current"].shift(-1)
df["VChange"] = df["Potential(V)"].diff().abs()
df["is_change"] = (
    ((df["mean_current"] > 0) & (df["prev_current"] < 0))
    | ((df["mean_current"] < 0) & (df["prev_current"] > 0))
).astype(int)

idx_changes = list(df[df["is_change"] == 1].index)
idx_changes.append(len(df) - 1)

all_curves = []
idx_start = 0
for idx in tqdm(idx_changes, disable=tqdm_disabled):
    if len(df.iloc[idx_start:idx, :]) > 50:
        all_curves.append(df.iloc[idx_start:idx, :])
    idx_start = idx

results = []
n_curves = np.max([1, int(np.floor(len(all_curves) / 2))])

for CN in notebook.tnrange(n_curves, disable=tqdm_disabled):
    CURVE_N1 = CN * 2
    CURVE_N2 = CN * 2 + 1

    # Process charge data

    if sampling:
        N_TERM_POINTS = int(np.min([MIN_POINTS0, len(all_curves[CURVE_N1]) / 2.0]))
        MIN_POINTS = int(
            np.min([MIN_POINTS0, len(all_curves[CURVE_N1]) - N_TERM_POINTS * 2])
        )

        df0 = pd.concat(
            [
                all_curves[CURVE_N1].iloc[:N_TERM_POINTS],
                all_curves[CURVE_N1]
                .iloc[N_TERM_POINTS:-N_TERM_POINTS]
                .sample(n=MIN_POINTS),
                all_curves[CURVE_N1].iloc[-N_TERM_POINTS:],
            ]
        ).sort_values("Elapsed time(s)", ascending=True)
        df0 = df0[df0["VChange"] < DIFF_LIMIT]
    else:
        df0 = all_curves[CURVE_N1].copy()
    df0["mAh"] = np.abs(
        scipy.integrate.cumulative_trapezoid(
            df0["Current(A)"], df0["Elapsed time(s)"], initial=0
        )
        * 1000.0
        / 3600.0
    )
    total_energy0 = scipy.integrate.cumulative_trapezoid(
        df0["Current(A)"].abs() * df0["Potential(V)"],
        df0["Elapsed time(s)"],
        initial=0.0,
    )[-1]

    # Process discharge data
    if sampling:
        N_TERM_POINTS = int(np.min([MIN_POINTS0, len(all_curves[CURVE_N2]) / 2.0]))
        MIN_POINTS = int(
            np.min([MIN_POINTS0, len(all_curves[CURVE_N2]) - N_TERM_POINTS * 2])
        )
        df1 = pd.concat(
            [
                all_curves[CURVE_N2].iloc[:N_TERM_POINTS],
                all_curves[CURVE_N2]
                .iloc[N_TERM_POINTS:-N_TERM_POINTS]
                .sample(n=MIN_POINTS),
                all_curves[CURVE_N2].iloc[-N_TERM_POINTS:],
            ]
        ).sort_values("Elapsed time(s)", ascending=True)
        df1 = df1[df1["VChange"] < DIFF_LIMIT]
    else:
        df1 = all_curves[CURVE_N2].copy()

    df1["mAh"] = np.abs(
        scipy.integrate.cumulative_trapezoid(
            df1["Current(A)"], df1["Elapsed time(s)"], initial=0.0
        )
        * 1000.0
        / 3600.0
    )
    total_energy1 = scipy.integrate.cumulative_trapezoid(
        df1["Current(A)"].abs() * df1["Potential(V)"],
        df1["Elapsed time(s)"],
        initial=0.0,
    )[-1]

    CE = 100.0 * (df1["mAh"].iloc[-1] / df0["mAh"].iloc[-1])
    EE = 100.0 * (total_energy1 / total_energy0)
    VE = 100.0 * EE / CE
    results.append(
        {
            "Number": CN + 1,
            "CE": CE,
            "VE": VE,
            "EE": EE,
            "Charge_potential": df0["Potential(V)"].mean(),
            "Discharge_potential": df1["Potential(V)"].mean(),
            "Charge_stored": df1["mAh"].iloc[-1] / TOTAL_VOLUME,
            "Energy_density_discharge": total_energy1 / TOTAL_VOLUME / 3600.0 * 1000,
        }
    )

    # Save the modified DataFrames back to the all_curves list
    all_curves[CURVE_N1] = df0
    all_curves[CURVE_N2] = df1

results_df = pd.DataFrame(results)

if not tqdm_disabled:
    print(results_df)
    print("")
    print(results_df.mean())





# Color gradient for charge curves
charge_colors = [px.colors.sequential.Blues[int(i)] for i in np.linspace(3, len(px.colors.sequential.Blues)-1, n_curves)]
discharge_colors = [px.colors.sequential.Greys[int(i)] for i in np.linspace(3, len(px.colors.sequential.Greys)-1, n_curves)]



# Plot charge/discharge curves
fig1 = go.Figure()
for CN in range(n_curves):
    CURVE_N1 = CN * 2
    CURVE_N2 = CN * 2 + 1
    fig1.add_trace(
        go.Scatter(
            x=all_curves[CURVE_N1]["mAh"] / TOTAL_VOLUME,
            y=all_curves[CURVE_N1]["Potential(V)"],
            mode="lines",
            name=f"Charge {CN+1}",
            line=dict(color=charge_colors[CN], dash="solid"),
            showlegend=False,
        )
    )
    fig1.add_trace(
        go.Scatter(
            x=all_curves[CURVE_N2]["mAh"] / TOTAL_VOLUME,
            y=all_curves[CURVE_N2]["Potential(V)"],
            mode="lines",
            name=f"Discharge {CN+1}",
            line=dict(color=discharge_colors[CN], dash="solid"),
            showlegend=False,
        )
    )
fig1.update_layout(
    xaxis_title="Capacity (Ah/L)",
    yaxis_title="Potential (V)",
    legend=dict(
        orientation="h",
        yanchor="bottom",
        y=0.02,
        xanchor="right",
        x=0.99
    ),
    hoverlabel=dict(
        bgcolor="white",
    ),
    xaxis=dict(range=[-1, 10]),


)

fig1.add_trace(go.Scatter(
    x=[None], y=[None],
    mode="lines",
    line=dict(color=charge_colors[0], dash="solid"),
    name="Charge (cycle 1)"
))
fig1.add_trace(go.Scatter(
    x=[None], y=[None],
    mode="lines",
    line=dict(color=charge_colors[-1], dash="solid"),
    name=f"Charge (cycle {n_curves})"
))
fig1.add_trace(go.Scatter(
    x=[None], y=[None],
    mode="lines",
    line=dict(color=discharge_colors[0], dash="solid"),
    name="Discharge (cycle 1)"
))
fig1.add_trace(go.Scatter(
    x=[None], y=[None],
    mode="lines",
    line=dict(color=discharge_colors[-1], dash="solid"),
    name=f"Discharge (cycle {n_curves})"
))

fig1.show()
Electrolyte Composition:
Molarities (moles/L solution): 0.93 M ZnCl~2~, 1.84 M NH~4~Cl, 1.82 M KI, 0.35 M triethylene glycol, 42.94 M H~2~O

Molalities (moles/kg solution): 0.74 m ZnCl~2~, 1.46 m NH~4~Cl, 1.45 m KI, 0.28 m triethylene glycol, 34.21 m H~2~O

Density approx. 1.3 g/mL

Experiment length: 133.1 hours

  0%|          | 0/62 [00:00<?, ?it/s]
100%|██████████| 62/62 [00:00<00:00, 3582.50it/s]
    Number         CE         VE         EE  Charge_potential  \
0        1  74.482643  91.515106  68.162869          1.219012   
1        2  76.057505  91.286123  69.429947          1.338136   
2        3  80.398324  91.043094  73.197122          1.310715   
3        4  82.489030  90.817890  74.914796          1.330804   
4        5  83.273763  90.819509  75.628822          1.334675   
5        6  78.649579  91.397348  71.883630          1.307738   
6        7  76.014819  91.970109  69.910912          1.078232   
7        8  79.179519  91.789011  72.678097          1.071767   
8        9  81.089355  91.545199  74.233411          1.179022   
9       10  81.718260  91.489238  74.763414          1.200545   
10      11  78.985307  91.682724  72.415881          1.202466   
11      12  75.600503  92.466765  69.905340          0.951631   
12      13  78.416888  92.137908  72.251680          0.955001   
13      14  80.291185  91.727961  73.649467          1.092070   
14      15  80.818304  91.639152  74.061209          1.168084   
15      16  81.209926  91.361529  74.194630          1.201205   
16      17  73.750208  93.347566  68.844024          1.093185   
17      18  75.560425  92.120381  69.606551          0.963568   
18      19  78.502346  92.030151  72.245828          0.965404   
19      20  80.423488  91.580144  73.651946          1.084080   
20      21  81.209364  91.388007  74.215620          1.197063   
21      22  79.734473  91.572440  73.014802          1.178487   
22      23  78.839960  91.830923  72.399463          1.071032   
23      24  79.687224  91.748878  73.112134          1.083358   
24      25  45.468285  93.164442  42.360274          1.138986   
25      26  72.167296  91.380676  65.946963          0.977492   
26      27  73.953292  93.146215  68.884692          0.962745   
27      28  78.112243  92.321713  72.114562          0.974684   
28      29  80.177900  91.753190  73.565782          1.013402   
29      30  76.948268  92.164553  70.919028          1.170233   

    Discharge_potential  Charge_stored  Energy_density_discharge  
0              1.090613       6.774120                  8.408337  
1              1.038782       6.916357                  8.581618  
2              1.002252       7.310167                  9.069730  
3              1.022532       7.499785                  9.293038  
4              0.960228       7.571819                  9.380031  
5              0.857035       7.150637                  8.897342  
6              0.928244       6.911116                  8.618815  
7              0.969775       7.199490                  8.973821  
8              1.000644       7.372071                  9.189240  
9              0.991448       7.429036                  9.261015  
10             0.857087       7.180640                  8.969514  
11             0.857255       6.872911                  8.580415  
12             0.918829       7.129080                  8.913754  
13             1.011068       7.299396                  9.138267  
14             1.036943       7.347251                  9.205696  
15             0.962051       7.382740                  9.237081  
16             1.263342       6.704725                  8.538769  
17             0.858537       6.860030                  8.623629  
18             0.863901       7.136810                  8.979901  
19             0.942023       7.311338                  9.203485  
20             0.936892       7.382838                  9.297663  
21             0.864936       7.248732                  9.147733  
22             0.914456       7.167453                  9.063532  
23             0.899418       7.244397                  9.170641  
24             1.291259       4.133612                  5.327451  
25             0.869856       6.562899                  8.248262  
26             0.876415       6.711665                  8.548644  
27             0.877896       7.101298                  9.069647  
28             0.891939       7.288998                  9.313567  
29             1.292730       6.995464                  9.010973  

Number                      15.500000
CE                          77.440323
VE                          91.807931
EE                          71.072096
Charge_potential             1.127161
Discharge_potential          0.971613
Charge_stored                7.039896
Energy_density_discharge     8.842054
dtype: float64
(a) Charge/discharge curves
(b)
Figure 1
Code
# Plot efficiency
fig2 = px.scatter(
    results_df,
    x="Number",
    y=["VE", "CE", "EE"],
    labels={"value": "Efficiency (%)", "variable": "Metric"},
)
fig2.update_traces(mode="markers")


fig2.update_layout(
    yaxis=dict(range=[-10, 100]),
    legend=dict(
        orientation="v",
        yanchor="bottom",
        y=0.02,
        xanchor="right",
        x=0.99
    )
)

fig2.add_annotation(
    x=25, y=45.46,
    text="Power Cut",
    showarrow=True,
    arrowhead=1
)


fig2.show()
Figure 2: Charge/discharge efficiencies

I’m also adapting some of Daniel’s visualization and showing here the max, mean, and 80% of max capacity values, in Wh/L, in Figure 3. The number of cycles before a battery hits 80% of its maximum capacity, n80, is a useful metric.

Code
fig5 = px.scatter(
    results_df,
    x="Number",
    y="Energy_density_discharge",
    labels={
        "Energy_density_discharge": "Energy Density on Discharge (Wh/L)",
        "Number": "Cycle Number",
    },
    range_y=[0,1.2*max(results_df["Energy_density_discharge"])]
)

max_val = results_df["Energy_density_discharge"].max()
mean_val = results_df["Energy_density_discharge"].mean()

fig5.add_hline(y=max_val, line_dash="dash", line_color="black", annotation_text="Max", annotation_position="top right",annotation_font_size = 10)
fig5.add_hline(y=mean_val, line_dash="dash", line_color="blue", annotation_text="Mean", annotation_position="top right",annotation_font_size = 10)
fig5.add_hline(y=0.8*max_val, line_dash="dash", line_color="red", annotation_text="80% Max", annotation_position="top right",annotation_font_size = 10)

fig5.add_annotation(
    x=25, y=5.32,
    text="Power Cut",
    showarrow=True,
    arrowhead=1
)

fig5.show()
Figure 3: Discharge energy densities

Also, plotting the normalized average potential of each cycle vs. the first cycle, in Figure 4

Code
norm_charge = results_df["Charge_potential"] / results_df["Charge_potential"].iloc[0]
norm_discharge = results_df["Discharge_potential"] / results_df["Discharge_potential"].iloc[0]

fig6 = go.Figure()
fig6.add_trace(go.Scatter(
    x=results_df["Number"],
    y=norm_charge,
    mode="lines+markers",
    name="Charge Potential"
))
fig6.add_trace(go.Scatter(
    x=results_df["Number"],
    y=norm_discharge,
    mode="lines+markers",
    name="Discharge Potential"
))
fig6.update_layout(
    xaxis_title="Cycle Number",
    yaxis_title="Normalized Potential",
    legend=dict(
        orientation="v",
        yanchor="top",
        y=0.95,
        xanchor="left",
        x=0.05
    )
)

fig6.show()
Figure 4: Average charge and discharge potentials normalized to first cycle

I am removing the power cut cycle from the following analysis.

Code
table_df = (
    results_df.drop(24).describe().loc[["mean", "std"]]
    .round(decimals=1)
    .drop(columns=["Number", "Charge_potential", "Discharge_potential"])
    .rename(
        columns={
            "CE": "Coulombic Efficiency (%)",
            "EE": "Energy Efficiency (%)",
            "VE": "Voltaic Efficiency (%)",
            "Charge_stored": "Discharge Capacity (Ah/L)",
            "Energy_density_discharge": "Energy Density (Wh/L)",
        }
    )
)

# Bar chart of efficiencies with error bars
eff_cols = ["Coulombic Efficiency (%)", "Voltaic Efficiency (%)", "Energy Efficiency (%)"]
eff_labels = ["Coulombic", "Voltaic", "Energy"]

means = table_df.loc["mean", eff_cols].values
stds = table_df.loc["std", eff_cols].values
Code
fig6 = go.Figure()
fig6.add_trace(go.Bar(
    x=eff_labels,
    y=means,
    error_y=dict(type="data", array=stds, visible=True),
    name="Efficiency",
    marker_color=["#1f77b4", "#ff7f0e", "#2ca02c"]
))
fig6.update_layout(
    yaxis_title="Efficiency (%)",
    yaxis=dict(range=[0, 100])
)
fig6.show()
Figure 5: Mean efficiency values with standard deviation
Code
cap_cols = ["Discharge Capacity (Ah/L)"]
cap_labels = ["Discharge Capacity"]

cap_means = table_df.loc["mean", cap_cols].values
cap_stds = table_df.loc["std", cap_cols].values

ed_cols = ["Energy Density (Wh/L)"]
ed_labels = ["Energy Density"]

ed_means = table_df.loc["mean", ed_cols].values
ed_stds = table_df.loc["std", ed_cols].values

fig_combined = make_subplots(rows=1, cols=2)

fig_combined.add_trace(
    go.Bar(
        x=cap_labels,
        y=cap_means,
        error_y=dict(type="data", array=cap_stds, visible=True),
        name="Capacity",
        marker_color="#1f77b4"
    ),
    row=1, col=1
)

fig_combined.add_trace(
    go.Bar(
        x=ed_labels,
        y=ed_means,
        error_y=dict(type="data", array=ed_stds, visible=True),
        name="Energy Density",
        marker_color="#2ca02c"
    ),
    row=1, col=2
)
top = max(max(cap_means + cap_stds), max(ed_means + ed_stds))*1.1
fig_combined.update_layout(
    yaxis=dict(title="Discharge Capacity (Ah/L)"),
    yaxis2=dict(title="Energy Density (Wh/L)", anchor="x2", overlaying="y", side="left"),
    yaxis_range=[0, top],
    yaxis2_range=[0, top],
    showlegend=False
)
fig_combined.show()
Figure 6: Mean discharge capacity and energy density values with standard deviation

Have started another test with the same cell, after the multiple power cuts, with file name 31-03-2026-KPS-10.txt.

The UPS has already been super useful, preventing two more interruptions to testing!

Other stuff

Learning basic KiCAD to be able to improve the MYSTAT design to be more affordable and pass certifications.

Getting some more cell parts together to run another test in parallel.

Citation

BibTeX citation:
@online{smith2026,
  author = {Smith, Kirk Pollard},
  title = {Lab {Notebook} {Entry} \#12},
  date = {2026-03-31},
  url = {https://dualpower.supply/posts/lab-notebook-12/},
  langid = {en}
}
For attribution, please cite this work as:
K.P. Smith, Lab Notebook Entry #12, (2026). https://dualpower.supply/posts/lab-notebook-12/.