@@ -228,6 +228,11 @@ def _fig_to_bytes(fig) -> bytes:
228228 return buf .read ()
229229
230230
231+ def _fmt (v , spec : str ) -> str :
232+ """Format a value, or an em-dash when it is absent (None)."""
233+ return "—" if v is None else f"{ v :{spec }} "
234+
235+
231236def _plot_isotherm (ads , des , iso_cls , hyst_cls ) -> plt .Figure :
232237 """Draw the N₂ adsorption–desorption isotherm as a standalone figure."""
233238 setup_plot_style ()
@@ -467,7 +472,19 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
467472 file_bytes = uploaded .read ()
468473 ext = Path (uploaded .name ).suffix .lower ()
469474 if ext == ".csv" :
470- data = _parse_csv_template (file_bytes )
475+ # Distinguish the sectioned "Manual CSV" template from a plain
476+ # two-column isotherm CSV.
477+ first = ""
478+ for line in file_bytes .decode ("utf-8-sig" , errors = "ignore" ).splitlines ():
479+ if line .strip ():
480+ first = line .strip ()
481+ break
482+ if first .startswith ("[" ) and "]" in first :
483+ data = _parse_csv_template (file_bytes )
484+ else :
485+ tmp = Path (f"/tmp/{ uploaded .name } " )
486+ tmp .write_bytes (file_bytes )
487+ data = read_bet_xls (str (tmp ))
471488 else :
472489 tmp = Path (f"/tmp/{ uploaded .name } " )
473490 tmp .write_bytes (file_bytes )
@@ -498,12 +515,18 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
498515if use_rouquerol :
499516 with st .spinner ("Running Rouquerol range selection…" ):
500517 rouquerol_result = select_bet_range (p_ads , n_ads )
501- try :
502- instrument_window = _match_instrument_window_by_pressure (
503- p_ads , n_ads , data ["bet_pts" ], s ["start_pt" ], s ["end_pt" ]
504- )
505- except Exception :
518+ if s .get ("window_derived" ):
519+ # No instrument window exists for a plain isotherm; the derived
520+ # default window is not an instrument value, so the instrument-vs-
521+ # Rouquerol comparison is declined.
506522 instrument_window = None
523+ else :
524+ try :
525+ instrument_window = _match_instrument_window_by_pressure (
526+ p_ads , n_ads , data ["bet_pts" ], s ["start_pt" ], s ["end_pt" ]
527+ )
528+ except Exception :
529+ instrument_window = None
507530 heatmap_result = None
508531 if rouquerol_result is not None :
509532 try :
@@ -530,10 +553,10 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
530553 # ─ KPI metrics row
531554 cols = st .columns (4 )
532555 kpi = [
533- (f" { s [ ' S_BET' ]: .2f} " , "m² g⁻¹" , "BET Surface Area" ),
534- (f" { s [ ' Vp_total' ]: .4f} " , "cm³ g⁻¹" , "Total Pore Volume" ),
535- (f" { s [ ' dp_avg' ]: .1f} " , "nm" , "Avg Pore Diameter" ),
536- (f" { s [ 'C' ]: .1f} " , "—" , "BET C Constant" ),
556+ (_fmt ( s . get ( " S_BET" ), " .2f" ) , "m² g⁻¹" , "BET Surface Area" ),
557+ (_fmt ( s . get ( " Vp_total" ), " .4f" ) , "cm³ g⁻¹" , "Total Pore Volume" ),
558+ (_fmt ( s . get ( " dp_avg" ), " .1f" ) , "nm" , "Avg Pore Diameter" ),
559+ (_fmt ( s . get ( "C" ), " .1f" ) , "—" , "BET C Constant" ),
537560 ]
538561 for col , (val , unit , label ) in zip (cols , kpi ):
539562 with col :
@@ -558,6 +581,9 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
558581 with col_cls :
559582 st .markdown ("**Isotherm Classification**" )
560583 st .success (f"**{ iso_cls ['type' ]} ** \n { iso_cls ['explanation' ]} " )
584+ if len (data .get ("des" , [])) == 0 :
585+ st .info ("Type IV/V not evaluated — no desorption branch supplied "
586+ "(adsorption-only input)." )
561587
562588 st .markdown ("**Hysteresis Classification**" )
563589 if hyst_cls ["type" ] != "None" :
@@ -568,6 +594,8 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
568594 f"{ hyst_cls ['explanation' ]} \n "
569595 f"Score share: { share } ({ hyst_cls ['score_share_pct' ]:.0f} % of total score)"
570596 )
597+ elif len (data .get ("des" , [])) == 0 :
598+ st .info ("Hysteresis not evaluated — no desorption branch supplied." )
571599 else :
572600 st .info ("No hysteresis detected." )
573601
@@ -586,16 +614,28 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
586614 # ─ Summary table
587615 st .markdown ("**Summary Table**" )
588616 df_out = pd .DataFrame ([
589- ["BET Surface Area" , f" { s [ ' S_BET' ]: .3f} " , "m² g⁻¹" ],
590- ["Vm (monolayer cap.)" , f" { s [ 'Vm' ]: .4f} " , "cm³(STP) g⁻¹" ],
591- ["BET C constant" , f" { s [ 'C' ]: .2f} " , "—" ],
592- ["Total Pore Volume" , f" { s [ ' Vp_total' ]: .4f} " , "cm³ g⁻¹" ],
593- ["Average Pore Diameter" , f" { s [ ' dp_avg' ]: .3f} " , "nm" ],
594- ["BJH Surface Area" , f" { s [ ' S_BJH' ]: .3f} " , "m² g⁻¹" ],
595- ["BJH Peak Pore Diameter" , f" { s [ ' rp_peak_BJH' ] * 2 : .2f} " , "nm" ],
617+ ["BET Surface Area" , _fmt ( s . get ( " S_BET" ), " .3f" ) , "m² g⁻¹" ],
618+ ["Vm (monolayer cap.)" , _fmt ( s . get ( "Vm" ), " .4f" ) , "cm³(STP) g⁻¹" ],
619+ ["BET C constant" , _fmt ( s . get ( "C" ), " .2f" ) , "—" ],
620+ ["Total Pore Volume" , _fmt ( s . get ( " Vp_total" ), " .4f" ) , "cm³ g⁻¹" ],
621+ ["Average Pore Diameter" , _fmt ( s . get ( " dp_avg" ), " .3f" ) , "nm" ],
622+ ["BJH Surface Area" , _fmt ( s . get ( " S_BJH" ), " .3f" ) , "m² g⁻¹" ],
623+ ["BJH Peak Pore Diameter" , _fmt ( None if s . get ( "rp_peak_BJH" ) is None else s [ " rp_peak_BJH" ] * 2 , " .2f" ) , "nm" ],
596624 ], columns = ["Parameter" , "Value" , "Unit" ])
597625 st .dataframe (df_out , use_container_width = True , hide_index = True )
598626
627+ # ─ Declined / derived notes (plain-isotherm input) ──────────────
628+ if s .get ("window_derived" ):
629+ st .caption ("BET point window derived from the data (0.05 ≤ p/p₀ ≤ 0.35), "
630+ "not read from an instrument." )
631+ declined = []
632+ for label , reason in (s .get ("declined" ) or {}).items ():
633+ declined .append (f"{ label } ({ reason } )" )
634+ if s .get ("Vp_total_reason" ):
635+ declined .append (f"total pore volume (Gurvich) — { s ['Vp_total_reason' ]} " )
636+ if declined :
637+ st .info ("**Not available (declined):** " + "; " .join (declined ))
638+
599639 tag = (
600640 '<span class="tag-valid">✓ C constant valid</span>'
601641 if bet_res ["C_valid" ] else
@@ -661,6 +701,8 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
661701 st .markdown (
662702 f"Points used: **{ s ['start_pt' ]} ** → **{ s ['end_pt' ]} ** "
663703 f"({ s ['end_pt' ] - s ['start_pt' ] + 1 } points)"
704+ + (" *(window derived from the data, not read from an instrument)*"
705+ if s .get ("window_derived" ) else "" )
664706 )
665707 with col_fig :
666708 setup_plot_style ()
@@ -798,8 +840,9 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
798840
799841 st .divider ()
800842 st .markdown ("**Comparison with BET**" )
843+ sbet_label = "S_BET" if not s .get ("instrument_summary" , True ) else "Instrument S_BET"
801844 comp_rows = [
802- ["Instrument S_BET", f" { s [ 'S_BET' ]: .2f} " , "—" ],
845+ [sbet_label , _fmt ( s . get ( " S_BET"), " .2f" ) , "—" ],
803846 ]
804847 if use_rouquerol and rouquerol_result is not None and rouquerol_result ["best" ] is not None :
805848 rb = rouquerol_result ["best" ]
@@ -946,50 +989,56 @@ def _match_instrument_window_by_pressure(p_ads, n_ads, bet_pts,
946989with tab_bjh :
947990 st .subheader ("BJH Pore Size Distribution" )
948991
949- peak_diam = s ["rp_peak_BJH" ] * 2.0
950- if peak_diam < BJH_NARROW_MESOPORE_NM :
951- st .warning (
952- f"⚠ BJH peak diameter { peak_diam :.1f} nm is below 10 nm — "
953- "Kelvin-equation (BJH) procedures underestimate narrow mesopore "
954- "size by ~20-30% (Thommes et al. 2015 §7.2, §9)."
955- )
956-
957992 bjh = data ["bjh" ]
958- # Instrument headers verified as radius ("rp/nm") and per-radius
959- # differential ("dVp/drp"), so rp*2 = diameter and dV/dd = dV/dr / 2.
960- rp = bjh [:, 0 ] * 2 # radius (nm) -> diameter (nm)
961- dVdd = bjh [:, 1 ] / 2.0 # dVp/drp -> dVp/ddp
962- cum_Vp = bjh [:, 2 ]; cum_Sap = bjh [:, 3 ]
963993
964- setup_plot_style ()
965- fig_bjh , (ax1 , ax2 ) = plt .subplots (1 , 2 , figsize = (9 , 4 ))
966-
967- ax1 .plot (rp , dVdd , "-" , color = C_BJH , lw = 1.5 )
968- ax1 .fill_between (rp , dVdd , alpha = 0.15 , color = C_BJH )
969- pk = np .argmax (dVdd )
970- ax1 .axvline (rp [pk ], ls = "--" , lw = 0.9 , color = C_BJH , alpha = 0.7 )
971- ax1 .text (rp [pk ]+ 0.3 , dVdd [pk ]* 0.9 , f"{ rp [pk ]:.1f} nm" , fontsize = 8 , color = C_BJH )
972- ax1 .axvline (N2_CAVITATION_NM , ls = ":" , lw = 0.8 , color = "0.6" )
973- ax1 .set_xlabel ("Pore Diameter (nm)" )
974- ax1 .set_ylabel (r"d$V_p$/d$d_p$ (cm³ g⁻¹ nm⁻¹)" )
975- ax1 .set_xlim (left = 0 ); ax1 .set_ylim (bottom = 0 )
976- ax1 .set_title ("Differential PSD" )
977-
978- ax2r = ax2 .twinx ()
979- ax2 .plot (rp , cum_Vp , "-" , color = C_CUM , lw = 1.5 , label = "Vp cumul." )
980- ax2r .plot (rp , cum_Sap , "--" , color = C_BJH , lw = 1.5 , label = "Sap cumul." )
981- ax2 .set_xlabel ("Pore Diameter (nm)" )
982- ax2 .set_ylabel ("Cum. Pore Volume (cm³ g⁻¹)" , color = C_CUM )
983- ax2r .set_ylabel ("Cum. Surface Area (m² g⁻¹)" , color = C_BJH )
984- ax2 .tick_params (axis = "y" , colors = C_CUM )
985- ax2r .tick_params (axis = "y" , colors = C_BJH )
986- ax2 .set_xlim (left = 0 ); ax2 .set_ylim (bottom = 0 )
987- ax2 .set_title ("Cumulative Pore Volume" )
988- l1 , b1 = ax2 .get_legend_handles_labels (); l2 , b2 = ax2r .get_legend_handles_labels ()
989- ax2 .legend (l1 + l2 , b1 + b2 , fontsize = 8 , loc = "lower right" )
990- plt .tight_layout ()
991- st .pyplot (fig_bjh , use_container_width = True )
992- plt .close (fig_bjh )
994+ if s .get ("rp_peak_BJH" ) is not None :
995+ peak_diam = s ["rp_peak_BJH" ] * 2.0
996+ if peak_diam < BJH_NARROW_MESOPORE_NM :
997+ st .warning (
998+ f"⚠ BJH peak diameter { peak_diam :.1f} nm is below 10 nm — "
999+ "Kelvin-equation (BJH) procedures underestimate narrow mesopore "
1000+ "size by ~20-30% (Thommes et al. 2015 §7.2, §9)."
1001+ )
1002+
1003+ if len (bjh ) == 0 :
1004+ st .info ("BJH pore size distribution not available — no BJH table "
1005+ "supplied (plain-isotherm input)." )
1006+ else :
1007+ # Instrument headers verified as radius ("rp/nm") and per-radius
1008+ # differential ("dVp/drp"), so rp*2 = diameter and dV/dd = dV/dr / 2.
1009+ rp = bjh [:, 0 ] * 2 # radius (nm) -> diameter (nm)
1010+ dVdd = bjh [:, 1 ] / 2.0 # dVp/drp -> dVp/ddp
1011+ cum_Vp = bjh [:, 2 ]; cum_Sap = bjh [:, 3 ]
1012+
1013+ setup_plot_style ()
1014+ fig_bjh , (ax1 , ax2 ) = plt .subplots (1 , 2 , figsize = (9 , 4 ))
1015+
1016+ ax1 .plot (rp , dVdd , "-" , color = C_BJH , lw = 1.5 )
1017+ ax1 .fill_between (rp , dVdd , alpha = 0.15 , color = C_BJH )
1018+ pk = np .argmax (dVdd )
1019+ ax1 .axvline (rp [pk ], ls = "--" , lw = 0.9 , color = C_BJH , alpha = 0.7 )
1020+ ax1 .text (rp [pk ]+ 0.3 , dVdd [pk ]* 0.9 , f"{ rp [pk ]:.1f} nm" , fontsize = 8 , color = C_BJH )
1021+ ax1 .axvline (N2_CAVITATION_NM , ls = ":" , lw = 0.8 , color = "0.6" )
1022+ ax1 .set_xlabel ("Pore Diameter (nm)" )
1023+ ax1 .set_ylabel (r"d$V_p$/d$d_p$ (cm³ g⁻¹ nm⁻¹)" )
1024+ ax1 .set_xlim (left = 0 ); ax1 .set_ylim (bottom = 0 )
1025+ ax1 .set_title ("Differential PSD" )
1026+
1027+ ax2r = ax2 .twinx ()
1028+ ax2 .plot (rp , cum_Vp , "-" , color = C_CUM , lw = 1.5 , label = "Vp cumul." )
1029+ ax2r .plot (rp , cum_Sap , "--" , color = C_BJH , lw = 1.5 , label = "Sap cumul." )
1030+ ax2 .set_xlabel ("Pore Diameter (nm)" )
1031+ ax2 .set_ylabel ("Cum. Pore Volume (cm³ g⁻¹)" , color = C_CUM )
1032+ ax2r .set_ylabel ("Cum. Surface Area (m² g⁻¹)" , color = C_BJH )
1033+ ax2 .tick_params (axis = "y" , colors = C_CUM )
1034+ ax2r .tick_params (axis = "y" , colors = C_BJH )
1035+ ax2 .set_xlim (left = 0 ); ax2 .set_ylim (bottom = 0 )
1036+ ax2 .set_title ("Cumulative Pore Volume" )
1037+ l1 , b1 = ax2 .get_legend_handles_labels (); l2 , b2 = ax2r .get_legend_handles_labels ()
1038+ ax2 .legend (l1 + l2 , b1 + b2 , fontsize = 8 , loc = "lower right" )
1039+ plt .tight_layout ()
1040+ st .pyplot (fig_bjh , use_container_width = True )
1041+ plt .close (fig_bjh )
9931042
9941043 if show_features and hyst_cls ["type" ] != "None" :
9951044 st .divider ()
@@ -1146,15 +1195,15 @@ def _fmt(v, spec):
11461195 st .markdown ("• **📋 CSV Report**" )
11471196 report_rows = [
11481197 ["Sample" , sample_name ],
1149- ["S_BET (m2/g)" , f" { s [ ' S_BET' ]: .3f} " ],
1150- ["Vm (cm3(STP)/g)" , f" { s [ 'Vm' ]: .4f} " ],
1151- ["C constant" , f" { s [ 'C' ]: .2f} " ],
1198+ ["S_BET (m2/g)" , _fmt ( s . get ( " S_BET" ), " .3f" ) ],
1199+ ["Vm (cm3(STP)/g)" , _fmt ( s . get ( "Vm" ), " .4f" ) ],
1200+ ["C constant" , _fmt ( s . get ( "C" ), " .2f" ) ],
11521201 ["C valid" , str (bet_res ["C_valid" ])],
11531202 ["R2" , f"{ bet_res ['R2' ]:.6f} " ],
1154- ["Vp_total (cm3/g)" , f" { s [ ' Vp_total' ]: .4f} " ],
1155- ["dp_avg (nm)" , f" { s [ ' dp_avg' ]: .3f} " ],
1156- ["S_BJH (m2/g)" , f" { s [ ' S_BJH' ]: .3f} " ],
1157- ["BJH_peak_diam (nm)" , f" { s [ ' rp_peak_BJH' ] * 2 : .2f} " ],
1203+ ["Vp_total (cm3/g)" , _fmt ( s . get ( " Vp_total" ), " .4f" ) ],
1204+ ["dp_avg (nm)" , _fmt ( s . get ( " dp_avg" ), " .3f" ) ],
1205+ ["S_BJH (m2/g)" , _fmt ( s . get ( " S_BJH" ), " .3f" ) ],
1206+ ["BJH_peak_diam (nm)" , _fmt ( None if s . get ( "rp_peak_BJH" ) is None else s [ " rp_peak_BJH" ] * 2 , " .2f" ) ],
11581207 ["Isotherm type" , iso_cls ["type" ]],
11591208 ["Hysteresis type" , hyst_cls ["type" ]],
11601209 ["Hysteresis score share" , hyst_cls .get ("score_share" , "—" )],
0 commit comments