Lab Notebook Entry #19

Made a long-form video for the project, initial all-iron results
lab notebook
research
flow batteries
doi
Author

Kirk Pollard Smith

Published

September 25, 2026

Been a while since my last post, been busy with traveling, but up to plenty of stuff and lots of things in the pipeline!

First long-form video

But—for those who haven’t seen—I made my first long-form video and released it on PeerTube/YouTube! A while ago now…

NB: I have always made heavy use of em dashes, and will continue to do so, despite their association with LLM’s… I do not use them to write these posts.

I learned a ton doing this, and while it was a lot of work, I had fun and I think it’s a great way to communicate science and R&D. I am already working on my next one!

YouTube:

PeerTube:

Found minor mistakes in previous electrolyte calculations and tubing specification.

Didn’t need an LLM to find these bugs:

molality_KI = MASS_ZnCl2/MW_KI/total_mass_kg
...
molarity_KI = MASS_ZnCl2 / MW_KI / TOTAL_VOLUME * 1000.0

The masses are based on zinc chloride here instead of potassium iodide—should use MASS_KI instead of MASS_ZnCl2.

The result of this was basically underreporting the prepared solution’s concentration of KI in both molarity and molality. However, in all cases I recorded (in the analysis code itself) the actual masses weighed out, so the correct raw data was always there. It only changes data in the table of experimental conditions, not any analysis of battery performance.

Also, before I have listed the pump tubing used as 3x5 mm Tygon Chemical, it turns out it is actually 3.2x6.4 mm (or 1/8” by 1/4”, before metrification).

I have corrected these errors in my previous lab notebook entries (which are tracked on Git).

First all-iron water-in-salt electrolyte test (in my lab)

Here are the raw data files for the experiments. Note the file names are from May 20… yeah, it’s been a while. I had this blog post basically ready for a while, but needed to change some data processing for iron electrolytes, and then it collected dust in my drafts folder for months.

Electrolyte Composition (molarity, per L solution) 0.89 M FeCl2, 4.11 M MgCl2, 0.03 M ascorbic acid, 0.06 M HCl, 48.58 M H2O
Electrolyte Composition (molality, per kg solution) 0.63 m FeCl2, 4.11 m MgCl2, 0.03 m ascorbic acid, 0.06 m HCl, 48.58 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 20 mA/cm2
Charging Conditions To 44 mAh (about 4 Ah/L) or 1.35 V
Discharging Conditions To 0 V
Flow Conditions Kamoer KPK200 24 V brushless peristaltic pumps at 40% duty cycle (about 1500 rpm) with 3.2x6.4 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 = True  # True for website, change to False for local work
electrolyte = "iron"

sampling = True

MIN_POINTS0 = 500
DIFF_LIMIT = 0.1

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

density = MASS_TO_RESERVOIRS / TOTAL_VOLUME

# molecular weights in g/mol

# electrolyte component masses, in g

###   ALL-IRON  ###

MASS_FeCl2_2H2O   = 1.60
MASS_MgCl2        = 4.30
MASS_AscorbicAcid = 0.06 # wasn't able to properly record it
MASS_HCl_15pct    = 3*.05 # estimation of 3 drops of 15% wt. solution
MASS_H2O          = 9.50

total_mass_kg = (MASS_FeCl2_2H2O + MASS_MgCl2 + MASS_AscorbicAcid + MASS_HCl_15pct + MASS_H2O) / 1000.0

MW_FeCl2_2H2O   = 162.78
MW_MgCl2        = 95.211
MW_AscorbicAcid = 176.124
MW_HCl          = 36.46
MW_H2O          = 18.01528

molality_FeCl2 = MASS_FeCl2_2H2O / MW_FeCl2_2H2O / total_mass_kg
molality_MgCl2 = MASS_MgCl2 / MW_MgCl2 / total_mass_kg
molality_AscorbicAcid = MASS_AscorbicAcid / MW_AscorbicAcid / total_mass_kg
molality_HCl = MASS_HCl_15pct*0.15 / MW_HCl / total_mass_kg
molality_H2O = (MASS_H2O + 0.85*MASS_HCl_15pct) / MW_H2O / total_mass_kg

