pdf_plots / app.py
lopezels's picture
Upload 22 files
304a534 verified
Raw
History Blame Contribute Delete
24.2 kB
"""
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()
# This Space is called directly from the CTEQ-TEA website's client-side JS
# (a different origin), which the browser will block without CORS headers.
# The API is read-mostly (it only ever writes to one known dataset repo
# using the server-side token below), so allowing any origin is acceptable;
# tighten allow_origins to the site's domain if that changes.
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)
# See services/pdf_utils.py::LHAPDF_LOCK — pdf.xfxQ()/pdf_set.uncertainty()
# below are native lhapdf calls and are not safe to run concurrently
# with any other request's lhapdf calls.
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)
# See services/pdf_utils.py::LHAPDF_LOCK.
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)
# See services/pdf_utils.py::LHAPDF_LOCK.
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)
# See services/pdf_utils.py::LHAPDF_LOCK.
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))