""" Plotly figure builders using the "standard" PDF-uncertainty style the user prefers: a central curve + uncertainty band computed via LHAPDF's own `PDFSet.uncertainty()`, and a Hessian-style correlation contour between two flavors. IMPORTANT: `PDFSet.uncertainty()` is only mathematically valid for error types "hessian", "symmhessian", and "replicas". Every function here assumes the caller already validated that with `services.pdf_utils.load_validated_pdf_set` — these functions do not re-check it themselves, to keep the validation in exactly one place. Nothing in this module talks to Hugging Face or FastAPI directly; each function takes an already-loaded `pdf_set` and returns a `plotly.graph_objects.Figure`. """ import logging import time from typing import Optional import numpy as np import plotly.graph_objects as go from services.colors import get_main_plot_label, get_parton_color, get_parton_name, get_parton_symbol logger = logging.getLogger(__name__) def build_standard_plot( pdf_set, pdfs, grid_name: str, q_scale: float, parton_id: int, plot_color: Optional[str] = None, x_vals=None, custom_title: Optional[str] = None, ) -> go.Figure: """ Central value + uncertainty band for x*f(x, Q) of a single flavor. `pdfs` is `pdf_set.mkPDFs()`, passed in already-built (see services.pdf_utils.get_pdf_members) so repeat requests for the same PDF set don't reload every member's grid file from disk each time. """ plot_color = plot_color or get_parton_color(parton_id) if x_vals is None: x_vals = np.logspace(-5, -0.01, 150) logger.info( "build_standard_plot: grid=%s parton_id=%s q_scale=%s n_points=%d members=%d", grid_name, parton_id, q_scale, len(x_vals), len(pdfs), ) t0 = time.monotonic() central_values, upper_band, lower_band = [], [], [] for x in x_vals: vals = [pdf.xfxQ(parton_id, x, q_scale) for pdf in pdfs] uncertainty = pdf_set.uncertainty(vals) central = uncertainty.central central_values.append(round(central, 6)) upper_band.append(round(central + uncertainty.errplus, 6)) lower_band.append(round(central - uncertainty.errminus, 6)) logger.info("build_standard_plot: evaluated %d points in %.2fs", len(x_vals), time.monotonic() - t0) fig = go.Figure() x_vals_rounded = np.round(x_vals, 6) x_band = np.concatenate([x_vals_rounded, x_vals_rounded[::-1]]) y_band = np.concatenate([upper_band, lower_band[::-1]]) fig.add_trace(go.Scatter( x=x_band, y=y_band, fill="toself", fillcolor=plot_color, opacity=0.3, line=dict(color="rgba(255,255,255,0)"), name="Uncertainty", hoverinfo="skip", )) hover_custom_data = np.stack((upper_band, lower_band), axis=-1) fig.add_trace(go.Scatter( x=x_vals, y=central_values, mode="lines", line=dict(color=plot_color, width=2), name=f"{grid_name} Central
Q={q_scale} GeV", customdata=hover_custom_data, hovertemplate=( "x: %{x:.4e}
" "Central: %{y:.4f}
" "Max (Upper): %{customdata[0]:.4f}
" "Min (Lower): %{customdata[1]:.4f}" "" ), )) default_title = " " #f"Parton Distribution: {get_parton_name(parton_id)} at Q = {q_scale} GeV" fig.update_layout( title=custom_title if custom_title else default_title, xaxis_title="$x$", yaxis_title=f"$x {get_parton_symbol(parton_id)}(x, Q)$", template="plotly_white", paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", autosize=True, legend=dict(x=0.02, y=0.98, xanchor="left", yanchor="top", bgcolor="rgba(255,255,255,0.8)"), margin=dict(l=60, r=40, t=60, b=60), ) fig.update_xaxes( type="log", exponentformat="power", showline=True, linewidth=1.5, linecolor="black", mirror="allticks", ticks="inside", tickwidth=1.5, tickcolor="black", showgrid=False, ) y_upper_limit = max(upper_band) * 1.30 y_lower_limit = min(lower_band) * (1 - 0.20) fig.update_yaxes( range=[y_lower_limit, y_upper_limit], showline=True, zeroline=False, linewidth=1.5, linecolor="black", mirror="allticks", ticks="inside", tickwidth=1.5, tickcolor="black", showgrid=False, ) return fig def build_standard_ratio_plot( pdf_set, pdfs, grid_name: str, q_scale: float, parton_id: int, plot_color: Optional[str] = None, x_vals=None, custom_title: Optional[str] = None, ) -> go.Figure: """ Same as build_standard_plot, but normalized to the central value at each x (i.e. central line sits at 1.0, band shows relative uncertainty). `pdfs` is `pdf_set.mkPDFs()`, passed in already-built — see services.pdf_utils.get_pdf_members. """ plot_color = plot_color or get_parton_color(parton_id) if x_vals is None: x_vals = np.logspace(-5, -0.01, 150) logger.info( "build_standard_ratio_plot: grid=%s parton_id=%s q_scale=%s n_points=%d members=%d", grid_name, parton_id, q_scale, len(x_vals), len(pdfs), ) t0 = time.monotonic() central_values, upper_band, lower_band = [], [], [] for x in x_vals: vals = [pdf.xfxQ(parton_id, x, q_scale) for pdf in pdfs] uncertainty = pdf_set.uncertainty(vals) central1 = uncertainty.central if abs(central1) < 1e-10: # Treat a (numerically) zero central value as a flat, uninformative ratio. central_values.append(1.0) upper_band.append(1.0) lower_band.append(1.0) else: central_values.append(round(central1 / central1, 6)) upper_band.append(round(1.0 + uncertainty.errplus / central1, 6)) lower_band.append(round(1.0 - uncertainty.errminus / central1, 6)) logger.info("build_standard_ratio_plot: evaluated %d points in %.2fs", len(x_vals), time.monotonic() - t0) fig = go.Figure() x_vals_rounded = np.round(x_vals, 6) x_band = np.concatenate([x_vals_rounded, x_vals_rounded[::-1]]) y_band = np.concatenate([upper_band, lower_band[::-1]]) fig.add_trace(go.Scatter( x=x_band, y=y_band, fill="toself", fillcolor=plot_color, opacity=0.3, line=dict(color="rgba(255,255,255,0)"), name="Uncertainty", hoverinfo="skip", )) hover_custom_data = np.stack((upper_band, lower_band), axis=-1) fig.add_trace(go.Scatter( x=x_vals, y=central_values, mode="lines", line=dict(color=plot_color, width=2), name=f"{grid_name} Central
Q={q_scale} GeV", customdata=hover_custom_data, hovertemplate=( "x: %{x:.4e}
" "Central: %{y:.4f}
" "Max (Upper): %{customdata[0]:.4f}
" "Min (Lower): %{customdata[1]:.4f}" "" ), )) default_title = " " #f"Parton Distribution: {get_parton_name(parton_id)} at Q = {q_scale} GeV" fig.update_layout( title=custom_title if custom_title else default_title, xaxis_title="$x$", yaxis_title=f"$x {get_parton_symbol(parton_id)}(x, Q)/central$", template="plotly_white", paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", autosize=True, legend=dict(x=0.02, y=0.98, xanchor="left", yanchor="top", bgcolor="rgba(255,255,255,0.8)"), margin=dict(l=60, r=40, t=60, b=60), ) fig.update_xaxes( type="log", exponentformat="power", showline=True, linewidth=1.5, linecolor="black", mirror="allticks", ticks="inside", tickwidth=1.5, tickcolor="black", showgrid=False, ) y_upper_limit = max(upper_band) * 1.30 ymin_data = min(lower_band) y_lower_limit = ymin_data * (1 - 0.30) if ymin_data >= 0 else ymin_data * (1 + 0.30) fig.update_yaxes( range=[y_lower_limit, y_upper_limit], showline=True, zeroline=False, linewidth=1.5, linecolor="black", mirror="allticks", ticks="inside", tickwidth=1.5, tickcolor="black", showgrid=False, ) return fig def build_correlation_plot( pdfs, q_scale: float, parton_id_1: int, parton_id_2: int, x_vals=None, color_correlated: Optional[str] = None, color_anti_correlated: str = "black", ) -> go.Figure: """ Hessian-style correlation contour between two flavors across an x-grid, at a fixed Q scale. `pdfs` is `pdf_set.mkPDFs()`, passed in already-built — see services.pdf_utils.get_pdf_members. """ color_correlated = color_correlated or get_parton_color(parton_id_1) num_members = len(pdfs) if x_vals is None: x_vals = np.logspace(-4, -0.01, 35) x_vals_rounded = np.round(x_vals, 6) num_x = len(x_vals_rounded) logger.info( "build_correlation_plot: parton_id_1=%s parton_id_2=%s q_scale=%s n_points=%d members=%d", parton_id_1, parton_id_2, q_scale, num_x, num_members, ) t0 = time.monotonic() matrix_p1 = np.zeros((num_members, num_x)) matrix_p2 = np.zeros((num_members, num_x)) for m_idx, pdf in enumerate(pdfs): for x_idx, x in enumerate(x_vals_rounded): matrix_p1[m_idx, x_idx] = pdf.xfxQ(parton_id_1, x, q_scale) matrix_p2[m_idx, x_idx] = pdf.xfxQ(parton_id_2, x, q_scale) logger.info("build_correlation_plot: evaluated %d members x %d points in %.2fs", num_members, num_x, time.monotonic() - t0) correlation_matrix = np.zeros((num_x, num_x)) p1_central = matrix_p1[0, :] p2_central = matrix_p2[0, :] p1_dev = matrix_p1[1:, :] - p1_central p2_dev = matrix_p2[1:, :] - p2_central for i in range(num_x): for j in range(num_x): delta_x = p1_dev[:, i] delta_y = p2_dev[:, j] numerator = np.sum(delta_x * delta_y) sum_x_sq = np.sum(delta_x ** 2) sum_y_sq = np.sum(delta_y ** 2) denominator = np.sqrt(sum_x_sq * sum_y_sq) if denominator > 1e-10: corr_value = np.clip(numerator / denominator, -1.0, 1.0) correlation_matrix[j, i] = round(corr_value, 4) else: correlation_matrix[j, i] = 0.0 name_1 = get_parton_symbol(parton_id_1) name_2 = get_parton_symbol(parton_id_2) fig = go.Figure(data=go.Contour( z=correlation_matrix, x=x_vals_rounded, y=x_vals_rounded, colorscale=[ [0.0, color_anti_correlated], [0.5, "rgb(245, 245, 245)"], [1.0, color_correlated], ], zmin=-1.0, zmax=1.0, line=dict(width=1, color="rgba(0, 0, 0, 0.2)"), contours=dict(coloring="heatmap", showlines=False), ncontours=15, colorbar=dict( title=dict(text="Corr. Coefficient", side="right"), thickness=15, len=1.0, ), hovertemplate=( f"x₁ ({name_1}): %{{x:.4e}}
" f"x₂ ({name_2}): %{{y:.4e}}
" "Correlation: %{z:.4f}" ), )) fig.update_layout( title=" ", template="plotly_white", paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", margin=dict(l=70, r=20, t=70, b=70), autosize=True, ) fig.update_xaxes( title_text=f"$x_1 \\text{{ in }} {name_1}(x_1, Q)$", type="log", exponentformat="power", showline=True, linewidth=1.5, linecolor="black", mirror="allticks", ticks="inside", tickwidth=1.5, tickcolor="black", showgrid=False, ) fig.update_yaxes( title_text=f"$x_2 \\text{{ in }} {name_2}(x_2, Q)$", type="log", exponentformat="power", showline=True, linewidth=1.5, linecolor="black", mirror="allticks", ticks="inside", tickwidth=1.5, tickcolor="black", showgrid=False, ) return fig def build_main_plot( pdf_set, pdfs, grid_name: str, q_scale: float, parton_ids: list, x_vals=None, custom_title: Optional[str] = None, ) -> go.Figure: """ The classic multi-flavor "overview" plot (CT18 Fig. 2 style, see arXiv:1912.10053): every flavor in parton_ids overlaid on one figure as central value + 90% C.L. uncertainty band, log-x / linear-y. The gluon (parton_id 21) is always scaled down by a fixed factor of 5 so it fits on the same y-scale as the quark flavors, following that same convention -- its legend entry is labelled "g/5" accordingly (see services.colors.get_main_plot_label). `pdfs` is `pdf_set.mkPDFs()`, passed in already-built (see services.pdf_utils.get_pdf_members). """ if x_vals is None: x_vals = np.logspace(-6, np.log10(0.9), 500) logger.info( "build_main_plot: grid=%s parton_ids=%s q_scale=%s n_points=%d members=%d", grid_name, parton_ids, q_scale, len(x_vals), len(pdfs), ) t0 = time.monotonic() fig = go.Figure() x_vals_rounded = np.round(x_vals, 6) x_band = np.concatenate([x_vals_rounded, x_vals_rounded[::-1]]) all_upper_bands = [] for parton_id in parton_ids: plot_color = get_parton_color(parton_id) label = get_main_plot_label(parton_id) scale = 1.0 / 5.0 if parton_id == 21 else 1.0 central_values, upper_band, lower_band = [], [], [] for x in x_vals: vals = [pdf.xfxQ(parton_id, x, q_scale) for pdf in pdfs] uncertainty = pdf_set.uncertainty(vals) central = uncertainty.central * scale central_values.append(round(central, 6)) upper_band.append(round(central + uncertainty.errplus * scale, 6)) lower_band.append(round(central - uncertainty.errminus * scale, 6)) all_upper_bands.extend(upper_band) y_band = np.concatenate([upper_band, lower_band[::-1]]) fig.add_trace(go.Scatter( x=x_band, y=y_band, fill="toself", fillcolor=plot_color, opacity=0.3, line=dict(color="rgba(255,255,255,0)"), name=label + " (unc.)", legendgroup=label, showlegend=False, hoverinfo="skip", )) hover_custom_data = np.stack((upper_band, lower_band), axis=-1) fig.add_trace(go.Scatter( x=x_vals, y=central_values, mode="lines", line=dict(color=plot_color, width=2), name=f"${label}$", legendgroup=label, customdata=hover_custom_data, hovertemplate=( f"Flavor: {label}
" "x: %{x:.4e}
" "Central: %{y:.4f}
" "Max (Upper): %{customdata[0]:.4f}
" "Min (Lower): %{customdata[1]:.4f}" "" ), )) logger.info( "build_main_plot: evaluated %d flavors x %d points in %.2fs", len(parton_ids), len(x_vals), time.monotonic() - t0, ) default_title = " " fig.update_layout( title=custom_title if custom_title else default_title, xaxis_title="$x$", yaxis_title="$x f(x, Q)$", template="plotly_white", paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", autosize=True, legend=dict(x=0.98, y=0.98, xanchor="right", yanchor="top", bgcolor="rgba(255,255,255,0.8)"), margin=dict(l=60, r=40, t=60, b=60), ) fig.update_xaxes( type="log", exponentformat="power", showline=True, linewidth=1.5, linecolor="black", mirror="allticks", ticks="inside", tickwidth=1.5, tickcolor="black", showgrid=False, ) y_upper_limit = max(all_upper_bands) * 1.15 if all_upper_bands else 1.0 fig.update_yaxes( range=[0, y_upper_limit], showline=True, zeroline=False, linewidth=1.5, linecolor="black", mirror="allticks", ticks="inside", tickwidth=1.5, tickcolor="black", showgrid=False, ) return fig