16 cycles and 72 hours, signs of periodic self-balancing of electrolyte volumes
lab notebook
research
flow batteries
doi
Author
Kirk Pollard Smith
Published
March 27, 2026
The test from my previous lab notebook entry is still running, here is an updated analysis, it is up to 16 complete cycles now over 72 hours.
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 =False# True for website, change to False for local worksampling =TrueMIN_POINTS0 =500DIFF_LIMIT =0.1# electrolyte component masses, in gMASS_ZnCl2 =1.40MASS_NH4Cl =1.08MASS_KI =3.32MASS_H2O =8.51MASS_TriEG =0.58total_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_KI/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_KI/MW_KI/TOTAL_VOLUME*1000.molarity_TriEG = MASS_TriEG/MW_TriEG/TOTAL_VOLUME*1000.molarity_H2O = MASS_H2O/MW_H2O/TOTAL_VOLUME*1000.filenames = ["../lab-notebook-9/23-03-2026-KPS-5.zip"]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)ifnot 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.))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 =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[:10] # lab notebook #9 was written only after first 10 cyclesifnot tqdm_disabled:print(results_df)print("")print(results_df.mean())# Plot charge/discharge curvesfig1 = go.Figure()for CN inrange(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="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()
Electrolyte Composition:
Molarities (moles/L solution): 0.93 M ZnCl~2~, 1.84 M NH~4~Cl, 1.82 M KI, 0.35 M triethylene glycol, 42.94 M H~2~O
Molalities (moles/kg solution): 0.74 m ZnCl~2~, 1.46 m NH~4~Cl, 1.45 m KI, 0.28 m triethylene glycol, 34.21 m H~2~O
Density approx. 1.3 g/mL
Experiment length: nan hours
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 capacityfig5 = 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
Okay, Figure 3 is interesting. There appears to be some periodic behavior of increasing capacity and then a sharp drop, like a wave. I think this is due to fluid transfer between the reservoirs.
To explain—flow batteries, especially those with porous separators, have problems with volume imbalance due to differing osmotic and mechanical pressures, water transfer, basically all the different transport phenomena going on at the same time through a porous membrane with two electrolytes with different intrinsic properties (when charged, at least).
If you have a symmetric system, like this, you can remedy this just by transferring the electrolyte from one reservoir back to the other. I did this a lot during my PhD. I even built a system to do it automatically by controlling pump speeds with a PID controller hooked to a camera that monitored reservoir levels, and wrote a paper on it [1]. That paper contains more background on the issue.
That approach relied on clear reservoirs though so we can’t use it here. It was also an active control system, and a passive one would be better.
To solve this simply, I have designed the reservoirs with a sort of spillover between them, so that they are connected. Here is a transparent FreeCAD view showing the internal connection.
So, what I think is happening, is that volume imbalance builds up, then electrolyte flows between one reservoir to the other. This discharges the cell somewhat, resulting in a loss of capacity, but it keeps the imbalance limited. The process then repeats itself it seems.
I could try to validate this visually, but likely will wait until a larger system makes this easier to observe. For now it’s interesting to see in the data.