|
| 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() |
0 commit comments