Skip to content

Commit 3d47be6

Browse files
committed
feat: read plain two-column CSV isotherms; validate against BETSI reference areas
1 parent 0e5947f commit 3d47be6

7 files changed

Lines changed: 653 additions & 172 deletions

File tree

app_bet.py

Lines changed: 116 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,11 @@ def _fig_to_bytes(fig) -> bytes:
228228
return buf.read()
229229

230230

231+
def _fmt(v, spec: str) -> str:
232+
"""Format a value, or an em-dash when it is absent (None)."""
233+
return "—" if v is None else f"{v:{spec}}"
234+
235+
231236
def _plot_isotherm(ads, des, iso_cls, hyst_cls) -> plt.Figure:
232237
"""Draw the N₂ adsorption–desorption isotherm as a standalone figure."""
233238
setup_plot_style()
@@ -467,7 +472,19 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
467472
file_bytes = uploaded.read()
468473
ext = Path(uploaded.name).suffix.lower()
469474
if ext == ".csv":
470-
data = _parse_csv_template(file_bytes)
475+
# Distinguish the sectioned "Manual CSV" template from a plain
476+
# two-column isotherm CSV.
477+
first = ""
478+
for line in file_bytes.decode("utf-8-sig", errors="ignore").splitlines():
479+
if line.strip():
480+
first = line.strip()
481+
break
482+
if first.startswith("[") and "]" in first:
483+
data = _parse_csv_template(file_bytes)
484+
else:
485+
tmp = Path(f"/tmp/{uploaded.name}")
486+
tmp.write_bytes(file_bytes)
487+
data = read_bet_xls(str(tmp))
471488
else:
472489
tmp = Path(f"/tmp/{uploaded.name}")
473490
tmp.write_bytes(file_bytes)
@@ -498,12 +515,18 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
498515
if use_rouquerol:
499516
with st.spinner("Running Rouquerol range selection…"):
500517
rouquerol_result = select_bet_range(p_ads, n_ads)
501-
try:
502-
instrument_window = _match_instrument_window_by_pressure(
503-
p_ads, n_ads, data["bet_pts"], s["start_pt"], s["end_pt"]
504-
)
505-
except Exception:
518+
if s.get("window_derived"):
519+
# No instrument window exists for a plain isotherm; the derived
520+
# default window is not an instrument value, so the instrument-vs-
521+
# Rouquerol comparison is declined.
506522
instrument_window = None
523+
else:
524+
try:
525+
instrument_window = _match_instrument_window_by_pressure(
526+
p_ads, n_ads, data["bet_pts"], s["start_pt"], s["end_pt"]
527+
)
528+
except Exception:
529+
instrument_window = None
507530
heatmap_result = None
508531
if rouquerol_result is not None:
509532
try:
@@ -530,10 +553,10 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
530553
# ─ KPI metrics row
531554
cols = st.columns(4)
532555
kpi = [
533-
(f"{s['S_BET']:.2f}", "m² g⁻¹", "BET Surface Area"),
534-
(f"{s['Vp_total']:.4f}", "cm³ g⁻¹", "Total Pore Volume"),
535-
(f"{s['dp_avg']:.1f}", "nm", "Avg Pore Diameter"),
536-
(f"{s['C']:.1f}", "—", "BET C Constant"),
556+
(_fmt(s.get("S_BET"), ".2f"), "m² g⁻¹", "BET Surface Area"),
557+
(_fmt(s.get("Vp_total"), ".4f"), "cm³ g⁻¹", "Total Pore Volume"),
558+
(_fmt(s.get("dp_avg"), ".1f"), "nm", "Avg Pore Diameter"),
559+
(_fmt(s.get("C"), ".1f"), "—", "BET C Constant"),
537560
]
538561
for col, (val, unit, label) in zip(cols, kpi):
539562
with col:
@@ -558,6 +581,9 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
558581
with col_cls:
559582
st.markdown("**Isotherm Classification**")
560583
st.success(f"**{iso_cls['type']}** \n{iso_cls['explanation']}")
584+
if len(data.get("des", [])) == 0:
585+
st.info("Type IV/V not evaluated — no desorption branch supplied "
586+
"(adsorption-only input).")
561587

