diff --git a/README.md b/README.md index d4be9ce..38a79a7 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The published limits are included in human-readable yaml files in the data folde To use the code, check out the _Usage and examples_. For users who want to quickly explore the limits and make plots without needing to install anything, check out the new online interface: [eorlimits.streamlit.app](https://eorlimits.streamlit.app/)! -![Example EoR Limit plot](docs/source/_static/eor_limits.png) +![Example EoR Limit plot](docs/source/_static/test_lib_plot_vs_k_basic.png) ## Installation and dependencies @@ -32,25 +32,25 @@ To use the code, check out the _Usage and examples_. For users who want to quick ## Usage and examples -There are three main ways to use the code: through the online interface, as a library within a notebook or python script, or through the command line interface (CLI). +There are three main ways to use `eor-limits`: through the online graphical user interface (GUI), through the Python API in a notebook or script, or through the command line interface (CLI). -### Using the online interface +### Using the online GUI -We recommend using the online interface for users who just want to explore the limits and make plots without needing to install anything. The interface is built using [Streamlit](https://streamlit.io/) and provides an easy-to-use interface for selecting which limits to include in the plot, filtering them in various ways, and downloading the data and the plot. You can access the online interface at [eorlimits.streamlit.app](https://eorlimits.streamlit.app/). +The online GUI is the quickest way to explore published limits and generate plots without installing anything locally. It is built with [Streamlit](https://streamlit.io/) and lets you select data sets, apply filters, customize the display, and download both the data and resulting figures. You can access it at [eorlimits.streamlit.app](https://eorlimits.streamlit.app/). -### Using the library +### Using the Python API -The library also allows you to load, slice and dice the data in various ways, and also includes plotting functionality. For a detailed tutorial on how to use the library, see the _Tutorial notebook_. Here we just give a brief overview of how to make plots. To make the default plot of all the limits as a function of scale $k$, run: +The Python API provides tools for loading, filtering, and plotting the included data sets. For a more complete walkthrough, see the _Tutorial notebook_. Here we just give a brief overview of how to make plots. The two main plotting functions are `plot_vs_k` and `plot_vs_z`, which show limits as a function of scale $k$ and redshift $z$, respectively. To make the default plot versus $k$, run: ```python -from eor_limits import plot_vs_k +from eor_limits import plot_vs_k, plot_vs_z plot_vs_k() ``` -A more customized plot can be also be made. For example, we can plot the HERA limits, shading the 2023 ones and bolding the legend item, and Mesinger2016Faint and Mesinger2016Bright simulations with different colors and shaded regions: +The plotting functions accept keyword arguments to choose which limits and simulations to include, customize their styles, and save the output. For example, the following code plots the HERA limits, shades the 2023 result, bolds the HERA 2026 legend item, and adds the `Mesinger2016Faint` and `Mesinger2016Bright` simulations with custom colors and shaded regions: ```python -from eor_limits import plot_vs_k +from eor_limits import plot_vs_k, plot_vs_z plot_vs_k( limits=["HERA2022", "HERA2023", "HERA2026"], bold_limits=["HERA2026"], @@ -72,13 +72,15 @@ plot_vs_k( ### Using the CLI -After installation, you can also use the `eor-limits` command from the terminal to make plots. In the terminal, you can run ```eor-limits -h``` or ```eor-limits plot-vs-k -h``` to see the various options for customizing the plot. The CLI provides the same functionality as the library, but allows you to make plots without needing to write any code. For the default plot of all the limits as a function of scale $k$, you can simply run: +After installation, you can use the `eor-limits` command to make plots from the terminal. Run `eor-limits -h`, `eor-limits plot-vs-k -h`, or `eor-limits plot-vs-z -h` to see the available options. + +For the default plot of all limits as a function of scale $k$, run: ```bash eor-limits plot-vs-k --out=MyPlot.pdf ``` -To make the same customized plot as in the library example, the command allows for JSON dictionaries as arguments for the various `-style` options, so you can run: +The CLI exposes the same plotting options as the Python API. Dictionary-style options, such as custom limit or theory styles, can be passed as JSON strings: ```bash eor-limits plot-vs-k \ diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..4df5c54 --- /dev/null +++ b/agents.md @@ -0,0 +1,15 @@ +## Environment + +- Use the `uv` python environment to run code. + +## Tests + +- Write tests using the pytest framework, in `tests/` +- Run narrow tests based on implemented features first, using `uv run pytest -k "test-identifier"` +- Always write a failing test before fixing a bug. +- Write tests of intended behaviour before implementing a feature. + +## Style + +- Prefer adding type annotations for new parameters and functions, wherever reasonable. +- Always run `uv run ruff format` and `ruff check --fix` before closing the loop. diff --git a/docs/source/_static/eor_limits.png b/docs/source/_static/test_lib_plot_vs_k_basic.png similarity index 87% rename from docs/source/_static/eor_limits.png rename to docs/source/_static/test_lib_plot_vs_k_basic.png index 674ea84..088ac47 100644 Binary files a/docs/source/_static/eor_limits.png and b/docs/source/_static/test_lib_plot_vs_k_basic.png differ diff --git a/docs/source/conf.py b/docs/source/conf.py index e4dc260..57ca2b4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -53,6 +53,8 @@ # Autosummary autosummary_generate = True # Automatically generate summary files for documented items +# nbsphinx +nbsphinx_kernel_name = "python3" # Use the standard Python kernel for notebook builds # Autodoc autodoc_typehints = "none" # No type hints, let numpydoc handle them in the docstrings # Numpydoc diff --git a/docs/source/tutorial.ipynb b/docs/source/tutorial.ipynb index fe89230..3ef8978 100644 --- a/docs/source/tutorial.ipynb +++ b/docs/source/tutorial.ipynb @@ -21,8 +21,8 @@ " KNOWN_LIMITS,\n", " KNOWN_THEORIES,\n", " load_limit_data,\n", - " load_theory_model,\n", " plot_vs_k,\n", + " plot_vs_z,\n", ")\n", "\n", "# KNOWN_LIMITS and KNOWN_THEORIES are both dictionaries with the keys being the names\n", @@ -100,7 +100,7 @@ "id": "ef476426", "metadata": {}, "source": [ - "What about looking at a specific $k$-bin or $z$-bin? Let's try selecting the redshift bin closest to $z=7$ and also, separately, the $k$-bin closest to $k=0.1$ h/Mpc." + "Similarly, we can try selecting the redshift bin closest to $z=7$ and, separately, the $k$-bin closest to $k=0.1$ h/Mpc." ] }, { @@ -127,83 +127,32 @@ }, { "cell_type": "markdown", - "id": "8b5df718", + "id": "d37e130a", "metadata": {}, "source": [ - "## Custom plotting\n", - "\n", - "Let's plot the limits, now including other HERA limits and some theoretical models for comparison. The theory models are loaded in the same manner as the limits so we can filter and plot them all together as shown in the next cell." + "Finally, let's select the lowest limits, first per redshift bin (`per_z=True, per_tag=False` which is default), and then across all redshift bins (`per_z=False, per_tag=False`)." ] }, { "cell_type": "code", "execution_count": null, - "id": "963e30b5", + "id": "db12d2e0", "metadata": {}, "outputs": [], "source": [ - "import matplotlib.pyplot as plt\n", - "\n", - "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))\n", - "\n", - "datasets = [\n", - " load_limit_data(\"HERA2022\"),\n", - " load_limit_data(\"HERA2023\"),\n", - " load_limit_data(\"HERA2026\"),\n", - " load_theory_model(\"Mesinger2016Faint\"),\n", - " load_theory_model(\"Mesinger2016Bright\"),\n", - "]\n", - "labels = [\n", - " \"Phase 1 preliminary\",\n", - " \"Phase 1 final\",\n", - " \"Phase 2 preliminary\",\n", - " \"Faint galaxies\",\n", - " \"Bright galaxies\",\n", - "]\n", - "colors = [\"C0\", \"C1\", \"C2\", \"C3\", \"C4\"]\n", - "markers = [\"v\", \"v\", \"v\", \"none\", \"none\"]\n", - "\n", - "for dataset, label, color, marker in zip(\n", - " datasets, labels, colors, markers, strict=True\n", - "):\n", - " # Plotting the limits for the redshift bin closest to z=7\n", - " # (k range included to limit the theory models to the data range)\n", - " data_z7 = dataset.select_closest_z(7).select_k_range(1e-1, 5).data\n", - " ax1.plot(\n", - " data_z7.k[0],\n", - " data_z7.delta_squared[0],\n", - " marker=marker,\n", - " color=color,\n", - " label=label + f\" (z={data_z7.z[0]:.2f})\",\n", - " )\n", - "\n", - " # Plotting the limits for the k-bin closest to k=0.1 h/Mpc\n", - " # (z range included to limit the theory models to the data range)\n", - " data_k02 = dataset.select_closest_k(0.1).select_z_range(6, 30).data\n", - " ax2.plot(\n", - " data_k02.z, data_k02.delta_squared, marker=marker, color=color, label=label\n", - " )\n", - "\n", - " # Some shading for the theory models\n", - " if \"galaxies\" in label:\n", - " ax1.fill_between(data_z7.k[0], data_z7.delta_squared[0], alpha=0.1, color=color)\n", - "\n", - "# Some plot formatting\n", - "ax1.set_title(r\"at $z \\approx 7$\")\n", - "ax1.set_xlabel(r\"Scale $k$ (h/Mpc)\")\n", - "ax1.set_xscale(\"log\")\n", - "ax2.set_title(r\"at $k \\approx 0.1$ h/Mpc\")\n", - "ax2.set_xlabel(r\"Redshift $z$\")\n", - "ax1.legend()\n", - "ax2.legend()\n", - "for ax in [ax1, ax2]:\n", - " ax.set_ylabel(r\"$\\Delta_{21}^2$ (mK$^2$)\")\n", - " ax.set_yscale(\"log\")\n", - " ax.grid()\n", - "\n", - "fig.suptitle(\"HERA Power Spectrum Limits\")\n", - "fig.tight_layout()\n", - "fig.show()" + "hera2026_lowest = hera2026.select_lowest_delta_squared()\n", + "hera2026_lowest.data.as_pandas_df()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "148b9b31", + "metadata": {}, + "outputs": [], + "source": [ + "hera2026_lowest = hera2026.select_lowest_delta_squared(per_z=False, per_tag=False)\n", + "hera2026_lowest.data.as_pandas_df()" ] }, { @@ -211,9 +160,23 @@ "id": "535aa0c4", "metadata": {}, "source": [ - "## In-built plotting\n", - "\n", - "A similar plot can be made using the in-built plotting function `plot_vs_k()`, which takes care of all the slicing and styling for you. This plot differs slightly, in that we are not making narrow selection cuts but plotting all the data within $z=7-8$ coloured by redshift bins." + "## In-built plotting" + ] + }, + { + "cell_type": "markdown", + "id": "368850b5", + "metadata": {}, + "source": [ + "### Plot vs k" + ] + }, + { + "cell_type": "markdown", + "id": "85c9934c", + "metadata": {}, + "source": [ + "Let's plot the HERA limits, including ome theoretical models for comparison, using the in-built plotting function `plot_vs_k()`. The function includes various options for slicing and styling, and color-codes the limits by redshift bin. For example:" ] }, { @@ -242,6 +205,80 @@ ")\n", "fig.show()" ] + }, + { + "cell_type": "markdown", + "id": "a3920635", + "metadata": {}, + "source": [ + "### Plot vs redshift" + ] + }, + { + "cell_type": "markdown", + "id": "1493f8c6", + "metadata": {}, + "source": [ + "Similarly, the in-built function `plot_vs_z()` allows you to plot the lowest limits (across $k$-bins) as a function of redshift. The function includes similar options for slicing and styling, and color-codes the limits by year of publication. For example:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7025f243", + "metadata": {}, + "outputs": [], + "source": [ + "fig = plot_vs_z(\n", + " fig_width=20.0,\n", + " fontsize=20,\n", + " legend_ncols=3,\n", + " theories=[\"Mesinger2016Faint\", \"Mesinger2016Bright\"],\n", + " z_range=(5.5, 15),\n", + " limit_styles={\n", + " \"HERA2022\": {\"s\": 500},\n", + " \"HERA2023\": {\"s\": 500},\n", + " \"HERA2026\": {\"s\": 500},\n", + " },\n", + ")\n", + "fig.show()" + ] + }, + { + "cell_type": "markdown", + "id": "4a284a87", + "metadata": {}, + "source": [ + "You can even combine the limit styling with custom legends to accentuate a particular set of observations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3e1861af", + "metadata": {}, + "outputs": [], + "source": [ + "fig = plot_vs_z(\n", + " fig_width=20.0,\n", + " fontsize=20,\n", + " legend_ncols=2,\n", + " theories=[\"Mesinger2016Faint\", \"Mesinger2016Bright\"],\n", + " z_range=(5.5, 15),\n", + " base_limit_style={\"s\": 200, \"marker\": \"o\", \"color\": \"gray\"},\n", + " limit_styles={\n", + " \"Nunhokee2025\": {\"s\": 300, \"linewidth\": 5, \"marker\": \"s\", \"color\": \"magenta\"},\n", + " \"Trott2025\": {\"s\": 300, \"linewidth\": 5, \"marker\": \"s\", \"color\": \"blue\"},\n", + " },\n", + " legend_labeler={\n", + " \"Nunhokee2025\": \"Nunhokee et al. 2025\",\n", + " \"Trott2025\": \"Trott et al. 2025\",\n", + " \"Mesinger2016Faint\": \"Mesinger et al. 2016 faint\",\n", + " \"Mesinger2016Bright\": \"Mesinger et al. 2016 bright\",\n", + " },\n", + ")\n", + "fig.show()" + ] } ], "metadata": { diff --git a/pyproject.toml b/pyproject.toml index 9cb5a95..a2fbd93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "pandas>=3.0.0", "pyyaml>=6.0.3", "rich>=14.3.1", + "scipy>=1.14.0", ] urls = { Repository = "https://github.com/EoRImaging/eor_limits"} license = { file = "LICENSE" } @@ -44,6 +45,8 @@ source = "uv-dynamic-versioning" [dependency-groups] dev = [ "coverage>=7.13.2", + "ipykernel>=7.3.0", + "pip>=26.1.2", "pre-commit>=4.5.1", "pytest>=9.0.2", "pytest-cov>=7.0.0", diff --git a/src/eor_limits/__init__.py b/src/eor_limits/__init__.py index 5b7f8e0..6b2612c 100644 --- a/src/eor_limits/__init__.py +++ b/src/eor_limits/__init__.py @@ -10,7 +10,7 @@ load_theory_model, ) from ._datatypes import Data, DataSet -from ._plot import plot_vs_k, plot_vs_z +from ._plot import get_latex_label, plot_vs_k, plot_vs_z from .data import KNOWN_LIMITS from .theory import KNOWN_THEORIES @@ -19,6 +19,7 @@ "KNOWN_THEORIES", "Data", "DataSet", + "get_latex_label", "load_limit_data", "load_theory_model", "plot_vs_k", diff --git a/src/eor_limits/_plot.py b/src/eor_limits/_plot.py index 2bc57c4..a6a10f7 100644 --- a/src/eor_limits/_plot.py +++ b/src/eor_limits/_plot.py @@ -5,14 +5,15 @@ from collections.abc import Sequence from itertools import chain from pathlib import Path -from typing import Annotated, Any +from typing import Annotated, Any, Literal import h5py import matplotlib.cm as cmx -import matplotlib.colors as colors import matplotlib.pyplot as plt import numpy as np from cyclopts import Parameter, Token +from matplotlib import colors +from scipy import interpolate from ._data_loading import load_limit_data, load_theory_model from ._datatypes import DataSet @@ -43,13 +44,424 @@ def _json_str_to_dict(type_, tokens: Sequence[Token]) -> dict: ] -def plot_vs_z(*args, **kwargs): +def plot_vs_z( + # Limit plotting options + limits: StrList = None, + *, + base_limit_style: JsonDict = None, + limit_styles: JsonNestedDict = None, + bold_limits: StrList = None, + shade_limits: bool = False, + aspoints: StrList = None, + aslines: StrList = None, + nz_for_lines: int = 10, + # Limits selection options + z_range: tuple[float, float] | None = None, + delta_squared_range: tuple[float, float] | None = None, + # Theory plotting options + theories: StrList = None, + theory_k: float = 0.25, + base_theory_style: JsonDict = None, + theory_styles: JsonNestedDict = None, + bold_theories: StrList = None, + shade_theories: bool = True, + # Sensitivity plotting options + sensitivities: dict | None = None, + sensitivity_style: dict | None = None, + # General plotting options + show_colorbar: bool = False, + color_by: Literal["year", "k"] = "year", + colormap: str = "Spectral_r", + legend_labeler: JsonDict = None, + k_labels: Literal["legend", "title"] | None = "legend", + legend_ncols: int = 3, + fontsize: int = 15, + fig_width: float = 25.0, + fig_ratio: float | None = None, + # Output options + fig: Annotated[plt.Figure | None, Parameter(show=False)] = None, + ax: Annotated[plt.Axes | None, Parameter(show=False)] = None, + out: str | Path | None = None, +) -> plt.Figure: """ Plot 21-cm power spectrum limits as a function of redshift |z|. - Not yet implemented. Stay tuned! + For each experiment, this plots the lowest (most constraining) limit + at each redshift as a point or line. The color of the points/lines + indicates the year of the experiment by default (see ``color_by``, + ``show_colorbar`` and ``colormap`` options). + + Parameters + ---------- + limits : list[str] | None (default: ``None``) + List of limits to include in the plot + (see ``KNOWN_LIMITS`` for available limits). + If not specified, **all** limits are plotted. + base_limit_style : dict[str, Any] | None (default: ``None``) + Base style parameters for plotting limits, applied to all limits before any + individual overrides. For example, ``{'alpha': 0.7}`` to make all limits + slightly transparent. + limit_styles : dict[str, dict[str, Any]] | None (default: ``None``) + Dictionary of style parameters for plotting limits. The keys are the limit + keys (e.g. ``'Paciga2013'``), and the values are dictionaries with style + parameters for plotting, e.g. ``{'color': 'C0', 's': 100}`` for points or + ``{'color': 'C0', 'linewidth': 3}`` for lines. + bold_limits : list[str] | None (default: ``None``) + List of limits to bold in the legend. If not specified, no limits are bolded. + shade_limits : bool (default: ``False``) + Whether to shade the area above each limit line (or points, if plotted as + points). If ``True``, the area above each limit will be shaded with the color + specified in the limit styles as ``shade_color`` (default grey) and an alpha + value specified in the limit styles as ``shade_alpha`` (default 0.5). + aspoints : list[str] | None (default: ``None``) + List of limits to plot as points instead of lines. + If not specified, the function automatically determines whether to plot as + points or lines based on the number of |z| bins (see ``nz_for_lines``). + aslines : list[str] | None (default: ``None``) + List of limits to plot as lines instead of points. + If not specified, the function automatically determines whether to plot as + points or lines based on the number of |z| bins (see ``nz_for_lines``). + nz_for_lines : int (default: ``10``) + Threshold :math:`n_z` (number of |z| bins) to determine whether to plot a limit + as points or lines if not specified in ``aspoints`` or ``aslines``. + If a limit has :math:`len(z) > n_z`, it will be plotted as a line; + otherwise, it will be plotted as points. + z_range : tuple[float, float] | None (default: ``None``) + Tuple specifying the redshift range to include in the plot, in the form + ``(z_min, z_max)``. If not specified, all redshifts will be included. + delta_squared_range : tuple[float, float] | None (default: ``None``) + Tuple specifying the delta squared range to include in the plot, in the form + ``(delta_squared_min, delta_squared_max)``. If not specified, set to + ``[1e0, 1e6]`` if theories are plotted, otherwise derived from the + observational limits. + theories : list[str] | None (default: ``None``) + List of theories to include in the plot (see ``KNOWN_THEORIES`` for available + models). If not specified, **no** theories are plotted. + theory_k : float (default: ``0.25``) + The |k| value at which to evaluate theories when plotting vs redshift. + All theories will be evaluated at this k value using spline interpolation + across their k-space data. Only used if theories are specified. + base_theory_style : dict[str, Any] | None (default: ``None``) + Base style parameters for plotting theories, applied to all theories before any + individual overrides. For example, ``{'alpha': 0.7}`` to make all theories + slightly transparent. + theory_styles : dict[str, dict[str, Any]] | None (default: ``None``) + Dictionary of style parameters for plotting theories. The keys are the theory + keys (e.g. ``'Mesinger2016Faint'``), and the values are dictionaries with + style parameters for plotting, e.g. ``{'color': 'C1', 'linestyle': '--'}``. + bold_theories : list[str] | None (default: ``None``) + List of theories to bold in the legend. + If not specified, no theories are bolded. + shade_theories : bool (default: ``True``) + Whether to shade the area below each theory line. If ``True``, the area below + each theory will be shaded with the color specified in the theory styles as + ``shade_color`` (default lightsteelblue) and an alpha value specified in the + theory styles as ``shade_alpha`` (default ``1/len(theories)``). + sensitivities : dict | None (default: ``None``) + Dictionary of sensitivities to plot on the figure. The keys are labels for each + sensitivity estimate, and the values are the file names of the + sensitivities to plot, which must be outputs from 21cmSense v2+. + If not specified, no sensitivities are plotted. + sensitivity_style : dict | None (default: ``None``) + Dictionary of style parameters for plotting sensitivities. The keys are + labels for each sensitivity estimate, and the values are dictionaries with + style parameters for plotting, + e.g. ``{'color': 'k', 'linestyle': '--', 'linewidth': 3}``. + An additional key 'sensitivity_kind' can be used to specify which kind of + sensitivity to plot, e.g. ``'sample+thermal'``, ``'sample'`` or ``'thermal'``. + show_colorbar : bool (default: ``False``) + Whether to display a colorbar showing the selected ``color_by`` values. + color_by : {"year", "k"} (default: ``"year"``) + Quantity to use for coloring limits. If ``"year"``, each paper is colored by + experiment year. If ``"k"``, plotted points are colored by their |k| values. + colormap : str (default: ``'Spectral_r'``) + Matplotlib colormap to use for coloring limits. + legend_labeler : dict[str, str] | None + Optional mapping from limit or theory keys to custom legend labels. + Keys not present in the mapping will be excluded from the legend. + k_labels : {"legend", "title"} | None (default: ``"legend"``) + Where to show the plotted |k| values. If ``"legend"``, add each paper's + plotted |k| range to its legend label. If ``"title"``, add the plotted |k| + range across all papers to the title. If ``None``, do not show |k| labels. + legend_ncols : int (default: ``3``) + Number of columns to use in the legend. + fontsize : int (default: ``15``) + Font size to use in the legend and axis labels. + fig_width : float (default: ``25.0``) + Width of the figure in inches. + fig_ratio : float | None (default: ``None``) + Height to width ratio of the figure. If not specified, ``height = 0.8 * width`` + if theories are plotted, and ``height = 0.6 * width`` if no theories are + plotted. + fig : matplotlib.figure.Figure | None + If specified, the figure to plot on. If not specified, a new figure + will be created. + ax : matplotlib.axes.Axes| None + If specified, the axis to plot on. If not specified, a new axis + will be created. + out : str | Path | None + If specified, the file name to save the figure to. + + Returns + ------- + matplotlib.figure.Figure + The figure object containing the plot. """ - raise NotImplementedError("This function is not yet implemented. Stay tuned!") + ################################################################################### + # Set up the figure and axis + if theories is not None: + fig_height = fig_width * (fig_ratio or 0.8) + else: + fig_height = fig_width * (fig_ratio or 0.6) + + if fig is None or ax is None: + fig, ax = plt.subplots(figsize=(fig_width, fig_height)) + elif ax is not None: + fig = ax.get_figure() + + ################################################################################### + # OBSERVATIONAL LIMITS + # Load data for limits and sort by year. + if limits is None: + limits = [load_limit_data(limit).drop_nan() for limit in KNOWN_LIMITS] + limits.sort(key=lambda limit: limit.year) + else: + # DataSet.load() instead of load_limit_data() to allow loading from a YAML file. + limits = [DataSet.load(limit).drop_nan() for limit in limits] + + # Select the specified z and delta_squared ranges from the limits + def _get_z_range_from_limits(limits): + z_min = min(min(limit.data.z) for limit in limits) + z_max = max(max(limit.data.z) for limit in limits) + return (z_min, z_max) + + def _get_delta_squared_range_from_limits(limits): + delta_squared_min = min( + min(dsq) for limit in limits for dsq in limit.data.delta_squared + ) + delta_squared_max = max( + max(dsq) for limit in limits for dsq in limit.data.delta_squared + ) + min_factor = 10 ** np.ceil(np.log10(delta_squared_min) * -1) + max_factor = 10 ** np.ceil(np.log10(delta_squared_max) * -1) + return ( + np.floor(delta_squared_min * min_factor) / min_factor, + np.ceil(delta_squared_max * max_factor) / max_factor, + ) + + z_range = z_range or _get_z_range_from_limits(limits) + + if delta_squared_range is None: + if theories is not None: + delta_squared_range = (1e0, 1e6) + else: + delta_squared_range = _get_delta_squared_range_from_limits(limits) + + # Select the lowest delta_squared for each z in each limit + limits_vs_z = [ + limit.select_lowest_delta_squared(per_z=True, per_tag=False) for limit in limits + ] + + # Filter by z and delta_squared ranges without applying any k-range selection. + limits_vs_z = select_k_and_z_ranges( + limits_vs_z, + z_range=z_range, + k_range=None, + delta_squared_range=delta_squared_range, + ) + + # Update z_range after filtering + z_range = _get_z_range_from_limits(limits_vs_z) + + # Get plotted k ranges for optional labels and k-based coloring. + delta_k_threshold = 0.05 # Threshold for considering k values as "similar" + limit_k_ranges = {} + all_k_values = [] + for limit in limits_vs_z: + k_range = np.array([k for k_values in limit.data.k for k in k_values]) + limit_k_ranges[limit.key] = ( + np.min(k_range), + np.max(k_range), + np.mean(k_range), + ) + all_k_values.extend(k_range) + + # Set up colormap for the selected observational quantity. + if color_by == "year": + color_values = [limit.year for limit in limits_vs_z] + colorbar_label = "Year" + else: + color_values = all_k_values + colorbar_label = r"k ($h Mpc^{-1}$)" + norm = colors.Normalize(vmin=min(color_values), vmax=max(color_values)) + scalar_map = cmx.ScalarMappable(norm=norm, cmap=colormap) + + # Building plotting styles for each limit. + limit_styles = _build_limit_styles( + limits=limits_vs_z, + aspoints=aspoints, + aslines=aslines, + nbins_for_lines=nz_for_lines, + bin_type="z", + shade_limits=shade_limits, + base_override=base_limit_style, + overrides=limit_styles, + ) + + # Whether to bold each limit in the legend + bold_limits = bold_limits or [] + if legend_labeler is None: + limit_labels = [] + for limit in limits_vs_z: + k_label_suffix = "" + if k_labels == "legend": + k_min, k_max, k_mean = limit_k_ranges[limit.key] + if np.abs(k_min - k_max) < delta_k_threshold: + k_label_suffix = rf"\ (k \approx {k_mean:.2f}\ h/Mpc)" + else: + k_label_suffix = rf"\ (k\sim{k_min:.2f}-{k_max:.2f}\ h/Mpc)" + limit_labels.append( + get_latex_label( + limit, + bold=(limit.key in bold_limits), + label_suffix=k_label_suffix, + ) + ) + else: + limit_labels = [legend_labeler.get(limit.key) for limit in limits_vs_z] + + # Plotting the limits as points or lines + limit_lines = plot_limits_vs_z( + ax=ax, + limits=limits_vs_z, + limit_styles=limit_styles, + limit_labels=limit_labels, + shade_limits=shade_limits, + delta_squared_range=delta_squared_range, + scalar_map=scalar_map, + color_by=color_by, + ) + + ################################################################################### + # THEORY MODELS + + # Loading data for theories + theories = theories or [] + theories = [load_theory_model(theory) for theory in theories] + + # For plot_vs_z, we use all available theory redshifts and interpolate at theory_k + # No need to select specific redshifts since spline will handle interpolation + + # Build styles for theory lines, applying any overrides specified by the user. + theory_styles = _build_theory_styles( + theories=theories, + shade_theories=shade_theories, + base_override=base_theory_style, + overrides=theory_styles, + ) + + # Whether to bold each theory in the legend + bold_theories = bold_theories or [] + if legend_labeler is None: + theory_labels = [ + get_latex_label(theory, bold=(theory.key in bold_theories), theory=True) + for theory in theories + ] + else: + theory_labels = [legend_labeler.get(theory.key) for theory in theories] + + # Plotting the theory curves vs z + theory_lines = plot_theories_vs_z( + ax=ax, + theories=theories, + theory_k=theory_k, + theory_styles=theory_styles, + theory_labels=theory_labels, + shade_theories=shade_theories, + delta_squared_range=delta_squared_range, + ) + + ################################################################################### + # SENSITIVITIES + + # If sensitivities are specified, build styles and plot them. + sensitivities = sensitivities or {} + + # Build styles for sensitivity lines, applying any overrides specified by the user. + sensitivity_style = _build_sensitivity_styles(sensitivities, sensitivity_style) + + # Plot the sensitivity curves. + plot_sensitivities_vs_k( + ax=ax, + sensitivities=sensitivities, + sensitivity_style=sensitivity_style, + fontsize=fontsize, + ) + + ################################################################################### + # PLOT ADJUSTMENTS + + plt.rcParams.update({"font.size": fontsize}) + ax.set_xlabel(r"Redshift $z$", fontsize=fontsize) + ax.set_ylabel(r"$\Delta^2$ ($mK^2$)", fontsize=fontsize) + ax.set_yscale("log") + # Use log scale for z only if the z range spans multiple orders of magnitude + if z_range[1] / z_range[0] > 100: + ax.set_xscale("log") + ax.set_ylim(*delta_squared_range) + ax.set_xlim(z_range[0] * 0.9, z_range[1] * 1.1) # Add some padding + + ax.tick_params(labelsize=fontsize) + if k_labels == "title": + k_min = min(all_k_values) + k_max = max(all_k_values) + k_mean = np.mean(all_k_values) + if np.abs(k_min - k_max) < delta_k_threshold: + ax.set_title(rf"$k \approx {k_mean:.2f}\ h/Mpc$", fontsize=fontsize) + else: + ax.set_title(rf"$k\sim{k_min:.2f}-{k_max:.2f}\ h/Mpc$", fontsize=fontsize) + + # Create colorbar for selected color quantity (if requested) + if show_colorbar: + cb = fig.colorbar( + scalar_map, ax=ax, fraction=0.1, pad=0.08, label=colorbar_label + ) + cb.ax.yaxis.set_label_position("left") + cb.ax.yaxis.set_ticks_position("left") + cb.set_label(label=colorbar_label, fontsize=fontsize) + ax.grid(axis="y") + + limit_lines, limit_labels = _filter_legend_entries(limit_lines, limit_labels) + theory_lines, theory_labels = _filter_legend_entries(theory_lines, theory_labels) + leg_rows = int(np.ceil(len(limit_labels) / legend_ncols)) + + point_size = 1 / 72.0 # typography standard (points/inch) + font_inch = fontsize * point_size + legend_height = (2 * leg_rows) * font_inch + + legend_height_norm = legend_height / fig_height # 0.25 + + axis_height = 3 * fontsize * point_size + axis_height_norm = axis_height / fig_height + plot_bottom = legend_height_norm + axis_height_norm + + ax.legend( + limit_lines + theory_lines, + limit_labels + theory_labels, + bbox_to_anchor=(0.48, legend_height_norm / 2.0), + loc="center", + bbox_transform=fig.transFigure, + ncol=legend_ncols, + frameon=False, + ) + + fig.subplots_adjust(bottom=plot_bottom) + fig.tight_layout() + + if out is not None: + fig.savefig(out) + + return fig def plot_vs_k( @@ -78,8 +490,12 @@ def plot_vs_k( sensitivities: dict | None = None, sensitivity_style: dict | None = None, # General plotting options + show_colorbar: bool = True, colormap: str = "Spectral_r", + legend_labeler: JsonDict = None, + legend_ncols: int = 3, fontsize: int = 15, + fig_width: float = 25.0, fig_ratio: float | None = None, # Output options fig: Annotated[plt.Figure | None, Parameter(show=False)] = None, @@ -89,6 +505,9 @@ def plot_vs_k( """ Plot 21-cm power spectrum limits as a function of scale |k|. + The color of the points/lines indicates the redshift of the limit + (see ``show_colorbar`` and ``colormap`` options). + Parameters ---------- limits : list[str] | None (default: ``None``) @@ -105,7 +524,7 @@ def plot_vs_k( parameters for plotting, e.g. ``{'color': 'C0', 's': 100}`` for points or ``{'color': 'C0', 'linewidth': 3}`` for lines. bold_limits : list[str] | None (default: ``None``) - List of limits to bold in the legend. If no specified, no limits are bolded. + List of limits to bold in the legend. If not specified, no limits are bolded. shade_limits : bool (default: ``True``) Whether to shade the area above each limit line (or points, if plotted as points). If ``True``, the area above each limit will be shaded with the color @@ -144,7 +563,7 @@ def plot_vs_k( If not specified, plots the line closest to the center of the ``z_range``. base_theory_style : dict[str, Any] | None (default: ``None``) Base style parameters for plotting theories, applied to all theories before any - individualoverrides. For example, ``{'alpha': 0.7}`` to make all theories + individual overrides. For example, ``{'alpha': 0.7}`` to make all theories slightly transparent. theory_styles : dict[str, dict[str, Any]] | None (default: ``None``) Dictionary of style parameters for plotting theories. The keys are the theory @@ -170,13 +589,23 @@ def plot_vs_k( e.g. ``{'color': 'k', 'linestyle': '--', 'linewidth': 3}``. An additional key 'sensitivity_kind' can be used to specify which kind of sensitivity to plot, e.g. ``'sample+thermal'``, ``'sample'`` or ``'thermal'``. + show_colorbar : bool (default: ``True``) + Whether to display a colorbar showing the redshift values. colormap : str (default: ``'Spectral_r'``) Matplotlib colormap to use for coloring limits by redshift. + legend_labeler : dict[str, str] | None + Optional mapping from limit or theory keys to custom legend labels. + Keys not present in the mapping will be excluded from the legend. + legend_ncols : int (default: ``3``) + Number of columns to use in the legend. fontsize : int (default: ``15``) Font size to use in the legend and axis labels. + fig_width : float (default: ``25.0``) + Width of the figure in inches. fig_ratio : float | None (default: ``None``) - Height to width ratio of the figure. If not specified, ``height= 1*width`` - if theories are plotted, and ``height= 0.5*width`` if no theories are plotted. + Height to width ratio of the figure. If not specified, ``height = 1 * width`` + if theories are plotted, and ``height = 0.5 * width`` if no theories are + plotted. fig : matplotlib.figure.Figure | None If specified, the figure to plot on. If not specified, a new figure will be created. @@ -193,7 +622,6 @@ def plot_vs_k( """ ################################################################################### # Set up the figure and axis - fig_width = 25 if theories is not None: fig_height = fig_width * (fig_ratio or 1) else: @@ -264,7 +692,8 @@ def _get_k_range_from_limits(limits): limits=limits, aspoints=aspoints, aslines=aslines, - nk_for_lines=nk_for_lines, + nbins_for_lines=nk_for_lines, + bin_type="k", shade_limits=shade_limits, base_override=base_limit_style, overrides=limit_styles, @@ -272,13 +701,16 @@ def _get_k_range_from_limits(limits): # Whether to bold each limit in the legend bold_limits = bold_limits or [] - limit_labels = [ - get_latex_label(limit, bold=(limit.key in bold_limits)) for limit in limits - ] + if legend_labeler is None: + limit_labels = [ + get_latex_label(limit, bold=(limit.key in bold_limits)) for limit in limits + ] + else: + limit_labels = [legend_labeler.get(limit.key) for limit in limits] # Plotting the limits as points or lines, depending on the number of k values # or user specifications. - limit_lines = plot_limits( + limit_lines = plot_limits_vs_k( ax=ax, limits=limits, limit_styles=limit_styles, @@ -317,13 +749,16 @@ def _get_k_range_from_limits(limits): # Whether to bold each theory in the legend bold_theories = bold_theories or [] - theory_labels = [ - get_latex_label(theory, bold=(theory.key in bold_theories), theory=True) - for theory in theories - ] + if legend_labeler is None: + theory_labels = [ + get_latex_label(theory, bold=(theory.key in bold_theories), theory=True) + for theory in theories + ] + else: + theory_labels = [legend_labeler.get(theory.key) for theory in theories] # Plotting the theory lines. - theory_lines = plot_theories( + theory_lines = plot_theories_vs_k( ax=ax, theories=theories, theory_styles=theory_styles, @@ -342,7 +777,7 @@ def _get_k_range_from_limits(limits): sensitivity_style = _build_sensitivity_styles(sensitivities, sensitivity_style) # Plot the sensitivity curves. - plot_sensitivities( + plot_sensitivities_vs_k( ax=ax, sensitivities=sensitivities, sensitivity_style=sensitivity_style, @@ -361,20 +796,16 @@ def _get_k_range_from_limits(limits): ax.set_xlim(*k_range) ax.tick_params(labelsize=fontsize) - cb = fig.colorbar(scalar_map, ax=ax, fraction=0.1, pad=0.08, label="Redshift") - cb.ax.yaxis.set_label_position("left") - cb.ax.yaxis.set_ticks_position("left") - cb.set_label(label="Redshift", fontsize=fontsize) + if show_colorbar: + cb = fig.colorbar(scalar_map, ax=ax, fraction=0.1, pad=0.08, label="Redshift") + cb.ax.yaxis.set_label_position("left") + cb.ax.yaxis.set_ticks_position("left") + cb.set_label(label="Redshift", fontsize=fontsize) ax.grid(axis="y") - if fontsize > 25: - leg_columns = 1 - elif fontsize > 20: - leg_columns = 2 - else: - leg_columns = 3 - - leg_rows = int(np.ceil(len(limit_labels) / leg_columns)) + limit_lines, limit_labels = _filter_legend_entries(limit_lines, limit_labels) + theory_lines, theory_labels = _filter_legend_entries(theory_lines, theory_labels) + leg_rows = int(np.ceil(len(limit_labels) / legend_ncols)) point_size = 1 / 72.0 # typography standard (points/inch) font_inch = fontsize * point_size @@ -392,7 +823,7 @@ def _get_k_range_from_limits(limits): bbox_to_anchor=(0.48, legend_height_norm / 2.0), loc="center", bbox_transform=fig.transFigure, - ncol=leg_columns, + ncol=legend_ncols, frameon=False, ) @@ -509,7 +940,8 @@ def _build_limit_styles( limits: list[DataSet], aspoints: list[str] | None, aslines: list[str] | None, - nk_for_lines: int, + nbins_for_lines: int, + bin_type: Literal["k", "z"], shade_limits: bool, base_override: dict[str, Any] | None = None, overrides: dict[str, dict[str, Any]] | None = None, @@ -526,8 +958,10 @@ def _build_limit_styles( The list of limit keys to plot as points. aslines : list[str] | None The list of limit keys to plot as lines. - nk_for_lines : int - The number of k values above which to automatically plot as lines. + nbins_for_lines : int + The number of bins above which to automatically plot as lines. + bin_type : {"k", "z"} + The bin axis to use for automatic line selection. shade_limits : bool Whether to shade the limits. base_override : dict[str, Any] | None @@ -547,6 +981,9 @@ def _build_limit_styles( for limit in limits: # Empty style = {} + nbins = ( + max(len(k) for k in limit.data.k) if bin_type == "k" else len(limit.data.z) + ) # Determine whether to plot as points or lines if limit.key in aspoints and limit.key in aslines: raise ValueError( @@ -557,7 +994,7 @@ def _build_limit_styles( if limit.key in aspoints else True if limit.key in aslines - else max(len(k) for k in limit.data.k) > nk_for_lines + else nbins > nbins_for_lines ) # Set defaults for points if not style["as_line"]: @@ -662,7 +1099,44 @@ def _build_sensitivity_styles( return styles -def get_latex_label(paper: DataSet, bold: bool = False, theory: bool = False) -> str: +def _filter_legend_entries( + handles: Sequence[Any], labels: Sequence[str | None] +) -> tuple[list[Any], list[str]]: + """Remove legend entries with no label while keeping handles aligned. + + Parameters + ---------- + handles + Matplotlib artists to include in the legend. + labels + Legend labels corresponding to ``handles``. Entries with ``None`` labels are + omitted from the returned handles and labels. + + Returns + ------- + list + The legend handles whose corresponding label is not ``None``. + list + The non-``None`` legend labels. + """ + entries = [ + (handle, label) + for handle, label in zip(handles, labels, strict=True) + if label is not None + ] + if not entries: + return [], [] + + filtered_handles, filtered_labels = zip(*entries, strict=True) + return list(filtered_handles), list(filtered_labels) + + +def get_latex_label( + paper: DataSet, + bold: bool = False, + theory: bool = False, + label_suffix: str = "", +) -> str: """Get a LaTeX label for a limit or theory paper. Parameters @@ -673,6 +1147,8 @@ def get_latex_label(paper: DataSet, bold: bool = False, theory: bool = False) -> Whether to make the label bold, by default False. theory : bool, optional Whether this is a theory (True) or limit (False) paper. + label_suffix : str, optional + Optional LaTeX suffix. Returns ------- @@ -692,11 +1168,12 @@ def get_latex_label(paper: DataSet, bold: bool = False, theory: bool = False) -> + r",\ " + str(paper.year) + ")" + + label_suffix + label_end ) -def plot_limits( +def plot_limits_vs_k( *, ax: plt.Axes, limits: list[DataSet], @@ -706,7 +1183,7 @@ def plot_limits( delta_squared_range: tuple[float, float], scalar_map: cmx.ScalarMappable, ): - """Plot limit papers on the given axes. + """Plot limit papers on k vs delta_squared axes. Parameters ---------- @@ -730,13 +1207,20 @@ def plot_limits( for limit, label in zip(limits, limit_labels, strict=True): logging.getLogger("eor_limits").info(f"Plotting {limit.author} {limit.year}") - limit_style = limit_styles[limit.key] + limit_style = limit_styles[limit.key].copy() # Pop invalid args for plt.plot or plt.scatter as_line = limit_style.pop("as_line") if shade_limits: shade_alpha = limit_style.pop("shade_alpha") shade_color = limit_style.pop("shade_color") + # (especially to avoid errors when you have base styling) + if as_line: + for key in ["s"]: + limit_style.pop(key, None) + else: + for key in ["linewidth", "lw", "linestyle", "ls"]: + limit_style.pop(key, None) # If we are plotting as points, we plot each redshift with specific colors # and making sure to meet towards the right edges to avoid overlaps. @@ -814,8 +1298,6 @@ def plot_limits( np.asarray(delta_squared), )).T.flatten() - color_val = scalar_map.to_rgba(redshift) - # make black outline by plotting thicker black line first ax.plot( k_edges, @@ -828,9 +1310,9 @@ def plot_limits( (this_line,) = ax.plot( k_edges, delta_edges, - color=color_val, label=label, zorder=1, + color=scalar_map.to_rgba(redshift), **limit_style, ) if shade_limits: @@ -851,7 +1333,7 @@ def plot_limits( return lines -def plot_theories( +def plot_theories_vs_k( *, ax: plt.Axes, theories: list[DataSet], @@ -860,7 +1342,7 @@ def plot_theories( shade_theories: bool, delta_squared_range: tuple[float, float], ): - """Plot theory lines on the given axes. + """Plot theory lines on k vs delta_squared axes. Parameters ---------- @@ -882,7 +1364,7 @@ def plot_theories( for theory, label in zip(theories, theory_labels, strict=True): logging.getLogger("eor_limits").info(f"Plotting {theory.author} {theory.year}") - theory_style = theory_styles[theory.key] + theory_style = theory_styles[theory.key].copy() # Shade first and pop the specific args if shade_theories: @@ -911,7 +1393,7 @@ def plot_theories( return lines -def plot_sensitivities( +def plot_sensitivities_vs_k( *, ax: plt.Axes, sensitivities: dict[str, str] | None, @@ -977,3 +1459,245 @@ def plot_sensitivities( ax.text( ks[k_ind], sense[k_ind], name, fontsize=fontsize, verticalalignment="top" ) + + +def plot_limits_vs_z( + *, + ax: plt.Axes, + limits: list[DataSet], + limit_styles: dict[str, dict[str, Any]], + limit_labels: list[str | None], + shade_limits: bool, + delta_squared_range: tuple[float, float], + scalar_map: cmx.ScalarMappable, + color_by: Literal["year", "k"], +): + """Plot limit papers on z vs delta_squared axes. + + Parameters + ---------- + ax : plt.Axes + The axes on which to plot the limits. + limits : list[DataSet] + A list of limit papers to plot, each reduced to one point per redshift. + limit_styles : dict[str, dict[str, Any]] + A dictionary mapping limit paper keys to their styles. + limit_labels : list[str | None] + A list of labels for the limit papers. + shade_limits : bool + Whether to shade the limit regions. + delta_squared_range : tuple[float, float] + The range of delta squared values to display. + scalar_map : cmx.ScalarMappable + A scalar mappable for coloring the limits. + color_by : {"year", "k"} + Quantity to use for coloring limits. + """ + lines = [] + + for _idx, (limit, label) in enumerate(zip(limits, limit_labels, strict=True)): + logging.getLogger("eor_limits").info(f"Plotting {limit.author} {limit.year}") + + limit_style = limit_styles[limit.key].copy() + + # Pop invalid args for plt.plot or plt.scatter + as_line = limit_style.pop("as_line") + if shade_limits: + shade_alpha = limit_style.pop("shade_alpha") + shade_color = limit_style.pop("shade_color") + # (especially to avoid errors when you have base styling) + if as_line: + for key in ["s"]: + limit_style.pop(key, None) + else: + for key in ["linewidth", "lw", "linestyle", "ls"]: + limit_style.pop(key, None) + + # Each limit now has one data point per redshift + z_vals = limit.data.z + dsq_vals = np.array([dsq[0] for dsq in limit.data.delta_squared]) + k_vals = np.array([k[0] for k in limit.data.k]) + + # Use user-provided color if available, otherwise use scalar_map. + has_color_override = "color" in limit_style + if has_color_override: + color_val = limit_style.pop("color") + elif color_by == "k": + color_val = scalar_map.to_rgba(k_vals) + else: + color_val = scalar_map.to_rgba(limit.year) + + if not as_line: + # Plot as scatter points + line = ax.scatter( + z_vals, + dsq_vals, + color=color_val, + label=label, + zorder=2, + **limit_style, + ) + + if shade_limits: + # Shade the region above the limits + ax.fill_between( + z_vals, + dsq_vals, + delta_squared_range[1], + color=shade_color, + alpha=shade_alpha, + zorder=0, + ) + else: + # Plot as connected line + # Make black outline by plotting thicker black line first + ax.plot( + z_vals, + dsq_vals, + color="black", + linewidth=limit_style.get("linewidth", 2) + 2, + zorder=1, + ) + + (line,) = ax.plot( + z_vals, + dsq_vals, + color=( + np.mean(color_val, axis=0) + if color_by == "k" and not has_color_override + else color_val + ), + label=label, + zorder=1, + **limit_style, + ) + + if shade_limits: + ax.fill_between( + z_vals, + dsq_vals, + delta_squared_range[1], + color=shade_color, + alpha=shade_alpha, + zorder=0, + ) + + lines.append(line) + + return lines + + +def plot_theories_vs_z( + *, + ax: plt.Axes, + theories: list[DataSet], + theory_k: float, + theory_styles: dict[str, dict[str, Any]], + theory_labels: list[str], + shade_theories: bool, + delta_squared_range: tuple[float, float], +): + """Plot theory lines on z vs delta_squared axes. + + Parameters + ---------- + ax : plt.Axes + The axes on which to plot the theories. + theories : list[DataSet] + A list of theory papers to plot. + theory_k : float + The |k| value at which to evaluate theories. + theory_styles : dict[str, dict[str, Any]] + A dictionary mapping theory paper keys to their styles. + theory_labels : list[str] + A list of labels for the theory papers. + shade_theories : bool + Whether to shade the theory regions. + delta_squared_range : tuple[float, float] + The range of delta squared values to display. + """ + lines = [] + + # If no theories provided, return empty list + if not theories: + return lines + + for theory, label in zip(theories, theory_labels, strict=True): + logging.getLogger("eor_limits").info(f"Plotting {theory.author} {theory.year}") + + theory_style = theory_styles[theory.key].copy() + + # For theories on z plot, interpolate delta_squared at the specified k value + # for each redshift + z_vals = theory.data.z + dsq_vals = [] + + for z_idx, k_vals in enumerate(theory.data.k): + dsq_arr = theory.data.delta_squared[z_idx] + + # Create a spline for this redshift's k-space data + # Sort by k to ensure monotonic x values for interpolation + sort_idx = np.argsort(k_vals) + k_sorted = k_vals[sort_idx] + dsq_sorted = dsq_arr[sort_idx] + + # Check if theory_k is within the available k range + k_min, k_max = k_sorted[0], k_sorted[-1] + if not (k_min <= theory_k <= k_max): + logging.getLogger("eor_limits").warning( + f"theory_k={theory_k} outside k range [{k_min:.4f}, " + f"{k_max:.4f}] for {theory.key} at z={z_vals[z_idx]}. " + f"Using nearest available k value." + ) + # Use nearest k value + nearest_idx = np.argmin(np.abs(k_sorted - theory_k)) + dsq_vals.append(dsq_sorted[nearest_idx]) + else: + # Check for non-finite values (NaN or inf) + mask = np.isfinite(dsq_sorted) + if not np.any(mask): + raise ValueError( + f"All delta_squared values are non-finite for {theory.key} " + f"at z={z_vals[z_idx]}." + ) + + try: + spl = interpolate.CubicSpline(k_sorted[mask], dsq_sorted[mask]) + dsq_at_k = spl(theory_k) + dsq_vals.append(dsq_at_k) + except ValueError as e: + msg = f"CubicSpline failed for {theory.key}: {e}" + logging.getLogger("eor_limits").warning( + f"{msg}. Using nearest neighbor interpolation." + ) + nearest_idx = np.argmin(np.abs(k_sorted - theory_k)) + dsq_vals.append(dsq_sorted[nearest_idx]) + + dsq_vals = np.array(dsq_vals) + + # Shade first and pop the specific args + if shade_theories: + shade_alpha = theory_style.pop("shade_alpha") + shade_color = theory_style.pop("shade_color") + ax.fill_between( + z_vals, + dsq_vals, + delta_squared_range[0], + color=shade_color, + alpha=shade_alpha, + zorder=0, + ) + + # Plot the theory line on top + (line,) = ax.plot( + z_vals, + dsq_vals, + label=label, + zorder=1, + **theory_style, + ) + + if label is not None: + lines.append(line) + + return lines diff --git a/tests/conftest.py b/tests/conftest.py index 55d2243..45d9606 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,16 @@ of generated plots. """ +import matplotlib.pyplot as plt +import pytest + + +@pytest.fixture(autouse=True) +def close_matplotlib_figures(): + """Close figures opened by each test.""" + yield + plt.close("all") + def pytest_collection_modifyitems(items): """Ensure test_plotting runs before test_cli_plotting.""" diff --git a/tests/test_cli_plotting.py b/tests/test_cli_plotting.py index 1f725d9..ea77a10 100644 --- a/tests/test_cli_plotting.py +++ b/tests/test_cli_plotting.py @@ -15,10 +15,10 @@ IMAGE_TOL = 0.1 -def _run_plot_vs_k(*args: str, expect_success: bool = True) -> None: - """Invoke the CLI plot-vs-k command with provided args.""" +def _run_plot_command(command: str, *args: str, expect_success: bool = True) -> None: + """Invoke a CLI plotting command with provided args.""" with pytest.raises(SystemExit) as exc_info: - app(["plot-vs-k", *args], exit_on_error=True) + app([command, *args], exit_on_error=True) if expect_success: assert exc_info.value.code == 0 if "--out" in args: @@ -29,13 +29,23 @@ def _run_plot_vs_k(*args: str, expect_success: bool = True) -> None: assert exc_info.value.code != 0 # negative test case -def _assert_images_match(test_name: str, cli_path: Path) -> None: +def _run_plot_vs_k(*args: str, expect_success: bool = True) -> None: + """Invoke the CLI plot-vs-k command with provided args.""" + _run_plot_command("plot-vs-k", *args, expect_success=expect_success) + + +def _run_plot_vs_z(*args: str, expect_success: bool = True) -> None: + """Invoke the CLI plot-vs-z command with provided args.""" + _run_plot_command("plot-vs-z", *args, expect_success=expect_success) + + +def _assert_images_match(plot_name: str, test_name: str, cli_path: Path) -> None: """Compare a CLI-generated PNG against the library-generated reference. This helper skips the test if the reference image is missing, so CLI tests can be run in isolation without depending on test_plotting side effects. """ - ref_path = OUTPUT_DIR / f"test_lib_plot_vs_k_{test_name}.png" + ref_path = OUTPUT_DIR / f"test_lib_{plot_name}_{test_name}.png" if not ref_path.exists(): pytest.skip( f"Reference image not found: {ref_path}. " @@ -56,7 +66,7 @@ def test_cli_plot_vs_k_basic(): """Test making a plot with default parameters through the CLI.""" out = OUTPUT_DIR / "test_cli_plot_vs_k_basic.png" _run_plot_vs_k("--out", str(out), expect_success=True) - _assert_images_match("basic", out) + _assert_images_match("plot_vs_k", "basic", out) def test_cli_plot_vs_k_with_fig_styling(): @@ -67,27 +77,38 @@ def test_cli_plot_vs_k_with_fig_styling(): "12", "--colormap", "viridis", + "--fig-width", + "20.0", "--fig-ratio", "2.0", + "--legend-ncols", + "2", "--out", str(out), expect_success=True, ) - _assert_images_match("with_fig_styling", out) + _assert_images_match("plot_vs_k", "with_fig_styling", out) + + +def test_cli_plot_vs_k_without_colorbar(): + """Test making a plot without a redshift colorbar through the CLI.""" + out = OUTPUT_DIR / "test_cli_plot_vs_k_without_colorbar.png" + _run_plot_vs_k("--show-colorbar=False", "--out", str(out), expect_success=True) + _assert_images_match("plot_vs_k", "without_colorbar", out) def test_cli_plot_vs_k_with_z_range(): """Test making a plot with redshift range filtering through the CLI.""" out = OUTPUT_DIR / "test_cli_plot_vs_k_with_z_range.png" _run_plot_vs_k("--z-range", "6.0", "10.0", "--out", str(out), expect_success=True) - _assert_images_match("with_z_range", out) + _assert_images_match("plot_vs_k", "with_z_range", out) def test_cli_plot_vs_k_with_k_range(): """Test making a plot with k range filtering through the CLI.""" out = OUTPUT_DIR / "test_cli_plot_vs_k_with_k_range.png" _run_plot_vs_k("--k-range", "0.1", "1.0", "--out", str(out), expect_success=True) - _assert_images_match("with_k_range", out) + _assert_images_match("plot_vs_k", "with_k_range", out) def test_cli_plot_vs_k_with_delta_squared_range(): @@ -96,7 +117,7 @@ def test_cli_plot_vs_k_with_delta_squared_range(): _run_plot_vs_k( "--delta-squared-range", "1e3", "1e5", "--out", str(out), expect_success=True ) - _assert_images_match("with_delta_squared_range", out) + _assert_images_match("plot_vs_k", "with_delta_squared_range", out) def test_cli_plot_vs_k_with_aspoints(): @@ -112,7 +133,7 @@ def test_cli_plot_vs_k_with_aspoints(): str(out), expect_success=True, ) - _assert_images_match("with_aspoints", out) + _assert_images_match("plot_vs_k", "with_aspoints", out) def test_cli_plot_vs_k_with_aslines(): @@ -128,7 +149,7 @@ def test_cli_plot_vs_k_with_aslines(): str(out), expect_success=True, ) - _assert_images_match("with_aslines", out) + _assert_images_match("plot_vs_k", "with_aslines", out) def test_cli_plot_vs_k_with_bold_limits(): @@ -145,7 +166,7 @@ def test_cli_plot_vs_k_with_bold_limits(): str(out), expect_success=True, ) - _assert_images_match("with_bold_limits", out) + _assert_images_match("plot_vs_k", "with_bold_limits", out) def test_cli_plot_vs_k_with_bold_theories(): @@ -162,7 +183,7 @@ def test_cli_plot_vs_k_with_bold_theories(): str(out), expect_success=True, ) - _assert_images_match("with_bold_theories", out) + _assert_images_match("plot_vs_k", "with_bold_theories", out) def test_cli_plot_vs_k_with_limit_and_theory_styling(): @@ -195,7 +216,7 @@ def test_cli_plot_vs_k_with_limit_and_theory_styling(): "--out", str(out), ) - _assert_images_match("with_limit_and_theory_styling", out) + _assert_images_match("plot_vs_k", "with_limit_and_theory_styling", out) # Negative case: invalid JSON should trigger a non-zero exit and an error message. bad_out = OUTPUT_DIR / "test_cli_plot_vs_k_with_invalid_style_json.png" @@ -211,3 +232,221 @@ def test_cli_plot_vs_k_with_limit_and_theory_styling(): str(bad_out), expect_success=False, # Negative test case ) + + +def test_cli_plot_vs_k_with_legend_labeler(): + """Test making a plot_vs_k with custom legend labels through the CLI.""" + out = OUTPUT_DIR / "test_cli_plot_vs_k_with_legend_labeler.png" + _run_plot_vs_k( + "--limits", + "HERA2022", + "HERA2023", + "Paciga2013", + "--legend-labeler", + '{"HERA2023": "HERA 2023"}', + "--out", + str(out), + expect_success=True, + ) + _assert_images_match("plot_vs_k", "with_legend_labeler", out) + + +def test_cli_plot_vs_z_basic(): + """Test making a plot_vs_z with default parameters through the CLI.""" + out = OUTPUT_DIR / "test_cli_plot_vs_z_basic.png" + _run_plot_vs_z("--out", str(out), expect_success=True) + _assert_images_match("plot_vs_z", "basic", out) + + +def test_cli_plot_vs_z_with_fig_styling(): + """Test making a plot_vs_z with custom styling parameters through the CLI.""" + out = OUTPUT_DIR / "test_cli_plot_vs_z_with_fig_styling.png" + _run_plot_vs_z( + "--fontsize", + "12", + "--colormap", + "viridis", + "--fig-ratio", + "1.0", + "--out", + str(out), + expect_success=True, + ) + _assert_images_match("plot_vs_z", "with_fig_styling", out) + + +def test_cli_plot_vs_z_with_color_by_k(): + """Test making a plot_vs_z colored by k through the CLI.""" + out = OUTPUT_DIR / "test_cli_plot_vs_z_with_color_by_k.png" + _run_plot_vs_z( + "--color-by", + "k", + "--show-colorbar=True", + "--out", + str(out), + expect_success=True, + ) + _assert_images_match("plot_vs_z", "with_color_by_k", out) + + +def test_cli_plot_vs_z_with_z_range(): + """Test making a plot_vs_z with redshift range filtering through the CLI.""" + out = OUTPUT_DIR / "test_cli_plot_vs_z_with_z_range.png" + _run_plot_vs_z("--z-range", "6.0", "10.0", "--out", str(out), expect_success=True) + _assert_images_match("plot_vs_z", "with_z_range", out) + + +def test_cli_plot_vs_z_with_delta_squared_range(): + """Test making a plot_vs_z with delta squared range filtering through the CLI.""" + out = OUTPUT_DIR / "test_cli_plot_vs_z_with_delta_squared_range.png" + _run_plot_vs_z( + "--delta-squared-range", "1e3", "1e5", "--out", str(out), expect_success=True + ) + _assert_images_match("plot_vs_z", "with_delta_squared_range", out) + + +def test_cli_plot_vs_z_with_specific_limits(): + """Test making a plot_vs_z with specific limits through the CLI.""" + out = OUTPUT_DIR / "test_cli_plot_vs_z_with_specific_limits.png" + _run_plot_vs_z( + "--limits", + "HERA2022", + "HERA2023", + "Paciga2013", + "--out", + str(out), + expect_success=True, + ) + _assert_images_match("plot_vs_z", "with_specific_limits", out) + + +def test_cli_plot_vs_z_with_aspoints(): + """Test making a plot_vs_z with specific limits as points through the CLI.""" + limits = list(eor_limits.KNOWN_LIMITS.keys())[:5] + out = OUTPUT_DIR / "test_cli_plot_vs_z_with_aspoints.png" + _run_plot_vs_z( + "--limits", + *limits, + "--aspoints", + *limits, + "--out", + str(out), + expect_success=True, + ) + _assert_images_match("plot_vs_z", "with_aspoints", out) + + +def test_cli_plot_vs_z_with_aslines(): + """Test making a plot_vs_z with specific limits as lines through the CLI.""" + limits = list(eor_limits.KNOWN_LIMITS.keys())[:5] + out = OUTPUT_DIR / "test_cli_plot_vs_z_with_aslines.png" + _run_plot_vs_z( + "--limits", + *limits, + "--aslines", + *limits, + "--out", + str(out), + expect_success=True, + ) + _assert_images_match("plot_vs_z", "with_aslines", out) + + +def test_cli_plot_vs_z_with_bold_limits(): + """Test making a plot_vs_z with bolded specific limits through the CLI.""" + out = OUTPUT_DIR / "test_cli_plot_vs_z_with_bold_limits.png" + _run_plot_vs_z( + "--limits", + "HERA2022", + "HERA2023", + "Paciga2013", + "--bold-limits", + "HERA2022", + "HERA2023", + "--out", + str(out), + expect_success=True, + ) + _assert_images_match("plot_vs_z", "with_bold_limits", out) + + +def test_cli_plot_vs_z_with_bold_theories(): + """Test making a plot_vs_z with bolded specific theories through the CLI.""" + theories = list(eor_limits.KNOWN_THEORIES.keys()) + out = OUTPUT_DIR / "test_cli_plot_vs_z_with_bold_theories.png" + _run_plot_vs_z( + "--theories", + *theories, + "--bold-theories", + "Mesinger2016Faint", + "Mesinger2016Bright", + "--out", + str(out), + expect_success=True, + ) + _assert_images_match("plot_vs_z", "with_bold_theories", out) + + +def test_cli_plot_vs_z_with_limit_styling(): + """Test making a plot_vs_z with custom limit styling through the CLI.""" + out = OUTPUT_DIR / "test_cli_plot_vs_z_with_limit_styling.png" + _run_plot_vs_z( + "--limits", + "HERA2022", + "HERA2023", + "Paciga2013", + "--base-limit-style", + '{"linewidth": 3, "alpha": 0.8}', + "--limit-styles", + '{"HERA2023": {"color": "C3"}}', + "--out", + str(out), + expect_success=True, + ) + _assert_images_match("plot_vs_z", "with_limit_styling", out) + + +def test_cli_plot_vs_z_with_limit_and_theory_styling(): + """Test making a plot_vs_z with custom limit/theory styling through the CLI.""" + out = OUTPUT_DIR / "test_cli_plot_vs_z_with_limit_and_theory_styling.png" + _run_plot_vs_z( + "--limits", + "HERA2022", + "HERA2023", + "Paciga2013", + "--theories", + "Mesinger2016Faint", + "Mesinger2016Bright", + "--base-limit-style", + '{"linewidth": 3, "alpha": 0.8}', + "--limit-styles", + '{"HERA2023": {"color": "C3"}}', + "--base-theory-style", + '{"linestyle": "-."}', + "--theory-styles", + '{"Mesinger2016Faint": {"color": "C0",\ + "shade_alpha": 0.5, "shade_color": "C2"},\ + "Mesinger2016Bright": {"color": "C1",\ + "shade_alpha": 0.1, "shade_color": "C2"}}', + "--out", + str(out), + expect_success=True, + ) + _assert_images_match("plot_vs_z", "with_limit_and_theory_styling", out) + + +def test_cli_plot_vs_z_with_legend_labeler(): + """Test making a plot_vs_z with custom legend labels through the CLI.""" + out = OUTPUT_DIR / "test_cli_plot_vs_z_with_legend_labeler.png" + _run_plot_vs_z( + "--limits", + "HERA2022", + "HERA2023", + "Paciga2013", + "--legend-labeler", + '{"HERA2023": "HERA 2023"}', + "--out", + str(out), + expect_success=True, + ) + _assert_images_match("plot_vs_z", "with_legend_labeler", out) diff --git a/tests/test_lib_plotting.py b/tests/test_lib_plotting.py index 62004eb..a775eb6 100644 --- a/tests/test_lib_plotting.py +++ b/tests/test_lib_plotting.py @@ -2,7 +2,7 @@ from pathlib import Path -from eor_limits import KNOWN_LIMITS, KNOWN_THEORIES, plot_vs_k +from eor_limits import KNOWN_LIMITS, KNOWN_THEORIES, plot_vs_k, plot_vs_z # Output directory for test PDFs OUTPUT_DIR = Path(__file__).parent / "figures" @@ -20,12 +20,24 @@ def test_lib_plot_vs_k_with_fig_styling(): fig = plot_vs_k( fontsize=12, colormap="viridis", + fig_width=20.0, fig_ratio=2.0, + legend_ncols=2, out=OUTPUT_DIR / "test_lib_plot_vs_k_with_fig_styling.png", ) assert fig is not None +def test_lib_plot_vs_k_without_colorbar(): + """Test making a plot without a redshift colorbar.""" + fig = plot_vs_k( + show_colorbar=False, + out=OUTPUT_DIR / "test_lib_plot_vs_k_without_colorbar.png", + ) + assert fig is not None + assert len(fig.axes) == 1 + + def test_lib_plot_vs_k_with_z_range(): """Test making a plot with redshift range filtering.""" fig = plot_vs_k( @@ -122,3 +134,190 @@ def test_lib_plot_vs_k_with_limit_and_theory_styling(): out=OUTPUT_DIR / "test_lib_plot_vs_k_with_limit_and_theory_styling.png", ) assert fig is not None + + +def test_lib_plot_vs_k_with_legend_labeler(): + """Test making a plot_vs_k with custom legend labels.""" + fig = plot_vs_k( + limits=["HERA2022", "HERA2023", "Paciga2013"], + legend_labeler={"HERA2023": "HERA 2023"}, + out=OUTPUT_DIR / "test_lib_plot_vs_k_with_legend_labeler.png", + ) + + legend = fig.axes[0].get_legend() + assert fig is not None + assert legend is not None + assert [text.get_text() for text in legend.get_texts()] == ["HERA 2023"] + + +# Tests for plot_vs_z function + + +def test_lib_plot_vs_z_basic(): + """Test making a plot_vs_z with default parameters.""" + fig = plot_vs_z(out=OUTPUT_DIR / "test_lib_plot_vs_z_basic.png") + assert fig is not None + + +def test_lib_plot_vs_z_with_fig_styling(): + """Test making a plot_vs_z with custom styling parameters.""" + fig = plot_vs_z( + fontsize=12, + colormap="viridis", + fig_ratio=1.0, + out=OUTPUT_DIR / "test_lib_plot_vs_z_with_fig_styling.png", + ) + assert fig is not None + + +def test_lib_plot_vs_z_with_color_by_k(): + """Test making a plot_vs_z colored by k.""" + fig = plot_vs_z( + color_by="k", + show_colorbar=True, + out=OUTPUT_DIR / "test_lib_plot_vs_z_with_color_by_k.png", + ) + + assert fig is not None + assert len(fig.axes) == 2 + assert fig.axes[1].get_ylabel() == r"k ($h Mpc^{-1}$)" + + +def test_lib_plot_vs_z_with_z_range(): + """Test making a plot_vs_z with redshift range filtering.""" + fig = plot_vs_z( + z_range=(6.0, 10.0), out=OUTPUT_DIR / "test_lib_plot_vs_z_with_z_range.png" + ) + assert fig is not None + + +def test_lib_plot_vs_z_with_delta_squared_range(): + """Test making a plot_vs_z with delta_squared range filtering.""" + fig = plot_vs_z( + delta_squared_range=(1e3, 1e5), + out=OUTPUT_DIR / "test_lib_plot_vs_z_with_delta_squared_range.png", + ) + assert fig is not None + + +def test_lib_plot_vs_z_with_specific_limits(): + """Test making a plot_vs_z with specific limits.""" + limits = ["HERA2022", "HERA2023", "Paciga2013"] + fig = plot_vs_z( + limits=limits, + out=OUTPUT_DIR / "test_lib_plot_vs_z_with_specific_limits.png", + ) + assert fig is not None + + +def test_lib_plot_vs_z_with_aspoints(): + """Test making a plot_vs_z with specific limits as points.""" + limits = list(KNOWN_LIMITS.keys())[:5] + fig = plot_vs_z( + limits=limits, + aspoints=limits, + out=OUTPUT_DIR / "test_lib_plot_vs_z_with_aspoints.png", + ) + assert fig is not None + + +def test_lib_plot_vs_z_with_aslines(): + """Test making a plot_vs_z with specific limits as lines.""" + limits = list(KNOWN_LIMITS.keys())[:5] + fig = plot_vs_z( + limits=limits, + aslines=limits, + out=OUTPUT_DIR / "test_lib_plot_vs_z_with_aslines.png", + ) + assert fig is not None + + +def test_lib_plot_vs_z_with_bold_limits(): + """Test making a plot_vs_z with bolded specific limits.""" + limits = ["HERA2022", "HERA2023", "Paciga2013"] + bold_limits = ["HERA2022", "HERA2023"] + fig = plot_vs_z( + limits=limits, + bold_limits=bold_limits, + out=OUTPUT_DIR / "test_lib_plot_vs_z_with_bold_limits.png", + ) + assert fig is not None + + +def test_lib_plot_vs_z_with_bold_theories(): + """Test making a plot_vs_z with bolded specific theories.""" + theories = list(KNOWN_THEORIES.keys()) + bold_theories = ["Mesinger2016Faint", "Mesinger2016Bright"] + fig = plot_vs_z( + theories=theories, + bold_theories=bold_theories, + out=OUTPUT_DIR / "test_lib_plot_vs_z_with_bold_theories.png", + ) + assert fig is not None + + +def test_lib_plot_vs_z_with_limit_styling(): + """Test making a plot_vs_z with custom styling for limits.""" + limits = ["HERA2022", "HERA2023", "Paciga2013"] + fig = plot_vs_z( + limits=limits, + base_limit_style={"linewidth": 3, "alpha": 0.8}, + limit_styles={"HERA2023": {"color": "C3"}}, + out=OUTPUT_DIR / "test_lib_plot_vs_z_with_limit_styling.png", + ) + assert fig is not None + + +def test_lib_plot_vs_z_with_limit_and_theory_styling(): + """Test making a plot_vs_z with custom styling for limits and theories.""" + limits = ["HERA2022", "HERA2023", "Paciga2013"] + theories = ["Mesinger2016Faint", "Mesinger2016Bright"] + fig = plot_vs_z( + limits=limits, + theories=theories, + base_limit_style={"linewidth": 3, "alpha": 0.8}, + limit_styles={"HERA2023": {"color": "C3"}}, + base_theory_style={"linestyle": "-."}, + theory_styles={ + "Mesinger2016Faint": { + "color": "C0", + "shade_alpha": 0.5, + "shade_color": "C2", + }, + "Mesinger2016Bright": { + "color": "C1", + "shade_alpha": 0.1, + "shade_color": "C2", + }, + }, + out=OUTPUT_DIR / "test_lib_plot_vs_z_with_limit_and_theory_styling.png", + ) + assert fig is not None + + +def test_lib_plot_vs_z_with_legend_labeler(): + """Test making a plot_vs_z with custom legend labels.""" + fig = plot_vs_z( + limits=["HERA2022", "HERA2023", "Paciga2013"], + legend_labeler={"HERA2023": "HERA 2023"}, + out=OUTPUT_DIR / "test_lib_plot_vs_z_with_legend_labeler.png", + ) + + legend = fig.axes[0].get_legend() + assert fig is not None + assert legend is not None + assert [text.get_text() for text in legend.get_texts()] == ["HERA 2023"] + + +def test_lib_plot_vs_z_with_k_labels_in_title(): + """Test making a plot_vs_z with the k range in the title.""" + fig = plot_vs_z( + k_labels="title", + out=OUTPUT_DIR / "test_lib_plot_vs_z_with_k_labels_in_title.png", + ) + + legend = fig.axes[0].get_legend() + assert fig is not None + assert legend is not None + assert fig.axes[0].get_title().startswith(r"$k\sim") + assert all("h/Mpc" not in text.get_text() for text in legend.get_texts()) diff --git a/uv.lock b/uv.lock index 5629799..65eb731 100644 --- a/uv.lock +++ b/uv.lock @@ -585,16 +585,20 @@ source = { editable = "." } dependencies = [ { name = "cattrs" }, { name = "cyclopts" }, + { name = "h5py" }, { name = "matplotlib" }, { name = "numpy" }, { name = "pandas" }, { name = "pyyaml" }, { name = "rich" }, + { name = "scipy" }, ] [package.dev-dependencies] dev = [ { name = "coverage" }, + { name = "ipykernel" }, + { name = "pip" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -614,16 +618,20 @@ docs = [ requires-dist = [ { name = "cattrs", specifier = ">=25.3.0" }, { name = "cyclopts", specifier = ">=4.5.1" }, + { name = "h5py", specifier = ">=3.15.1" }, { name = "matplotlib", specifier = ">=3.10.8" }, { name = "numpy", specifier = ">=2.4.1" }, { name = "pandas", specifier = ">=3.0.0" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "rich", specifier = ">=14.3.1" }, + { name = "scipy", specifier = ">=1.14.0" }, ] [package.metadata.requires-dev] dev = [ { name = "coverage", specifier = ">=7.13.2" }, + { name = "ipykernel", specifier = ">=7.3.0" }, + { name = "pip", specifier = ">=26.1.2" }, { name = "pre-commit", specifier = ">=4.5.1" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=7.0.0" }, @@ -1837,6 +1845,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] +[[package]] +name = "pip" +version = "26.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" }, +] + [[package]] name = "platformdirs" version = "4.10.0" @@ -2386,6 +2403,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" }, ] +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + [[package]] name = "send2trash" version = "2.1.0"