|
30 | 30 | diagnose_instrument_range, |
31 | 31 | format_rouquerol_report, |
32 | 32 | rouquerol_transform, |
| 33 | + bet_sensitivity_heatmap, |
33 | 34 | ) |
34 | 35 |
|
35 | 36 | # ════════════════════════════════════════════════════════════════════════════ |
@@ -273,6 +274,49 @@ def _plot_rouquerol_transform(p_rel, n, best_window) -> plt.Figure: |
273 | 274 | return fig |
274 | 275 |
|
275 | 276 |
|
| 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 | + |
276 | 320 | def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts, |
277 | 321 | start_pt, end_pt): |
278 | 322 | """ |
@@ -415,6 +459,12 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts, |
415 | 459 | ) |
416 | 460 | except Exception: |
417 | 461 | 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 |
418 | 468 |
|
419 | 469 |
|
420 | 470 | # ════════════════════════════════════════════════════════════════════════════ |
@@ -632,6 +682,19 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts, |
632 | 682 | }) |
633 | 683 | st.dataframe(crit_df, use_container_width=True, hide_index=True) |
634 | 684 |
|
| 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 | + |
635 | 698 | if instrument_window is not None: |
636 | 699 | st.divider() |
637 | 700 | st.markdown("**Instrument Range vs Rouquerol** (matched by p/p₀)") |
|
0 commit comments