562588
st.markdown("**Hysteresis Classification**")
563589
if hyst_cls["type"] != "None":
@@ -568,6 +594,8 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
568594
f"{hyst_cls['explanation']} \n"
569595
f"Score share: {share} ({hyst_cls['score_share_pct']:.0f}% of total score)"
570596
)
597+
elif len(data.get("des", [])) == 0:
598+
st.info("Hysteresis not evaluated — no desorption branch supplied.")
571599
else:
572600
st.info("No hysteresis detected.")
573601

@@ -586,16 +614,28 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
586614
# ─ Summary table
587615
st.markdown("**Summary Table**")
588616
df_out = pd.DataFrame([
589-
["BET Surface Area", f"{s['S_BET']:.3f}", "m² g⁻¹"],
590-
["Vm (monolayer cap.)", f"{s['Vm']:.4f}", "cm³(STP) g⁻¹"],
591-
["BET C constant", f"{s['C']:.2f}", "—"],
592-
["Total Pore Volume", f"{s['Vp_total']:.4f}", "cm³ g⁻¹"],
593-
["Average Pore Diameter", f"{s['dp_avg']:.3f}", "nm"],
594-
["BJH Surface Area", f"{s['S_BJH']:.3f}", "m² g⁻¹"],
595-
["BJH Peak Pore Diameter", f"{s['rp_peak_BJH']*2:.2f}", "nm"],
617+
["BET Surface Area", _fmt(s.get("S_BET"), ".3f"), "m² g⁻¹"],
618+
["Vm (monolayer cap.)", _fmt(s.get("Vm"), ".4f"), "cm³(STP) g⁻¹"],
619+
["BET C constant", _fmt(s.get("C"), ".2f"), "—"],
620+
["Total Pore Volume", _fmt(s.get("Vp_total"), ".4f"), "cm³ g⁻¹"],
621+
["Average Pore Diameter", _fmt(s.get("dp_avg"), ".3f"), "nm"],
622+
["BJH Surface Area", _fmt(s.get("S_BJH"), ".3f"), "m² g⁻¹"],
623+
["BJH Peak Pore Diameter", _fmt(None if s.get("rp_peak_BJH") is None else s["rp_peak_BJH"] * 2, ".2f"), "nm"],
596624
], columns=["Parameter", "Value", "Unit"])
597625
st.dataframe(df_out, use_container_width=True, hide_index=True)
598626

