Skip to content

Commit 73f0963

Browse files
committed
fix: hysteresis shading, label collision, axis limits; close reference loop
1 parent 9b02de3 commit 73f0963

6 files changed

Lines changed: 62 additions & 18 deletions

File tree

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Developed and validated for **graphene-like carbon nitride (C₃N₄)**, MOFs, z
2727

2828
*Output of `bet_analysis.py` on the bundled reference dataset — a synthetic
2929
Type IV isotherm whose true surface area is known by construction
30-
(S_BET = n_m × 4.353 = 177.00 m² g⁻¹; the tool recovers 177.01, R² = 0.999999).
30+
(S_BET = n_m × 4.353 = 177.28 m² g⁻¹; the tool recovers 177.28, R² = 0.999999).
3131
Reproduce it with:*
3232

3333
```bash
@@ -202,6 +202,10 @@ Cychosz & Thommes 2018 §3). §6.1 also recommends argon at 87 K over nitrogen
202202
at 77 K where surface functional groups interact with the N2 quadrupole.
203203
```
204204

205+
*This example message is from a measurement whose lowest adsorption point sits
206+
above p/p₀ = 0.015. The bundled reference dataset is purely mesoporous and does
207+
not trigger this gate.*
208+
205209
| Check | Behaviour |
206210
|-------|-----------|
207211
| **BET C constant** | `UserWarning` raised if C < 0 — invalid p/p₀ range; adjust `start_pt`/`end_pt` to 0.05 ≤ p/p₀ ≤ 0.35 |
@@ -317,6 +321,9 @@ It contains no measured data. All four sheets (AdsDes, BET, BJH, Summary)
317321
derive from the same monolayer capacity and pore-size distribution, so the file
318322
is internally self-consistent: S_BET and S_BJH agree to within 1.2 %.
319323

324+
Every figure in this README was produced from this synthetic file. No measured
325+
instrument data is included in this repository.
326+
320327
---
321328

322329
## 📚 References

assets/bet_analysis_example.png

4.44 KB
Loading

