""" Fluorescence Calibration & Prediction Tool ========================================== Tab 1 — Calibration : upload images, type concentration next to each, fit G/B and G/G₀ vs concentration, get equations. Tab 2 — Predict : upload one unknown image, get predicted concentration plotted on the calibration curve. Tab 3 — Data & Export: full table, residuals, CSV download. References [1] Stern & Volmer, Physik. Z., 1919, 20, 183-188. [2] arXiv:2603.27118, Eq. 2 (G/B ratio) [3] Han et al., Molecules 2024, DOI: 10.3390/molecules29071658 Run: streamlit run app.py """ import io import numpy as np import pandas as pd import streamlit as st from PIL import Image, ImageDraw from scipy import ndimage, stats # ── Page config ─────────────────────────────────────────────────────────────── st.set_page_config( page_title="Fluorescence Calibration Tool", page_icon="🔬", layout="wide", ) # ── Constants ────────────────────────────────────────────────────────────────── G0_DEFAULT = 135.0 G0_IMAGE_NAME = "fluorescence_0159.jpg" # ── Session state init ──────────────────────────────────────────────────────── for key, val in { "calibration_done": False, "calib_gb": {"m": None, "b": None, "r2": None, "p": None}, "calib_gog0": {"m": None, "b": None, "r2": None, "p": None}, "calib_df": None, "g0_used": G0_DEFAULT, }.items(): if key not in st.session_state: st.session_state[key] = val # ══════════════════════════════════════════════════════════════════════════════ # CORE FUNCTIONS # ══════════════════════════════════════════════════════════════════════════════ def detect_cuvette(arr, green_thresh_pct=0.40, padding=15, max_crop_frac=0.30): h, w = arr.shape[:2] green = arr[:, :, 1] for pct in [green_thresh_pct, green_thresh_pct + 0.10, green_thresh_pct + 0.20, green_thresh_pct + 0.30]: pct = min(pct, 0.90) mask = (green > green.max() * pct).astype(np.uint8) if mask.sum() < 50: continue labeled, n = ndimage.label(mask) if n == 0: continue sizes = ndimage.sum(mask, labeled, range(1, n + 1)) lbl = int(np.argmax(sizes)) + 1 ys, xs = np.where(labeled == lbl) x1 = max(0, int(xs.min()) - padding) y1 = max(0, int(ys.min()) - padding) x2 = min(w - 1, int(xs.max()) + padding) y2 = min(h - 1, int(ys.max()) + padding) if (x2 - x1) <= w * max_crop_frac and (y2 - y1) <= h * max_crop_frac: return (x1, y1, x2, y2), f"auto (G>{pct:.0%})" box = (w // 4, h // 4, 3 * w // 4, 3 * h // 4) return box, "center (fallback)" def analyze_image(img: Image.Image, g0: float, region_mode: str = "Auto-detect (green channel)", green_thresh: float = 0.40) -> dict: rgb = img.convert("RGB") arr = np.array(rgb, dtype=np.float32) h, w = arr.shape[:2] if region_mode == "Auto-detect (green channel)": box, crop_used = detect_cuvette(arr, green_thresh) elif region_mode == "Center 50%": box, crop_used = (w//4, h//4, 3*w//4, 3*h//4), "center" else: box, crop_used = (0, 0, w, h), "full" x1, y1, x2, y2 = box crop = arr[y1:y2, x1:x2] g_arr = crop[:, :, 1] b_arr = crop[:, :, 2] r_arr = crop[:, :, 0] g_m = float(np.mean(g_arr)) b_m = float(np.mean(b_arr)) r_m = float(np.mean(r_arr)) return dict( G_mean = round(g_m, 2), G0 = round(g0, 2), G_over_G0 = round(g_m / g0, 4), delta_G_G0 = round((g0 - g_m) / g0, 4), Quench_pct = round((g0 - g_m) / g0 * 100, 2), G_B_ratio = round(g_m / b_m, 4) if b_m > 0 else None, G_median = round(float(np.median(g_arr)), 2), G_std = round(float(np.std(g_arr)), 2), HEX = "#{:02X}{:02X}{:02X}".format(int(r_m), int(g_m), int(b_m)), Dominant = ["R","G","B"][int(np.argmax([r_m, g_m, b_m]))], Brightness = round(0.299*r_m + 0.587*g_m + 0.114*b_m, 2), Crop_used = crop_used, Pixels = crop.shape[0] * crop.shape[1], _box = box, ) def linear_fit(x, y): """Return slope, intercept, R², p-value.""" s, b, r, p, _ = stats.linregress(x, y) return round(s, 6), round(b, 4), round(r**2, 4), round(p, 4) def predict_concentration(metric_val, m, b): """Invert linear model: C = (y - b) / m""" if m and m != 0: return round((metric_val - b) / m, 2) return None # ── Drawing helpers ─────────────────────────────────────────────────────────── def draw_box(img, box, color=(255, 60, 60)): out = img.convert("RGB").copy() draw = ImageDraw.Draw(out) lw = max(3, img.width // 300) draw.rectangle(box, outline=color, width=lw) return out def resize_display(img, max_w=480): if img.width > max_w: r = max_w / img.width img = img.resize((max_w, int(img.height * r)), Image.LANCZOS) return img def color_swatch(hex_code, size=60): r = int(hex_code[1:3], 16) g = int(hex_code[3:5], 16) b = int(hex_code[5:7], 16) img = Image.new("RGB", (size, size), (r, g, b)) draw = ImageDraw.Draw(img) draw.rectangle([0, 0, size-1, size-1], outline=(100,100,100), width=2) return img def df_to_csv(df): buf = io.StringIO() df.to_csv(buf, index=False) return buf.getvalue().encode() # ── Sidebar ─────────────────────────────────────────────────────────────────── with st.sidebar: st.title("🔬 Fluorescence Tool") st.divider() region_mode = st.radio( "ROI detection", ["Auto-detect (green channel)", "Center 50%", "Full image"], ) green_thresh = 0.40 if region_mode == "Auto-detect (green channel)": green_thresh = st.slider("Green threshold", 0.20, 0.70, 0.40, 0.05) st.divider() g0_value = st.number_input( "G₀ — blank reference", min_value=1.0, max_value=255.0, value=G0_DEFAULT, step=0.1, help=f"From {G0_IMAGE_NAME} — carbon dots only, no analyte.", ) st.caption(f"Default: {G0_DEFAULT} from `{G0_IMAGE_NAME}`") st.divider() if st.session_state.calibration_done: st.success("✅ Calibration ready") gb = st.session_state.calib_gb gg0 = st.session_state.calib_gog0 st.caption( f"**G/B:** y = {gb['m']}x + {gb['b']}\n" f"R² = {gb['r2']} p = {gb['p']}\n\n" f"**G/G₀:** y = {gg0['m']}x + {gg0['b']}\n" f"R² = {gg0['r2']} p = {gg0['p']}" ) if st.button("🗑️ Clear calibration"): st.session_state.calibration_done = False st.session_state.calib_df = None st.rerun() else: st.info("No calibration yet.\nGo to **Calibration** tab.") st.divider() st.caption( "**References**\n" "[1] Stern & Volmer 1919\n" "[2] arXiv:2603.27118 Eq.2\n" "[3] Han et al. Molecules 2024" ) # ── Tabs ────────────────────────────────────────────────────────────────────── tab1, tab2, tab3 = st.tabs([ "📊 Calibration", "🔍 Predict Unknown", "📋 Data & Export", ]) # ══════════════════════════════════════════════════════════════════════════════ # TAB 1 — CALIBRATION # ══════════════════════════════════════════════════════════════════════════════ with tab1: st.header("Calibration — Build your concentration curve") st.markdown( "Upload your **known concentration** images. " "Type the concentration next to each image. " "Click **Run Calibration** to fit the equations." ) uploaded_cal = st.file_uploader( "Drop calibration images here (JPG / PNG / BMP / TIFF)", type=["jpg","jpeg","png","bmp","tiff","tif"], accept_multiple_files=True, key="cal_uploader", ) if not uploaded_cal: st.info("⬆️ Upload calibration images to get started.") else: st.subheader(f"Step 1 — Enter concentration for each image ({len(uploaded_cal)} uploaded)") st.caption("Type polystyrene concentration in ppm next to each image thumbnail.") conc_inputs = {} # Show images in rows of 4 with concentration input below each cols_per_row = 4 for row_start in range(0, len(uploaded_cal), cols_per_row): batch = uploaded_cal[row_start : row_start + cols_per_row] cols = st.columns(cols_per_row) for col, f in zip(cols, batch): with col: img_thumb = Image.open(f).convert("RGB") # Quick crop for thumbnail arr_t = np.array(img_thumb, dtype=np.float32) box_t, _ = detect_cuvette(arr_t, green_thresh) x1,y1,x2,y2 = box_t crop_t = img_thumb.crop((x1,y1,x2,y2)) # show thumbnail st.image(resize_display(crop_t, 160), caption=f.name[:20], use_container_width=True) conc = st.number_input( "Concentration (ppm)", min_value=0.0, max_value=100000.0, value=0.0, step=10.0, key=f"conc_{f.name}", label_visibility="collapsed", ) st.caption(f"ppm: {conc:.0f}") conc_inputs[f.name] = conc st.divider() # ── Run calibration button ───────────────────────────────────────── if st.button("🚀 Run Calibration", type="primary", use_container_width=True): with st.spinner("Analysing images and fitting calibration curves..."): cal_rows = [] for f in uploaded_cal: f.seek(0) img = Image.open(f) res = analyze_image(img, g0_value, region_mode, green_thresh) box = res.pop("_box") res["Filename"] = f.name res["Concentration_ppm"] = conc_inputs[f.name] res["_box"] = box res["_img"] = img cal_rows.append(res) cal_df = pd.DataFrame(cal_rows) # Only use rows with concentration > 0 for fitting (exclude pure blank) fit_df = cal_df[cal_df["Concentration_ppm"] > 0].copy() if len(fit_df) < 2: st.error("Need at least 2 images with concentration > 0 to fit a calibration curve.") else: concs = fit_df["Concentration_ppm"].values gb_vals = fit_df["G_B_ratio"].dropna().values gg0_vals = fit_df["G_over_G0"].values # Fit G/B if len(gb_vals) == len(concs): m_gb, b_gb, r2_gb, p_gb = linear_fit(concs, gb_vals) else: m_gb, b_gb, r2_gb, p_gb = None, None, None, None # Fit G/G₀ m_gg0, b_gg0, r2_gg0, p_gg0 = linear_fit(concs, gg0_vals) # Save to session state st.session_state.calib_gb = {"m":m_gb, "b":b_gb, "r2":r2_gb, "p":p_gb} st.session_state.calib_gog0 = {"m":m_gg0, "b":b_gg0, "r2":r2_gg0, "p":p_gg0} st.session_state.calib_df = cal_df st.session_state.calibration_done = True st.session_state.g0_used = g0_value st.success("✅ Calibration complete!") # ── Show calibration results if done ────────────────────────────── if st.session_state.calibration_done and st.session_state.calib_df is not None: cal_df = st.session_state.calib_df gb = st.session_state.calib_gb gg0 = st.session_state.calib_gog0 st.subheader("Step 2 — Calibration results") # Equation cards c1, c2 = st.columns(2) with c1: st.markdown("#### G/B Calibration") if gb["m"]: st.metric("R²", gb["r2"]) st.metric("p-value", gb["p"]) st.code(f"G/B = {gb['m']} × C + {gb['b']}", language=None) st.code(f"C = (G/B − {gb['b']}) / {gb['m']}", language=None) sig = "✅ Significant" if gb["p"] and gb["p"] < 0.05 else "⚠️ Not significant" st.caption(sig) with c2: st.markdown("#### G/G₀ Calibration") st.metric("R²", gg0["r2"]) st.metric("p-value", gg0["p"]) st.code(f"G/G₀ = {gg0['m']} × C + {gg0['b']}", language=None) st.code(f"C = (G/G₀ − {gg0['b']}) / {gg0['m']}", language=None) sig2 = "✅ Significant" if gg0["p"] < 0.05 else "⚠️ Not significant" st.caption(sig2) st.divider() st.subheader("Step 3 — Calibration curves") fit_df = cal_df[cal_df["Concentration_ppm"] > 0].copy() concs = fit_df["Concentration_ppm"].values chart_c1, chart_c2 = st.columns(2) # ── G/B scatter chart ────────────────────────────────────────── with chart_c1: st.markdown(f"**G/B ratio vs Concentration** (R²={gb['r2']}, p={gb['p']})") if gb["m"]: gb_vals = fit_df["G_B_ratio"].values xfit = np.linspace(0, max(concs)*1.1, 200) yfit = gb["m"]*xfit + gb["b"] import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(5.5, 3.8), facecolor='#0A1628') ax.set_facecolor('#0A1628') ax.plot(xfit, yfit, color='#BA7517', linestyle='--', lw=1.8) ax.scatter(concs, gb_vals, color='#BA7517', s=80, zorder=4) # annotate each point for cx, cy in zip(concs, gb_vals): ax.annotate(f"{cx:.0f}", (cx, cy), textcoords="offset points", xytext=(4, 4), fontsize=7, color='#F5C080') ax.text(min(concs)*0.05 + max(concs)*0.05, max(gb_vals)*0.98, f"G/B = {gb['m']}·C + {gb['b']}\nR²={gb['r2']} p={gb['p']}", fontsize=8, color='#F5C080', style='italic', va='top') ax.set_xlabel("Polystyrene concentration (ppm)", color='#7EC8C8', fontsize=9) ax.set_ylabel("G/B ratio", color='#7EC8C8', fontsize=9) ax.tick_params(colors='#7EC8C8', labelsize=8) for sp in ax.spines.values(): sp.set_edgecolor('#1D4060') ax.grid(color='#1D3050', lw=0.5, alpha=0.6) plt.tight_layout(pad=0.4) st.pyplot(fig, use_container_width=True) plt.close(fig) # ── G/G₀ scatter chart ───────────────────────────────────────── with chart_c2: st.markdown(f"**G/G₀ vs Concentration** (R²={gg0['r2']}, p={gg0['p']})") gg0_vals = fit_df["G_over_G0"].values xfit = np.linspace(0, max(concs)*1.1, 200) yfit_gg0 = gg0["m"]*xfit + gg0["b"] fig2, ax2 = plt.subplots(figsize=(5.5, 3.8), facecolor='#0A1628') ax2.set_facecolor('#0A1628') ax2.plot(xfit, yfit_gg0, color='#1D9E75', linestyle='--', lw=1.8) ax2.scatter(concs, gg0_vals, color='#1D9E75', s=80, zorder=4) for cx, cy in zip(concs, gg0_vals): ax2.annotate(f"{cx:.0f}", (cx, cy), textcoords="offset points", xytext=(4, 4), fontsize=7, color='#90E0B0') ax2.text(min(concs)*0.05 + max(concs)*0.05, max(gg0_vals)*0.98, f"G/G₀ = {gg0['m']}·C + {gg0['b']}\nR²={gg0['r2']} p={gg0['p']}", fontsize=8, color='#90E0B0', style='italic', va='top') ax2.set_xlabel("Polystyrene concentration (ppm)", color='#7EC8C8', fontsize=9) ax2.set_ylabel("G / G₀", color='#7EC8C8', fontsize=9) ax2.tick_params(colors='#7EC8C8', labelsize=8) for sp in ax2.spines.values(): sp.set_edgecolor('#1D4060') ax2.grid(color='#1D3050', lw=0.5, alpha=0.6) plt.tight_layout(pad=0.4) st.pyplot(fig2, use_container_width=True) plt.close(fig2) st.divider() st.subheader("Calibration data table") display_cols = ["Filename","Concentration_ppm","G_mean","G_B_ratio", "G_over_G0","delta_G_G0","Quench_pct","Crop_used"] st.dataframe( cal_df[[c for c in display_cols if c in cal_df.columns]], use_container_width=True, height=min(400, 60 + 35*len(cal_df)), ) # ══════════════════════════════════════════════════════════════════════════════ # TAB 2 — PREDICT UNKNOWN # ══════════════════════════════════════════════════════════════════════════════ with tab2: st.header("Predict Unknown Concentration") if not st.session_state.calibration_done: st.warning("⚠️ No calibration loaded. Go to the **Calibration** tab first and run the calibration.") st.stop() gb = st.session_state.calib_gb gg0 = st.session_state.calib_gog0 g0 = st.session_state.g0_used cdf = st.session_state.calib_df st.info( f"Using calibration: " f"**G/B:** y = {gb['m']}x + {gb['b']} (R²={gb['r2']}) | " f"**G/G₀:** y = {gg0['m']}x + {gg0['b']} (R²={gg0['r2']})" ) unk_file = st.file_uploader( "Upload unknown sample image", type=["jpg","jpeg","png","bmp","tiff","tif"], key="unknown_uploader", ) if not unk_file: st.info("⬆️ Upload the unknown sample image.") else: unk_img = Image.open(unk_file) unk_res = analyze_image(unk_img, g0, region_mode, green_thresh) unk_box = unk_res.pop("_box") unk_gb_val = unk_res["G_B_ratio"] unk_gg0_val = unk_res["G_over_G0"] # Predict concentration pred_from_gb = predict_concentration(unk_gb_val, gb["m"], gb["b"]) pred_from_gg0 = predict_concentration(unk_gg0_val, gg0["m"], gg0["b"]) # ── Result cards ────────────────────────────────────────────────── st.subheader("Prediction result") rc1, rc2, rc3, rc4 = st.columns(4) with rc1: st.metric("Measured G/B", f"{unk_gb_val:.4f}") with rc2: st.metric("Predicted (G/B model)", f"{pred_from_gb} ppm" if pred_from_gb else "—") with rc3: st.metric("Measured G/G₀", f"{unk_gg0_val:.4f}") with rc4: st.metric("Predicted (G/G₀ model)", f"{pred_from_gg0} ppm" if pred_from_gg0 else "—") st.divider() # ── Image + crop ────────────────────────────────────────────────── img_col, chart_col = st.columns([1.4, 2]) with img_col: disp = draw_box(unk_img.convert("RGB"), unk_box) st.image(resize_display(disp, 440), caption=f"Unknown: {unk_file.name}", use_container_width=True) x1,y1,x2,y2 = unk_box crop_img = unk_img.convert("RGB").crop((x1,y1,x2,y2)) scale = min(200/max(crop_img.width,1), 200/max(crop_img.height,1), 1.0) if scale < 1: crop_img = crop_img.resize( (int(crop_img.width*scale), int(crop_img.height*scale)), Image.LANCZOS) st.image(crop_img, caption=f"ROI | G/B={unk_gb_val} G/G₀={unk_gg0_val}", width=200) st.markdown("**Colour swatch**") st.image(color_swatch(unk_res["HEX"], 60), width=60) st.code(unk_res["HEX"], language=None) # ── Calibration plot with unknown overlaid ──────────────────────── with chart_col: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt fit_df = cdf[cdf["Concentration_ppm"] > 0].copy() cal_concs = fit_df["Concentration_ppm"].values xmax = max(max(cal_concs)*1.15, (pred_from_gb or 0)*1.15, (pred_from_gg0 or 0)*1.15, 50) xfit = np.linspace(0, xmax, 300) fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4.2), facecolor='#0A1628') for ax, vals_col, model, color, label_y, pred_val, title in [ (ax1, "G_B_ratio", gb, '#BA7517', 'G/B ratio', pred_from_gb, 'G/B'), (ax2, "G_over_G0", gg0, '#1D9E75', 'G / G₀', pred_from_gg0, 'G/G₀'), ]: ax.set_facecolor('#0A1628') cal_y = fit_df[vals_col].values if model["m"]: yfit = model["m"]*xfit + model["b"] ax.plot(xfit, yfit, color=color, linestyle='--', lw=1.8, zorder=1) ax.scatter(cal_concs, cal_y, color=color, s=80, zorder=4, label='Calibration points') # Unknown point unk_y_val = unk_gb_val if vals_col == "G_B_ratio" else unk_gg0_val if pred_val is not None and model["m"]: # drop lines ax.plot([0, pred_val], [unk_y_val, unk_y_val], color='#4DA6FF', linestyle=':', lw=1.5, zorder=2) ax.plot([pred_val, pred_val], [min(cal_y)*0.98, unk_y_val], color='#4DA6FF', linestyle=':', lw=1.5, zorder=2) ax.scatter([pred_val], [unk_y_val], color='#4DA6FF', s=140, marker='*', zorder=5, label=f'Unknown → {pred_val} ppm') ax.annotate(f" {pred_val} ppm", (pred_val, min(cal_y)*0.98), fontsize=8, color='#4DA6FF') ax.set_xlabel("Polystyrene concentration (ppm)", color='#7EC8C8', fontsize=9) ax.set_ylabel(label_y, color='#7EC8C8', fontsize=9) ax.set_title(f"{title} calibration curve", color='white', fontsize=10) ax.tick_params(colors='#7EC8C8', labelsize=8) for sp in ax.spines.values(): sp.set_edgecolor('#1D4060') ax.grid(color='#1D3050', lw=0.5, alpha=0.6) ax.legend(fontsize=7.5, framealpha=0.2, facecolor='#152840', edgecolor='#1D4060', labelcolor='#B0CCE0') plt.tight_layout(pad=0.5) st.pyplot(fig, use_container_width=True) plt.close(fig) # ── Step-by-step calculation ─────────────────────────────────────── st.divider() st.subheader("Step-by-step calculation") calc1, calc2 = st.columns(2) with calc1: st.markdown("**G/B model**") if gb["m"]: st.code( f"Calibration: G/B = {gb['m']} × C + {gb['b']}\n" f"Invert: C = (G/B − {gb['b']}) / {gb['m']}\n\n" f"Measured G/B = {unk_gb_val}\n" f"C = ({unk_gb_val} − {gb['b']}) / {gb['m']}\n" f"C = {round(unk_gb_val - gb['b'], 6)} / {gb['m']}\n" f"C = {pred_from_gb} ppm", language=None, ) with calc2: st.markdown("**G/G₀ model**") st.code( f"Calibration: G/G₀ = {gg0['m']} × C + {gg0['b']}\n" f"Invert: C = (G/G₀ − {gg0['b']}) / {gg0['m']}\n\n" f"Measured G/G₀ = {unk_gg0_val}\n" f"C = ({unk_gg0_val} − {gg0['b']}) / {gg0['m']}\n" f"C = {round(unk_gg0_val - gg0['b'], 6)} / {gg0['m']}\n" f"C = {pred_from_gg0} ppm", language=None, ) # ══════════════════════════════════════════════════════════════════════════════ # TAB 3 — DATA & EXPORT # ══════════════════════════════════════════════════════════════════════════════ with tab3: st.header("Data & Export") if not st.session_state.calibration_done or st.session_state.calib_df is None: st.info("Run a calibration first (Tab 1) to see data here.") else: cal_df = st.session_state.calib_df gb = st.session_state.calib_gb gg0 = st.session_state.calib_gog0 # ── Summary equations ────────────────────────────────────────────── st.subheader("Calibration equations") eq1, eq2 = st.columns(2) with eq1: st.markdown("**G/B model**") if gb["m"]: st.latex( rf"\frac{{G}}{{B}} = {gb['m']} \times C + {gb['b']}" ) st.latex( rf"C = \frac{{G/B - {gb['b']}}}{{{gb['m']}}}" ) st.markdown(f"R² = **{gb['r2']}** | p = **{gb['p']}**") with eq2: st.markdown("**G/G₀ model**") st.latex( rf"\frac{{G}}{{G_0}} = {gg0['m']} \times C + {gg0['b']}" ) st.latex( rf"C = \frac{{G/G_0 - {gg0['b']}}}{{{gg0['m']}}}" ) st.markdown(f"R² = **{gg0['r2']}** | p = **{gg0['p']}**") st.divider() # ── Full data table ──────────────────────────────────────────────── st.subheader("Full calibration dataset") export_cols = ["Filename","Concentration_ppm","G_mean","G0", "G_B_ratio","G_over_G0","delta_G_G0","Quench_pct", "G_median","G_std","HEX","Dominant","Brightness","Crop_used","Pixels"] export_df = cal_df[[c for c in export_cols if c in cal_df.columns]].copy() st.dataframe(export_df, use_container_width=True, height=min(500, 60 + 35*len(export_df))) # ── Residuals table ──────────────────────────────────────────────── st.divider() st.subheader("Residuals table") fit_df = cal_df[cal_df["Concentration_ppm"] > 0].copy() if gb["m"]: fit_df["GB_predicted"] = gb["m"] * fit_df["Concentration_ppm"] + gb["b"] fit_df["GB_residual"] = fit_df["G_B_ratio"] - fit_df["GB_predicted"] fit_df["GG0_predicted"] = gg0["m"] * fit_df["Concentration_ppm"] + gg0["b"] fit_df["GG0_residual"] = fit_df["G_over_G0"] - fit_df["GG0_predicted"] res_cols = ["Filename","Concentration_ppm", "G_B_ratio","GB_predicted","GB_residual", "G_over_G0","GG0_predicted","GG0_residual"] st.dataframe( fit_df[[c for c in res_cols if c in fit_df.columns]].round(4), use_container_width=True, ) # ── Downloads ───────────────────────────────────────────────────── st.divider() d1, d2 = st.columns(2) with d1: st.download_button( "⬇️ Download calibration CSV", data=df_to_csv(export_df), file_name="calibration_data.csv", mime="text/csv", use_container_width=True, ) with d2: # Summary CSV summary = pd.DataFrame([{ "Model": "G/B", "Slope_m": gb["m"], "Intercept_b":gb["b"], "R2": gb["r2"], "p_value": gb["p"], "Equation": f"G/B = {gb['m']}*C + {gb['b']}", "Invert": f"C = (G/B - {gb['b']}) / {gb['m']}", "G0": st.session_state.g0_used, }, { "Model": "G/G0", "Slope_m": gg0["m"], "Intercept_b":gg0["b"], "R2": gg0["r2"], "p_value": gg0["p"], "Equation": f"G/G0 = {gg0['m']}*C + {gg0['b']}", "Invert": f"C = (G/G0 - {gg0['b']}) / {gg0['m']}", "G0": st.session_state.g0_used, }]) st.download_button( "⬇️ Download equations summary", data=df_to_csv(summary), file_name="calibration_equations.csv", mime="text/csv", use_container_width=True, ) st.divider() st.caption( "**References:** " "[1] Stern & Volmer, *Physik. Z.*, 1919, 20, 183 — G/G₀ quenching formula | " "[2] *arXiv:2603.27118*, Eq. 2 — G/B ratiometric formula | " "[3] Han et al., *Molecules* 2024, DOI: 10.3390/molecules29071658" )