627+
# ─ Declined / derived notes (plain-isotherm input) ──────────────
628+
if s.get("window_derived"):
629+
st.caption("BET point window derived from the data (0.05 ≤ p/p₀ ≤ 0.35), "
630+
"not read from an instrument.")
631+
declined = []
632+
for label, reason in (s.get("declined") or {}).items():
633+
declined.append(f"{label} ({reason})")
634+
if s.get("Vp_total_reason"):
635+
declined.append(f"total pore volume (Gurvich) — {s['Vp_total_reason']}")
636+
if declined:
637+
st.info("**Not available (declined):** " + "; ".join(declined))
638+
599639
tag = (
600640
'<span class="tag-valid">✓ C constant valid</span>'
601641
if bet_res["C_valid"] else
@@ -661,6 +701,8 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
661701
st.markdown(
662702
f"Points used: **{s['start_pt']}** → **{s['end_pt']}** "
663703
f"({s['end_pt'] - s['start_pt'] + 1} points)"
704+
+ (" *(window derived from the data, not read from an instrument)*"
705+
if s.get("window_derived") else "")
664706
)
665707
with col_fig:
666708
setup_plot_style()
@@ -798,8 +840,9 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
798840

799841
st.divider()
800842
st.markdown("**Comparison with BET**")
843+
sbet_label = "S_BET" if not s.get("instrument_summary", True) else "Instrument S_BET"
801844
comp_rows = [
802-
["Instrument S_BET", f"{s['S_BET']:.2f}", "—"],
845+
[sbet_label, _fmt(s.get("S_BET"), ".2f"), "—"],
803846
]
804847
if use_rouquerol and rouquerol_result is not None and rouquerol_result["best"] is not None:
805848
rb = rouquerol_result["best"]
@@ -946,50 +989,56 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
946989
with tab_bjh:
947990
st.subheader("BJH Pore Size Distribution")
948991

949-
peak_diam = s["rp_peak_BJH"] * 2.0
950-
if peak_diam < BJH_NARROW_MESOPORE_NM:
951-
st.warning(
952-
f"⚠ BJH peak diameter {peak_diam:.1f} nm is below 10 nm — "
953-
"Kelvin-equation (BJH) procedures underestimate narrow mesopore "
954-
"size by ~20-30% (Thommes et al. 2015 §7.2, §9)."
955-
)
956-
957992
bjh = data["bjh"]
958-
# Instrument headers verified as radius ("rp/nm") and per-radius
959-
# differential ("dVp/drp"), so rp*2 = diameter and dV/dd = dV/dr / 2.
960-
rp = bjh[:, 0] * 2 # radius (nm) -> diameter (nm)
961-
dVdd = bjh[:, 1] / 2.0 # dVp/drp -> dVp/ddp
962-
cum_Vp = bjh[:, 2]; cum_Sap = bjh[:, 3]
963993

964-
setup_plot_style()
965-
fig_bjh, (ax1, ax2) = plt.subplots(1, 2, figsize=(9, 4))
966-
967-
ax1.plot(rp, dVdd, "-", color=C_BJH, lw=1.5)
968-
ax1.fill_between(rp, dVdd, alpha=0.15, color=C_BJH)
969-
pk = np.argmax(dVdd)
970-
ax1.axvline(rp[pk], ls="--", lw=0.9, color=C_BJH, alpha=0.7)
971-
ax1.text(rp[pk]+0.3, dVdd[pk]*0.9, f"{rp[pk]:.1f} nm", fontsize=8, color=C_BJH)
972-
ax1.axvline(N2_CAVITATION_NM, ls=":", lw=0.8, color="0.6")
973-
ax1.set_xlabel("Pore Diameter (nm)")
974-
ax1.set_ylabel(r"d$V_p$/d$d_p$ (cm³ g⁻¹ nm⁻¹)")
975-
ax1.set_xlim(left=0); ax1.set_ylim(bottom=0)
976-
ax1.set_title("Differential PSD")
977-
978-
ax2r = ax2.twinx()
979-
ax2.plot(rp, cum_Vp, "-", color=C_CUM, lw=1.5, label="Vp cumul.")
980-
ax2r.plot(rp, cum_Sap, "--", color=C_BJH, lw=1.5, label="Sap cumul.")
981-
ax2.set_xlabel("Pore Diameter (nm)")
982-
ax2.set_ylabel("Cum. Pore Volume (cm³ g⁻¹)", color=C_CUM)
983-
ax2r.set_ylabel("Cum. Surface Area (m² g⁻¹)", color=C_BJH)
984-
ax2.tick_params(axis="y", colors=C_CUM)
985-
ax2r.tick_params(axis="y", colors=C_BJH)
986-
ax2.set_xlim(left=0); ax2.set_ylim(bottom=0)
987-
ax2.set_title("Cumulative Pore Volume")
988-
l1, b1 = ax2.get_legend_handles_labels(); l2, b2 = ax2r.get_legend_handles_labels()
989-
ax2.legend(l1+l2, b1+b2, fontsize=8, loc="lower right")
990-
plt.tight_layout()
991-
st.pyplot(fig_bjh, use_container_width=True)
992-
plt.close(fig_bjh)
994+
if s.get("rp_peak_BJH") is not None:
995+
peak_diam = s["rp_peak_BJH"] * 2.0
996+
if peak_diam < BJH_NARROW_MESOPORE_NM:
997+
st.warning(
998+
f"⚠ BJH peak diameter {peak_diam:.1f} nm is below 10 nm — "
999+
"Kelvin-equation (BJH) procedures underestimate narrow mesopore "
1000+
"size by ~20-30% (Thommes et al. 2015 §7.2, §9)."
1001+
)
1002+
1003+
if len(bjh) == 0:
1004+
st.info("BJH pore size distribution not available — no BJH table "
1005+
"supplied (plain-isotherm input).")
1006+
else:
1007+
# Instrument headers verified as radius ("rp/nm") and per-radius
1008+
# differential ("dVp/drp"), so rp*2 = diameter and dV/dd = dV/dr / 2.
1009+
rp = bjh[:, 0] * 2 # radius (nm) -> diameter (nm)
1010+
dVdd = bjh[:, 1] / 2.0 # dVp/drp -> dVp/ddp
1011+
cum_Vp = bjh[:, 2]; cum_Sap = bjh[:, 3]
1012+
1013+
setup_plot_style()
1014+
fig_bjh, (ax1, ax2) = plt.subplots(1, 2, figsize=(9, 4))
1015+
1016+
ax1.plot(rp, dVdd, "-", color=C_BJH, lw=1.5)
1017+
ax1.fill_between(rp, dVdd, alpha=0.15, color=C_BJH)
1018+
pk = np.argmax(dVdd)
1019+
ax1.axvline(rp[pk], ls="--", lw=0.9, color=C_BJH, alpha=0.7)
1020+
ax1.text(rp[pk]+0.3, dVdd[pk]*0.9, f"{rp[pk]:.1f} nm", fontsize=8, color=C_BJH)
1021+
ax1.axvline(N2_CAVITATION_NM, ls=":", lw=0.8, color="0.6")
1022+
ax1.set_xlabel("Pore Diameter (nm)")
1023+
ax1.set_ylabel(r"d$V_p$/d$d_p$ (cm³ g⁻¹ nm⁻¹)")
1024+
ax1.set_xlim(left=0); ax1.set_ylim(bottom=0)
1025+
ax1.set_title("Differential PSD")
1026+
1027+
ax2r = ax2.twinx()
1028+
ax2.plot(rp, cum_Vp, "-", color=C_CUM, lw=1.5, label="Vp cumul.")
1029+
ax2r.plot(rp, cum_Sap, "--", color=C_BJH, lw=1.5, label="Sap cumul.")
1030+
ax2.set_xlabel("Pore Diameter (nm)")
1031+
ax2.set_ylabel("Cum. Pore Volume (cm³ g⁻¹)", color=C_CUM)
1032+
ax2r.set_ylabel("Cum. Surface Area (m² g⁻¹)", color=C_BJH)
1033+
ax2.tick_params(axis="y", colors=C_CUM)
1034+
ax2r.tick_params(axis="y", colors=C_BJH)
1035+
ax2.set_xlim(left=0); ax2.set_ylim(bottom=0)
1036+
ax2.set_title("Cumulative Pore Volume")
1037+
l1, b1 = ax2.get_legend_handles_labels(); l2, b2 = ax2r.get_legend_handles_labels()
1038+
ax2.legend(l1+l2, b1+b2, fontsize=8, loc="lower right")
1039+
plt.tight_layout()
1040+
st.pyplot(fig_bjh, use_container_width=True)
1041+
plt.close(fig_bjh)
9931042

9941043
if show_features and hyst_cls["type"] != "None":
9951044
st.divider()
@@ -1146,15 +1195,15 @@ def _fmt(v, spec):
11461195
st.markdown("• **📋 CSV Report**")
11471196
report_rows = [
11481197
["Sample", sample_name],
1149-
["S_BET (m2/g)", f"{s['S_BET']:.3f}"],
1150-
["Vm (cm3(STP)/g)", f"{s['Vm']:.4f}"],
1151-
["C constant", f"{s['C']:.2f}"],
1198+
["S_BET (m2/g)", _fmt(s.get("S_BET"), ".3f")],
1199+
["Vm (cm3(STP)/g)", _fmt(s.get("Vm"), ".4f")],
1200+
["C constant", _fmt(s.get("C"), ".2f")],
11521201
["C valid", str(bet_res["C_valid"])],
11531202
["R2", f"{bet_res['R2']:.6f}"],
1154-
["Vp_total (cm3/g)", f"{s['Vp_total']:.4f}"],
1155-
["dp_avg (nm)", f"{s['dp_avg']:.3f}"],
1156-
["S_BJH (m2/g)", f"{s['S_BJH']:.3f}"],
1157-
["BJH_peak_diam (nm)", f"{s['rp_peak_BJH']*2:.2f}"],
1203+
["Vp_total (cm3/g)", _fmt(s.get("Vp_total"), ".4f")],
1204+
["dp_avg (nm)", _fmt(s.get("dp_avg"), ".3f")],
1205+
["S_BJH (m2/g)", _fmt(s.get("S_BJH"), ".3f")],
1206+
["BJH_peak_diam (nm)", _fmt(None if s.get("rp_peak_BJH") is None else s["rp_peak_BJH"] * 2, ".2f")],
11581207
["Isotherm type", iso_cls["type"]],
11591208
["Hysteresis type", hyst_cls["type"]],
11601209
["Hysteresis score share", hyst_cls.get("score_share", "—")],

0 commit comments

Comments
 (0)