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.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.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.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.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 = [
"../lab-notebook-9/23-03-2026-KPS-5.zip",
"../lab-notebook-11/23-03-2026-KPS-7.zip",
"../lab-notebook-12/29-03-2026-KPS-8.zip",
"../lab-notebook-12/29-03-2026-KPS-9.zip",
"31-03-2026-KPS-10.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(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)
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.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: 204.1 hours
0%| | 0/94 [00:00<?, ?it/s]
100%|██████████| 94/94 [00:00<00:00, 4513.26it/s]
Number CE VE EE Charge_potential \
0 1 74.482785 91.514740 68.162728 1.219876
1 2 76.057640 91.287835 69.431373 1.337808
2 3 80.398304 91.041153 73.195543 1.310536
3 4 82.489012 90.817625 74.914562 1.330686
4 5 83.504995 90.819266 75.838624 1.334909
5 6 78.649689 91.369703 71.861987 1.307750
6 7 76.014861 91.975045 69.914702 1.078977
7 8 79.179486 91.789044 72.678093 1.071497
8 9 81.089259 91.543128 74.231645 1.179064
9 10 81.718298 91.490315 74.764328 1.200599
10 11 78.985390 91.602210 72.352362 1.202085
11 12 75.600501 92.261102 69.749855 0.952621
12 13 78.416923 92.047430 72.180763 0.953425
13 14 80.291292 91.727367 73.649088 1.092331
14 15 80.818271 91.640164 74.061997 1.167660
15 16 81.209914 91.368872 74.200582 1.201080
16 17 73.750216 93.347814 68.844215 1.092778
17 18 75.419887 92.269942 69.589886 0.963043
18 19 78.502418 91.934127 72.170512 0.965378
19 20 80.423453 91.586542 73.657060 1.084120
20 21 81.209333 91.396678 74.222633 1.197147
21 22 79.734594 91.584608 73.024615 1.177615
22 23 78.839839 91.827309 72.396502 1.070984
23 24 79.687233 91.746605 73.110331 1.082747
24 25 45.468588 93.163917 42.360318 1.139180
25 26 72.167256 91.615293 66.116244 0.981077
26 27 73.821036 93.185771 68.790702 0.961848
27 28 78.112220 92.213360 72.029902 0.977552
28 29 80.177918 91.750840 73.563913 1.013357
29 30 76.949255 92.164732 70.920075 1.170159
30 31 75.881578 91.230287 69.226981 1.406522
31 32 76.710080 91.232448 69.984484 1.061643
32 33 80.406593 90.611889 72.857933 1.279086
33 34 82.209905 90.169895 74.128585 1.366272
34 35 83.296498 89.838526 74.832346 1.396354
35 36 79.393958 90.863701 72.140289 1.392455
36 37 75.747611 90.933336 68.879830 1.230943
37 38 78.178643 89.780077 70.188846 1.285283
38 39 79.723063 89.075561 71.013765 1.376051
39 40 81.389585 89.521364 72.861066 1.434518
40 41 82.436043 90.149658 74.315811 1.459566
41 42 80.131736 90.795532 72.756036 1.437257
42 43 75.772247 90.114285 68.281619 1.451073
43 44 76.073144 89.501879 68.086893 1.455988
44 45 65.730510 88.713114 58.311582 1.494345
45 46 77.303978 89.152376 68.918334 1.495257
Discharge_potential Charge_stored Energy_density_discharge
0 1.090403 6.774124 8.408325
1 1.039399 6.916363 8.581756
2 1.002273 7.310162 9.069552
3 1.022430 7.499779 9.293009
4 0.960267 7.592855 9.406033
5 0.857036 7.150641 8.894655
6 0.928136 6.911126 8.619261
7 0.969761 7.199492 8.973811
8 1.000690 7.372062 9.189018
9 0.991483 7.429035 9.261136
10 0.857750 7.180639 8.961631
11 0.855881 6.872906 8.589460
12 0.918868 7.129086 8.913703
13 1.011342 7.299398 9.138276
14 1.036508 7.347255 9.205803
15 0.961993 7.382736 9.237786
16 1.263445 6.704726 8.538774
17 0.861824 6.860035 8.632968
18 0.864010 7.136807 8.978421
19 0.941096 7.311341 9.204148
20 0.936329 7.382838 9.298572
21 0.865257 7.248738 9.148923
22 0.914972 7.167454 9.063182
23 0.899728 7.244397 9.170445
24 1.291594 4.133608 5.327436
25 0.874352 6.562902 8.253644
26 0.875791 6.711673 8.548177
27 0.878835 7.101303 9.076712
28 0.890311 7.289004 9.313183
29 1.292476 6.995557 9.011091
30 0.884403 6.898714 8.866026
31 0.906657 6.973890 8.940676
32 0.940431 7.309825 9.362325
33 0.961175 7.473841 9.557091
34 0.916254 7.572610 9.671710
35 0.924614 7.218071 9.322722
36 0.902087 6.886407 8.894104
37 0.895082 7.107328 9.117921
38 0.893772 7.247741 9.282203
39 0.912384 7.399128 9.581066
40 0.922150 7.494292 9.839085
41 1.122366 7.284853 9.659140
42 1.160292 6.888560 9.092748
43 1.227836 6.915872 9.095070
44 1.144951 5.975619 7.824207
45 1.240542 6.519662 8.614187
Number 23.500000
CE 77.685979
VE 91.212314
EE 70.842816
Charge_potential 1.213924
Discharge_potential 0.980636
Charge_stored 7.051836
Energy_density_discharge 8.957156
dtype: float64