Lab Notebook Entry #11

Cell cycled most of weekend, power cut interrupted cycling. 25 cycles, 110 hours, seems stable.
lab notebook
research
flow batteries
doi
Author

Kirk Pollard Smith

Published

March 30, 2026

The test from Lab Notebook Entry #9 ran over the weekend. Well, partly. My lab lost power briefly, the first time because my landlord was messing around and flipping circuit breakers (hooray), but I was there to restart the test manually since I was in lab when it happened. The cut lasted a few seconds but shut down the pumps, Arduino, potentiostat, and computer that were running the tests. Thankfully, it was almost right at the end of a discharge cycle, so it was a “convenient” time for a cut.

The second time was over the weekend, I’m not sure why, but of course I’m inclined to blame my landlord. Anyway, I have gone ahead and purchased a UPS with our Open Collective account to prevent this from interrupting future tests. Thank you to everyone who has donated and helped us finance this research; a reminder that if you are willing and able, we really appreciate donations to our Open Collective account that go directly to fund research expenses!

I’ve restarted the test in any case, though I have no way of knowing what happened to the cell while the power was cut. Weird stuff is possible when you suddently cut power to sensitive scientific instrumentation..

It’s now cycled for 25 cycles, with some interruptions, and over 110 hours. There are two more cycling files, the first being a dud with incorrect conditions that barely charged.

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
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", "23-03-2026-KPS-7.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)

results_df = results_df[:24] # drop last partial cycle

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

# 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="blue", dash="solid"),
        )
    )
    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="grey", dash="solid"),
        )
    )
fig1.update_layout(
    xaxis_title="Capacity (Ah/L)",
    yaxis_title="Potential (V)",
)

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: 111.1 hours

  0%|          | 0/50 [00:00<?, ?it/s]
100%|██████████| 50/50 [00:00<00:00, 5129.77it/s]
    Number         CE         VE         EE  Charge_potential  \
0        1  74.538015  91.515232  68.213637          1.219059   
1        2  76.057687  91.288122  69.431635          1.338254   
2        3  80.398310  91.042266  73.196443          1.310405   
3        4  82.489039  90.818173  74.915038          1.330474   
4        5  83.505066  90.821005  75.840140          1.334951   
5        6  78.443516  91.414569  71.708803          1.307085   
6        7  76.014653  91.979961  69.918248          1.078496   
7        8  79.179658  91.788894  72.678132          1.071963   
8        9  81.089169  91.543144  74.231575          1.179142   
9       10  81.718362  91.488064  74.762547          1.200774   
10      11  78.985327  91.565923  72.323643          1.202667   
11      12  75.600563  92.251670  69.742782          0.950466   
12      13  78.417027  92.087881  72.212578          0.955793   
13      14  80.291202  91.724445  73.646659          1.092323   
14      15  80.818320  91.639496  74.061501          1.167748   
15      16  81.209892  91.368185  74.200004          1.201094   
16      17  73.750202  93.347400  68.843896          1.092663   
17      18  75.419892  92.053073  69.426328          0.964092   
18      19  78.502455  91.992556  72.216414          0.965094   
19      20  80.423460  91.582262  73.653624          1.084296   
20      21  81.209396  91.399890  74.225298          1.197595   
21      22  79.734421  91.612445  73.046653          1.177896   
22      23  78.839927  91.822083  72.392463          1.070738   
23      24  79.687129  91.753026  73.115353          1.083110   

    Discharge_potential  Charge_stored  Energy_density_discharge  
0              1.090629       6.774121                  8.408329  
1              1.039126       6.916368                  8.581801  
2              1.001775       7.310167                  9.069675  
3              1.022579       7.499787                  9.293066  
4              0.960522       7.592852                  9.406224  
5              0.856747       7.131906                  8.875710  
6              0.927649       6.911115                  8.619733  
7              0.969982       7.199499                  8.973818  
8              1.000966       7.372057                  9.189049  
9              0.991907       7.429036                  9.260892  
10             0.857748       7.180639                  8.958062  
11             0.854511       6.872907                  8.583824  
12             0.919458       7.129083                  8.913429  
13             1.011422       7.299402                  9.137931  
14             1.036570       7.347257                  9.205721  
15             0.961673       7.382737                  9.237699  
16             1.263511       6.704724                  8.538736  
17             0.862177       6.860032                  8.623396  
18             0.864161       7.136810                  8.980433  
19             0.941646       7.311332                  9.203725  
20             0.937030       7.382840                  9.298879  
21             0.864912       7.248730                  9.151718  
22             0.914409       7.167457                  9.062722  
23             0.898910       7.244396                  9.171078  

Number                      12.500000
CE                          79.013445
VE                          91.662490
EE                          72.416808
Charge_potential             1.149007
Discharge_potential          0.960418
Charge_stored                7.183552
Energy_density_discharge     8.989402
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])
)



fig2.show()

# Plot potential
# fig3 = px.scatter(results_df, x="Number", y=["Charge_potential", "Discharge_potential"],
#                labels={"value": "Potential (V)", "variable": "Metric", "Number": "Cycle Number"},
#                title="Mean Potential by Cycle")
# fig3.show()
Figure 2: Charge/discharge efficiencies of zinc-iodide chemistry
Code
# Plot discharge charge capacity
# fig4 = px.scatter(results_df, x="Number", y="Charge_stored",
#                labels={"Charge_stored": "Discharge Capacity (Ah/L)", "Number": "Cycle Number"},
#                title="Discharge Capacity by Cycle")
# fig4.show()

# Plot discharge energy capacity
fig5 = px.scatter(
    results_df,
    x="Number",
    y="Energy_density_discharge",
    labels={
        "Energy_density_discharge": "Energy Density on Discharge (Wh/L)",
        "Number": "Cycle Number",
    },
)
fig5.show()
Figure 3: Discharge energy densities

The periodic behavior of Figure 3 seems to have continued, which I attribute to overflow.

The slight leak on the barb I noted previously is also a bit worse but other than that the outside of the cell looks great.

Code
table_df = (
    results_df.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 4: 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

fig7 = go.Figure()
fig7.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"
))
fig7.update_layout(
    yaxis_title="Discharge Capacity (Ah/L)"
)
fig7.show()
Figure 5: Mean discharge capacity value with standard deviation
Code
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

fig8 = go.Figure()
fig8.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"
))
fig8.update_layout(
    yaxis_title="Discharge Energy Density (Wh/L)"

)
fig8.show()
Figure 6: Mean energy density value on discharge with standard deviation

Have started another test with the same cell, after the power cut, with file name 29-03-2026-KPS-8.txt.

Citation

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