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
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 pdfrom tqdm import tqdm, notebookimport numpy as npimport scipyimport plotly.express as pximport plotly.graph_objects as goimport kaleidofrom IPython.display import Imagetqdm_disabled =True# for website, change to False for local worksampling =TrueMIN_POINTS0 =500DIFF_LIMIT =0.1# electrolyte component masses, in gMASS_ZnCl2 =1.36MASS_NH4Cl =1.07MASS_KI =3.32MASS_H2O =8.50MASS_TriEG =0.55total_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 syringeMASS_TO_RESERVOIRS =14.75# g of electrolyte actually loaded into system, based on weighing syringe before/after loading reservoirs# molecular weights in g/moldensity = MASS_TO_RESERVOIRS/TOTAL_VOLUMEMW_ZnCl2 =136.315MW_NH4Cl =53.49MW_KI =166.0028MW_H2O =18.01528MW_TriEG =150.174molality_ZnCl2 = MASS_ZnCl2/MW_ZnCl2/total_mass_kgmolality_NH4Cl = MASS_NH4Cl/MW_NH4Cl/total_mass_kgmolality_KI = MASS_ZnCl2/MW_KI/total_mass_kgmolality_TriEG = MASS_TriEG/MW_TriEG/total_mass_kgmolality_H2O = MASS_H2O/MW_H2O/total_mass_kgmolarity_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.ifnot 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:iflen(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 =0for idx in tqdm(idx_changes, disable=tqdm_disabled):iflen(df.iloc[idx_start:idx, :]) >50: all_curves.append(df.iloc[idx_start:idx, :]) idx_start = idxresults = []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 dataif 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 dataif 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] = df1results_df = pd.DataFrame(results)results_df = results_df[:4] #only first 4 cycles are worth plottingifnot tqdm_disabled:print(results_df)print("")print(results_df.mean())# Plot charge/discharge curvesfig1 = go.Figure()# for CN in range(n_curves):for CN inrange(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.
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 capacityfig5 = 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 barseff_cols = ["Coulombic Efficiency (%)", "Voltaic Efficiency (%)", "Energy Efficiency (%)"]eff_labels = ["Coulombic", "Voltaic", "Energy"]means = table_df.loc["mean", eff_cols].valuesstds = table_df.loc["std", eff_cols].valuesfig6 = 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 barscap_cols = ["Discharge Capacity (Ah/L)"]cap_labels = ["Discharge Capacity"]cap_means = table_df.loc["mean", cap_cols].valuescap_stds = table_df.loc["std", cap_cols].valuesfig7 = 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 barsed_cols = ["Energy Density (Wh/L)"]ed_labels = ["Energy Density"]ed_means = table_df.loc["mean", ed_cols].valuesed_stds = table_df.loc["std", ed_cols].valuesfig8 = 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!