Lab Notebook Entry #9

Finally a decent test cell underway
lab notebook
research
flow batteries
doi
Author

Kirk Pollard Smith

Published

March 26, 2026

Another test with the 107% flowrate double reservoir. I made a short video of the build for this test on PeerTube and YouTube.

The test is still running as of me writing this! The first 10 cycles are done, in about two days.

I also made some small improvements to the MYSTAT GUI that I am testing.

Table 1: Test conditions
Electrolyte Composition (molarity, per L solution) 0.93 M ZnCl2, 1.84 M NH4Cl, 0.77 M KI, 0.35 M triethylene glycol, 42.94 M H2O
Electrolyte Composition (molality, per kg solution) 0.74 m ZnCl2, 1.46 m NH4Cl, 0.61 m KI, 0.28 m triethylene glycol, 34.21 m H2O
Electrolyte Volume 5.5 mL (anolyte) + 5.5 mL (catholyte)
Electrolyte Density 1.3 g/mL
RFB Dev Kit commit a411703e47
Cell Geometric Area 2 cm2
Separator Daramic AA-900
Anode Configuration Grafoil + 3.2 mm graphite felt
Cathode Configuration Grafoil + 3.2 mm graphite felt
Gaskets Outer and inner, 0.40 mm silicone (measured with micrometer)
Current Density 20 mA/cm2
Charging Conditions To 100 mAh (about 9 Ah/L) or 1.7 V
Discharging Conditions To 0 V
Flow Conditions Kamoer KPK200 24 V brushless peristaltic pumps at 40% duty cycle (about 1500 rpm) with 3x5 mm Tygon Chemical (PTFE-lined BPT) tubing

Here are the raw data of the test with the conditions shown in Table 1.

Changed the rolling mean variable for current to 3 to cope with the raw data that was splitting up the cycles.

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 = True  # 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_ZnCl2/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_ZnCl2/MW_KI/TOTAL_VOLUME*1000.
molarity_TriEG = MASS_TriEG/MW_TriEG/TOTAL_VOLUME*1000.
molarity_H2O = MASS_H2O/MW_H2O/TOTAL_VOLUME*1000.

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".format(density))


filenames = ["23-03-2026-KPS-5.zip"]

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

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

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[:10] # lab notebook #9 was written only after first 10 cycles

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):
for CN in range(10):
    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(
    title="Charge and Discharge Curves",
    xaxis_title="Capacity (Ah/L)",
    yaxis_title="Potential (V)",
)

fig1.show()
Figure 1: Charge/discharge curves
Code
# Plot efficiency
fig2 = px.scatter(
    results_df,
    x="Number",
    y=["VE", "CE", "EE"],
    labels={"value": "Efficiency (%)", "variable": "Metric"},
    title="Efficiency by Cycle",
)
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
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(
)
fig8.show()
Figure 6: Mean energy density value on discharge with standard deviation

Ahhh… those error bars are getting smaller.

This is “only” 20 mA/cm2, so not very aggressive cycling conditions—in terms of both current and SOC. But the results are decent—over 70% energy efficiency for an aqueous chemistry with a porous, non-selective separator.

We will see how long it goes. There is a slight leak/crusty crystal growth on one barb.

Small crystals/leakage on the negative reservoir barb, which is the outlet leading to the inlet of the cell.

I would like to change the barb design, but it took a lot of iteration to even get here… I have an idea to change them to Luer Lock fittings though which might be better.

Next steps

Let it keep running! In tandem, I am building up a second cell—would be nice to be able to run two simultaneously. And move on to some iron chemistry testing…

Citation

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