Lab Notebook Entry #7

Back to wet lab, a crappy experiment to get the cobwebs off
lab notebook
research
flow batteries
doi
Author

Kirk Pollard Smith

Published

March 22, 2026

Daniel has done virtually all the chemistry/cycling for FBRC while I have been doing mostly the design and engineering. I’m trying to get back into the wet lab game, so I decided to a zinc-iodide cell. It went horribly, basically because my FDM-printed polypropylene reservoirs leaked (poor print profile settings), but it was good since it forced me to get everything connected and working again, and write this blogpost as a template for hopefully more cycling!

This post includes the raw data and analysis. It is adapted from a previous blogpost I did over at at fbrc.dev.

Table 1: Test conditions
Electrolyte Composition (molarity, per L solution) 0.9 M ZnCl2, 1.8 M NH4Cl, 0.7 M KI, 0.3 M triethylene glycol, 43.9 M H2O
Electrolyte Composition (molality, per kg solution) 0.7 m ZnCl2, 1.5 m NH4Cl, 0.6 m KI, 0.3 m triethylene glycol, 34.4 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 KHPP50KPK200 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 files (1, 2, 3) of the test from the open-source MYSTAT potentiostat we use, generated with the conditions shown in Table 1. There are three columns for charge/discharge data: time, voltage, and current.

The MYSTAT GUI software on my PC crashed partway through the first cycle hence the three files. I made a minor change to the code that may prevent the crash in the future, hopefully, here in the MYSTAT repo on Codeberg.

Below is the analysis, I won’t interpret much here, this is a lab notebook after all. The code block is expandable if you’d like to view it or run it yourself.

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  # 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.36
MASS_NH4Cl  = 1.07
MASS_KI     = 3.32
MASS_H2O    = 8.50
MASS_TriEG  = 0.55 

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): ZnCl2 {:.2f} M, NH4Cl {:.2f} M, KI {:.2f} M, triethylene glycol {:.2f} M, H2O {:.2f} M\n".format(molarity_ZnCl2, molarity_NH4Cl, molarity_KI, molarity_TriEG, molarity_H2O))
    print("Molalities (moles/kg solution): ZnCl2 {:.2f} m, NH4Cl {:.2f} M, KI {:.2f} m, triethylene glycol {:.2f} m, H2O {:.2f} m\n".format(molality_ZnCl2, molality_NH4Cl, molality_KI, molality_TriEG, molality_H2O))
    print("Density approx. {:.1f} g/mL".format(density))


filenames = ["20-03-2026-KPS-1.txt", "20-03-2026-KPS-2.txt","20-03-2026-KPS-3.txt"]

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(1).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[:4] #only first 4 cycles are worth plotting

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(4):


    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()

# fig1.write_image('fig1.pdf')
Figure 1: Charge/discharge curves of zinc-iodide chemistry that leaked a lot after the first charge cycle

Here in Figure 1 you can see the cell fail quickly over 4 cycles, because of a huge leak, lol.

Code
# Plot efficiency
fig2 = px.scatter(
    results_df,
    x="Number",
    y=["CE", "VE", "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 rapidly decaying due to a leak
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",
    },
    title="Energy Density by Cycle",
)
fig5.show()
Figure 3: Discharge energy densities of zinc-iodide chemistry
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

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(
    title="Efficiencies with Standard Deviations",
    yaxis_title="Efficiency (%)",
    yaxis=dict(range=[0, 100])
)
fig6.show()

# Bar chart for Discharge Capacity with error bars
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(
    title="Discharge Capacity with Standard Deviation",
    yaxis_title="Discharge Capacity (Ah/L)"
)
fig7.show()

# Bar chart for Energy Density with error bars
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(
    title="Energy Density with Standard Deviation",
    yaxis_title="Energy Density (Wh/L)"
)
fig8.show()

Efficiencies and capacities/densities can’t be negative, this test was just crap so they happen to go below zero on the plot. The data goes further past the four cycles but it’s not useful as it’s so degraded.

Of course, these results are abysmal but I got off my butt to actually run a test which is great.

Next steps

Reprinting new reservoirs now with increased line width, 0.45 mm for a 0.4 mm nozzle, 105% flow rate, 7 perimeters, polypropylene, and rerunning the test—I already have the test running now!

Citation

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