molarity_FeCl2 = MASS_FeCl2_2H2O / MW_FeCl2_2H2O / TOTAL_VOLUME * 1000.0
molarity_MgCl2 = MASS_MgCl2 / MW_MgCl2 / TOTAL_VOLUME * 1000.0
molarity_AscorbicAcid = MASS_AscorbicAcid / MW_AscorbicAcid / TOTAL_VOLUME * 1000.0
molarity_HCl = MASS_HCl_15pct*0.15 / MW_HCl / TOTAL_VOLUME * 1000.0
molarity_H2O = (MASS_H2O + 0.85*MASS_HCl_15pct) / MW_H2O / TOTAL_VOLUME * 1000.0


### ZINC-IODIDE ###

# 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

# 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.0
# molarity_NH4Cl = MASS_NH4Cl / MW_NH4Cl / TOTAL_VOLUME * 1000.0
# molarity_KI = MASS_KI / 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 = [
    "20-05-2026-KPS-25.zip",
    "21-05-2026-KPS-26.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:")
    if electrolyte=="zinc":
        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
            )
        )
    elif electrolyte=="iron":
        print(
            "Molarities (moles/L solution): {:.2f} M FeCl~2~, {:.2f} M MgCl~2~, {:.2f} M ascorbic acid, {:.2f} M HCl, {:.2f} M H~2~O\n".format(
                molarity_FeCl2, molarity_MgCl2, molarity_AscorbicAcid, molarity_HCl, molarity_H2O
            )
        )
        print(
            "Molalities (moles/kg solution): {:.2f} m FeCl~2~, {:.2f} m MgCl~2~, {:.2f} m ascorbic acid, {:.2f} m HCl, {:.2f} m H~2~O\n".format(
                molality_FeCl2, molarity_MgCl2, molarity_AscorbicAcid, molarity_HCl, molarity_H2O
            )
        )
    else:
        print("WARNING: Specify the type of electrolyte")
        
    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(3).mean()
df["prev_current"] = df["mean_current"].shift(-1)
df["VChange"] = df["Potential(V)"].diff().abs()
df["is_OCP"] = (abs(df["mean_current"])<0.0001).astype(int)
df["is_change"] = (
    (((df["mean_current"] > 0) & (df["prev_current"] < 0))
    | ((df["mean_current"] < 0) & (df["prev_current"] > 0)))
    # & df["is_OCP"]==False #current must be greater than 0.5 mA to avoid OCP holds
).astype(int)
df["is_start_charge"] = ((df["prev_current"] < -0.001) & (df["is_OCP"]== 1)).astype(int)
df["is_start_discharge"] = ((df["prev_current"] > 0.001) & (df["is_OCP"]== 1)).astype(int)


idx_changes = list(df[(df["is_start_charge"] == 1) | (df["is_start_discharge"] == 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))])
n_curves = 150

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())
Code
# 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
plot_current = False
if(plot_current):
    fig1 = make_subplots(specs=[[{"secondary_y": False}]])
else:
    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,
            visible=True,
        )
    )
    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,
            visible=True,
        )
    )
    if(plot_current):
        fig1.add_trace(
            go.Scatter(
                x=all_curves[CURVE_N1]["mAh"] / TOTAL_VOLUME,
                y=all_curves[CURVE_N1]["Current(A)"]*1000,
                mode="lines",
                name=f"Charge Current {CN+1}",
                line=dict(color=charge_colors[CN], dash="dot"),
                showlegend=True,
                visible="legendonly",
            ),
            secondary_y=True,
        )
        fig1.add_trace(
            go.Scatter(
            x=all_curves[CURVE_N2][ "mAh"] / TOTAL_VOLUME,
            y=all_curves[CURVE_N2]["Current(A)"]*1000,
            mode="lines",
            name=f"Discharge Current {CN+1}",
            line=dict(color=discharge_colors[CN], dash="dot"),
            showlegend=False,
            visible="legendonly",
        ),
        secondary_y=True,
        )   


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()
Figure 1: Charge/discharge curves
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.iloc[5:11].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

Dropping the initial ascorbic acid cycles:

Code
for i in range(7):
    results_df = results_df.drop(i)

for i in range(13,92):
    results_df = results_df.drop(i)
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

Uploading this data into the void, I have already moved on to more tests, not commenting on it much here—working some kinks out.

Basically this was my first attempt at replicating Daniel’s WiSE testing. I set a conservative high voltage cutoff which the cell hit while cycling unattended which is why there are lots of cycles with zero capacity, since it was hitting the HVC immediately. I then increased the HVC and there are some more cycles that came in.

Citation

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