Lab Notebook Entry #17

Set up high-current cycler, and another Zn/I test
lab notebook
research
flow batteries
doi
Author

Kirk Pollard Smith

Published

May 19, 2026

A higher-current battery cycler

I can officially do charge/discharge tests on a much larger scale now! Should cover my needs for a while, can allegedly go up to 24 A/30 V/75 W (with more appropriate cabling. Testing out automated charge/discharge testing as I mentioned in https://dualpower.supply/posts/lab-notebook-14/#large-cell-testing. I tested it out with a lead-acid deep cycle battery and it works. Planning to use this for testing the large-format cell.

Another Zn-I test

I tried higher current to higher SOC to cycle more aggressively. I think I was too ambitious and these hybrid chemistries need to be initially “broken-in” by cycling at lower currents/SOCs to build the nucleation sites on the plating side and generate the required morphology.

This test went for 27 hours and “101” cycles, but the cycles deteriorated rapidly due to a leak which I think came from solid iodine formation due to the more aggressive initial cycling. I am only plotting the first 20 cycles but all the raw data is attached. I initially applied 60 mA/cm2, had the pumps at 40% duty cycle/1500 rpm, before dropping current to 40 mA/cm2 and increasing pumps to 100% duty cycle/4000 rpm.

Electrolyte Composition (molarity, per L solution) 0.91 M ZnCl2, 1.80 M NH4Cl, 0.75 M KI, 0.38 M triethylene glycol, 42.99 M H2O
Electrolyte Composition (molality, per kg solution) 0.73 m ZnCl2, 1.43 m NH4Cl, 0.60 m KI, 0.30 m triethylene glycol, 34.20 m H2O
Electrolyte Volume 5.5 mL (anolyte) + 5.5 mL (catholyte)
Electrolyte Density 1.3 g/mL
RFB Dev Kit commit e655c88f06
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 40 mA/cm2 (60 for first cycle)
Charging Conditions To 200 mAh (about 18 Ah/L) or 1.7 V
Discharging Conditions To 0 V
Flow Conditions Kamoer KPK200 24 V brushless peristaltic pumps at 100% duty cycle (about 4000 rpm) with 3x5 mm Tygon Chemical (PTFE-lined BPT) tubing, double reservoirs with Luer Lock barbed fittings
Inert Gas Sparged electrolyte through septa with argon for 15 minutes prior to cycling with pumps on, septa slightly open to allow gas to escape, then attached septa firmly, removed gas tubing with septa attached and put parafilm over septa
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.37
MASS_NH4Cl  = 1.06
MASS_KI     = 3.31
MASS_H2O    = 8.52
MASS_TriEG  = 0.63

total_mass_kg = (MASS_ZnCl2 + MASS_KI + MASS_H2O + MASS_TriEG) / 1000.0

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.62  # 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.0
molarity_NH4Cl = MASS_NH4Cl / MW_NH4Cl / TOTAL_VOLUME * 1000.0
molarity_KI = MASS_ZnCl2 / MW_KI / TOTAL_VOLUME * 1000.0
molarity_TriEG = MASS_TriEG / MW_TriEG / TOTAL_VOLUME * 1000.0
molarity_H2O = MASS_H2O / MW_H2O / TOTAL_VOLUME * 1000.0


filenames = [
    "../lab-notebook-17/14-05-2026-KPS-24.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.0
        )
    )


df["mean_current"] = df["Current(A)"].rolling(4).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

all_curves = all_curves[:40]

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]),
    yaxis=dict(range=[-.49, 1.8]),


)

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.91 M ZnCl~2~, 1.80 M NH~4~Cl, 0.75 M KI, 0.38 M triethylene glycol, 42.99 M H~2~O

Molalities (moles/kg solution): 0.73 m ZnCl~2~, 1.43 m NH~4~Cl, 0.60 m KI, 0.30 m triethylene glycol, 34.20 m H~2~O

Density approx. 1.3 g/mL

Experiment length: 26.7 hours

  0%|          | 0/202 [00:00<?, ?it/s]
100%|██████████| 202/202 [00:00<00:00, 14933.19it/s]
    Number         CE         VE         EE  Charge_potential  \
0        1  86.423511  78.007393  67.416728          1.512584   
1        2  85.674629  82.588359  70.757271          1.269158   
2        3  81.411774  85.475455  69.587084          1.262975   
3        4  77.954009  86.987323  67.810105          1.179610   
4        5  76.457409  86.834210  66.391187          1.138521   
5        6  73.280384  86.602863  63.462911          1.127556   
6        7  69.231249  86.570025  59.933509          1.116643   
7        8  66.401158  86.209204  57.243910          1.106123   
8        9  65.454704  86.036967  56.315242          1.115234   
9       10  64.464911  85.837835  55.335284          1.105522   
10      11  64.404516  85.507574  55.070740          1.098712   
11      12  61.616087  85.026494  52.389999          1.109989   
12      13  58.739275  83.153321  48.843658          1.186845   
13      14  63.246896  84.463888  53.420787          1.330407   
14      15  71.070111  85.550382  60.800752          1.420110   
15      16  74.263017  86.160048  63.985051          1.459880   
16      17  76.202840  82.509590  62.874651          1.473875   
17      18  53.638549  73.728099  39.546682          1.446375   
18      19  93.180435  72.874094  67.904398          1.464956   
19      20  89.352674  73.467565  65.645234          1.449799   

    Discharge_potential  Charge_stored  Energy_density_discharge  
0              1.097284       4.362448                  5.023415  
1              1.096917       3.331823                  3.897224  
2              1.111885       3.381371                  4.097731  
3              1.091829       3.812832                  4.626510  
4              1.097489       3.690288                  4.483998  
5              1.102905       3.544152                  4.306951  
6              1.104681       3.444950                  4.197858  
7              1.098372       3.407402                  4.158018  
8              1.088480       3.516524                  4.306092  
9              1.094581       3.661372                  4.498260  
10             1.088653       3.826008                  4.711819  
11             1.103030       3.918619                  4.845328  
12             1.044931       4.013550                  4.973090  
13             1.154534       4.492089                  5.754228  
14             1.189325       4.802592                  6.308267  
15             1.234934       4.559496                  6.002216  
16             1.067160       4.148343                  5.220120  
17             0.955312       1.067970                  1.215864  
18             1.115237       0.319009                  0.360206  
19             1.133336       0.158832                  0.180076  

Number                      10.500000
CE                          72.623407
VE                          83.179534
EE                          60.236759
Charge_potential             1.268744
Discharge_potential          1.103544
Charge_stored                3.372984
Energy_density_discharge     4.158363
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.show()
Figure 2: Charge/discharge efficiencies
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.show()
Figure 3: Discharge energy densities
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
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 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

The reason I think there is some formation behavior happening on the negative side is the increasing capacity over the initial cycles. The Nernst cutoff happens quite early so the capacity is never really pushed; all my previous tests have gone to higher capacities. Maybe it’s just a fluke, in any case I will run it again but starting at lower current/SOC initially before increasing.

Edit: after original post, realized that one connection lead was loose, and the negative pump tubing was blocked/degraded—PTFE liner had detached from the wall and formed some sort of blob inside the tubing, it seems.

Citation

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