Managing electrolyte volume imbalance in flow battery testing / Lab Notebook Entry #10

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 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 = 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.40
MASS_NH4Cl  = 1.08
MASS_KI     = 3.32
MASS_H2O    = 8.51
MASS_TriEG  = 0.58

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_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.
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:
    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)

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


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 = 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[:10] # lab notebook #9 was written only after first 10 cycles

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

  0%|          | 0/34 [00:00<?, ?it/s]
100%|██████████| 34/34 [00:00<00:00, 5216.03it/s]
    Number         CE         VE         EE  Charge_potential  \
0        1  74.482647  91.513482  68.161663          1.219355   
1        2  75.764786  91.291527  69.166830          1.337796   
2        3  80.728122  91.044483  73.498501          1.311279   
3        4  82.488946  90.816325  74.913430          1.330494   
4        5  83.505070  90.810609  75.831462          1.334691   
5        6  78.649483  91.402408  71.887521          1.307444   
6        7  76.014783  91.977791  69.916718          1.078655   
7        8  79.179666  91.781689  72.672435          1.071468   
8        9  81.089357  91.544265  74.232656          1.179142   
9       10  81.718277  91.489793  74.763882          1.200719   
10      11  78.985324  91.655826  72.394651          1.203022   
11      12  75.600559  92.224224  69.722029          0.951551   
12      13  78.417051  92.041187  72.175985          0.954850   
13      14  80.291148  91.727822  73.649322          1.092197   
14      15  80.818426  91.640049  74.062045          1.167895   
15      16  81.209963  91.369160  74.200861          1.201077   
16      17  73.750326  93.347457  68.844053          1.092756   

    Discharge_potential  Charge_stored  Energy_density_discharge  
0              1.090712       6.774118                  8.408193  
1              1.039120       6.889738                  8.549080  
2              1.001803       7.310168                  9.069696  
3              1.022504       7.499784                  9.292872  
4              0.960679       7.592860                  9.405155  
5              0.857173       7.150637                  8.897820  
6              0.928006       6.911119                  8.619536  
7              0.969663       7.199493                  8.973157  
8              1.000596       7.372066                  9.189183  
9              0.991248       7.429037                  9.261058  
10             0.857501       7.180638                  8.966882  
11             0.855875       6.872912                  8.585964  
12             0.919006       7.129084                  8.913482  
13             1.011651       7.299398                  9.138244  
14             1.036401       7.347261                  9.205807  
15             0.961909       7.382741                  9.237823  
16             1.262901       6.704733                  8.538759  

Number                       9.000000
CE                          78.981996
VE                          91.628123
EE                          72.358473
Charge_potential             1.178494
Discharge_potential          0.986279
Charge_stored                7.179164
Energy_density_discharge     8.956042
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"},
    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
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",
    },
)
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.

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

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(
    yaxis_title="Discharge Capacity (Ah/L)"
)
fig7.show()
Figure 5: Mean discharge capacity value with standard deviation
Code
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(
    yaxis_title="Discharge Energy Density (Wh/L)"

)
fig8.show()
Figure 6: Mean energy density value on discharge with standard deviation

Gonna let this cell cycle over the weekend, fingers crossed!

References

[1]
K.P. Smith, C.W. Monroe, Image-based mechanical balancing of reservoir volumes during benchtop flow battery operation, Frontiers in Chemical Engineering 3 (2021). https://doi.org/10.3389/fceng.2021.748865.

Citation

BibTeX citation:
@online{smith2026,
  author = {Smith, Kirk Pollard},
  title = {Managing Electrolyte Volume Imbalance in Flow Battery Testing
    / {Lab} {Notebook} {Entry} \#10},
  date = {2026-03-27},
  url = {https://dualpower.supply/posts/lab-notebook-10/},
  langid = {en}
}
For attribution, please cite this work as:
K.P. Smith, Managing electrolyte volume imbalance in flow battery testing / Lab Notebook Entry #10, (2026). https://dualpower.supply/posts/lab-notebook-10/.