Skip to content

Commit 9b9c9ba

Browse files
committed
examples: add self-consistent synthetic reference dataset
1 parent 74290ef commit 9b9c9ba

2 files changed

Lines changed: 339 additions & 0 deletions

File tree

examples/make_reference_data.py

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
"""
2+
make_reference_data.py — generate examples/reference_mesoporous.xlsx
3+
===================================================================
4+
5+
Synthetic, committable reference dataset for BET_analyser: a Type IV(a)
6+
mesoporous isotherm (BET multilayer + capillary condensation + hysteresis
7+
loop) with a well-defined BJH mesopore peak, written in the exact 4-sheet
8+
layout that ``bet_analysis.read_bet_xls`` expects.
9+
10+
Single source of truth (constants at the top of this file):
11+
12+
C = 120.0 BET C constant
13+
n_m monolayer capacity — NOT chosen freely;
14+
derived by fixed-point iteration so that
15+
S_BET = n_m × 4.353 equals S_BJH (the
16+
reference material is purely mesoporous,
17+
so the two areas must agree).
18+
S_BET_true = n_m × 4.353 m² g⁻¹ (reported after convergence)
19+
20+
The low-pressure branch is the finite-layer (BDDT) BET equation — reused from
21+
``tests/synthetic_isotherms.bet`` — with the multilayer carried on *top* of a
22+
capillary-condensation sigmoid, i.e. the single continuous form
23+
24+
Va(x) = bet(x, n_m, C, n=N_LAYERS) + step(x, X_STEP, STEP_W, STEP_H)
25+
26+
This has no step discontinuity and keeps the multilayer growing (rather than
27+
freezing it at the condensation onset). ``bet`` is called with a finite number
28+
of layers so the isotherm saturates to a genuine Type IV plateau; with the
29+
default ``n=None`` the infinite-layer BET diverges as p/p0 → 1, which would
30+
break both the plateau and the fixed-point iteration below. ``N_LAYERS = 7``
31+
keeps the BET window (0.05–0.35) within ~0.3 % of the infinite-layer form, so
32+
the fitted BET area still equals ``n_m × 4.353``.
33+
34+
Every sheet derives from ``n_m``, ``C`` and the generated ads/des arrays —
35+
nothing is hardcoded to make the answer "come out right":
36+
37+
* AdsDes cols 5,6 : the generated p/p0 and Va.
38+
* BET col 2 : y = 1/[Va(1/p/p0 − 1)] from the SAME Va values
39+
written into AdsDes.
40+
* Summary "Vm" / "as,BET" : n_m / n_m × 4.353.
41+
* Summary "C" : the C used to generate the curve.
42+
* Summary "Total pore volume(p/p0=0.990)" : Gurvich rule, from the Va nearest
43+
p/p0 = 0.990, using ``bet_analysis.N2_STP_TO_LIQUID``.
44+
* Summary "rp,peak(Area)" : radius at the maximum of the BJH dVp/drp col.
45+
* Summary "ap" / "Vp" : BJH cumulative surface area / pore volume,
46+
running integrals of the dVp/drp column.
47+
* Summary "Average pore diameter" : 4 × Vp / S_BJH (cylindrical model).
48+
49+
Instrument metadata rows that cannot be derived (sample/operator/date/
50+
instrument) are filled with the placeholder ``SYNTHETIC-REFERENCE``; the sample
51+
name is ``SYNTHETIC-mesoporous-reference`` so the file cannot be mistaken for
52+
measured data.
53+
54+
Deterministic: running it again reproduces the file identically (no RNG).
55+
No new runtime dependencies (openpyxl is already in requirements.txt).
56+
"""
57+
import io
58+
import os
59+
import re
60+
import sys
61+
import zipfile
62+
from datetime import datetime
63+
64+
import numpy as np
65+
from openpyxl import Workbook
66+
67+
HERE = os.path.dirname(os.path.abspath(__file__))
68+
REPO = os.path.dirname(HERE)
69+
sys.path.insert(0, REPO)
70+
sys.path.insert(0, os.path.join(REPO, "tests"))
71+
72+
# Reuse the project's own constants and physics generators (no second BET
73+
# implementation, no duplicated physical constants).
74+
from bet_analysis import N2_BET_FACTOR, N2_STP_TO_LIQUID # noqa: E402
75+
from synthetic_isotherms import bet, step, desorption # noqa: E402
76+
77+
_trapezoid = getattr(np, "trapezoid", None) or np.trapz
78+
79+
# ══════════════════════════════════════════════════════════════
80+
# Single source of truth
81+
# ══════════════════════════════════════════════════════════════
82+
C = 120.0 # BET C constant
83+
N_M_INIT = 90.0 # initial guess for n_m (fixed-point iteration)
84+
N_LAYERS = 7 # finite-layer BET: saturating multilayer, and
85+
# keeps the BET window within ~0.3 % of the
86+
# infinite-layer form (see module docstring).
87+
88+
SAMPLE = "SYNTHETIC-mesoporous-reference"
89+
PLACEHOLDER = "SYNTHETIC-REFERENCE"
90+
91+
# Isotherm shape: finite-layer BET multilayer + capillary-condensation sigmoid
92+
# (single continuous form, no freeze/discontinuity).
93+
X_STEP = 0.60
94+
STEP_W = 0.08
95+
STEP_H = 200.0 # cm³(STP) g⁻¹ added by capillary condensation
96+
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
101+
102+
# BJH mesopore peak (radius, nm) → 12 nm diameter peak.
103+
R_PEAK = 6.0
104+
R_SIGMA = 0.35
105+
106+
BET_CUTOFF = 0.50 # BET sheet includes adsorption points below this p/p0
107+
108+
OUT = os.path.join(HERE, "reference_mesoporous.xlsx")
109+
110+
111+
def _grid():
112+
"""p/p0 grid: dense in the BET region, 0.05–0.35, and up to 0.995."""
113+
lo = np.logspace(np.log10(5e-4), np.log10(0.05), 16, endpoint=False)
114+
mid = np.linspace(0.05, 0.35, 14, endpoint=False)
115+
hi = np.linspace(0.35, 0.995, 30, endpoint=True)
116+
return np.unique(np.concatenate([lo, mid, hi]))
117+
118+
119+
def make_isotherm(n_m):
120+
x = _grid()
121+
va = bet(x, n_m, C, n=N_LAYERS) + step(x, X_STEP, STEP_W, STEP_H)
122+
ads = np.column_stack([x, va])
123+
des = desorption(x, va, SHIFT, CLOSE_AT)
124+
return x, ads, des
125+
126+
127+
def make_bjh(vp_total):
128+
"""BJH pore-size distribution whose total volume equals the Gurvich Vp.
129+
130+
Returns (rp, dV, cum_vp, cum_sap, rp_peak, s_bjh, vp_bjh). ``dV`` is the
131+
differential dVp/drp (per radius), and the cumulative columns are running
132+
trapezoidal integrals of ``dV`` over the same radius grid (cylindrical
133+
pore geometry: dS = 2 dV / r, with 1 cm³→1e-6 m³ and 1 nm→1e-9 m).
134+
"""
135+
rp = np.logspace(np.log10(1.0), np.log10(100.0), 60)
136+
dV = np.exp(-0.5 * ((np.log(rp) - np.log(R_PEAK)) / R_SIGMA) ** 2)
137+
dV /= _trapezoid(dV, rp)
138+
dV *= vp_total
139+
140+
drp = np.diff(rp)
141+
dV_seg = 0.5 * (dV[:-1] + dV[1:]) * drp
142+
cum_vp = np.concatenate([[0.0], np.cumsum(dV_seg)])
143+
144+
rp_mid = 0.5 * (rp[:-1] + rp[1:])
145+
dS_seg = 2.0e3 * dV_seg / rp_mid
146+
cum_sap = np.concatenate([[0.0], np.cumsum(dS_seg)])
147+
148+
rp_peak = rp[int(np.argmax(dV))]
149+
s_bjh = float(cum_sap[-1])
150+
vp_bjh = float(cum_vp[-1])
151+
return rp, dV, cum_vp, cum_sap, rp_peak, s_bjh, vp_bjh
152+
153+
154+
def write_workbook(ads, des, bet_pts, bjh_cols, summary):
155+
rp, dV, cum_vp, cum_sap = bjh_cols
156+
157+
wb = Workbook()
158+
wb.remove(wb.active)
159+
160+
ws = wb.create_sheet("AdsDes")
161+
ws.append(["ADS", None, None, None, None, "p/p0", "Va (cm3/g STP)"])
162+
for p, v in ads:
163+
ws.append([None, None, None, None, None, p, v])
164+
ws.append(["DES", None, None, None, None, "p/p0", "Va (cm3/g STP)"])
165+
for p, v in des[::-1]: # desorption written high → low pressure
166+
ws.append([None, None, None, None, None, p, v])
167+
168+
ws = wb.create_sheet("BET")
169+
ws.append(["No", "p/p0", "y", "idx"])
170+
for i, (p, y) in enumerate(bet_pts):
171+
ws.append([None, p, y, i])
172+
ws.append(["Starting point", "START", None, summary["start_pt"]])
173+
ws.append(["End point", "END", None, summary["end_pt"]])
174+
175+
ws = wb.create_sheet("BJH")
176+
ws.append(["No", None, "rp/nm", "dVp/drp", "cum Vp", "cum Sap"])
177+
for row in zip(rp, dV, cum_vp, cum_sap):
178+
ws.append([None, None, *row])
179+
180+
ws = wb.create_sheet("Summary")
181+
ws.append(["Sample", None, None, SAMPLE])
182+
ws.append(["Instrument", None, None, PLACEHOLDER])
183+
ws.append(["Operator", None, None, PLACEHOLDER])
184+
ws.append(["Date", None, None, PLACEHOLDER])
185+
ws.append(["Vm", None, None, summary["Vm"]])
186+
ws.append(["as,BET", None, None, summary["S_BET"]])
187+
ws.append(["C", None, None, summary["C"]])
188+
ws.append(["Total pore volume(p/p0=0.990)", None, None, summary["Vp_total"]])
189+
ws.append(["Average pore diameter", None, None, summary["dp_avg"]])
190+
ws.append(["rp,peak(Area)", None, None, summary["rp_peak_BJH"]])
191+
ws.append(["ap", None, None, summary["S_BJH"]])
192+
ws.append(["Vp", None, None, summary["Vp_BJH"]])
193+
194+
_save_deterministic(wb, OUT)
195+
196+
197+
_FIXED_TS = (2026, 1, 1, 0, 0, 0) # fixed zip-entry + core-property timestamp
198+
_FIXED_DT = "2026-01-01T00:00:00Z"
199+
_FIX_MODIFIED_RE = re.compile(
200+
r"(<dcterms:modified[^>]*>)([^<]*)(</dcterms:modified>)"
201+
)
202+
203+
204+
def _save_deterministic(wb, path):
205+
"""Save the workbook so that re-running the script reproduces it exactly.
206+
207+
openpyxl stamps ``docProps/core.xml`` (created/modified) and every zip
208+
entry with the current time, which makes two runs differ byte-for-byte.
209+
Pin both to a fixed value so the generated file is reproducible.
210+
"""
211+
wb.properties.creator = "BET_analyser"
212+
wb.properties.created = datetime(*_FIXED_TS)
213+
wb.properties.modified = datetime(*_FIXED_TS)
214+
215+
buf = io.BytesIO()
216+
wb.save(buf)
217+
buf.seek(0)
218+
with zipfile.ZipFile(buf, "r") as zin, \
219+
zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zout:
220+
for item in zin.infolist():
221+
data = zin.read(item.filename)
222+
if item.filename == "docProps/core.xml":
223+
# openpyxl forces dcterms:modified to "now" inside save(); pin it.
224+
data = _FIX_MODIFIED_RE.sub(
225+
lambda m: m.group(1) + _FIXED_DT + m.group(3),
226+
data.decode("utf-8")).encode("utf-8")
227+
zi = zipfile.ZipInfo(item.filename, date_time=_FIXED_TS)
228+
zi.compress_type = zipfile.ZIP_DEFLATED
229+
zi.external_attr = item.external_attr
230+
zi.create_system = item.create_system
231+
zout.writestr(zi, data)
232+
233+
234+
def main():
235+
try:
236+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
237+
except (AttributeError, ValueError):
238+
pass
239+
240+
# ── B2: fixed-point iteration ──────────────────────────────
241+
# n_m is not chosen freely: iterate until S_BET (= n_m × 4.353) agrees
242+
# with S_BJH (the surface area implied by the BJH pore geometry, itself
243+
# normalised to the isotherm's Gurvich pore volume). This closes the loop
244+
# that previously left the two areas 2.3× apart.
245+
n_m = N_M_INIT
246+
for it in range(20):
247+
x, ads, des = make_isotherm(n_m)
248+
249+
i099 = int(np.argmin(np.abs(ads[:, 0] - 0.990)))
250+
vp_total = ads[i099, 1] * N2_STP_TO_LIQUID
251+
252+
rp, dV, cum_vp, cum_sap, rp_peak, s_bjh, vp_bjh = make_bjh(vp_total)
253+
254+
s_bet = n_m * N2_BET_FACTOR
255+
ratio = s_bet / s_bjh
256+
if abs(ratio - 1.0) < 0.02:
257+
break
258+
n_m = s_bjh / N2_BET_FACTOR
259+
else:
260+
raise RuntimeError("B2 fixed-point iteration did not converge in 20 "
261+
"iterations")
262+
263+
# ── B1: smoothness check (no step discontinuity) ───────────
264+
diff_va = np.abs(np.diff(ads[:, 1]))
265+
step_profile = step(x, X_STEP, STEP_W, STEP_H)
266+
cond = (step_profile[1:] > 0.05 * STEP_H) & (step_profile[1:] < 0.95 * STEP_H)
267+
max_diff = float(diff_va.max())
268+
med_cond = float(np.median(diff_va[cond]))
269+
assert max_diff <= 3.0 * med_cond, "isotherm has a step discontinuity"
270+
271+
# BET plot points: every adsorption point below BET_CUTOFF, with
272+
# y = 1/[Va(1/x - 1)] computed from the SAME Va written into AdsDes.
273+
mask = ads[:, 0] < BET_CUTOFF
274+
x_bet = ads[mask, 0]
275+
v_bet = ads[mask, 1]
276+
y_bet = 1.0 / (v_bet * (1.0 / x_bet - 1.0))
277+
bet_pts = np.column_stack([x_bet, y_bet])
278+
279+
in_range = (x_bet >= 0.05) & (x_bet <= 0.35)
280+
assert in_range.sum() >= 5, "ISO 9277 requires >= 5 points in 0.05–0.35"
281+
start_pt = int(np.where(x_bet >= 0.05)[0][0])
282+
end_pt = int(np.where(x_bet <= 0.35)[0][-1])
283+
284+
dp_avg = 4.0e3 * vp_bjh / s_bjh # nm, cylindrical 4 V / S
285+
286+
summary = {
287+
"Vm": n_m,
288+
"S_BET": s_bet,
289+
"C": C,
290+
"Vp_total": vp_total,
291+
"dp_avg": dp_avg,
292+
"rp_peak_BJH": rp_peak,
293+
"S_BJH": s_bjh,
294+
"Vp_BJH": vp_bjh,
295+
"start_pt": start_pt,
296+
"end_pt": end_pt,
297+
}
298+
299+
write_workbook(ads, des, bet_pts, (rp, dV, cum_vp, cum_sap), summary)
300+
301+
# ── B1/B2 acceptance + round-trip report ───────────────────
302+
print("=== B1 (smoothness) ===")
303+
print(f"max|diff(va)| = {max_diff:.4f}")
304+
print(f"median|diff| over condensation = {med_cond:.4f}")
305+
print(f"3 x median = {3.0 * med_cond:.4f}")
306+
print(f"pass (max <= 3x median) = {max_diff <= 3.0 * med_cond}")
307+
308+
print("=== B2 (self-consistency) ===")
309+
print(f"iterations = {it + 1}")
310+
print(f"n_m (final) = {n_m:.6f} cm³(STP) g⁻¹")
311+
print(f"C = {C}")
312+
print(f"S_BET_true = n_m × 4.353 = {s_bet:.4f} m²/g")
313+
print(f"S_BJH = {s_bjh:.4f} m²/g")
314+
print(f"S_BET / S_BJH = {ratio:.4f}")
315+
print(f"pass (|ratio - 1| <= 0.15) = {abs(ratio - 1.0) <= 0.15}")
316+
317+
# Round-trip check: read the file back with the project's own reader and
318+
# confirm every value survived the pandas/openpyxl positional read.
319+
from bet_analysis import read_bet_xls, verify_bet
320+
321+
data = read_bet_xls(OUT)
322+
print("=== round-trip (read_bet_xls) ===")
323+
print("summary:", data["summary"])
324+
for k in ("ads", "des", "bet_pts", "bjh"):
325+
print(f"{k}: shape = {tuple(data[k].shape)}")
326+
327+
res = verify_bet(data["bet_pts"], data["summary"])
328+
print("=== verify_bet ===")
329+
print(f"S_BET_calc = {res['S_BET_calc']:.4f} m²/g (summary S_BET = {s_bet:.4f})")
330+
print(f"Vm (calc) = {res['Vm']:.4f} (n_m = {n_m:.4f})")
331+
print(f"C (calc) = {res['C']:.2f} (C = {C})")
332+
print(f"R² = {res['R2']:.6f}")
333+
print(f"BET window = points {summary['start_pt']}{summary['end_pt']} "
334+
f"({end_pt - start_pt + 1} points)")
335+
print(f"Wrote: {OUT}")
336+
337+
338+
if __name__ == "__main__":
339+
main()

examples/reference_mesoporous.xlsx

12.9 KB
Binary file not shown.

0 commit comments

Comments
 (0)