bet_analysis.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -775,10 +775,19 @@ def plot_all(data: dict, iso_cls: dict, hyst_cls: dict,
775775
sort_d = np.argsort(des[:, 0])[::-1]
776776
ax.plot(des[sort_d, 0], des[sort_d, 1], "s--",
777777
color=C_DES, ms=4, lw=1.4, label="Desorption")
778-
pp0_d_s, Va_d_s = des[sort_d, 0], des[sort_d, 1]
779-
pp0_all = np.concatenate([ads[:, 0], pp0_d_s[::-1]])
780-
Va_all = np.concatenate([ads[:, 1], Va_d_s[::-1]])
781-
ax.fill(pp0_all, Va_all, alpha=0.10, color=C_ADS)
778+
# Shade only the hysteresis loop: the p/p0 interval where both branches
779+
# exist, bounded by the two interpolated branches (not a single polygon
780+
# with straight closing edges, which produced a full-width wedge).
781+
p_lo = max(ads[:, 0].min(), des[:, 0].min())
782+
p_hi = min(ads[:, 0].max(), des[:, 0].max())
783+
if p_hi > p_lo:
784+
p_grid = np.linspace(p_lo, p_hi, 200)
785+
s_a = np.argsort(ads[:, 0])
786+
s_d = np.argsort(des[:, 0])
787+
Va_a_g = np.interp(p_grid, ads[s_a, 0], ads[s_a, 1])
788+
Va_d_g = np.interp(p_grid, des[s_d, 0], des[s_d, 1])
789+
ax.fill_between(p_grid, Va_a_g, Va_d_g, alpha=0.10,
790+
color=C_ADS, linewidth=0)
782791

783792
ax.set_xlabel(r"Relative Pressure ($p/p_0$)")
784793
ax.set_ylabel(r"Volume Adsorbed (cm$^3$ g$^{-1}$ STP)")
@@ -791,7 +800,7 @@ def plot_all(data: dict, iso_cls: dict, hyst_cls: dict,
791800
iso_label = iso_cls["type"]
792801
hyst_label = hyst_cls["type"] if hyst_cls["type"] != "None" else ""
793802
ax_ann = iso_label + (f" / {hyst_label}" if hyst_label else "")
794-
ax.text(0.03, 0.96, ax_ann, transform=ax.transAxes,
803+
ax.text(0.03, 0.78, ax_ann, transform=ax.transAxes,
795804
va="top", ha="left", fontsize=8.5,
796805
bbox=dict(boxstyle="round,pad=0.3", fc="white",
797806
ec="0.7", lw=0.7, alpha=0.9))
@@ -833,6 +842,16 @@ def plot_all(data: dict, iso_cls: dict, hyst_cls: dict,
833842
rp = bjh[:, 0] * 2 # radius (nm) -> diameter (nm)
834843
dVdd = bjh[:, 1] / 2.0 # dVp/drp -> dVp/ddp
835844

845+
# Upper x-limit from the data: the smallest diameter where the cumulative
846+
# pore volume reaches 99 % of its final value (drops the mostly-empty
847+
# high-diameter tail), with a floor so microporous samples aren't cramped.
848+
x_max = float(rp[-1])
849+
if bjh[-1, 2] > 0:
850+
done = np.where(bjh[:, 2] >= 0.99 * bjh[-1, 2])[0]
851+
if len(done):
852+
x_max = float(rp[done[0]])
853+
x_max = max(x_max, 5.0)
854+
836855
ax.plot(rp, dVdd, "-", color=C_BJH, lw=1.5)
837856
ax.fill_between(rp, dVdd, alpha=0.15, color=C_BJH)
838857

@@ -843,15 +862,15 @@ def plot_all(data: dict, iso_cls: dict, hyst_cls: dict,
843862

844863
ax.set_xlabel(r"Pore Diameter (nm)")
845864
ax.set_ylabel(r"d$V_p$/d$d_p$ (cm$^3$ g$^{-1}$ nm$^{-1}$)")
846-
ax.set_xlim(left=0)
865+
ax.set_xlim(left=0, right=x_max)
847866
ax.set_ylim(bottom=0)
848867
ax.xaxis.set_minor_locator(AutoMinorLocator())
849868
ax.yaxis.set_minor_locator(AutoMinorLocator())
850869

851870
ax.axvline(N2_CAVITATION_NM, ls=":", lw=0.8, color="0.6", alpha=0.7)
852-
ax.text(N2_CAVITATION_NM + 0.2,
853-
ax.get_ylim()[1] * 0.01 if ax.get_ylim()[1] > 0 else 0.001,
854-
"cavitation\n(~3.4 nm)", fontsize=6.5, color="0.5", va="bottom")
871+
ax.text(N2_CAVITATION_NM, ax.get_ylim()[1] * 0.9,
872+
"cavitation\n(~3.4 nm)", fontsize=6.5, color="0.5",
873+
va="top", ha="left")
855874
_label_panel(ax, "C")
856875

857876
# ── [D] Cumulative Pore Volume ────────────────────────────
@@ -876,7 +895,7 @@ def plot_all(data: dict, iso_cls: dict, hyst_cls: dict,
876895
ax2.set_ylabel(r"Cum. Surface Area (m$^2$ g$^{-1}$)", color=C_BJH)
877896
ax.tick_params(axis="y", colors=C_CUM)
878897
ax2.tick_params(axis="y", colors=C_BJH)
879-
ax.set_xlim(left=0)
898+
ax.set_xlim(left=0, right=x_max)
880899
ax.set_ylim(bottom=0)
881900

882901
lines1, lbl1 = ax.get_legend_handles_labels()

examples/make_reference_data.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@
7272
# Reuse the project's own constants and physics generators (no second BET
7373
# implementation, no duplicated physical constants).
7474
from bet_analysis import N2_BET_FACTOR, N2_STP_TO_LIQUID # noqa: E402
75-
from synthetic_isotherms import bet, step, desorption # noqa: E402
75+
from synthetic_isotherms import bet, step, desorption_closed # noqa: E402
7676

7777
_trapezoid = getattr(np, "trapezoid", None) or np.trapz
7878

@@ -94,10 +94,12 @@
9494
STEP_W = 0.08
9595
STEP_H = 200.0 # cm³(STP) g⁻¹ added by capillary condensation
9696

97-
# Desorption branch (H1 loop): adsorption pressure shifted down by SHIFT above
98-
# CLOSE_AT (mirrors the TypeIV_H1 fixture in synthetic_isotherms).
99-
SHIFT = 0.15
100-
CLOSE_AT = 0.45
97+
# Desorption branch (H1 loop, closed at both ends): a volume-domain bump
98+
# vd = v_ads + AMPLITUDE·sin(pi·u) that vanishes at CLOSE_AT and p/p0 = 1
99+
# (desorption_closed). AMPLITUDE = 25 keeps the closure gap < 0.02, the branch
100+
# monotonic, slope_ratio_max <= 1.10, and a visible H1 loop (area_norm ~ 0.024).
101+
AMPLITUDE = 25.0
102+
CLOSE_AT = 0.45
101103

102104
# BJH mesopore peak (radius, nm) → 12 nm diameter peak.
103105
R_PEAK = 6.0
@@ -120,7 +122,7 @@ def make_isotherm(n_m):
120122
x = _grid()
121123
va = bet(x, n_m, C, n=N_LAYERS) + step(x, X_STEP, STEP_W, STEP_H)
122124
ads = np.column_stack([x, va])
123-
des = desorption(x, va, SHIFT, CLOSE_AT)
125+
des = desorption_closed(x, va, AMPLITUDE, CLOSE_AT)
124126
return x, ads, des
125127

126128

@@ -132,7 +134,7 @@ def make_bjh(vp_total):
132134
trapezoidal integrals of ``dV`` over the same radius grid (cylindrical
133135
pore geometry: dS = 2 dV / r, with 1 cm³→1e-6 m³ and 1 nm→1e-9 m).
134136
"""
135-
rp = np.logspace(np.log10(1.0), np.log10(100.0), 60)
137+
rp = np.logspace(np.log10(1.0), np.log10(25.0), 60)
136138
dV = np.exp(-0.5 * ((np.log(rp) - np.log(R_PEAK)) / R_SIGMA) ** 2)
137139
dV /= _trapezoid(dV, rp)
138140
dV *= vp_total

examples/reference_mesoporous.xlsx

99 Bytes
Binary file not shown.

tests/synthetic_isotherms.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,22 @@ def desorption(x, v_ads, shift, close_at):
8888
vd = np.maximum(vd, np.interp(xd, x, v_ads))
8989
return np.column_stack([xd, vd])
9090

91+
92+
def desorption_closed(x, v_ads, amplitude, close_at):
93+
"""H1 desorption branch that closes at close_at.
94+
95+
Separation is added in the *volume* domain (vd = v_ads + A·sin(pi·u))
96+
rather than the *pressure* domain, so the two branches stay nearly
97+
parallel: condensation and evaporation keep the same steepness, as
98+
expected for a single pore-size distribution. The bump vanishes at both
99+
ends, so the loop is closed at the bottom and the top.
100+
"""
101+
m = x >= close_at
102+
xd = x[m]
103+
u = (xd - close_at) / (1.0 - close_at)
104+
vd = v_ads[m] + amplitude * np.sin(np.pi * u)
105+
return np.column_stack([xd, vd])
106+
91107
CASES = {
92108
"TypeIa_noHyst": (type_Ia, None),
93109
"TypeIb_noHyst": (type_Ib, None),

0 commit comments

Comments
 (0)