| """ |
| FastAPI entrypoint for the PDF-plots Hugging Face Space. |
| |
| This file only wires HTTP endpoints to the logic living in `services/`. |
| Update the plotting/storage/color logic there without ever touching |
| this file (and vice versa). |
| |
| Every endpoint follows the same pattern: |
| 1. Hash the request parameters -> deterministic plot_id. |
| 2. If use_cache and a matching plot already exists -> return it. |
| 3. Otherwise: load + validate the PDF set's error type, build the |
| figure, upload it under its conventional filename, update the |
| grid's index.json, and return the new entry. |
| |
| Only PDF sets with ErrorType in {"hessian", "symmhessian", "replicas"} |
| are supported right now (see services/error_types.py) — anything else |
| returns HTTP 400 with an explanation, rather than silently computing |
| wrong uncertainties. |
| """ |
|
|
| import logging |
| import os |
| import threading |
| import time |
|
|
| import numpy as np |
| from fastapi import FastAPI, HTTPException |
| from fastapi.middleware.cors import CORSMiddleware |
| from fastapi.responses import FileResponse |
|
|
| from services.config import ( |
| COMPUTE_SEMAPHORE_LIMIT, |
| FLUSH_INTERVAL_SECONDS, |
| TMP_DIR, |
| correlation_plot_filename, |
| main_plot_filename, |
| ratio_plot_filename, |
| standard_plot_filename, |
| ) |
| from services.error_types import UnsupportedErrorTypeError |
| from services.models import ( |
| CorrelationPlotRequest, |
| MainPlotRequest, |
| PlotResponse, |
| RatioPlotRequest, |
| StandardPlotRequest, |
| ) |
| from services.pdf_utils import COMPUTE_SEMAPHORE, LHAPDF_LOCK, get_pdf_members, invalidate_cache, load_validated_pdf_set |
| from services.plotting import ( |
| build_correlation_plot, |
| build_main_plot, |
| build_standard_plot, |
| build_standard_ratio_plot, |
| ) |
| from services.storage import ( |
| find_cached_plot, |
| find_pending_plot, |
| flush_pending, |
| make_plot_id, |
| pending_status, |
| queue_plot, |
| ) |
|
|
| logger = logging.getLogger(__name__) |
|
|
| app = FastAPI(title="LHAPDF Plotly Plots Space") |
|
|
|
|
| def _background_flush_loop(): |
| """ |
| Runs for the lifetime of the container: every FLUSH_INTERVAL_SECONDS, |
| batches every plot queued since the last flush into one HF commit per |
| dataset_repo (see services/storage.py::flush_pending). Errors are |
| swallowed and retried next cycle rather than crashing the Space — |
| a missed flush just means plots stay queued (and locally servable) |
| a bit longer. |
| """ |
| while True: |
| time.sleep(FLUSH_INTERVAL_SECONDS) |
| token = os.environ.get("HF_TOKEN") |
| if not token: |
| logger.warning("[flush loop] HF_TOKEN not set, skipping this cycle") |
| continue |
| try: |
| logger.info("[flush loop] running scheduled flush...") |
| result = flush_pending(token) |
| logger.info("[flush loop] scheduled flush result: %s", result) |
| except Exception as e: |
| logger.exception("[flush loop] flush_pending failed: %s", e) |
|
|
|
|
| @app.on_event("startup") |
| def _start_background_flush(): |
| threading.Thread(target=_background_flush_loop, daemon=True).start() |
|
|
| |
| |
| |
| |
| |
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
|
|
| @app.get("/health") |
| def health(): |
| logger.info("GET /health (likely a wake-up ping from the website)") |
| return {"status": "ok"} |
|
|
|
|
| @app.get("/plots/{folder}/{filename}") |
| def get_plot_file(folder: str, filename: str): |
| """ |
| Serves a generated plot's JSON straight off local disk. This is the |
| URL returned as `plotly_json_url` for any plot queued but not yet |
| committed to the HF dataset (see services/storage.py::queue_plot), so |
| the requesting page can render it immediately without waiting for the |
| next batch flush. Files stay here even after a flush, so this keeps |
| working afterwards too — it only goes stale if the container restarts |
| (ephemeral disk), at which point a fresh request just regenerates it. |
| """ |
| path = os.path.join(TMP_DIR, folder, filename) |
| if not os.path.isfile(path): |
| raise HTTPException( |
| status_code=404, |
| detail="Plot not found locally — it may not have been generated yet, or this Space container restarted since it was.", |
| ) |
| return FileResponse(path, media_type="application/json") |
|
|
|
|
| @app.get("/pending-status") |
| def get_pending_status(): |
| """Number of plots queued and waiting for the next batched commit, per dataset_repo|grid_name.""" |
| return pending_status() |
|
|
|
|
| @app.post("/flush-cache") |
| def force_flush(): |
| """Manually trigger a batch commit now instead of waiting for the next scheduled flush.""" |
| token = _server_hf_token() |
| return flush_pending(token) |
|
|
|
|
| def _server_hf_token() -> str: |
| """ |
| The HF token with write access to dataset_repo, read from this Space's |
| own HF_TOKEN secret. Never accepted from the caller (see |
| services.models.BasePlotRequest) so a public webpage can call these |
| endpoints directly without ever handling a write-capable credential. |
| """ |
| token = os.environ.get("HF_TOKEN") |
| if not token: |
| raise HTTPException( |
| status_code=500, |
| detail="Server misconfigured: HF_TOKEN secret is not set on this Space.", |
| ) |
| return token |
|
|
|
|
| def _get_pdf_set_or_400(dataset_repo: str, grid_name: str, hf_token: str): |
| """Shared error-type gate for every endpoint below.""" |
| try: |
| return load_validated_pdf_set(dataset_repo, grid_name, hf_token) |
| except UnsupportedErrorTypeError as e: |
| logger.warning("grid=%s dataset_repo=%s rejected: %s", grid_name, dataset_repo, e) |
| raise HTTPException(status_code=400, detail=str(e)) |
| except Exception as e: |
| logger.exception("grid=%s dataset_repo=%s failed to load PDFSet: %s", grid_name, dataset_repo, e) |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| def _get_pdf_members_or_400(dataset_repo: str, grid_name: str, hf_token: str): |
| """ |
| Shared member-loading gate, mirroring _get_pdf_set_or_400. Returns |
| `pdf_set.mkPDFs()`, cached per (dataset_repo, grid_name) so requests |
| for the same PDF set (any Q/flavor/plot type) reuse the same loaded |
| members instead of re-parsing every member's grid file from disk. |
| """ |
| try: |
| return get_pdf_members(dataset_repo, grid_name, hf_token) |
| except UnsupportedErrorTypeError as e: |
| logger.warning("grid=%s dataset_repo=%s rejected: %s", grid_name, dataset_repo, e) |
| raise HTTPException(status_code=400, detail=str(e)) |
| except Exception as e: |
| logger.exception("grid=%s dataset_repo=%s failed to load PDF members: %s", grid_name, dataset_repo, e) |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @app.post("/compute-standard-plot", response_model=PlotResponse) |
| def compute_standard_plot(request: StandardPlotRequest): |
| hf_token = _server_hf_token() |
| parameters = { |
| "q_scale": request.q_scale, |
| "parton_id": request.parton_id, |
| "plot_color": request.plot_color, |
| "custom_title": request.custom_title, |
| "x_min": request.x_min, |
| "x_max": request.x_max, |
| "n_points": request.n_points, |
| } |
| plot_id = make_plot_id("standard_plot", request.grid_name, parameters) |
| logger.info("POST /compute-standard-plot: grid=%s dataset_repo=%s plot_id=%s params=%s", request.grid_name, request.dataset_repo, plot_id, parameters) |
| t0 = time.monotonic() |
|
|
| if request.use_cache: |
| pending = find_pending_plot(plot_id) |
| if pending: |
| logger.info("plot_id=%s served from pending cache in %.3fs", plot_id, time.monotonic() - t0) |
| return PlotResponse( |
| status="success", |
| cached=True, |
| message="Plot already generated this session, awaiting next batch upload.", |
| plot_id=plot_id, |
| plot_url=pending["plot_url"], |
| plotly_json_url=pending["plotly_json_url"], |
| ) |
| cached = find_cached_plot(request.dataset_repo, request.grid_name, plot_id, hf_token) |
| if cached: |
| logger.info("plot_id=%s served from committed HF cache in %.3fs", plot_id, time.monotonic() - t0) |
| return PlotResponse( |
| status="success", |
| cached=True, |
| message="Plot already existed, returning cached version.", |
| plot_id=plot_id, |
| plot_url=cached["plot_url"], |
| plotly_json_url=cached["plotly_json_url"], |
| ) |
|
|
| logger.info("plot_id=%s not cached, generating...", plot_id) |
| logger.info("plot_id=%s waiting for compute slot (max %d concurrent)...", plot_id, COMPUTE_SEMAPHORE_LIMIT) |
| with COMPUTE_SEMAPHORE: |
| logger.info("plot_id=%s acquired compute slot", plot_id) |
| pdf_set = _get_pdf_set_or_400(request.dataset_repo, request.grid_name, hf_token) |
| pdfs = _get_pdf_members_or_400(request.dataset_repo, request.grid_name, hf_token) |
|
|
| try: |
| x_vals = np.logspace(np.log10(request.x_min), np.log10(request.x_max), request.n_points) |
|
|
| |
| |
| |
| logger.info("plot_id=%s waiting for LHAPDF_LOCK...", plot_id) |
| with LHAPDF_LOCK: |
| logger.info("plot_id=%s acquired LHAPDF_LOCK, building figure...", plot_id) |
| fig = build_standard_plot( |
| pdf_set=pdf_set, |
| pdfs=pdfs, |
| grid_name=request.grid_name, |
| q_scale=request.q_scale, |
| parton_id=request.parton_id, |
| plot_color=request.plot_color, |
| x_vals=x_vals, |
| custom_title=request.custom_title, |
| ) |
| logger.info("plot_id=%s figure built, released LHAPDF_LOCK", plot_id) |
|
|
| json_filename = standard_plot_filename(request.grid_name, request.parton_id, request.q_scale) |
|
|
| entry = queue_plot( |
| dataset_repo=request.dataset_repo, |
| grid_name=request.grid_name, |
| plot_id=plot_id, |
| plot_type="standard_plot", |
| parameters=parameters, |
| fig=fig, |
| json_filename=json_filename, |
| ) |
| logger.info("plot_id=%s done in %.2fs total", plot_id, time.monotonic() - t0) |
|
|
| return PlotResponse( |
| status="success", |
| cached=False, |
| message="Plot generated; queued for the next batched upload to " + request.dataset_repo + ".", |
| plot_id=plot_id, |
| plot_url=entry["plot_url"], |
| plotly_json_url=entry["plotly_json_url"], |
| ) |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.exception("plot_id=%s compute-standard-plot failed: %s", plot_id, e) |
| invalidate_cache(request.dataset_repo, request.grid_name) |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @app.post("/compute-ratio-plot", response_model=PlotResponse) |
| def compute_ratio_plot(request: RatioPlotRequest): |
| hf_token = _server_hf_token() |
| parameters = { |
| "q_scale": request.q_scale, |
| "parton_id": request.parton_id, |
| "plot_color": request.plot_color, |
| "custom_title": request.custom_title, |
| "x_min": request.x_min, |
| "x_max": request.x_max, |
| "n_points": request.n_points, |
| } |
| plot_id = make_plot_id("ratio_plot", request.grid_name, parameters) |
| logger.info("POST /compute-ratio-plot: grid=%s dataset_repo=%s plot_id=%s params=%s", request.grid_name, request.dataset_repo, plot_id, parameters) |
| t0 = time.monotonic() |
|
|
| if request.use_cache: |
| pending = find_pending_plot(plot_id) |
| if pending: |
| logger.info("plot_id=%s served from pending cache in %.3fs", plot_id, time.monotonic() - t0) |
| return PlotResponse( |
| status="success", |
| cached=True, |
| message="Plot already generated this session, awaiting next batch upload.", |
| plot_id=plot_id, |
| plot_url=pending["plot_url"], |
| plotly_json_url=pending["plotly_json_url"], |
| ) |
| cached = find_cached_plot(request.dataset_repo, request.grid_name, plot_id, hf_token) |
| if cached: |
| logger.info("plot_id=%s served from committed HF cache in %.3fs", plot_id, time.monotonic() - t0) |
| return PlotResponse( |
| status="success", |
| cached=True, |
| message="Plot already existed, returning cached version.", |
| plot_id=plot_id, |
| plot_url=cached["plot_url"], |
| plotly_json_url=cached["plotly_json_url"], |
| ) |
|
|
| logger.info("plot_id=%s not cached, generating...", plot_id) |
| logger.info("plot_id=%s waiting for compute slot (max %d concurrent)...", plot_id, COMPUTE_SEMAPHORE_LIMIT) |
| with COMPUTE_SEMAPHORE: |
| logger.info("plot_id=%s acquired compute slot", plot_id) |
| pdf_set = _get_pdf_set_or_400(request.dataset_repo, request.grid_name, hf_token) |
| pdfs = _get_pdf_members_or_400(request.dataset_repo, request.grid_name, hf_token) |
|
|
| try: |
| x_vals = np.logspace(np.log10(request.x_min), np.log10(request.x_max), request.n_points) |
|
|
| |
| logger.info("plot_id=%s waiting for LHAPDF_LOCK...", plot_id) |
| with LHAPDF_LOCK: |
| logger.info("plot_id=%s acquired LHAPDF_LOCK, building figure...", plot_id) |
| fig = build_standard_ratio_plot( |
| pdf_set=pdf_set, |
| pdfs=pdfs, |
| grid_name=request.grid_name, |
| q_scale=request.q_scale, |
| parton_id=request.parton_id, |
| plot_color=request.plot_color, |
| x_vals=x_vals, |
| custom_title=request.custom_title, |
| ) |
| logger.info("plot_id=%s figure built, released LHAPDF_LOCK", plot_id) |
|
|
| json_filename = ratio_plot_filename(request.grid_name, request.parton_id, request.q_scale) |
|
|
| entry = queue_plot( |
| dataset_repo=request.dataset_repo, |
| grid_name=request.grid_name, |
| plot_id=plot_id, |
| plot_type="ratio_plot", |
| parameters=parameters, |
| fig=fig, |
| json_filename=json_filename, |
| ) |
| logger.info("plot_id=%s done in %.2fs total", plot_id, time.monotonic() - t0) |
|
|
| return PlotResponse( |
| status="success", |
| cached=False, |
| message="Plot generated; queued for the next batched upload to " + request.dataset_repo + ".", |
| plot_id=plot_id, |
| plot_url=entry["plot_url"], |
| plotly_json_url=entry["plotly_json_url"], |
| ) |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.exception("plot_id=%s compute-ratio-plot failed: %s", plot_id, e) |
| invalidate_cache(request.dataset_repo, request.grid_name) |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @app.post("/compute-correlation-plot", response_model=PlotResponse) |
| def compute_correlation_plot(request: CorrelationPlotRequest): |
| hf_token = _server_hf_token() |
| parameters = { |
| "q_scale": request.q_scale, |
| "parton_id_1": request.parton_id_1, |
| "parton_id_2": request.parton_id_2, |
| "color_correlated": request.color_correlated, |
| "color_anti_correlated": request.color_anti_correlated, |
| "x_min": request.x_min, |
| "x_max": request.x_max, |
| "n_points": request.n_points, |
| } |
| plot_id = make_plot_id("correlation_plot", request.grid_name, parameters) |
| logger.info("POST /compute-correlation-plot: grid=%s dataset_repo=%s plot_id=%s params=%s", request.grid_name, request.dataset_repo, plot_id, parameters) |
| t0 = time.monotonic() |
|
|
| if request.use_cache: |
| pending = find_pending_plot(plot_id) |
| if pending: |
| logger.info("plot_id=%s served from pending cache in %.3fs", plot_id, time.monotonic() - t0) |
| return PlotResponse( |
| status="success", |
| cached=True, |
| message="Plot already generated this session, awaiting next batch upload.", |
| plot_id=plot_id, |
| plot_url=pending["plot_url"], |
| plotly_json_url=pending["plotly_json_url"], |
| ) |
| cached = find_cached_plot(request.dataset_repo, request.grid_name, plot_id, hf_token) |
| if cached: |
| logger.info("plot_id=%s served from committed HF cache in %.3fs", plot_id, time.monotonic() - t0) |
| return PlotResponse( |
| status="success", |
| cached=True, |
| message="Plot already existed, returning cached version.", |
| plot_id=plot_id, |
| plot_url=cached["plot_url"], |
| plotly_json_url=cached["plotly_json_url"], |
| ) |
|
|
| logger.info("plot_id=%s not cached, generating...", plot_id) |
| logger.info("plot_id=%s waiting for compute slot (max %d concurrent)...", plot_id, COMPUTE_SEMAPHORE_LIMIT) |
| with COMPUTE_SEMAPHORE: |
| logger.info("plot_id=%s acquired compute slot", plot_id) |
| pdfs = _get_pdf_members_or_400(request.dataset_repo, request.grid_name, hf_token) |
|
|
| try: |
| x_vals = np.logspace(np.log10(request.x_min), np.log10(request.x_max), request.n_points) |
|
|
| |
| logger.info("plot_id=%s waiting for LHAPDF_LOCK...", plot_id) |
| with LHAPDF_LOCK: |
| logger.info("plot_id=%s acquired LHAPDF_LOCK, building figure...", plot_id) |
| fig = build_correlation_plot( |
| pdfs=pdfs, |
| q_scale=request.q_scale, |
| parton_id_1=request.parton_id_1, |
| parton_id_2=request.parton_id_2, |
| x_vals=x_vals, |
| color_correlated=request.color_correlated, |
| color_anti_correlated=request.color_anti_correlated, |
| ) |
| logger.info("plot_id=%s figure built, released LHAPDF_LOCK", plot_id) |
|
|
| json_filename = correlation_plot_filename( |
| request.grid_name, request.parton_id_1, request.parton_id_2, request.q_scale |
| ) |
|
|
| entry = queue_plot( |
| dataset_repo=request.dataset_repo, |
| grid_name=request.grid_name, |
| plot_id=plot_id, |
| plot_type="correlation_plot", |
| parameters=parameters, |
| fig=fig, |
| json_filename=json_filename, |
| ) |
| logger.info("plot_id=%s done in %.2fs total", plot_id, time.monotonic() - t0) |
|
|
| return PlotResponse( |
| status="success", |
| cached=False, |
| message="Plot generated; queued for the next batched upload to " + request.dataset_repo + ".", |
| plot_id=plot_id, |
| plot_url=entry["plot_url"], |
| plotly_json_url=entry["plotly_json_url"], |
| ) |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.exception("plot_id=%s compute-correlation-plot failed: %s", plot_id, e) |
| invalidate_cache(request.dataset_repo, request.grid_name) |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @app.post("/compute-main-plot", response_model=PlotResponse) |
| def compute_main_plot(request: MainPlotRequest): |
| hf_token = _server_hf_token() |
| parameters = { |
| "q_scale": request.q_scale, |
| "parton_ids": request.parton_ids, |
| "custom_title": request.custom_title, |
| "x_min": request.x_min, |
| "x_max": request.x_max, |
| "n_points": request.n_points, |
| } |
| plot_id = make_plot_id("main_plot", request.grid_name, parameters) |
| logger.info("POST /compute-main-plot: grid=%s dataset_repo=%s plot_id=%s params=%s", request.grid_name, request.dataset_repo, plot_id, parameters) |
| t0 = time.monotonic() |
|
|
| if request.use_cache: |
| pending = find_pending_plot(plot_id) |
| if pending: |
| logger.info("plot_id=%s served from pending cache in %.3fs", plot_id, time.monotonic() - t0) |
| return PlotResponse( |
| status="success", |
| cached=True, |
| message="Plot already generated this session, awaiting next batch upload.", |
| plot_id=plot_id, |
| plot_url=pending["plot_url"], |
| plotly_json_url=pending["plotly_json_url"], |
| ) |
| cached = find_cached_plot(request.dataset_repo, request.grid_name, plot_id, hf_token) |
| if cached: |
| logger.info("plot_id=%s served from committed HF cache in %.3fs", plot_id, time.monotonic() - t0) |
| return PlotResponse( |
| status="success", |
| cached=True, |
| message="Plot already existed, returning cached version.", |
| plot_id=plot_id, |
| plot_url=cached["plot_url"], |
| plotly_json_url=cached["plotly_json_url"], |
| ) |
|
|
| logger.info("plot_id=%s not cached, generating...", plot_id) |
| logger.info("plot_id=%s waiting for compute slot (max %d concurrent)...", plot_id, COMPUTE_SEMAPHORE_LIMIT) |
| with COMPUTE_SEMAPHORE: |
| logger.info("plot_id=%s acquired compute slot", plot_id) |
| pdf_set = _get_pdf_set_or_400(request.dataset_repo, request.grid_name, hf_token) |
| pdfs = _get_pdf_members_or_400(request.dataset_repo, request.grid_name, hf_token) |
|
|
| try: |
| x_vals = np.logspace(np.log10(request.x_min), np.log10(request.x_max), request.n_points) |
|
|
| |
| logger.info("plot_id=%s waiting for LHAPDF_LOCK...", plot_id) |
| with LHAPDF_LOCK: |
| logger.info("plot_id=%s acquired LHAPDF_LOCK, building figure...", plot_id) |
| fig = build_main_plot( |
| pdf_set=pdf_set, |
| pdfs=pdfs, |
| grid_name=request.grid_name, |
| q_scale=request.q_scale, |
| parton_ids=request.parton_ids, |
| x_vals=x_vals, |
| custom_title=request.custom_title, |
| ) |
| logger.info("plot_id=%s figure built, released LHAPDF_LOCK", plot_id) |
|
|
| json_filename = main_plot_filename(request.grid_name, request.parton_ids, request.q_scale) |
|
|
| entry = queue_plot( |
| dataset_repo=request.dataset_repo, |
| grid_name=request.grid_name, |
| plot_id=plot_id, |
| plot_type="main_plot", |
| parameters=parameters, |
| fig=fig, |
| json_filename=json_filename, |
| ) |
| logger.info("plot_id=%s done in %.2fs total", plot_id, time.monotonic() - t0) |
|
|
| return PlotResponse( |
| status="success", |
| cached=False, |
| message="Plot generated; queued for the next batched upload to " + request.dataset_repo + ".", |
| plot_id=plot_id, |
| plot_url=entry["plot_url"], |
| plotly_json_url=entry["plotly_json_url"], |
| ) |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.exception("plot_id=%s compute-main-plot failed: %s", plot_id, e) |
| invalidate_cache(request.dataset_repo, request.grid_name) |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|