Skip to content

Commit 3b7f4a2

Browse files
committed
feat: BET sensitivity heatmap (BEaTmap-style) in Rouquerol tab
1 parent 4a82aba commit 3b7f4a2

3 files changed

Lines changed: 141 additions & 0 deletions

File tree

app_bet.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
diagnose_instrument_range,
3131
format_rouquerol_report,
3232
rouquerol_transform,
33+
bet_sensitivity_heatmap,
3334
)
3435

3536
# ════════════════════════════════════════════════════════════════════════════
@@ -273,6 +274,49 @@ def _plot_rouquerol_transform(p_rel, n, best_window) -> plt.Figure:
273274
return fig
274275

275276

277+
278+
def _plot_bet_heatmap(heatmap_result, best_window) -> plt.Figure:
279+
"""Plot S_BET sensitivity heatmap (BEaTmap-style)."""
280+
setup_plot_style()
281+
s_bet = heatmap_result["s_bet"]
282+
valid = heatmap_result["valid"]
283+
p = heatmap_result["p_sorted"]
284+
N = heatmap_result["n_points"]
285+
286+
s_masked = np.ma.masked_where(~valid | ~np.isfinite(s_bet), s_bet)
287+
288+
fig, ax = plt.subplots(figsize=(7, 5.5))
289+
cmap = plt.cm.RdYlGn_r.copy()
290+
cmap.set_bad(color="#e0e0e0")
291+
292+
im = ax.imshow(s_masked, aspect="auto", cmap=cmap,
293+
origin="lower", interpolation="nearest")
294+
295+
if best_window is not None:
296+
p_lo = np.searchsorted(p, best_window.p_min)
297+
p_hi = np.searchsorted(p, best_window.p_max)
298+
if p_hi > p_lo:
299+
rect = plt.Rectangle((p_lo, p_lo), p_hi - p_lo, p_hi - p_lo,
300+
linewidth=2, edgecolor="blue",
301+
facecolor="none", linestyle="--")
302+
ax.add_patch(rect)
303+
304+
tick_step = max(1, N // 8)
305+
tick_pos = np.arange(0, N, tick_step)
306+
tick_labels = [f"{p[i]:.2f}" for i in tick_pos]
307+
ax.set_xticks(tick_pos)
308+
ax.set_xticklabels(tick_labels, fontsize=8, rotation=45)
309+
ax.set_yticks(tick_pos)
310+
ax.set_yticklabels(tick_labels, fontsize=8)
311+
312+
ax.set_xlabel("End point p/p₀")
313+
ax.set_ylabel("Start point p/p₀")
314+
plt.colorbar(im, ax=ax, label="S_BET (m² g⁻¹)")
315+
ax.set_title("BET Sensitivity Heatmap", fontsize=10)
316+
plt.tight_layout()
317+
return fig
318+
319+
276320
def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
277321
start_pt, end_pt):
278322
"""
@@ -415,6 +459,12 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
415459
)
416460
except Exception:
417461
instrument_window = None
462+
heatmap_result = None
463+
if rouquerol_result is not None:
464+
try:
465+
heatmap_result = bet_sensitivity_heatmap(p_ads, n_ads)
466+
except Exception:
467+
heatmap_result = None
418468

419469

420470
# ════════════════════════════════════════════════════════════════════════════
@@ -632,6 +682,19 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
632682
})
633683
st.dataframe(crit_df, use_container_width=True, hide_index=True)
634684

685+
if heatmap_result is not None:
686+
st.divider()
687+
st.markdown("**BET Sensitivity Heatmap**")
688+
st.caption(
689+
"Each cell shows S_BET for a specific p/p₀ window "
690+
"(start × end). Colored = valid (Rouquerol PASS). "
691+
"Gray = invalid. Blue dashed = selected range."
692+
)
693+
fig_hm = _plot_bet_heatmap(heatmap_result, best)
694+
st.pyplot(fig_hm, use_container_width=True)
695+
plt.close(fig_hm)
696+
697+
635698
if instrument_window is not None:
636699
st.divider()
637700
st.markdown("**Instrument Range vs Rouquerol** (matched by p/p₀)")

rouquerol.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,3 +407,63 @@ def format_rouquerol_report(result: dict, sample_name: str = "Sample") -> str:
407407
]
408408
)
409409
return "\n".join(lines)
410+
411+
412+
def bet_sensitivity_heatmap(
413+
p_rel: np.ndarray,
414+
n: np.ndarray,
415+
*,
416+
min_points: int = MIN_POINTS_DEFAULT,
417+
criterion4_tol: float = CRITERION4_TOL_DEFAULT,
418+
) -> dict:
419+
"""Compute S_BET for ALL possible windows — BEaTmap-style sensitivity map.
420+
421+
For every contiguous window [i, j] with at least ``min_points`` points,
422+
the BET fit is evaluated and S_BET, C, R² and Rouquerol validity are
423+
stored in 2D matrices. The result can be plotted as a heatmap to show
424+
how sensitive S_BET is to the choice of pressure range.
425+
426+
Returns
427+
-------
428+
dict with keys:
429+
s_bet : (N, N) ndarray — S_BET for each window (NaN if too few
430+
points or non-physical)
431+
c : (N, N) ndarray — BET C constant
432+
r2 : (N, N) ndarray — R² of the linear fit
433+
valid : (N, N) bool ndarray — Rouquerol-valid windows
434+
p_sorted : (N,) ndarray — sorted p/p0 values (axis labels)
435+
n_points : int — number of points after filtering
436+
"""
437+
p_rel = np.asarray(p_rel, dtype=float)
438+
n = np.asarray(n, dtype=float)
439+
if p_rel.ndim != 1 or n.ndim != 1 or len(p_rel) != len(n):
440+
raise ValueError("p_rel and n must be 1-D arrays of equal length.")
441+
442+
order = np.argsort(p_rel)
443+
p_s, n_s = p_rel[order], n[order]
444+
finite = np.isfinite(p_s) & np.isfinite(n_s) & (p_s > 0) & (p_s < 1) & (n_s > 0)
445+
p_s, n_s = p_s[finite], n_s[finite]
446+
447+
N = len(p_s)
448+
s_bet = np.full((N, N), np.nan)
449+
c_val = np.full((N, N), np.nan)
450+
r2_val = np.full((N, N), np.nan)
451+
valid = np.full((N, N), False)
452+
453+
for i in range(N):
454+
for j in range(i + min_points - 1, N):
455+
win = evaluate_window(p_s, n_s, i, j, criterion4_tol=criterion4_tol)
456+
if win is not None:
457+
s_bet[i, j] = win.S_BET
458+
c_val[i, j] = win.C
459+
r2_val[i, j] = win.R2
460+
valid[i, j] = win.valid
461+
462+
return {
463+
"s_bet": s_bet,
464+
"c": c_val,
465+
"r2": r2_val,
466+
"valid": valid,
467+
"p_sorted": p_s,
468+
"n_points": N,
469+
}

tests/test_rouquerol.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
monolayer_pressure_theory,
1111
rouquerol_transform,
1212
select_bet_range,
13+
bet_sensitivity_heatmap,
1314
)
1415

1516

@@ -111,3 +112,20 @@ def test_bet_uncertainty_propagation():
111112
assert fit["sigma_C"] > 0
112113
S_true = Vm * N2_BET_FACTOR
113114
assert abs(fit["S_BET"] - S_true) < 3.0 * fit["sigma_S_BET"]
115+
116+
117+
def test_bet_sensitivity_heatmap_dimensions_and_stability():
118+
"""Heatmap returns NxN matrices; ideal isotherm gives stable S_BET."""
119+
p, n, C, Vm = _ideal_bet_isotherm()
120+
result = bet_sensitivity_heatmap(p, n)
121+
N = result["n_points"]
122+
assert result["s_bet"].shape == (N, N)
123+
assert result["valid"].shape == (N, N)
124+
assert result["r2"].shape == (N, N)
125+
assert result["valid"].sum() > 0
126+
S_true = Vm * N2_BET_FACTOR
127+
valid_s = result["s_bet"][result["valid"]]
128+
assert np.all(np.abs(valid_s - S_true) / S_true < 0.01)
129+
for i in range(N):
130+
for j in range(i):
131+
assert np.isnan(result["s_bet"][i, j])

0 commit comments

Comments
 (0)