import json from fastapi import APIRouter, HTTPException from app.config import BACKGROUND_SNAPSHOT_PATH, BACKGROUND_PATH, energy_axis, NUM_CHANNELS, ENERGY_OFFSET, ENERGY_SLOPE from app.theoretical_bg import generate_continuum_only from app.noise import extract_continuum import numpy as np router = APIRouter() def _load_snapshot(): """Load the live snapshot file, or raise 404.""" if not BACKGROUND_SNAPSHOT_PATH.exists(): raise HTTPException(status_code=404, detail="Background capture not available yet") try: with open(BACKGROUND_SNAPSHOT_PATH) as f: return json.load(f) except (json.JSONDecodeError, OSError): raise HTTPException(status_code=500, detail="Background snapshot file corrupt") def _load_reference(): """Load the 24h reference background, or return None.""" if not BACKGROUND_PATH.exists(): return None try: bg_data = np.load(str(BACKGROUND_PATH), allow_pickle=True).item() return { "counts": [round(float(c), 1) for c in bg_data["counts"][:NUM_CHANNELS]], "live_time_s": round(float(bg_data["duration"]), 1), } except Exception: return None @router.get("") async def get_background_info(): """Background metadata: elapsed time, CPS, top peaks.""" snapshot = _load_snapshot() full_available = BACKGROUND_PATH.exists() return { "elapsed_hours": snapshot.get("elapsed_hours", 0), "live_time_s": snapshot.get("live_time_s", 0), "total_counts": snapshot.get("total_counts", 0), "cps": snapshot.get("cps", 0), "top_peaks": snapshot.get("top_peaks", []), "full_background_available": full_available, } @router.get("/spectrum") async def get_background_spectrum(): """Live background spectrum (from snapshot) with energy axis.""" snapshot = _load_snapshot() live_time = snapshot.get("live_time_s", 0) return { "channels": list(range(NUM_CHANNELS)), "energy_kev": energy_axis(), "counts": snapshot.get("spectrum", [0] * 1024)[:NUM_CHANNELS], "live_time_s": live_time, "cps": snapshot.get("cps", 0), "top_peaks": snapshot.get("top_peaks", []), "reference_available": BACKGROUND_PATH.exists(), } @router.get("/reference") async def get_background_reference(): """24h reference background spectrum for overlay comparison.""" ref = _load_reference() if ref is None: raise HTTPException(status_code=404, detail="No 24h reference background available") return { "channels": list(range(NUM_CHANNELS)), "energy_kev": energy_axis(), "counts": ref["counts"], "live_time_s": ref["live_time_s"], } @router.get("/continuum") async def get_continuum(cps: float = 6.0, live_time_s: float = 3600.0): """CsI(Tl) detector response continuum only (no photopeaks, no noise).""" return generate_continuum_only(cps=cps, live_time_s=live_time_s) @router.get("/fit") async def fit_background(): """Fit the parametric CsI(Tl) detector response model to measured background data. Returns the fitted curve, parameters, and quality metrics. """ from app.bg_calibration import calibrate_background, build_calibrated_continuum # Load measured data measured_counts = None live_time = 0 if BACKGROUND_PATH.exists(): try: bg_data = np.load(str(BACKGROUND_PATH), allow_pickle=True).item() measured_counts = bg_data["counts"].astype(np.float64)[:NUM_CHANNELS] live_time = float(bg_data["duration"]) except Exception: pass if measured_counts is None and BACKGROUND_SNAPSHOT_PATH.exists(): try: with open(BACKGROUND_SNAPSHOT_PATH) as f: snapshot = json.load(f) measured_counts = np.array(snapshot.get("spectrum", [])[:NUM_CHANNELS], dtype=np.float64) live_time = float(snapshot.get("live_time_s", 0)) except Exception: pass if measured_counts is None or live_time < 600: raise HTTPException(status_code=404, detail="No measured background available for fitting") channels = np.arange(NUM_CHANNELS, dtype=np.float64) e_axis = ENERGY_OFFSET + ENERGY_SLOPE * channels # Run calibration measured_cps = measured_counts / live_time result = calibrate_background(measured_cps, e_axis) if "error" in result: raise HTTPException(status_code=500, detail=f"Fitting failed: {result['error']}") # Build fitted curve at same scale as measured fitted_counts = build_calibrated_continuum(e_axis, measured_counts.sum(), result) return { "energy_kev": [round(float(E), 2) for E in e_axis], "measured_counts": [round(float(c), 1) for c in measured_counts], "fitted_counts": [round(float(c), 1) for c in fitted_counts], "method": result.get("method", "spline"), "r_squared": result["r_squared"], "residuals_rms": result["residuals_rms"], "live_time_s": round(live_time, 1), } @router.get("/noise") async def get_background_noise(): """Detector's intrinsic continuum curve (isotope peaks subtracted). Returns the smooth detector response shape without any isotope photopeak signatures. Works with any detector type. """ counts = None if BACKGROUND_PATH.exists(): try: bg_data = np.load(str(BACKGROUND_PATH), allow_pickle=True).item() counts = bg_data["counts"].astype(np.float64)[:NUM_CHANNELS] except Exception: pass if counts is None and BACKGROUND_SNAPSHOT_PATH.exists(): try: with open(BACKGROUND_SNAPSHOT_PATH) as f: snapshot = json.load(f) counts = np.array(snapshot.get("spectrum", [])[:NUM_CHANNELS], dtype=np.float64) except Exception: pass if counts is None: raise HTTPException(status_code=404, detail="No background data available") channels = np.arange(NUM_CHANNELS, dtype=np.float64) e_axis = ENERGY_OFFSET + ENERGY_SLOPE * channels continuum = extract_continuum(counts, energy_axis=e_axis) return { "energy_kev": [round(float(E), 2) for E in e_axis], "counts": [round(float(c), 1) for c in continuum], }