From de5b8546b32a22a8c31b867422d82f703be303f9 Mon Sep 17 00:00:00 2001 From: Henrik Jacobsen Date: Mon, 17 Aug 2026 21:31:07 +0200 Subject: [PATCH 1/5] Add Bayesian posterior sampling to Analysis1d (#237) * Add Bayesian posterior sampling to Analysis1d Expose the EasyScience Fitter on Analysis1d and add MCMC posterior sampling on top of it, using the BUMPS DREAM sampler introduced in easyscience 2.5.1 (easyscience.fitting.Sampler). Least-squares fitting reports a single point with a curvature-derived uncertainty, which is only trustworthy when parameters are uncorrelated and roughly Gaussian. Sampling maps the whole posterior instead, so correlated and skewed parameters get honest credible intervals. The sampling machinery lives in a mixin with three hooks (build the fitter, bind the data, list the chain parameters) so that Analysis and ParameterAnalysis can reuse it. ParameterAnalysis is not an AnalysisBase and builds a MultiFitter over binding models rather than over itself, so a shared base class would not have worked. Notable details: - fit() now uses a cached Fitter instead of building one per call, and the cache is invalidated through the existing dirty-flag pattern. - Bounds are the prior in DREAM, so sampling refuses to run with any infinite bound. suggest_bounds() proposes finite ones from the fitted values and uncertainties; it is advisory until .apply() is called and never loosens a bound that is already finite, so physical limits survive. A zero-width suggestion is flagged rather than invented. - Sampling restores parameter values afterwards, since BUMPS leaves them wherever the last likelihood evaluation put them. - Chains are reported under Parameter.name, not the internal unique_name. Those names are per-session, so save_chain() writes a sidecar mapping them to stable names and load_chain() uses it; loading without one warns rather than mislabelling the columns. - After sampling, a warning fires when the posterior has piled up against a bound, which catches both bounds that are too tight and degenerate parameters that drift until a bound stops them. - BUMPS crashes with a bare IndexError inside its own outlier removal when chains scatter, which in practice means a degenerate model. That is re-raised with the likely cause and a workaround. Co-Authored-By: Claude Opus 5 (1M context) * Compose the posterior sampler instead of mixing it in Review feedback: bayesian_sampling.py had a lot in it that belonged elsewhere, and it was unclear why it was a mixin at all. It was a mixin because ParameterAnalysis is not an AnalysisBase and fits its binding models rather than itself, so a shared base class does not work. That was a reason, not a good one: it injected some forty methods into every Analysis class. The sampler is now composed. An Analysis exposes one `bayesian` property, and hands the sampler the few things that differ between the Analysis classes -- the data, the free parameters, their labels, and a hook to refresh cached computation -- so PosteriorSampler needs no knowledge of how any Analysis is built, and no Analysis inherits sampling machinery it does not use. Labelling moves to posterior_labels.py. Building it once for a fixed set of parameters also removes the quadratic cost the old code needed a scoped cache to avoid: the counts and lookups are computed in the constructor rather than per column. Plotting stays in posterior_plotting.py, where it already lived. The sampler keeps three short delegates so a chain can still be plotted from the object holding it, but none of the drawing happens there. The public API becomes analysis.bayesian.sample() and friends, and the explicit suggest_bounds().apply() step stays: in DREAM the bounds are the prior, and an unbounded parameter gives a confident-looking interval set by nothing. Co-Authored-By: Claude Opus 5 (1M context) * Warm the tutorial data cache before running notebooks in parallel The notebook tests run with '-n auto', and five of the notebooks fetch vanadium_data_example.h5 through pooch. On a cold cache the workers race: one is still writing the file into the cache while another opens it, which fails on Windows with "PermissionError: Permission denied". This failed twice in a row on windows-latest, always on that file, always with the other sixteen notebooks passing. The race is pre-existing, but adding a fifth notebook that wants the same file, and lengthening tutorial 1, made it reliable rather than rare. Fetching every tutorial data file once, before the parallel run starts, leaves the workers with nothing to do but read, which is safe. The prefetch reads the URLs and hashes out of the notebooks themselves, so it cannot drift from what they actually download, and it never fails the run: a file it cannot fetch is left to the notebook that needs it, which reports the problem with far more context. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 46d745a4a73e8025c37e02e490fab67cfdb5ff22) * Mark setup, action and expectation apart in the new tests The sampling tests labelled the action WHEN and had no THEN, so a reader could not see where the arrangement stopped and the call under test began. Setup is WHEN, the action is THEN, the assertions are EXPECT, and steps that genuinely collapse onto one statement carry one combined marker instead. Comments only; no test changed what it does. Co-Authored-By: Claude Opus 5 (1M context) * Give the sampler its own test file Tests were split by feature rather than by the file they exercise, so posterior_sampling.py had no test file of its own and Analysis1d had two. The sampler's tests now live in test_posterior_sampling.py under one TestPosteriorSampler, with the old class names as section banners, and the four tests that are really about Analysis1d's cached fitter move into TestAnalysis1d. No test changed what it does; the same 31 + 4 tests run as before. Co-Authored-By: Claude Opus 5 (1M context) * Refuse silent chain corruption and harden the posterior sampler - extend() now verifies the chain holds the same parameters, not just the same number, and refuses to resume after a failed run or after the model or data changed - Parameter objects passed to sample(parameters=...) are validated against the free set the same way strings are - sampling with no free parameters and degenerate (min >= max) bounds raise clear errors before reaching BUMPS - parameters_at_bounds keys by unique_name so same-named per-Q parameters no longer collide, and guards empty draws - suggest_bounds flags non-finite fitted uncertainties for attention - save() refuses to write an empty label sidecar; loading one warns like a missing sidecar - colliding display labels get positional suffixes in the sidecar so save/load resolves each column to its own parameter - plot_posterior_predictive omits error bars when the data carries no variances (new Experiment.has_variances) - posterior plots validate draws/logp up front, name NaN columns, and share x-limits per corner column - document that sampling runs are not seedable Co-Authored-By: Claude Fable 5 * Add marginal posteriors, correlation heatmaps and sampling progress - plot_marginal(parameter) renders one parameter's posterior histogram with the median and the 16/84 percentile interval summary() reports, resolving labels the same way sample(parameters=...) does - plot_correlations() renders the Pearson correlation matrix of the chain with annotated cells, a diverging colormap and masked cells for constant columns - sample(progress=True) and extend(progress=True) report sampling progress through the Sampler's progress_callback, closing the line with an explicit done marker because BUMPS' own step estimate assumes the wrong chain count - the 95 percent predictive band needed no change: credible_interval already exists on plot_posterior_predictive Co-Authored-By: Claude Fable 5 * Write the progress line through sys.stdout Co-Authored-By: Claude Fable 5 * Apply the formatting fixes Co-Authored-By: Claude Fable 5 * Satisfy the docstring and formatting checks The progress reporter closes through try/finally instead of a bare re-raise, and the plotting validation errors are documented in the form the docstring linter expects. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 5 (1M context) --- docs/docs/tutorials/bayesian.ipynb | 306 ++++ docs/docs/tutorials/index.md | 3 + docs/mkdocs.yml | 1 + pixi.lock | 3 +- pixi.toml | 8 +- pyproject.toml | 23 +- src/easydynamics/analysis/__init__.py | 12 + src/easydynamics/analysis/analysis1d.py | 137 +- src/easydynamics/analysis/posterior.py | 668 +++++++++ src/easydynamics/analysis/posterior_labels.py | 200 +++ .../analysis/posterior_sampling.py | 1278 +++++++++++++++++ src/easydynamics/experiment/experiment.py | 15 + src/easydynamics/utils/__init__.py | 11 +- src/easydynamics/utils/posterior_plotting.py | 592 ++++++++ .../fitting/test_bayesian_sampling.py | 234 +++ .../easydynamics/analysis/test_analysis1d.py | 72 + .../easydynamics/analysis/test_posterior.py | 366 +++++ .../analysis/test_posterior_labels.py | 149 ++ .../analysis/test_posterior_sampling.py | 907 ++++++++++++ .../experiment/test_experiment.py | 13 + .../utils/test_posterior_plotting.py | 352 +++++ tools/prefetch_tutorial_data.py | 92 ++ 22 files changed, 5415 insertions(+), 27 deletions(-) create mode 100644 docs/docs/tutorials/bayesian.ipynb create mode 100644 src/easydynamics/analysis/posterior.py create mode 100644 src/easydynamics/analysis/posterior_labels.py create mode 100644 src/easydynamics/analysis/posterior_sampling.py create mode 100644 src/easydynamics/utils/posterior_plotting.py create mode 100644 tests/integration/fitting/test_bayesian_sampling.py create mode 100644 tests/unit/easydynamics/analysis/test_posterior.py create mode 100644 tests/unit/easydynamics/analysis/test_posterior_labels.py create mode 100644 tests/unit/easydynamics/analysis/test_posterior_sampling.py create mode 100644 tests/unit/easydynamics/utils/test_posterior_plotting.py create mode 100644 tools/prefetch_tutorial_data.py diff --git a/docs/docs/tutorials/bayesian.ipynb b/docs/docs/tutorials/bayesian.ipynb new file mode 100644 index 000000000..51a68acd2 --- /dev/null +++ b/docs/docs/tutorials/bayesian.ipynb @@ -0,0 +1,306 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "eac0b8bb", + "metadata": {}, + "source": [ + "# Bayesian analysis\n", + "\n", + "Fitting with `fit()` finds the single set of parameter values that best matches the data, and reports an uncertainty derived from the curvature of $\\chi^2$ at that point. That uncertainty is only trustworthy when the parameters are uncorrelated and their uncertainties are close to Gaussian, which in QENS is often not the case.\n", + "\n", + "A **Bayesian** analysis answers a different question: instead of one best point, it maps out the whole *posterior distribution* over the parameters. From that you can read off credible intervals that stay honest when parameters are correlated or their distributions are skewed, and you can see the correlations directly.\n", + "\n", + "EasyDynamics does this with the DREAM sampler from [BUMPS](https://bumps.readthedocs.io/), through `bayesian.sample()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "02cb7aec", + "metadata": {}, + "outputs": [], + "source": [ + "import pooch\n", + "\n", + "import easydynamics as edyn\n", + "import easydynamics.sample_model as sm\n", + "from easydynamics.analysis.analysis1d import Analysis1d\n", + "\n", + "%matplotlib inline" + ] + }, + { + "cell_type": "markdown", + "id": "0499fea7", + "metadata": {}, + "source": [ + "## Load the data\n", + "\n", + "We use the same artificial vanadium measurement as the [Analysis 1D](analysis1d.ipynb) tutorial, and analyse a single Q slice." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fb407621", + "metadata": {}, + "outputs": [], + "source": [ + "vanadium_experiment = edyn.Experiment('Vanadium')\n", + "\n", + "file_path = pooch.retrieve(\n", + " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/master/docs/docs/tutorials/data/vanadium_data_example.h5',\n", + " known_hash='16cc1b327c303feeb88fb9dda5390dc4880b62396b1793f98c6fef0b27c7b873',\n", + ")\n", + "\n", + "vanadium_experiment.load_hdf5(filename=file_path)" + ] + }, + { + "cell_type": "markdown", + "id": "fcdfd395", + "metadata": {}, + "source": [ + "## Build the model and fit it\n", + "\n", + "As in [Tutorial 1](tutorial1_brownian.ipynb), a vanadium measurement is modelled with the Gaussian as the *sample*: what is being measured is the resolution function itself, so there is nothing to convolve it with.\n", + "\n", + "Sampling does not require a fit first, but it benefits from one: DREAM starts its chains in a small ball around the parameters' current values, so beginning from fitted values means less burn-in is needed before the chains reach the interesting region." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "de3297cf", + "metadata": {}, + "outputs": [], + "source": [ + "vanadium_components = sm.ComponentCollection()\n", + "vanadium_components.append_component(sm.Gaussian(width=0.1, area=1, name='Res. Gauss'))\n", + "\n", + "instrument_model = sm.InstrumentModel(\n", + " background_model=sm.BackgroundModel(components=sm.Polynomial(coefficients=[0.001])),\n", + ")\n", + "\n", + "analysis = Analysis1d(\n", + " display_name='Vanadium Analysis',\n", + " experiment=vanadium_experiment,\n", + " sample_model=sm.SampleModel(components=vanadium_components),\n", + " instrument_model=instrument_model,\n", + " Q_index=5,\n", + ")\n", + "\n", + "fit_result = analysis.fit()\n", + "print(f'reduced chi-squared = {fit_result.reduced_chi2:.4f}')" + ] + }, + { + "cell_type": "markdown", + "id": "b0a709f7", + "metadata": {}, + "source": [ + "## Bounds are the prior\n", + "\n", + "In DREAM, each parameter's `min` and `max` define a uniform prior, so **every free parameter must have finite bounds** before sampling. Most parameters start with at least one infinite bound, so `bayesian.sample()` would refuse to run.\n", + "\n", + "`bayesian.suggest_bounds()` proposes bounds from the fitted values and uncertainties. It is advisory: it changes nothing until you call `.apply()`, and it only ever fills in an *infinite* bound, so physical limits you have already set (an area that cannot go below zero, say) are left alone." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a004cd83", + "metadata": {}, + "outputs": [], + "source": [ + "suggestions = analysis.bayesian.suggest_bounds()\n", + "print(suggestions)" + ] + }, + { + "cell_type": "markdown", + "id": "ab263f41", + "metadata": {}, + "source": [ + "The defaults are deliberately generous — 10 standard deviations plus 20% of the value. Because the bounds are a uniform prior, being too *narrow* is the dangerous mistake: it truncates the posterior and makes the uncertainty look smaller than it is. The 20% term is there for parameters whose fitted uncertainty comes back as zero. All three settings (`n_sigma`, `relative_pad`, `absolute_floor`) can be adjusted, and you can always set `min` and `max` by hand.\n", + "\n", + "It is worth reading the table before applying it. A suggestion many orders of magnitude larger than the parameter itself is a useful warning sign: it means the fit returned a huge uncertainty, which usually happens because two parameters are **degenerate** — the data determines only some combination of them, so one can grow while the other shrinks with no effect on the fit. That is a problem to fix in the model, not with the sampler." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1a92a899", + "metadata": {}, + "outputs": [], + "source": [ + "changed = suggestions.apply()\n", + "print(f'Applied bounds to: {[parameter.name for parameter in changed]}')" + ] + }, + { + "cell_type": "markdown", + "id": "8cf71e53", + "metadata": {}, + "source": [ + "## Sample the posterior\n", + "\n", + "`bayesian.sample()` runs the chains. The three numbers that matter are:\n", + "\n", + "- `samples` — how many draws to collect in total. More is better, at linear cost.\n", + "- `burn` — generations discarded at the start, while the chains are still travelling towards the bulk of the posterior.\n", + "- `thin` — keep only every n-th generation, which reduces the correlation between neighbouring draws.\n", + "\n", + "Sampling never moves your parameters: their values are restored afterwards, so the model is left exactly as the fit left it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "861fa264", + "metadata": {}, + "outputs": [], + "source": [ + "results = analysis.bayesian.sample(samples=4000, burn=300, thin=2)\n", + "\n", + "print(f'Collected {results.draws.shape[0]} draws for {results.draws.shape[1]} parameters.')" + ] + }, + { + "cell_type": "markdown", + "id": "f2ce5232", + "metadata": {}, + "source": [ + "## Did the chains converge?\n", + "\n", + "Always look at the traces before trusting the numbers. A converged chain looks like a \"hairy caterpillar\": noisy, but flat and stationary. A visible drift or slow wander means the chain has not settled and needs a longer burn-in or more samples." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b2fe638c", + "metadata": {}, + "outputs": [], + "source": [ + "analysis.bayesian.plot_trace()" + ] + }, + { + "cell_type": "markdown", + "id": "2b3ea7b4", + "metadata": {}, + "source": [ + "## Summarize the posterior\n", + "\n", + "`bayesian.summary()` reports the median and the 68% credible interval of each parameter, under the parameter's own name and unit. The interval is asymmetric in general, which is precisely the information a single symmetric error bar throws away." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8470375e", + "metadata": {}, + "outputs": [], + "source": [ + "analysis.bayesian.summary()" + ] + }, + { + "cell_type": "markdown", + "id": "60b4a448", + "metadata": {}, + "source": [ + "## Correlations between parameters\n", + "\n", + "The corner plot is the part least available from a least-squares fit. The diagonal shows each parameter's own distribution; each off-diagonal panel shows a pair. A round blob means the two are independent, while a tilted, narrow ridge means they are correlated and the data constrains only a combination of them." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c96509cc", + "metadata": {}, + "outputs": [], + "source": [ + "analysis.bayesian.plot_corner()" + ] + }, + { + "cell_type": "markdown", + "id": "8f819c75", + "metadata": {}, + "source": [ + "## Does the model actually describe the data?\n", + "\n", + "The posterior predictive plot re-evaluates the model for a sample of posterior draws and shades the region they cover. If the data wanders outside the band in a systematic way, the model is missing a feature, and no amount of parameter tuning will fix it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cce95199", + "metadata": {}, + "outputs": [], + "source": [ + "analysis.bayesian.plot_posterior_predictive(n_draws=100)" + ] + }, + { + "cell_type": "markdown", + "id": "5947da0f", + "metadata": {}, + "source": [ + "## Continuing and storing a chain\n", + "\n", + "If the traces suggest the chain needs to run longer, `extend_sampling()` continues the existing chain rather than starting over, so nothing already computed is thrown away." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "974e486b", + "metadata": {}, + "outputs": [], + "source": [ + "extended = analysis.bayesian.extend(additional_samples=1000, thin=2)\n", + "print(f'Chain now holds {extended.draws.shape[0]} draws.')" + ] + }, + { + "cell_type": "markdown", + "id": "69793d31", + "metadata": {}, + "source": [ + "Chains are expensive, so they can be saved and reloaded with `analysis.bayesian.save(path)` and `analysis.bayesian.load(path)`. A reloaded chain can be summarized, plotted, or extended further, exactly like a fresh one." + ] + }, + { + "cell_type": "markdown", + "id": "a3448aee", + "metadata": {}, + "source": [ + "## Things to watch out for\n", + "\n", + "**Data without uncertainties.** If your data carries no variances, the weights fall back to 1, which means the sampler assumes a noise level of 1 in whatever units the intensity happens to be. Least-squares does not care, since that scale cancels out of the best-fit position, but a posterior *does*: its width scales directly with the assumed noise, so the credible intervals will be wrong by whatever factor the true noise differs from 1. Bayesian analysis is not a way to avoid needing uncertainties on your data.\n", + "\n", + "**Sampling only some parameters.** `bayesian.sample(parameters=[...])` restricts the chain to a subset, which is faster because the number of chains scales with the number of parameters. Be careful with the result: the other parameters are held *fixed*, which is not the same as averaging over them. The intervals you get are conditional on those fixed values, and will be too narrow whenever the parameters are correlated.\n", + "\n", + "**Degenerate parameters.** As seen above, they are a modelling problem rather than a sampling one. `bayesian.suggest_bounds()` returning absurd values, or a warning that the posterior has piled up against its bounds, are both signs to go back and look at the model." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "default", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/docs/tutorials/index.md b/docs/docs/tutorials/index.md index 0617edb2a..23a36adc5 100644 --- a/docs/docs/tutorials/index.md +++ b/docs/docs/tutorials/index.md @@ -62,3 +62,6 @@ tutorials. - [Analysis](analysis.ipynb) - Learn how to fit a model to your data. - [Analysis 1D](analysis1d.ipynb) - Learn how to fit a model to your data at a particular Q. +- [Bayesian analysis](bayesian.ipynb) - Learn how to map out the full + posterior distribution of your parameters, including their + correlations, instead of a single best-fit point. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 64e94f967..3db04ace0 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -217,6 +217,7 @@ nav: - Experiment: tutorials/experiment.ipynb - Analysis: tutorials/analysis.ipynb - Analysis 1D: tutorials/analysis1d.ipynb + - Bayesian analysis: tutorials/bayesian.ipynb - API Reference: - API Reference: api-reference/index.md - analysis: api-reference/analysis.md diff --git a/pixi.lock b/pixi.lock index d1ebb0ed7..f07f02cf8 100644 --- a/pixi.lock +++ b/pixi.lock @@ -7535,12 +7535,13 @@ packages: name: easydynamics requires_dist: - darkdetect - - easyscience + - easyscience>=2.5.1 - ipykernel - ipympl - ipython - ipywidgets - jupyterlab + - matplotlib - pixi-kernel - plopp - pooch diff --git a/pixi.toml b/pixi.toml index f26b4fb7e..db46523e1 100644 --- a/pixi.toml +++ b/pixi.toml @@ -100,7 +100,13 @@ user = { features = ['py-max', 'user'] } unit-tests = 'python -m pytest tests/unit/ --color=yes -v' functional-tests = 'python -m pytest tests/functional/ --color=yes -v' integration-tests = 'python -m pytest tests/integration/ --color=yes -n auto -v' -notebook-tests = 'python -m pytest --nbmake docs/docs/tutorials/ --nbmake-timeout=1200 --color=yes -n auto -v' +# Warm the pooch cache first. Several notebooks fetch the same file, and running them with +# '-n auto' has the workers race: one writes the file while another opens it, which fails on +# Windows. Fetching up front leaves the parallel run with nothing to do but read. +prefetch-tutorial-data = 'python tools/prefetch_tutorial_data.py' +notebook-tests = { cmd = 'python -m pytest --nbmake docs/docs/tutorials/ --nbmake-timeout=1200 --color=yes -n auto -v', depends-on = [ + 'prefetch-tutorial-data', +] } test = { depends-on = ['unit-tests'] } diff --git a/pyproject.toml b/pyproject.toml index b39261f87..3330304ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,17 +23,18 @@ classifiers = [ ] requires-python = '>=3.12' dependencies = [ - 'easyscience', # The base library of the EasyScience framework - 'pooch', # Data downloader - 'darkdetect', # Detecting dark mode (system-level) - 'plopp', # Plotting library - 'jupyterlab', # Jupyter notebooks - 'pixi-kernel', # Pixi Jupyter kernel - 'ipykernel', # Jupyter kernel (required for running notebooks) - 'ipywidgets', # Widgets (needed for interactive matplotlib backends) - 'ipympl', # Matplotlib Jupyter widget backend (%matplotlib widget) - 'IPython', # Interactive Python shell - 'sympy', # Symbolic mathematics (used for expression components) + 'easyscience>=2.5.1', # The base library of the EasyScience framework. 2.5.1 adds fitting.Sampler + 'matplotlib', # Plotting (posterior trace, corner, and predictive plots) + 'pooch', # Data downloader + 'darkdetect', # Detecting dark mode (system-level) + 'plopp', # Plotting library + 'jupyterlab', # Jupyter notebooks + 'pixi-kernel', # Pixi Jupyter kernel + 'ipykernel', # Jupyter kernel (required for running notebooks) + 'ipywidgets', # Widgets (needed for interactive matplotlib backends) + 'ipympl', # Matplotlib Jupyter widget backend (%matplotlib widget) + 'IPython', # Interactive Python shell + 'sympy', # Symbolic mathematics (used for expression components) ] [project.optional-dependencies] diff --git a/src/easydynamics/analysis/__init__.py b/src/easydynamics/analysis/__init__.py index 289ec02f5..89126ecdf 100644 --- a/src/easydynamics/analysis/__init__.py +++ b/src/easydynamics/analysis/__init__.py @@ -3,8 +3,20 @@ from easydynamics.analysis.analysis import Analysis from easydynamics.analysis.parameter_analysis import ParameterAnalysis +from easydynamics.analysis.posterior import BoundsSuggestion +from easydynamics.analysis.posterior import BoundsSuggestions +from easydynamics.analysis.posterior import ParameterPosterior +from easydynamics.analysis.posterior import PosteriorSummary +from easydynamics.analysis.posterior_labels import ParameterLabels +from easydynamics.analysis.posterior_sampling import PosteriorSampler __all__ = [ 'Analysis', + 'BoundsSuggestion', + 'BoundsSuggestions', 'ParameterAnalysis', + 'ParameterLabels', + 'ParameterPosterior', + 'PosteriorSampler', + 'PosteriorSummary', ] diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index e50b09737..5c8a68be3 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -12,6 +12,8 @@ from plopp.backends.matplotlib.figure import InteractiveFigure from easydynamics.analysis.analysis_base import AnalysisBase +from easydynamics.analysis.posterior_labels import ParameterLabels +from easydynamics.analysis.posterior_sampling import PosteriorSampler from easydynamics.convolution.convolution import Convolution from easydynamics.experiment import Experiment from easydynamics.sample_model import InstrumentModel @@ -31,6 +33,10 @@ class Analysis1d(AnalysisBase): Is used primarily in the Analysis class, but can also be used on its own for simpler analyses. + Besides least-squares fitting with :meth:`fit`, the posterior distribution of the free + parameters can be explored through :attr:`bayesian`; see + :class:`~easydynamics.analysis.posterior_sampling.PosteriorSampler`. + Examples -------- **Fitting a single Q slice** @@ -116,6 +122,9 @@ def __init__( self._fit_result = None self._convolver = None self._convolver_is_dirty = True + self._fitter = None + self._fitter_is_dirty = True + self._bayesian = None super().__init__( display_name=display_name, @@ -245,27 +254,122 @@ def fit(self) -> FitResults: if self._experiment is None: raise ValueError('No experiment is associated with this Analysis.') - if ( - self.sample_model.component_collections_is_dirty - or self.instrument_model.resolution_model.component_collections_is_dirty - ): - self._convolver_is_dirty = True + self._prepare_for_sampling() - self._ensure_convolver_current() + x, y, weights = self._sampling_data() + fit_result = self.fitter.fit(x=x, y=y, weights=weights) - fitter = EasyScienceFitter( - fit_object=self, - fit_function=self.as_fit_function(), - ) + self._fit_result = fit_result + + return fit_result + + @property + def fitter(self) -> EasyScienceFitter: + """ + The EasyScience Fitter used for fitting and sampling, built on first use. + Exposed so the minimizer, tolerance, and maximum evaluation count can be configured + directly, e.g. ``analysis.fitter.switch_minimizer(AvailableMinimizers.Bumps)``. + + Returns + ------- + EasyScienceFitter + The cached Fitter. + """ + if self._fitter_is_dirty or self._fitter is None: + self._fitter = EasyScienceFitter( + fit_object=self, + fit_function=self.as_fit_function(), + ) + self._fitter_is_dirty = False + return self._fitter + + @property + def bayesian(self) -> PosteriorSampler: + """ + Bayesian posterior sampling for this Analysis, created on first use. + + Returns + ------- + PosteriorSampler + The sampler, which holds any chain that has been run. + """ + if self._bayesian is None: + self._bayesian = PosteriorSampler( + analysis=self, + sampling_data=self._sampling_data, + chain_parameters=self._chain_parameters, + parameter_labels=self._parameter_labels, + prepare=self._prepare_for_sampling, + ) + return self._bayesian + + def _invalidate_fitter(self) -> None: + """Mark the Fitter, and the Sampler built from it, as needing a rebuild.""" + self._fitter_is_dirty = True + self._invalidate_bayesian_sampler() + + def _invalidate_bayesian_sampler(self) -> None: + """Mark the Sampler as needing a rebuild, the data having changed.""" + if self._bayesian is not None: + self._bayesian.invalidate() + + ############# + # The contract PosteriorSampler relies on + ############# + + def _parameter_labels(self) -> ParameterLabels: + """ + Get labels for the chain's parameters. + + A single Q index holds one copy of each parameter, so nothing needs qualifying. + + Returns + ------- + ParameterLabels + Labels over the current free parameters. + """ + return ParameterLabels(self._chain_parameters()) + + def _sampling_data(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Get the finite data for the chosen Q index, as used by both fitting and sampling. + + Returns + ------- + tuple[np.ndarray, np.ndarray, np.ndarray] + The ``(x, y, weights)`` triple. + """ x, y, weights, _ = self.experiment.extract_x_y_weights_only_finite( Q_index=self._require_Q_index() ) - fit_result = fitter.fit(x=x, y=y, weights=weights) + return x, y, weights - self._fit_result = fit_result + def _chain_parameters(self) -> list[Parameter]: + """ + Get the free parameters of this Analysis. - return fit_result + Returns + ------- + list[Parameter] + The parameters that are free to vary, which are the ones the sampler explores. + """ + return self.get_free_parameters() + + def _prepare_for_sampling(self) -> None: + """ + Rebuild the convolver if anything it depends on has changed. + + The energy grid is fixed for the duration of a fit or a sampling run, so the convolution + objects are built once here and reused for every model evaluation. + """ + if ( + self.sample_model.component_collections_is_dirty + or self.instrument_model.resolution_model.component_collections_is_dirty + ): + self._convolver_is_dirty = True + + self._ensure_convolver_current() def as_fit_function( self, @@ -483,6 +587,7 @@ def rebin(self, dimensions: dict[str, int | sc.Variable]) -> None: if self._Q_index is not None and self.experiment is not None: self._masked_energy = self.experiment.get_masked_energy(Q_index=self._Q_index) self._convolver_is_dirty = True + self._invalidate_bayesian_sampler() def refresh_convolver(self, energy: sc.Variable | None = None) -> None: """Refresh the pre-built Convolution object for the current Q index.""" @@ -523,10 +628,13 @@ def _on_Q_index_changed(self) -> None: if self._Q_index is None: self._masked_energy = None self._convolver_is_dirty = True + self._invalidate_bayesian_sampler() return masked_energy = self.experiment.get_masked_energy(Q_index=self._Q_index) self._masked_energy = masked_energy self._convolver_is_dirty = True + # A different Q index means different data, and the Sampler binds its data at construction. + self._invalidate_bayesian_sampler() def _on_experiment_changed(self) -> None: """Mark the convolver as dirty when the experiment changes.""" @@ -535,16 +643,19 @@ def _on_experiment_changed(self) -> None: if self._Q_index is not None and self.experiment is not None: self._masked_energy = self.experiment.get_masked_energy(Q_index=self._Q_index) self._convolver_is_dirty = True + self._invalidate_bayesian_sampler() def _on_sample_model_changed(self) -> None: """Mark the convolver as dirty when the sample model changes.""" super()._on_sample_model_changed() self._convolver_is_dirty = True + self._invalidate_fitter() def _on_instrument_model_changed(self) -> None: """Mark the convolver as dirty when the instrument model changes.""" super()._on_instrument_model_changed() self._convolver_is_dirty = True + self._invalidate_fitter() def _on_convolution_settings_changed(self) -> None: """Mark the convolver as dirty when the convolution settings change.""" diff --git a/src/easydynamics/analysis/posterior.py b/src/easydynamics/analysis/posterior.py new file mode 100644 index 000000000..d8d65af77 --- /dev/null +++ b/src/easydynamics/analysis/posterior.py @@ -0,0 +1,668 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +""" +Bounds suggestions and posterior summaries for Bayesian sampling. + +The helpers here are deliberately free of any Analysis or Fitter machinery: they operate on plain +``Parameter`` objects and on the ``(n_draws, n_parameters)`` array produced by the sampler, so they +can be unit-tested on their own and reused by every Analysis class. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from easyscience.variable import Parameter + +# Fraction of the allowed range at each end that counts as "at the bound" when checking whether +# the posterior has piled up against a bound. +BOUND_EDGE_FRACTION = 0.05 + +# Fraction of draws inside those edge bands above which a pile-up is reported. A posterior spread +# uniformly across its bounds -- the signature of a bound, rather than the data, setting the +# credible interval -- puts 2 * BOUND_EDGE_FRACTION of its draws there. A posterior comfortably +# inside its bounds puts essentially none there, so the threshold sits well below the uniform value +# to stay sensitive to partly-clipped posteriors without risking false positives. +BOUND_OCCUPANCY_THRESHOLD = 0.05 + + +@dataclass(frozen=True) +class BoundsSuggestion: + """ + A proposed pair of bounds for a single parameter. + + Attributes + ---------- + parameter : Parameter + The parameter the suggestion applies to. + label : str + The name the parameter is reported under, qualified where several share a name. + suggested_min : float + The proposed lower bound. Equal to the parameter's current lower bound when that is already + finite. + suggested_max : float + The proposed upper bound. Equal to the parameter's current upper bound when that is already + finite. + reason : str + Empty when the suggestion is usable. Otherwise, why the parameter needs manual attention. + """ + + parameter: Parameter + label: str + suggested_min: float + suggested_max: float + reason: str + + @property + def needs_attention(self) -> bool: + """ + Whether this parameter could not be given a usable suggestion. + + Returns + ------- + bool + True when no usable bounds could be derived and the user must set them by hand. + """ + return bool(self.reason) + + @property + def changes_bounds(self) -> bool: + """ + Whether applying this suggestion would actually change the parameter. + + Returns + ------- + bool + True when either bound differs from the parameter's current bound. + """ + return self.suggested_min != self.parameter.min or self.suggested_max != self.parameter.max + + +class BoundsSuggestions: + """ + The result of :func:`suggest_bounds_for_parameters`, rendered as a table. + + This is advisory: nothing is changed until :meth:`apply` is called. Suggestions only ever fill + in an infinite bound; a bound that is already finite is never widened or narrowed, so physical + limits such as a non-negative area survive untouched. + """ + + def __init__(self, suggestions: list[BoundsSuggestion]) -> None: + """ + Initialize the collection. + + Parameters + ---------- + suggestions : list[BoundsSuggestion] + The per-parameter suggestions. + """ + self._suggestions = list(suggestions) + + @property + def suggestions(self) -> list[BoundsSuggestion]: + """ + All suggestions, including those needing manual attention. + + Returns + ------- + list[BoundsSuggestion] + The per-parameter suggestions. + """ + return list(self._suggestions) + + @property + def needing_attention(self) -> list[BoundsSuggestion]: + """ + The suggestions for which no usable bounds could be derived. + + Returns + ------- + list[BoundsSuggestion] + Suggestions whose parameters must be bounded by hand. + """ + return [s for s in self._suggestions if s.needs_attention] + + def apply(self) -> list[Parameter]: + """ + Set the suggested bounds on every parameter that has a usable suggestion. + + Parameters needing manual attention are skipped rather than guessed at. + + Returns + ------- + list[Parameter] + The parameters whose bounds were changed. + """ + changed = [] + for suggestion in self._suggestions: + if suggestion.needs_attention or not suggestion.changes_bounds: + continue + suggestion.parameter.min = suggestion.suggested_min + suggestion.parameter.max = suggestion.suggested_max + changed.append(suggestion.parameter) + return changed + + def __len__(self) -> int: + """ + Return the number of suggestions. + + Returns + ------- + int + The number of suggestions. + """ + return len(self._suggestions) + + def __iter__(self) -> iter: + """ + Iterate over the suggestions. + + Returns + ------- + iter + An iterator over the suggestions. + """ + return iter(self._suggestions) + + def __repr__(self) -> str: + """ + Render the suggestions as a table. + + Returns + ------- + str + A table of current and suggested bounds, one row per parameter. + """ + if not self._suggestions: + return 'BoundsSuggestions(no free parameters)' + + width = max(len('parameter'), *(len(s.label) for s in self._suggestions)) + header = f'{"parameter":<{width}s} {"current":>26s} {"suggested":>26s}' + lines = ['BoundsSuggestions', header, '-' * len(header)] + for s in self._suggestions: + current = f'({s.parameter.min:.4g}, {s.parameter.max:.4g})' + if s.needs_attention: + suggested = f'-- {s.reason}' + else: + suggested = f'({s.suggested_min:.4g}, {s.suggested_max:.4g})' + lines.append(f'{s.label:<{width}s} {current:>26s} {suggested:>26s}') + + attention = self.needing_attention + if attention: + lines.append('') + lines.append( + f'{len(attention)} parameter(s) need bounds set by hand; .apply() will skip them.' + ) + return '\n'.join(lines) + + +def suggest_bounds_for_parameters( + parameters: list[Parameter], + labels: list[str] | None = None, + n_sigma: float = 10.0, + relative_pad: float = 0.2, + absolute_floor: float | None = None, +) -> BoundsSuggestions: + """ + Propose finite bounds for parameters that currently have an infinite one. + + The half-width of a proposed bound is ``n_sigma * error + relative_pad * abs(value)``, floored + at ``absolute_floor`` when one is given. The ``relative_pad`` term matters because + least-squares minimizers sometimes report a zero or absurdly small uncertainty; without it such + a parameter would be given a zero-width bound. When the half-width still comes out as zero or + non-finite, the parameter is flagged for manual attention rather than given an invented scale. + + In BUMPS' DREAM sampler the bounds act as a uniform prior, so a generous width is the safe + choice: too narrow a bound truncates the posterior and understates the uncertainty. Hence the + deliberately loose ``n_sigma`` default. + + A ``TypeError`` is raised if any of the three settings is not a number, and a ``ValueError`` if + any is negative. + + Parameters + ---------- + parameters : list[Parameter] + The parameters to propose bounds for. + labels : list[str] | None, default=None + The name to report each parameter under, one per parameter. Defaults to the parameters' own + names. + n_sigma : float, default=10.0 + How many standard deviations of the parameter's fitted uncertainty to allow on each side. + relative_pad : float, default=0.2 + Extra half-width as a fraction of the absolute parameter value, guarding against + artificially small uncertainties. + absolute_floor : float | None, default=None + A minimum half-width, in the parameter's own units. Use it when the natural scale is known + but neither the uncertainty nor the value carries it. + + Returns + ------- + BoundsSuggestions + The proposed bounds, which must be applied explicitly. + """ + _verify_nonneg_number(n_sigma, 'n_sigma') + _verify_nonneg_number(relative_pad, 'relative_pad') + if absolute_floor is not None: + _verify_nonneg_number(absolute_floor, 'absolute_floor') + + if labels is None: + labels = [parameter.name for parameter in parameters] + suggestions = [ + _suggest_bounds_for_parameter( + parameter=parameter, + label=label, + n_sigma=n_sigma, + relative_pad=relative_pad, + absolute_floor=absolute_floor, + ) + for parameter, label in zip(parameters, labels, strict=True) + ] + return BoundsSuggestions(suggestions) + + +def _suggest_bounds_for_parameter( + parameter: Parameter, + label: str, + n_sigma: float, + relative_pad: float, + absolute_floor: float | None, +) -> BoundsSuggestion: + """ + Propose bounds for a single parameter. + + Parameters + ---------- + parameter : Parameter + The parameter to propose bounds for. + label : str + The name to report the parameter under. + n_sigma : float + How many standard deviations to allow on each side. + relative_pad : float + Extra half-width as a fraction of the absolute parameter value. + absolute_floor : float | None + A minimum half-width, or None. + + Returns + ------- + BoundsSuggestion + The proposal for this parameter. + """ + current_min = float(parameter.min) + current_max = float(parameter.max) + min_is_finite = np.isfinite(current_min) + max_is_finite = np.isfinite(current_max) + + # Nothing to fill in: a bound that is already finite is never touched. + if min_is_finite and max_is_finite: + return BoundsSuggestion( + parameter=parameter, + label=label, + suggested_min=current_min, + suggested_max=current_max, + reason='', + ) + + value = float(parameter.value) + error = float(parameter.error) + if not np.isfinite(value): + return BoundsSuggestion( + parameter=parameter, + label=label, + suggested_min=current_min, + suggested_max=current_max, + reason='value is not finite', + ) + + # A NaN uncertainty is exactly the degenerate fit this helper exists to guard against, so it + # is flagged rather than silently treated like a zero error, which would yield deceptively + # tight bounds of value +/- relative_pad * |value|. + if not np.isfinite(error): + return BoundsSuggestion( + parameter=parameter, + label=label, + suggested_min=current_min, + suggested_max=current_max, + reason='fitted uncertainty is not finite', + ) + + half_width = relative_pad * abs(value) + n_sigma * error + if absolute_floor is not None: + half_width = max(half_width, absolute_floor) + + if not np.isfinite(half_width) or half_width <= 0: + return BoundsSuggestion( + parameter=parameter, + label=label, + suggested_min=current_min, + suggested_max=current_max, + reason='no scale information (zero value and uncertainty)', + ) + + return BoundsSuggestion( + parameter=parameter, + label=label, + suggested_min=current_min if min_is_finite else value - half_width, + suggested_max=current_max if max_is_finite else value + half_width, + reason='', + ) + + +def unbounded_parameters(parameters: list[Parameter]) -> list[Parameter]: + """ + Find parameters with a non-finite lower or upper bound. + + Parameters + ---------- + parameters : list[Parameter] + The parameters to check. + + Returns + ------- + list[Parameter] + Those parameters that have at least one infinite bound. + """ + return [ + parameter + for parameter in parameters + if not (np.isfinite(parameter.min) and np.isfinite(parameter.max)) + ] + + +def degenerate_parameters(parameters: list[Parameter]) -> list[Parameter]: + """ + Find parameters whose finite bounds enclose no range at all. + + A zero-width range (``min >= max``) gives DREAM nothing to explore: as the prior it has zero + volume, and letting it through surfaces only as NaNs deep inside the sampler, far from the + cause. + + Parameters + ---------- + parameters : list[Parameter] + The parameters to check. + + Returns + ------- + list[Parameter] + Those parameters whose bounds are both finite with ``min >= max``. + """ + return [ + parameter + for parameter in parameters + if np.isfinite(parameter.min) + and np.isfinite(parameter.max) + and float(parameter.min) >= float(parameter.max) + ] + + +def parameters_at_bounds( + draws: np.ndarray, + parameters_by_column: list[Parameter | None], +) -> dict[str, float]: + """ + Find parameters whose posterior has piled up against one of its bounds. + + A chain that spends much of its time hard against a bound is a sign that the bound, rather than + the data, is setting the credible interval. That happens when a bound is too tight, and also + when two parameters are degenerate and the pair drifts until it is stopped by a bound. + + Parameters + ---------- + draws : np.ndarray + Posterior draws, shape ``(n_draws, n_parameters)``. + parameters_by_column : list[Parameter | None] + The parameter for each column of ``draws``, or None where no parameter could be matched. + + Returns + ------- + dict[str, float] + Mapping of the parameter's ``unique_name`` -- ``name`` is not used as the key because two + same-named parameters would collide -- to the fraction of draws sitting in the outer + ``BOUND_EDGE_FRACTION`` of its allowed range, for those parameters where that fraction + exceeds ``BOUND_OCCUPANCY_THRESHOLD``. The caller resolves the unique names back to + readable labels where the result is reported. + """ + if draws.shape[0] == 0: + return {} + piled_up = {} + for column, parameter in enumerate(parameters_by_column): + if parameter is None: + continue + low = float(parameter.min) + high = float(parameter.max) + if not (np.isfinite(low) and np.isfinite(high)) or high <= low: + continue + edge = BOUND_EDGE_FRACTION * (high - low) + values = draws[:, column] + at_edge = (values <= low + edge) | (values >= high - edge) + fraction = float(np.count_nonzero(at_edge)) / len(values) + if fraction > BOUND_OCCUPANCY_THRESHOLD: + piled_up[parameter.unique_name] = fraction + return piled_up + + +@dataclass(frozen=True) +class ParameterPosterior: + """ + The marginal posterior of a single parameter. + + Attributes + ---------- + name : str + The parameter's name. + unit : str + The parameter's unit, as a string. + median : float + The 50th percentile of the marginal posterior. + lower : float + The 16th percentile. + upper : float + The 84th percentile. + value : float + The parameter's current value, for comparison with the median. + """ + + name: str + unit: str + median: float + lower: float + upper: float + value: float + + @property + def minus(self) -> float: + """ + Distance from the median down to the 16th percentile. + + Returns + ------- + float + The lower half of the 68% credible interval. + """ + return self.median - self.lower + + @property + def plus(self) -> float: + """ + Distance from the median up to the 84th percentile. + + Returns + ------- + float + The upper half of the 68% credible interval. + """ + return self.upper - self.median + + +class PosteriorSummary: + """ + Marginal posterior summaries for every sampled parameter, rendered as a table. + """ + + def __init__(self, entries: list[ParameterPosterior]) -> None: + """ + Initialize the summary. + + Parameters + ---------- + entries : list[ParameterPosterior] + One entry per sampled parameter. + """ + self._entries = list(entries) + + @property + def entries(self) -> list[ParameterPosterior]: + """ + The per-parameter summaries. + + Returns + ------- + list[ParameterPosterior] + One entry per sampled parameter. + """ + return list(self._entries) + + def __len__(self) -> int: + """ + Return the number of summarized parameters. + + Returns + ------- + int + The number of entries. + """ + return len(self._entries) + + def __iter__(self) -> iter: + """ + Iterate over the entries. + + Returns + ------- + iter + An iterator over the entries. + """ + return iter(self._entries) + + def __getitem__(self, name: str) -> ParameterPosterior: + """ + Look up a parameter's summary by name. + + Parameters + ---------- + name : str + The parameter name. + + Returns + ------- + ParameterPosterior + The summary for that parameter. + + Raises + ------ + KeyError + If no sampled parameter has that name. + """ + for entry in self._entries: + if entry.name == name: + return entry + raise KeyError(f'No sampled parameter named {name!r}.') + + def __repr__(self) -> str: + """ + Render the summary as a table. + + Returns + ------- + str + A table with the median and 68% credible interval of each parameter. + """ + if not self._entries: + return 'PosteriorSummary(no parameters)' + + width = max(len('parameter'), *(len(e.name) for e in self._entries)) + header = ( + f'{"parameter":<{width}s} {"unit":>10s} {"median":>14s} ' + f'{"-":>12s} {"+":>12s} {"current":>14s}' + ) + lines = ['PosteriorSummary', header, '-' * len(header)] + lines.extend( + f'{e.name:<{width}s} {e.unit:>10s} {e.median:>14.5g} ' + f'{e.minus:>12.4g} {e.plus:>12.4g} {e.value:>14.5g}' + for e in self._entries + ) + return '\n'.join(lines) + + +def summarize_draws( + draws: np.ndarray, + labels: list[str], + parameters_by_column: list[Parameter | None], +) -> PosteriorSummary: + """ + Summarize posterior draws under the parameters' own names and units. + + The sampler labels its columns with each parameter's ``unique_name`` (``Parameter_4`` and the + like), which is not what a user recognises, so columns are reported under ``Parameter.name`` + wherever a parameter could be matched. + + Parameters + ---------- + draws : np.ndarray + Posterior draws, shape ``(n_draws, n_parameters)``. + labels : list[str] + The label to report each column under, one per column. + parameters_by_column : list[Parameter | None] + The parameter for each column of ``draws``, or None where none could be matched. + + Returns + ------- + PosteriorSummary + One entry per column of ``draws``, in column order. + """ + entries = [] + for column, parameter in enumerate(parameters_by_column): + lower, median, upper = ( + float(percentile) for percentile in np.percentile(draws[:, column], [16, 50, 84]) + ) + entries.append( + ParameterPosterior( + name=labels[column], + unit='' if parameter is None else str(parameter.unit), + median=median, + lower=lower, + upper=upper, + value=float('nan') if parameter is None else float(parameter.value), + ) + ) + return PosteriorSummary(entries) + + +def _verify_nonneg_number(value: object, name: str) -> None: + """ + Raise if a value is not a non-negative number. + + Parameters + ---------- + value : object + The object to verify. + name : str + The name of the object, for the error message. + + Raises + ------ + TypeError + If value is not an int or float. + ValueError + If value is negative. + """ + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise TypeError(f'{name} must be a number. Got {type(value)}.') + if value < 0: + raise ValueError(f'{name} must be non-negative. Got {value}.') diff --git a/src/easydynamics/analysis/posterior_labels.py b/src/easydynamics/analysis/posterior_labels.py new file mode 100644 index 000000000..fe406f706 --- /dev/null +++ b/src/easydynamics/analysis/posterior_labels.py @@ -0,0 +1,200 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +""" +Naming the columns of an MCMC chain. + +The sampler labels its columns with each parameter's ``unique_name`` -- ``Parameter_4`` and the +like -- which is not what a user recognises, and which is handed out per session so it does not +survive a saved chain either. This turns those columns back into readable labels. +""" + +from __future__ import annotations + +from collections import Counter +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + + from easyscience.variable import Parameter + + +class ParameterLabels: + """ + Readable labels and units for the columns of a chain. + + Built once for a fixed set of parameters, so the name counts and lookups are computed a single + time. Doing this per column instead is quadratic in the parameter count, which is seconds of + work for an analysis with many Q values. + + Parameters + ---------- + parameters : list[Parameter] + The parameters that can appear as columns. + qualify : Callable[[Parameter], str | None] | None, default=None + Returns a qualifier for a parameter whose name is shared with another, for example its Q + index. Only consulted when the bare name really is ambiguous, so an analysis with nothing + to disambiguate keeps its short names. Returning None leaves the name unqualified. + """ + + def __init__( + self, + parameters: list[Parameter], + qualify: Callable[[Parameter], str | None] | None = None, + ) -> None: + self._parameters = list(parameters) + self._qualify = qualify + self._counts = Counter(parameter.name for parameter in self._parameters) + self._by_unique_name = {p.unique_name: p for p in self._parameters} + # Display labels can still collide after qualification -- two same-named parameters with + # no qualifier, or one that declines. A colliding label cannot round-trip through a saved + # chain: both columns would silently resolve to whichever parameter was registered last. + # So the labels used as lookup keys, and written to the sidecar by name_map(), carry a + # deterministic positional suffix wherever they collide, while label() keeps the bare + # display name. + label_counts = Counter(self.label(p) for p in self._parameters) + occurrence: Counter = Counter() + self._storage_labels: dict[str, str] = {} + for p in self._parameters: + base = self.label(p) + if label_counts[base] > 1: + occurrence[base] += 1 + self._storage_labels[p.unique_name] = f'{base} [{occurrence[base]}]' + else: + self._storage_labels[p.unique_name] = base + self._by_label = {self._storage_labels[p.unique_name]: p for p in self._parameters} + + @property + def parameters(self) -> list[Parameter]: + """ + The parameters these labels describe. + + Returns + ------- + list[Parameter] + The parameters given at construction. + """ + return list(self._parameters) + + def label(self, parameter: Parameter) -> str: + """ + Get the label a parameter is reported under. + + Parameters + ---------- + parameter : Parameter + The parameter to label. + + Returns + ------- + str + The parameter's name, qualified only where that name is shared with another parameter. + """ + if self._counts[parameter.name] <= 1 or self._qualify is None: + return parameter.name + qualifier = self._qualify(parameter) + return parameter.name if qualifier is None else f'{parameter.name} ({qualifier})' + + def name_map(self) -> dict[str, str]: + """ + Map each parameter's ``unique_name`` to its label. + + Saved alongside a chain, because unique names are per-session: without this a reloaded + chain cannot be matched back to any parameter. Where two parameters share a display label, + the recorded labels carry a deterministic positional suffix (``width [1]``, ``width [2]``) + so each column can be matched back to exactly one parameter. + + Returns + ------- + dict[str, str] + Mapping of unique name to label, collision-free. + """ + return dict(self._storage_labels) + + def resolve( + self, + column_names: list[str], + saved_labels: dict[str, str] | None = None, + ) -> list[Parameter | None]: + """ + Match each column of a chain to a parameter. + + Columns are matched on ``unique_name`` first. That fails for a chain loaded from disk, + where the saved labels are used instead. + + Parameters + ---------- + column_names : list[str] + The sampler's name for each column. + saved_labels : dict[str, str] | None, default=None + Mapping of unique name to label, as recorded when a chain was saved. + + Returns + ------- + list[Parameter | None] + The parameter for each column, or None where no match could be made. + """ + saved_labels = saved_labels or {} + resolved = [] + for unique_name in column_names: + parameter = self._by_unique_name.get(unique_name) + if parameter is None: + parameter = self._by_label.get(saved_labels.get(unique_name, '')) + resolved.append(parameter) + return resolved + + def display_names( + self, + column_names: list[str], + saved_labels: dict[str, str] | None = None, + ) -> list[str]: + """ + Get a readable label for each column of a chain. + + Parameters + ---------- + column_names : list[str] + The sampler's name for each column. + saved_labels : dict[str, str] | None, default=None + Mapping of unique name to label, as recorded when a chain was saved. + + Returns + ------- + list[str] + One label per column, falling back to the saved label and then to the raw column name. + """ + saved_labels = saved_labels or {} + return [ + saved_labels.get(unique_name, unique_name) + if parameter is None + else self.label(parameter) + for unique_name, parameter in zip( + column_names, self.resolve(column_names, saved_labels), strict=True + ) + ] + + def units( + self, + column_names: list[str], + saved_labels: dict[str, str] | None = None, + ) -> list[str]: + """ + Get the unit of each column of a chain. + + Parameters + ---------- + column_names : list[str] + The sampler's name for each column. + saved_labels : dict[str, str] | None, default=None + Mapping of unique name to label, as recorded when a chain was saved. + + Returns + ------- + list[str] + One unit per column, empty where no parameter could be matched. + """ + return [ + '' if parameter is None else str(parameter.unit) + for parameter in self.resolve(column_names, saved_labels) + ] diff --git a/src/easydynamics/analysis/posterior_sampling.py b/src/easydynamics/analysis/posterior_sampling.py new file mode 100644 index 000000000..2c23a9235 --- /dev/null +++ b/src/easydynamics/analysis/posterior_sampling.py @@ -0,0 +1,1278 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +""" +Bayesian MCMC sampling for the Analysis classes, backed by the BUMPS DREAM sampler. + +The sampler is composed into an Analysis rather than inherited by it: an Analysis exposes one +``bayesian`` property, and everything to do with sampling lives here instead of being mixed into +three classes. Labelling lives in :mod:`easydynamics.analysis.posterior_labels` and the figures in +:mod:`easydynamics.utils.posterior_plotting`; this module only runs chains. +""" + +from __future__ import annotations + +import json +import sys +import warnings +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any + +import numpy as np +from easyscience.fitting import AvailableMinimizers +from easyscience.fitting import Sampler + +from easydynamics.analysis.posterior import degenerate_parameters +from easydynamics.analysis.posterior import parameters_at_bounds +from easydynamics.analysis.posterior import suggest_bounds_for_parameters +from easydynamics.analysis.posterior import summarize_draws +from easydynamics.analysis.posterior import unbounded_parameters + +if TYPE_CHECKING: + import os + from collections.abc import Callable + + from easyscience.fitting.sampler import SamplingResults + from easyscience.variable import Parameter + from matplotlib.figure import Figure + + from easydynamics.analysis.posterior import BoundsSuggestions + from easydynamics.analysis.posterior import PosteriorSummary + from easydynamics.analysis.posterior_labels import ParameterLabels + +# Suffix of the sidecar mapping chain columns to stable labels, written next to the BUMPS chain +# files by save(). +_LABEL_MAP_SUFFIX = '.parameter-names.json' + + +class PosteriorSampler: + """ + Draws samples from the posterior distribution of an Analysis' free parameters. + + Reached as ``analysis.bayesian``. Sampling explores the whole posterior rather than reporting a + single best-fit point, which is worth doing when parameters are correlated or their + uncertainties are strongly non-Gaussian, both common in QENS. + + Running a fit first is not required, but it helps: DREAM seeds its population in a small ball + around the parameters' current values, so starting from fitted values shortens the burn-in. + + The Analysis passes in everything that differs between the Analysis classes, so this class + needs no knowledge of how any of them is built. + + Parameters + ---------- + analysis : object + The Analysis being sampled, used for its ``display_name`` and its ``fitter``. + sampling_data : Callable[[], tuple] + Returns the ``(x, y, weights)`` to bind to the sampler. Each is an array, or a list of + arrays for a multi-dataset fit. + chain_parameters : Callable[[], list[Parameter]] + Returns the free parameters that will form the chain's columns. + parameter_labels : Callable[[], ParameterLabels] + Returns labels for those parameters. + prepare : Callable[[], None] | None, default=None + Brings any cached computation on the Analysis up to date before a run. + + Notes + ----- + Every free parameter must have finite bounds before sampling, because in DREAM the bounds are + the prior. :meth:`suggest_bounds` proposes bounds for any parameter still missing one. + + Examples + -------- + ```python + analysis.fit() + analysis.bayesian.suggest_bounds().apply() + analysis.bayesian.sample(samples=10000, burn=2000, thin=10) + analysis.bayesian.summary() + ``` + """ + + def __init__( + self, + analysis: object, + sampling_data: Callable[[], tuple], + chain_parameters: Callable[[], list[Parameter]], + parameter_labels: Callable[[], ParameterLabels], + prepare: Callable[[], None] | None = None, + ) -> None: + self._analysis = analysis + self._sampling_data = sampling_data + self._chain_parameters = chain_parameters + self._parameter_labels = parameter_labels + self._prepare_hook = prepare + self._sampler: Sampler | None = None + self._sampler_is_dirty = True + self._results: SamplingResults | None = None + # Maps a chain column's unique_name to the label it had when saved. Only populated by + # load(), because unique names are per-session and do not survive a round trip. + self._saved_labels: dict[str, str] = {} + + ############# + # State + ############# + + def invalidate(self) -> None: + """ + Mark the underlying Sampler as needing a rebuild. + + Called by the Analysis when its data changes, since the Sampler binds its data at + construction. + """ + self._sampler_is_dirty = True + + @property + def sampler(self) -> Sampler | None: + """ + The EasyScience Sampler holding the chain, or None before the first run. + + Returns + ------- + Sampler | None + The cached Sampler. + """ + return self._sampler + + @property + def results(self) -> SamplingResults | None: + """ + The results of the most recent run, or None if there has not been one. + + Returns + ------- + SamplingResults | None + The most recent sampling results. + """ + return self._results + + ############# + # Bounds + ############# + + def suggest_bounds( + self, + n_sigma: float = 10.0, + relative_pad: float = 0.2, + absolute_floor: float | None = None, + ) -> BoundsSuggestions: + """ + Propose finite bounds for free parameters that still have an infinite one. + + Nothing changes until :meth:`BoundsSuggestions.apply` is called, so the proposal can be + reviewed first. Bounds that are already finite are never widened or narrowed, so physical + limits such as a non-negative area are left alone. + + Because the bounds act as a uniform prior in DREAM, a generous width is the safe choice: + too tight a bound truncates the posterior and understates the uncertainty. + + Parameters + ---------- + n_sigma : float, default=10.0 + How many standard deviations of the fitted uncertainty to allow on each side. + relative_pad : float, default=0.2 + Extra half-width as a fraction of the absolute parameter value, guarding against + minimizers that report a zero or absurdly small uncertainty. + absolute_floor : float | None, default=None + A minimum half-width in the parameter's own units, for when neither the uncertainty nor + the value carries the natural scale. + + Returns + ------- + BoundsSuggestions + The proposed bounds, which must be applied explicitly. + """ + labels = self._labels() + return suggest_bounds_for_parameters( + labels.parameters, + labels=[labels.label(parameter) for parameter in labels.parameters], + n_sigma=n_sigma, + relative_pad=relative_pad, + absolute_floor=absolute_floor, + ) + + def check_bounds(self) -> None: + """ + Verify that every free parameter has finite bounds. + + Raises + ------ + ValueError + If any free parameter has an infinite lower or upper bound, or finite bounds that + enclose no range (``min >= max``). + """ + labels = self._labels() + unbounded = unbounded_parameters(labels.parameters) + if unbounded: + names = ', '.join(labels.label(parameter) for parameter in unbounded) + raise ValueError( + f'Bayesian sampling requires finite bounds on every free parameter, because the ' + f'bounds act as the prior. These parameters are unbounded: {names}. ' + f'Set their min and max, or call suggest_bounds() to propose values.' + ) + degenerate = degenerate_parameters(labels.parameters) + if degenerate: + names = ', '.join(labels.label(parameter) for parameter in degenerate) + raise ValueError( + f'Bayesian sampling requires min < max on every free parameter, because the ' + f'bounds act as the prior and a zero-width range leaves the sampler nothing to ' + f'explore. These parameters have degenerate bounds: {names}. ' + f'Widen their min and max, or fix them instead of sampling them.' + ) + + ############# + # Sampling + ############# + + def sample( + self, + samples: int = 10000, + burn: int = 2000, + thin: int = 10, + population: int | None = None, + parameters: list[Parameter] | list[str] | None = None, + progress: bool = False, + **sampler_options: dict[str, Any], + ) -> SamplingResults: + """ + Draw samples from the posterior distribution of the free parameters. + + Starts a fresh chain, replacing any existing one; use :meth:`extend` to continue one. + Parameter values are restored afterwards, so sampling never silently moves the model off + its fitted values; use :meth:`set_parameters_to_median` to adopt the posterior. + + Parameters + ---------- + samples : int, default=10000 + Number of raw samples to draw across all chains, before thinning. A guaranteed minimum + rather than an exact count. + burn : int, default=2000 + Burn-in generations to discard before collecting samples. + thin : int, default=10 + Thinning interval, which reduces autocorrelation between retained draws. + population : int | None, default=None + DREAM population scale factor: BUMPS runs ``ceil(population * n_parameters)`` chains. + parameters : list[Parameter] | list[str] | None, default=None + Restrict the chain to these parameters, given as Parameter objects or labels. All other + free parameters are held fixed for the run. Holding a parameter fixed is not the same + as marginalizing over it: the resulting intervals are conditional on those values and + will be too narrow if the parameters are correlated. The default samples everything. + progress : bool, default=False + Print a progress line, redrawn in place as the sampler advances and closed with a done + marker when the run finishes. Off by default so scripted runs stay quiet; a + ``progress_callback`` given in ``sampler_options`` takes precedence over it. + **sampler_options : dict[str, Any] + Forwarded to the EasyScience Sampler, e.g. ``sampler_kwargs`` or ``progress_callback``. + + Returns + ------- + SamplingResults + The sampling results, also stored on :attr:`results`. + + Notes + ----- + Runs are not reproducible. BUMPS' DREAM sampler draws from NumPy's global random state and + the underlying EasyScience Sampler exposes no seed control, so two identical calls return + two different chains. Their summaries should nevertheless agree to well within the reported + credible intervals; if they do not, the chain is too short to have converged. + """ + reporter = _install_progress_reporter(progress, sampler_options) + completed = False + try: + results = self._run( + parameters=parameters, + run=lambda sampler: sampler.sample( + samples=samples, burn=burn, thin=thin, population=population, **sampler_options + ), + ) + completed = True + finally: + if reporter is not None: + reporter.close(completed=completed) + return results + + def extend( + self, + additional_samples: int = 5000, + thin: int = 10, + parameters: list[Parameter] | list[str] | None = None, + progress: bool = False, + **sampler_options: dict[str, Any], + ) -> SamplingResults: + """ + Continue the existing chain with additional samples. + + Parameters + ---------- + additional_samples : int, default=5000 + Number of additional samples to draw, in the same units as ``samples``. + thin : int, default=10 + Thinning interval for the retained draws. + parameters : list[Parameter] | list[str] | None, default=None + The same restriction as in :meth:`sample`. It must leave the chain the same width, + since BUMPS resumes from a stored chain whose columns are fixed. + progress : bool, default=False + Print a progress line, redrawn in place as the sampler advances, as in :meth:`sample`. + **sampler_options : dict[str, Any] + Forwarded to the EasyScience Sampler. + + Returns + ------- + SamplingResults + The sampling results for the full extended chain. + + Raises + ------ + RuntimeError + If there is no chain to extend, or the previous run failed and left no results. + + Notes + ----- + A ``ValueError`` propagates from the run guards if the model or data changed since the + chain was started, or if this run's parameters differ from the ones the chain holds. + + Like :meth:`sample`, extensions are not reproducible: the sampler draws from NumPy's global + random state and exposes no seed control. + """ + if self._sampler is None: + raise RuntimeError('No chain to extend. Call sample() or load() first.') + reporter = _install_progress_reporter(progress, sampler_options) + completed = False + try: + results = self._run( + parameters=parameters, + run=lambda sampler: sampler.extend( + additional_samples=additional_samples, thin=thin, **sampler_options + ), + reuse_sampler=True, + ) + completed = True + finally: + if reporter is not None: + reporter.close(completed=completed) + return results + + def _run( + self, + parameters: list[Parameter] | list[str] | None, + run: Callable[[Sampler], SamplingResults], + reuse_sampler: bool = False, + ) -> SamplingResults: + """ + Run a sampling operation with the surrounding guards in place. + + Checks the bounds, switches the minimizer to BUMPS, optionally holds parameters fixed, and + restores the parameter values, fixed flags and minimizer afterwards. + + Parameters + ---------- + parameters : list[Parameter] | list[str] | None + Parameters to restrict the chain to, or None for all free parameters. + run : Callable[[Sampler], SamplingResults] + The operation to perform on the prepared Sampler. + reuse_sampler : bool, default=False + Whether to reuse the cached Sampler, as an extension must. + + Returns + ------- + SamplingResults + The results of the run. + + Raises + ------ + IndexError + Re-raised untouched when it did not come from BUMPS, since that is a bug here rather + than a modelling problem. + RuntimeError + If the BUMPS sampler fails while removing outlier chains. + ValueError + If there are no free parameters to sample. + """ + held_fixed = self._resolve_parameters_to_hold_fixed(parameters) + _warn_about_held_parameters(self._labels(), held_fixed) + + with _FixedParameters(held_fixed): + self.check_bounds() + self._prepare() + + chain_parameters = self._chain_parameters() + if not chain_parameters: + raise ValueError( + 'There are no free parameters to sample: every parameter is fixed. ' + 'Free at least one parameter before sampling.' + ) + saved_values = [(p, p.value) for p in chain_parameters] + + if reuse_sampler: + self._verify_chain_shape_unchanged(chain_parameters) + + fitter = self._analysis.fitter + original_minimizer = fitter.minimizer.enum + fitter.switch_minimizer(AvailableMinimizers.Bumps) + try: + results = run(self._get_or_build_sampler(reuse_sampler=reuse_sampler)) + except IndexError as error: + if not _raised_inside_bumps(error): + raise + raise RuntimeError( + 'The BUMPS sampler failed while removing outlier chains. This happens when ' + 'the chains scatter because two or more free parameters are degenerate, and ' + 'also on short chains, where BUMPS has too few generations to work with. ' + 'Check for degenerate parameters, raise samples, or switch the outlier ' + "removal off with sampler_kwargs={'outliers': 'none'}." + ) from error + finally: + fitter.switch_minimizer(original_minimizer) + for parameter, value in saved_values: + parameter.value = value + + # Labelled outside the block above, so a subset run records the labels a full run would. + # Inside it the other parameters are fixed, nothing looks ambiguous, and the sidecar would + # be written with unqualified names that no longer match on reload. + self._saved_labels = self._labels().name_map() + self._results = results + self._warn_about_bounds_occupancy(results) + return results + + def _get_or_build_sampler(self, reuse_sampler: bool) -> Sampler: + """ + Get the cached Sampler, rebuilding it if the data changed. + + Parameters + ---------- + reuse_sampler : bool + Whether to reuse the cached Sampler, as an extension must. + + Returns + ------- + Sampler + The Sampler to run. + + Raises + ------ + ValueError + If the cached Sampler must be reused but the model or data has changed since it was + built, so continuing its chain would silently mix draws against different data. + """ + if reuse_sampler and self._sampler is not None and self._sampler_is_dirty: + raise ValueError( + 'Cannot extend the chain: the model or data has changed since the chain was ' + 'started, and an extension would mix draws taken against different data. ' + 'Start a fresh chain with sample() instead.' + ) + if self._sampler is None or (self._sampler_is_dirty and not reuse_sampler): + x, y, weights = self._sampling_data() + self._sampler = Sampler(self._analysis.fitter, x, y, weights=weights) + self._sampler_is_dirty = False + return self._sampler + + def _verify_chain_shape_unchanged(self, chain_parameters: list[Parameter]) -> None: + """ + Check that an extension keeps the chain's columns, both in count and in identity. + + Parameters + ---------- + chain_parameters : list[Parameter] + The parameters that would form the chain for this run. + + Raises + ------ + RuntimeError + If there are no stored results to continue from, as after a failed run. + ValueError + If the number or the identity of the parameters differs from the existing chain's. + """ + if self._results is None: + raise RuntimeError( + 'Cannot extend: the previous run failed and left no results to continue from. ' + 'Start a fresh chain with sample() instead.' + ) + existing = self._results.draws.shape[1] + if len(chain_parameters) != existing: + raise ValueError( + f'Cannot extend a chain of {existing} parameters with a run of ' + f'{len(chain_parameters)}. An extension continues the stored chain, whose columns ' + f'are fixed, so it needs the same parameters the chain was started with. Start a ' + f'fresh chain with sample() instead.' + ) + + # An equal count is not enough: the columns must be draws of the same parameters. For a + # chain from this session the stored column names are current unique names; for a loaded + # chain they are foreign, so they are resolved through the saved labels instead. + requested = {parameter.unique_name for parameter in chain_parameters} + if set(self._results.param_names) == requested: + return + resolved = self._resolve(self._results) + if ( + all(parameter is not None for parameter in resolved) + and {parameter.unique_name for parameter in resolved} == requested + ): + return + labels = self._labels() + chain_names = ', '.join(self._display_names(self._results)) + run_names = ', '.join(labels.label(parameter) for parameter in chain_parameters) + raise ValueError( + f'Cannot extend the chain: it holds draws of [{chain_names}], but this run would ' + f'sample [{run_names}]. An extension continues the stored chain, whose columns are ' + f'fixed, so it needs the same parameters the chain was started with. Start a fresh ' + f'chain with sample() instead.' + ) + + def _resolve_parameters_to_hold_fixed( + self, + parameters: list[Parameter] | list[str] | None, + ) -> list[Parameter]: + """ + Work out which free parameters must be held fixed to honour a subset request. + + Parameters + ---------- + parameters : list[Parameter] | list[str] | None + The requested subset, as Parameter objects or labels, or None for everything. + + Returns + ------- + list[Parameter] + The free parameters that are not in the requested subset. + + Raises + ------ + TypeError + If parameters is not a list of Parameters or strings, or None. + ValueError + If a requested label or Parameter matches no free parameter of this analysis, or the + subset is empty. + """ + if parameters is None: + return [] + if not isinstance(parameters, (list, tuple)): + raise TypeError('parameters must be a list of Parameters, a list of labels, or None.') + + labels = self._labels() + by_label = {labels.label(parameter): parameter for parameter in labels.parameters} + by_unique_name = {parameter.unique_name: parameter for parameter in labels.parameters} + requested = [] + for entry in parameters: + if isinstance(entry, str): + if entry not in by_label: + raise ValueError( + f'No free parameter named {entry!r}. ' + f'Available: {", ".join(sorted(by_label))}.' + ) + requested.append(by_label[entry]) + elif hasattr(entry, 'unique_name'): + # A Parameter object gets the same membership check a label does. Without it a + # fixed or foreign parameter slips through, every free parameter ends up held + # fixed, and the run dies with a cryptic zero-parameter failure deep in BUMPS. + if entry.unique_name not in by_unique_name: + name = getattr(entry, 'name', entry.unique_name) + raise ValueError( + f'Parameter {name!r} is not a free parameter of this analysis, so it ' + f'cannot be sampled. It is either fixed or not part of this analysis. ' + f'Available: {", ".join(sorted(by_label))}.' + ) + requested.append(by_unique_name[entry.unique_name]) + else: + raise TypeError('parameters must contain Parameter objects or labels (strings).') + + wanted = {parameter.unique_name for parameter in requested} + if not wanted: + raise ValueError('parameters must name at least one parameter to sample.') + return [p for p in labels.parameters if p.unique_name not in wanted] + + def _warn_about_bounds_occupancy(self, results: SamplingResults) -> None: + """ + Warn when the posterior has piled up against a bound. + + Parameters + ---------- + results : SamplingResults + The sampling results to inspect. + """ + piled_up = parameters_at_bounds(results.draws, self._resolve(results)) + if not piled_up: + return + labels = self._labels() + by_unique_name = {parameter.unique_name: parameter for parameter in labels.parameters} + details = ', '.join( + f'{labels.label(by_unique_name[unique_name])} ({fraction:.0%} of draws)' + if unique_name in by_unique_name + else f'{unique_name} ({fraction:.0%} of draws)' + for unique_name, fraction in piled_up.items() + ) + warnings.warn( + ( + f'The posterior is piled up against the bounds for: {details}. ' + f'The bounds, rather than the data, are setting these credible intervals. ' + f'Widen the bounds, or check whether these parameters are degenerate with others.' + ), + UserWarning, + stacklevel=4, + ) + + ############# + # Results + ############# + + def summary(self) -> PosteriorSummary: + """ + Summarize the marginal posterior of each sampled parameter. + + Reports the median and the 68% credible interval under the parameter's own label and unit. + + Returns + ------- + PosteriorSummary + One entry per sampled parameter. + """ + results = self._require_results() + labels = self._labels() + return summarize_draws( + draws=results.draws, + labels=labels.display_names(results.param_names, self._saved_labels), + parameters_by_column=self._resolve(results), + ) + + def set_parameters_to_median(self) -> list[Parameter]: + """ + Set every sampled parameter to the median of its marginal posterior. + + The vector of marginal medians is not in general the highest-posterior point, and for + strongly correlated parameters need not even be a good fit. + + Returns + ------- + list[Parameter] + The parameters that were changed. + """ + results = self._require_results() + changed = [] + for column, parameter in enumerate(self._resolve(results)): + if parameter is None: + continue + parameter.value = float(np.median(results.draws[:, column])) + changed.append(parameter) + return changed + + ############# + # Persistence + ############# + + def save(self, path: str | os.PathLike) -> None: + """ + Save the MCMC chain to disk. + + Writes the BUMPS chain files plus a sidecar recording the column labels, because the unique + names BUMPS stores are per-session and cannot be matched up again on their own. + + Parameters + ---------- + path : str | os.PathLike + Path prefix for the chain files. + + Raises + ------ + RuntimeError + If there is no chain to save. + """ + if self._sampler is None: + raise RuntimeError('No chain to save. Call sample() first.') + self._sampler.save(path) + if not self._saved_labels: + # A chain loaded without a sidecar has no labels to record. Writing an empty sidecar + # would be worse than none: the next load() would find a "valid" file, warn about + # nothing, and report every column under its raw internal name. + warnings.warn( + ( + f'No parameter labels are recorded for this chain, so no parameter-name ' + f'sidecar was written next to {path}; the chain was probably loaded without ' + f'one. A future load() will report the columns under their internal names.' + ), + UserWarning, + stacklevel=2, + ) + return + Path(f'{path}{_LABEL_MAP_SUFFIX}').write_text( + json.dumps(self._saved_labels, indent=2), encoding='utf-8' + ) + + def load(self, path: str | os.PathLike, skip: int = 0) -> SamplingResults: + """ + Load a previously saved MCMC chain. + + The loaded chain can be summarized, plotted, or continued with :meth:`extend`. + + Parameters + ---------- + path : str | os.PathLike + The path prefix the chain was saved under. + skip : int, default=0 + Number of initial samples to skip when reading the chain. + + Returns + ------- + SamplingResults + The loaded results, also stored on :attr:`results`. + """ + self._prepare() + sidecar = Path(f'{path}{_LABEL_MAP_SUFFIX}') + self._saved_labels = ( + json.loads(sidecar.read_text(encoding='utf-8')) if sidecar.is_file() else {} + ) + if not self._saved_labels: + # An empty sidecar is as unusable as a missing one, so both warn the same way. + warnings.warn( + ( + f'No parameter-name sidecar with usable content found at {sidecar}. The ' + f'chain will be reported under the internal names it was saved with, because ' + f'those cannot be matched to this Analysis.' + ), + UserWarning, + stacklevel=2, + ) + + fitter = self._analysis.fitter + original_minimizer = fitter.minimizer.enum + fitter.switch_minimizer(AvailableMinimizers.Bumps) + try: + self._results = self._get_or_build_sampler(reuse_sampler=False).load_state( + path, skip=skip + ) + finally: + fitter.switch_minimizer(original_minimizer) + return self._results + + ############# + # Figures, each one a call into posterior_plotting + ############# + + def plot_trace(self, **kwargs: dict[str, Any]) -> Figure: + """ + Plot the chain trace of each sampled parameter. + + Parameters + ---------- + **kwargs : dict[str, Any] + Forwarded to :func:`easydynamics.utils.posterior_plotting.plot_trace`. + + Returns + ------- + Figure + The matplotlib Figure. + """ + from easydynamics.utils.posterior_plotting import plot_trace + + results = self._require_results() + return plot_trace( + draws=results.draws, + names=self._display_names(results), + logp=results.logp, + units=self._units(results), + title=self._analysis.display_name, + **kwargs, + ) + + def plot_corner(self, **kwargs: dict[str, Any]) -> Figure: + """ + Plot the marginal and pairwise posterior distributions. + + Parameters + ---------- + **kwargs : dict[str, Any] + Forwarded to :func:`easydynamics.utils.posterior_plotting.plot_corner`. + + Returns + ------- + Figure + The matplotlib Figure. + """ + from easydynamics.utils.posterior_plotting import plot_corner + + results = self._require_results() + return plot_corner( + draws=results.draws, + names=self._display_names(results), + units=self._units(results), + title=self._analysis.display_name, + **kwargs, + ) + + def plot_marginal(self, parameter: Parameter | str, **kwargs: dict[str, Any]) -> Figure: + """ + Plot the marginal posterior distribution of a single sampled parameter. + + Shows a density-normalized histogram of the parameter's draws, with the median and the 16th + and 84th percentiles marked -- the same 68% credible interval :meth:`summary` reports. + + Parameters + ---------- + parameter : Parameter | str + The parameter to plot, as a Parameter object or its label. + **kwargs : dict[str, Any] + Forwarded to :func:`easydynamics.utils.posterior_plotting.plot_marginal`. + + Returns + ------- + Figure + The matplotlib Figure. + """ + from easydynamics.utils.posterior_plotting import plot_marginal + + results = self._require_results() + column = self._resolve_column(results, parameter) + return plot_marginal( + values=results.draws[:, column], + name=self._display_names(results)[column], + unit=self._units(results)[column], + title=self._analysis.display_name, + **kwargs, + ) + + def plot_correlations(self, **kwargs: dict[str, Any]) -> Figure: + """ + Plot the Pearson correlation matrix of the sampled parameters. + + A strongly correlated pair cannot be determined separately from this data. The matrix + condenses what the off-diagonal panels of :meth:`plot_corner` show, one number per pair, + which scales better to many parameters. + + Parameters + ---------- + **kwargs : dict[str, Any] + Forwarded to :func:`easydynamics.utils.posterior_plotting.plot_correlations`. + + Returns + ------- + Figure + The matplotlib Figure. + """ + from easydynamics.utils.posterior_plotting import plot_correlations + + results = self._require_results() + return plot_correlations( + draws=results.draws, + names=self._display_names(results), + title=self._analysis.display_name, + **kwargs, + ) + + def plot_posterior_predictive( + self, + n_draws: int = 200, + credible_interval: float = 68.0, + **kwargs: dict[str, Any], + ) -> Figure: + """ + Plot the data against the credible band implied by the posterior. + + Parameters + ---------- + n_draws : int, default=200 + How many posterior draws to evaluate the model for. Each costs a full model evaluation. + credible_interval : float, default=68.0 + Width of the credible band, as a percentage. + **kwargs : dict[str, Any] + Forwarded to :func:`easydynamics.utils.posterior_plotting.plot_posterior_predictive`. + + Returns + ------- + Figure + The matplotlib Figure. + + Raises + ------ + NotImplementedError + If this Analysis binds a list of datasets rather than a single one. + ValueError + If n_draws is not a positive integer. + """ + from easydynamics.utils.posterior_plotting import plot_posterior_predictive + + if not isinstance(n_draws, int) or isinstance(n_draws, bool) or n_draws < 1: + raise ValueError(f'n_draws must be a positive integer. Got {n_draws}.') + + self._require_results() + x, y, weights = self._sampling_data() + if isinstance(x, (list, tuple)): + raise NotImplementedError( + 'plot_posterior_predictive supports a single dataset only. Plot each dataset ' + 'from its own Analysis1d instead.' + ) + + energy = getattr(self._analysis, 'energy', None) + sample_model = getattr(self._analysis, 'sample_model', None) + y_unit = None if sample_model is None else getattr(sample_model, 'y_unit', None) + kwargs.setdefault('xlabel', None if energy is None else f'Energy ({energy.unit})') + kwargs.setdefault('ylabel', 'Intensity' if y_unit is None else f'Intensity ({y_unit})') + + # When the data carries no variances the weights are all-ones placeholders, and inverting + # them would fabricate error bars of 1.0 that the data never had. + experiment = getattr(self._analysis, 'experiment', None) + has_variances = experiment is None or getattr(experiment, 'has_variances', True) + + return plot_posterior_predictive( + x=np.asarray(x), + y=np.asarray(y), + predictions=self.predictions(n_draws), + y_err=1.0 / np.asarray(weights) if weights is not None and has_variances else None, + title=self._analysis.display_name, + credible_interval=credible_interval, + **kwargs, + ) + + def predictions(self, n_draws: int = 200) -> np.ndarray: + """ + Evaluate the model once per posterior draw, restoring the parameters afterwards. + + Parameters + ---------- + n_draws : int, default=200 + How many draws to evaluate, taken evenly across the chain. + + Returns + ------- + np.ndarray + Model evaluations, shape ``(n_selected, len(x))``. + """ + results = self._require_results() + self._prepare() + + x, _, _ = self._sampling_data() + columns = [ + (parameter, column) + for column, parameter in enumerate(self._resolve(results)) + if parameter is not None + ] + saved_values = [(parameter, parameter.value) for parameter, _ in columns] + + total = results.draws.shape[0] + indices = np.unique(np.linspace(0, total - 1, min(n_draws, total)).astype(int)) + + fit_function = self._analysis.fitter.fit_function + predictions = [] + try: + for index in indices: + for parameter, column in columns: + parameter.value = float(results.draws[index, column]) + predictions.append(np.asarray(fit_function(x))) + finally: + for parameter, value in saved_values: + parameter.value = value + return np.vstack(predictions) + + ############# + # Talking to the Analysis + ############# + + def _labels(self) -> ParameterLabels: + """ + Get the label helper for the current free parameters. + + Returns + ------- + ParameterLabels + Built fresh, because which parameters are free can change between calls. + """ + return self._parameter_labels() + + def _prepare(self) -> None: + """Bring any cached computation on the Analysis up to date before a run.""" + if self._prepare_hook is not None: + self._prepare_hook() + + def _resolve(self, results: SamplingResults) -> list[Parameter | None]: + """ + Match each column of a chain to a parameter. + + Parameters + ---------- + results : SamplingResults + The results whose columns should be matched. + + Returns + ------- + list[Parameter | None] + The parameter for each column, or None where none could be matched. + """ + return self._labels().resolve(results.param_names, self._saved_labels) + + def _resolve_column(self, results: SamplingResults, parameter: Parameter | str) -> int: + """ + Find the chain column holding a parameter's draws. + + Labels are matched against the columns' display names, so the same names the summary and + the plots report under are the ones accepted here. Parameter objects are matched through + the resolved columns, so a parameter reloaded from a saved chain is found too. + + Parameters + ---------- + results : SamplingResults + The results whose columns should be searched. + parameter : Parameter | str + The parameter to look for, as a Parameter object or its label. + + Returns + ------- + int + The index of the column holding the parameter's draws. + + Raises + ------ + TypeError + If parameter is neither a Parameter object nor a string. + ValueError + If the parameter matches no column of the chain. + """ + names = self._display_names(results) + if isinstance(parameter, str): + matches = [column for column, name in enumerate(names) if name == parameter] + elif hasattr(parameter, 'unique_name'): + matches = [ + column + for column, candidate in enumerate(self._resolve(results)) + if candidate is not None and candidate.unique_name == parameter.unique_name + ] + else: + raise TypeError('parameter must be a Parameter object or a label (string).') + if not matches: + requested = ( + parameter if isinstance(parameter, str) else getattr(parameter, 'name', '?') + ) + raise ValueError( + f'No sampled parameter named {requested!r}. Available: {", ".join(sorted(names))}.' + ) + return matches[0] + + def _display_names(self, results: SamplingResults) -> list[str]: + """ + Get a readable label for each column of a chain. + + Parameters + ---------- + results : SamplingResults + The results whose columns should be named. + + Returns + ------- + list[str] + One label per column. + """ + return self._labels().display_names(results.param_names, self._saved_labels) + + def _units(self, results: SamplingResults) -> list[str]: + """ + Get the unit of each column of a chain. + + Parameters + ---------- + results : SamplingResults + The results whose columns should be described. + + Returns + ------- + list[str] + One unit per column. + """ + return self._labels().units(results.param_names, self._saved_labels) + + def _require_results(self) -> SamplingResults: + """ + Get the stored results, raising if there are none. + + Returns + ------- + SamplingResults + The most recent sampling results. + + Raises + ------ + RuntimeError + If no sampling has been run yet. + """ + if self._results is None: + raise RuntimeError('No posterior samples yet. Call sample() or load() first.') + return self._results + + +def _warn_about_held_parameters(labels: object, held_fixed: list[Parameter]) -> None: + """ + Warn that holding parameters fixed makes the credible intervals conditional. + + Parameters + ---------- + labels : object + The ParameterLabels used to name them. + held_fixed : list[Parameter] + The parameters being held fixed for the run. + """ + if not held_fixed: + return + names = ', '.join(labels.label(parameter) for parameter in held_fixed) + warnings.warn( + ( + f'Holding these parameters fixed while sampling: {names}. ' + f'Fixing a parameter is not the same as marginalizing over it, so the resulting ' + f'credible intervals are conditional on these values and will be too narrow if the ' + f'parameters are correlated.' + ), + UserWarning, + stacklevel=4, + ) + + +def _install_progress_reporter( + progress: bool, + sampler_options: dict[str, Any], +) -> _SamplingProgress | None: + """ + Put a progress reporter into the sampler options when one is asked for. + + A ``progress_callback`` the caller supplied themselves is left untouched, since an explicit + callback is more specific than the boolean convenience flag. + + Parameters + ---------- + progress : bool + Whether a progress line was requested. + sampler_options : dict[str, Any] + The options about to be forwarded to the EasyScience Sampler, modified in place. + + Returns + ------- + _SamplingProgress | None + The installed reporter, which the caller must close after the run, or None when nothing was + installed. + """ + if not progress or 'progress_callback' in sampler_options: + return None + reporter = _SamplingProgress() + sampler_options['progress_callback'] = reporter + return reporter + + +class _SamplingProgress: + """ + Renders the sampler's per-generation callbacks as a single self-overwriting progress line. + + BUMPS invokes the callback once per DREAM generation, which for a long run is far too often to + print, so the line is only redrawn when the percentage changes. Carriage-return output works in + terminals and notebooks alike, and needs no extra dependency. + + The generation total in the payload is the backend's own estimate, and it overestimates when + DREAM runs more chains than the estimate assumes, so a finished run can stop short of 100%. The + line is therefore closed with an explicit done marker rather than trusting the estimate. + """ + + def __init__(self) -> None: + self._last_percent = -1 + self._line_length = 0 + self._printed = False + + def __call__(self, payload: dict[str, Any]) -> None: + """ + Handle one progress callback from the sampler. + + Parameters + ---------- + payload : dict[str, Any] + The sampler's progress payload. ``iteration`` carries the DREAM generation and + ``total_steps``, when present, the estimated total number of generations. + """ + iteration = payload.get('iteration') + if iteration is None: + return + total = payload.get('total_steps') + if total: + # Clamped, so the line never reports more than 100% when the run outlives the + # backend's estimate of its own length. + percent = min(100, int(100 * iteration / total)) + if percent == self._last_percent: + return + self._last_percent = percent + line = f'Sampling: {percent:3d}% ({iteration}/{total} generations)' + else: + line = f'Sampling: generation {iteration}' + self._write(line) + + def close(self, completed: bool) -> None: + """ + End the progress line, so any later output starts on a line of its own. + + Parameters + ---------- + completed : bool + Whether the run finished. A finished run gets a done marker; a failed one only has its + line terminated, so the exception is not decorated with a claim of success. + """ + if not self._printed: + return + if completed: + self._write('Sampling: done') + sys.stdout.write('\n') + sys.stdout.flush() + + def _write(self, line: str) -> None: + """ + Redraw the progress line in place. + + Parameters + ---------- + line : str + The text to show, padded so it fully overwrites a longer previous line. + """ + sys.stdout.write(f'\r{line.ljust(self._line_length)}') + sys.stdout.flush() + self._line_length = max(self._line_length, len(line)) + self._printed = True + + +def _raised_inside_bumps(error: BaseException) -> bool: + """ + Check whether an exception came from inside BUMPS. + + Used so only BUMPS' own failures are relabelled, and a bug in this package is not reported as a + modelling problem. + + Parameters + ---------- + error : BaseException + The exception to inspect. + + Returns + ------- + bool + True when any frame of the traceback lies in the bumps package. + """ + traceback = error.__traceback__ + while traceback is not None: + module = traceback.tb_frame.f_globals.get('__name__', '') + if module == 'bumps' or module.startswith('bumps.'): + return True + traceback = traceback.tb_next + return False + + +class _FixedParameters: + """Context manager that temporarily fixes parameters and restores their flags on exit.""" + + def __init__(self, parameters: list[Parameter]) -> None: + self._parameters = list(parameters) + self._saved: list[tuple[Parameter, bool]] = [] + + def __enter__(self) -> None: + """Fix the parameters, remembering their previous state.""" + self._saved = [(parameter, parameter.fixed) for parameter in self._parameters] + for parameter in self._parameters: + parameter.fixed = True + + def __exit__(self, *_exc_info: object) -> None: + """ + Restore the previous fixed state of every parameter. + + Parameters + ---------- + *_exc_info : object + Exception information, ignored. + """ + for parameter, was_fixed in self._saved: + parameter.fixed = was_fixed diff --git a/src/easydynamics/experiment/experiment.py b/src/easydynamics/experiment/experiment.py index d064326f5..5be71f109 100644 --- a/src/easydynamics/experiment/experiment.py +++ b/src/easydynamics/experiment/experiment.py @@ -585,6 +585,21 @@ def _extract_x_y_var(self, Q_index: int) -> tuple[np.ndarray, np.ndarray, np.nda var = data.variances return x, y, var + @property + def has_variances(self) -> bool: + """ + Whether the data carries variances. + + When it does not, :meth:`extract_x_y_weights_only_finite` falls back to all-ones weights, + which are placeholders for the fit rather than measured uncertainties. + + Returns + ------- + bool + True when there is data and it has variances. + """ + return self._binned_data is not None and self._binned_data.variances is not None + def extract_x_y_weights_only_finite( self, Q_index: int ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: diff --git a/src/easydynamics/utils/__init__.py b/src/easydynamics/utils/__init__.py index 5e644a06b..1c3402ced 100644 --- a/src/easydynamics/utils/__init__.py +++ b/src/easydynamics/utils/__init__.py @@ -3,5 +3,14 @@ from easydynamics.utils.detailed_balance import detailed_balance_factor from easydynamics.utils.plotting import slicerplot_with_residuals +from easydynamics.utils.posterior_plotting import plot_corner +from easydynamics.utils.posterior_plotting import plot_posterior_predictive +from easydynamics.utils.posterior_plotting import plot_trace -__all__ = ['detailed_balance_factor', 'slicerplot_with_residuals'] +__all__ = [ + 'detailed_balance_factor', + 'plot_corner', + 'plot_posterior_predictive', + 'plot_trace', + 'slicerplot_with_residuals', +] diff --git a/src/easydynamics/utils/posterior_plotting.py b/src/easydynamics/utils/posterior_plotting.py new file mode 100644 index 000000000..c3576d164 --- /dev/null +++ b/src/easydynamics/utils/posterior_plotting.py @@ -0,0 +1,592 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +""" +Diagnostic plots for Bayesian posterior samples. + +These take plain arrays rather than an Analysis, so they can be used on any chain, including one +loaded from disk. The Analysis classes wrap them in convenience methods. +""" + +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib import colormaps +from matplotlib.ticker import MaxNLocator + +if TYPE_CHECKING: + from matplotlib.figure import Figure + + +def plot_trace( + draws: np.ndarray, + names: list[str], + logp: np.ndarray | None = None, + units: list[str] | None = None, + title: str | None = None, + figsize: tuple[float, float] | None = None, +) -> Figure: + """ + Plot the chain trace of every sampled parameter. + + A converged chain looks like a "hairy caterpillar": noisy but stationary, with no drift or long + excursions. A visible trend means the chain has not reached the typical set and needs a longer + burn-in. + + Parameters + ---------- + draws : np.ndarray + Posterior draws, shape ``(n_draws, n_parameters)``. + names : list[str] + One label per column of ``draws``. + logp : np.ndarray | None, default=None + Log-posterior values, one per draw, plotted in an extra panel when given. + units : list[str] | None, default=None + Unit of each column, appended to its label. Entries that are empty or dimensionless are + skipped, since a bare "dimensionless" only adds clutter. + title : str | None, default=None + Figure title. + figsize : tuple[float, float] | None, default=None + Figure size in inches. Defaults to a height that scales with the number of panels. + + Returns + ------- + Figure + The matplotlib Figure. + + Raises + ------ + ValueError + If ``draws`` is not two-dimensional or is empty, if ``names`` does not have one entry per + column, or if ``logp`` does not have one entry per draw. + """ + draws = np.asarray(draws) + _verify_draws(draws, names) + if logp is not None: + logp = np.asarray(logp) + if logp.ndim != 1 or logp.shape[0] != draws.shape[0]: + raise ValueError( + f'logp must have one entry per draw. ' + f'Got shape {logp.shape} for {draws.shape[0]} draws.' + ) + + n_panels = draws.shape[1] + (1 if logp is not None else 0) + if figsize is None: + figsize = (10.0, max(2.0, 1.6 * n_panels)) + + fig, axes = plt.subplots(n_panels, 1, figsize=figsize, sharex=True, squeeze=False) + axes = axes[:, 0] + + for axis, column, name in zip(axes, range(draws.shape[1]), names, strict=False): + axis.plot(draws[:, column], lw=0.5) + axis.set_ylabel(_with_unit(name, units, column), fontsize=8) + # A single draw would make (0, len - 1) a zero-width range; matplotlib's autoscaling + # handles that case better than an explicit degenerate limit would. + if len(draws) > 1: + axis.set_xlim(0, len(draws) - 1) + + if logp is not None: + axes[-1].plot(logp, lw=0.5, color='C4') + axes[-1].set_ylabel('log-posterior', fontsize=8) + + axes[-1].set_xlabel('sample index') + if title is not None: + fig.suptitle(title) + fig.tight_layout() + return fig + + +def plot_corner( + draws: np.ndarray, + names: list[str], + units: list[str] | None = None, + title: str | None = None, + bins: int = 40, + figsize: tuple[float, float] | None = None, +) -> Figure: + """ + Plot marginal and pairwise posterior distributions. + + Diagonal panels show each parameter's marginal distribution. Off-diagonal panels show the joint + distribution of a pair: a compact blob means the two are independent, while a narrow diagonal + ridge means they are correlated and cannot be determined separately from this data. + + Parameters + ---------- + draws : np.ndarray + Posterior draws, shape ``(n_draws, n_parameters)``. + names : list[str] + One label per column of ``draws``. + units : list[str] | None, default=None + Unit of each column, appended to its label. Entries that are empty or dimensionless are + skipped, since a bare "dimensionless" only adds clutter. + title : str | None, default=None + Figure title. + bins : int, default=40 + Number of bins for the marginal histograms. + figsize : tuple[float, float] | None, default=None + Figure size in inches. Defaults to a square that scales with the parameter count. + + Returns + ------- + Figure + The matplotlib Figure. + + Raises + ------ + ValueError + If ``draws`` is not two-dimensional or is empty, if ``names`` does not have one entry per + column, or if any column contains non-finite values. + """ + draws = np.asarray(draws) + _verify_draws(draws, names) + + # Caught up front, because numpy would otherwise report it as an obscure + # "range [nan, nan]" error from inside the histogram. + finite_columns = np.isfinite(draws).all(axis=0) + if not finite_columns.all(): + bad = ', '.join(name for name, ok in zip(names, finite_columns, strict=True) if not ok) + raise ValueError(f'draws contain non-finite values (NaN or infinity) in: {bad}.') + + n = draws.shape[1] + if figsize is None: + side = max(4.0, 2.0 * n) + figsize = (side, side) + + # One shared limit per column, applied to the diagonal histogram and every hexbin panel below + # it, so the ticks of a column line up instead of each panel autoscaling on its own. + limits = _column_limits(draws) + + fig, axes = plt.subplots(n, n, figsize=figsize, squeeze=False) + for row in range(n): + for col in range(n): + axis = axes[row, col] + if col > row: + axis.set_visible(False) + continue + if row == col: + axis.hist(draws[:, row], bins=bins, color='C0', histtype='stepfilled', alpha=0.7) + axis.set_yticks([]) + else: + axis.hexbin(draws[:, col], draws[:, row], gridsize=30, cmap='Blues', mincnt=1) + axis.set_ylim(limits[row]) + axis.set_xlim(limits[col]) + if row == n - 1: + axis.set_xlabel(names[col], fontsize=8) + else: + axis.set_xticklabels([]) + if col == 0 and row != 0: + axis.set_ylabel(names[row], fontsize=8) + else: + axis.set_yticklabels([]) + if row == 0 and col == 0: + # The top-left panel is a histogram, so its vertical axis counts draws rather than + # carrying a parameter. Say so, instead of leaving it blank as if by omission. + axis.set_ylabel('counts', fontsize=8) + axis.tick_params(labelsize=7) + axis.xaxis.set_major_locator(MaxNLocator(nbins=4)) + if row != col: + axis.yaxis.set_major_locator(MaxNLocator(nbins=4)) + + # Matplotlib parks the shared exponent ("1e-8") at the end of the axis, where it lands on top + # of the axis label. Fold it into the label instead. + fig.canvas.draw() + for row in range(n): + for col in range(row + 1): + axis = axes[row, col] + if row == n - 1: + _absorb_offset(axis.xaxis, axis.set_xlabel, names[col], units, col) + if col == 0 and row != 0: + _absorb_offset(axis.yaxis, axis.set_ylabel, names[row], units, row) + + if title is not None: + fig.suptitle(title) + fig.tight_layout() + return fig + + +def plot_marginal( + values: np.ndarray, + name: str, + unit: str | None = None, + title: str | None = None, + bins: int = 40, + figsize: tuple[float, float] = (8.0, 5.0), +) -> Figure: + """ + Plot the marginal posterior distribution of a single parameter. + + Shows a density-normalized histogram of the parameter's draws, with the median and the 16th and + 84th percentiles marked -- the same 68% credible interval the posterior summary reports. + + Parameters + ---------- + values : np.ndarray + The parameter's posterior draws, one-dimensional. + name : str + The label the parameter is reported under. + unit : str | None, default=None + The parameter's unit, appended to the axis label. Empty or dimensionless units are skipped, + since a bare "dimensionless" only adds clutter. + title : str | None, default=None + Figure title. + bins : int, default=40 + Number of histogram bins. + figsize : tuple[float, float], default=(8.0, 5.0) + Figure size in inches. + + Returns + ------- + Figure + The matplotlib Figure. + + Raises + ------ + ValueError + If ``values`` is not one-dimensional, is empty, or contains non-finite entries. + """ + values = np.asarray(values) + if values.ndim != 1: + raise ValueError(f'values must be one-dimensional. Got shape {values.shape}.') + if values.size == 0: + raise ValueError('values is empty: there are no samples to plot.') + # Caught up front, because numpy would otherwise report it as an obscure + # "range [nan, nan]" error from inside the histogram. + if not np.isfinite(values).all(): + raise ValueError(f'values contain non-finite entries (NaN or infinity) for {name}.') + + lower, median, upper = np.percentile(values, [16.0, 50.0, 84.0]) + + fig, axis = plt.subplots(figsize=figsize) + axis.hist(values, bins=bins, density=True, color='C0', histtype='stepfilled', alpha=0.7) + axis.axvline(median, color='C3', lw=1.5, label='Median') + axis.axvline(lower, color='C3', lw=1.0, ls='--', label='68% credible interval') + axis.axvline(upper, color='C3', lw=1.0, ls='--') + axis.set_xlabel(_with_unit(name, [unit] if unit is not None else None, 0)) + axis.set_ylabel('Probability density') + axis.legend() + if title is not None: + axis.set_title(title) + fig.tight_layout() + return fig + + +def plot_correlations( + draws: np.ndarray, + names: list[str], + title: str | None = None, + figsize: tuple[float, float] | None = None, +) -> Figure: + """ + Plot the Pearson correlation matrix of the sampled parameters. + + A strongly correlated pair (an entry near +1 or -1) cannot be determined separately from this + data: the chain trades one off against the other. The matrix condenses what the off-diagonal + panels of the corner plot show, one number per pair, which scales better to many parameters. + + Correlations are dimensionless, so the labels carry no units. A constant column has no defined + correlation with anything; its cells are shown greyed out and marked "n/a" rather than failing. + A ``ValueError`` propagates from the input validation if ``draws`` is not two-dimensional or + is empty, or if ``names`` does not have one entry per column. + + Parameters + ---------- + draws : np.ndarray + Posterior draws, shape ``(n_draws, n_parameters)``. + names : list[str] + One label per column of ``draws``. + title : str | None, default=None + Figure title. + figsize : tuple[float, float] | None, default=None + Figure size in inches. Defaults to a square that scales with the parameter count, plus room + for the colorbar. + + Returns + ------- + Figure + The matplotlib Figure. + """ + draws = np.asarray(draws) + _verify_draws(draws, names) + + matrix = _correlation_matrix(draws) + n = draws.shape[1] + if figsize is None: + side = max(4.0, 0.9 * n + 2.0) + figsize = (side + 1.5, side) + + # A diverging map centred on zero, so positive and negative correlations read as two hues + # around a neutral midpoint. Cells with no defined correlation are greyed out. + colormap = colormaps['RdBu_r'].with_extremes(bad='0.85') + + fig, axis = plt.subplots(figsize=figsize) + image = axis.imshow(np.ma.masked_invalid(matrix), cmap=colormap, vmin=-1.0, vmax=1.0) + axis.set_xticks(range(n), labels=names, rotation=45, ha='right', fontsize=8) + axis.set_yticks(range(n), labels=names, fontsize=8) + for row in range(n): + for col in range(n): + value = matrix[row, col] + defined = bool(np.isfinite(value)) + axis.text( + col, + row, + f'{value:.2f}' if defined else 'n/a', + ha='center', + va='center', + fontsize=8, + # Saturated cells at the ends of the map are too dark for black text. + color='white' if defined and abs(value) > 0.6 else 'black', + ) + fig.colorbar(image, ax=axis, label='Pearson correlation') + if title is not None: + axis.set_title(title) + fig.tight_layout() + return fig + + +def plot_posterior_predictive( + x: np.ndarray, + y: np.ndarray, + predictions: np.ndarray, + y_err: np.ndarray | None = None, + title: str | None = None, + credible_interval: float = 68.0, + xlabel: str | None = None, + ylabel: str | None = None, + figsize: tuple[float, float] = (8.0, 5.0), +) -> Figure: + """ + Plot the data against the credible band implied by the posterior. + + The band shows where the model says the data should lie, given the posterior. If the data + strays outside it systematically, the model is missing something that no amount of parameter + tuning will fix. + + Parameters + ---------- + x : np.ndarray + Independent variable of the data. + y : np.ndarray + Observed values. + predictions : np.ndarray + Model evaluations, shape ``(n_draws, len(x))``, one row per posterior draw. + y_err : np.ndarray | None, default=None + Standard deviation of the observed values, drawn as error bars when given. + title : str | None, default=None + Figure title. + credible_interval : float, default=68.0 + Width of the credible band, as a percentage. + xlabel : str | None, default=None + Label for the independent axis. + ylabel : str | None, default=None + Label for the dependent axis. + figsize : tuple[float, float], default=(8.0, 5.0) + Figure size in inches. + + Returns + ------- + Figure + The matplotlib Figure. + + Raises + ------ + ValueError + If ``predictions`` is not two-dimensional with one column per point in ``x``, or if + ``credible_interval`` is not between 0 and 100. + """ + x = np.asarray(x) + y = np.asarray(y) + predictions = np.asarray(predictions) + if predictions.ndim != 2 or predictions.shape[1] != len(x): + raise ValueError( + f'predictions must have shape (n_draws, {len(x)}). Got {predictions.shape}.' + ) + if not 0 < credible_interval < 100: + raise ValueError(f'credible_interval must be between 0 and 100. Got {credible_interval}.') + + tail = (100.0 - credible_interval) / 2.0 + lower, median, upper = np.percentile(predictions, [tail, 50.0, 100.0 - tail], axis=0) + + fig, axis = plt.subplots(figsize=figsize) + if y_err is None: + axis.plot(x, y, 'o', mfc='none', color='black', label='Data', markersize=4) + else: + axis.errorbar( + x, y, np.asarray(y_err), fmt='o', mfc='none', color='black', label='Data', markersize=4 + ) + axis.fill_between( + x, + lower, + upper, + color='C3', + alpha=0.3, + label=f'{credible_interval:.0f}% credible band', + ) + axis.plot(x, median, '-', color='C3', label='Posterior median') + if xlabel is not None: + axis.set_xlabel(xlabel) + if ylabel is not None: + axis.set_ylabel(ylabel) + axis.legend() + if title is not None: + axis.set_title(title) + fig.tight_layout() + return fig + + +def _column_limits(draws: np.ndarray) -> list[tuple[float, float]]: + """ + Compute one shared axis range per column of a corner plot. + + Parameters + ---------- + draws : np.ndarray + Posterior draws, shape ``(n_draws, n_parameters)``, all finite. + + Returns + ------- + list[tuple[float, float]] + A padded ``(low, high)`` range per column, widened to a usable span when a column is + constant. + """ + lows = draws.min(axis=0) + highs = draws.max(axis=0) + spans = highs - lows + pads = np.where(spans > 0, 0.05 * spans, 0.05 * np.maximum(np.abs(highs), 1.0)) + return [(float(low), float(high)) for low, high in zip(lows - pads, highs + pads, strict=True)] + + +def _correlation_matrix(draws: np.ndarray) -> np.ndarray: + """ + Compute the Pearson correlation matrix of a chain's columns. + + Parameters + ---------- + draws : np.ndarray + Posterior draws, shape ``(n_draws, n_parameters)``. + + Returns + ------- + np.ndarray + The ``(n_parameters, n_parameters)`` correlation matrix, two-dimensional even for a + single-parameter chain, with NaN wherever a column has zero variance. Numpy's + division-by-zero warnings for those columns are suppressed, since the NaNs are handled by + the caller rather than being a numerical accident. + """ + with np.errstate(invalid='ignore', divide='ignore'), warnings.catch_warnings(): + warnings.simplefilter('ignore', RuntimeWarning) + matrix = np.corrcoef(draws, rowvar=False) + # np.corrcoef collapses a single-column input to a 0-d scalar; restore the 1x1 matrix. + return np.atleast_2d(np.asarray(matrix, dtype=float)) + + +def _unit_for(units: list[str] | None, column: int) -> str: + """ + Get the unit to show for a column, if it is worth showing. + + Parameters + ---------- + units : list[str] | None + The units, one per column, or None. + column : int + The column to look up. + + Returns + ------- + str + The unit, or an empty string when there is none worth printing. + """ + if units is None or column >= len(units): + return '' + unit = (units[column] or '').strip() + return '' if unit.lower() in ('', 'dimensionless', 'none') else unit + + +def _with_unit(name: str, units: list[str] | None, column: int) -> str: + """ + Append a column's unit to its label. + + Parameters + ---------- + name : str + The label to extend. + units : list[str] | None + The units, one per column, or None. + column : int + The column the label belongs to. + + Returns + ------- + str + The label, with the unit in parentheses when there is one. + """ + unit = _unit_for(units, column) + return f'{name} ({unit})' if unit else name + + +def _absorb_offset( + axis_object: object, + set_label: object, + name: str, + units: list[str] | None = None, + column: int = 0, +) -> None: + """ + Move an axis' shared exponent into its label, so the two stop overlapping. + + The exponent and the unit share one set of parentheses, since two adjacent parentheticals read + badly: ``D (1e-8 m^2/s)`` rather than ``D (1e-8) (m^2/s)``. + + Parameters + ---------- + axis_object : object + The matplotlib ``XAxis`` or ``YAxis`` carrying the offset text. + set_label : object + The corresponding ``set_xlabel`` or ``set_ylabel`` callable. + name : str + The label the axis should carry, before the exponent and unit are appended. + units : list[str] | None, default=None + The units, one per column, or None. + column : int, default=0 + The column the axis belongs to. + """ + offset_text = axis_object.get_offset_text() + offset = offset_text.get_text() + unit = _unit_for(units, column) + suffix = ' '.join(part for part in (offset, unit) if part) + set_label(f'{name} ({suffix})' if suffix else name, fontsize=8) + if offset: + offset_text.set_visible(False) + + +def _verify_draws(draws: np.ndarray, names: list[str]) -> None: + """ + Verify that a draws array is two-dimensional and matches its labels. + + Parameters + ---------- + draws : np.ndarray + The posterior draws to check. + names : list[str] + The labels to check against. + + Raises + ------ + ValueError + If ``draws`` is not two-dimensional or its column count differs from ``len(names)``. + """ + if draws.ndim != 2: + raise ValueError(f'draws must be two-dimensional. Got shape {draws.shape}.') + if draws.shape[0] == 0: + raise ValueError('draws is empty: there are no samples to plot.') + if draws.shape[1] == 0: + raise ValueError('draws has no columns: there are no parameters to plot.') + if draws.shape[1] != len(names): + raise ValueError( + f'names must have one entry per column of draws. ' + f'Got {len(names)} names for {draws.shape[1]} columns.' + ) diff --git a/tests/integration/fitting/test_bayesian_sampling.py b/tests/integration/fitting/test_bayesian_sampling.py new file mode 100644 index 000000000..f341a1c84 --- /dev/null +++ b/tests/integration/fitting/test_bayesian_sampling.py @@ -0,0 +1,234 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +""" +Integration tests running real BUMPS DREAM chains through Analysis1d. + +These are slow by nature. They deliberately run with ``sampler_kwargs={'trim': False}``: BUMPS' +automatic burn-point trimming re-runs a convergence detector on every call and can crash inside its +own outlier removal on the very short chains used here. +""" + +import warnings + +import matplotlib as mpl +import numpy as np +import pytest +import scipp as sc + +mpl.use('Agg') + +from easydynamics.analysis.analysis1d import Analysis1d +from easydynamics.experiment import Experiment +from easydynamics.sample_model import InstrumentModel +from easydynamics.sample_model import SampleModel +from easydynamics.sample_model.components.gaussian import Gaussian + +TRUE_AREA = 9.0 +TRUE_WIDTH = 1.2 +NOISE = 0.05 + +# Keep the chains short enough to stay usable in CI; long enough to locate the peak. +SAMPLE_KWARGS = { + 'samples': 2000, + 'burn': 100, + 'thin': 2, + 'sampler_kwargs': {'trim': False}, +} + + +def build_analysis(): + energy_values = np.linspace(-5.0, 5.0, 60) + truth = TRUE_AREA / (TRUE_WIDTH * np.sqrt(2 * np.pi)) + truth = truth * np.exp(-0.5 * (energy_values / TRUE_WIDTH) ** 2) + observed = truth + np.random.default_rng(0).normal(0.0, NOISE, size=truth.shape) + + data = sc.array( + dims=['Q', 'energy'], + values=observed[None, :], + variances=np.full_like(observed, NOISE**2)[None, :], + ) + experiment = Experiment( + data=sc.DataArray( + data=data, + coords={ + 'Q': sc.array(dims=['Q'], values=[1.0], unit='1/Angstrom'), + 'energy': sc.array(dims=['energy'], values=energy_values, unit='meV'), + }, + ) + ) + analysis = Analysis1d( + display_name='BayesianIntegration', + experiment=experiment, + sample_model=SampleModel( + components=Gaussian(area=TRUE_AREA, width=TRUE_WIDTH, center=0.0) + ), + instrument_model=InstrumentModel(), + Q_index=0, + ) + # The energy offset shifts the spectrum exactly as the Gaussian centre does. Leaving both free + # makes the model unidentifiable, which no amount of sampling can repair. + analysis.instrument_model.fix_energy_offset(Q_index=0) + return analysis + + +@pytest.fixture(scope='module') +def sampled_analysis(): + analysis = build_analysis() + analysis.fit() + analysis.bayesian.suggest_bounds().apply() + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + analysis.bayesian.sample(**SAMPLE_KWARGS) + return analysis + + +class TestRealChain: + def test_chain_has_one_column_per_free_parameter(self, sampled_analysis): + # THEN + results = sampled_analysis.bayesian.results + + # EXPECT + assert results.draws.shape[1] == len(sampled_analysis.get_free_parameters()) + assert results.draws.shape[0] > 0 + + @pytest.mark.parametrize( + ('name', 'truth'), + [('Gaussian area', TRUE_AREA), ('Gaussian width', TRUE_WIDTH)], + ) + def test_posterior_recovers_the_true_parameters(self, sampled_analysis, name, truth): + # THEN + entry = sampled_analysis.bayesian.summary()[name] + + # EXPECT the truth sits within a few posterior standard deviations of the median. A 68% + # interval is deliberately not used: it excludes the truth about a third of the time for + # any single noise realization, which would make this test flaky rather than strict. + spread = max(entry.minus, entry.plus) + assert abs(entry.median - truth) < 4 * spread + + def test_summary_is_reported_under_parameter_names_and_units(self, sampled_analysis): + # THEN + summary = sampled_analysis.bayesian.summary() + + # EXPECT + assert {entry.name for entry in summary} == { + p.name for p in sampled_analysis.get_free_parameters() + } + assert all(entry.unit == 'meV' for entry in summary) + + def test_sampling_leaves_the_fitted_values_untouched(self): + # WHEN + analysis = build_analysis() + analysis.fit() + analysis.bayesian.suggest_bounds().apply() + before = [float(p.value) for p in analysis.get_free_parameters()] + + # THEN + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + analysis.bayesian.sample(**SAMPLE_KWARGS) + + # EXPECT + after = [float(p.value) for p in analysis.get_free_parameters()] + assert after == pytest.approx(before) + + def test_extend_grows_the_chain(self, sampled_analysis): + # WHEN + before = int(sampled_analysis.bayesian.results.state.Ngen) + + # THEN + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + extended = sampled_analysis.bayesian.extend( + additional_samples=500, thin=2, sampler_kwargs={'trim': False} + ) + + # EXPECT + assert int(extended.state.Ngen) > before + + def test_save_and_load_round_trip_keeps_parameter_identity(self, sampled_analysis, tmp_path): + # WHEN + prefix = str(tmp_path / 'chain') + sampled_analysis.bayesian.save(prefix) + + fresh = build_analysis() + fresh.fit() + fresh.bayesian.suggest_bounds().apply() + + # THEN + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + fresh.bayesian.load(prefix) + + # EXPECT the reloaded chain is reported under real names, not internal unique names + summary = fresh.bayesian.summary() + assert {entry.name for entry in summary} == {p.name for p in fresh.get_free_parameters()} + assert all(np.isfinite(entry.value) for entry in summary) + + def test_subset_sampling_produces_a_single_column(self): + # WHEN + analysis = build_analysis() + analysis.fit() + analysis.bayesian.suggest_bounds().apply() + + # THEN + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + results = analysis.bayesian.sample(parameters=['Gaussian width'], **SAMPLE_KWARGS) + + # EXPECT + assert results.draws.shape[1] == 1 + assert analysis.bayesian.summary().entries[0].name == 'Gaussian width' + + def test_plots_render(self, sampled_analysis): + # WHEN + import matplotlib.pyplot as plt + + n_parameters = len(sampled_analysis.get_free_parameters()) + + # THEN + trace = sampled_analysis.bayesian.plot_trace() + corner = sampled_analysis.bayesian.plot_corner() + predictive = sampled_analysis.bayesian.plot_posterior_predictive(n_draws=20) + + # EXPECT + assert len(trace.axes) == n_parameters + 1 + assert len(corner.axes) == n_parameters**2 + assert len(predictive.axes) == 1 + plt.close('all') + + def test_marginal_and_correlation_figures_render(self, sampled_analysis): + # WHEN + import matplotlib.pyplot as plt + + n_parameters = len(sampled_analysis.get_free_parameters()) + + # THEN + marginal = sampled_analysis.bayesian.plot_marginal('Gaussian width') + correlations = sampled_analysis.bayesian.plot_correlations() + + # EXPECT a real chain renders both figures + assert len(marginal.axes) == 1 + matrix = correlations.axes[0].images[0].get_array() + assert matrix.shape == (n_parameters, n_parameters) + assert np.asarray(np.diag(matrix)) == pytest.approx(np.ones(n_parameters)) + plt.close('all') + + def test_posterior_median_is_close_to_the_least_squares_fit(self): + # WHEN + analysis = build_analysis() + analysis.fit() + analysis.bayesian.suggest_bounds().apply() + fitted = {p.name: float(p.value) for p in analysis.get_free_parameters()} + + # THEN + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + analysis.bayesian.sample(**SAMPLE_KWARGS) + summary = analysis.bayesian.summary() + + # EXPECT the two agree within the posterior's own uncertainty, since with flat priors the + # maximum-likelihood point sits inside the bulk of the posterior + for entry in summary: + spread = max(entry.minus, entry.plus) + assert abs(entry.median - fitted[entry.name]) < 5 * spread diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index 85804738f..99ab53ab5 100644 --- a/tests/unit/easydynamics/analysis/test_analysis1d.py +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -8,6 +8,7 @@ import numpy as np import pytest import scipp as sc +from easyscience.fitting import AvailableMinimizers from easyscience.variable import Parameter from easydynamics.analysis.analysis1d import Analysis1d @@ -132,6 +133,77 @@ def test__calculate_adds_sample_and_background(self, analysis1d): analysis1d._evaluate_with_convolution.assert_called_once() analysis1d._evaluate_direct.assert_called_once() + ############# + # The cached fitter + ############# + + @pytest.fixture + def fittable_analysis1d(self): + # The analysis1d fixture holds three points against three free parameters, which leaves + # a fit with no degrees of freedom. This one has a curve to land on. + energy_values = np.linspace(-5.0, 5.0, 20) + intensity = 3.0 * np.exp(-0.5 * (energy_values / 1.2) ** 2) + data = sc.array( + dims=['Q', 'energy'], + values=intensity[None, :], + variances=np.full_like(intensity, 0.01)[None, :], + ) + experiment = Experiment( + data=sc.DataArray( + data=data, + coords={ + 'Q': sc.array(dims=['Q'], values=[1.0], unit='1/Angstrom'), + 'energy': sc.array(dims=['energy'], values=energy_values, unit='meV'), + }, + ) + ) + analysis = Analysis1d( + display_name='TestFittable', + experiment=experiment, + sample_model=SampleModel(components=Gaussian(area=3.0, width=1.2, center=0.0)), + instrument_model=InstrumentModel(), + Q_index=0, + ) + analysis.instrument_model.fix_energy_offset(Q_index=0) + return analysis + + def test_fitter_is_built_lazily_and_cached(self, analysis1d): + # THEN + fitter = analysis1d.fitter + + # EXPECT + assert fitter is analysis1d.fitter + assert fitter.fit_object is analysis1d + + def test_fitter_is_rebuilt_when_the_sample_model_changes(self, analysis1d): + # WHEN + original = analysis1d.fitter + + # THEN + analysis1d.sample_model = SampleModel(components=Gaussian(area=1.0)) + + # EXPECT + assert analysis1d.fitter is not original + + def test_minimizer_can_be_switched_through_the_fitter(self, analysis1d): + # THEN + analysis1d.fitter.switch_minimizer(AvailableMinimizers.Bumps) + + # EXPECT + assert analysis1d.fitter.minimizer.enum == AvailableMinimizers.Bumps + + def test_fit_uses_the_persistent_fitter(self, fittable_analysis1d): + # THEN + result = fittable_analysis1d.fit() + + # EXPECT + assert result is fittable_analysis1d._fit_result + assert np.isfinite(result.reduced_chi2) + + ############# + # Fitting + ############# + def test_fit_raises_if_no_experiment(self, analysis1d): # WHEN THEN analysis1d._experiment = None diff --git a/tests/unit/easydynamics/analysis/test_posterior.py b/tests/unit/easydynamics/analysis/test_posterior.py new file mode 100644 index 000000000..28817e340 --- /dev/null +++ b/tests/unit/easydynamics/analysis/test_posterior.py @@ -0,0 +1,366 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import numpy as np +import pytest +from easyscience.variable import Parameter + +from easydynamics.analysis.posterior import BoundsSuggestion +from easydynamics.analysis.posterior import BoundsSuggestions +from easydynamics.analysis.posterior import degenerate_parameters +from easydynamics.analysis.posterior import parameters_at_bounds +from easydynamics.analysis.posterior import suggest_bounds_for_parameters +from easydynamics.analysis.posterior import summarize_draws +from easydynamics.analysis.posterior import unbounded_parameters + + +def make_parameter(name='p', value=1.0, error=0.0, minimum=-np.inf, maximum=np.inf, unit='meV'): + parameter = Parameter(name=name, value=value, unit=unit) + parameter.min = minimum + parameter.max = maximum + if error: + parameter.variance = error**2 + return parameter + + +class TestSuggestBounds: + def test_fills_in_both_infinite_sides(self): + # WHEN + parameter = make_parameter(value=10.0, error=0.5) + + # THEN + suggestions = suggest_bounds_for_parameters([parameter], n_sigma=10.0, relative_pad=0.2) + + # EXPECT: 10 * 0.5 + 0.2 * 10 = 7 + suggestion = suggestions.suggestions[0] + assert suggestion.suggested_min == pytest.approx(3.0) + assert suggestion.suggested_max == pytest.approx(17.0) + assert not suggestion.needs_attention + + def test_never_loosens_an_existing_finite_bound(self): + # WHEN a physical lower bound is already set + parameter = make_parameter(value=1.2, error=1.5, minimum=1e-10) + + # THEN + suggestion = suggest_bounds_for_parameters([parameter]).suggestions[0] + + # EXPECT the finite side survives untouched, even though the sigma rule would go negative + assert suggestion.suggested_min == pytest.approx(1e-10) + assert suggestion.suggested_max > 1.2 + + def test_fully_bounded_parameter_is_left_alone(self): + # WHEN + parameter = make_parameter(value=1.0, error=0.1, minimum=0.0, maximum=2.0) + + # THEN + suggestion = suggest_bounds_for_parameters([parameter]).suggestions[0] + + # EXPECT + assert suggestion.suggested_min == pytest.approx(0.0) + assert suggestion.suggested_max == pytest.approx(2.0) + assert not suggestion.changes_bounds + + def test_zero_error_falls_back_to_the_relative_pad(self): + # WHEN a minimizer reports no uncertainty at all + parameter = make_parameter(value=4.0, error=0.0) + + # THEN + suggestion = suggest_bounds_for_parameters([parameter], relative_pad=0.25).suggestions[0] + + # EXPECT the pad still yields a usable width + assert suggestion.suggested_min == pytest.approx(3.0) + assert suggestion.suggested_max == pytest.approx(5.0) + assert not suggestion.needs_attention + + def test_zero_value_and_zero_error_is_flagged_not_guessed(self): + # WHEN there is no scale information anywhere + parameter = make_parameter(value=0.0, error=0.0) + + # THEN + suggestion = suggest_bounds_for_parameters([parameter]).suggestions[0] + + # EXPECT + assert suggestion.needs_attention + assert 'no scale information' in suggestion.reason + assert not np.isfinite(suggestion.suggested_min) + + def test_absolute_floor_rescues_a_scaleless_parameter(self): + # WHEN + parameter = make_parameter(value=0.0, error=0.0) + + # THEN + suggestion = suggest_bounds_for_parameters([parameter], absolute_floor=0.5).suggestions[0] + + # EXPECT + assert not suggestion.needs_attention + assert suggestion.suggested_min == pytest.approx(-0.5) + assert suggestion.suggested_max == pytest.approx(0.5) + + def test_non_finite_error_is_flagged_not_silently_narrowed(self): + # WHEN a degenerate fit reports a NaN uncertainty + parameter = make_parameter(value=5.0) + parameter.variance = np.nan + + # THEN + suggestion = suggest_bounds_for_parameters([parameter]).suggestions[0] + + # EXPECT a flag, rather than deceptively tight bounds from the relative pad alone + assert suggestion.needs_attention + assert 'uncertainty is not finite' in suggestion.reason + + def test_non_finite_value_is_flagged(self): + # WHEN + parameter = make_parameter(value=1.0) + parameter.value = np.inf + + # THEN + suggestion = suggest_bounds_for_parameters([parameter]).suggestions[0] + + # EXPECT + assert suggestion.needs_attention + assert 'not finite' in suggestion.reason + + @pytest.mark.parametrize('kwargs', [{'n_sigma': -1.0}, {'relative_pad': -0.1}]) + def test_negative_settings_raise(self, kwargs): + # THEN EXPECT + with pytest.raises(ValueError): + suggest_bounds_for_parameters([make_parameter()], **kwargs) + + def test_non_numeric_setting_raises(self): + # THEN EXPECT + with pytest.raises(TypeError): + suggest_bounds_for_parameters([make_parameter()], n_sigma='wide') + + +class TestBoundsSuggestionsApply: + def test_apply_sets_bounds_and_reports_changes(self): + # WHEN + parameter = make_parameter(value=10.0, error=0.5) + suggestions = suggest_bounds_for_parameters([parameter]) + + # WHEN nothing has changed until apply is called + assert parameter.max == np.inf + + # THEN + changed = suggestions.apply() + + # EXPECT + assert changed == [parameter] + assert parameter.min == pytest.approx(3.0) + assert parameter.max == pytest.approx(17.0) + + def test_apply_skips_parameters_needing_attention(self): + # WHEN + parameter = make_parameter(value=0.0, error=0.0) + suggestions = suggest_bounds_for_parameters([parameter]) + + # THEN + changed = suggestions.apply() + + # EXPECT the unusable suggestion is skipped rather than written + assert changed == [] + assert parameter.min == -np.inf + + def test_repr_lists_parameters_and_flags_attention(self): + # WHEN + good = make_parameter(name='good', value=10.0, error=0.5) + bad = make_parameter(name='bad', value=0.0, error=0.0) + + # THEN + text = repr(suggest_bounds_for_parameters([good, bad])) + + # EXPECT + assert 'good' in text + assert 'bad' in text + assert 'need bounds set by hand' in text + + def test_repr_with_no_parameters(self): + # WHEN THEN EXPECT + assert 'no free parameters' in repr(BoundsSuggestions([])) + + def test_len_and_iteration(self): + # WHEN + suggestions = suggest_bounds_for_parameters([make_parameter(), make_parameter()]) + + # THEN EXPECT + assert len(suggestions) == 2 + assert all(isinstance(s, BoundsSuggestion) for s in suggestions) + + +class TestUnboundedParameters: + def test_finds_parameters_with_an_infinite_side(self): + # WHEN + bounded = make_parameter(name='bounded', minimum=0.0, maximum=1.0) + half_open = make_parameter(name='half_open', minimum=0.0) + + # THEN + result = unbounded_parameters([bounded, half_open]) + + # EXPECT + assert result == [half_open] + + +class TestDegenerateParameters: + def test_finds_zero_width_ranges(self): + # WHEN one parameter's finite bounds enclose no range at all. The setters refuse identical + # bounds, but a deserialized or hand-built parameter can still carry them, so the internal + # state is written directly. + healthy = make_parameter(name='healthy', minimum=0.0, maximum=2.0) + degenerate = make_parameter(name='degenerate', value=1.0, minimum=0.0, maximum=1.0) + degenerate._min.value = 1.0 + + # THEN + result = degenerate_parameters([healthy, degenerate]) + + # EXPECT + assert result == [degenerate] + + def test_infinite_bounds_are_not_reported_as_degenerate(self): + # WHEN a bound is infinite, that is unboundedness rather than degeneracy + parameter = make_parameter() + + # THEN EXPECT + assert degenerate_parameters([parameter]) == [] + + +class TestParametersAtBounds: + def test_uniform_posterior_across_the_bounds_is_reported(self): + # WHEN a posterior fills its whole allowed range, the bound is setting the interval + parameter = make_parameter(minimum=0.0, maximum=1.0) + draws = np.linspace(0.0, 1.0, 1000).reshape(-1, 1) + + # THEN + result = parameters_at_bounds(draws, [parameter]) + + # EXPECT + assert parameter.unique_name in result + assert result[parameter.unique_name] == pytest.approx(0.1, abs=0.01) + + def test_posterior_well_inside_its_bounds_is_not_reported(self): + # WHEN + parameter = make_parameter(minimum=0.0, maximum=1.0) + draws = np.random.default_rng(0).normal(0.5, 0.02, size=1000).reshape(-1, 1) + + # THEN + result = parameters_at_bounds(draws, [parameter]) + + # EXPECT + assert result == {} + + def test_partly_clipped_posterior_is_reported(self): + # WHEN a posterior fills most, but not all, of its allowed range. A real bound-limited + # chain looks like this rather than perfectly uniform, so the threshold has to catch it. + parameter = make_parameter(minimum=0.0, maximum=1.0) + draws = np.linspace(0.02, 0.98, 1000).reshape(-1, 1) + + # THEN + result = parameters_at_bounds(draws, [parameter]) + + # EXPECT + assert parameter.unique_name in result + + def test_posterior_pinned_at_one_bound_is_reported(self): + # WHEN + parameter = make_parameter(minimum=0.0, maximum=1.0) + draws = np.abs(np.random.default_rng(0).normal(0.0, 0.02, size=1000)).reshape(-1, 1) + + # THEN + result = parameters_at_bounds(draws, [parameter]) + + # EXPECT + assert result[parameter.unique_name] > 0.9 + + def test_unmatched_and_unbounded_columns_are_skipped(self): + # WHEN + unbounded = make_parameter() + draws = np.zeros((10, 2)) + + # THEN + result = parameters_at_bounds(draws, [None, unbounded]) + + # EXPECT + assert result == {} + + def test_same_named_parameters_do_not_collide(self): + # WHEN two parameters share a name and both posteriors are pinned at a bound + first = make_parameter(name='width', minimum=0.0, maximum=1.0) + second = make_parameter(name='width', minimum=0.0, maximum=1.0) + draws = np.zeros((100, 2)) + + # THEN + result = parameters_at_bounds(draws, [first, second]) + + # EXPECT one entry per parameter, keyed so they cannot overwrite each other + assert len(result) == 2 + assert set(result) == {first.unique_name, second.unique_name} + + def test_zero_row_draws_return_nothing_rather_than_dividing_by_zero(self): + # WHEN + parameter = make_parameter(minimum=0.0, maximum=1.0) + draws = np.zeros((0, 1)) + + # THEN EXPECT + assert parameters_at_bounds(draws, [parameter]) == {} + + +class TestSummarizeDraws: + def test_reports_parameter_names_units_and_percentiles(self): + # WHEN + parameter = make_parameter(name='Gaussian width', value=1.5, unit='meV') + draws = np.linspace(0.0, 100.0, 101).reshape(-1, 1) + + # THEN + summary = summarize_draws(draws, ['Gaussian width'], [parameter]) + + # EXPECT + entry = summary['Gaussian width'] + assert entry.unit == 'meV' + assert entry.median == pytest.approx(50.0) + assert entry.lower == pytest.approx(16.0) + assert entry.upper == pytest.approx(84.0) + assert entry.minus == pytest.approx(34.0) + assert entry.plus == pytest.approx(34.0) + assert entry.value == pytest.approx(1.5) + + def test_labels_are_reported_verbatim(self): + # WHEN a caller supplies a qualified label, as a multi-Q analysis does + parameter = make_parameter(name='Gaussian width') + + # THEN + summary = summarize_draws(np.zeros((5, 1)), ['Gaussian width (Q_index=2)'], [parameter]) + + # EXPECT + assert summary.entries[0].name == 'Gaussian width (Q_index=2)' + assert summary.entries[0].unit == 'meV' + + def test_unmatched_column_falls_back_to_the_supplied_name(self): + # WHEN + draws = np.zeros((10, 1)) + + # THEN + summary = summarize_draws(draws, ['Parameter_7'], [None]) + + # EXPECT + entry = summary.entries[0] + assert entry.name == 'Parameter_7' + assert entry.unit == '' + assert np.isnan(entry.value) + + def test_lookup_of_missing_name_raises(self): + # WHEN + summary = summarize_draws(np.zeros((5, 1)), ['x'], [None]) + + # THEN EXPECT + with pytest.raises(KeyError): + summary['not a parameter'] + + def test_repr_contains_the_parameter_name(self): + # WHEN + parameter = make_parameter(name='Gaussian area') + + # THEN + text = repr(summarize_draws(np.zeros((5, 1)), ['Gaussian area'], [parameter])) + + # EXPECT + assert 'Gaussian area' in text + assert 'median' in text diff --git a/tests/unit/easydynamics/analysis/test_posterior_labels.py b/tests/unit/easydynamics/analysis/test_posterior_labels.py new file mode 100644 index 000000000..618c8284c --- /dev/null +++ b/tests/unit/easydynamics/analysis/test_posterior_labels.py @@ -0,0 +1,149 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import numpy as np +from easyscience.variable import Parameter + +from easydynamics.analysis.posterior_labels import ParameterLabels + + +def make_parameter(name, unit='meV'): + return Parameter(name=name, value=1.0, unit=unit) + + +class TestLabelling: + def test_unique_names_are_left_alone(self): + # WHEN nothing is ambiguous, a qualifier would only cost width + parameters = [make_parameter('area'), make_parameter('width')] + + # THEN + labels = ParameterLabels(parameters, qualify=lambda _p: 'Q_index=0') + + # EXPECT + assert [labels.label(p) for p in parameters] == ['area', 'width'] + + def test_shared_names_are_qualified(self): + # WHEN two parameters share a name + first, second = make_parameter('width'), make_parameter('width') + owners = {first.unique_name: 'Q_index=0', second.unique_name: 'Q_index=1'} + + # THEN + labels = ParameterLabels([first, second], qualify=lambda p: owners[p.unique_name]) + + # EXPECT + assert labels.label(first) == 'width (Q_index=0)' + assert labels.label(second) == 'width (Q_index=1)' + + def test_a_qualifier_that_declines_leaves_the_name_alone(self): + # WHEN the qualifier cannot identify an owner, as for a parameter shared across Q + first, second = make_parameter('width'), make_parameter('width') + + # THEN + labels = ParameterLabels([first, second], qualify=lambda _p: None) + + # EXPECT the plain name rather than an invented qualifier + assert labels.label(first) == 'width' + + def test_without_a_qualifier_names_stay_bare(self): + # WHEN + first, second = make_parameter('width'), make_parameter('width') + + # THEN + labels = ParameterLabels([first, second]) + + # EXPECT + assert labels.label(first) == 'width' + + +class TestChainColumns: + def test_columns_resolve_by_unique_name(self): + # WHEN + parameters = [make_parameter('area'), make_parameter('width')] + labels = ParameterLabels(parameters) + columns = [p.unique_name for p in reversed(parameters)] + + # THEN EXPECT resolution follows the chain's order, not the parameter list's + assert labels.resolve(columns) == list(reversed(parameters)) + assert labels.display_names(columns) == ['width', 'area'] + assert labels.units(columns) == ['meV', 'meV'] + + def test_a_saved_chain_resolves_through_its_labels(self): + # WHEN a chain was saved in another session, so its unique names mean nothing here + original = make_parameter('width') + saved = {original.unique_name: 'width'} + current = make_parameter('width') + + # THEN + labels = ParameterLabels([current]) + + # EXPECT the saved label finds the parameter this session has + assert labels.resolve([original.unique_name], saved) == [current] + assert labels.display_names([original.unique_name], saved) == ['width'] + + def test_an_unknown_column_is_reported_not_guessed(self): + # THEN + labels = ParameterLabels([make_parameter('area')]) + + # EXPECT None rather than a wrong parameter, and the raw name to show something + assert labels.resolve(['Parameter_999']) == [None] + assert labels.display_names(['Parameter_999']) == ['Parameter_999'] + assert labels.units(['Parameter_999']) == [''] + + def test_colliding_labels_get_distinct_sidecar_entries(self): + # WHEN two parameters end up with the same display label + first, second = make_parameter('width'), make_parameter('width') + + # THEN + labels = ParameterLabels([first, second]) + + # EXPECT deterministic positional suffixes in the sidecar mapping, rather than a silent + # last-write-wins, while the display label stays bare + assert labels.name_map() == { + first.unique_name: 'width [1]', + second.unique_name: 'width [2]', + } + assert labels.label(first) == 'width' + + def test_colliding_labels_round_trip_to_their_own_parameters(self): + # WHEN a chain of two same-labelled parameters was saved in another session + old_first, old_second = make_parameter('width'), make_parameter('width') + saved = ParameterLabels([old_first, old_second]).name_map() + new_first, new_second = make_parameter('width'), make_parameter('width') + + # THEN + labels = ParameterLabels([new_first, new_second]) + resolved = labels.resolve([old_first.unique_name, old_second.unique_name], saved) + + # EXPECT each column finds its own parameter, not both the same one + assert resolved == [new_first, new_second] + + def test_name_map_records_labels_against_unique_names(self): + # WHEN + first, second = make_parameter('width'), make_parameter('width') + owners = {first.unique_name: 'Q_index=0', second.unique_name: 'Q_index=1'} + + # THEN + labels = ParameterLabels([first, second], qualify=lambda p: owners[p.unique_name]) + + # EXPECT what save() writes alongside a chain + assert labels.name_map() == { + first.unique_name: 'width (Q_index=0)', + second.unique_name: 'width (Q_index=1)', + } + + +class TestCost: + def test_labelling_does_not_rescan_per_parameter(self): + # WHEN there are many parameters. Computing the name counts per parameter is quadratic, + # which was seconds of work for an analysis with many Q values. + parameters = [make_parameter(f'p{i // 2}') for i in range(400)] + labels = ParameterLabels(parameters, qualify=lambda _p: 'q') + + # THEN EXPECT labelling all of them stays cheap + import time + + start = time.perf_counter() + names = [labels.label(p) for p in parameters] + assert time.perf_counter() - start < 0.5 + assert len(names) == len(parameters) + assert np.all([n.endswith('(q)') for n in names]) diff --git a/tests/unit/easydynamics/analysis/test_posterior_sampling.py b/tests/unit/easydynamics/analysis/test_posterior_sampling.py new file mode 100644 index 000000000..5fb5cd24e --- /dev/null +++ b/tests/unit/easydynamics/analysis/test_posterior_sampling.py @@ -0,0 +1,907 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +""" +Unit tests for the posterior sampler, driven through an Analysis1d, with the EasyScience Sampler +mocked out. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock +from unittest.mock import patch + +import numpy as np +import pytest +import scipp as sc +from easyscience.fitting import AvailableMinimizers +from easyscience.variable import Parameter + +from easydynamics.analysis.analysis1d import Analysis1d +from easydynamics.experiment import Experiment +from easydynamics.sample_model import InstrumentModel +from easydynamics.sample_model import SampleModel +from easydynamics.sample_model.components.gaussian import Gaussian + +SAMPLER_PATH = 'easydynamics.analysis.posterior_sampling.Sampler' + + +def make_analysis(with_variances=True): + energy_values = np.linspace(-5.0, 5.0, 20) + intensity = 3.0 * np.exp(-0.5 * (energy_values / 1.2) ** 2) + data = sc.array( + dims=['Q', 'energy'], + values=intensity[None, :], + variances=np.full_like(intensity, 0.01)[None, :] if with_variances else None, + ) + experiment = Experiment( + data=sc.DataArray( + data=data, + coords={ + 'Q': sc.array(dims=['Q'], values=[1.0], unit='1/Angstrom'), + 'energy': sc.array(dims=['energy'], values=energy_values, unit='meV'), + }, + ) + ) + analysis = Analysis1d( + display_name='TestBayesian', + experiment=experiment, + sample_model=SampleModel(components=Gaussian(area=3.0, width=1.2, center=0.0)), + instrument_model=InstrumentModel(), + Q_index=0, + ) + analysis.instrument_model.fix_energy_offset(Q_index=0) + return analysis + + +def bound_all(analysis, half_width=5.0): + """Give every free parameter finite bounds so the pre-flight passes.""" + for parameter in analysis.get_free_parameters(): + parameter.min = float(parameter.value) - half_width + parameter.max = float(parameter.value) + half_width + + +def fake_results(analysis, n_draws=100, values=None): + """Build a SamplingResults-shaped object for the free parameters of an analysis.""" + parameters = analysis.get_free_parameters() + if values is None: + draws = np.tile([float(p.value) for p in parameters], (n_draws, 1)) + else: + draws = np.asarray(values, dtype=float) + return SimpleNamespace( + draws=draws, + param_names=[p.unique_name for p in parameters], + logp=np.zeros(draws.shape[0]), + state=MagicMock(Ngen=10, Npop=4), + ) + + +@pytest.fixture +def analysis(): + return make_analysis() + + +class TestPosteriorSampler: + ############# + # Bounds pre-flight + ############# + + def test_sampling_refuses_unbounded_parameters(self, analysis): + # THEN EXPECT + with pytest.raises(ValueError, match='finite bounds'): + analysis.bayesian.sample(samples=10) + + def test_error_names_the_offending_parameters(self, analysis): + # THEN EXPECT + with pytest.raises(ValueError, match='Gaussian area'): + analysis.bayesian.check_bounds() + + def test_bounded_parameters_pass(self, analysis): + # WHEN + bound_all(analysis) + + # THEN EXPECT: does not raise + analysis.bayesian.check_bounds() + + def test_suggest_bounds_covers_the_free_parameters(self, analysis): + # THEN + suggestions = analysis.bayesian.suggest_bounds() + + # EXPECT + assert len(suggestions) == len(analysis.get_free_parameters()) + + def test_degenerate_bounds_are_rejected(self, analysis): + # WHEN one parameter's bounds collapse to a zero-width range, which internal state can + # carry even though the setters refuse it + bound_all(analysis) + parameter = analysis.get_free_parameters()[0] + parameter._min.value = float(parameter.max) + + # THEN EXPECT + with pytest.raises(ValueError, match='degenerate bounds'): + analysis.bayesian.check_bounds() + + ############# + # Sampling + ############# + + def test_restores_parameter_values_and_minimizer(self, analysis): + # WHEN + bound_all(analysis) + before = [(p.unique_name, p.value) for p in analysis.get_free_parameters()] + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + + def mutate_then_return(**_kwargs): + # The real sampler leaves the parameters wherever the last evaluation put them. + for parameter in analysis.get_free_parameters(): + parameter.value = float(parameter.value) + 1.0 + return fake_results(analysis) + + sampler_class.return_value.sample.side_effect = mutate_then_return + analysis.bayesian.sample(samples=10, burn=1, thin=1) + + # EXPECT + after = [(p.unique_name, p.value) for p in analysis.get_free_parameters()] + assert after == before + assert analysis.fitter.minimizer.enum == AvailableMinimizers.LMFit_leastsq + + def test_switches_to_bumps_for_the_run(self, analysis): + # WHEN + bound_all(analysis) + seen = [] + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: ( + seen.append(analysis.fitter.minimizer.enum), + fake_results(analysis), + )[1] + analysis.bayesian.sample(samples=10) + + # EXPECT + assert seen == [AvailableMinimizers.Bumps] + + def test_restores_the_minimizer_even_when_sampling_raises(self, analysis): + # WHEN + bound_all(analysis) + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = RuntimeError('boom') + with pytest.raises(RuntimeError, match='boom'): + analysis.bayesian.sample(samples=10) + + # EXPECT + assert analysis.fitter.minimizer.enum == AvailableMinimizers.LMFit_leastsq + + def test_forwards_sampling_arguments(self, analysis): + # WHEN + bound_all(analysis) + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=123, burn=7, thin=3, population=5) + + # EXPECT + kwargs = sampler_class.return_value.sample.call_args.kwargs + assert kwargs['samples'] == 123 + assert kwargs['burn'] == 7 + assert kwargs['thin'] == 3 + assert kwargs['population'] == 5 + + def test_stores_the_result(self, analysis): + # WHEN + bound_all(analysis) + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + expected = fake_results(analysis) + sampler_class.return_value.sample.return_value = expected + returned = analysis.bayesian.sample(samples=10) + + # EXPECT + assert returned is expected + assert analysis.bayesian.results is expected + + def test_warns_when_the_posterior_piles_up_against_a_bound(self, analysis): + # WHEN a parameter's draws span its whole allowed range + bound_all(analysis) + parameters = analysis.get_free_parameters() + draws = np.tile([float(p.value) for p in parameters], (500, 1)) + draws[:, 0] = np.linspace(parameters[0].min, parameters[0].max, 500) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_results(analysis, values=draws) + + # THEN EXPECT + with pytest.warns(UserWarning, match='piled up'): + analysis.bayesian.sample(samples=10) + + def test_sampling_with_no_free_parameters_raises(self, analysis): + # WHEN every parameter is fixed + for parameter in analysis.get_free_parameters(): + parameter.fixed = True + + # THEN EXPECT a clear refusal, rather than a zero-parameter failure deep in BUMPS + with pytest.raises(ValueError, match='no free parameters to sample'): + analysis.bayesian.sample(samples=10) + + def test_does_not_warn_when_the_posterior_is_well_inside(self, analysis): + # WHEN + bound_all(analysis) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_results(analysis) + + # THEN EXPECT + with warnings_as_errors(): + analysis.bayesian.sample(samples=10) + + ############# + # Parameter subsets + ############# + + def test_holds_other_parameters_fixed_during_the_run(self, analysis): + # WHEN + bound_all(analysis) + target = analysis.get_free_parameters()[0] + seen = {} + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + + def record(**_kwargs): + seen['free'] = [p.unique_name for p in analysis.get_free_parameters()] + return fake_results(analysis) + + sampler_class.return_value.sample.side_effect = record + with pytest.warns(UserWarning, match='Holding these parameters fixed'): + analysis.bayesian.sample(samples=10, parameters=[target.name]) + + # EXPECT + assert seen['free'] == [target.unique_name] + + def test_restores_the_fixed_flags_afterwards(self, analysis): + # WHEN + bound_all(analysis) + before = [(p.unique_name, p.fixed) for p in analysis.get_all_parameters()] + target = analysis.get_free_parameters()[0] + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + with pytest.warns(UserWarning): + analysis.bayesian.sample(samples=10, parameters=[target]) + + # EXPECT + assert [(p.unique_name, p.fixed) for p in analysis.get_all_parameters()] == before + + def test_unknown_parameter_name_raises(self, analysis): + # WHEN + bound_all(analysis) + + # THEN EXPECT + with pytest.raises(ValueError, match='No free parameter named'): + analysis.bayesian.sample(samples=10, parameters=['not a parameter']) + + def test_fixed_parameter_object_is_rejected(self, analysis): + # WHEN a Parameter object that is currently fixed is requested + bound_all(analysis) + target = analysis.get_free_parameters()[0] + target.fixed = True + + # THEN EXPECT the same membership check a label gets, instead of every free parameter + # ending up held fixed and BUMPS failing with zero parameters + with pytest.raises(ValueError, match='not a free parameter'): + analysis.bayesian.sample(samples=10, parameters=[target]) + + def test_parameter_from_another_model_is_rejected(self, analysis): + # WHEN + bound_all(analysis) + foreign = Parameter(name='foreign', value=1.0, unit='meV') + + # THEN EXPECT + with pytest.raises(ValueError, match='not a free parameter'): + analysis.bayesian.sample(samples=10, parameters=[foreign]) + + def test_non_list_parameters_raises(self, analysis): + # THEN EXPECT + with pytest.raises(TypeError, match='must be a list'): + analysis.bayesian.sample(samples=10, parameters='Gaussian area') + + def test_empty_parameter_list_raises(self, analysis): + # THEN EXPECT + with pytest.raises(ValueError, match='at least one parameter'): + analysis.bayesian.sample(samples=10, parameters=[]) + + ############# + # Sampler caching + ############# + + def test_sampler_is_reused_between_runs(self, analysis): + # WHEN + bound_all(analysis) + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10) + analysis.bayesian.sample(samples=10) + + # EXPECT the data is bound once, not per run + assert sampler_class.call_count == 1 + + def test_changing_the_q_index_rebuilds_the_sampler(self, analysis): + # WHEN + bound_all(analysis) + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10) + analysis.Q_index = 0 + analysis.bayesian.sample(samples=10) + + # EXPECT the Sampler binds its data at construction, so it must be rebuilt + assert sampler_class.call_count == 2 + + def test_binds_the_same_data_the_fit_uses(self, analysis): + # WHEN + bound_all(analysis) + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10) + + # EXPECT + expected_x, expected_y, expected_w = analysis._sampling_data() + args, kwargs = sampler_class.call_args + assert np.array_equal(args[1], expected_x) + assert np.array_equal(args[2], expected_y) + assert np.array_equal(kwargs['weights'], expected_w) + + ############# + # Extending and persistence + ############# + + def test_extend_without_a_chain_raises(self, analysis): + # THEN EXPECT + with pytest.raises(RuntimeError, match='No chain to extend'): + analysis.bayesian.extend() + + def test_extend_delegates_to_the_sampler(self, analysis): + # WHEN + bound_all(analysis) + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + sampler_class.return_value.extend.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10) + analysis.bayesian.extend(additional_samples=42, thin=2) + + # EXPECT + kwargs = sampler_class.return_value.extend.call_args.kwargs + assert kwargs['additional_samples'] == 42 + assert kwargs['thin'] == 2 + + def test_extend_with_different_parameters_raises(self, analysis): + # WHEN a chain was sampled over one parameter + bound_all(analysis) + first, second = analysis.get_free_parameters()[:2] + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + sampler_class.return_value.extend.side_effect = lambda **_k: fake_results(analysis) + with pytest.warns(UserWarning, match='Holding these parameters fixed'): + analysis.bayesian.sample(samples=10, parameters=[first.name]) + + # THEN EXPECT extending with a different parameter, even at the same chain width, + # is refused rather than silently merging draws of different quantities + with ( + pytest.warns(UserWarning, match='Holding these parameters fixed'), + pytest.raises(ValueError, match='holds draws of'), + ): + analysis.bayesian.extend(parameters=[second.name]) + + def test_extend_after_a_data_change_raises(self, analysis): + # WHEN the data changed after the chain was started + bound_all(analysis) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10) + analysis.bayesian.invalidate() + + # THEN EXPECT the stale chain is refused rather than silently continued + with pytest.raises(ValueError, match='model or data has changed'): + analysis.bayesian.extend() + + def test_extend_after_a_failed_run_raises(self, analysis): + # WHEN the previous run failed after building the sampler, leaving no results + bound_all(analysis) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = RuntimeError('boom') + with pytest.raises(RuntimeError, match='boom'): + analysis.bayesian.sample(samples=10) + + # THEN EXPECT + with pytest.raises(RuntimeError, match='left no results'): + analysis.bayesian.extend() + + def test_save_without_a_chain_raises(self, analysis): + # THEN EXPECT + with pytest.raises(RuntimeError, match='No chain to save'): + analysis.bayesian.save('somewhere') + + def test_save_writes_the_parameter_name_sidecar(self, analysis, tmp_path): + # WHEN + import json + + bound_all(analysis) + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10) + analysis.bayesian.save(str(tmp_path / 'chain')) + + # EXPECT the unique names are recorded against the stable parameter names + sidecar = tmp_path / 'chain.parameter-names.json' + assert sidecar.is_file() + mapping = json.loads(sidecar.read_text(encoding='utf-8')) + assert set(mapping.values()) == {p.name for p in analysis.get_free_parameters()} + + def test_load_without_a_sidecar_warns(self, analysis, tmp_path): + # WHEN + bound_all(analysis) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.load_state.return_value = fake_results(analysis) + + # THEN EXPECT + with pytest.warns(UserWarning, match='No parameter-name sidecar'): + analysis.bayesian.load(str(tmp_path / 'missing')) + + def test_load_with_an_empty_sidecar_warns_like_a_missing_one(self, analysis, tmp_path): + # WHEN a sidecar file exists but records no labels + bound_all(analysis) + (tmp_path / 'chain.parameter-names.json').write_text('{}', encoding='utf-8') + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.load_state.return_value = fake_results(analysis) + + # THEN EXPECT + with pytest.warns(UserWarning, match='No parameter-name sidecar'): + analysis.bayesian.load(str(tmp_path / 'chain')) + + def test_load_passes_skip_through(self, analysis, tmp_path): + # WHEN + bound_all(analysis) + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.load_state.return_value = fake_results(analysis) + with pytest.warns(UserWarning, match='No parameter-name sidecar'): + analysis.bayesian.load(str(tmp_path / 'chain'), skip=7) + + # EXPECT + assert sampler_class.return_value.load_state.call_args.kwargs['skip'] == 7 + + def test_save_after_a_sidecarless_load_writes_no_empty_sidecar(self, analysis, tmp_path): + # WHEN a chain was loaded without a sidecar, so there are no labels to record + bound_all(analysis) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.load_state.return_value = fake_results(analysis) + with pytest.warns(UserWarning, match='No parameter-name sidecar'): + analysis.bayesian.load(str(tmp_path / 'original')) + + # THEN EXPECT saving warns instead of writing an empty sidecar, which a later load() + # would mistake for a valid one and resolve every column to raw names + with pytest.warns(UserWarning, match='no parameter-name sidecar was written'): + analysis.bayesian.save(str(tmp_path / 'resaved')) + + # EXPECT + assert not (tmp_path / 'resaved.parameter-names.json').exists() + + ############# + # Results + ############# + + def test_summary_without_sampling_raises(self, analysis): + # THEN EXPECT + with pytest.raises(RuntimeError, match='No posterior samples yet'): + analysis.bayesian.summary() + + def test_summary_uses_parameter_names_and_units(self, analysis): + # WHEN + bound_all(analysis) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10) + + # THEN + summary = analysis.bayesian.summary() + + # EXPECT + names = {entry.name for entry in summary} + assert names == {p.name for p in analysis.get_free_parameters()} + assert all(entry.unit == 'meV' for entry in summary) + + def test_set_parameters_to_posterior_median(self, analysis): + # WHEN + bound_all(analysis) + parameters = analysis.get_free_parameters() + draws = np.tile([float(p.value) + 2.0 for p in parameters], (50, 1)) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_results(analysis, values=draws) + expected = [float(p.value) + 2.0 for p in parameters] + analysis.bayesian.sample(samples=10) + + # THEN + changed = analysis.bayesian.set_parameters_to_median() + + # EXPECT + assert len(changed) == len(parameters) + assert [float(p.value) for p in parameters] == pytest.approx(expected) + + def test_median_without_sampling_raises(self, analysis): + # THEN EXPECT + with pytest.raises(RuntimeError, match='No posterior samples yet'): + analysis.bayesian.set_parameters_to_median() + + ############# + # Plots + ############# + + def test_predictive_rejects_a_bad_draw_count(self, analysis): + # WHEN + bound_all(analysis) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10) + + # THEN EXPECT + with pytest.raises(ValueError, match='positive integer'): + analysis.bayesian.plot_posterior_predictive(n_draws=0) + + def test_predictive_restores_parameter_values(self, analysis): + # WHEN + bound_all(analysis) + parameters = analysis.get_free_parameters() + draws = np.tile([float(p.value) + 0.5 for p in parameters], (20, 1)) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_results(analysis, values=draws) + analysis.bayesian.sample(samples=10) + + before = [float(p.value) for p in parameters] + + # THEN + analysis.bayesian.plot_posterior_predictive(n_draws=5) + + # EXPECT + assert [float(p.value) for p in parameters] == pytest.approx(before) + + def test_plots_without_sampling_raise(self, analysis): + # THEN EXPECT + with pytest.raises(RuntimeError): + analysis.bayesian.plot_trace() + with pytest.raises(RuntimeError): + analysis.bayesian.plot_corner() + + def test_predictive_forwards_the_measured_error_bars(self, analysis): + # WHEN the data carries variances of 0.01, i.e. an uncertainty of 0.1 + bound_all(analysis) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10) + + # THEN + with patch('easydynamics.utils.posterior_plotting.plot_posterior_predictive') as plot: + analysis.bayesian.plot_posterior_predictive(n_draws=2) + + # EXPECT + assert plot.call_args.kwargs['y_err'] == pytest.approx(np.full(20, 0.1)) + + def test_predictive_omits_error_bars_when_the_data_has_no_variances(self): + # WHEN the data has no variances, so the weights are all-ones placeholders + analysis = make_analysis(with_variances=False) + bound_all(analysis) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10) + + # THEN + with patch('easydynamics.utils.posterior_plotting.plot_posterior_predictive') as plot: + analysis.bayesian.plot_posterior_predictive(n_draws=2) + + # EXPECT no error bars fabricated from the placeholder weights + assert plot.call_args.kwargs['y_err'] is None + + def test_marginal_forwards_the_resolved_column(self, analysis): + # WHEN the width column carries distinctive draws + bound_all(analysis) + parameters = analysis.get_free_parameters() + column = [p.name for p in parameters].index('Gaussian width') + draws = np.tile([float(p.value) for p in parameters], (30, 1)) + draws[:, column] += np.random.default_rng(0).normal(scale=0.01, size=30) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_results(analysis, values=draws) + analysis.bayesian.sample(samples=10) + + # THEN + with patch('easydynamics.utils.posterior_plotting.plot_marginal') as plot: + analysis.bayesian.plot_marginal('Gaussian width', bins=13) + + # EXPECT the label resolved to that column's draws, name and unit + kwargs = plot.call_args.kwargs + assert np.array_equal(kwargs['values'], draws[:, column]) + assert kwargs['name'] == 'Gaussian width' + assert kwargs['unit'] == 'meV' + assert kwargs['bins'] == 13 + + def test_marginal_accepts_a_parameter_object(self, analysis): + # WHEN + bound_all(analysis) + target = analysis.get_free_parameters()[0] + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10) + + # THEN + with patch('easydynamics.utils.posterior_plotting.plot_marginal') as plot: + analysis.bayesian.plot_marginal(target) + + # EXPECT + assert plot.call_args.kwargs['name'] == target.name + + def test_marginal_unknown_label_raises_naming_the_available_ones(self, analysis): + # WHEN + bound_all(analysis) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10) + + # THEN EXPECT + with pytest.raises(ValueError, match='No sampled parameter named') as excinfo: + analysis.bayesian.plot_marginal('not a parameter') + assert 'Gaussian width' in str(excinfo.value) + + def test_marginal_foreign_parameter_raises(self, analysis): + # WHEN + bound_all(analysis) + foreign = Parameter(name='foreign', value=1.0, unit='meV') + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10) + + # THEN EXPECT + with pytest.raises(ValueError, match='No sampled parameter named'): + analysis.bayesian.plot_marginal(foreign) + + def test_marginal_without_sampling_raises(self, analysis): + # THEN EXPECT + with pytest.raises(RuntimeError, match='No posterior samples yet'): + analysis.bayesian.plot_marginal('Gaussian width') + + def test_correlations_use_the_display_names(self, analysis): + # WHEN + bound_all(analysis) + parameters = analysis.get_free_parameters() + draws = np.tile([float(p.value) for p in parameters], (30, 1)) + draws += np.random.default_rng(0).normal(scale=0.01, size=draws.shape) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_results(analysis, values=draws) + analysis.bayesian.sample(samples=10) + + # THEN + with patch('easydynamics.utils.posterior_plotting.plot_correlations') as plot: + analysis.bayesian.plot_correlations() + + # EXPECT the chain's draws under the parameters' own names + kwargs = plot.call_args.kwargs + assert np.array_equal(kwargs['draws'], draws) + assert kwargs['names'] == [p.name for p in parameters] + + def test_correlations_without_sampling_raise(self, analysis): + # THEN EXPECT + with pytest.raises(RuntimeError, match='No posterior samples yet'): + analysis.bayesian.plot_correlations() + + ############# + # Progress reporting + ############# + + def test_progress_is_off_by_default(self, analysis): + # WHEN + bound_all(analysis) + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10) + + # EXPECT + assert 'progress_callback' not in sampler_class.return_value.sample.call_args.kwargs + + def test_progress_installs_a_callback(self, analysis): + # WHEN + bound_all(analysis) + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10, progress=True) + + # EXPECT + kwargs = sampler_class.return_value.sample.call_args.kwargs + assert callable(kwargs['progress_callback']) + + def test_progress_reports_and_finishes_the_line(self, analysis, capsys): + # WHEN + bound_all(analysis) + + # THEN the sampler drives the installed callback, as BUMPS does per generation + with patch(SAMPLER_PATH) as sampler_class: + + def run_reporting_progress(**kwargs): + for iteration in (1, 5, 10): + kwargs['progress_callback']({ + 'iteration': iteration, + 'total_steps': 10, + 'sampling': True, + }) + return fake_results(analysis) + + sampler_class.return_value.sample.side_effect = run_reporting_progress + results = analysis.bayesian.sample(samples=10, progress=True) + + # EXPECT progress was printed and the line was finished, without breaking the results + out = capsys.readouterr().out + assert '100%' in out + assert 'Sampling: done' in out + assert out.endswith('\n') + assert analysis.bayesian.results is results + + def test_progress_line_is_not_marked_done_when_sampling_fails(self, analysis, capsys): + # WHEN + bound_all(analysis) + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + + def fail_after_progress(**kwargs): + kwargs['progress_callback']({'iteration': 1, 'total_steps': 10}) + raise RuntimeError('boom') + + sampler_class.return_value.sample.side_effect = fail_after_progress + with pytest.raises(RuntimeError, match='boom'): + analysis.bayesian.sample(samples=10, progress=True) + + # EXPECT the line is terminated but not decorated with a claim of success + out = capsys.readouterr().out + assert 'done' not in out + assert out.endswith('\n') + + def test_progress_defers_to_an_explicit_callback(self, analysis): + # WHEN the caller supplies their own callback alongside progress=True + bound_all(analysis) + explicit = MagicMock() + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10, progress=True, progress_callback=explicit) + + # EXPECT the explicit callback is forwarded untouched + kwargs = sampler_class.return_value.sample.call_args.kwargs + assert kwargs['progress_callback'] is explicit + + def test_extend_supports_progress(self, analysis): + # WHEN + bound_all(analysis) + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + sampler_class.return_value.extend.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10) + analysis.bayesian.extend(additional_samples=10, progress=True) + + # EXPECT + kwargs = sampler_class.return_value.extend.call_args.kwargs + assert callable(kwargs['progress_callback']) + + ############# + # Predictions + ############# + + def test_predictions_have_one_row_per_selected_draw(self, analysis): + # WHEN + bound_all(analysis) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results( + analysis, n_draws=100 + ) + analysis.bayesian.sample(samples=10) + + # THEN + predictions = analysis.bayesian.predictions(n_draws=10) + + # EXPECT + x, _, _ = analysis._sampling_data() + assert predictions.shape == (10, len(x)) + + def test_predictions_clamp_to_the_chain_length(self, analysis): + # WHEN more draws are requested than the chain holds + bound_all(analysis) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results( + analysis, n_draws=100 + ) + analysis.bayesian.sample(samples=10) + + # THEN + predictions = analysis.bayesian.predictions(n_draws=500) + + # EXPECT one row per available draw, not 500 + x, _, _ = analysis._sampling_data() + assert predictions.shape == (100, len(x)) + + def test_predictions_take_draws_evenly_across_the_chain(self, analysis): + # WHEN the area column identifies each draw, since the model scales linearly with it + bound_all(analysis, half_width=500.0) + parameters = analysis.get_free_parameters() + column = [p.name for p in parameters].index('Gaussian area') + draws = np.tile([float(p.value) for p in parameters], (100, 1)) + draws[:, column] = 1.0 + np.arange(100.0) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_results(analysis, values=draws) + analysis.bayesian.sample(samples=10) + + # THEN + predictions = analysis.bayesian.predictions(n_draws=5) + + # EXPECT rows for draws 0, 24, 49, 74 and 99, read back through the model's linear + # scaling with the area + amplitudes = predictions.max(axis=1) + expected = draws[[0, 24, 49, 74, 99], column] + assert amplitudes / amplitudes[0] == pytest.approx(expected / expected[0]) + + +class warnings_as_errors: + """Context manager asserting that no UserWarning is emitted inside the block.""" + + def __enter__(self): + import warnings + + self._ctx = warnings.catch_warnings(record=True) + self._caught = self._ctx.__enter__() + warnings.simplefilter('always') + return self + + def __exit__(self, *exc_info): + caught = [w for w in self._caught if issubclass(w.category, UserWarning)] + self._ctx.__exit__(*exc_info) + if exc_info[0] is None: + assert not caught, f'unexpected warnings: {[str(w.message) for w in caught]}' + return False diff --git a/tests/unit/easydynamics/experiment/test_experiment.py b/tests/unit/easydynamics/experiment/test_experiment.py index 2329e29a2..1aebc0436 100644 --- a/tests/unit/easydynamics/experiment/test_experiment.py +++ b/tests/unit/easydynamics/experiment/test_experiment.py @@ -637,6 +637,19 @@ def testextract_x_y_weights_only_finite_zero_variance(self, experiment_with_data assert np.array_equal(weights, np.ones_like(y)) assert np.array_equal(mask, np.isfinite(y) & np.isfinite(x)) + def test_has_variances_true_when_the_data_carries_them(self, experiment_with_data): + # WHEN THEN EXPECT + assert experiment_with_data.has_variances + + def test_has_variances_false_when_the_data_has_none(self, experiment): + # WHEN THEN EXPECT the fixture's data has no variances, so the all-ones weights that + # extract_x_y_weights_only_finite falls back to are recognisable as placeholders + assert not experiment.has_variances + + def test_has_variances_false_without_data(self): + # WHEN THEN EXPECT + assert not Experiment().has_variances + ############## # test dunder methods ############## diff --git a/tests/unit/easydynamics/utils/test_posterior_plotting.py b/tests/unit/easydynamics/utils/test_posterior_plotting.py new file mode 100644 index 000000000..c470bdd20 --- /dev/null +++ b/tests/unit/easydynamics/utils/test_posterior_plotting.py @@ -0,0 +1,352 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import matplotlib as mpl +import numpy as np +import pytest + +mpl.use('Agg') + +import matplotlib.pyplot as plt + +from easydynamics.utils.posterior_plotting import plot_corner +from easydynamics.utils.posterior_plotting import plot_correlations +from easydynamics.utils.posterior_plotting import plot_marginal +from easydynamics.utils.posterior_plotting import plot_posterior_predictive +from easydynamics.utils.posterior_plotting import plot_trace + + +@pytest.fixture(autouse=True) +def close_figures(): + yield + plt.close('all') + + +@pytest.fixture +def draws(): + return np.random.default_rng(0).normal(size=(200, 3)) + + +class TestPlotTrace: + def test_one_panel_per_parameter(self, draws): + # THEN + fig = plot_trace(draws=draws, names=['a', 'b', 'c']) + + # EXPECT + assert len(fig.axes) == 3 + + def test_logp_adds_a_panel(self, draws): + # THEN + fig = plot_trace(draws=draws, names=['a', 'b', 'c'], logp=np.zeros(len(draws))) + + # EXPECT + assert len(fig.axes) == 4 + assert fig.axes[-1].get_ylabel() == 'log-posterior' + + def test_names_label_the_panels(self, draws): + # THEN + fig = plot_trace(draws=draws, names=['alpha', 'beta', 'gamma']) + + # EXPECT + assert [axis.get_ylabel() for axis in fig.axes] == ['alpha', 'beta', 'gamma'] + + def test_single_parameter_works(self): + # THEN + fig = plot_trace(draws=np.zeros((10, 1)), names=['only']) + + # EXPECT + assert len(fig.axes) == 1 + + def test_mismatched_names_raise(self, draws): + # THEN EXPECT + with pytest.raises(ValueError, match='one entry per column'): + plot_trace(draws=draws, names=['a', 'b']) + + def test_one_dimensional_draws_raise(self): + # THEN EXPECT + with pytest.raises(ValueError, match='two-dimensional'): + plot_trace(draws=np.zeros(10), names=['a']) + + def test_zero_row_draws_raise(self): + # THEN EXPECT + with pytest.raises(ValueError, match='no samples'): + plot_trace(draws=np.zeros((0, 2)), names=['a', 'b']) + + def test_zero_column_draws_raise(self): + # THEN EXPECT + with pytest.raises(ValueError, match='no parameters'): + plot_trace(draws=np.zeros((5, 0)), names=[]) + + def test_a_single_draw_keeps_a_usable_axis(self): + # THEN + fig = plot_trace(draws=np.ones((1, 2)), names=['a', 'b']) + + # EXPECT a non-inverted, non-degenerate x range + left, right = fig.axes[0].get_xlim() + assert left < right + + def test_mismatched_logp_length_raises(self, draws): + # THEN EXPECT + with pytest.raises(ValueError, match='one entry per draw'): + plot_trace(draws=draws, names=['a', 'b', 'c'], logp=np.zeros(len(draws) - 1)) + + +class TestPlotCorner: + def test_grid_is_square_in_the_parameter_count(self, draws): + # THEN + fig = plot_corner(draws=draws, names=['a', 'b', 'c']) + + # EXPECT + assert len(fig.axes) == 9 + + def test_upper_triangle_is_hidden(self, draws): + # THEN + fig = plot_corner(draws=draws, names=['a', 'b', 'c']) + + # EXPECT: 3 hidden panels above the diagonal of a 3x3 grid + assert sum(not axis.get_visible() for axis in fig.axes) == 3 + + def test_mismatched_names_raise(self, draws): + # THEN EXPECT + with pytest.raises(ValueError, match='one entry per column'): + plot_corner(draws=draws, names=['a']) + + def test_non_finite_draws_raise_naming_the_column(self, draws): + # WHEN one column contains a NaN + draws[5, 1] = np.nan + + # THEN EXPECT a clear error naming that column, not numpy's "range [nan, nan]" + with pytest.raises(ValueError, match='non-finite') as excinfo: + plot_corner(draws=draws, names=['a', 'b', 'c']) + assert ': b.' in str(excinfo.value) + + def test_columns_share_limits_between_histogram_and_hexbin_panels(self, draws): + # THEN + fig = plot_corner(draws=draws, names=['a', 'b', 'c']) + + # EXPECT every panel of a column agrees with the diagonal histogram on x-limits, so the + # ticks line up down the column + grid = np.array(fig.axes, dtype=object).reshape(3, 3) + for col in range(3): + column_limits = [grid[row, col].get_xlim() for row in range(col, 3)] + assert all(limits == pytest.approx(column_limits[0]) for limits in column_limits) + + +class TestPlotMarginal: + @pytest.fixture + def values(self): + return np.random.default_rng(0).normal(size=5000) + + def test_returns_a_single_axis_figure(self, values): + # THEN + fig = plot_marginal(values=values, name='width') + + # EXPECT + assert len(fig.axes) == 1 + + def test_marks_the_median_and_the_credible_interval(self, values): + # THEN + fig = plot_marginal(values=values, name='width') + + # EXPECT three vertical lines at the 16th, 50th and 84th percentiles + positions = sorted(line.get_xdata()[0] for line in fig.axes[0].lines) + assert positions == pytest.approx(np.percentile(values, [16.0, 50.0, 84.0])) + + def test_legend_names_the_median_and_the_interval(self, values): + # THEN + fig = plot_marginal(values=values, name='width') + + # EXPECT + labels = [text.get_text() for text in fig.axes[0].get_legend().get_texts()] + assert 'Median' in labels + assert any('credible interval' in label for label in labels) + + def test_histogram_is_density_normalized(self, values): + # WHEN values are standard-normal draws, whose density peaks near 0.4 + + # THEN + fig = plot_marginal(values=values, name='width') + + # EXPECT the peak reads as a probability density, not a raw count of thousands + peak = fig.axes[0].dataLim.ymax + assert 0.2 < peak < 0.7 + + def test_unit_is_appended_to_the_label(self, values): + # THEN + fig = plot_marginal(values=values, name='width', unit='meV') + + # EXPECT + assert fig.axes[0].get_xlabel() == 'width (meV)' + + def test_dimensionless_unit_is_skipped(self, values): + # THEN + fig = plot_marginal(values=values, name='area', unit='dimensionless') + + # EXPECT + assert fig.axes[0].get_xlabel() == 'area' + + def test_two_dimensional_values_raise(self): + # THEN EXPECT + with pytest.raises(ValueError, match='one-dimensional'): + plot_marginal(values=np.zeros((10, 2)), name='width') + + def test_empty_values_raise(self): + # THEN EXPECT + with pytest.raises(ValueError, match='no samples'): + plot_marginal(values=np.zeros(0), name='width') + + def test_non_finite_values_raise_naming_the_parameter(self, values): + # WHEN + values[3] = np.nan + + # THEN EXPECT a clear error, not numpy's "range [nan, nan]" + with pytest.raises(ValueError, match='non-finite') as excinfo: + plot_marginal(values=values, name='width') + assert 'width' in str(excinfo.value) + + +class TestPlotCorrelations: + def test_labels_both_axes_with_the_names(self, draws): + # THEN + fig = plot_correlations(draws=draws, names=['a', 'b', 'c']) + + # EXPECT + axis = fig.axes[0] + assert [text.get_text() for text in axis.get_xticklabels()] == ['a', 'b', 'c'] + assert [text.get_text() for text in axis.get_yticklabels()] == ['a', 'b', 'c'] + + def test_diagonal_is_one(self, draws): + # THEN + fig = plot_correlations(draws=draws, names=['a', 'b', 'c']) + + # EXPECT + matrix = fig.axes[0].images[0].get_array() + assert np.asarray(np.diag(matrix)) == pytest.approx(np.ones(3)) + + def test_every_cell_is_annotated(self, draws): + # THEN + fig = plot_correlations(draws=draws, names=['a', 'b', 'c']) + + # EXPECT + assert len(fig.axes[0].texts) == 9 + + def test_color_limits_span_the_full_correlation_range(self, draws): + # THEN + fig = plot_correlations(draws=draws, names=['a', 'b', 'c']) + + # EXPECT the diverging map is centred on 0 regardless of the data + assert fig.axes[0].images[0].get_clim() == (-1.0, 1.0) + + def test_has_a_colorbar(self, draws): + # THEN + fig = plot_correlations(draws=draws, names=['a', 'b', 'c']) + + # EXPECT + assert len(fig.axes) == 2 + + def test_correlated_columns_read_near_one(self): + # WHEN two columns are almost the same draw + rng = np.random.default_rng(0) + base = rng.normal(size=500) + draws = np.column_stack([base, base + rng.normal(scale=1e-6, size=500)]) + + # THEN + fig = plot_correlations(draws=draws, names=['a', 'b']) + + # EXPECT + matrix = fig.axes[0].images[0].get_array() + assert matrix[0, 1] == pytest.approx(1.0, abs=1e-6) + + def test_single_parameter_chain_works(self): + # THEN + fig = plot_correlations(draws=np.random.default_rng(0).normal(size=(50, 1)), names=['a']) + + # EXPECT a 1x1 matrix whose only entry is 1 + matrix = fig.axes[0].images[0].get_array() + assert matrix.shape == (1, 1) + assert matrix[0, 0] == pytest.approx(1.0) + + def test_constant_column_is_masked_without_warnings(self, draws): + # WHEN one column has zero variance, so its correlations are undefined + import warnings + + draws[:, 1] = 2.5 + + # THEN numpy's zero-variance warnings are suppressed rather than leaking out + with warnings.catch_warnings(): + warnings.simplefilter('error') + fig = plot_correlations(draws=draws, names=['a', 'b', 'c']) + + # EXPECT the undefined cells are masked and annotated as unavailable + matrix = fig.axes[0].images[0].get_array() + assert matrix.mask[0, 1] + assert any(text.get_text() == 'n/a' for text in fig.axes[0].texts) + + def test_mismatched_names_raise(self, draws): + # THEN EXPECT + with pytest.raises(ValueError, match='one entry per column'): + plot_correlations(draws=draws, names=['a']) + + +class TestPlotPosteriorPredictive: + def test_returns_a_figure_with_data_and_band(self): + # WHEN + x = np.linspace(0.0, 1.0, 25) + predictions = np.random.default_rng(0).normal(size=(50, 25)) + + # THEN + fig = plot_posterior_predictive(x=x, y=np.zeros(25), predictions=predictions) + + # EXPECT + labels = [text.get_text() for text in fig.axes[0].get_legend().get_texts()] + assert 'Data' in labels + assert any('credible band' in label for label in labels) + + def test_error_bars_are_drawn_when_given(self): + # WHEN + x = np.linspace(0.0, 1.0, 10) + + # THEN + fig = plot_posterior_predictive( + x=x, + y=np.zeros(10), + predictions=np.zeros((5, 10)), + y_err=np.full(10, 0.1), + ) + + # EXPECT + assert len(fig.axes[0].containers) == 1 + + def test_wrong_prediction_shape_raises(self): + # THEN EXPECT + with pytest.raises(ValueError, match='predictions must have shape'): + plot_posterior_predictive(x=np.zeros(10), y=np.zeros(10), predictions=np.zeros((5, 3))) + + @pytest.mark.parametrize('interval', [0.0, 100.0, -5.0]) + def test_invalid_credible_interval_raises(self, interval): + # THEN EXPECT + with pytest.raises(ValueError, match='credible_interval'): + plot_posterior_predictive( + x=np.zeros(4), + y=np.zeros(4), + predictions=np.zeros((5, 4)), + credible_interval=interval, + ) + + def test_band_widens_with_the_credible_interval(self): + # WHEN + x = np.linspace(0.0, 1.0, 8) + predictions = np.random.default_rng(0).normal(size=(400, 8)) + + # THEN + narrow = plot_posterior_predictive( + x=x, y=np.zeros(8), predictions=predictions, credible_interval=50.0 + ) + wide = plot_posterior_predictive( + x=x, y=np.zeros(8), predictions=predictions, credible_interval=95.0 + ) + + # EXPECT + narrow_span = narrow.axes[0].collections[0].get_paths()[0].get_extents().height + wide_span = wide.axes[0].collections[0].get_paths()[0].get_extents().height + assert wide_span > narrow_span diff --git a/tools/prefetch_tutorial_data.py b/tools/prefetch_tutorial_data.py new file mode 100644 index 000000000..839b1897a --- /dev/null +++ b/tools/prefetch_tutorial_data.py @@ -0,0 +1,92 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +""" +Download every data file the tutorial notebooks fetch, once, before they are run. + +The notebooks are executed in parallel with ``pytest -n auto``, and several of them fetch the same +file through ``pooch``. On a cold cache the workers race: one is still writing the file into the +cache while another tries to open it, which fails on Windows with a permission error. Fetching +everything up front leaves the parallel run with nothing to do but read. + +Run as ``python tools/prefetch_tutorial_data.py``; it is wired into the ``notebook-tests`` task. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +import pooch + +TUTORIALS = Path(__file__).resolve().parent.parent / 'docs' / 'docs' / 'tutorials' + +# Matches the pooch.retrieve(url=..., known_hash=...) calls the notebooks use, in either order. +URL_PATTERN = re.compile(r"url\s*=\s*f?['\"]([^'\"]+)['\"]") +HASH_PATTERN = re.compile(r"known_hash\s*=\s*['\"]([^'\"]+)['\"]") + + +def find_downloads() -> dict[str, str]: + """ + Collect the ``(url, known_hash)`` pairs the notebooks fetch. + + Returns + ------- + dict[str, str] + Mapping of URL to expected hash, deduplicated across notebooks. + """ + downloads: dict[str, str] = {} + for notebook in sorted(TUTORIALS.glob('*.ipynb')): + cells = json.loads(notebook.read_text(encoding='utf-8'))['cells'] + for cell in cells: + if cell['cell_type'] != 'code': + continue + source = ''.join(cell['source']) + if 'pooch.retrieve' not in source: + continue + urls = URL_PATTERN.findall(source) + hashes = HASH_PATTERN.findall(source) + # Only pairs are usable; a templated URL without a literal hash is skipped rather than + # guessed at, and the notebook will simply fetch it itself. + for url, known_hash in zip(urls, hashes, strict=False): + downloads[url] = known_hash + return downloads + + +def main() -> int: + """ + Fetch every tutorial data file into the pooch cache. + + Deliberately never fails: this only warms a cache. A file that cannot be fetched here is left + to the notebook that needs it, which reports the problem with far more context than this script + could, and which is where the failure belongs. + + Returns + ------- + int + Always zero. + """ + downloads = find_downloads() + if not downloads: + sys.stdout.write('No tutorial downloads found.\n') + return 0 + + failures = 0 + for url, known_hash in downloads.items(): + name = url.rsplit('/', 1)[-1] + try: + pooch.retrieve(url=url, known_hash=known_hash) + except Exception as error: # noqa: BLE001 - report and continue, the notebook will retry + failures += 1 + sys.stdout.write(f'could not prefetch {name}, leaving it to the notebook: {error}\n') + else: + sys.stdout.write(f'cached {name}\n') + + sys.stdout.write(f'{len(downloads) - failures}/{len(downloads)} tutorial data files ready.\n') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) From abb5e216560a309239d5069ac509c6b60af6bb4a Mon Sep 17 00:00:00 2001 From: Henrik Jacobsen Date: Mon, 17 Aug 2026 21:45:58 +0200 Subject: [PATCH 2/5] Add Bayesian posterior sampling to Analysis and ParameterAnalysis (#238) * Add Bayesian posterior sampling to Analysis1d Expose the EasyScience Fitter on Analysis1d and add MCMC posterior sampling on top of it, using the BUMPS DREAM sampler introduced in easyscience 2.5.1 (easyscience.fitting.Sampler). Least-squares fitting reports a single point with a curvature-derived uncertainty, which is only trustworthy when parameters are uncorrelated and roughly Gaussian. Sampling maps the whole posterior instead, so correlated and skewed parameters get honest credible intervals. The sampling machinery lives in a mixin with three hooks (build the fitter, bind the data, list the chain parameters) so that Analysis and ParameterAnalysis can reuse it. ParameterAnalysis is not an AnalysisBase and builds a MultiFitter over binding models rather than over itself, so a shared base class would not have worked. Notable details: - fit() now uses a cached Fitter instead of building one per call, and the cache is invalidated through the existing dirty-flag pattern. - Bounds are the prior in DREAM, so sampling refuses to run with any infinite bound. suggest_bounds() proposes finite ones from the fitted values and uncertainties; it is advisory until .apply() is called and never loosens a bound that is already finite, so physical limits survive. A zero-width suggestion is flagged rather than invented. - Sampling restores parameter values afterwards, since BUMPS leaves them wherever the last likelihood evaluation put them. - Chains are reported under Parameter.name, not the internal unique_name. Those names are per-session, so save_chain() writes a sidecar mapping them to stable names and load_chain() uses it; loading without one warns rather than mislabelling the columns. - After sampling, a warning fires when the posterior has piled up against a bound, which catches both bounds that are too tight and degenerate parameters that drift until a bound stops them. - BUMPS crashes with a bare IndexError inside its own outlier removal when chains scatter, which in practice means a degenerate model. That is re-raised with the likely cause and a workaround. Co-Authored-By: Claude Opus 5 (1M context) * Add Bayesian posterior sampling to Analysis and ParameterAnalysis Extends the sampling introduced for Analysis1d to the remaining two Analysis classes, using the mixin hooks added with it. No new sampling machinery: each class supplies its fitter, its data, and its chain parameters, and everything else is shared. Analysis gains sample_posterior(fit_method=...), mirroring fit(): - 'independent' gives each Q index its own chain, delegating to the Analysis1d objects, and returns one result per Q (or a single result when a Q_index is given). - 'simultaneous' runs one chain over every Q at once through a MultiFitter, refreshing each per-Q convolver against its masked energy grid first, exactly as the simultaneous fit does. ParameterAnalysis samples the binding models. Its fit() built the MultiFitter inline, so the per-target data, functions, and models are now resolved by a shared _build_fit_inputs() that both paths use, which also guarantees fitting and sampling see the same targets in the same order with the same unit conversions. Parameter labels needed rethinking. A multi-Q analysis holds one copy of each parameter per Q, all sharing a name, so a summary showed several identical rows and a name could not pick a parameter out. Labels are now produced by an overridable parameter_label(): Analysis qualifies by Q index, ParameterAnalysis by binding model, and both only when the bare name is actually ambiguous, so single-Q and single-binding cases keep their short names. The summary and bounds tables size themselves to the longest label rather than truncating. Also fixes Analysis.fit's docstring, which promised a single FitResults for a simultaneous fit. MultiFitter splits its combined result back up by dataset, so a list has always been returned. Tutorial 1 gains a Bayesian section on the two-step diffusion fit, where the posterior turns out to be about twelve times tighter than the reported least-squares uncertainties. That gap is real and worth explaining: the width fit has a reduced chi-squared near 150, so lmfit inflates its uncertainties by the square root of that, while the sampler takes the stated uncertainties at face value. Sampling the full simultaneous diffusion model was measured at over ten minutes, so the tutorial uses the ParameterAnalysis step instead. Co-Authored-By: Claude Opus 5 (1M context) * Label the posterior plot axes with units and quantities The summary table already reported each parameter's unit, but the plots did not, so a diffusion coefficient came out as a bare number. Units are now threaded through to plot_trace and plot_corner, and the posterior predictive plot gets axis labels taken from the analysis' own energy and intensity units. Details that needed care: - Matplotlib parks a shared exponent at the end of the axis, on top of the axis label. It is now folded into the label, sharing one set of parentheses with the unit, so a diffusion coefficient reads "diffusion_coefficient (1e-8 m^2/s)" rather than stacking two parentheticals or overlapping. - Dimensionless and empty units are skipped. A polynomial coefficient labelled "dimensionless" is noise. - The top-left panel of a corner plot is a histogram, so its vertical axis counts draws rather than carrying a parameter. It is now labelled "counts" instead of being left blank, which read as an omission. - Corner tick counts are capped, since four labelled ticks per panel is as much as a small panel can carry legibly. Co-Authored-By: Claude Opus 5 (1M context) * Qualify parameter labels by model name, and cover the remaining branches Two fixes found by writing the tests codecov asked for. ParameterAnalysis qualified an ambiguous parameter with the owning model's display_name, but for several models -- the diffusion models among them -- display_name is the class name, so two models constructed as name='Diffusion A' and name='Diffusion B' both came back as "BrownianTranslationalDiffusion" and the label did not disambiguate anything. It now uses the model's name, matching the choice to report parameters under their name rather than their display name, and falls back to the unique name only when the names collide too. The rest is test coverage for branches that were reachable but untested: the label fallbacks, the BUMPS outlier crash being re-raised as a degeneracy hint, a chain column that matches no parameter, loading a chain through its sidecar, the mixin's unimplemented hooks, and the scientific-notation exponent being folded into an axis label. Co-Authored-By: Claude Opus 5 (1M context) * Warm the tutorial data cache before running notebooks in parallel The notebook tests run with '-n auto', and five of the notebooks fetch vanadium_data_example.h5 through pooch. On a cold cache the workers race: one is still writing the file into the cache while another opens it, which fails on Windows with "PermissionError: Permission denied". This failed twice in a row on windows-latest, always on that file, always with the other sixteen notebooks passing. The race is pre-existing, but adding a fifth notebook that wants the same file, and lengthening tutorial 1, made it reliable rather than rare. Fetching every tutorial data file once, before the parallel run starts, leaves the workers with nothing to do but read, which is safe. The prefetch reads the URLs and hashes out of the notebooks themselves, so it cannot drift from what they actually download, and it never fails the run: a file it cannot fetch is left to the notebook that needs it, which reports the problem with far more context. Co-Authored-By: Claude Opus 5 (1M context) * Rebuild the fitter when a binding changes shape, and stabilise the integration tests Two problems found while reviewing the previous commits. Caching the MultiFitter on ParameterAnalysis introduced a regression. A FitBinding can be edited in place -- binding.targets = ... -- which ParameterAnalysis cannot observe. Changing the number of targets left the cached fitter holding one fit function against two datasets, and fit() died with "FitError: list index out of range". It rebuilt every call before, so this worked previously. The targets the fitter was built for are now recorded and compared, which is enough to catch an edit that cannot be observed directly. The integration tests then failed in CI on macOS, inside BUMPS' outlier removal, on an identifiable model. That matters beyond the test: the error message claimed the crash means degenerate parameters, and this shows short chains do it too. The message now names both causes, and the integration tests switch the outlier removal off, as they already do for the burn-point trimming. Co-Authored-By: Claude Opus 5 (1M context) * Address the review findings on the sampling API Six issues found reviewing the previous commits. The sidecar could be written with the wrong labels. A subset run built the name map inside the block that holds the other parameters fixed, where nothing looks ambiguous, so a multi-Q chain recorded unqualified names that no longer matched on reload. The map is now built outside that block, where the free set is the user's real one. extend_sampling() accepted a different parameter subset. BUMPS resumes from a stored chain whose width is fixed, so that could only fail deep inside the sampler; it is now refused up front. The IndexError relabelling was unconditional, so an IndexError from this package would have been reported as a BUMPS modelling problem. It now only applies when the traceback passes through bumps. Labelling a chain was quadratic in the parameter count: collecting the parameters and scanning for their owner both happened per parameter, and each walks every sub-model. 75 parameters took 0.39 s, and every summary and plot pays it. The parameters are now collected once per pass, and Analysis keeps an owner index alongside its analysis list. The same case now measures at 0.00 s. Asking an Analysis for a summary after sampling independently reported that nothing had been sampled, moments after it had. It now says where the chains actually are. Applying bounds many orders of magnitude wider than the parameter is still allowed -- it is what the fit implied -- but no longer silent, so a scripted apply() cannot hide a degeneracy the table would have shown. Co-Authored-By: Claude Opus 5 (1M context) * Cover the review fixes, and drop a redundant guard Three lines the review fixes added were not reachable from the unit tests. Two are now covered: extending after a run that died before storing results, where the chain-shape guard has nothing to compare against, and a parameter shared across every Q index, which is left out of the owner map because no single Q identifies it. The third was the non-finite check in the absurd-width test, and it was redundant rather than untested: an infinite width already compares greater than any threshold, and the zero-scale case returns before it. Removed, so the behaviour is unchanged and there is no dead branch. Co-Authored-By: Claude Opus 5 (1M context) * Gather the per-Q chains on Analysis after independent sampling Sampling with fit_method='independent' left the results only on the Analysis1d objects, so the Analysis that produced them could not report on them. It now gathers them, but only where gathering is sound. posterior_summary() collects every Q into one table, labelled by Q index, and set_parameters_to_posterior_median() applies each chain to its own Q. Both are per-parameter marginal operations, and a marginal is well defined within its own chain, so combining them across separate chains says nothing that was not sampled. plot_corner() deliberately does not aggregate. Independent sampling draws each Q separately, so no draw pairs a parameter at one Q with a parameter at another, and a corner plot built from them would show correlations that are an artefact of how the sampling was run rather than anything measured. It says so and points at the per-Q corner plots, which are real. plot_trace() likewise, the chains being separate runs of different lengths rather than one trace. posterior_results exposes the per-Q chains directly, and a simultaneous chain still takes precedence over stale per-Q ones. Co-Authored-By: Claude Opus 5 (1M context) * Step through the per-Q corner plots with a slider Independent chains share no draws, so there is no joint distribution across Q to plot, and combining them would show correlations that came from how the sampling was run rather than from the data. Refusing outright was correct but unhelpful: the correlations within each Q are real and worth looking at. Analysis.plot_corner() now shows one Q at a time. Pass Q_index for a particular one, or leave it out in a notebook for a slider across the Q values that were sampled. A simultaneous chain is unaffected; it already covers every Q in one figure. Outside a notebook the error names the sampled Q indices rather than only saying no. The slider is built with append_display_data rather than the Output widget's context manager. The context manager is the obvious choice and captures nothing under some kernels, which would have shipped a slider with a permanently blank panel beside it. Verified by executing a notebook against a real kernel, and the test asserts the panel actually holds a figure, since an empty panel is the regression that matters. Co-Authored-By: Claude Opus 5 (1M context) * Show the per-Q corner slider in the Bayesian tutorial The slider was described in the tutorial's caveats but never demonstrated: every notebook call to plot_corner() went through the single-chain path, because the Bayesian tutorial used Analysis1d and tutorial 1 used ParameterAnalysis, neither of which has a Q dimension. So the only things exercising it were the unit tests. The tutorial now builds the full multi-Q Analysis, samples a few Q values, gathers them with posterior_summary(), and shows the slider. It samples Q indices 4, 8 and 12 rather than all sixteen. Sampling every Q measured at 70 s against 16 s for three, and the subset also shows two things worth showing: that sampling is slow enough to be worth trying a few Q values first, and that the slider offers only the Q values that were actually sampled. Verified against a real kernel that the cell emits a widget view, rather than only that the notebook ran without raising. Co-Authored-By: Claude Opus 5 (1M context) * Put the corner slider under the figure Matches where plopp puts its slicer controls, which is also where the existing slicerplot_with_residuals puts them via the figure's bottom bar. Co-Authored-By: Claude Opus 5 (1M context) * Compose the posterior sampler instead of mixing it in Review feedback: bayesian_sampling.py had a lot in it that belonged elsewhere, and it was unclear why it was a mixin at all. It was a mixin because ParameterAnalysis is not an AnalysisBase and fits its binding models rather than itself, so a shared base class does not work. That was a reason, not a good one: it injected some forty methods into every Analysis class. The sampler is now composed. An Analysis exposes one `bayesian` property, and hands the sampler the few things that differ between the Analysis classes -- the data, the free parameters, their labels, and a hook to refresh cached computation -- so PosteriorSampler needs no knowledge of how any Analysis is built, and no Analysis inherits sampling machinery it does not use. Labelling moves to posterior_labels.py. Building it once for a fixed set of parameters also removes the quadratic cost the old code needed a scoped cache to avoid: the counts and lookups are computed in the constructor rather than per column. Plotting stays in posterior_plotting.py, where it already lived. The sampler keeps three short delegates so a chain can still be plotted from the object holding it, but none of the drawing happens there. The public API becomes analysis.bayesian.sample() and friends, and the explicit suggest_bounds().apply() step stays: in DREAM the bounds are the prior, and an unbounded parameter gives a confident-looking interval set by nothing. Co-Authored-By: Claude Opus 5 (1M context) * Export the multi-Q sampler and drop the mixin's name The section headers still pointed at a class that no longer exists, and MultiQPosteriorSampler was reachable only through Analysis.bayesian. Co-Authored-By: Claude Opus 5 (1M context) * Warm the tutorial data cache before running notebooks in parallel The notebook tests run with '-n auto', and five of the notebooks fetch vanadium_data_example.h5 through pooch. On a cold cache the workers race: one is still writing the file into the cache while another opens it, which fails on Windows with "PermissionError: Permission denied". This failed twice in a row on windows-latest, always on that file, always with the other sixteen notebooks passing. The race is pre-existing, but adding a fifth notebook that wants the same file, and lengthening tutorial 1, made it reliable rather than rare. Fetching every tutorial data file once, before the parallel run starts, leaves the workers with nothing to do but read, which is safe. The prefetch reads the URLs and hashes out of the notebooks themselves, so it cannot drift from what they actually download, and it never fails the run: a file it cannot fetch is left to the notebook that needs it, which reports the problem with far more context. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 46d745a4a73e8025c37e02e490fab67cfdb5ff22) * Mark setup, action and expectation apart in the new tests The sampling tests labelled the action WHEN and had no THEN, so a reader could not see where the arrangement stopped and the call under test began. Setup is WHEN, the action is THEN, the assertions are EXPECT, and steps that genuinely collapse onto one statement carry one combined marker instead. Comments only; no test changed what it does. Co-Authored-By: Claude Opus 5 (1M context) * Mark setup, action and expectation apart in the multi-Q tests Same pass as on the single-Q tests: setup is WHEN, the action is THEN, the assertions are EXPECT, and a step that collapses onto one statement carries one combined marker. Comments only; no test changed what it does. Co-Authored-By: Claude Opus 5 (1M context) * Give the sampler its own test file Tests were split by feature rather than by the file they exercise, so posterior_sampling.py had no test file of its own and Analysis1d had two. The sampler's tests now live in test_posterior_sampling.py under one TestPosteriorSampler, with the old class names as section banners, and the four tests that are really about Analysis1d's cached fitter move into TestAnalysis1d. No test changed what it does; the same 31 + 4 tests run as before. Co-Authored-By: Claude Opus 5 (1M context) * Put each test in the file of the class it exercises Analysis and ParameterAnalysis each had a second test file, and the sampler had none of its own. The sampler's tests, whichever analysis drives them, now live in test_posterior_sampling.py under TestPosteriorSampler and TestMultiQPosteriorSampler; the fitter, chain parameter and label tests move into TestAnalysis and TestParameterAnalysis. Old class names became section banners. The multi-Q and ParameterAnalysis helpers keep distinct names in the merged file, since their signatures differ from the single-Q ones. The same 1660 tests run as before. Co-Authored-By: Claude Opus 5 (1M context) * Refuse silent chain corruption and harden the posterior sampler - extend() now verifies the chain holds the same parameters, not just the same number, and refuses to resume after a failed run or after the model or data changed - Parameter objects passed to sample(parameters=...) are validated against the free set the same way strings are - sampling with no free parameters and degenerate (min >= max) bounds raise clear errors before reaching BUMPS - parameters_at_bounds keys by unique_name so same-named per-Q parameters no longer collide, and guards empty draws - suggest_bounds flags non-finite fitted uncertainties for attention - save() refuses to write an empty label sidecar; loading one warns like a missing sidecar - colliding display labels get positional suffixes in the sidecar so save/load resolves each column to its own parameter - plot_posterior_predictive omits error bars when the data carries no variances (new Experiment.has_variances) - posterior plots validate draws/logp up front, name NaN columns, and share x-limits per corner column - document that sampling runs are not seedable Co-Authored-By: Claude Fable 5 * Keep the multi-Q sampler pointed at the chain the user actually ran - sampling one Q index independently now clears a stale simultaneous chain, so summary(), set_parameters_to_median() and plot_corner() report the run the user just made instead of the old one - extend() and save() after an independent run explain that the chains live on the per-Q analyses instead of resuming or saving the stale simultaneous chain; a genuinely failed run keeps its own message - Q_index arguments are validated like every Analysis method, so a negative index raises instead of silently wrapping - the gathered summary resolves each per-Q chain through its own saved labels, so chains loaded from disk keep names and units - warnings are attributed to the caller on both the single-Q and multi-Q paths, and the corner-plot slider forwards plot kwargs - the multi-Q integration tests share one independent sampling run, assert the straight line is actually recovered, and the extend test no longer mutates the shared fixture Co-Authored-By: Claude Fable 5 * Add marginal posteriors, correlation heatmaps and sampling progress - plot_marginal(parameter) renders one parameter's posterior histogram with the median and the 16/84 percentile interval summary() reports, resolving labels the same way sample(parameters=...) does - plot_correlations() renders the Pearson correlation matrix of the chain with annotated cells, a diverging colormap and masked cells for constant columns - sample(progress=True) and extend(progress=True) report sampling progress through the Sampler's progress_callback, closing the line with an explicit done marker because BUMPS' own step estimate assumes the wrong chain count - the 95 percent predictive band needed no change: credible_interval already exists on plot_posterior_predictive Co-Authored-By: Claude Fable 5 * Give every posterior plot a Q slider over independent chains After independent per-Q sampling the multi-Q sampler now presents a Q slider instead of refusing: - plot_posterior_predictive builds the per-Q data, median and credible band into a scipp DataGroup and renders it through plopp exactly like plot_data_and_model; plopp cannot shade a band on sliced lines, so the slider view draws labelled band edges while the Q_index path keeps the shaded band - plot_trace, plot_marginal and plot_correlations take Q_index for a single figure, show a slider in a notebook, and otherwise name the sampled Q indices - the matplotlib sliders render every figure once up front and only swap PNG bytes on a move, so dragging tracks smoothly with continuous updates instead of re-rendering per change - per-Q energy grids are NaN-padded onto the common grid through the finite mask, so masked points draw as gaps Co-Authored-By: Claude Fable 5 * Write the progress line through sys.stdout Co-Authored-By: Claude Fable 5 * Show the new posterior plots in the Bayesian tutorial The tutorial now demonstrates plot_marginal and plot_correlations from the sampled chain, progress=True on the sampling call, the 95 percent predictive band option, the Q slider that every posterior plot offers over independent chains, and notes that runs are not seedable. Co-Authored-By: Claude Fable 5 * Apply the formatting fixes Co-Authored-By: Claude Fable 5 * Satisfy the docstring and formatting checks The progress reporter closes through try/finally instead of a bare re-raise, and the plotting validation errors are documented in the form the docstring linter expects. Co-Authored-By: Claude Fable 5 * Document propagated exceptions the way the docstring linter expects Co-Authored-By: Claude Fable 5 * Give the Bayesian tutorial the widget backend its sliders need The Q-slider cells go through the plopp slicer, which refuses the inline backend; every plopp-using tutorial already runs %matplotlib widget. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 5 (1M context) --- docs/docs/tutorials/bayesian.ipynb | 161 ++- docs/docs/tutorials/tutorial1_brownian.ipynb | 65 + src/easydynamics/analysis/__init__.py | 2 + src/easydynamics/analysis/analysis.py | 185 ++- .../analysis/parameter_analysis.py | 220 +++- src/easydynamics/analysis/posterior.py | 61 +- .../analysis/posterior_sampling.py | 800 +++++++++++- src/easydynamics/utils/posterior_plotting.py | 245 +++- .../fitting/test_bayesian_sampling.py | 28 +- .../fitting/test_bayesian_sampling_multi_q.py | 329 +++++ .../easydynamics/analysis/test_analysis.py | 148 +++ .../analysis/test_parameter_analysis.py | 277 +++++ .../easydynamics/analysis/test_posterior.py | 58 +- .../analysis/test_posterior_sampling.py | 1107 ++++++++++++++++- .../utils/test_posterior_plotting.py | 245 ++++ 15 files changed, 3883 insertions(+), 48 deletions(-) create mode 100644 tests/integration/fitting/test_bayesian_sampling_multi_q.py diff --git a/docs/docs/tutorials/bayesian.ipynb b/docs/docs/tutorials/bayesian.ipynb index 51a68acd2..bc92e21fc 100644 --- a/docs/docs/tutorials/bayesian.ipynb +++ b/docs/docs/tutorials/bayesian.ipynb @@ -27,7 +27,8 @@ "import easydynamics.sample_model as sm\n", "from easydynamics.analysis.analysis1d import Analysis1d\n", "\n", - "%matplotlib inline" + "# Make the plots interactive; the Q sliders need the widget backend\n", + "%matplotlib widget" ] }, { @@ -152,7 +153,9 @@ "- `burn` — generations discarded at the start, while the chains are still travelling towards the bulk of the posterior.\n", "- `thin` — keep only every n-th generation, which reduces the correlation between neighbouring draws.\n", "\n", - "Sampling never moves your parameters: their values are restored afterwards, so the model is left exactly as the fit left it." + "Sampling never moves your parameters: their values are restored afterwards, so the model is left exactly as the fit left it.\n", + "\n", + "For a long run, `progress=True` shows a single self-updating line with the percentage of generations completed, closed with `Sampling: done`. The percentage is based on the backend's own estimate of the run length, which can be too high, so a finished run may close the line before reaching 100%." ] }, { @@ -162,7 +165,7 @@ "metadata": {}, "outputs": [], "source": [ - "results = analysis.bayesian.sample(samples=4000, burn=300, thin=2)\n", + "results = analysis.bayesian.sample(samples=4000, burn=300, thin=2, progress=True)\n", "\n", "print(f'Collected {results.draws.shape[0]} draws for {results.draws.shape[1]} parameters.')" ] @@ -227,6 +230,46 @@ "analysis.bayesian.plot_corner()" ] }, + { + "cell_type": "markdown", + "id": "e52ab7b4", + "metadata": {}, + "source": [ + "### One parameter at a time\n", + "\n", + "`plot_marginal()` pulls a single parameter's posterior out of the chain: a histogram of its draws, with the median and the 16/84 percentiles — the same numbers `summary()` reports — marked on it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2cdf2451", + "metadata": {}, + "outputs": [], + "source": [ + "analysis.bayesian.plot_marginal('Res. Gauss width')" + ] + }, + { + "cell_type": "markdown", + "id": "683943ef", + "metadata": {}, + "source": [ + "### The correlation matrix at a glance\n", + "\n", + "Where the corner plot shows every pairwise distribution, `plot_correlations()` reduces each panel to a single number — the Pearson correlation between the two parameters — and colour-codes the grid. It is the quickest way to spot which parameters the data cannot tell apart." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d577ef22", + "metadata": {}, + "outputs": [], + "source": [ + "analysis.bayesian.plot_correlations()" + ] + }, { "cell_type": "markdown", "id": "8f819c75", @@ -234,7 +277,9 @@ "source": [ "## Does the model actually describe the data?\n", "\n", - "The posterior predictive plot re-evaluates the model for a sample of posterior draws and shades the region they cover. If the data wanders outside the band in a systematic way, the model is missing a feature, and no amount of parameter tuning will fix it." + "The posterior predictive plot re-evaluates the model for a sample of posterior draws and shades the region they cover. If the data wanders outside the band in a systematic way, the model is missing a feature, and no amount of parameter tuning will fix it.\n", + "\n", + "The band defaults to the 68% credible interval; `credible_interval=95.0` widens it to 95%." ] }, { @@ -276,6 +321,106 @@ "Chains are expensive, so they can be saved and reloaded with `analysis.bayesian.save(path)` and `analysis.bayesian.load(path)`. A reloaded chain can be summarized, plotted, or extended further, exactly like a fresh one." ] }, + { + "cell_type": "markdown", + "id": "ef0998e4", + "metadata": {}, + "source": [ + "## Several Q values at once\n", + "\n", + "Everything so far used `Analysis1d`, a single Q slice. A full `Analysis` can sample too, either way round:\n", + "\n", + "- `fit_method='independent'` gives each Q its own chain. Cheaper, and the Q values cannot influence one another.\n", + "- `fit_method='simultaneous'` runs a single chain over every Q at once, which is what you need when parameters are shared across Q. It costs considerably more, because DREAM runs a number of chains proportional to the parameter count and a simultaneous run has every Q's parameters in play together.\n", + "\n", + "Sampling is much slower than fitting, so it is worth trying a few Q values before committing to all of them. Passing `Q_index` samples just that one." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b9d3b565", + "metadata": {}, + "outputs": [], + "source": [ + "# Fresh models, so this analysis is independent of the single-Q one above rather than\n", + "# sharing its already-sampled components.\n", + "all_q_components = sm.ComponentCollection()\n", + "all_q_components.append_component(sm.Gaussian(width=0.1, area=1, name='Res. Gauss'))\n", + "\n", + "full_analysis = edyn.Analysis(\n", + " display_name='Vanadium, all Q',\n", + " experiment=vanadium_experiment,\n", + " sample_model=sm.SampleModel(components=all_q_components),\n", + " instrument_model=sm.InstrumentModel(\n", + " background_model=sm.BackgroundModel(components=sm.Polynomial(coefficients=[0.001])),\n", + " ),\n", + ")\n", + "full_analysis.fit(fit_method='independent')\n", + "\n", + "for Q_index in (4, 8, 12):\n", + " full_analysis.analysis_list[Q_index].bayesian.suggest_bounds().apply()\n", + " full_analysis.bayesian.sample(\n", + " fit_method='independent', Q_index=Q_index, samples=3000, burn=200, thin=2\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "740fa625", + "metadata": {}, + "source": [ + "`bayesian.summary()` gathers the per-Q chains into one table, labelled by Q index. Each row is a marginal distribution, and a marginal is well defined within its own chain, so collecting them says nothing that was not sampled." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "511ef922", + "metadata": {}, + "outputs": [], + "source": [ + "full_analysis.bayesian.summary()" + ] + }, + { + "cell_type": "markdown", + "id": "12347890", + "metadata": {}, + "source": [ + "Corner plots are the one thing that cannot be gathered up. The chains were run separately, so no draw pairs a parameter at one Q with a parameter at another, and a combined figure would show correlations that came from how the sampling was run rather than from the data.\n", + "\n", + "So `plot_corner()` steps through them instead. The slider offers only the Q values that were actually sampled — 4, 8 and 12 here — and `plot_corner(Q_index=8)` goes straight to one of them." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38d0b23c", + "metadata": {}, + "outputs": [], + "source": [ + "full_analysis.bayesian.plot_corner()" + ] + }, + { + "cell_type": "markdown", + "id": "2c16b5e4", + "metadata": {}, + "source": [ + "The other plots work the same way over independent chains: `plot_posterior_predictive()`, `plot_trace()`, `plot_marginal()` and `plot_correlations()` all show a Q slider in a notebook — the predictive plot through the same slider machinery as `plot_data_and_model()` — take `Q_index=` to go straight to one Q, and outside a notebook name the sampled Q indices instead." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "86b57835", + "metadata": {}, + "outputs": [], + "source": [ + "full_analysis.bayesian.plot_posterior_predictive(n_draws=100)" + ] + }, { "cell_type": "markdown", "id": "a3448aee", @@ -287,7 +432,13 @@ "\n", "**Sampling only some parameters.** `bayesian.sample(parameters=[...])` restricts the chain to a subset, which is faster because the number of chains scales with the number of parameters. Be careful with the result: the other parameters are held *fixed*, which is not the same as averaging over them. The intervals you get are conditional on those fixed values, and will be too narrow whenever the parameters are correlated.\n", "\n", - "**Degenerate parameters.** As seen above, they are a modelling problem rather than a sampling one. `bayesian.suggest_bounds()` returning absurd values, or a warning that the posterior has piled up against its bounds, are both signs to go back and look at the model." + "**Degenerate parameters.** As seen above, they are a modelling problem rather than a sampling one. `bayesian.suggest_bounds()` returning absurd values, or a warning that the posterior has piled up against its bounds, are both signs to go back and look at the model.\n", + "\n", + "**Several Q values at once.** An `Analysis` can sample either way. `fit_method='independent'` gives each Q its own chain, which is cheaper; `fit_method='simultaneous'` runs one chain over every Q, which is what you need when parameters are shared across Q. `bayesian.summary()` gathers the per-Q chains into one table either way.\n", + "\n", + "Corner plots are the exception. Independent chains share no draws, so nothing pairs a parameter at one Q with a parameter at another, and combining them would show correlations that came from how the sampling was run rather than from the data. `analysis.bayesian.plot_corner()` therefore shows one Q at a time: pass `Q_index`, or leave it out in a notebook to get a slider across the sampled Q values.\n", + "\n", + "**Reproducibility.** Two identical `sample()` calls will not give identical chains: the DREAM backend draws from global random state and exposes no seed. Judge results by whether the summary is stable when the chain is extended, not by exact repetition." ] } ], diff --git a/docs/docs/tutorials/tutorial1_brownian.ipynb b/docs/docs/tutorials/tutorial1_brownian.ipynb index bb6403252..3c902ea16 100644 --- a/docs/docs/tutorials/tutorial1_brownian.ipynb +++ b/docs/docs/tutorials/tutorial1_brownian.ipynb @@ -531,6 +531,71 @@ "parameter_analysis.get_all_parameters()" ] }, + { + "cell_type": "markdown", + "id": "163c27bb", + "metadata": {}, + "source": [ + "### How certain are the diffusion parameters?\n", + "\n", + "The uncertainties printed above come from the curvature of $\\chi^2$ at the best fit. That is a good estimate when the parameters are uncorrelated and their uncertainties are roughly Gaussian, but $D$ and the scale are fitted to the same curve and need not be either. A Bayesian analysis maps the full posterior instead, so we can check.\n", + "\n", + "The bounds act as the prior, so every free parameter needs finite ones first. `bayesian.suggest_bounds()` proposes them from the fit and is advisory until `.apply()` is called." + ] + }, + { + "cell_type": "code", + "id": "de797763", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "suggestions = parameter_analysis.bayesian.suggest_bounds()\n", + "print(suggestions)\n", + "suggestions.apply()" + ] + }, + { + "cell_type": "code", + "id": "604cd4e9", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "parameter_analysis.bayesian.sample(samples=4000, burn=200, thin=2)\n", + "parameter_analysis.bayesian.summary()" + ] + }, + { + "cell_type": "markdown", + "id": "2e0cdab0", + "metadata": {}, + "source": [ + "The corner plot shows how the two parameters trade off against each other. A tilted, narrow ridge means the data pins down a combination of $D$ and the scale more tightly than either one separately." + ] + }, + { + "cell_type": "code", + "id": "6208979b", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "parameter_analysis.bayesian.plot_corner()" + ] + }, + { + "cell_type": "markdown", + "id": "154d6137", + "metadata": {}, + "source": [ + "Notice that these credible intervals are **much narrower** than the uncertainties printed further up, and that difference is worth understanding rather than trusting.\n", + "\n", + "The least-squares fit of the widths has a reduced $\\chi^2$ of about 150: the Brownian model does not describe the fitted widths to within their error bars. `lmfit` responds by inflating its reported uncertainties by the square root of that, roughly a factor of 12, on the assumption that a poor fit means the input uncertainties were understated. The sampler makes no such adjustment — it takes the stated uncertainties at face value — so its intervals come out around twelve times tighter.\n", + "\n", + "Neither is simply right. The gap is a signal that the two-step model is not capturing the data, which is exactly what we address next by fitting the diffusion model to all the data at once." + ] + }, { "cell_type": "markdown", "id": "fc2f8434", diff --git a/src/easydynamics/analysis/__init__.py b/src/easydynamics/analysis/__init__.py index 89126ecdf..c6eb02a92 100644 --- a/src/easydynamics/analysis/__init__.py +++ b/src/easydynamics/analysis/__init__.py @@ -8,12 +8,14 @@ from easydynamics.analysis.posterior import ParameterPosterior from easydynamics.analysis.posterior import PosteriorSummary from easydynamics.analysis.posterior_labels import ParameterLabels +from easydynamics.analysis.posterior_sampling import MultiQPosteriorSampler from easydynamics.analysis.posterior_sampling import PosteriorSampler __all__ = [ 'Analysis', 'BoundsSuggestion', 'BoundsSuggestions', + 'MultiQPosteriorSampler', 'ParameterAnalysis', 'ParameterLabels', 'ParameterPosterior', diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index 8fb3d703d..46645afa1 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -14,6 +14,8 @@ from easydynamics.analysis.analysis1d import Analysis1d from easydynamics.analysis.analysis_base import AnalysisBase +from easydynamics.analysis.posterior_labels import ParameterLabels +from easydynamics.analysis.posterior_sampling import MultiQPosteriorSampler from easydynamics.experiment import Experiment from easydynamics.sample_model import SampleModel from easydynamics.sample_model.instrument_model import InstrumentModel @@ -30,6 +32,10 @@ class Analysis(AnalysisBase): Supports independent fits of each Q value and simultaneous fits of all Q. + Besides least-squares fitting with :meth:`fit`, the posterior distribution of the free + parameters can be explored through :attr:`bayesian`; see + :class:`~easydynamics.analysis.posterior_sampling.MultiQPosteriorSampler`. + Examples -------- **Fitting vanadium data for instrument calibration** @@ -117,6 +123,11 @@ def __init__( self._analysis_list: list[Analysis1d] = [] self._analysis_list_is_dirty = True + # Rebuilt with the analysis list; see _parameter_owner_index. + self._owner_index = None + self._fitter = None + self._fitter_is_dirty = True + self._bayesian = None super().__init__( display_name=display_name, unique_name=unique_name, @@ -170,6 +181,70 @@ def analysis_list(self, _value: list[Analysis1d]) -> None: 'or instrument model.' ) + @property + def fitter(self) -> MultiFitter: + """ + The EasyScience MultiFitter covering every Q index, built on first use. + + Returns + ------- + MultiFitter + The cached MultiFitter. + """ + if self._fitter_is_dirty or self._fitter is None: + self._fitter = self._build_fitter() + self._fitter_is_dirty = False + return self._fitter + + @property + def bayesian(self) -> MultiQPosteriorSampler: + """ + Bayesian posterior sampling for this Analysis, created on first use. + + Returns + ------- + MultiQPosteriorSampler + The sampler, which can run per Q index or over all of them at once. + """ + if self._bayesian is None: + self._bayesian = MultiQPosteriorSampler( + analysis=self, + sampling_data=self._sampling_data, + chain_parameters=self._chain_parameters, + parameter_labels=self._parameter_labels, + prepare=self._prepare_for_sampling, + per_q=lambda: self.analysis_list, + ) + return self._bayesian + + def _invalidate_fitter(self) -> None: + """Mark the MultiFitter, and the Sampler built from it, as needing a rebuild.""" + self._fitter_is_dirty = True + if self._bayesian is not None: + self._bayesian.invalidate() + + def _parameter_labels(self) -> ParameterLabels: + """ + Get labels for the chain's parameters, qualified by Q index where needed. + + Every Q index carries its own copy of each model parameter, all sharing a name, so a bare + name would produce several identical rows in a summary and could not pick a parameter out. + + Returns + ------- + ParameterLabels + Labels over the current free parameters. + """ + owners = self._parameter_owner_index() + return ParameterLabels( + self._chain_parameters(), + qualify=lambda parameter: ( + None + if owners.get(parameter.unique_name) is None + else f'Q_index={owners[parameter.unique_name]}' + ), + ) + ############# # Other methods ############# @@ -283,8 +358,9 @@ def fit( Returns ------- FitResults | list[FitResults] - A list of FitResults if fitting independently, or a single FitResults object if fitting - simultaneously. + A single FitResults when a specific Q index was fitted, and otherwise a list holding + one FitResults per Q index. A simultaneous fit also reports per-Q results, since the + underlying MultiFitter splits its combined result back up by dataset. """ if self.Q is None: @@ -661,6 +737,8 @@ def _on_experiment_changed(self) -> None: """ super()._on_experiment_changed() self._analysis_list_is_dirty = True + self._owner_index = None + self._invalidate_fitter() def _on_sample_model_changed(self) -> None: """ @@ -668,6 +746,8 @@ def _on_sample_model_changed(self) -> None: """ super()._on_sample_model_changed() self._analysis_list_is_dirty = True + self._owner_index = None + self._invalidate_fitter() def _on_instrument_model_changed(self) -> None: """ @@ -675,6 +755,8 @@ def _on_instrument_model_changed(self) -> None: """ super()._on_instrument_model_changed() self._analysis_list_is_dirty = True + self._owner_index = None + self._invalidate_fitter() def _on_convolution_settings_changed(self) -> None: """ @@ -682,6 +764,8 @@ def _on_convolution_settings_changed(self) -> None: """ super()._on_convolution_settings_changed() self._analysis_list_is_dirty = True + self._owner_index = None + self._invalidate_fitter() def _ensure_analysis_list_current(self) -> None: """Rebuild the analysis list if any dependency has changed since it was last built.""" @@ -695,6 +779,7 @@ def _create_analysis_list(self) -> None: experiment, sample model, and instrument model. """ self._analysis_list = [] + self._owner_index = None for Q_index in range(len(self.Q)): # The ConvolutionSettings object is shared so user changes reach every Q index; # plan validity is tracked per convolver, not on the settings object. @@ -714,6 +799,102 @@ def _create_analysis_list(self) -> None: # Private methods ############# + ############# + # The contract PosteriorSampler relies on (simultaneous sampling over all Q) + ############# + + def _build_fitter(self) -> MultiFitter: + """ + Build the MultiFitter covering every Q index. + + Returns + ------- + MultiFitter + A MultiFitter over the Analysis1d objects and their fit functions. + """ + return MultiFitter( + fit_objects=self.analysis_list, + fit_functions=self.get_fit_functions(), + ) + + def _sampling_data(self) -> tuple[list, list, list]: + """ + Get the per-Q data to bind to the Sampler, as lists of arrays. + + Returns + ------- + tuple[list, list, list] + The ``(x, y, weights)`` triple, one entry per Q index. + """ + xs, ys, ws = [], [], [] + for analysis1d in self.analysis_list: + x, y, weight, _ = self.experiment.extract_x_y_weights_only_finite(analysis1d.Q_index) + xs.append(x) + ys.append(y) + ws.append(weight) + return xs, ys, ws + + def _chain_parameters(self) -> list[Parameter]: + """ + Get the free parameters across every Q index. + + Each Q index holds its own copy of the model parameters, so the union is taken by + ``unique_name``. Parameters shared between Q indices therefore appear only once. + + Returns + ------- + list[Parameter] + The free parameters of the whole analysis, in Q order and without duplicates. + """ + parameters = {} + for analysis1d in self.analysis_list: + for parameter in analysis1d.get_free_parameters(): + parameters.setdefault(parameter.unique_name, parameter) + return list(parameters.values()) + + def _parameter_owner_index(self) -> dict[str, int]: + """ + Map each parameter to the Q index that owns it. + + Built once per analysis list and reused, because scanning the list for every parameter + makes labelling a chain quadratic in the parameter count -- seconds, for a dataset with + many Q values. Built from all parameters rather than only the free ones, so that fixing a + parameter cannot leave the map stale. + + Returns + ------- + dict[str, int] + Mapping of parameter ``unique_name`` to owning Q index. Parameters shared by more than + one Q index are left out, since no single Q identifies them. + """ + self._ensure_analysis_list_current() + if self._owner_index is None: + owners: dict[str, int | None] = {} + for analysis1d in self._analysis_list: + for parameter in analysis1d.get_all_parameters(): + if parameter.unique_name in owners: + owners[parameter.unique_name] = None + else: + owners[parameter.unique_name] = analysis1d.Q_index + self._owner_index = { + name: q_index for name, q_index in owners.items() if q_index is not None + } + return self._owner_index + + def _prepare_for_sampling(self) -> None: + """ + Rebuild every per-Q convolver against its masked energy grid. + + Mirrors what a simultaneous fit does, so that the model evaluations seen by the sampler + match the ones the fit would have made. + """ + for analysis1d in self.analysis_list: + _, _, _, mask = self.experiment.extract_x_y_weights_only_finite(analysis1d.Q_index) + mask_var = sc.array(dims=['energy'], values=mask) + analysis1d.refresh_convolver( + energy=self.experiment.get_masked_energy(Q_index=analysis1d.Q_index, mask=mask_var) + ) + def _fit_single_Q(self, Q_index: int) -> FitResults: """ Fit data for a single Q index. diff --git a/src/easydynamics/analysis/parameter_analysis.py b/src/easydynamics/analysis/parameter_analysis.py index 7e24108e1..1ac496842 100644 --- a/src/easydynamics/analysis/parameter_analysis.py +++ b/src/easydynamics/analysis/parameter_analysis.py @@ -9,11 +9,14 @@ import scipp as sc from easyscience.fitting.minimizers.utils import FitResults from easyscience.fitting.multi_fitter import MultiFitter +from easyscience.variable import Parameter from matplotlib import rcParams from plopp.backends.matplotlib.figure import InteractiveFigure from easydynamics.analysis.analysis import Analysis from easydynamics.analysis.fit_binding import FitBinding +from easydynamics.analysis.posterior_labels import ParameterLabels +from easydynamics.analysis.posterior_sampling import PosteriorSampler from easydynamics.base_classes.easydynamics_modelbase import EasyDynamicsModelBase from easydynamics.utils.fit_target import FitTarget from easydynamics.utils.utils import _in_notebook @@ -98,6 +101,13 @@ def __init__( default, None. """ + self._fitter = None + self._fitter_is_dirty = True + self._bayesian = None + # Which targets the cached fitter was built for, so an in-place edit of a FitBinding is + # noticed even though it cannot be observed directly. + self._fitter_targets = None + super().__init__(display_name=display_name, unique_name=unique_name) self._parameters = self._verify_parameters(parameters) @@ -130,6 +140,7 @@ def parameters(self, value: sc.Dataset | Analysis | None) -> None: The new parameter dataset for the parameter analysis. """ self._parameters = self._verify_parameters(value) + self._invalidate_fitter() @property def bindings(self) -> list[FitBinding]: @@ -154,6 +165,94 @@ def bindings(self, value: FitBinding | list[FitBinding] | None) -> None: The new fit bindings for the parameter analysis. """ self._bindings = self._verify_bindings(value) + self._invalidate_fitter() + + @property + def fitter(self) -> MultiFitter: + """ + The EasyScience MultiFitter over the binding models, built on first use. + + Returns + ------- + MultiFitter + The cached MultiFitter. + """ + if self._fitter_is_dirty or self._fitter is None: + self._fitter = self._build_fitter() + self._fitter_is_dirty = False + return self._fitter + + @property + def bayesian(self) -> PosteriorSampler: + """ + Bayesian posterior sampling for this analysis, created on first use. + + Returns + ------- + PosteriorSampler + The sampler, which holds any chain that has been run. + """ + if self._bayesian is None: + self._bayesian = PosteriorSampler( + analysis=self, + sampling_data=self._sampling_data, + chain_parameters=self._chain_parameters, + parameter_labels=self._parameter_labels, + ) + return self._bayesian + + def _invalidate_fitter(self) -> None: + """Mark the MultiFitter, and the Sampler built from it, as needing a rebuild.""" + self._fitter_is_dirty = True + if self._bayesian is not None: + self._bayesian.invalidate() + + def _parameter_labels(self) -> ParameterLabels: + """ + Get labels for the chain's parameters, qualified by binding model where needed. + + Two bindings can use models of the same kind, whose parameters would then share a name. The + prefix is the model's name, matching the choice to report parameters under their name + rather than their display name, since for several models the display name is just the class + name. If two models share a name as well, the unique name is used: a label that does not + disambiguate is worse than a long one. + + Returns + ------- + ParameterLabels + Labels over the free parameters of the binding models. + """ + models = {binding.model.unique_name: binding.model for binding in self.bindings} + owners = {} + for model in models.values(): + for parameter in model.get_free_parameters(): + owners.setdefault(parameter.unique_name, model) + model_names = [getattr(m, 'name', None) or m.display_name for m in models.values()] + + def qualify(parameter: Parameter) -> str | None: + """ + Get the model name a parameter belongs to. + + Parameters + ---------- + parameter : Parameter + The parameter to qualify. + + Returns + ------- + str | None + The owning model's name, its unique name if that name is shared, or None if the + parameter belongs to no binding model. + """ + owner = owners.get(parameter.unique_name) + if owner is None: + return None + name = getattr(owner, 'name', None) or owner.display_name + if name is None or model_names.count(name) > 1: + return owner.unique_name + return name + + return ParameterLabels(self._chain_parameters(), qualify=qualify) ############# # Other methods @@ -163,18 +262,37 @@ def fit(self) -> FitResults: """ Fit the parameters using the specified fit functions and settings. + A ``ValueError`` is raised if no parameters Dataset is provided, if no fit bindings are + provided, or if a binding names a dataset key that is not in the parameters Dataset. + Returns ------- FitResults The results of the fit + """ + + xs, ys, ws, _, models = self._build_fit_inputs() + self._invalidate_fitter_if_targets_changed(models) + return self.fitter.fit(x=xs, y=ys, weights=ws) + + def _build_fit_inputs(self) -> tuple[list, list, list, list, list]: + """ + Resolve every binding into the per-target data, fit functions, and models. + + Shared by fitting and sampling so that both see exactly the same targets, in the same + order, with the same unit conversions applied. + + Returns + ------- + tuple[list, list, list, list, list] + The ``(x, y, weights, functions, models)`` lists, one entry per fit target. Raises ------ ValueError - If no parameters Dataset is provided. If no fit functions are provided. If no parameter - names are found for the fit functions. + If no parameters Dataset is provided, if no fit bindings are provided, or if a binding + names a dataset key that is not in the parameters Dataset. """ - if self.parameters is None: raise ValueError('No parameters Dataset provided.') @@ -207,16 +325,94 @@ def fit(self) -> FitResults: funcs.append(target.function) models.append(binding.model) - mf = MultiFitter( - fit_objects=models, - fit_functions=funcs, - ) + return xs, ys, ws, funcs, models - return mf.fit( - x=xs, - y=ys, - weights=ws, - ) + ############# + # The contract PosteriorSampler relies on + ############# + + def _build_fitter(self) -> MultiFitter: + """ + Build the MultiFitter over the binding models. + + Unlike the other Analysis classes, the objects being fitted are the binding models rather + than this object, so the parameters live on those models. + + Returns + ------- + MultiFitter + A MultiFitter over the per-target models and fit functions. + """ + _, _, _, funcs, models = self._build_fit_inputs() + self._fitter_targets = self._target_signature(models) + return MultiFitter(fit_objects=models, fit_functions=funcs) + + @staticmethod + def _target_signature(models: list) -> tuple: + """ + Summarize which models the fitter was built for, in target order. + + Parameters + ---------- + models : list + The model behind each fit target. + + Returns + ------- + tuple + A comparable signature of the current targets. + """ + return tuple(model.unique_name for model in models) + + def _invalidate_fitter_if_targets_changed(self, models: list) -> None: + """ + Rebuild the cached fitter when the bindings no longer resolve to the same targets. + + A FitBinding can be edited in place -- ``binding.targets = ...`` -- which this object + cannot observe. Doing so changes how many datasets there are, while the cached MultiFitter + still holds the old fit functions, and the fit then dies deep inside the minimizer. Compare + the targets the fitter was built for against the current ones instead. + + Parameters + ---------- + models : list + The model behind each fit target, as currently resolved. + """ + if self._fitter is None: + return + if self._target_signature(models) != getattr(self, '_fitter_targets', None): + self._invalidate_fitter() + + def _sampling_data(self) -> tuple[list, list, list]: + """ + Get the per-target data to bind to the Sampler. + + Returns + ------- + tuple[list, list, list] + The ``(x, y, weights)`` triple, one entry per fit target. + """ + xs, ys, ws, _, models = self._build_fit_inputs() + self._invalidate_fitter_if_targets_changed(models) + return xs, ys, ws + + def _chain_parameters(self) -> list[Parameter]: + """ + Get the free parameters across every binding model. + + A model appears once per target it is fitted against, so the union is taken by + ``unique_name`` to avoid counting its parameters more than once. + + Returns + ------- + list[Parameter] + The free parameters of the binding models, without duplicates. + """ + parameters = {} + for binding in self.bindings: + for parameter in binding.model.get_free_parameters(): + parameters.setdefault(parameter.unique_name, parameter) + return list(parameters.values()) def plot( self, names: str | list[str] | None = None, **kwargs: dict[str, Any] diff --git a/src/easydynamics/analysis/posterior.py b/src/easydynamics/analysis/posterior.py index d8d65af77..e2c197a8b 100644 --- a/src/easydynamics/analysis/posterior.py +++ b/src/easydynamics/analysis/posterior.py @@ -11,6 +11,7 @@ from __future__ import annotations +import warnings from dataclasses import dataclass from typing import TYPE_CHECKING @@ -19,6 +20,11 @@ if TYPE_CHECKING: from easyscience.variable import Parameter +# How many times wider than the parameter's own value a suggested range may be before it is +# reported as suspicious. A fit that returns an uncertainty this large is describing a flat +# direction rather than a measurement. +ABSURD_WIDTH_FACTOR = 1e4 + # Fraction of the allowed range at each end that counts as "at the bound" when checking whether # the posterior has piled up against a bound. BOUND_EDGE_FRACTION = 0.05 @@ -41,7 +47,8 @@ class BoundsSuggestion: parameter : Parameter The parameter the suggestion applies to. label : str - The name the parameter is reported under, qualified where several share a name. + The name the parameter is reported under. For a multi-Q analysis this is qualified by Q, + since every Q holds an identically named copy of each parameter. suggested_min : float The proposed lower bound. Equal to the parameter's current lower bound when that is already finite. @@ -131,7 +138,9 @@ def apply(self) -> list[Parameter]: """ Set the suggested bounds on every parameter that has a usable suggestion. - Parameters needing manual attention are skipped rather than guessed at. + Parameters needing manual attention are skipped rather than guessed at. A suggestion that + is absurdly wide is still applied -- it is what the fit implied -- but warned about, since + reading the table first is easy to skip in a script. Returns ------- @@ -139,12 +148,27 @@ def apply(self) -> list[Parameter]: The parameters whose bounds were changed. """ changed = [] + absurd = [] for suggestion in self._suggestions: if suggestion.needs_attention or not suggestion.changes_bounds: continue suggestion.parameter.min = suggestion.suggested_min suggestion.parameter.max = suggestion.suggested_max changed.append(suggestion.parameter) + if _is_absurdly_wide(suggestion): + absurd.append(suggestion.label) + + if absurd: + warnings.warn( + ( + f'Applied bounds far wider than the parameter itself for: ' + f'{", ".join(absurd)}. That width comes from a very large fitted uncertainty, ' + f'which usually means these parameters are degenerate with others, so the ' + f'data cannot determine them separately. Sampling explores that whole range.' + ), + UserWarning, + stacklevel=2, + ) return changed def __len__(self) -> int: @@ -201,6 +225,29 @@ def __repr__(self) -> str: return '\n'.join(lines) +def _is_absurdly_wide(suggestion: BoundsSuggestion) -> bool: + """ + Check whether a suggested range dwarfs the parameter it describes. + + Parameters + ---------- + suggestion : BoundsSuggestion + The suggestion to judge. + + Returns + ------- + bool + True when the range is more than ``ABSURD_WIDTH_FACTOR`` times the parameter's magnitude. + """ + scale = abs(float(suggestion.parameter.value)) + if scale == 0: + # No magnitude to compare against, so the ratio would be meaningless rather than alarming. + return False + width = suggestion.suggested_max - suggestion.suggested_min + # An infinite width compares greater than any threshold, so it needs no separate check. + return width > ABSURD_WIDTH_FACTOR * scale + + def suggest_bounds_for_parameters( parameters: list[Parameter], labels: list[str] | None = None, @@ -230,7 +277,8 @@ def suggest_bounds_for_parameters( The parameters to propose bounds for. labels : list[str] | None, default=None The name to report each parameter under, one per parameter. Defaults to the parameters' own - names. + names, which is ambiguous when several share a name, as the per-Q copies of a multi-Q + analysis do. n_sigma : float, default=10.0 How many standard deviations of the parameter's fitted uncertainty to allow on each side. relative_pad : float, default=0.2 @@ -606,11 +654,12 @@ def summarize_draws( parameters_by_column: list[Parameter | None], ) -> PosteriorSummary: """ - Summarize posterior draws under the parameters' own names and units. + Summarize posterior draws under caller-supplied labels. The sampler labels its columns with each parameter's ``unique_name`` (``Parameter_4`` and the - like), which is not what a user recognises, so columns are reported under ``Parameter.name`` - wherever a parameter could be matched. + like), which is not what a user recognises, so the caller supplies readable labels instead. A + plain parameter name is enough for a single dataset, but a multi-Q analysis holds one copy of + each parameter per Q, all sharing a name, so those labels have to be qualified by Q. Parameters ---------- diff --git a/src/easydynamics/analysis/posterior_sampling.py b/src/easydynamics/analysis/posterior_sampling.py index 2c23a9235..04ca3104a 100644 --- a/src/easydynamics/analysis/posterior_sampling.py +++ b/src/easydynamics/analysis/posterior_sampling.py @@ -12,6 +12,7 @@ from __future__ import annotations +import inspect import json import sys import warnings @@ -23,11 +24,15 @@ from easyscience.fitting import AvailableMinimizers from easyscience.fitting import Sampler +from easydynamics.analysis.posterior import PosteriorSummary from easydynamics.analysis.posterior import degenerate_parameters from easydynamics.analysis.posterior import parameters_at_bounds from easydynamics.analysis.posterior import suggest_bounds_for_parameters from easydynamics.analysis.posterior import summarize_draws from easydynamics.analysis.posterior import unbounded_parameters +from easydynamics.analysis.posterior_labels import ParameterLabels +from easydynamics.utils.utils import _in_notebook +from easydynamics.utils.utils import verify_Q_index if TYPE_CHECKING: import os @@ -35,11 +40,11 @@ from easyscience.fitting.sampler import SamplingResults from easyscience.variable import Parameter + from ipywidgets import VBox from matplotlib.figure import Figure + from plopp.backends.matplotlib.figure import InteractiveFigure from easydynamics.analysis.posterior import BoundsSuggestions - from easydynamics.analysis.posterior import PosteriorSummary - from easydynamics.analysis.posterior_labels import ParameterLabels # Suffix of the sidecar mapping chain columns to stable labels, written next to the BUMPS chain # files by save(). @@ -397,6 +402,10 @@ def _run( chain_parameters = self._chain_parameters() if not chain_parameters: + # Let the analysis raise its own, more specific complaint first — e.g. a + # ParameterAnalysis without a parameters Dataset or bindings has no free + # parameters either, but "every parameter is fixed" would mislead there. + self._sampling_data() raise ValueError( 'There are no free parameters to sample: every parameter is fixed. ' 'Free at least one parameter before sampling.' @@ -607,19 +616,26 @@ def _warn_about_bounds_occupancy(self, results: SamplingResults) -> None: f'Widen the bounds, or check whether these parameters are degenerate with others.' ), UserWarning, - stacklevel=4, + stacklevel=_stacklevel_above_module(), ) ############# # Results ############# - def summary(self) -> PosteriorSummary: + def summary(self, labeller: Callable[[Parameter], str] | None = None) -> PosteriorSummary: """ Summarize the marginal posterior of each sampled parameter. Reports the median and the 68% credible interval under the parameter's own label and unit. + Parameters + ---------- + labeller : Callable[[Parameter], str] | None, default=None + Overrides the label a resolved column is reported under. Used by an Analysis covering + several Q values, whose gathered table qualifies each name with its Q index. Columns + that resolve to no parameter keep their usual fallback name. + Returns ------- PosteriorSummary @@ -627,10 +643,17 @@ def summary(self) -> PosteriorSummary: """ results = self._require_results() labels = self._labels() + names = labels.display_names(results.param_names, self._saved_labels) + parameters = self._resolve(results) + if labeller is not None: + names = [ + name if parameter is None else labeller(parameter) + for parameter, name in zip(parameters, names, strict=True) + ] return summarize_draws( draws=results.draws, - labels=labels.display_names(results.param_names, self._saved_labels), - parameters_by_column=self._resolve(results), + labels=names, + parameters_by_column=parameters, ) def set_parameters_to_median(self) -> list[Parameter]: @@ -689,7 +712,7 @@ def save(self, path: str | os.PathLike) -> None: f'one. A future load() will report the columns under their internal names.' ), UserWarning, - stacklevel=2, + stacklevel=_stacklevel_above_module(), ) return Path(f'{path}{_LABEL_MAP_SUFFIX}').write_text( @@ -1094,6 +1117,767 @@ def _require_results(self) -> SamplingResults: return self._results +class MultiQPosteriorSampler(PosteriorSampler): + """ + Posterior sampling for an Analysis covering several Q values. + + Reached as ``analysis.bayesian``. Sampling can run either way round: + + - ``fit_method='independent'`` gives each Q index its own chain, which is cheaper and keeps the + Q values from influencing one another. + - ``fit_method='simultaneous'`` runs a single chain over every Q at once, which is what is + needed when parameters are shared across Q, and costs considerably more: DREAM runs a number + of chains proportional to the parameter count, and a simultaneous run has every Q's + parameters in play together. + + Results from independent runs stay on the per-Q samplers. This class gathers them where + gathering is sound, and declines where it is not; see :meth:`summary` and :meth:`plot_corner`. + + Parameters + ---------- + per_q : Callable[[], list] + Returns the per-Q Analysis objects, each exposing ``Q_index`` and its own ``bayesian``. + **kwargs : dict[str, Any] + Forwarded to :class:`PosteriorSampler`. + """ + + def __init__(self, per_q: Callable[[], list], **kwargs: dict[str, Any]) -> None: + super().__init__(**kwargs) + self._per_q = per_q + + @property + def results_per_q(self) -> list[SamplingResults | None] | None: + """ + The per-Q chains from independent sampling, or None if there are none. + + A simultaneous run produces one chain covering every Q, which is on :attr:`results`. + + Returns + ------- + list[SamplingResults | None] | None + One entry per Q index, None where that Q has not been sampled, or None overall if no Q + index has been sampled. + """ + results = [analysis1d.bayesian.results for analysis1d in self._per_q()] + return results if any(result is not None for result in results) else None + + def sample( + self, + samples: int = 10000, + burn: int = 2000, + thin: int = 10, + fit_method: str = 'independent', + Q_index: int | None = None, + **sampler_options: dict[str, Any], + ) -> SamplingResults | list[SamplingResults]: + """ + Draw samples from the posterior, per Q index or over all of them at once. + + Parameters + ---------- + samples : int, default=10000 + Number of raw samples to draw across all chains, before thinning. + burn : int, default=2000 + Burn-in generations to discard before collecting samples. + thin : int, default=10 + Thinning interval, which reduces autocorrelation between retained draws. + fit_method : str, default='independent' + Either "independent" (a separate chain per Q index) or "simultaneous" (one chain over + all Q indices at once). + Q_index : int | None, default=None + With ``fit_method='independent'``, sample only this Q index. Ignored when sampling + simultaneously. + **sampler_options : dict[str, Any] + Forwarded to the underlying sampler. + + Returns + ------- + SamplingResults | list[SamplingResults] + A single result when a specific Q index was sampled or when sampling simultaneously, + and otherwise one result per Q index. + + Raises + ------ + ValueError + If fit_method is not "independent" or "simultaneous", or there are no Q values. + + Notes + ----- + An ``IndexError`` or ``TypeError`` propagates from the Q_index validation if Q_index is + out of range or not an int. + """ + if fit_method not in ('independent', 'simultaneous'): + raise ValueError("Invalid fit method. Choose 'independent' or 'simultaneous'.") + per_q = self._per_q() + if not per_q: + raise ValueError( + 'No Q values available for sampling. Please check the experiment data.' + ) + verify_Q_index(Q_index=Q_index, Q=self._analysis.Q, allow_none=True) + if fit_method == 'simultaneous': + return super().sample(samples=samples, burn=burn, thin=thin, **sampler_options) + if Q_index is not None: + result = per_q[Q_index].bayesian.sample( + samples=samples, burn=burn, thin=thin, **sampler_options + ) + # The fresh per-Q chain now outranks any older simultaneous one, exactly as after an + # all-Q independent run; keeping the old chain here would make summary() silently + # report it instead. Cleared only on success, so a failed run changes nothing. + self._results = None + return result + # The per-Q chains live on their own samplers; this one then holds nothing of its own. + self._results = None + return [ + analysis1d.bayesian.sample(samples=samples, burn=burn, thin=thin, **sampler_options) + for analysis1d in per_q + ] + + def extend( + self, + additional_samples: int = 5000, + thin: int = 10, + parameters: list[Parameter] | list[str] | None = None, + **sampler_options: dict[str, Any], + ) -> SamplingResults: + """ + Continue the existing simultaneous chain with additional samples. + + The chains from independent sampling live on the per-Q samplers, so each is extended there + rather than here. + + Parameters + ---------- + additional_samples : int, default=5000 + Number of additional samples to draw, in the same units as ``samples``. + thin : int, default=10 + Thinning interval for the retained draws. + parameters : list[Parameter] | list[str] | None, default=None + The same restriction as in :meth:`PosteriorSampler.extend`. + **sampler_options : dict[str, Any] + Forwarded to the EasyScience Sampler. + + Returns + ------- + SamplingResults + The sampling results for the full extended chain. + + Raises + ------ + RuntimeError + If the latest sampling ran per Q index, so there is no simultaneous chain here to + extend, or if there is no chain at all. + + Notes + ----- + A ``ValueError`` propagates from the run guards if the model or data changed since the + chain was started, or if this run's parameters differ from the ones the chain holds. + """ + if self._results is None and self.results_per_q is not None: + # Without this check, a stale simultaneous sampler would either be extended silently + # or misdiagnosed as a failed run. + raise RuntimeError( + 'The latest sampling ran per Q index, so there is no simultaneous chain here to ' + 'extend. Extend a per-Q chain with ' + 'analysis.analysis_list[Q_index].bayesian.extend(), or start a fresh simultaneous ' + "chain with sample(fit_method='simultaneous')." + ) + return super().extend( + additional_samples=additional_samples, + thin=thin, + parameters=parameters, + **sampler_options, + ) + + def save(self, path: str | os.PathLike) -> None: + """ + Save the simultaneous MCMC chain to disk. + + The chains from independent sampling live on the per-Q samplers, so each is saved there + rather than here. + + Parameters + ---------- + path : str | os.PathLike + Path prefix for the chain files. + + Raises + ------ + RuntimeError + If the latest sampling ran per Q index -- there is then no simultaneous chain here to + save -- or if there is no chain at all. + """ + if self._results is None and self.results_per_q is not None: + # Without this check, a stale simultaneous chain would be written to disk as if it + # were the latest sampling. + raise RuntimeError( + 'The latest sampling ran per Q index, and those chains live on the per-Q ' + 'samplers; there is no simultaneous chain here to save. Save each with ' + 'analysis.analysis_list[Q_index].bayesian.save(), or sample with ' + "fit_method='simultaneous' first." + ) + super().save(path) + + def summary(self, labeller: Callable[[Parameter], str] | None = None) -> PosteriorSummary: + """ + Summarize the posterior, gathering the per-Q chains when sampling was independent. + + Every entry is a marginal distribution of one parameter, and a marginal is well defined + within its own chain, so collecting them into one table is sound even though the chains are + separate. Labels carry the Q index either way, so the table reads the same. + + Parameters + ---------- + labeller : Callable[[Parameter], str] | None, default=None + Overrides the label a resolved column is reported under. The default is this analysis' + own Q-qualified labels. + + Returns + ------- + PosteriorSummary + One entry per sampled parameter, across every Q index that has been sampled. + """ + per_q = self.results_per_q + if self._results is not None or per_q is None: + return super().summary(labeller) + + # Each chain is summarized by its own per-Q sampler, whose saved labels can match a chain + # loaded from disk in a fresh session; this sampler's labels then supply the Q-qualified + # display name for every column that resolves to a parameter. + qualify = self._labels().label if labeller is None else labeller + entries = [] + for analysis1d in self._per_q(): + if analysis1d.bayesian.results is None: + continue + entries.extend(analysis1d.bayesian.summary(labeller=qualify).entries) + return PosteriorSummary(entries) + + def set_parameters_to_median(self) -> list[Parameter]: + """ + Set every sampled parameter to the median of its marginal posterior. + + Applies the per-Q chains to their own Q when sampling was independent. + + Returns + ------- + list[Parameter] + The parameters that were changed. + """ + if self._results is not None or self.results_per_q is None: + return super().set_parameters_to_median() + changed = [] + for analysis1d in self._per_q(): + if analysis1d.bayesian.results is not None: + changed.extend(analysis1d.bayesian.set_parameters_to_median()) + return changed + + def plot_corner(self, Q_index: int | None = None, **kwargs: dict[str, Any]) -> Figure | VBox: + """ + Plot the marginal and pairwise posterior distributions. + + After independent sampling each Q has its own chain, and no draw pairs a parameter at one Q + with a parameter at another, so there is no joint distribution across Q to plot. Rather + than combine them into a figure showing correlations that came from how the sampling was + run, this steps through the chains one at a time: pick one with ``Q_index``, or leave it + out in a notebook to get a slider. + + Parameters + ---------- + Q_index : int | None, default=None + Which Q index to plot, when the chains are per-Q. If None, a slider is returned. Not + used for a simultaneous chain, which already covers every Q. + **kwargs : dict[str, Any] + Forwarded to :func:`easydynamics.utils.posterior_plotting.plot_corner`. + + Returns + ------- + Figure | VBox + The matplotlib Figure, or an ipywidgets box with a Q slider. + + Raises + ------ + RuntimeError + If a slider is asked for outside a notebook. + + Notes + ----- + An ``IndexError`` or ``TypeError`` propagates from the Q_index validation if Q_index is + out of range or not an int. + """ + from easydynamics.utils.posterior_plotting import corner_with_slider + + verify_Q_index(Q_index=Q_index, Q=self._analysis.Q, allow_none=True) + per_q = self.results_per_q + if self._results is not None or per_q is None: + return super().plot_corner(**kwargs) + + analyses = self._per_q() + if Q_index is not None: + return analyses[Q_index].bayesian.plot_corner(**kwargs) + + if not _in_notebook(): + sampled = [index for index, result in enumerate(per_q) if result is not None] + raise RuntimeError( + f'Each Q index has its own chain, and the slider needs a Jupyter notebook. ' + f'Pass Q_index to plot one of them; sampled Q indices are {sampled}.' + ) + + chains = {} + for analysis1d, result in zip(analyses, per_q, strict=True): + if result is None: + continue + # Named by the per-Q sampler, so the labels match that Q's own summary and stay short: + # the Q index is on the slider, and repeating it in every axis label would only cost + # width. The summary entries follow the draw columns, so the order lines up. + entries = list(analysis1d.bayesian.summary()) + chains[analysis1d.Q_index] = { + 'draws': result.draws, + 'names': [entry.name for entry in entries], + 'units': [entry.unit for entry in entries], + } + return corner_with_slider(chains, title=self._analysis.display_name, **kwargs) + + def plot_trace(self, Q_index: int | None = None, **kwargs: dict[str, Any]) -> Figure | VBox: + """ + Plot the chain trace of each sampled parameter. + + A simultaneous chain is one trace and is drawn directly. After independent sampling each Q + index has its own chain, so the traces are stepped through one at a time: pick one with + ``Q_index``, or leave it out in a notebook to get a slider. + + Parameters + ---------- + Q_index : int | None, default=None + Which Q index to plot, when the chains are per-Q. If None, a slider is returned. Not + used for a simultaneous chain, which is a single trace already. + **kwargs : dict[str, Any] + Forwarded to :func:`easydynamics.utils.posterior_plotting.plot_trace`. + + Returns + ------- + Figure | VBox + The matplotlib Figure, or an ipywidgets box with a Q slider. + + Notes + ----- + A ``RuntimeError`` propagates if a slider is asked for outside a notebook or nothing has + been sampled yet, and an ``IndexError`` or ``TypeError`` from the Q_index validation if + Q_index is out of range or not an int. + """ + verify_Q_index(Q_index=Q_index, Q=self._analysis.Q, allow_none=True) + per_q = self.results_per_q + if self._results is not None or per_q is None: + return super().plot_trace(**kwargs) + if Q_index is not None: + return self._per_q()[Q_index].bayesian.plot_trace(**kwargs) + self._require_notebook_for_slider(per_q) + return self._figures_with_q_slider( + per_q, lambda analysis1d: analysis1d.bayesian.plot_trace(**kwargs) + ) + + def plot_marginal( + self, + parameter: Parameter | str, + Q_index: int | None = None, + **kwargs: dict[str, Any], + ) -> Figure | VBox: + """ + Plot the marginal posterior distribution of a single sampled parameter. + + A simultaneous chain holds every Q's parameters under Q-qualified labels, so the label + picks the Q as well (``'Gaussian width (Q_index=1)'``). After independent sampling the + chains are per-Q and the parameter goes by its plain label in each; pick a chain with + ``Q_index``, or leave it out in a notebook to step through the Q values with a slider. + + Parameters + ---------- + parameter : Parameter | str + The parameter to plot, as a Parameter object or its label. On the slider path a + Parameter object is resolved to its display name first, so the matching parameter of + every Q is shown even though the object itself belongs to one Q. + Q_index : int | None, default=None + Which Q index to plot, when the chains are per-Q. If None, a slider is returned. Not + used for a simultaneous chain, whose labels carry the Q index already. + **kwargs : dict[str, Any] + Forwarded to :func:`easydynamics.utils.posterior_plotting.plot_marginal`. + + Returns + ------- + Figure | VBox + The matplotlib Figure, or an ipywidgets box with a Q slider. + + Notes + ----- + A ``ValueError`` propagates if the parameter matches no sampled chain column, a + ``RuntimeError`` if a slider is asked for outside a notebook or nothing has been sampled + yet, and an ``IndexError`` or ``TypeError`` from the Q_index validation if Q_index is out + of range or not an int. + """ + verify_Q_index(Q_index=Q_index, Q=self._analysis.Q, allow_none=True) + per_q = self.results_per_q + if self._results is not None or per_q is None: + return super().plot_marginal(parameter, **kwargs) + if Q_index is not None: + return self._per_q()[Q_index].bayesian.plot_marginal(parameter, **kwargs) + self._require_notebook_for_slider(per_q) + # Resolved to a display name up front, because a Parameter object belongs to one Q only + # and every chain must find its own copy under the shared name. + label = ( + parameter + if isinstance(parameter, str) + else self._shared_display_name(parameter, per_q) + ) + return self._figures_with_q_slider( + per_q, lambda analysis1d: analysis1d.bayesian.plot_marginal(label, **kwargs) + ) + + def plot_correlations( + self, Q_index: int | None = None, **kwargs: dict[str, Any] + ) -> Figure | VBox: + """ + Plot the Pearson correlation matrix of the sampled parameters. + + A simultaneous chain gives one matrix over every Q's parameters at once. After independent + sampling no draw pairs one Q with another, so there is one matrix per chain: pick one with + ``Q_index``, or leave it out in a notebook to get a slider. + + Parameters + ---------- + Q_index : int | None, default=None + Which Q index to plot, when the chains are per-Q. If None, a slider is returned. Not + used for a simultaneous chain, which already covers every Q. + **kwargs : dict[str, Any] + Forwarded to :func:`easydynamics.utils.posterior_plotting.plot_correlations`. + + Returns + ------- + Figure | VBox + The matplotlib Figure, or an ipywidgets box with a Q slider. + + Notes + ----- + A ``RuntimeError`` propagates if a slider is asked for outside a notebook or nothing has + been sampled yet, and an ``IndexError`` or ``TypeError`` from the Q_index validation if + Q_index is out of range or not an int. + """ + verify_Q_index(Q_index=Q_index, Q=self._analysis.Q, allow_none=True) + per_q = self.results_per_q + if self._results is not None or per_q is None: + return super().plot_correlations(**kwargs) + if Q_index is not None: + return self._per_q()[Q_index].bayesian.plot_correlations(**kwargs) + self._require_notebook_for_slider(per_q) + return self._figures_with_q_slider( + per_q, lambda analysis1d: analysis1d.bayesian.plot_correlations(**kwargs) + ) + + def plot_posterior_predictive( + self, + n_draws: int = 200, + credible_interval: float = 68.0, + Q_index: int | None = None, + **kwargs: dict[str, Any], + ) -> Figure | InteractiveFigure: + """ + Plot the data against the credible band implied by the posterior. + + After independent sampling each Q has its own chain, and its own band: pick one with + ``Q_index`` for a single matplotlib figure, or leave it out in a notebook to get a plopp + figure with a Q slider, looking and handling exactly like ``Analysis.plot_data_and_model``. + Plopp draws no filled band, so the slider view shows the posterior median with a dashed + line along each band edge instead of a shaded band. + + Parameters + ---------- + n_draws : int, default=200 + How many posterior draws to evaluate the model for, per Q on the slider path. Each + costs a full model evaluation. + credible_interval : float, default=68.0 + Width of the credible band, as a percentage. + Q_index : int | None, default=None + Which Q index to plot, when the chains are per-Q. If None, a slider is returned. + **kwargs : dict[str, Any] + Forwarded to :func:`easydynamics.utils.posterior_plotting.plot_posterior_predictive` + for a single figure, or to + :func:`easydynamics.utils.posterior_plotting.predictive_with_slider` for the slider. + + Returns + ------- + Figure | InteractiveFigure + The matplotlib Figure for one Q, or the plopp figure with a Q slider. + + Raises + ------ + ValueError + If n_draws is not a positive integer, or credible_interval is out of range. + + Notes + ----- + A ``NotImplementedError`` propagates when the latest chain is simultaneous: it binds every + dataset at once, and no per-Q chain exists for Q_index to pick out. A ``RuntimeError`` + propagates if a slider is asked for outside a notebook or nothing has been sampled yet, + and an ``IndexError`` or ``TypeError`` from the Q_index validation if Q_index is out of + range or not an int. + """ + if not isinstance(n_draws, int) or isinstance(n_draws, bool) or n_draws < 1: + raise ValueError(f'n_draws must be a positive integer. Got {n_draws}.') + verify_Q_index(Q_index=Q_index, Q=self._analysis.Q, allow_none=True) + per_q = self.results_per_q + if self._results is not None or per_q is None: + return super().plot_posterior_predictive( + n_draws=n_draws, credible_interval=credible_interval, **kwargs + ) + if Q_index is not None: + return self._per_q()[Q_index].bayesian.plot_posterior_predictive( + n_draws=n_draws, credible_interval=credible_interval, **kwargs + ) + self._require_notebook_for_slider(per_q) + return self._predictive_with_q_slider(per_q, n_draws, credible_interval, **kwargs) + + ############# + # Sliders over the independent per-Q chains + ############# + + def _require_notebook_for_slider(self, per_q: list[SamplingResults | None]) -> None: + """ + Refuse the slider path outside a notebook, naming the sampled Q indices. + + Parameters + ---------- + per_q : list[SamplingResults | None] + The per-Q chains, None where a Q index has not been sampled. + + Raises + ------ + RuntimeError + If not running in a Jupyter notebook. + """ + if _in_notebook(): + return + sampled = [index for index, result in enumerate(per_q) if result is not None] + raise RuntimeError( + f'Each Q index has its own chain, and the slider needs a Jupyter notebook. ' + f'Pass Q_index to plot one of them; sampled Q indices are {sampled}.' + ) + + def _figures_with_q_slider( + self, + per_q: list[SamplingResults | None], + plot_one: Callable[[object], Figure], + ) -> VBox: + """ + Render one figure per sampled Q index and put them behind a slider. + + Only the Q indices that actually hold a chain get a figure, so the slider cannot land on an + empty position. Each figure carries its per-Q Analysis' own display name, which names the Q + index. + + Parameters + ---------- + per_q : list[SamplingResults | None] + The per-Q chains, None where a Q index has not been sampled. + plot_one : Callable[[object], Figure] + Renders the figure for one per-Q Analysis. + + Returns + ------- + VBox + An ipywidgets box with the pre-rendered figures behind a Q slider. + """ + from easydynamics.utils.posterior_plotting import figures_with_slider + + figures = {} + for analysis1d, result in zip(self._per_q(), per_q, strict=True): + if result is None: + continue + figures[analysis1d.Q_index] = plot_one(analysis1d) + return figures_with_slider(figures) + + def _shared_display_name( + self, + parameter: Parameter, + per_q: list[SamplingResults | None], + ) -> str: + """ + Find the display name a Parameter goes by within its own Q's chain. + + The same model is repeated per Q, so the name one chain reports a parameter under is the + name every other chain reports its own copy under. Resolving through it lets a slider show + the matching marginal at every Q even though the Parameter object belongs to one. + + Parameters + ---------- + parameter : Parameter + The parameter to resolve. + per_q : list[SamplingResults | None] + The per-Q chains, None where a Q index has not been sampled. + + Returns + ------- + str + The display name of the chain column holding the parameter's draws. + + Raises + ------ + ValueError + If no sampled chain holds draws of the parameter. + """ + for analysis1d, result in zip(self._per_q(), per_q, strict=True): + if result is None: + continue + # The same labels that Q's own sampler reports its chain under: its free parameters, + # unqualified, since a single Q has one copy of each. + labels = ParameterLabels(analysis1d.get_free_parameters()) + if any( + candidate.unique_name == parameter.unique_name for candidate in labels.parameters + ): + return labels.label(parameter) + name = getattr(parameter, 'name', '?') + raise ValueError(f'No sampled parameter named {name!r} in any per-Q chain.') + + def _predictive_with_q_slider( + self, + per_q: list[SamplingResults | None], + n_draws: int, + credible_interval: float, + **kwargs: dict[str, Any], + ) -> InteractiveFigure: + """ + Build the posterior-predictive figure with a Q slider from the per-Q chains. + + Each sampled Q contributes its data, median prediction and band edges, computed from its + own chain with the same machinery the single-Q figure uses. Rows are laid out on the + experiment's common energy grid; a Q's masked-away points stay NaN, leaving a gap rather + than inventing a value there. + + Parameters + ---------- + per_q : list[SamplingResults | None] + The per-Q chains, None where a Q index has not been sampled. + n_draws : int + How many posterior draws to evaluate the model for, per Q. + credible_interval : float + Width of the credible band, as a percentage. + **kwargs : dict[str, Any] + Forwarded to :func:`easydynamics.utils.posterior_plotting.predictive_with_slider`. + + Returns + ------- + InteractiveFigure + The plopp figure with its Q slider. + + Raises + ------ + ValueError + If credible_interval is not between 0 and 100. + """ + from easydynamics.utils.posterior_plotting import predictive_with_slider + + if not 0 < credible_interval < 100: + raise ValueError( + f'credible_interval must be between 0 and 100. Got {credible_interval}.' + ) + + energy = self._analysis.energy + q = self._analysis.Q + energy_values = np.asarray(energy.values, dtype=float) + + # As in the single-Q figure: without variances the weights are all-ones placeholders, and + # inverting them would fabricate error bars the data never had. + experiment = getattr(self._analysis, 'experiment', None) + has_variances = experiment is None or getattr(experiment, 'has_variances', True) + sample_model = getattr(self._analysis, 'sample_model', None) + y_unit = None if sample_model is None else getattr(sample_model, 'y_unit', None) + kwargs.setdefault('ylabel', 'Intensity' if y_unit is None else f'Intensity ({y_unit})') + + sampled = [ + analysis1d + for analysis1d, result in zip(self._per_q(), per_q, strict=True) + if result is not None + ] + shape = (len(sampled), len(energy_values)) + data = np.full(shape, np.nan) + variances = np.full(shape, np.nan) if has_variances else None + lower = np.full(shape, np.nan) + median = np.full(shape, np.nan) + upper = np.full(shape, np.nan) + tail = (100.0 - credible_interval) / 2.0 + for row, analysis1d in enumerate(sampled): + _, y, weights, mask = analysis1d.experiment.extract_x_y_weights_only_finite( + Q_index=analysis1d.Q_index + ) + predictions = analysis1d.bayesian.predictions(n_draws) + # The mask places every finite point back on the common grid, so the padding stays + # NaN wherever a point was masked away. + data[row, mask] = np.asarray(y) + if variances is not None: + variances[row, mask] = 1.0 / np.asarray(weights) ** 2 + lower[row, mask], median[row, mask], upper[row, mask] = np.percentile( + predictions, [tail, 50.0, 100.0 - tail], axis=0 + ) + + return predictive_with_slider( + energy=energy_values, + q_values=np.asarray([float(q.values[a.Q_index]) for a in sampled]), + y=data, + lower=lower, + median=median, + upper=upper, + y_variances=variances, + energy_unit=str(energy.unit), + q_unit=str(q.unit), + title=self._analysis.display_name, + credible_interval=credible_interval, + **kwargs, + ) + + def _require_results(self) -> SamplingResults: + """ + Get the stored results, pointing at the per-Q chains when those are what exist. + + Returns + ------- + SamplingResults + The results of the most recent simultaneous run. + + Raises + ------ + RuntimeError + If no simultaneous sampling has been run. + """ + if self._results is None and self.results_per_q is not None: + raise RuntimeError( + 'This Analysis holds no chain of its own, but its Q indices do: sampling with ' + "fit_method='independent' gives each Q its own chain. summary() and " + 'set_parameters_to_median() gather those up; for anything needing a single chain, ' + 'use analysis.analysis_list[Q_index].bayesian, or sample with ' + "fit_method='simultaneous'." + ) + return super()._require_results() + + +def _stacklevel_above_module() -> int: + """ + Compute the stacklevel that points a warning at the first frame outside this module. + + The entry points nest to different depths -- ``MultiQPosteriorSampler.sample`` goes through + ``PosteriorSampler.sample`` and ``_run``, a plain ``sample`` skips the first hop -- so any + fixed stacklevel points warnings at an internal frame on one path or the other. Counting the + in-module frames instead lands the warning on the caller's own line either way. + + Returns + ------- + int + The stacklevel for a ``warnings.warn`` call made directly by this function's caller. + """ + frame = inspect.currentframe() + frame = None if frame is None else frame.f_back + level = 1 + while frame is not None and frame.f_globals.get('__name__') == __name__: + frame = frame.f_back + level += 1 + return level + + def _warn_about_held_parameters(labels: object, held_fixed: list[Parameter]) -> None: """ Warn that holding parameters fixed makes the credible intervals conditional. @@ -1116,7 +1900,7 @@ def _warn_about_held_parameters(labels: object, held_fixed: list[Parameter]) -> f'parameters are correlated.' ), UserWarning, - stacklevel=4, + stacklevel=_stacklevel_above_module(), ) diff --git a/src/easydynamics/utils/posterior_plotting.py b/src/easydynamics/utils/posterior_plotting.py index c3576d164..d1e45b146 100644 --- a/src/easydynamics/utils/posterior_plotting.py +++ b/src/easydynamics/utils/posterior_plotting.py @@ -10,8 +10,10 @@ from __future__ import annotations +import io import warnings from typing import TYPE_CHECKING +from typing import Any import matplotlib.pyplot as plt import numpy as np @@ -19,7 +21,9 @@ from matplotlib.ticker import MaxNLocator if TYPE_CHECKING: + from ipywidgets import VBox from matplotlib.figure import Figure + from plopp.backends.matplotlib.figure import InteractiveFigure def plot_trace( @@ -290,8 +294,8 @@ def plot_correlations( Correlations are dimensionless, so the labels carry no units. A constant column has no defined correlation with anything; its cells are shown greyed out and marked "n/a" rather than failing. - A ``ValueError`` propagates from the input validation if ``draws`` is not two-dimensional or - is empty, or if ``names`` does not have one entry per column. + A ``ValueError`` propagates from the input validation if ``draws`` is not two-dimensional or is + empty, or if ``names`` does not have one entry per column. Parameters ---------- @@ -590,3 +594,240 @@ def _verify_draws(draws: np.ndarray, names: list[str]) -> None: f'names must have one entry per column of draws. ' f'Got {len(names)} names for {draws.shape[1]} columns.' ) + + +def figures_with_slider(figures: dict[int, Figure], description: str = 'Q index') -> VBox: + """ + Show one pre-rendered figure at a time, with a slider choosing which one. + + Every figure is rendered to PNG bytes once, up front, and the slider callback only swaps the + stored bytes into an image widget. Moving the slider therefore costs no matplotlib work at all, + which keeps it as responsive as the plopp slider on the data plots; re-rendering a figure on + every move is what made the previous slider feel sluggish. + + The figures are closed after rendering, so no backend draws them a second time. + + Parameters + ---------- + figures : dict[int, Figure] + Mapping of slider position to the matplotlib Figure shown there. Only these positions are + offered, so the slider cannot land on an index with nothing to show. + description : str, default='Q index' + Label shown next to the slider. + + Returns + ------- + VBox + An ipywidgets box holding the image and, under it, the slider. + + Raises + ------ + ValueError + If no figures are given. + """ + import ipywidgets as widgets + + if not figures: + raise ValueError('No figures to show.') + + indices = sorted(figures) + rendered = {} + for index in indices: + figure = figures[index] + buffer = io.BytesIO() + figure.savefig(buffer, format='png', bbox_inches='tight') + rendered[index] = buffer.getvalue() + # Rendered to bytes already, so the figure is closed rather than left for a backend to + # draw a second time. + plt.close(figure) + + image = widgets.Image(value=rendered[indices[0]], format='png') + image.layout.max_width = '100%' + # Swapping stored bytes is instant, so the image can follow the slider continuously; there is + # no need for the release-to-update behaviour an expensive redraw would force. + slider = widgets.SelectionSlider( + options=indices, + value=indices[0], + description=description, + continuous_update=True, + ) + slider.observe(lambda change: setattr(image, 'value', rendered[change['new']]), names='value') + # Slider under the figure, matching where plopp puts its slicer controls. + return widgets.VBox([image, slider]) + + +def corner_with_slider( + chains: dict[int, dict], + title: str | None = None, + **kwargs: dict[str, Any], +) -> VBox: + """ + Show one corner plot at a time, with a slider choosing which chain to look at. + + Chains sampled separately share no draws, so there is no joint distribution across them to + plot. Stepping through them one at a time shows the correlations that were actually sampled, + which is what a single combined figure could not do honestly. The figures are pre-rendered + through :func:`figures_with_slider`, so the slider moves without re-drawing anything. + + Parameters + ---------- + chains : dict[int, dict] + Mapping of index to a ``{'draws': ..., 'names': ..., 'units': ...}`` description of one + chain. ``units`` is optional. + title : str | None, default=None + Title prefix, extended with the selected index. + **kwargs : dict[str, Any] + Forwarded to :func:`plot_corner`. + + Returns + ------- + VBox + An ipywidgets box holding the figure and the slider. + + Raises + ------ + ValueError + If no chains are given. + """ + if not chains: + raise ValueError('No chains to plot.') + + figures = { + index: plot_corner( + draws=chain['draws'], + names=chain['names'], + units=chain.get('units'), + title=title if title is None else f'{title} (Q index {index})', + **kwargs, + ) + for index, chain in chains.items() + } + return figures_with_slider(figures) + + +def predictive_with_slider( + energy: np.ndarray, + q_values: np.ndarray, + y: np.ndarray, + lower: np.ndarray, + median: np.ndarray, + upper: np.ndarray, + y_variances: np.ndarray | None = None, + energy_unit: str | None = None, + q_unit: str | None = None, + ylabel: str | None = None, + title: str | None = None, + credible_interval: float = 68.0, + **kwargs: dict[str, Any], +) -> InteractiveFigure: + """ + Plot per-Q posterior-predictive bands behind a plopp Q slider. + + Built on ``plopp.slicer`` over a scipp DataGroup with a Q dimension, so the figure looks and + handles exactly like ``Analysis.plot_data_and_model``: the data with its error bars, the model + curves on top, and a Q slider underneath. Plopp draws no filled band for sliced data -- its + only spread representation is variance-based error bars -- so the credible band is drawn as the + posterior median with a dashed line along each band edge, labelled with the interval. + + Rows are laid out on one common energy grid; where a Q has no point (masked or never measured), + NaN leaves a gap in the lines rather than inventing a value. + + Parameters + ---------- + energy : np.ndarray + The common energy grid, one column per point. + q_values : np.ndarray + The Q value of each row, shown on the slider. + y : np.ndarray + Observed values, shape ``(len(q_values), len(energy))``, NaN where a Q has no point. + lower : np.ndarray + Lower band edge per Q, same shape as ``y``. + median : np.ndarray + Posterior median prediction per Q, same shape as ``y``. + upper : np.ndarray + Upper band edge per Q, same shape as ``y``. + y_variances : np.ndarray | None, default=None + Variances of the observed values, drawn as error bars when given. + energy_unit : str | None, default=None + Unit of the energy grid, shown on the horizontal axis. + q_unit : str | None, default=None + Unit of the Q values, shown beside the slider. + ylabel : str | None, default=None + Label for the dependent axis. + title : str | None, default=None + Figure title. + credible_interval : float, default=68.0 + Width of the credible band the edges enclose, as a percentage, used in their labels. + **kwargs : dict[str, Any] + Forwarded to ``plopp.slicer``, overriding the style defaults. + + Returns + ------- + InteractiveFigure + The plopp figure with its Q slider. + + Raises + ------ + ValueError + If the arrays do not share the shape ``(len(q_values), len(energy))``, or if + ``credible_interval`` is not between 0 and 100. + """ + import plopp as pp + import scipp as sc + + if not 0 < credible_interval < 100: + raise ValueError(f'credible_interval must be between 0 and 100. Got {credible_interval}.') + expected = (len(q_values), len(energy)) + arrays = {'y': y, 'lower': lower, 'median': median, 'upper': upper} + if y_variances is not None: + arrays['y_variances'] = y_variances + for name, array in arrays.items(): + if np.asarray(array).shape != expected: + raise ValueError(f'{name} must have shape {expected}. Got {np.asarray(array).shape}.') + + coords = { + 'Q': sc.array(dims=['Q'], values=np.asarray(q_values, dtype=float), unit=q_unit), + 'energy': sc.array( + dims=['energy'], values=np.asarray(energy, dtype=float), unit=energy_unit + ), + } + + def data_array(values: np.ndarray, variances: np.ndarray | None = None) -> sc.DataArray: + return sc.DataArray( + data=sc.array( + dims=['Q', 'energy'], + values=np.asarray(values, dtype=float), + variances=None if variances is None else np.asarray(variances, dtype=float), + ), + coords=coords, + ) + + lower_key = f'{credible_interval:.0f}% band (lower)' + upper_key = f'{credible_interval:.0f}% band (upper)' + data_group = sc.DataGroup({ + 'Data': data_array(y, y_variances), + 'Posterior median': data_array(median), + lower_key: data_array(lower), + upper_key: data_array(upper), + }) + + # The same styling plot_data_and_model gives its DataGroup: data as open black circles, the + # model curves as lines, with the band edges dashed to read as edges rather than curves. + style = { + 'keep': 'energy', + 'linestyle': {'Data': 'none', 'Posterior median': '-', lower_key: '--', upper_key: '--'}, + 'marker': {'Data': 'o', 'Posterior median': None, lower_key: None, upper_key: None}, + 'color': {'Data': 'black', 'Posterior median': 'C3', lower_key: 'C3', upper_key: 'C3'}, + 'markerfacecolor': {'Data': 'none'}, + } + if title is not None: + style['title'] = title + style.update(kwargs) + + fig = pp.slicer(data_group, **style) + for widget in fig.bottom_bar[0].controls.values(): + widget.slider_toggler.value = '-o-' + if ylabel is not None: + fig.ax.set_ylabel(ylabel) + fig.autoscale() + return fig diff --git a/tests/integration/fitting/test_bayesian_sampling.py b/tests/integration/fitting/test_bayesian_sampling.py index f341a1c84..58fc48ab6 100644 --- a/tests/integration/fitting/test_bayesian_sampling.py +++ b/tests/integration/fitting/test_bayesian_sampling.py @@ -4,9 +4,9 @@ """ Integration tests running real BUMPS DREAM chains through Analysis1d. -These are slow by nature. They deliberately run with ``sampler_kwargs={'trim': False}``: BUMPS' -automatic burn-point trimming re-runs a convergence detector on every call and can crash inside its -own outlier removal on the very short chains used here. +These are slow by nature. Two BUMPS options are switched off deliberately: its burn-point trimming, +which re-runs a convergence detector on every call, and its outlier removal, which indexes past the +end of its own buffer on chains as short as these. Neither affects the sampling itself. """ import warnings @@ -33,7 +33,10 @@ 'samples': 2000, 'burn': 100, 'thin': 2, - 'sampler_kwargs': {'trim': False}, + # 'trim': BUMPS' burn-point detector re-runs on every call and is not worth paying + # for here. 'outliers': its outlier removal indexes past the end of its own buffer on + # chains this short, which has failed in CI; the sampling itself is unaffected. + 'sampler_kwargs': {'trim': False, 'outliers': 'none'}, } @@ -132,15 +135,22 @@ def test_sampling_leaves_the_fitted_values_untouched(self): after = [float(p.value) for p in analysis.get_free_parameters()] assert after == pytest.approx(before) - def test_extend_grows_the_chain(self, sampled_analysis): - # WHEN - before = int(sampled_analysis.bayesian.results.state.Ngen) + def test_extend_grows_the_chain(self): + # WHEN a chain of this test's own: extending mutates the sampler state, so running it on + # the module-scoped fixture would hand every later test the extended chain + analysis = build_analysis() + analysis.fit() + analysis.bayesian.suggest_bounds().apply() + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + analysis.bayesian.sample(**SAMPLE_KWARGS) + before = int(analysis.bayesian.results.state.Ngen) # THEN with warnings.catch_warnings(): warnings.simplefilter('ignore') - extended = sampled_analysis.bayesian.extend( - additional_samples=500, thin=2, sampler_kwargs={'trim': False} + extended = analysis.bayesian.extend( + additional_samples=500, thin=2, sampler_kwargs={'trim': False, 'outliers': 'none'} ) # EXPECT diff --git a/tests/integration/fitting/test_bayesian_sampling_multi_q.py b/tests/integration/fitting/test_bayesian_sampling_multi_q.py new file mode 100644 index 000000000..74061b57e --- /dev/null +++ b/tests/integration/fitting/test_bayesian_sampling_multi_q.py @@ -0,0 +1,329 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +""" +Integration tests running real BUMPS DREAM chains through Analysis and ParameterAnalysis. + +Slow by nature, and with the same two BUMPS options switched off as the single-Q integration tests: +its burn-point trimming, which re-runs a convergence detector on every call, and its outlier +removal, which indexes past the end of its own buffer on chains as short as these. +""" + +import warnings +from unittest.mock import patch + +import matplotlib as mpl +import numpy as np +import pytest +import scipp as sc + +mpl.use('Agg') + +import easydynamics as edyn +import easydynamics.sample_model as sm + +Q_VALUES = [0.5, 1.0, 1.5] +NOISE = 0.02 +TRUE_AREA = 2.0 + +SAMPLE_KWARGS = { + 'samples': 2000, + 'burn': 100, + 'thin': 2, + # 'trim': BUMPS' burn-point detector re-runs on every call and is not worth paying + # for here. 'outliers': its outlier removal indexes past the end of its own buffer on + # chains this short, which has failed in CI; the sampling itself is unaffected. + 'sampler_kwargs': {'trim': False, 'outliers': 'none'}, +} + + +def true_width(q): + return 0.8 + 0.4 * q**2 + + +def build_analysis(): + energy_values = np.linspace(-5.0, 5.0, 40) + rng = np.random.default_rng(0) + rows = [] + for q in Q_VALUES: + width = true_width(q) + row = TRUE_AREA / (width * np.sqrt(2 * np.pi)) + row = row * np.exp(-0.5 * (energy_values / width) ** 2) + rows.append(row + rng.normal(0.0, NOISE, size=row.shape)) + observed = np.vstack(rows) + + experiment = edyn.Experiment( + data=sc.DataArray( + data=sc.array( + dims=['Q', 'energy'], + values=observed, + variances=np.full_like(observed, NOISE**2), + ), + coords={ + 'Q': sc.array(dims=['Q'], values=Q_VALUES, unit='1/Angstrom'), + 'energy': sc.array(dims=['energy'], values=energy_values, unit='meV'), + }, + ) + ) + return edyn.Analysis( + display_name='MultiQIntegration', + experiment=experiment, + sample_model=sm.SampleModel(components=sm.Gaussian(area=TRUE_AREA, width=1.0)), + instrument_model=sm.InstrumentModel(), + ) + + +@pytest.fixture(scope='module') +def simultaneously_sampled(): + analysis = build_analysis() + analysis.fit(fit_method='simultaneous') + analysis.bayesian.suggest_bounds().apply() + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + analysis.bayesian.sample(fit_method='simultaneous', **SAMPLE_KWARGS) + return analysis + + +@pytest.fixture(scope='module') +def independently_sampled(): + """One independent DREAM run shared by every test that only reads the per-Q chains.""" + analysis = build_analysis() + analysis.fit(fit_method='independent') + for analysis1d in analysis.analysis_list: + analysis1d.bayesian.suggest_bounds().apply() + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + results = analysis.bayesian.sample(fit_method='independent', **SAMPLE_KWARGS) + return analysis, results + + +class TestSimultaneousChain: + def test_chain_covers_every_q_index(self, simultaneously_sampled): + # THEN + results = simultaneously_sampled.bayesian.results + + # EXPECT one column per free parameter across all Q, in one chain + assert results.draws.shape[1] == len(simultaneously_sampled._chain_parameters()) + assert results.draws.shape[1] == 3 * len(Q_VALUES) + + def test_summary_labels_are_unique_and_q_qualified(self, simultaneously_sampled): + # THEN + names = [entry.name for entry in simultaneously_sampled.bayesian.summary()] + + # EXPECT + assert len(set(names)) == len(names) + assert all('Q_index=' in name for name in names) + + @pytest.mark.parametrize('q_index', range(len(Q_VALUES))) + def test_posterior_recovers_the_true_width_at_each_q(self, simultaneously_sampled, q_index): + # THEN + entry = simultaneously_sampled.bayesian.summary()[f'Gaussian width (Q_index={q_index})'] + + # EXPECT the truth within a few posterior standard deviations. A 68% interval is not used + # here: it excludes the truth about a third of the time for a single noise realization. + spread = max(entry.minus, entry.plus) + assert abs(entry.median - true_width(Q_VALUES[q_index])) < 4 * spread + + def test_sampling_leaves_the_fitted_values_untouched(self): + # WHEN + analysis = build_analysis() + analysis.fit(fit_method='simultaneous') + analysis.bayesian.suggest_bounds().apply() + before = [float(p.value) for p in analysis._chain_parameters()] + + # THEN + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + analysis.bayesian.sample(fit_method='simultaneous', **SAMPLE_KWARGS) + + # EXPECT + after = [float(p.value) for p in analysis._chain_parameters()] + assert after == pytest.approx(before) + + def test_plots_render(self, simultaneously_sampled): + # WHEN + import matplotlib.pyplot as plt + + n_parameters = len(simultaneously_sampled._chain_parameters()) + + # THEN + trace = simultaneously_sampled.bayesian.plot_trace() + corner = simultaneously_sampled.bayesian.plot_corner() + + # EXPECT + assert len(trace.axes) == n_parameters + 1 + assert len(corner.axes) == n_parameters**2 + plt.close('all') + + +class TestIndependentChains: + def test_one_chain_per_q_index(self, independently_sampled): + # THEN + analysis, results = independently_sampled + + # EXPECT + assert len(results) == len(Q_VALUES) + for analysis1d, result in zip(analysis.analysis_list, results, strict=True): + assert result.draws.shape[1] == len(analysis1d.get_free_parameters()) + + def test_independent_and_simultaneous_agree_on_the_widths( + self, simultaneously_sampled, independently_sampled + ): + # THEN the same data sampled per-Q is compared with the single simultaneous chain + analysis, _ = independently_sampled + + # EXPECT both routes land on the same widths, since nothing is shared across Q here + for q_index, analysis1d in enumerate(analysis.analysis_list): + independent = analysis1d.bayesian.summary()['Gaussian width'] + simultaneous = simultaneously_sampled.bayesian.summary()[ + f'Gaussian width (Q_index={q_index})' + ] + spread = max(independent.minus, independent.plus, simultaneous.plus) + assert abs(independent.median - simultaneous.median) < 4 * spread + + +class TestIndependentChainWidgets: + def test_corner_slider_renders_from_the_real_chains(self, independently_sampled): + # WHEN + analysis, _ = independently_sampled + + # THEN + with patch('easydynamics.analysis.posterior_sampling._in_notebook', return_value=True): + widget = analysis.bayesian.plot_corner() + + # EXPECT every real chain pre-rendered behind the slider, and moving the slider swapping + # the stored renderings rather than drawing anything new + image, slider = widget.children + assert list(slider.options) == list(range(len(Q_VALUES))) + assert bytes(image.value).startswith(b'\x89PNG') + first_bytes = image.value + slider.value = 1 + assert image.value != first_bytes + slider.value = 0 + assert image.value == first_bytes + + def test_predictive_slider_renders_from_the_real_chains(self, independently_sampled): + # WHEN the plopp slicer needs an interactive matplotlib backend, switched in for the test + import matplotlib.pyplot as plt + + analysis, _ = independently_sampled + plt.switch_backend('module://ipympl.backend_nbagg') + try: + # THEN + with patch('easydynamics.analysis.posterior_sampling._in_notebook', return_value=True): + fig = analysis.bayesian.plot_posterior_predictive(n_draws=10) + + # EXPECT a plopp figure whose one slider spans the sampled Q values, labelled like + # the single-Q predictive plot + controls = list(fig.bottom_bar[0].controls.values()) + assert len(controls) == 1 + assert controls[0].slider.min == 0 + assert controls[0].slider.max == len(Q_VALUES) - 1 + assert fig.ax.get_ylabel().startswith('Intensity') + finally: + plt.switch_backend('Agg') + + def test_predictive_q_index_plots_one_q_from_its_own_chain(self, independently_sampled): + # WHEN + import matplotlib.pyplot as plt + + analysis, _ = independently_sampled + + # THEN + figure = analysis.bayesian.plot_posterior_predictive(Q_index=1, n_draws=10) + + # EXPECT the single-Q matplotlib figure, with its data and credible band + labels = [text.get_text() for text in figure.axes[0].get_legend().get_texts()] + assert 'Data' in labels + assert any('credible band' in label for label in labels) + plt.close('all') + + +class TestParameterAnalysisChain: + def test_recovers_a_straight_line_through_the_widths(self): + # WHEN the fitted widths are themselves fitted against a model of their Q dependence + q = np.array(Q_VALUES) + widths = true_width(q) + dataset = sc.Dataset({ + 'Gaussian width': sc.DataArray( + data=sc.array( + dims=['Q'], + values=widths, + variances=np.full_like(widths, 0.01**2), + unit='meV', + ), + coords={'Q': sc.array(dims=['Q'], values=q, unit='1/angstrom')}, + ) + }) + model = sm.Polynomial( + coefficients=[0.8, 0.0, 0.4], x_unit='1/angstrom', y_unit='meV', name='Width model' + ) + analysis = edyn.ParameterAnalysis( + parameters=dataset, + bindings=edyn.FitBinding(model=model, targets='Gaussian width'), + ) + analysis.fit() + # The linear coefficient sits at exactly zero with a vanishing uncertainty, so the sigma + # rule has no scale to work from and flags it rather than inventing one. absolute_floor + # supplies the scale the data cannot; the asserts guard that this setup really leaves + # every coefficient bounded before sampling. + flagged = analysis.bayesian.suggest_bounds().needing_attention + assert [s.label for s in flagged] == ['Width model_c1'] + analysis.bayesian.suggest_bounds(absolute_floor=1.0).apply() + assert not analysis.bayesian.suggest_bounds().needing_attention + + # THEN + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + results = analysis.bayesian.sample(**SAMPLE_KWARGS) + + # EXPECT the posterior recovers the generating polynomial within a few posterior + # standard deviations (a 68% interval would exclude the truth too often to be strict), + # with a column per coefficient and a readable, collision-free summary + summary = analysis.bayesian.summary() + for name, truth in ( + ('Width model_c0', 0.8), + ('Width model_c1', 0.0), + ('Width model_c2', 0.4), + ): + entry = summary[name] + spread = max(entry.minus, entry.plus) + assert abs(entry.median - truth) < 4 * spread + assert results.draws.shape[1] == len(analysis._chain_parameters()) + names = [entry.name for entry in summary] + assert len(set(names)) == len(names) + + +class TestAggregatedIndependentChains: + def test_summary_gathers_the_real_per_q_chains(self, independently_sampled): + # THEN + analysis, _ = independently_sampled + summary = analysis.bayesian.summary() + + # EXPECT one table covering every Q, and the widths still recovered + assert len(summary) == sum(len(a.get_free_parameters()) for a in analysis.analysis_list) + for q_index in range(len(Q_VALUES)): + entry = summary[f'Gaussian width (Q_index={q_index})'] + spread = max(entry.minus, entry.plus) + assert abs(entry.median - true_width(Q_VALUES[q_index])) < 4 * spread + + def test_median_applies_each_chain_to_its_own_q(self, independently_sampled): + # WHEN the fixture is module-scoped, so the values moved here are restored afterwards + analysis, _ = independently_sampled + parameters = [p for a in analysis.analysis_list for p in a.get_free_parameters()] + saved_values = [(p, float(p.value)) for p in parameters] + + try: + # THEN + changed = analysis.bayesian.set_parameters_to_median() + + # EXPECT every Q's parameters land on that Q's own median + assert len(changed) == sum( + len(a.get_free_parameters()) for a in analysis.analysis_list + ) + summary = analysis.bayesian.summary() + for entry in summary: + assert entry.value == pytest.approx(entry.median, rel=1e-6) + finally: + for parameter, value in saved_values: + parameter.value = value diff --git a/tests/unit/easydynamics/analysis/test_analysis.py b/tests/unit/easydynamics/analysis/test_analysis.py index fdedd845e..b8bda4f69 100644 --- a/tests/unit/easydynamics/analysis/test_analysis.py +++ b/tests/unit/easydynamics/analysis/test_analysis.py @@ -8,6 +8,8 @@ import pytest import scipp as sc +import easydynamics as edyn +import easydynamics.sample_model as sm from easydynamics.analysis.analysis import Analysis from easydynamics.experiment import Experiment from easydynamics.sample_model import InstrumentModel @@ -15,6 +17,8 @@ from easydynamics.sample_model.components.gaussian import Gaussian from easydynamics.settings.convolution_settings import ConvolutionSettings +Q_VALUES = [0.5, 1.0, 1.5] + class TestAnalysis: @pytest.fixture @@ -70,6 +74,33 @@ def analysis_single_Q(self): extra_parameters=None, ) + @pytest.fixture + def multi_q_analysis(self): + # Three Q indices sharing one Gaussian, so the per-Q parameter copies collide by name. + energy_values = np.linspace(-5.0, 5.0, 15) + rows = [2.0 * np.exp(-0.5 * (energy_values / (0.8 + 0.4 * q**2)) ** 2) for q in Q_VALUES] + observed = np.vstack(rows) + experiment = Experiment( + data=sc.DataArray( + data=sc.array( + dims=['Q', 'energy'], + values=observed, + variances=np.full_like(observed, 0.01), + ), + coords={ + 'Q': sc.array(dims=['Q'], values=Q_VALUES, unit='1/Angstrom'), + 'energy': sc.array(dims=['energy'], values=energy_values, unit='meV'), + }, + ) + ) + + return Analysis( + display_name='TestMultiQ', + experiment=experiment, + sample_model=SampleModel(components=Gaussian(area=2.0, width=1.0)), + instrument_model=InstrumentModel(), + ) + def test_init(self, analysis): # WHEN THEN @@ -1141,3 +1172,120 @@ def test_repr(self, analysis): assert 'Analysis' in repr_str assert 'display_name=' in repr_str assert 'n_analyses=' in repr_str + + ############# + # Chain parameters and labels + ############# + + def test_union_covers_every_q_index(self, multi_q_analysis): + # THEN + parameters = multi_q_analysis._chain_parameters() + + # EXPECT one copy of each per-Q parameter, with no duplicates + assert len(parameters) == sum( + len(a.get_free_parameters()) for a in multi_q_analysis.analysis_list + ) + assert len({p.unique_name for p in parameters}) == len(parameters) + + def test_labels_are_qualified_by_q_index(self, multi_q_analysis): + # THEN + labels = [ + multi_q_analysis._parameter_labels().label(p) + for p in multi_q_analysis._chain_parameters() + ] + + # EXPECT every per-Q copy is distinguishable, which the bare name would not be + assert len(set(labels)) == len(labels) + assert 'Gaussian width (Q_index=0)' in labels + assert 'Gaussian width (Q_index=2)' in labels + + def test_bare_names_would_collide(self, multi_q_analysis): + # THEN + names = [p.name for p in multi_q_analysis._chain_parameters()] + + # EXPECT the collision the Q-qualified label exists to solve + assert len(set(names)) < len(names) + + ############# + # Parameter label edge cases + ############# + + def test_single_q_analysis_keeps_plain_names(self): + # WHEN there is only one Q index, nothing needs disambiguating + energy_values = np.linspace(-5.0, 5.0, 15) + intensity = 2.0 * np.exp(-0.5 * (energy_values / 1.2) ** 2) + experiment = edyn.Experiment( + data=sc.DataArray( + data=sc.array( + dims=['Q', 'energy'], + values=intensity[None, :], + variances=np.full_like(intensity, 0.01)[None, :], + ), + coords={ + 'Q': sc.array(dims=['Q'], values=[1.0], unit='1/Angstrom'), + 'energy': sc.array(dims=['energy'], values=energy_values, unit='meV'), + }, + ) + ) + analysis = edyn.Analysis( + display_name='SingleQ', + experiment=experiment, + sample_model=sm.SampleModel(components=sm.Gaussian(area=2.0, width=1.0)), + instrument_model=sm.InstrumentModel(), + ) + + # THEN + labels = [analysis._parameter_labels().label(p) for p in analysis._chain_parameters()] + + # EXPECT the short form, not 'Gaussian width (Q_index=0)' + assert 'Gaussian width' in labels + assert not any('Q_index=' in label for label in labels) + + def test_parameter_from_outside_the_analysis_keeps_its_name(self, multi_q_analysis): + # WHEN a parameter belongs to no Q index of this analysis + from easyscience.variable import Parameter + + stranger = Parameter(name='Gaussian width', value=1.0) + + # EXPECT it is returned unqualified rather than mislabelled + assert multi_q_analysis._parameter_labels().label(stranger) == 'Gaussian width' + + def test_a_parameter_shared_across_q_is_not_tied_to_one_index(self): + # WHEN a diffusion model contributes global parameters, the same objects appear at every Q + energy_values = np.linspace(-5.0, 5.0, 15) + rows = [2.0 * np.exp(-0.5 * (energy_values / 1.2) ** 2) for _ in Q_VALUES] + observed = np.vstack(rows) + experiment = edyn.Experiment( + data=sc.DataArray( + data=sc.array( + dims=['Q', 'energy'], + values=observed, + variances=np.full_like(observed, 0.01), + ), + coords={ + 'Q': sc.array(dims=['Q'], values=Q_VALUES, unit='1/Angstrom'), + 'energy': sc.array(dims=['energy'], values=energy_values, unit='meV'), + }, + ) + ) + analysis = edyn.Analysis( + display_name='Shared', + experiment=experiment, + sample_model=sm.SampleModel( + components=sm.ComponentCollection(components=[sm.DeltaFunction(area=0.2)]), + diffusion_models=sm.BrownianTranslationalDiffusion( + name='Brownian', diffusion_coefficient=2.4e-9, scale=0.5 + ), + ), + instrument_model=sm.InstrumentModel(), + ) + + # THEN + owners = analysis._parameter_owner_index() + shared = [p for p in analysis._chain_parameters() if p.unique_name not in owners] + + # EXPECT the shared parameters are left out of the owner map, since no single Q owns them, + # and so keep their plain names rather than being labelled with an arbitrary Q + assert shared, 'expected the diffusion model to contribute parameters shared across Q' + for parameter in shared: + assert analysis._parameter_labels().label(parameter) == parameter.name diff --git a/tests/unit/easydynamics/analysis/test_parameter_analysis.py b/tests/unit/easydynamics/analysis/test_parameter_analysis.py index 031f813cf..1b6b80178 100644 --- a/tests/unit/easydynamics/analysis/test_parameter_analysis.py +++ b/tests/unit/easydynamics/analysis/test_parameter_analysis.py @@ -8,7 +8,10 @@ import numpy as np import pytest import scipp as sc +from easyscience.fitting.multi_fitter import MultiFitter +import easydynamics as edyn +import easydynamics.sample_model as sm from easydynamics.analysis.analysis import Analysis from easydynamics.analysis.fit_binding import FitBinding from easydynamics.analysis.parameter_analysis import ParameterAnalysis @@ -19,6 +22,8 @@ ) from easydynamics.utils.fit_target import FitTarget +Q = np.array([0.5, 0.8, 1.1, 1.4, 1.7, 2.0]) + def make_target(dataset_key, function, label, x_unit=None, y_unit=None, name='value'): """Build a FitTarget for mocking FitBinding.get_targets in tests.""" @@ -32,6 +37,51 @@ def make_target(dataset_key, function, label, x_unit=None, y_unit=None, name='va ) +def make_dataset(): + widths = 0.10 + 0.35 * Q + areas = 2.0 - 0.3 * Q + return sc.Dataset({ + 'Lorentzian width': sc.DataArray( + data=sc.array( + dims=['Q'], values=widths, variances=np.full_like(widths, 1e-4), unit='meV' + ), + coords={'Q': sc.array(dims=['Q'], values=Q, unit='1/angstrom')}, + ), + 'Lorentzian area': sc.DataArray( + data=sc.array( + dims=['Q'], values=areas, variances=np.full_like(areas, 4e-4), unit='meV' + ), + coords={'Q': sc.array(dims=['Q'], values=Q, unit='1/angstrom')}, + ), + }) + + +def make_analysis(two_bindings=True): + bindings = [ + edyn.FitBinding( + model=sm.Polynomial( + coefficients=[0.1, 0.35], x_unit='1/angstrom', y_unit='meV', name='Width line' + ), + targets='Lorentzian width', + ) + ] + if two_bindings: + bindings.append( + edyn.FitBinding( + model=sm.Polynomial( + coefficients=[2.0, -0.3], x_unit='1/angstrom', y_unit='meV', name='Area line' + ), + targets='Lorentzian area', + ) + ) + return edyn.ParameterAnalysis(parameters=make_dataset(), bindings=bindings) + + +@pytest.fixture +def analysis(): + return make_analysis() + + class TestParameterAnalysis: @pytest.fixture def dataset(self): @@ -1150,6 +1200,233 @@ def test_repr(self, parameter_analysis): assert 'parameter_names=' in repr_str assert 'bindings=' in repr_str + ############# + # The cached fitter + ############# + + def test_fitter_is_a_cached_multifitter(self, analysis): + # EXPECT + assert isinstance(analysis.fitter, MultiFitter) + assert analysis.fitter is analysis.fitter + + def test_fit_still_returns_per_target_results(self, analysis): + # THEN + results = analysis.fit() + + # EXPECT one result per fit target, as before + assert isinstance(results, list) + assert len(results) == 2 + + def test_changing_bindings_rebuilds_the_fitter(self, analysis): + # WHEN + original = analysis.fitter + + # THEN + analysis.bindings = analysis.bindings[:1] + + # EXPECT + assert analysis.fitter is not original + + def test_changing_parameters_rebuilds_the_fitter(self, analysis): + # WHEN + original = analysis.fitter + + # THEN + analysis.parameters = make_dataset() + + # EXPECT + assert analysis.fitter is not original + + def test_changing_the_number_of_targets_rebuilds_the_fitter(self): + # WHEN a binding is edited in place so that it resolves to two targets instead of one. + # ParameterAnalysis cannot observe this, and the cached fitter would otherwise still hold + # one fit function against two datasets, which dies inside the minimizer. + binding = edyn.FitBinding( + model=sm.BrownianTranslationalDiffusion( + name='Brownian', + lorentzian_name='Lorentzian', + diffusion_coefficient=2.4e-9, + scale=0.5, + ), + targets={'width': 'Lorentzian width'}, + ) + analysis = edyn.ParameterAnalysis(parameters=make_dataset(), bindings=[binding]) + assert len(analysis.fit()) == 1 + + # THEN + binding.targets = {'width': 'Lorentzian width', 'area': 'Lorentzian area'} + + # EXPECT the fit follows the binding rather than failing on a stale fitter + assert len(analysis.fit()) == 2 + + def test_shrinking_the_targets_also_rebuilds(self): + # WHEN + binding = edyn.FitBinding( + model=sm.BrownianTranslationalDiffusion( + name='Brownian', + lorentzian_name='Lorentzian', + diffusion_coefficient=2.4e-9, + scale=0.5, + ), + targets={'width': 'Lorentzian width', 'area': 'Lorentzian area'}, + ) + analysis = edyn.ParameterAnalysis(parameters=make_dataset(), bindings=[binding]) + assert len(analysis.fit()) == 2 + + # THEN + binding.targets = {'width': 'Lorentzian width'} + + # EXPECT + assert len(analysis.fit()) == 1 + + ############# + # Chain parameters and labels + ############# + + def test_covers_every_binding_model(self, analysis): + # THEN + parameters = analysis._chain_parameters() + + # EXPECT both Polynomials contribute their two coefficients + assert len(parameters) == 4 + assert len({p.unique_name for p in parameters}) == 4 + + def test_labels_are_unique(self, analysis): + # THEN + labels = [analysis._parameter_labels().label(p) for p in analysis._chain_parameters()] + + # EXPECT + assert len(set(labels)) == len(labels) + + def test_model_name_is_not_repeated_in_the_label(self, analysis): + # WHEN a model already names its parameters after itself + + # THEN + labels = [analysis._parameter_labels().label(p) for p in analysis._chain_parameters()] + + # EXPECT no 'Width line: Width line_c0' + assert 'Width line_c0' in labels + assert not any(label.count('Width line') > 1 for label in labels) + + def test_colliding_names_are_qualified_by_model(self): + # WHEN two bindings use models whose parameters share a name + shared_name_model_a = sm.Polynomial( + coefficients=[0.1, 0.35], x_unit='1/angstrom', y_unit='meV', name='Line' + ) + shared_name_model_b = sm.Polynomial( + coefficients=[2.0, -0.3], x_unit='1/angstrom', y_unit='meV', name='Line' + ) + analysis = edyn.ParameterAnalysis( + parameters=make_dataset(), + bindings=[ + edyn.FitBinding(model=shared_name_model_a, targets='Lorentzian width'), + edyn.FitBinding(model=shared_name_model_b, targets='Lorentzian area'), + ], + ) + + # THEN + parameters = analysis._chain_parameters() + names = [p.name for p in parameters] + labels = [analysis._parameter_labels().label(p) for p in parameters] + + # EXPECT the bare names collide, and the labels resolve it + assert len(set(names)) < len(names) + assert len(set(labels)) == len(labels) + + def test_single_binding_keeps_plain_names(self): + # WHEN + analysis = make_analysis(two_bindings=False) + + # THEN + labels = [analysis._parameter_labels().label(p) for p in analysis._chain_parameters()] + + # EXPECT no model prefix, since there is nothing to disambiguate + assert labels == ['Width line_c0', 'Width line_c1'] + + def test_parameter_from_outside_the_analysis_keeps_its_name(self, analysis): + # WHEN a parameter belongs to none of the binding models + from easyscience.variable import Parameter + + stranger = Parameter(name='Width line_c0', value=1.0) + + # THEN EXPECT it is returned unqualified rather than mislabelled + assert analysis._parameter_labels().label(stranger) == 'Width line_c0' + + def test_models_without_a_display_name_fall_back_to_the_unique_name(self): + # WHEN two colliding models have no display name to tell them apart + model_a = sm.Polynomial(coefficients=[0.1, 0.35], x_unit='1/angstrom', y_unit='meV') + model_b = sm.Polynomial(coefficients=[2.0, -0.3], x_unit='1/angstrom', y_unit='meV') + analysis = edyn.ParameterAnalysis( + parameters=make_dataset(), + bindings=[ + edyn.FitBinding(model=model_a, targets='Lorentzian width'), + edyn.FitBinding(model=model_b, targets='Lorentzian area'), + ], + ) + + # THEN + labels = [analysis._parameter_labels().label(p) for p in analysis._chain_parameters()] + + # EXPECT still unambiguous, which is what matters + assert len(set(labels)) == len(labels) + + def test_colliding_names_with_distinct_models_use_the_display_name(self): + # WHEN two diffusion models are bound to different targets. Their parameters are not named + # after the model, so the names collide while the model names do not. + analysis = edyn.ParameterAnalysis( + parameters=make_dataset(), + bindings=[ + edyn.FitBinding( + model=sm.BrownianTranslationalDiffusion( + name='Diffusion A', diffusion_coefficient=2.4e-9, scale=0.5 + ), + targets={'width': 'Lorentzian width'}, + ), + edyn.FitBinding( + model=sm.BrownianTranslationalDiffusion( + name='Diffusion B', diffusion_coefficient=2.4e-9, scale=0.5 + ), + targets={'area': 'Lorentzian area'}, + ), + ], + ) + + # THEN + parameters = analysis._chain_parameters() + labels = [analysis._parameter_labels().label(p) for p in parameters] + + # EXPECT the model's name resolves the collision + assert len({p.name for p in parameters}) < len(parameters) + assert len(set(labels)) == len(labels) + assert any(label.endswith('(Diffusion A)') for label in labels) + assert any(label.endswith('(Diffusion B)') for label in labels) + + def test_ambiguous_name_owned_by_no_model_keeps_its_name(self): + # WHEN a parameter shares an ambiguous name but belongs to none of the models + from easyscience.variable import Parameter + + analysis = edyn.ParameterAnalysis( + parameters=make_dataset(), + bindings=[ + edyn.FitBinding( + model=sm.Polynomial( + coefficients=[0.1, 0.35], x_unit='1/angstrom', y_unit='meV', name='Line' + ), + targets='Lorentzian width', + ), + edyn.FitBinding( + model=sm.Polynomial( + coefficients=[2.0, -0.3], x_unit='1/angstrom', y_unit='meV', name='Line' + ), + targets='Lorentzian area', + ), + ], + ) + stranger = Parameter(name='Line_c0', value=1.0) + + # THEN EXPECT it falls back to the plain name rather than claiming an owner + assert analysis._parameter_labels().label(stranger) == 'Line_c0' + class TestParameterAnalysisWorkflows: """End-to-end fits for the standard workflows on synthetic data.""" diff --git a/tests/unit/easydynamics/analysis/test_posterior.py b/tests/unit/easydynamics/analysis/test_posterior.py index 28817e340..436283a4c 100644 --- a/tests/unit/easydynamics/analysis/test_posterior.py +++ b/tests/unit/easydynamics/analysis/test_posterior.py @@ -322,8 +322,9 @@ def test_reports_parameter_names_units_and_percentiles(self): assert entry.plus == pytest.approx(34.0) assert entry.value == pytest.approx(1.5) - def test_labels_are_reported_verbatim(self): - # WHEN a caller supplies a qualified label, as a multi-Q analysis does + def test_labels_qualified_by_q_are_kept_verbatim(self): + # WHEN a multi-Q analysis supplies Q-qualified labels, since every Q holds a copy of the + # same parameter and the bare name would repeat parameter = make_parameter(name='Gaussian width') # THEN @@ -364,3 +365,56 @@ def test_repr_contains_the_parameter_name(self): # EXPECT assert 'Gaussian area' in text assert 'median' in text + + +class TestPosteriorSummaryContainer: + def test_len_and_iteration(self): + # WHEN + parameters = [make_parameter(name='a'), make_parameter(name='b')] + summary = summarize_draws(np.zeros((7, 2)), ['a', 'b'], parameters) + + # THEN EXPECT + assert len(summary) == 2 + assert [entry.name for entry in summary] == ['a', 'b'] + assert len(summary.entries) == 2 + + def test_repr_with_no_entries(self): + # WHEN THEN EXPECT + assert 'no parameters' in repr(summarize_draws(np.zeros((3, 0)), [], [])) + + +class TestAbsurdBoundsWarning: + def test_applying_a_wildly_wide_bound_warns(self): + # WHEN a fit returns an enormous uncertainty, which is what a degenerate parameter looks + # like coming out of least squares + parameter = make_parameter(name='Delta area', value=1.0, error=1e9) + suggestions = suggest_bounds_for_parameters([parameter]) + + # THEN EXPECT it is still applied, since it is what the fit implied, but not silently + with pytest.warns(UserWarning, match='far wider than the parameter'): + changed = suggestions.apply() + assert changed == [parameter] + + def test_a_sane_bound_applies_without_warning(self): + # WHEN + parameter = make_parameter(name='sane', value=10.0, error=0.5) + suggestions = suggest_bounds_for_parameters([parameter]) + + # THEN EXPECT + import warnings as warnings_module + + with warnings_module.catch_warnings(): + warnings_module.simplefilter('error') + suggestions.apply() + + def test_a_zero_valued_parameter_is_not_called_absurd(self): + # WHEN there is no magnitude to compare the width against + parameter = make_parameter(name='zero', value=0.0, error=1.0) + suggestions = suggest_bounds_for_parameters([parameter]) + + # THEN EXPECT no warning, since the ratio is meaningless rather than alarming + import warnings as warnings_module + + with warnings_module.catch_warnings(): + warnings_module.simplefilter('error') + suggestions.apply() diff --git a/tests/unit/easydynamics/analysis/test_posterior_sampling.py b/tests/unit/easydynamics/analysis/test_posterior_sampling.py index 5fb5cd24e..ab3759f95 100644 --- a/tests/unit/easydynamics/analysis/test_posterior_sampling.py +++ b/tests/unit/easydynamics/analysis/test_posterior_sampling.py @@ -2,20 +2,29 @@ # SPDX-License-Identifier: BSD-3-Clause """ -Unit tests for the posterior sampler, driven through an Analysis1d, with the EasyScience Sampler -mocked out. +Unit tests for the posterior sampler, with the EasyScience Sampler mocked out. + +The sampler is driven through the analyses that hold one: an Analysis1d and a ParameterAnalysis +for PosteriorSampler, and an Analysis for the multi-Q subclass. """ +import types from types import SimpleNamespace from unittest.mock import MagicMock from unittest.mock import patch +import matplotlib as mpl import numpy as np import pytest import scipp as sc from easyscience.fitting import AvailableMinimizers +from easyscience.fitting.multi_fitter import MultiFitter from easyscience.variable import Parameter +mpl.use('Agg') + +import easydynamics as edyn +import easydynamics.sample_model as sm from easydynamics.analysis.analysis1d import Analysis1d from easydynamics.experiment import Experiment from easydynamics.sample_model import InstrumentModel @@ -75,11 +84,122 @@ def fake_results(analysis, n_draws=100, values=None): ) +def _bumps_style_index_error(): + """Build a callable that raises an IndexError from a frame that looks like it is in BUMPS.""" + + def raise_index_error(**_kwargs): + raise IndexError('index 71 is out of bounds for axis 0 with size 40') + + # The relabelling walks the traceback for a frame belonging to the bumps package, so the + # function has to appear to live there. + return types.FunctionType( + raise_index_error.__code__, + {'__name__': 'bumps.dream.state', '__builtins__': __builtins__}, + ) + + @pytest.fixture def analysis(): return make_analysis() +Q_VALUES = [0.5, 1.0, 1.5] + + +def make_multi_q_analysis(): + energy_values = np.linspace(-5.0, 5.0, 15) + rows = [2.0 * np.exp(-0.5 * (energy_values / (0.8 + 0.4 * q**2)) ** 2) for q in Q_VALUES] + observed = np.vstack(rows) + experiment = edyn.Experiment( + data=sc.DataArray( + data=sc.array( + dims=['Q', 'energy'], + values=observed, + variances=np.full_like(observed, 0.01), + ), + coords={ + 'Q': sc.array(dims=['Q'], values=Q_VALUES, unit='1/Angstrom'), + 'energy': sc.array(dims=['energy'], values=energy_values, unit='meV'), + }, + ) + ) + return edyn.Analysis( + display_name='TestMultiQ', + experiment=experiment, + sample_model=sm.SampleModel(components=sm.Gaussian(area=2.0, width=1.0)), + instrument_model=sm.InstrumentModel(), + ) + + +def bound_all_chain(multi_q_analysis, half_width=5.0): + for parameter in multi_q_analysis._chain_parameters(): + parameter.min = float(parameter.value) - half_width + parameter.max = float(parameter.value) + half_width + + +def fake_chain_results(parameters, n_draws=50): + draws = np.tile([float(p.value) for p in parameters], (n_draws, 1)) + return SimpleNamespace( + draws=draws, + param_names=[p.unique_name for p in parameters], + logp=np.zeros(n_draws), + state=MagicMock(Ngen=10, Npop=4), + ) + + +@pytest.fixture +def multi_q_analysis(): + return make_multi_q_analysis() + + +Q = np.array([0.5, 0.8, 1.1, 1.4, 1.7, 2.0]) + + +def make_dataset(): + widths = 0.10 + 0.35 * Q + areas = 2.0 - 0.3 * Q + return sc.Dataset({ + 'Lorentzian width': sc.DataArray( + data=sc.array( + dims=['Q'], values=widths, variances=np.full_like(widths, 1e-4), unit='meV' + ), + coords={'Q': sc.array(dims=['Q'], values=Q, unit='1/angstrom')}, + ), + 'Lorentzian area': sc.DataArray( + data=sc.array( + dims=['Q'], values=areas, variances=np.full_like(areas, 4e-4), unit='meV' + ), + coords={'Q': sc.array(dims=['Q'], values=Q, unit='1/angstrom')}, + ), + }) + + +def make_parameter_analysis(two_bindings=True): + bindings = [ + edyn.FitBinding( + model=sm.Polynomial( + coefficients=[0.1, 0.35], x_unit='1/angstrom', y_unit='meV', name='Width line' + ), + targets='Lorentzian width', + ) + ] + if two_bindings: + bindings.append( + edyn.FitBinding( + model=sm.Polynomial( + coefficients=[2.0, -0.3], x_unit='1/angstrom', y_unit='meV', name='Area line' + ), + targets='Lorentzian area', + ) + ) + return edyn.ParameterAnalysis(parameters=make_dataset(), bindings=bindings) + + +@pytest.fixture +def parameter_analysis(): + return make_parameter_analysis() + + class TestPosteriorSampler: ############# # Bounds pre-flight @@ -598,6 +718,106 @@ def test_plots_without_sampling_raise(self, analysis): with pytest.raises(RuntimeError): analysis.bayesian.plot_corner() + ############# + # Error paths + ############# + + def test_bumps_outlier_crash_is_reported_helpfully(self, analysis): + # WHEN BUMPS' own outlier removal indexes past the end of its buffer + bound_all(analysis) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = _bumps_style_index_error() + + # THEN EXPECT the bare IndexError is replaced by something actionable, naming both + # causes + with pytest.raises(RuntimeError, match='degenerate') as raised: + analysis.bayesian.sample(samples=10) + assert 'short chains' in str(raised.value) + assert isinstance(raised.value.__cause__, IndexError) + + def test_an_index_error_of_our_own_is_not_relabelled(self, analysis): + # WHEN the IndexError comes from anywhere but BUMPS, it is a bug here and must not be + # dressed up as a modelling problem + bound_all(analysis) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = IndexError('list index out of range') + + # THEN EXPECT it propagates untouched + with pytest.raises(IndexError, match='list index out of range'): + analysis.bayesian.sample(samples=10) + + def test_parameters_entry_of_the_wrong_type_raises(self, analysis): + # THEN EXPECT + with pytest.raises(TypeError, match='Parameter objects or labels'): + analysis.bayesian.sample(samples=10, parameters=[42]) + + def test_median_skips_columns_with_no_matching_parameter(self, analysis): + # WHEN a chain carries a column this analysis knows nothing about + bound_all(analysis) + + with patch(SAMPLER_PATH) as sampler_class: + results = fake_results(analysis) + results.param_names = [*results.param_names, 'Parameter_does_not_exist'] + results.draws = np.column_stack([results.draws, np.zeros(results.draws.shape[0])]) + sampler_class.return_value.sample.return_value = results + analysis.bayesian.sample(samples=10) + + # THEN + changed = analysis.bayesian.set_parameters_to_median() + + # EXPECT the unknown column is skipped rather than crashing + assert len(changed) == len(analysis.get_free_parameters()) + + def test_load_chain_uses_the_sidecar_when_present(self, analysis, tmp_path): + # WHEN a chain is saved and reloaded into a *different* analysis, whose unique names differ + bound_all(analysis) + with patch(SAMPLER_PATH) as sampler_class: + saved = fake_results(analysis) + sampler_class.return_value.sample.return_value = saved + analysis.bayesian.sample(samples=10) + analysis.bayesian.save(str(tmp_path / 'chain')) + + fresh = make_analysis() + bound_all(fresh) + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.load_state.return_value = saved + fresh.bayesian.load(str(tmp_path / 'chain')) + + # EXPECT the sidecar maps the old unique names onto the new analysis's parameters + summary = fresh.bayesian.summary() + assert {entry.name for entry in summary} == {p.name for p in fresh.get_free_parameters()} + assert all(np.isfinite(entry.value) for entry in summary) + + ############# + # Plot rendering + ############# + + def test_trace_and_corner_render_from_a_chain(self, analysis): + # WHEN + import matplotlib as mpl + import matplotlib.pyplot as plt + + mpl.use('Agg') + bound_all(analysis) + n_parameters = len(analysis.get_free_parameters()) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_results(analysis) + analysis.bayesian.sample(samples=10) + + # THEN EXPECT + assert len(analysis.bayesian.plot_trace().axes) == n_parameters + 1 + assert len(analysis.bayesian.plot_corner().axes) == n_parameters**2 + plt.close('all') + + ############# + # Predictive error bars + ############# + def test_predictive_forwards_the_measured_error_bars(self, analysis): # WHEN the data carries variances of 0.01, i.e. an uncertainty of 0.1 bound_all(analysis) @@ -887,6 +1107,889 @@ def test_predictions_take_draws_evenly_across_the_chain(self, analysis): expected = draws[[0, 24, 49, 74, 99], column] assert amplitudes / amplitudes[0] == pytest.approx(expected / expected[0]) + ############# + # Extend guards + ############# + + def test_extending_with_a_different_subset_is_refused(self, analysis): + # WHEN a chain is started over all parameters and then extended over one + bound_all(analysis) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10) + + target = analysis.get_free_parameters()[0] + + # THEN EXPECT refused up front, rather than failing obscurely inside BUMPS, which + # resumes from a stored chain whose width is fixed + with pytest.warns(UserWarning), pytest.raises(ValueError, match='Cannot extend'): + analysis.bayesian.extend(additional_samples=10, parameters=[target.name]) + + def test_extending_with_the_same_parameters_is_allowed(self, analysis): + # WHEN + bound_all(analysis) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + sampler_class.return_value.extend.side_effect = lambda **_k: fake_results(analysis) + analysis.bayesian.sample(samples=10) + + # THEN EXPECT: does not raise + analysis.bayesian.extend(additional_samples=10) + + ############# + # Sidecar labels + ############# + + def test_a_subset_run_records_the_same_labels_a_full_run_would(self, analysis): + # WHEN only one parameter is sampled. Inside the run the others are fixed, so nothing looks + # ambiguous; the recorded labels must still match what a full run would have written, or + # the chain cannot be matched up again on reload. + bound_all(analysis) + target = analysis.get_free_parameters()[0] + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_results(analysis) + with pytest.warns(UserWarning): + analysis.bayesian.sample(samples=10, parameters=[target.name]) + + # EXPECT + assert analysis.bayesian._saved_labels[ + target.unique_name + ] == analysis._parameter_labels().label(target) + + ############# + # Driven through a ParameterAnalysis + ############# + + def test_refuses_unbounded_parameters(self, parameter_analysis): + # THEN EXPECT + with pytest.raises(ValueError, match='finite bounds'): + parameter_analysis.bayesian.sample(samples=10) + + def test_binds_one_dataset_per_target(self, parameter_analysis): + # WHEN + bound_all_chain(parameter_analysis) + parameters = parameter_analysis._chain_parameters() + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_chain_results(parameters) + parameter_analysis.bayesian.sample(samples=10) + + # EXPECT + args, kwargs = sampler_class.call_args + assert len(args[1]) == 2 + assert len(kwargs['weights']) == 2 + + def test_summary_uses_model_qualified_labels(self, parameter_analysis): + # WHEN + bound_all_chain(parameter_analysis) + parameters = parameter_analysis._chain_parameters() + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_chain_results(parameters) + parameter_analysis.bayesian.sample(samples=10) + + # EXPECT + names = [entry.name for entry in parameter_analysis.bayesian.summary()] + assert len(set(names)) == len(names) + assert 'Width line_c0' in names + + def test_restores_parameter_values(self, parameter_analysis): + # WHEN + bound_all_chain(parameter_analysis) + parameters = parameter_analysis._chain_parameters() + before = [float(p.value) for p in parameters] + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + + def mutate(**_kwargs): + for parameter in parameters: + parameter.value = float(parameter.value) + 1.0 + return fake_chain_results(parameters) + + sampler_class.return_value.sample.side_effect = mutate + parameter_analysis.bayesian.sample(samples=10) + + # EXPECT + assert [float(p.value) for p in parameters] == pytest.approx(before) + + def test_missing_parameters_dataset_raises(self): + # WHEN + parameter_analysis = edyn.ParameterAnalysis() + + # THEN EXPECT + with pytest.raises(ValueError, match='No parameters Dataset'): + parameter_analysis.bayesian.sample(samples=10) + + def test_missing_bindings_raises(self): + # WHEN + parameter_analysis = edyn.ParameterAnalysis(parameters=make_dataset()) + + # THEN EXPECT + with pytest.raises(ValueError, match='No fit bindings'): + parameter_analysis.bayesian.sample(samples=10) + + +class TestMultiQPosteriorSampler: + ############# + # Bounds pre-flight + ############# + + def test_sampling_refuses_unbounded_parameters(self, multi_q_analysis): + # THEN EXPECT + with pytest.raises(ValueError, match='finite bounds'): + multi_q_analysis.bayesian.sample(fit_method='simultaneous', samples=10) + + def test_error_names_parameters_by_q_index(self, multi_q_analysis): + # THEN EXPECT + with pytest.raises(ValueError, match=r'Gaussian width \(Q_index=0\)'): + multi_q_analysis.bayesian.check_bounds() + + def test_suggest_bounds_labels_every_q(self, multi_q_analysis): + # THEN + suggestions = multi_q_analysis.bayesian.suggest_bounds() + + # EXPECT + labels = [s.label for s in suggestions] + assert len(set(labels)) == len(labels) + assert 'Gaussian area (Q_index=1)' in labels + + ############# + # Simultaneous sampling + ############# + + def test_binds_one_dataset_per_q_index(self, multi_q_analysis): + # WHEN + bound_all_chain(multi_q_analysis) + parameters = multi_q_analysis._chain_parameters() + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_chain_results(parameters) + multi_q_analysis.bayesian.sample(fit_method='simultaneous', samples=10) + + # EXPECT + args, kwargs = sampler_class.call_args + assert len(args[1]) == len(Q_VALUES) + assert len(args[2]) == len(Q_VALUES) + assert len(kwargs['weights']) == len(Q_VALUES) + + def test_returns_a_single_result(self, multi_q_analysis): + # WHEN + bound_all_chain(multi_q_analysis) + parameters = multi_q_analysis._chain_parameters() + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + expected = fake_chain_results(parameters) + sampler_class.return_value.sample.return_value = expected + returned = multi_q_analysis.bayesian.sample(fit_method='simultaneous', samples=10) + + # EXPECT + assert returned is expected + assert multi_q_analysis.bayesian.results is expected + + def test_summary_is_labelled_by_q_index(self, multi_q_analysis): + # WHEN + bound_all_chain(multi_q_analysis) + parameters = multi_q_analysis._chain_parameters() + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_chain_results(parameters) + multi_q_analysis.bayesian.sample(fit_method='simultaneous', samples=10) + + # EXPECT + names = [entry.name for entry in multi_q_analysis.bayesian.summary()] + assert len(set(names)) == len(names) + assert all('Q_index=' in name for name in names) + + def test_refreshes_every_convolver_before_sampling(self, multi_q_analysis): + # WHEN + bound_all_chain(multi_q_analysis) + parameters = multi_q_analysis._chain_parameters() + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_chain_results(parameters) + for analysis1d in multi_q_analysis.analysis_list: + analysis1d._convolver_is_dirty = True + multi_q_analysis.bayesian.sample(fit_method='simultaneous', samples=10) + + # EXPECT the sampler sees the same prepared convolvers a simultaneous fit would + assert all(not a._convolver_is_dirty for a in multi_q_analysis.analysis_list) + + def test_uses_a_multifitter(self, multi_q_analysis): + # WHEN + + # EXPECT + assert isinstance(multi_q_analysis.fitter, MultiFitter) + assert len(multi_q_analysis.fitter.fit_object) == len(Q_VALUES) + + ############# + # Independent sampling + ############# + + def test_returns_one_result_per_q_index(self, multi_q_analysis): + # WHEN + for analysis1d in multi_q_analysis.analysis_list: + for parameter in analysis1d.get_free_parameters(): + parameter.min = float(parameter.value) - 5.0 + parameter.max = float(parameter.value) + 5.0 + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_chain_results( + multi_q_analysis.analysis_list[0].get_free_parameters() + ) + results = multi_q_analysis.bayesian.sample(fit_method='independent', samples=10) + + # EXPECT + assert isinstance(results, list) + assert len(results) == len(Q_VALUES) + + def test_single_q_index_returns_one_result(self, multi_q_analysis): + # WHEN + target = multi_q_analysis.analysis_list[1] + for parameter in target.get_free_parameters(): + parameter.min = float(parameter.value) - 5.0 + parameter.max = float(parameter.value) + 5.0 + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_chain_results( + target.get_free_parameters() + ) + result = multi_q_analysis.bayesian.sample( + fit_method='independent', Q_index=1, samples=10 + ) + + # EXPECT + assert not isinstance(result, list) + assert result is target.bayesian.results + + def test_invalid_q_index_raises(self, multi_q_analysis): + # THEN EXPECT + with pytest.raises((ValueError, IndexError)): + multi_q_analysis.bayesian.sample(fit_method='independent', Q_index=99, samples=10) + + ############# + # Validation + ############# + + def test_invalid_fit_method_raises(self, multi_q_analysis): + # THEN EXPECT + with pytest.raises(ValueError, match='Invalid fit method'): + multi_q_analysis.bayesian.sample(fit_method='nonsense') + + def test_negative_q_index_raises(self, multi_q_analysis): + # THEN EXPECT a refusal, rather than silently wrapping around to the last Q + with pytest.raises(IndexError, match='non-negative'): + multi_q_analysis.bayesian.sample(fit_method='independent', Q_index=-1, samples=10) + + def test_corner_q_index_is_validated(self, multi_q_analysis): + # THEN EXPECT both ends of the range are checked before any chain is looked up + with pytest.raises(IndexError, match='non-negative'): + multi_q_analysis.bayesian.plot_corner(Q_index=-1) + with pytest.raises(IndexError, match='out of bounds'): + multi_q_analysis.bayesian.plot_corner(Q_index=99) + + def test_missing_q_values_raises(self): + # WHEN + multi_q_analysis = edyn.Analysis(display_name='Empty') + + # THEN EXPECT + with pytest.raises(ValueError, match='No Q values available'): + multi_q_analysis.bayesian.sample() + + ############# + # Predictive plot + ############# + + def test_predictive_is_not_supported_for_multiple_datasets(self, multi_q_analysis): + # WHEN + bound_all_chain(multi_q_analysis) + parameters = multi_q_analysis._chain_parameters() + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_chain_results(parameters) + multi_q_analysis.bayesian.sample(fit_method='simultaneous', samples=10) + + # THEN EXPECT + with pytest.raises(NotImplementedError, match='single dataset only'): + multi_q_analysis.bayesian.plot_posterior_predictive() + + def test_predictive_q_index_plots_that_q_alone(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # THEN + figure = multi_q_analysis.bayesian.plot_posterior_predictive(Q_index=1, n_draws=3) + + # EXPECT a single matplotlib figure from that Q's own chain + assert len(figure.axes) == 1 + labels = [text.get_text() for text in figure.axes[0].get_legend().get_texts()] + assert 'Data' in labels + assert any('credible band' in label for label in labels) + + def test_predictive_offers_a_plopp_slider_in_a_notebook(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # THEN the per-Q predictive data is assembled and handed to the plopp-backed slider + with ( + patch('easydynamics.analysis.posterior_sampling._in_notebook', return_value=True), + patch('easydynamics.utils.posterior_plotting.predictive_with_slider') as slicer, + ): + multi_q_analysis.bayesian.plot_posterior_predictive(n_draws=3) + + # EXPECT one row per sampled Q on the common energy grid, each Q's own data in its row, + # a band that encloses its median, and the labelling of plot_data_and_model + kwargs = slicer.call_args.kwargs + n_energy = len(multi_q_analysis.energy.values) + assert kwargs['y'].shape == (len(Q_VALUES), n_energy) + assert list(kwargs['q_values']) == pytest.approx(Q_VALUES) + for row, analysis1d in enumerate(multi_q_analysis.analysis_list): + _, y, _ = analysis1d._sampling_data() + assert kwargs['y'][row] == pytest.approx(np.asarray(y)) + assert np.all(kwargs['lower'] <= kwargs['median']) + assert np.all(kwargs['median'] <= kwargs['upper']) + assert kwargs['y_variances'].shape == (len(Q_VALUES), n_energy) + assert kwargs['energy_unit'] == 'meV' + assert kwargs['q_unit'] == '1/Å' + assert kwargs['ylabel'].startswith('Intensity') + assert kwargs['title'] == multi_q_analysis.display_name + + def test_predictive_pads_a_masked_point_with_nan(self, multi_q_analysis): + # WHEN one Q's data has a NaN point, so its masked grid is shorter than the common grid + multi_q_analysis.experiment.binned_data.values[1, 4] = np.nan + self._sample_independently(multi_q_analysis) + + # THEN + with ( + patch('easydynamics.analysis.posterior_sampling._in_notebook', return_value=True), + patch('easydynamics.utils.posterior_plotting.predictive_with_slider') as slicer, + ): + multi_q_analysis.bayesian.plot_posterior_predictive(n_draws=3) + + # EXPECT the gap stays NaN in every per-Q array, and only there + kwargs = slicer.call_args.kwargs + for key in ('y', 'lower', 'median', 'upper'): + assert np.isnan(kwargs[key][1, 4]) + assert np.isfinite(np.delete(kwargs[key], 4, axis=1)).all() + + def test_predictive_without_a_notebook_or_q_index_says_what_to_do(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # THEN EXPECT it names the sampled Q indices rather than just refusing + with ( + patch('easydynamics.analysis.posterior_sampling._in_notebook', return_value=False), + pytest.raises(RuntimeError, match=r'sampled Q indices are \[0, 1, 2\]'), + ): + multi_q_analysis.bayesian.plot_posterior_predictive() + + def test_predictive_rejects_a_bad_draw_count(self, multi_q_analysis): + # THEN EXPECT the count is checked before any chain is looked up + with pytest.raises(ValueError, match='positive integer'): + multi_q_analysis.bayesian.plot_posterior_predictive(n_draws=0) + + ############# + # Discoverability + ############# + + def test_operations_needing_one_chain_point_at_the_per_q_chains(self, multi_q_analysis): + # WHEN sampling independently, the chains live on the Analysis1d objects, not here + remaining = iter(multi_q_analysis.analysis_list) + for analysis1d in multi_q_analysis.analysis_list: + for parameter in analysis1d.get_free_parameters(): + parameter.min = float(parameter.value) - 5.0 + parameter.max = float(parameter.value) + 5.0 + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_chain_results( + next(remaining).get_free_parameters() + ) + multi_q_analysis.bayesian.sample(fit_method='independent', samples=10) + + # THEN EXPECT anything that genuinely needs a single chain says where the chains + # actually are, rather than claiming none exist + with pytest.raises(RuntimeError, match='analysis_list'): + multi_q_analysis.bayesian.predictions() + + def test_untouched_analysis_still_reports_no_samples(self, multi_q_analysis): + # THEN EXPECT the plain message when nothing has been sampled anywhere + with pytest.raises(RuntimeError, match='No posterior samples yet'): + multi_q_analysis.bayesian.summary() + + ############# + # Aggregating the per-Q chains + ############# + + def _sample_independently(self, multi_q_analysis): + for analysis1d in multi_q_analysis.analysis_list: + for parameter in analysis1d.get_free_parameters(): + parameter.min = float(parameter.value) - 5.0 + parameter.max = float(parameter.value) + 5.0 + + # The Q indices sample in order, and each must get a chain over its own parameters. + remaining = iter(multi_q_analysis.analysis_list) + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_chain_results( + next(remaining).get_free_parameters() + ) + multi_q_analysis.bayesian.sample(fit_method='independent', samples=10) + + def test_posterior_results_holds_one_chain_per_q(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # EXPECT + assert len(multi_q_analysis.bayesian.results_per_q) == len(Q_VALUES) + assert all(result is not None for result in multi_q_analysis.bayesian.results_per_q) + + def test_posterior_results_is_none_before_sampling(self, multi_q_analysis): + # EXPECT + assert multi_q_analysis.bayesian.results_per_q is None + + def test_summary_gathers_every_q(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # THEN + summary = multi_q_analysis.bayesian.summary() + + # EXPECT one entry per free parameter per Q, each labelled by its Q index + expected = sum(len(a.get_free_parameters()) for a in multi_q_analysis.analysis_list) + names = [entry.name for entry in summary] + assert len(summary) == expected + assert len(set(names)) == len(names) + assert all('Q_index=' in name for name in names) + + def test_median_applies_each_chain_to_its_own_q(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # THEN + changed = multi_q_analysis.bayesian.set_parameters_to_median() + + # EXPECT every Q's parameters are set, from that Q's own chain + expected = sum(len(a.get_free_parameters()) for a in multi_q_analysis.analysis_list) + assert len(changed) == expected + + def test_corner_plots_one_q_at_a_time(self, multi_q_analysis): + # WHEN each Q was sampled separately, no draw pairs one Q with another, so a corner plot + # can only show one chain at a time + self._sample_independently(multi_q_analysis) + + # THEN + figure = multi_q_analysis.bayesian.plot_corner(Q_index=1) + + # EXPECT that Q's own chain, not a combination across Q + n_parameters = len(multi_q_analysis.analysis_list[1].get_free_parameters()) + assert len(figure.axes) == n_parameters**2 + + def test_corner_offers_a_slider_in_a_notebook(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # THEN + with patch('easydynamics.analysis.posterior_sampling._in_notebook', return_value=True): + widget = multi_q_analysis.bayesian.plot_corner() + + # EXPECT a slider over the sampled Q indices, and an image that actually holds a + # pre-rendered figure: every chain is rendered to PNG bytes once, up front, so an empty + # image is the regression worth guarding. The figure comes first and the slider sits + # under it, where plopp puts its controls. + image, slider = widget.children + assert list(slider.options) == list(range(len(Q_VALUES))) + assert bytes(image.value).startswith(b'\x89PNG'), 'the initial chain was not rendered' + + slider.value = 2 + assert bytes(image.value).startswith(b'\x89PNG'), 'changing Q did not swap in a rendering' + + def test_the_corner_slider_swaps_bytes_without_redrawing(self, multi_q_analysis): + # WHEN every chain's figure was rendered once, at construction + self._sample_independently(multi_q_analysis) + with patch('easydynamics.analysis.posterior_sampling._in_notebook', return_value=True): + widget = multi_q_analysis.bayesian.plot_corner() + image, slider = widget.children + first_bytes = image.value + + # THEN the slider moves with matplotlib rendering forbidden + with patch('easydynamics.utils.posterior_plotting.plot_corner') as render: + slider.value = 1 + changed_bytes = image.value + slider.value = 0 + + # EXPECT the callback only swapped stored bytes: nothing was drawn on a move, the image + # followed the slider, and coming back restored the identical rendering + render.assert_not_called() + assert changed_bytes != first_bytes + assert image.value == first_bytes + + def test_corner_without_a_notebook_or_q_index_says_what_to_do(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # THEN EXPECT it names the sampled Q indices rather than just refusing + with ( + patch('easydynamics.analysis.posterior_sampling._in_notebook', return_value=False), + pytest.raises(RuntimeError, match=r'sampled Q indices are \[0, 1, 2\]'), + ): + multi_q_analysis.bayesian.plot_corner() + + def test_the_slider_only_offers_q_indices_that_were_sampled(self, multi_q_analysis): + # WHEN only one Q index is sampled + target = multi_q_analysis.analysis_list[2] + for parameter in target.get_free_parameters(): + parameter.min = float(parameter.value) - 5.0 + parameter.max = float(parameter.value) + 5.0 + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_chain_results( + target.get_free_parameters() + ) + multi_q_analysis.bayesian.sample(fit_method='independent', Q_index=2, samples=10) + + # THEN + with patch('easydynamics.analysis.posterior_sampling._in_notebook', return_value=True): + widget = multi_q_analysis.bayesian.plot_corner() + + # EXPECT the slider cannot land on a Q with nothing to draw + assert list(widget.children[1].options) == [2] + + ############# + # Per-Q sliders for trace, marginal and correlations + ############# + + def test_trace_q_index_plots_that_qs_chain(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # THEN + figure = multi_q_analysis.bayesian.plot_trace(Q_index=1) + + # EXPECT that Q's own trace: one panel per parameter plus the log-posterior + n_parameters = len(multi_q_analysis.analysis_list[1].get_free_parameters()) + assert len(figure.axes) == n_parameters + 1 + + def test_trace_offers_a_slider_in_a_notebook(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # THEN + with patch('easydynamics.analysis.posterior_sampling._in_notebook', return_value=True): + widget = multi_q_analysis.bayesian.plot_trace() + + # EXPECT the pre-rendered image-and-slider box, offering every sampled Q index + image, slider = widget.children + assert list(slider.options) == list(range(len(Q_VALUES))) + assert bytes(image.value).startswith(b'\x89PNG') + + def test_trace_without_a_notebook_or_q_index_says_what_to_do(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # THEN EXPECT it names the sampled Q indices rather than just refusing + with ( + patch('easydynamics.analysis.posterior_sampling._in_notebook', return_value=False), + pytest.raises(RuntimeError, match=r'sampled Q indices are \[0, 1, 2\]'), + ): + multi_q_analysis.bayesian.plot_trace() + + def test_marginal_q_index_plots_that_qs_chain(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # THEN + figure = multi_q_analysis.bayesian.plot_marginal('Gaussian width', Q_index=2) + + # EXPECT a single-axis marginal under the parameter's plain per-Q label + assert len(figure.axes) == 1 + assert figure.axes[0].get_xlabel() == 'Gaussian width (meV)' + + def test_marginal_offers_a_slider_in_a_notebook(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # THEN + with patch('easydynamics.analysis.posterior_sampling._in_notebook', return_value=True): + widget = multi_q_analysis.bayesian.plot_marginal('Gaussian width') + + # EXPECT + image, slider = widget.children + assert list(slider.options) == list(range(len(Q_VALUES))) + assert bytes(image.value).startswith(b'\x89PNG') + + def test_marginal_slider_resolves_a_parameter_object_across_q(self, multi_q_analysis): + # WHEN the Parameter object belongs to one Q's model only + self._sample_independently(multi_q_analysis) + parameters = multi_q_analysis.analysis_list[1].get_free_parameters() + target = next(p for p in parameters if p.name == 'Gaussian width') + + # THEN + with patch('easydynamics.analysis.posterior_sampling._in_notebook', return_value=True): + widget = multi_q_analysis.bayesian.plot_marginal(target) + + # EXPECT the slider still covers every Q, through the shared display name + image, slider = widget.children + assert list(slider.options) == list(range(len(Q_VALUES))) + assert bytes(image.value).startswith(b'\x89PNG') + + def test_marginal_without_a_notebook_or_q_index_says_what_to_do(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # THEN EXPECT + with ( + patch('easydynamics.analysis.posterior_sampling._in_notebook', return_value=False), + pytest.raises(RuntimeError, match=r'sampled Q indices are \[0, 1, 2\]'), + ): + multi_q_analysis.bayesian.plot_marginal('Gaussian width') + + def test_correlations_q_index_plots_that_qs_chain(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # THEN + figure = multi_q_analysis.bayesian.plot_correlations(Q_index=0) + + # EXPECT that Q's own matrix and its colorbar, under the plain per-Q labels + assert len(figure.axes) == 2 + labels = [text.get_text() for text in figure.axes[0].get_xticklabels()] + assert 'Gaussian width' in labels + assert all('Q_index=' not in label for label in labels) + + def test_correlations_offers_a_slider_in_a_notebook(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # THEN + with patch('easydynamics.analysis.posterior_sampling._in_notebook', return_value=True): + widget = multi_q_analysis.bayesian.plot_correlations() + + # EXPECT + image, slider = widget.children + assert list(slider.options) == list(range(len(Q_VALUES))) + assert bytes(image.value).startswith(b'\x89PNG') + + def test_correlations_without_a_notebook_or_q_index_says_what_to_do(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # THEN EXPECT + with ( + patch('easydynamics.analysis.posterior_sampling._in_notebook', return_value=False), + pytest.raises(RuntimeError, match=r'sampled Q indices are \[0, 1, 2\]'), + ): + multi_q_analysis.bayesian.plot_correlations() + + def test_chain_figure_q_indices_are_validated(self, multi_q_analysis): + # THEN EXPECT both ends of the range are checked before any chain is looked up + for plot in ( + multi_q_analysis.bayesian.plot_trace, + multi_q_analysis.bayesian.plot_correlations, + ): + with pytest.raises(IndexError, match='non-negative'): + plot(Q_index=-1) + with pytest.raises(IndexError, match='out of bounds'): + plot(Q_index=99) + with pytest.raises(IndexError, match='non-negative'): + multi_q_analysis.bayesian.plot_marginal('Gaussian width', Q_index=-1) + with pytest.raises(IndexError, match='out of bounds'): + multi_q_analysis.bayesian.plot_posterior_predictive(Q_index=99) + + def test_a_simultaneous_chain_serves_marginal_and_correlations(self, multi_q_analysis): + # WHEN + bound_all_chain(multi_q_analysis) + parameters = multi_q_analysis._chain_parameters() + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_chain_results(parameters) + multi_q_analysis.bayesian.sample(fit_method='simultaneous', samples=10) + + # THEN + marginal = multi_q_analysis.bayesian.plot_marginal('Gaussian width (Q_index=0)') + correlations = multi_q_analysis.bayesian.plot_correlations() + + # EXPECT single figures over the joint chain, under its Q-qualified labels + assert len(marginal.axes) == 1 + assert marginal.axes[0].get_xlabel().startswith('Gaussian width (Q_index=0)') + labels = [text.get_text() for text in correlations.axes[0].get_xticklabels()] + assert len(labels) == len(parameters) + assert all('Q_index=' in label for label in labels) + + def test_a_simultaneous_chain_still_takes_precedence(self, multi_q_analysis): + # WHEN a simultaneous run follows an independent one + self._sample_independently(multi_q_analysis) + bound_all_chain(multi_q_analysis) + parameters = multi_q_analysis._chain_parameters() + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_chain_results(parameters) + multi_q_analysis.bayesian.sample(fit_method='simultaneous', samples=10) + + # EXPECT the single chain is summarized, not the stale per-Q ones + assert len(multi_q_analysis.bayesian.summary()) == len(parameters) + multi_q_analysis.bayesian.plot_corner() + + def test_a_fresh_per_q_chain_wins_after_a_simultaneous_run(self, multi_q_analysis): + # WHEN an independent run of one Q follows a simultaneous one + bound_all_chain(multi_q_analysis) + parameters = multi_q_analysis._chain_parameters() + target = multi_q_analysis.analysis_list[2] + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_chain_results(parameters) + multi_q_analysis.bayesian.sample(fit_method='simultaneous', samples=10) + + # THEN + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_chain_results( + target.get_free_parameters() + ) + multi_q_analysis.bayesian.sample(fit_method='independent', Q_index=2, samples=10) + + # EXPECT the fresh per-Q chain is what summary() reports, not the stale simultaneous one + summary = multi_q_analysis.bayesian.summary() + assert len(summary) == len(target.get_free_parameters()) + assert all('Q_index=2' in entry.name for entry in summary) + + def test_gathered_summary_uses_the_per_q_saved_labels(self, multi_q_analysis): + # WHEN the per-Q chains look freshly loaded from disk in a new session: foreign column + # names, matched to parameters only through each per-Q sampler's saved labels + self._sample_independently(multi_q_analysis) + for q_index, analysis1d in enumerate(multi_q_analysis.analysis_list): + sampler = analysis1d.bayesian + name_map = analysis1d._parameter_labels().name_map() + foreign = [f'Loaded_{q_index}_{i}' for i in range(len(sampler.results.param_names))] + sampler._saved_labels = { + foreign_name: name_map[unique_name] + for foreign_name, unique_name in zip( + foreign, sampler.results.param_names, strict=True + ) + } + sampler.results.param_names = foreign + + # THEN + summary = multi_q_analysis.bayesian.summary() + + # EXPECT every column resolves to its parameter: Q-qualified names, real units and finite + # values, rather than raw column names with no unit and NaN + expected = sum(len(a.get_free_parameters()) for a in multi_q_analysis.analysis_list) + assert len(summary) == expected + assert all('Q_index=' in entry.name for entry in summary) + assert all(entry.unit != '' for entry in summary) + assert all(np.isfinite(entry.value) for entry in summary) + + def test_the_slider_path_forwards_plot_kwargs(self, multi_q_analysis): + # WHEN + self._sample_independently(multi_q_analysis) + + # THEN + with ( + patch('easydynamics.analysis.posterior_sampling._in_notebook', return_value=True), + patch('easydynamics.utils.posterior_plotting.corner_with_slider') as slider, + ): + multi_q_analysis.bayesian.plot_corner(bins=13) + + # EXPECT the kwargs the docstring promises to forward reach the slider's corner plots + assert slider.call_args.kwargs['bins'] == 13 + + ############# + # Extending and persistence + ############# + + def test_extend_after_an_independent_run_points_at_the_per_q_chains(self, multi_q_analysis): + # WHEN an independent run follows a simultaneous one, so this sampler still holds the old + # simultaneous chain while the latest chains live per Q + bound_all_chain(multi_q_analysis) + parameters = multi_q_analysis._chain_parameters() + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_chain_results(parameters) + multi_q_analysis.bayesian.sample(fit_method='simultaneous', samples=10) + self._sample_independently(multi_q_analysis) + + # THEN EXPECT the error says where the chains are, rather than extending the stale chain + # or misdiagnosing a failed run + with pytest.raises(RuntimeError, match=r'analysis_list\[Q_index\]\.bayesian\.extend'): + multi_q_analysis.bayesian.extend() + + def test_save_after_an_independent_run_refuses_the_stale_chain( + self, multi_q_analysis, tmp_path + ): + # WHEN an independent run follows a simultaneous one + bound_all_chain(multi_q_analysis) + parameters = multi_q_analysis._chain_parameters() + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_chain_results(parameters) + multi_q_analysis.bayesian.sample(fit_method='simultaneous', samples=10) + stale_sampler = sampler_class.return_value + self._sample_independently(multi_q_analysis) + + # THEN EXPECT save refuses, rather than silently writing the stale simultaneous chain + with pytest.raises(RuntimeError, match='no simultaneous chain here to save'): + multi_q_analysis.bayesian.save(str(tmp_path / 'chain')) + stale_sampler.save.assert_not_called() + + def test_extend_after_a_failed_simultaneous_run_keeps_the_failed_run_message( + self, multi_q_analysis + ): + # WHEN a simultaneous run fails after building the sampler, with no per-Q chains anywhere + bound_all_chain(multi_q_analysis) + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = RuntimeError('boom') + with pytest.raises(RuntimeError, match='boom'): + multi_q_analysis.bayesian.sample(fit_method='simultaneous', samples=10) + + # THEN EXPECT the genuine failed-run diagnosis, not the pointer at per-Q chains + with pytest.raises(RuntimeError, match='left no results'): + multi_q_analysis.bayesian.extend() + + def test_only_the_sampled_q_indices_are_gathered(self, multi_q_analysis): + # WHEN just one Q index is sampled + target = multi_q_analysis.analysis_list[1] + for parameter in target.get_free_parameters(): + parameter.min = float(parameter.value) - 5.0 + parameter.max = float(parameter.value) + 5.0 + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.side_effect = lambda **_k: fake_chain_results( + target.get_free_parameters() + ) + multi_q_analysis.bayesian.sample(fit_method='independent', Q_index=1, samples=10) + + # THEN + summary = multi_q_analysis.bayesian.summary() + + # EXPECT the unsampled Q indices are passed over rather than breaking the aggregation + assert len(summary) == len(target.get_free_parameters()) + assert all('Q_index=1' in entry.name for entry in summary) + assert len(multi_q_analysis.bayesian.set_parameters_to_median()) == len( + target.get_free_parameters() + ) + + def test_a_simultaneous_chain_serves_the_median_and_the_trace(self, multi_q_analysis): + # WHEN + bound_all_chain(multi_q_analysis) + parameters = multi_q_analysis._chain_parameters() + + with patch(SAMPLER_PATH) as sampler_class: + sampler_class.return_value.sample.return_value = fake_chain_results(parameters) + multi_q_analysis.bayesian.sample(fit_method='simultaneous', samples=10) + + # EXPECT both come from the single chain, with no per-Q gathering involved + assert len(multi_q_analysis.bayesian.set_parameters_to_median()) == len(parameters) + assert len(multi_q_analysis.bayesian.plot_trace().axes) == len(parameters) + 1 + class warnings_as_errors: """Context manager asserting that no UserWarning is emitted inside the block.""" diff --git a/tests/unit/easydynamics/utils/test_posterior_plotting.py b/tests/unit/easydynamics/utils/test_posterior_plotting.py index c470bdd20..1bb3bc4c3 100644 --- a/tests/unit/easydynamics/utils/test_posterior_plotting.py +++ b/tests/unit/easydynamics/utils/test_posterior_plotting.py @@ -1,6 +1,9 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +from unittest.mock import MagicMock +from unittest.mock import patch + import matplotlib as mpl import numpy as np import pytest @@ -9,11 +12,14 @@ import matplotlib.pyplot as plt +from easydynamics.utils.posterior_plotting import corner_with_slider +from easydynamics.utils.posterior_plotting import figures_with_slider from easydynamics.utils.posterior_plotting import plot_corner from easydynamics.utils.posterior_plotting import plot_correlations from easydynamics.utils.posterior_plotting import plot_marginal from easydynamics.utils.posterior_plotting import plot_posterior_predictive from easydynamics.utils.posterior_plotting import plot_trace +from easydynamics.utils.posterior_plotting import predictive_with_slider @pytest.fixture(autouse=True) @@ -67,6 +73,13 @@ def test_one_dimensional_draws_raise(self): with pytest.raises(ValueError, match='two-dimensional'): plot_trace(draws=np.zeros(10), names=['a']) + def test_labels_carry_units(self, draws): + # THEN + fig = plot_trace(draws=draws, names=['a', 'b', 'c'], units=['meV', 'm^2/s', '']) + + # EXPECT the real units are shown, and an empty one is skipped + assert [axis.get_ylabel() for axis in fig.axes] == ['a (meV)', 'b (m^2/s)', 'c'] + def test_zero_row_draws_raise(self): # THEN EXPECT with pytest.raises(ValueError, match='no samples'): @@ -111,6 +124,24 @@ def test_mismatched_names_raise(self, draws): with pytest.raises(ValueError, match='one entry per column'): plot_corner(draws=draws, names=['a']) + def test_diagonal_panel_is_labelled_as_counts(self, draws): + # THEN + fig = plot_corner(draws=draws, names=['a', 'b', 'c']) + + # EXPECT the top-left panel says what its vertical axis actually is. It is a histogram, so + # the parameter is on the x axis and labelling y with the parameter name would be wrong. + assert fig.axes[0].get_ylabel() == 'counts' + + def test_units_are_appended_to_labels(self, draws): + # THEN + fig = plot_corner(draws=draws, names=['a', 'b', 'c'], units=['meV', '', 'dimensionless']) + + # EXPECT the real unit is shown, and empty or dimensionless ones are skipped + bottom_row = fig.axes[-3:] + assert bottom_row[0].get_xlabel() == 'a (meV)' + assert bottom_row[1].get_xlabel() == 'b' + assert bottom_row[2].get_xlabel() == 'c' + def test_non_finite_draws_raise_naming_the_column(self, draws): # WHEN one column contains a NaN draws[5, 1] = np.nan @@ -350,3 +381,217 @@ def test_band_widens_with_the_credible_interval(self): narrow_span = narrow.axes[0].collections[0].get_paths()[0].get_extents().height wide_span = wide.axes[0].collections[0].get_paths()[0].get_extents().height assert wide_span > narrow_span + + def test_axis_labels_are_set_when_given(self): + # THEN + fig = plot_posterior_predictive( + x=np.zeros(4), + y=np.zeros(4), + predictions=np.zeros((5, 4)), + xlabel='Energy (meV)', + ylabel='Intensity', + ) + + # EXPECT + assert fig.axes[0].get_xlabel() == 'Energy (meV)' + assert fig.axes[0].get_ylabel() == 'Intensity' + + +class TestFiguresWithSlider: + @staticmethod + def _figure(value): + fig, axis = plt.subplots(figsize=(2.0, 1.5)) + axis.plot([0.0, 1.0], [0.0, value]) + return fig + + def test_returns_an_image_above_a_slider_over_the_given_indices(self): + # WHEN figures exist for a sparse set of indices + figures = {0: self._figure(0.0), 2: self._figure(2.0)} + + # THEN + widget = figures_with_slider(figures) + + # EXPECT the pre-rendered PNG of the first index, and only positions that hold a figure + image, slider = widget.children + assert bytes(image.value).startswith(b'\x89PNG') + assert list(slider.options) == [0, 2] + assert slider.value == 0 + + def test_moving_the_slider_swaps_stored_bytes_without_rendering(self): + # WHEN every figure was rendered once, at construction + widget = figures_with_slider({0: self._figure(0.0), 1: self._figure(1.0)}) + image, slider = widget.children + first_bytes = image.value + + # THEN the slider moves with no figures left to draw from + open_before = plt.get_fignums() + slider.value = 1 + changed_bytes = image.value + slider.value = 0 + + # EXPECT the image followed the slider by swapping stored bytes: no new matplotlib work, + # and coming back restores the identical rendering + assert plt.get_fignums() == open_before + assert changed_bytes != first_bytes + assert image.value == first_bytes + + def test_figures_are_closed_after_rendering(self): + # WHEN + figures = {0: self._figure(0.0), 1: self._figure(1.0)} + + # THEN + figures_with_slider(figures) + + # EXPECT no figure is left for a backend to draw a second time + assert plt.get_fignums() == [] + + def test_no_figures_raise(self): + # THEN EXPECT + with pytest.raises(ValueError, match='No figures'): + figures_with_slider({}) + + +class TestCornerWithSlider: + @pytest.fixture + def chains(self, draws): + return { + index: {'draws': draws + index, 'names': ['a', 'b', 'c'], 'units': ['meV', '', '']} + for index in (0, 2) + } + + def test_renders_one_corner_per_chain_behind_the_slider(self, chains): + # THEN + with patch( + 'easydynamics.utils.posterior_plotting.plot_corner', wraps=plot_corner + ) as render: + widget = corner_with_slider(chains, title='Fit', bins=13) + + # EXPECT every chain rendered once, up front, with the kwargs and per-index titles + # forwarded, and only the given indices on the slider + assert render.call_count == len(chains) + titles = {call.kwargs['title'] for call in render.call_args_list} + assert titles == {'Fit (Q index 0)', 'Fit (Q index 2)'} + assert all(call.kwargs['bins'] == 13 for call in render.call_args_list) + image, slider = widget.children + assert bytes(image.value).startswith(b'\x89PNG') + assert list(slider.options) == [0, 2] + + def test_no_chains_raise(self): + # THEN EXPECT + with pytest.raises(ValueError, match='No chains'): + corner_with_slider({}) + + +class TestPredictiveWithSlider: + @pytest.fixture + def arrays(self): + energy = np.linspace(-5.0, 5.0, 10) + q_values = np.array([0.5, 1.0]) + median = np.tile(np.exp(-0.5 * energy**2), (2, 1)) + return { + 'energy': energy, + 'q_values': q_values, + 'y': median + 0.01, + 'lower': median - 0.1, + 'median': median, + 'upper': median + 0.1, + } + + @staticmethod + def _fake_slicer_figure(): + control = MagicMock() + fig = MagicMock() + fig.bottom_bar = [MagicMock()] + fig.bottom_bar[0].controls = {'Q': control} + return fig, control + + def test_builds_the_datagroup_and_style_plopp_slices(self, arrays): + # WHEN pp.slicer is mocked out, since the real one needs an interactive backend + fake_fig, control = self._fake_slicer_figure() + + # THEN + with patch('plopp.slicer', return_value=fake_fig) as slicer: + fig = predictive_with_slider( + **arrays, + y_variances=np.full((2, 10), 0.01), + energy_unit='meV', + q_unit='1/angstrom', + ylabel='Intensity', + title='Fit', + credible_interval=68.0, + ) + + # EXPECT a Q/energy DataGroup sliced along energy, styled like plot_data_and_model: + # data as open black circles with error bars, the median a solid line, the band edges + # dashed and labelled with the interval + assert fig is fake_fig + args, kwargs = slicer.call_args + data_group = args[0] + assert set(data_group.keys()) == { + 'Data', + 'Posterior median', + '68% band (lower)', + '68% band (upper)', + } + assert data_group['Data'].dims == ('Q', 'energy') + assert data_group['Data'].variances is not None + assert str(data_group['Data'].coords['energy'].unit) == 'meV' + assert kwargs['keep'] == 'energy' + assert kwargs['title'] == 'Fit' + assert kwargs['linestyle']['Data'] == 'none' + assert kwargs['marker']['Data'] == 'o' + assert kwargs['color']['Data'] == 'black' + assert kwargs['linestyle']['Posterior median'] == '-' + assert kwargs['linestyle']['68% band (lower)'] == '--' + assert kwargs['linestyle']['68% band (upper)'] == '--' + # The plopp slider is switched to its single-value mode, as plot_data_and_model does, + # and the y label lands on the axis + assert control.slider_toggler.value == '-o-' + fake_fig.ax.set_ylabel.assert_called_once_with('Intensity') + fake_fig.autoscale.assert_called_once() + + def test_nan_padding_survives_into_the_datagroup(self, arrays): + # WHEN one Q is missing a point on the common grid + arrays['y'][1, 3] = np.nan + arrays['median'][1, 3] = np.nan + fake_fig, _ = self._fake_slicer_figure() + + # THEN + with patch('plopp.slicer', return_value=fake_fig) as slicer: + predictive_with_slider(**arrays) + + # EXPECT the gap reaches plopp as NaN, drawn as a break rather than an invented value + data_group = slicer.call_args.args[0] + assert np.isnan(data_group['Data'].values[1, 3]) + assert np.isnan(data_group['Posterior median'].values[1, 3]) + + def test_mismatched_shapes_raise(self, arrays): + # WHEN + arrays['median'] = arrays['median'][:, :-1] + + # THEN EXPECT + with pytest.raises(ValueError, match='median must have shape'): + predictive_with_slider(**arrays) + + @pytest.mark.parametrize('interval', [0.0, 100.0, -5.0]) + def test_invalid_credible_interval_raises(self, arrays, interval): + # THEN EXPECT + with pytest.raises(ValueError, match='credible_interval'): + predictive_with_slider(**arrays, credible_interval=interval) + + +class TestScientificNotation: + def test_shared_exponent_is_folded_into_the_label(self): + # WHEN the values are small enough that matplotlib factors out an exponent, which it parks + # on top of the axis label + draws = np.random.default_rng(0).normal(size=(200, 2)) * 1e-8 + 1.15e-8 + + # THEN + fig = plot_corner(draws=draws, names=['D', 'scale'], units=['m^2/s', '']) + + # EXPECT the exponent and the unit share one parenthetical, and the overlapping offset + # text is hidden + xlabel = fig.axes[-2].get_xlabel() + assert xlabel.startswith('D (1e') + assert 'm^2/s' in xlabel + assert not fig.axes[-2].xaxis.get_offset_text().get_visible() From 1654e2a8f0301ab9a4988a7e05a8f909c590de93 Mon Sep 17 00:00:00 2001 From: Henrik Jacobsen Date: Mon, 17 Aug 2026 21:51:25 +0200 Subject: [PATCH 3/5] Reach the whole library through one namespace (#241) * Add Bayesian posterior sampling to Analysis1d Expose the EasyScience Fitter on Analysis1d and add MCMC posterior sampling on top of it, using the BUMPS DREAM sampler introduced in easyscience 2.5.1 (easyscience.fitting.Sampler). Least-squares fitting reports a single point with a curvature-derived uncertainty, which is only trustworthy when parameters are uncorrelated and roughly Gaussian. Sampling maps the whole posterior instead, so correlated and skewed parameters get honest credible intervals. The sampling machinery lives in a mixin with three hooks (build the fitter, bind the data, list the chain parameters) so that Analysis and ParameterAnalysis can reuse it. ParameterAnalysis is not an AnalysisBase and builds a MultiFitter over binding models rather than over itself, so a shared base class would not have worked. Notable details: - fit() now uses a cached Fitter instead of building one per call, and the cache is invalidated through the existing dirty-flag pattern. - Bounds are the prior in DREAM, so sampling refuses to run with any infinite bound. suggest_bounds() proposes finite ones from the fitted values and uncertainties; it is advisory until .apply() is called and never loosens a bound that is already finite, so physical limits survive. A zero-width suggestion is flagged rather than invented. - Sampling restores parameter values afterwards, since BUMPS leaves them wherever the last likelihood evaluation put them. - Chains are reported under Parameter.name, not the internal unique_name. Those names are per-session, so save_chain() writes a sidecar mapping them to stable names and load_chain() uses it; loading without one warns rather than mislabelling the columns. - After sampling, a warning fires when the posterior has piled up against a bound, which catches both bounds that are too tight and degenerate parameters that drift until a bound stops them. - BUMPS crashes with a bare IndexError inside its own outlier removal when chains scatter, which in practice means a degenerate model. That is re-raised with the likely cause and a workaround. Co-Authored-By: Claude Opus 5 (1M context) * Add Bayesian posterior sampling to Analysis and ParameterAnalysis Extends the sampling introduced for Analysis1d to the remaining two Analysis classes, using the mixin hooks added with it. No new sampling machinery: each class supplies its fitter, its data, and its chain parameters, and everything else is shared. Analysis gains sample_posterior(fit_method=...), mirroring fit(): - 'independent' gives each Q index its own chain, delegating to the Analysis1d objects, and returns one result per Q (or a single result when a Q_index is given). - 'simultaneous' runs one chain over every Q at once through a MultiFitter, refreshing each per-Q convolver against its masked energy grid first, exactly as the simultaneous fit does. ParameterAnalysis samples the binding models. Its fit() built the MultiFitter inline, so the per-target data, functions, and models are now resolved by a shared _build_fit_inputs() that both paths use, which also guarantees fitting and sampling see the same targets in the same order with the same unit conversions. Parameter labels needed rethinking. A multi-Q analysis holds one copy of each parameter per Q, all sharing a name, so a summary showed several identical rows and a name could not pick a parameter out. Labels are now produced by an overridable parameter_label(): Analysis qualifies by Q index, ParameterAnalysis by binding model, and both only when the bare name is actually ambiguous, so single-Q and single-binding cases keep their short names. The summary and bounds tables size themselves to the longest label rather than truncating. Also fixes Analysis.fit's docstring, which promised a single FitResults for a simultaneous fit. MultiFitter splits its combined result back up by dataset, so a list has always been returned. Tutorial 1 gains a Bayesian section on the two-step diffusion fit, where the posterior turns out to be about twelve times tighter than the reported least-squares uncertainties. That gap is real and worth explaining: the width fit has a reduced chi-squared near 150, so lmfit inflates its uncertainties by the square root of that, while the sampler takes the stated uncertainties at face value. Sampling the full simultaneous diffusion model was measured at over ten minutes, so the tutorial uses the ParameterAnalysis step instead. Co-Authored-By: Claude Opus 5 (1M context) * Label the posterior plot axes with units and quantities The summary table already reported each parameter's unit, but the plots did not, so a diffusion coefficient came out as a bare number. Units are now threaded through to plot_trace and plot_corner, and the posterior predictive plot gets axis labels taken from the analysis' own energy and intensity units. Details that needed care: - Matplotlib parks a shared exponent at the end of the axis, on top of the axis label. It is now folded into the label, sharing one set of parentheses with the unit, so a diffusion coefficient reads "diffusion_coefficient (1e-8 m^2/s)" rather than stacking two parentheticals or overlapping. - Dimensionless and empty units are skipped. A polynomial coefficient labelled "dimensionless" is noise. - The top-left panel of a corner plot is a histogram, so its vertical axis counts draws rather than carrying a parameter. It is now labelled "counts" instead of being left blank, which read as an omission. - Corner tick counts are capped, since four labelled ticks per panel is as much as a small panel can carry legibly. Co-Authored-By: Claude Opus 5 (1M context) * Qualify parameter labels by model name, and cover the remaining branches Two fixes found by writing the tests codecov asked for. ParameterAnalysis qualified an ambiguous parameter with the owning model's display_name, but for several models -- the diffusion models among them -- display_name is the class name, so two models constructed as name='Diffusion A' and name='Diffusion B' both came back as "BrownianTranslationalDiffusion" and the label did not disambiguate anything. It now uses the model's name, matching the choice to report parameters under their name rather than their display name, and falls back to the unique name only when the names collide too. The rest is test coverage for branches that were reachable but untested: the label fallbacks, the BUMPS outlier crash being re-raised as a degeneracy hint, a chain column that matches no parameter, loading a chain through its sidecar, the mixin's unimplemented hooks, and the scientific-notation exponent being folded into an axis label. Co-Authored-By: Claude Opus 5 (1M context) * Warm the tutorial data cache before running notebooks in parallel The notebook tests run with '-n auto', and five of the notebooks fetch vanadium_data_example.h5 through pooch. On a cold cache the workers race: one is still writing the file into the cache while another opens it, which fails on Windows with "PermissionError: Permission denied". This failed twice in a row on windows-latest, always on that file, always with the other sixteen notebooks passing. The race is pre-existing, but adding a fifth notebook that wants the same file, and lengthening tutorial 1, made it reliable rather than rare. Fetching every tutorial data file once, before the parallel run starts, leaves the workers with nothing to do but read, which is safe. The prefetch reads the URLs and hashes out of the notebooks themselves, so it cannot drift from what they actually download, and it never fails the run: a file it cannot fetch is left to the notebook that needs it, which reports the problem with far more context. Co-Authored-By: Claude Opus 5 (1M context) * Rebuild the fitter when a binding changes shape, and stabilise the integration tests Two problems found while reviewing the previous commits. Caching the MultiFitter on ParameterAnalysis introduced a regression. A FitBinding can be edited in place -- binding.targets = ... -- which ParameterAnalysis cannot observe. Changing the number of targets left the cached fitter holding one fit function against two datasets, and fit() died with "FitError: list index out of range". It rebuilt every call before, so this worked previously. The targets the fitter was built for are now recorded and compared, which is enough to catch an edit that cannot be observed directly. The integration tests then failed in CI on macOS, inside BUMPS' outlier removal, on an identifiable model. That matters beyond the test: the error message claimed the crash means degenerate parameters, and this shows short chains do it too. The message now names both causes, and the integration tests switch the outlier removal off, as they already do for the burn-point trimming. Co-Authored-By: Claude Opus 5 (1M context) * Address the review findings on the sampling API Six issues found reviewing the previous commits. The sidecar could be written with the wrong labels. A subset run built the name map inside the block that holds the other parameters fixed, where nothing looks ambiguous, so a multi-Q chain recorded unqualified names that no longer matched on reload. The map is now built outside that block, where the free set is the user's real one. extend_sampling() accepted a different parameter subset. BUMPS resumes from a stored chain whose width is fixed, so that could only fail deep inside the sampler; it is now refused up front. The IndexError relabelling was unconditional, so an IndexError from this package would have been reported as a BUMPS modelling problem. It now only applies when the traceback passes through bumps. Labelling a chain was quadratic in the parameter count: collecting the parameters and scanning for their owner both happened per parameter, and each walks every sub-model. 75 parameters took 0.39 s, and every summary and plot pays it. The parameters are now collected once per pass, and Analysis keeps an owner index alongside its analysis list. The same case now measures at 0.00 s. Asking an Analysis for a summary after sampling independently reported that nothing had been sampled, moments after it had. It now says where the chains actually are. Applying bounds many orders of magnitude wider than the parameter is still allowed -- it is what the fit implied -- but no longer silent, so a scripted apply() cannot hide a degeneracy the table would have shown. Co-Authored-By: Claude Opus 5 (1M context) * Cover the review fixes, and drop a redundant guard Three lines the review fixes added were not reachable from the unit tests. Two are now covered: extending after a run that died before storing results, where the chain-shape guard has nothing to compare against, and a parameter shared across every Q index, which is left out of the owner map because no single Q identifies it. The third was the non-finite check in the absurd-width test, and it was redundant rather than untested: an infinite width already compares greater than any threshold, and the zero-scale case returns before it. Removed, so the behaviour is unchanged and there is no dead branch. Co-Authored-By: Claude Opus 5 (1M context) * Gather the per-Q chains on Analysis after independent sampling Sampling with fit_method='independent' left the results only on the Analysis1d objects, so the Analysis that produced them could not report on them. It now gathers them, but only where gathering is sound. posterior_summary() collects every Q into one table, labelled by Q index, and set_parameters_to_posterior_median() applies each chain to its own Q. Both are per-parameter marginal operations, and a marginal is well defined within its own chain, so combining them across separate chains says nothing that was not sampled. plot_corner() deliberately does not aggregate. Independent sampling draws each Q separately, so no draw pairs a parameter at one Q with a parameter at another, and a corner plot built from them would show correlations that are an artefact of how the sampling was run rather than anything measured. It says so and points at the per-Q corner plots, which are real. plot_trace() likewise, the chains being separate runs of different lengths rather than one trace. posterior_results exposes the per-Q chains directly, and a simultaneous chain still takes precedence over stale per-Q ones. Co-Authored-By: Claude Opus 5 (1M context) * Step through the per-Q corner plots with a slider Independent chains share no draws, so there is no joint distribution across Q to plot, and combining them would show correlations that came from how the sampling was run rather than from the data. Refusing outright was correct but unhelpful: the correlations within each Q are real and worth looking at. Analysis.plot_corner() now shows one Q at a time. Pass Q_index for a particular one, or leave it out in a notebook for a slider across the Q values that were sampled. A simultaneous chain is unaffected; it already covers every Q in one figure. Outside a notebook the error names the sampled Q indices rather than only saying no. The slider is built with append_display_data rather than the Output widget's context manager. The context manager is the obvious choice and captures nothing under some kernels, which would have shipped a slider with a permanently blank panel beside it. Verified by executing a notebook against a real kernel, and the test asserts the panel actually holds a figure, since an empty panel is the regression that matters. Co-Authored-By: Claude Opus 5 (1M context) * Show the per-Q corner slider in the Bayesian tutorial The slider was described in the tutorial's caveats but never demonstrated: every notebook call to plot_corner() went through the single-chain path, because the Bayesian tutorial used Analysis1d and tutorial 1 used ParameterAnalysis, neither of which has a Q dimension. So the only things exercising it were the unit tests. The tutorial now builds the full multi-Q Analysis, samples a few Q values, gathers them with posterior_summary(), and shows the slider. It samples Q indices 4, 8 and 12 rather than all sixteen. Sampling every Q measured at 70 s against 16 s for three, and the subset also shows two things worth showing: that sampling is slow enough to be worth trying a few Q values first, and that the slider offers only the Q values that were actually sampled. Verified against a real kernel that the cell emits a widget view, rather than only that the notebook ran without raising. Co-Authored-By: Claude Opus 5 (1M context) * Put the corner slider under the figure Matches where plopp puts its slicer controls, which is also where the existing slicerplot_with_residuals puts them via the figure's bottom bar. Co-Authored-By: Claude Opus 5 (1M context) * Reach the whole library through one namespace Review feedback: the import style was inconsistent enough that a reader had to scroll back to the imports cell to find out where a name came from. Surveying it, the tutorials used four styles, and the last two existed only because there was no other way to reach those names: import easydynamics as edyn 32 uses import easydynamics.sample_model as sm 151 uses from easydynamics.convolution import Convolution forced from easydynamics.utils.utils import hbar forced easydynamics.__all__ held six names, so Analysis1d, Convolution, detailed_balance_factor and hbar could only be had by importing the module that defines them. The inconsistency was structural rather than careless, and no amount of tidying the notebooks alone would have fixed it. Everything public is now re-exported from easydynamics, 37 names, so `import easydynamics as edyn` reaches all of it. The sub-packages stay importable and the internal layout is untouched: only the front door is flat. Flat is comfortable at this size, there were no name collisions, and the sample_model grouping was already imprecise, holding InstrumentModel, ResolutionModel and BackgroundModel. The tutorials and the docstring examples that render into the API reference now use that one style throughout. A test keeps the front door in step with the sub-packages and the notebooks in step with the convention, which is also written down in CONTRIBUTING. Co-Authored-By: Claude Opus 5 (1M context) * Compose the posterior sampler instead of mixing it in Review feedback: bayesian_sampling.py had a lot in it that belonged elsewhere, and it was unclear why it was a mixin at all. It was a mixin because ParameterAnalysis is not an AnalysisBase and fits its binding models rather than itself, so a shared base class does not work. That was a reason, not a good one: it injected some forty methods into every Analysis class. The sampler is now composed. An Analysis exposes one `bayesian` property, and hands the sampler the few things that differ between the Analysis classes -- the data, the free parameters, their labels, and a hook to refresh cached computation -- so PosteriorSampler needs no knowledge of how any Analysis is built, and no Analysis inherits sampling machinery it does not use. Labelling moves to posterior_labels.py. Building it once for a fixed set of parameters also removes the quadratic cost the old code needed a scoped cache to avoid: the counts and lookups are computed in the constructor rather than per column. Plotting stays in posterior_plotting.py, where it already lived. The sampler keeps three short delegates so a chain can still be plotted from the object holding it, but none of the drawing happens there. The public API becomes analysis.bayesian.sample() and friends, and the explicit suggest_bounds().apply() step stays: in DREAM the bounds are the prior, and an unbounded parameter gives a confident-looking interval set by nothing. Co-Authored-By: Claude Opus 5 (1M context) * Export the multi-Q sampler and drop the mixin's name The section headers still pointed at a class that no longer exists, and MultiQPosteriorSampler was reachable only through Analysis.bayesian. Co-Authored-By: Claude Opus 5 (1M context) * Point the front door at the composed sampler The flat namespace still re-exported the mixin that the refactor removed, and not the sampler classes that replaced it. Co-Authored-By: Claude Opus 5 (1M context) * Unwrap the security-issue line again Prettier 3.9, which CI installs, measures the shield emoji differently from the older release cached here and wants the line whole. Co-Authored-By: Claude Opus 5 (1M context) * Warm the tutorial data cache before running notebooks in parallel The notebook tests run with '-n auto', and five of the notebooks fetch vanadium_data_example.h5 through pooch. On a cold cache the workers race: one is still writing the file into the cache while another opens it, which fails on Windows with "PermissionError: Permission denied". This failed twice in a row on windows-latest, always on that file, always with the other sixteen notebooks passing. The race is pre-existing, but adding a fifth notebook that wants the same file, and lengthening tutorial 1, made it reliable rather than rare. Fetching every tutorial data file once, before the parallel run starts, leaves the workers with nothing to do but read, which is safe. The prefetch reads the URLs and hashes out of the notebooks themselves, so it cannot drift from what they actually download, and it never fails the run: a file it cannot fetch is left to the notebook that needs it, which reports the problem with far more context. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 46d745a4a73e8025c37e02e490fab67cfdb5ff22) * Mark setup, action and expectation apart in the new tests The sampling tests labelled the action WHEN and had no THEN, so a reader could not see where the arrangement stopped and the call under test began. Setup is WHEN, the action is THEN, the assertions are EXPECT, and steps that genuinely collapse onto one statement carry one combined marker instead. Comments only; no test changed what it does. Co-Authored-By: Claude Opus 5 (1M context) * Mark setup, action and expectation apart in the multi-Q tests Same pass as on the single-Q tests: setup is WHEN, the action is THEN, the assertions are EXPECT, and a step that collapses onto one statement carries one combined marker. Comments only; no test changed what it does. Co-Authored-By: Claude Opus 5 (1M context) * Mark setup, action and expectation apart in the namespace tests Co-Authored-By: Claude Opus 5 (1M context) * Give the sampler its own test file Tests were split by feature rather than by the file they exercise, so posterior_sampling.py had no test file of its own and Analysis1d had two. The sampler's tests now live in test_posterior_sampling.py under one TestPosteriorSampler, with the old class names as section banners, and the four tests that are really about Analysis1d's cached fitter move into TestAnalysis1d. No test changed what it does; the same 31 + 4 tests run as before. Co-Authored-By: Claude Opus 5 (1M context) * Put each test in the file of the class it exercises Analysis and ParameterAnalysis each had a second test file, and the sampler had none of its own. The sampler's tests, whichever analysis drives them, now live in test_posterior_sampling.py under TestPosteriorSampler and TestMultiQPosteriorSampler; the fitter, chain parameter and label tests move into TestAnalysis and TestParameterAnalysis. Old class names became section banners. The multi-Q and ParameterAnalysis helpers keep distinct names in the merged file, since their signatures differ from the single-Q ones. The same 1660 tests run as before. Co-Authored-By: Claude Opus 5 (1M context) * Refuse silent chain corruption and harden the posterior sampler - extend() now verifies the chain holds the same parameters, not just the same number, and refuses to resume after a failed run or after the model or data changed - Parameter objects passed to sample(parameters=...) are validated against the free set the same way strings are - sampling with no free parameters and degenerate (min >= max) bounds raise clear errors before reaching BUMPS - parameters_at_bounds keys by unique_name so same-named per-Q parameters no longer collide, and guards empty draws - suggest_bounds flags non-finite fitted uncertainties for attention - save() refuses to write an empty label sidecar; loading one warns like a missing sidecar - colliding display labels get positional suffixes in the sidecar so save/load resolves each column to its own parameter - plot_posterior_predictive omits error bars when the data carries no variances (new Experiment.has_variances) - posterior plots validate draws/logp up front, name NaN columns, and share x-limits per corner column - document that sampling runs are not seedable Co-Authored-By: Claude Fable 5 * Keep the multi-Q sampler pointed at the chain the user actually ran - sampling one Q index independently now clears a stale simultaneous chain, so summary(), set_parameters_to_median() and plot_corner() report the run the user just made instead of the old one - extend() and save() after an independent run explain that the chains live on the per-Q analyses instead of resuming or saving the stale simultaneous chain; a genuinely failed run keeps its own message - Q_index arguments are validated like every Analysis method, so a negative index raises instead of silently wrapping - the gathered summary resolves each per-Q chain through its own saved labels, so chains loaded from disk keep names and units - warnings are attributed to the caller on both the single-Q and multi-Q paths, and the corner-plot slider forwards plot kwargs - the multi-Q integration tests share one independent sampling run, assert the straight line is actually recovered, and the extend test no longer mutates the shared fixture Co-Authored-By: Claude Fable 5 * Add marginal posteriors, correlation heatmaps and sampling progress - plot_marginal(parameter) renders one parameter's posterior histogram with the median and the 16/84 percentile interval summary() reports, resolving labels the same way sample(parameters=...) does - plot_correlations() renders the Pearson correlation matrix of the chain with annotated cells, a diverging colormap and masked cells for constant columns - sample(progress=True) and extend(progress=True) report sampling progress through the Sampler's progress_callback, closing the line with an explicit done marker because BUMPS' own step estimate assumes the wrong chain count - the 95 percent predictive band needed no change: credible_interval already exists on plot_posterior_predictive Co-Authored-By: Claude Fable 5 * Give every posterior plot a Q slider over independent chains After independent per-Q sampling the multi-Q sampler now presents a Q slider instead of refusing: - plot_posterior_predictive builds the per-Q data, median and credible band into a scipp DataGroup and renders it through plopp exactly like plot_data_and_model; plopp cannot shade a band on sliced lines, so the slider view draws labelled band edges while the Q_index path keeps the shaded band - plot_trace, plot_marginal and plot_correlations take Q_index for a single figure, show a slider in a notebook, and otherwise name the sampled Q indices - the matplotlib sliders render every figure once up front and only swap PNG bytes on a move, so dragging tracks smoothly with continuous updates instead of re-rendering per change - per-Q energy grids are NaN-padded onto the common grid through the finite mask, so masked points draw as gaps Co-Authored-By: Claude Fable 5 * Write the progress line through sys.stdout Co-Authored-By: Claude Fable 5 * Show the new posterior plots in the Bayesian tutorial The tutorial now demonstrates plot_marginal and plot_correlations from the sampled chain, progress=True on the sampling call, the 95 percent predictive band option, the Q slider that every posterior plot offers over independent chains, and notes that runs are not seedable. Co-Authored-By: Claude Fable 5 * Apply the formatting fixes Co-Authored-By: Claude Fable 5 * Satisfy the docstring and formatting checks The progress reporter closes through try/finally instead of a bare re-raise, and the plotting validation errors are documented in the form the docstring linter expects. Co-Authored-By: Claude Fable 5 * Document propagated exceptions the way the docstring linter expects Co-Authored-By: Claude Fable 5 * Give the Bayesian tutorial the widget backend its sliders need The Q-slider cells go through the plopp slicer, which refuses the inline backend; every plopp-using tutorial already runs %matplotlib widget. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 5 (1M context) --- CONTRIBUTING.md | 24 ++++++ docs/docs/tutorials/analysis.ipynb | 39 +++++---- docs/docs/tutorials/analysis1d.ipynb | 18 ++--- docs/docs/tutorials/bayesian.ipynb | 24 +++--- .../docs/tutorials/component_collection.ipynb | 14 ++-- docs/docs/tutorials/components.ipynb | 22 ++--- docs/docs/tutorials/convolution.ipynb | 66 ++++++++------- docs/docs/tutorials/delta_lorentz.ipynb | 4 +- docs/docs/tutorials/detailed_balance.ipynb | 6 +- docs/docs/tutorials/diffusion_model.ipynb | 4 +- docs/docs/tutorials/instrument_model.ipynb | 12 +-- docs/docs/tutorials/sample_model.ipynb | 20 ++--- docs/docs/tutorials/tutorial0_basics.ipynb | 7 +- .../tutorials/tutorial0_more_advanced.ipynb | 21 +++-- docs/docs/tutorials/tutorial1_brownian.ipynb | 37 +++++---- .../tutorials/tutorial2_nanoparticles.ipynb | 46 +++++------ src/easydynamics/__init__.py | 80 ++++++++++++++++++- src/easydynamics/analysis/analysis.py | 9 +-- src/easydynamics/analysis/analysis1d.py | 12 ++- src/easydynamics/analysis/fit_binding.py | 7 +- .../analysis/parameter_analysis.py | 5 +- src/easydynamics/convolution/convolution.py | 11 ++- .../sample_model/background_model.py | 12 +-- .../sample_model/component_collection.py | 10 +-- .../components/damped_harmonic_oscillator.py | 8 +- .../sample_model/components/delta_function.py | 8 +- .../sample_model/components/exponential.py | 8 +- .../components/expression_component.py | 8 +- .../sample_model/components/gaussian.py | 8 +- .../sample_model/components/lorentzian.py | 8 +- .../sample_model/components/polynomial.py | 16 ++-- .../sample_model/components/voigt.py | 8 +- .../brownian_translational_diffusion.py | 4 +- .../diffusion_model/delta_lorentz.py | 4 +- .../jump_translational_diffusion.py | 4 +- .../sample_model/instrument_model.py | 8 +- .../sample_model/resolution_model.py | 8 +- src/easydynamics/sample_model/sample_model.py | 14 ++-- src/easydynamics/utils/detailed_balance.py | 4 +- src/easydynamics/utils/plotting.py | 4 +- tests/unit/easydynamics/test_public_api.py | 78 ++++++++++++++++++ 41 files changed, 435 insertions(+), 275 deletions(-) create mode 100644 tests/unit/easydynamics/test_public_api.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ee9f0e25..817b2831a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -193,6 +193,30 @@ git add . git commit -m "Improve performance of time integrator for large systems" ``` +### Imports in Tutorials and Examples + +Anything user-facing — the tutorial notebooks and the `python` examples +in docstrings — reaches EasyDynamics through a single namespace: + +```python +import easydynamics as edyn + +experiment = edyn.Experiment('Vanadium') +model = edyn.SampleModel(components=edyn.Gaussian(width=0.1)) +``` + +Every public name is re-exported from `easydynamics`, so this always +works. Please do not mix in `import easydynamics.sample_model as sm`, or +reach into a module with +`from easydynamics.analysis.analysis1d import Analysis1d`: a reader then +has to scroll back to the imports to find out where a name came from. + +If something you need is missing from `edyn.`, add it to `__all__` in +`src/easydynamics/__init__.py` rather than importing around it. + +Inside the library itself, keep importing from the specific module that +defines a name. Only the public front door is flat. + --- ## 6. Code Quality Checks diff --git a/docs/docs/tutorials/analysis.ipynb b/docs/docs/tutorials/analysis.ipynb index 7ba7858b5..671e38a71 100644 --- a/docs/docs/tutorials/analysis.ipynb +++ b/docs/docs/tutorials/analysis.ipynb @@ -24,7 +24,6 @@ "import pooch\n", "\n", "import easydynamics as edyn\n", - "import easydynamics.sample_model as sm\n", "\n", "%matplotlib widget" ] @@ -56,28 +55,28 @@ "# Example of Analysis with a simple sample model and instrument model\n", "# The scattering from vanadium is purely elastic, so we model it with a\n", "# delta function\n", - "delta_function = sm.DeltaFunction(display_name='DeltaFunction', area=1)\n", - "sample_model = sm.SampleModel(\n", + "delta_function = edyn.DeltaFunction(display_name='DeltaFunction', area=1)\n", + "sample_model = edyn.SampleModel(\n", " components=delta_function,\n", ")\n", "\n", "# The resolution is in this case modeled as a Gaussian. However, we can\n", "# add as many components as we like to the resolution model\n", - "res_gauss = sm.Gaussian(width=0.1)\n", + "res_gauss = edyn.Gaussian(width=0.1)\n", "res_gauss.area.fixed = True\n", - "resolution_components = sm.ComponentCollection()\n", + "resolution_components = edyn.ComponentCollection()\n", "resolution_components.append_component(res_gauss)\n", - "resolution_model = sm.ResolutionModel(components=resolution_components)\n", + "resolution_model = edyn.ResolutionModel(components=resolution_components)\n", "\n", "# The background model is created in the same way. In this case, we use\n", "# a flat background\n", - "background_model = sm.BackgroundModel(components=sm.Polynomial(coefficients=[0.001]))\n", + "background_model = edyn.BackgroundModel(components=edyn.Polynomial(coefficients=[0.001]))\n", "\n", "# We combine the resolution abd background model into an instrument\n", "# model. This model also contains a small energy offset to account for\n", "# instrument misalignment.\n", "\n", - "instrument_model = sm.InstrumentModel(\n", + "instrument_model = edyn.InstrumentModel(\n", " resolution_model=resolution_model,\n", " background_model=background_model,\n", ")\n", @@ -190,19 +189,19 @@ "# Now we set up the model, similarly to how we set up the model for the\n", "# vanadium data.\n", "\n", - "delta_function = sm.DeltaFunction(display_name='DeltaFunction', area=0.2)\n", - "lorentzian = sm.Lorentzian(display_name='Lorentzian', area=0.5, width=0.3)\n", - "component_collection = sm.ComponentCollection(\n", + "delta_function = edyn.DeltaFunction(display_name='DeltaFunction', area=0.2)\n", + "lorentzian = edyn.Lorentzian(display_name='Lorentzian', area=0.5, width=0.3)\n", + "component_collection = edyn.ComponentCollection(\n", " components=[delta_function, lorentzian],\n", ")\n", "\n", - "sample_model = sm.SampleModel(\n", + "sample_model = edyn.SampleModel(\n", " components=component_collection,\n", ")\n", "\n", - "background_model = sm.BackgroundModel(components=sm.Polynomial(coefficients=[0.001]))\n", + "background_model = edyn.BackgroundModel(components=edyn.Polynomial(coefficients=[0.001]))\n", "\n", - "instrument_model = sm.InstrumentModel(\n", + "instrument_model = edyn.InstrumentModel(\n", " background_model=background_model,\n", ")\n", "\n", @@ -265,22 +264,22 @@ "# Let us now fit directly to a diffusion model. We replace the\n", "# Lorentzian with a Brownian translational diffusion model and keep the\n", "# other parameters the same.\n", - "delta_function = sm.DeltaFunction(display_name='DeltaFunction', area=0.2)\n", - "component_collection = sm.ComponentCollection(\n", + "delta_function = edyn.DeltaFunction(display_name='DeltaFunction', area=0.2)\n", + "component_collection = edyn.ComponentCollection(\n", " components=[delta_function],\n", ")\n", - "diffusion_model = sm.BrownianTranslationalDiffusion(\n", + "diffusion_model = edyn.BrownianTranslationalDiffusion(\n", " display_name='Brownian Translational Diffusion', diffusion_coefficient=2.4e-9, scale=0.5\n", ")\n", "\n", - "sample_model = sm.SampleModel(\n", + "sample_model = edyn.SampleModel(\n", " components=component_collection,\n", " diffusion_models=diffusion_model,\n", ")\n", "\n", - "background_model = sm.BackgroundModel(components=sm.Polynomial(coefficients=[0.001]))\n", + "background_model = edyn.BackgroundModel(components=edyn.Polynomial(coefficients=[0.001]))\n", "\n", - "instrument_model = sm.InstrumentModel(\n", + "instrument_model = edyn.InstrumentModel(\n", " background_model=background_model,\n", ")\n", "\n", diff --git a/docs/docs/tutorials/analysis1d.ipynb b/docs/docs/tutorials/analysis1d.ipynb index 5ee06676d..77b19bab8 100644 --- a/docs/docs/tutorials/analysis1d.ipynb +++ b/docs/docs/tutorials/analysis1d.ipynb @@ -19,8 +19,6 @@ "import pooch\n", "\n", "import easydynamics as edyn\n", - "import easydynamics.sample_model as sm\n", - "from easydynamics.analysis.analysis1d import Analysis1d\n", "\n", "%matplotlib widget" ] @@ -49,24 +47,24 @@ "metadata": {}, "outputs": [], "source": [ - "# Example of Analysis1d with a simple sample model and instrument model\n", - "delta_function = sm.DeltaFunction(display_name='DeltaFunction', area=1)\n", - "sample_model = sm.SampleModel(\n", + "# Example of edyn.Analysis1d with a simple sample model and instrument model\n", + "delta_function = edyn.DeltaFunction(display_name='DeltaFunction', area=1)\n", + "sample_model = edyn.SampleModel(\n", " components=delta_function,\n", ")\n", "\n", - "res_gauss = sm.Gaussian(width=0.1)\n", - "resolution_model = sm.ResolutionModel(components=res_gauss)\n", + "res_gauss = edyn.Gaussian(width=0.1)\n", + "resolution_model = edyn.ResolutionModel(components=res_gauss)\n", "\n", "\n", - "background_model = sm.BackgroundModel(components=sm.Polynomial(coefficients=[0.001]))\n", + "background_model = edyn.BackgroundModel(components=edyn.Polynomial(coefficients=[0.001]))\n", "\n", - "instrument_model = sm.InstrumentModel(\n", + "instrument_model = edyn.InstrumentModel(\n", " resolution_model=resolution_model,\n", " background_model=background_model,\n", ")\n", "\n", - "my_analysis = Analysis1d(\n", + "my_analysis = edyn.Analysis1d(\n", " display_name='Vanadium Analysis',\n", " experiment=vanadium_experiment,\n", " sample_model=sample_model,\n", diff --git a/docs/docs/tutorials/bayesian.ipynb b/docs/docs/tutorials/bayesian.ipynb index bc92e21fc..d76a2c3b9 100644 --- a/docs/docs/tutorials/bayesian.ipynb +++ b/docs/docs/tutorials/bayesian.ipynb @@ -24,8 +24,6 @@ "import pooch\n", "\n", "import easydynamics as edyn\n", - "import easydynamics.sample_model as sm\n", - "from easydynamics.analysis.analysis1d import Analysis1d\n", "\n", "# Make the plots interactive; the Q sliders need the widget backend\n", "%matplotlib widget" @@ -77,17 +75,17 @@ "metadata": {}, "outputs": [], "source": [ - "vanadium_components = sm.ComponentCollection()\n", - "vanadium_components.append_component(sm.Gaussian(width=0.1, area=1, name='Res. Gauss'))\n", + "vanadium_components = edyn.ComponentCollection()\n", + "vanadium_components.append_component(edyn.Gaussian(width=0.1, area=1, name='Res. Gauss'))\n", "\n", - "instrument_model = sm.InstrumentModel(\n", - " background_model=sm.BackgroundModel(components=sm.Polynomial(coefficients=[0.001])),\n", + "instrument_model = edyn.InstrumentModel(\n", + " background_model=edyn.BackgroundModel(components=edyn.Polynomial(coefficients=[0.001])),\n", ")\n", "\n", - "analysis = Analysis1d(\n", + "analysis = edyn.Analysis1d(\n", " display_name='Vanadium Analysis',\n", " experiment=vanadium_experiment,\n", - " sample_model=sm.SampleModel(components=vanadium_components),\n", + " sample_model=edyn.SampleModel(components=vanadium_components),\n", " instrument_model=instrument_model,\n", " Q_index=5,\n", ")\n", @@ -345,15 +343,15 @@ "source": [ "# Fresh models, so this analysis is independent of the single-Q one above rather than\n", "# sharing its already-sampled components.\n", - "all_q_components = sm.ComponentCollection()\n", - "all_q_components.append_component(sm.Gaussian(width=0.1, area=1, name='Res. Gauss'))\n", + "all_q_components = edyn.ComponentCollection()\n", + "all_q_components.append_component(edyn.Gaussian(width=0.1, area=1, name='Res. Gauss'))\n", "\n", "full_analysis = edyn.Analysis(\n", " display_name='Vanadium, all Q',\n", " experiment=vanadium_experiment,\n", - " sample_model=sm.SampleModel(components=all_q_components),\n", - " instrument_model=sm.InstrumentModel(\n", - " background_model=sm.BackgroundModel(components=sm.Polynomial(coefficients=[0.001])),\n", + " sample_model=edyn.SampleModel(components=all_q_components),\n", + " instrument_model=edyn.InstrumentModel(\n", + " background_model=edyn.BackgroundModel(components=edyn.Polynomial(coefficients=[0.001])),\n", " ),\n", ")\n", "full_analysis.fit(fit_method='independent')\n", diff --git a/docs/docs/tutorials/component_collection.ipynb b/docs/docs/tutorials/component_collection.ipynb index 656fcf59f..b286d0f1e 100644 --- a/docs/docs/tutorials/component_collection.ipynb +++ b/docs/docs/tutorials/component_collection.ipynb @@ -20,7 +20,7 @@ "import matplotlib.pyplot as plt\n", "import numpy as np\n", "\n", - "import easydynamics.sample_model as sm\n", + "import easydynamics as edyn\n", "\n", "%matplotlib widget" ] @@ -32,13 +32,15 @@ "metadata": {}, "outputs": [], "source": [ - "component_collection = sm.ComponentCollection()\n", + "component_collection = edyn.ComponentCollection()\n", "\n", "# Creating components\n", - "gaussian = sm.Gaussian(display_name='Gaussian', width=0.5, area=1)\n", - "dho = sm.DampedHarmonicOscillator(display_name='DHO', center=1.0, width=0.3, area=2.0)\n", - "lorentzian = sm.Lorentzian(display_name='Lorentzian', center=-1.0, width=0.2, area=1.0)\n", - "polynomial = sm.Polynomial(display_name='Polynomial', coefficients=[0.1, 0, 0.5]) # y=0.1+0.5*x^2\n", + "gaussian = edyn.Gaussian(display_name='Gaussian', width=0.5, area=1)\n", + "dho = edyn.DampedHarmonicOscillator(display_name='DHO', center=1.0, width=0.3, area=2.0)\n", + "lorentzian = edyn.Lorentzian(display_name='Lorentzian', center=-1.0, width=0.2, area=1.0)\n", + "polynomial = edyn.Polynomial(\n", + " display_name='Polynomial', coefficients=[0.1, 0, 0.5]\n", + ") # y=0.1+0.5*x^2\n", "\n", "# Adding components to the component collection\n", "component_collection.append_component(gaussian)\n", diff --git a/docs/docs/tutorials/components.ipynb b/docs/docs/tutorials/components.ipynb index eafa88973..0fa5a8884 100644 --- a/docs/docs/tutorials/components.ipynb +++ b/docs/docs/tutorials/components.ipynb @@ -23,7 +23,7 @@ "import numpy as np\n", "import scipp as sc\n", "\n", - "import easydynamics.sample_model as sm\n", + "import easydynamics as edyn\n", "\n", "%matplotlib widget" ] @@ -36,13 +36,13 @@ "outputs": [], "source": [ "# Creating a component\n", - "gaussian = sm.Gaussian(display_name='Gaussian', width=0.5, area=1)\n", - "dho = sm.DampedHarmonicOscillator(display_name='DHO', center=1.0, width=0.3, area=2.0)\n", - "lorentzian = sm.Lorentzian(display_name='Lorentzian', center=-1.0, width=0.2, area=1.0)\n", - "polynomial = sm.Polynomial(\n", + "gaussian = edyn.Gaussian(display_name='Gaussian', width=0.5, area=1)\n", + "dho = edyn.DampedHarmonicOscillator(display_name='DHO', center=1.0, width=0.3, area=2.0)\n", + "lorentzian = edyn.Lorentzian(display_name='Lorentzian', center=-1.0, width=0.2, area=1.0)\n", + "polynomial = edyn.Polynomial(\n", " display_name='Polynomial', coefficients=[-0.2, 0, 0.5]\n", ") # y=-0.2+0.5*x^2\n", - "exponential = sm.Exponential(display_name='Exponential', amplitude=1.0, rate=-0.5)\n", + "exponential = edyn.Exponential(display_name='Exponential', amplitude=1.0, rate=-0.5)\n", "\n", "x = np.linspace(-2, 2, 100)\n", "\n", @@ -94,7 +94,7 @@ "metadata": {}, "outputs": [], "source": [ - "delta = sm.DeltaFunction(display_name='Delta', center=0.0, area=1.0)\n", + "delta = edyn.DeltaFunction(display_name='Delta', center=0.0, area=1.0)\n", "x1 = np.linspace(-2, 2, 100)\n", "y = delta.evaluate(x1)\n", "x2 = np.linspace(-2, 2, 51)\n", @@ -122,7 +122,9 @@ "x1 = sc.linspace(dim='x', start=-2.0, stop=2.0, num=100, unit='meV')\n", "x2 = sc.linspace(dim='x', start=-2.0 * 1e3, stop=2.0 * 1e3, num=101, unit='microeV')\n", "\n", - "polynomial = sm.Polynomial(display_name='Polynomial', coefficients=[0.1, 0, 0.5]) # y=0.1+0.5*x^2\n", + "polynomial = edyn.Polynomial(\n", + " display_name='Polynomial', coefficients=[0.1, 0, 0.5]\n", + ") # y=0.1+0.5*x^2\n", "y1 = polynomial.evaluate(x1)\n", "y2 = polynomial.evaluate(x2)\n", "\n", @@ -148,7 +150,7 @@ "metadata": {}, "outputs": [], "source": [ - "expr = sm.ExpressionComponent(\n", + "expr = edyn.ExpressionComponent(\n", " 'A * exp(-(x - x0)**2 / (2*sigma**2)) +B*sin(2*pi*x/period)',\n", " parameters={'A': 10, 'x0': 0, 'sigma': 1},\n", " parameter_units={\n", @@ -185,7 +187,7 @@ "metadata": {}, "outputs": [], "source": [ - "expr = sm.ExpressionComponent(\n", + "expr = edyn.ExpressionComponent(\n", " 'A*erf(B*x)',\n", ")\n", "\n", diff --git a/docs/docs/tutorials/convolution.ipynb b/docs/docs/tutorials/convolution.ipynb index 2e9625559..c366478c4 100644 --- a/docs/docs/tutorials/convolution.ipynb +++ b/docs/docs/tutorials/convolution.ipynb @@ -24,9 +24,7 @@ "import matplotlib.pyplot as plt\n", "import numpy as np\n", "\n", - "import easydynamics.sample_model as sm\n", - "from easydynamics.convolution import Convolution\n", - "from easydynamics.utils import detailed_balance_factor\n", + "import easydynamics as edyn\n", "\n", "%matplotlib widget" ] @@ -40,25 +38,25 @@ "source": [ "# Standard example of convolution of a sample model with a\n", "# resolution model\n", - "sample_components = sm.ComponentCollection()\n", - "gaussian = sm.Gaussian(display_name='Gaussian', width=0.5, area=1)\n", - "dho = sm.DampedHarmonicOscillator(display_name='DHO', center=1.0, width=0.3, area=2.0)\n", - "lorentzian = sm.Lorentzian(display_name='Lorentzian', center=-1.0, width=0.2, area=1.0)\n", - "delta = sm.DeltaFunction(display_name='Delta', center=0.4, area=0.5)\n", + "sample_components = edyn.ComponentCollection()\n", + "gaussian = edyn.Gaussian(display_name='Gaussian', width=0.5, area=1)\n", + "dho = edyn.DampedHarmonicOscillator(display_name='DHO', center=1.0, width=0.3, area=2.0)\n", + "lorentzian = edyn.Lorentzian(display_name='Lorentzian', center=-1.0, width=0.2, area=1.0)\n", + "delta = edyn.DeltaFunction(display_name='Delta', center=0.4, area=0.5)\n", "sample_components.append_component(gaussian)\n", "# sample_components.append_component(dho)\n", "sample_components.append_component(lorentzian)\n", "sample_components.append_component(delta)\n", "\n", - "resolution_components = sm.ComponentCollection()\n", - "resolution_gaussian = sm.Gaussian(display_name='Resolution Gaussian', width=0.05, area=0.8)\n", - "resolution_lorentzian = sm.Lorentzian(display_name='Resolution Lorentzian', width=0.05, area=0.2)\n", + "resolution_components = edyn.ComponentCollection()\n", + "resolution_gaussian = edyn.Gaussian(display_name='Resolution Gaussian', width=0.05, area=0.8)\n", + "resolution_lorentzian = edyn.Lorentzian(display_name='Resolution Lorentzian', width=0.05, area=0.2)\n", "resolution_components.append_component(resolution_gaussian)\n", "resolution_components.append_component(resolution_lorentzian)\n", "\n", "energy = np.linspace(-2, 2, 100)\n", "\n", - "convolver = Convolution(\n", + "convolver = edyn.Convolution(\n", " sample_components=sample_components, resolution_components=resolution_components, energy=energy\n", ")\n", "y = convolver.convolution()\n", @@ -66,7 +64,7 @@ "plt.plot(energy, y, label='Convoluted Model')\n", "plt.xlabel('Energy (meV)')\n", "plt.ylabel('Intensity (arb. units)')\n", - "plt.title('Convolution of Sample Model with Resolution Model')\n", + "plt.title('edyn.Convolution of Sample Model with Resolution Model')\n", "\n", "plt.plot(energy, sample_components.evaluate(energy), label='Sample Model', linestyle='--')\n", "plt.plot(energy, resolution_components.evaluate(energy), label='Resolution Model', linestyle=':')\n", @@ -85,19 +83,19 @@ "outputs": [], "source": [ "# Use some of the extra settings for the numerical convolution\n", - "sample_components = sm.ComponentCollection()\n", - "gaussian = sm.Gaussian(display_name='Gaussian', width=0.3, area=1)\n", - "dho = sm.DampedHarmonicOscillator(display_name='DHO', center=1.0, width=0.3, area=2.0)\n", - "lorentzian = sm.Lorentzian(display_name='Lorentzian', center=-1.0, width=0.2, area=1.0)\n", - "delta = sm.DeltaFunction(display_name='Delta', center=0.4, area=0.5)\n", + "sample_components = edyn.ComponentCollection()\n", + "gaussian = edyn.Gaussian(display_name='Gaussian', width=0.3, area=1)\n", + "dho = edyn.DampedHarmonicOscillator(display_name='DHO', center=1.0, width=0.3, area=2.0)\n", + "lorentzian = edyn.Lorentzian(display_name='Lorentzian', center=-1.0, width=0.2, area=1.0)\n", + "delta = edyn.DeltaFunction(display_name='Delta', center=0.4, area=0.5)\n", "sample_components.append_component(gaussian)\n", "sample_components.append_component(dho)\n", "sample_components.append_component(lorentzian)\n", "sample_components.append_component(delta)\n", "\n", - "resolution_components = sm.ComponentCollection()\n", - "resolution_gaussian = sm.Gaussian(display_name='Resolution Gaussian', width=0.15, area=0.8)\n", - "resolution_lorentzian = sm.Lorentzian(display_name='Resolution Lorentzian', width=0.25, area=0.2)\n", + "resolution_components = edyn.ComponentCollection()\n", + "resolution_gaussian = edyn.Gaussian(display_name='Resolution Gaussian', width=0.15, area=0.8)\n", + "resolution_lorentzian = edyn.Lorentzian(display_name='Resolution Lorentzian', width=0.25, area=0.2)\n", "resolution_components.append_component(resolution_gaussian)\n", "resolution_components.append_component(resolution_lorentzian)\n", "\n", @@ -112,7 +110,7 @@ "plt.xlabel('Energy (meV)')\n", "plt.ylabel('Intensity (arb. units)')\n", "\n", - "convolver = Convolution(\n", + "convolver = edyn.Convolution(\n", " sample_components=sample_components,\n", " resolution_components=resolution_components,\n", " energy=energy - energy_offset,\n", @@ -130,13 +128,13 @@ "plt.plot(\n", " energy,\n", " sample_components.evaluate(energy - energy_offset)\n", - " * detailed_balance_factor(energy - energy_offset, temperature),\n", + " * edyn.detailed_balance_factor(energy - energy_offset, temperature),\n", " label='Sample Model with DB',\n", " linestyle='--',\n", ")\n", "\n", "plt.plot(energy, resolution_components.evaluate(energy), label='Resolution Model', linestyle=':')\n", - "plt.title('Convolution of Sample Model with Resolution Model with detailed balancing')\n", + "plt.title('edyn.Convolution of Sample Model with Resolution Model with detailed balancing')\n", "\n", "plt.legend()\n", "plt.ylim(0, 2.5)\n", @@ -151,19 +149,19 @@ "outputs": [], "source": [ "# Use some of the extra settings for the numerical convolution\n", - "sample_components = sm.ComponentCollection()\n", - "gaussian = sm.Gaussian(display_name='Gaussian', width=0.3, area=1)\n", - "dho = sm.DampedHarmonicOscillator(display_name='DHO', center=1.0, width=0.3, area=2.0)\n", - "lorentzian = sm.Lorentzian(display_name='Lorentzian', center=-1.0, width=0.2, area=1.0)\n", - "delta = sm.DeltaFunction(display_name='Delta', center=0.4, area=0.5)\n", + "sample_components = edyn.ComponentCollection()\n", + "gaussian = edyn.Gaussian(display_name='Gaussian', width=0.3, area=1)\n", + "dho = edyn.DampedHarmonicOscillator(display_name='DHO', center=1.0, width=0.3, area=2.0)\n", + "lorentzian = edyn.Lorentzian(display_name='Lorentzian', center=-1.0, width=0.2, area=1.0)\n", + "delta = edyn.DeltaFunction(display_name='Delta', center=0.4, area=0.5)\n", "sample_components.append_component(gaussian)\n", "# sample_components.append_component(dho)\n", "sample_components.append_component(lorentzian)\n", "# sample_components.append_component(delta)\n", "\n", - "resolution_components = sm.ComponentCollection()\n", - "resolution_gaussian = sm.Gaussian(display_name='Resolution Gaussian', width=0.15, area=0.8)\n", - "resolution_lorentzian = sm.Lorentzian(display_name='Resolution Lorentzian', width=0.25, area=0.2)\n", + "resolution_components = edyn.ComponentCollection()\n", + "resolution_gaussian = edyn.Gaussian(display_name='Resolution Gaussian', width=0.15, area=0.8)\n", + "resolution_lorentzian = edyn.Lorentzian(display_name='Resolution Lorentzian', width=0.25, area=0.2)\n", "resolution_components.append_component(resolution_gaussian)\n", "# resolution_components.append_component(resolution_lorentzian)\n", "\n", @@ -178,7 +176,7 @@ "plt.xlabel('Energy (meV)')\n", "plt.ylabel('Intensity (arb. units)')\n", "\n", - "convolver = Convolution(\n", + "convolver = edyn.Convolution(\n", " sample_components=sample_components,\n", " resolution_components=resolution_components,\n", " energy=energy,\n", @@ -200,7 +198,7 @@ ")\n", "\n", "plt.plot(energy, resolution_components.evaluate(energy), label='Resolution Model', linestyle=':')\n", - "plt.title('Convolution of Sample Model with Resolution Model')\n", + "plt.title('edyn.Convolution of Sample Model with Resolution Model')\n", "\n", "plt.legend()\n", "plt.ylim(0, 2.5)\n", diff --git a/docs/docs/tutorials/delta_lorentz.ipynb b/docs/docs/tutorials/delta_lorentz.ipynb index d676ddf85..47cf6bd90 100644 --- a/docs/docs/tutorials/delta_lorentz.ipynb +++ b/docs/docs/tutorials/delta_lorentz.ipynb @@ -29,7 +29,7 @@ "import matplotlib.pyplot as plt\n", "import numpy as np\n", "\n", - "import easydynamics.sample_model as sm\n", + "import easydynamics as edyn\n", "\n", "%matplotlib widget" ] @@ -48,7 +48,7 @@ "A_0 = 0.01\n", "lorentzian_width = 0.2\n", "\n", - "diffusion_model = sm.DeltaLorentz(\n", + "diffusion_model = edyn.DeltaLorentz(\n", " scale=scale,\n", " mean_u_squared=mean_u_squared,\n", " A_0=A_0,\n", diff --git a/docs/docs/tutorials/detailed_balance.ipynb b/docs/docs/tutorials/detailed_balance.ipynb index bd6fccce3..0b57b18c8 100644 --- a/docs/docs/tutorials/detailed_balance.ipynb +++ b/docs/docs/tutorials/detailed_balance.ipynb @@ -25,7 +25,7 @@ "import matplotlib.pyplot as plt\n", "import numpy as np\n", "\n", - "from easydynamics.utils import detailed_balance_factor\n", + "import easydynamics as edyn\n", "\n", "%matplotlib widget" ] @@ -45,7 +45,7 @@ "\n", "plt.figure()\n", "for temperature in temperatures:\n", - " DBF = detailed_balance_factor(energy, temperature, energy_unit, temperature_unit)\n", + " DBF = edyn.detailed_balance_factor(energy, temperature, energy_unit, temperature_unit)\n", " plt.plot(energy, DBF, label=f'T={temperature} K')\n", "plt.legend()\n", "plt.xlabel('Energy transfer (meV)')\n", @@ -72,7 +72,7 @@ "\n", "plt.figure()\n", "for temperature in temperatures:\n", - " DBF = detailed_balance_factor(\n", + " DBF = edyn.detailed_balance_factor(\n", " energy, temperature, energy_unit, temperature_unit, divide_by_temperature=False\n", " )\n", " plt.plot(energy, DBF, label=f'T={temperature} K')\n", diff --git a/docs/docs/tutorials/diffusion_model.ipynb b/docs/docs/tutorials/diffusion_model.ipynb index ffc26cc09..e3d613284 100644 --- a/docs/docs/tutorials/diffusion_model.ipynb +++ b/docs/docs/tutorials/diffusion_model.ipynb @@ -19,7 +19,7 @@ "import matplotlib.pyplot as plt\n", "import numpy as np\n", "\n", - "import easydynamics.sample_model as sm\n", + "import easydynamics as edyn\n", "\n", "%matplotlib widget" ] @@ -40,7 +40,7 @@ "scale = 1.0\n", "diffusion_coefficient = 2.4e-9 # m^2/s\n", "\n", - "diffusion_model = sm.BrownianTranslationalDiffusion(\n", + "diffusion_model = edyn.BrownianTranslationalDiffusion(\n", " display_name='DiffusionModel', scale=scale, diffusion_coefficient=diffusion_coefficient, Q=Q\n", ")\n", "\n", diff --git a/docs/docs/tutorials/instrument_model.ipynb b/docs/docs/tutorials/instrument_model.ipynb index 99e05545a..4f30fad44 100644 --- a/docs/docs/tutorials/instrument_model.ipynb +++ b/docs/docs/tutorials/instrument_model.ipynb @@ -21,7 +21,7 @@ "source": [ "import numpy as np\n", "\n", - "import easydynamics.sample_model as sm\n", + "import easydynamics as edyn\n", "\n", "%matplotlib widget" ] @@ -38,13 +38,13 @@ "\n", "Q = np.linspace(0.1, 2.0, 5)\n", "\n", - "background_model = sm.BackgroundModel()\n", - "background_model.components = sm.Polynomial(coefficients=[1, 0.1, 0.01])\n", + "background_model = edyn.BackgroundModel()\n", + "background_model.components = edyn.Polynomial(coefficients=[1, 0.1, 0.01])\n", "\n", - "resolution_model = sm.ResolutionModel()\n", - "resolution_model.append_component(sm.Gaussian(width=0.05))\n", + "resolution_model = edyn.ResolutionModel()\n", + "resolution_model.append_component(edyn.Gaussian(width=0.05))\n", "\n", - "instrument_model = sm.InstrumentModel(\n", + "instrument_model = edyn.InstrumentModel(\n", " Q=Q,\n", " resolution_model=resolution_model,\n", " background_model=background_model,\n", diff --git a/docs/docs/tutorials/sample_model.ipynb b/docs/docs/tutorials/sample_model.ipynb index 0edad8e3e..ca42289f4 100644 --- a/docs/docs/tutorials/sample_model.ipynb +++ b/docs/docs/tutorials/sample_model.ipynb @@ -23,7 +23,7 @@ "import matplotlib.pyplot as plt\n", "import numpy as np\n", "\n", - "import easydynamics.sample_model as sm\n", + "import easydynamics as edyn\n", "\n", "%matplotlib widget" ] @@ -41,7 +41,7 @@ "\n", "scale = 1.0\n", "diffusion_coefficient = 2.4e-9 # m^2/s\n", - "diffusion_model = sm.BrownianTranslationalDiffusion(\n", + "diffusion_model = edyn.BrownianTranslationalDiffusion(\n", " display_name='DiffusionModel',\n", " scale=scale,\n", " diffusion_coefficient=diffusion_coefficient,\n", @@ -49,15 +49,15 @@ "\n", "\n", "# Creating components\n", - "component_collection = sm.ComponentCollection()\n", - "gaussian = sm.Gaussian(display_name='Gaussian', width=0.2, area=1, center=1.5)\n", - "dho = sm.DampedHarmonicOscillator(display_name='DHO', center=1.0, width=0.3, area=2.0)\n", + "component_collection = edyn.ComponentCollection()\n", + "gaussian = edyn.Gaussian(display_name='Gaussian', width=0.2, area=1, center=1.5)\n", + "dho = edyn.DampedHarmonicOscillator(display_name='DHO', center=1.0, width=0.3, area=2.0)\n", "\n", "# Adding components to the component collection\n", "component_collection.append_component(gaussian)\n", "component_collection.append_component(dho)\n", "\n", - "sample_model = sm.SampleModel(\n", + "sample_model = edyn.SampleModel(\n", " diffusion_models=diffusion_model,\n", " components=component_collection,\n", " Q=Q,\n", @@ -89,17 +89,17 @@ "source": [ "# Create a BackgroundModel and show other ways to set Q and components\n", "\n", - "background_model = sm.BackgroundModel()\n", + "background_model = edyn.BackgroundModel()\n", "background_model.Q = Q\n", "\n", - "background_model.components = sm.Polynomial(coefficients=[1, 0.1, 0.01])\n", + "background_model.components = edyn.Polynomial(coefficients=[1, 0.1, 0.01])\n", "background = background_model.evaluate(energy)\n", "\n", "# Also create a ResolutionModel.\n", "# It doesn't do anything here, but shows how to set it up.\n", - "resolution_model = sm.ResolutionModel()\n", + "resolution_model = edyn.ResolutionModel()\n", "resolution_model.Q = Q\n", - "resolution_model.append_component(sm.Gaussian(width=0.05))\n", + "resolution_model.append_component(edyn.Gaussian(width=0.05))\n", "resolution = resolution_model.evaluate(energy)" ] }, diff --git a/docs/docs/tutorials/tutorial0_basics.ipynb b/docs/docs/tutorials/tutorial0_basics.ipynb index 335e2ac0c..45f83061d 100644 --- a/docs/docs/tutorials/tutorial0_basics.ipynb +++ b/docs/docs/tutorials/tutorial0_basics.ipynb @@ -23,7 +23,6 @@ "import scipp as sc\n", "\n", "import easydynamics as edyn\n", - "import easydynamics.sample_model as sm\n", "\n", "# Make the plots interactive\n", "%matplotlib widget" @@ -132,7 +131,7 @@ "metadata": {}, "outputs": [], "source": [ - "gaussian = sm.Gaussian(name='Gaussian', area=1, width=0.05)" + "gaussian = edyn.Gaussian(name='Gaussian', area=1, width=0.05)" ] }, { @@ -171,7 +170,7 @@ "metadata": {}, "outputs": [], "source": [ - "model = sm.SampleModel(components=gaussian)" + "model = edyn.SampleModel(components=gaussian)" ] }, { @@ -409,7 +408,7 @@ "metadata": {}, "outputs": [], "source": [ - "fit_func = sm.Polynomial(\n", + "fit_func = edyn.Polynomial(\n", " coefficients=[3.7, -0.5],\n", " x_unit='1/angstrom',\n", " y_unit='meV',\n", diff --git a/docs/docs/tutorials/tutorial0_more_advanced.ipynb b/docs/docs/tutorials/tutorial0_more_advanced.ipynb index 4bbcdf22e..f8fe2250b 100644 --- a/docs/docs/tutorials/tutorial0_more_advanced.ipynb +++ b/docs/docs/tutorials/tutorial0_more_advanced.ipynb @@ -22,7 +22,6 @@ "import pooch\n", "\n", "import easydynamics as edyn\n", - "import easydynamics.sample_model as sm\n", "\n", "# Make the plots interactive\n", "%matplotlib widget" @@ -73,11 +72,11 @@ "metadata": {}, "outputs": [], "source": [ - "gaussian = sm.Gaussian(name='Gaussian', area=3, width=0.05)\n", - "lorentzian = sm.Lorentzian(name='Lorentzian', area=2, width=0.3)\n", - "dho = sm.DampedHarmonicOscillator(name='DHO', area=1.5, width=0.2, center=1.5)\n", + "gaussian = edyn.Gaussian(name='Gaussian', area=3, width=0.05)\n", + "lorentzian = edyn.Lorentzian(name='Lorentzian', area=2, width=0.3)\n", + "dho = edyn.DampedHarmonicOscillator(name='DHO', area=1.5, width=0.2, center=1.5)\n", "\n", - "collection = sm.ComponentCollection()\n", + "collection = edyn.ComponentCollection()\n", "collection.append_component(gaussian)\n", "collection.append_component(lorentzian)\n", "collection.append_component(dho)" @@ -104,7 +103,7 @@ "metadata": {}, "outputs": [], "source": [ - "model = sm.SampleModel(components=collection)" + "model = edyn.SampleModel(components=collection)" ] }, { @@ -130,7 +129,7 @@ "metadata": {}, "outputs": [], "source": [ - "instrument = sm.InstrumentModel(energy_offset=0.05)" + "instrument = edyn.InstrumentModel(energy_offset=0.05)" ] }, { @@ -161,7 +160,7 @@ "metadata": {}, "outputs": [], "source": [ - "background = sm.BackgroundModel(components=sm.Polynomial(coefficients=[1.2, 0.05]))" + "background = edyn.BackgroundModel(components=edyn.Polynomial(coefficients=[1.2, 0.05]))" ] }, { @@ -329,13 +328,13 @@ "metadata": {}, "outputs": [], "source": [ - "gauss_fit_func = sm.Polynomial(\n", + "gauss_fit_func = edyn.Polynomial(\n", " coefficients=[3.7, -0.5], x_unit='1/angstrom', y_unit='meV', name='Gauss area fit'\n", ")\n", - "dho_area_fit_func = sm.Polynomial(\n", + "dho_area_fit_func = edyn.Polynomial(\n", " coefficients=[2.0, 0.12], x_unit='1/angstrom', y_unit='meV', name='DHO area fit'\n", ")\n", - "dho_center_fit_func = sm.Polynomial(\n", + "dho_center_fit_func = edyn.Polynomial(\n", " coefficients=[1.1, 0.2], x_unit='1/angstrom', y_unit='meV', name='DHO center fit'\n", ")\n", "\n", diff --git a/docs/docs/tutorials/tutorial1_brownian.ipynb b/docs/docs/tutorials/tutorial1_brownian.ipynb index 3c902ea16..c5cf5e9bb 100644 --- a/docs/docs/tutorials/tutorial1_brownian.ipynb +++ b/docs/docs/tutorials/tutorial1_brownian.ipynb @@ -22,7 +22,6 @@ "import pooch\n", "\n", "import easydynamics as edyn\n", - "import easydynamics.sample_model as sm\n", "\n", "# Make the plots interactive\n", "%matplotlib widget" @@ -112,10 +111,10 @@ "metadata": {}, "outputs": [], "source": [ - "vanadium_components = sm.ComponentCollection()\n", - "res_gauss = sm.Gaussian(width=0.1, area=1, name='Res. Gauss')\n", + "vanadium_components = edyn.ComponentCollection()\n", + "res_gauss = edyn.Gaussian(width=0.1, area=1, name='Res. Gauss')\n", "vanadium_components.append_component(res_gauss)\n", - "vanadium_model = sm.SampleModel(components=vanadium_components)" + "vanadium_model = edyn.SampleModel(components=vanadium_components)" ] }, { @@ -133,7 +132,7 @@ "metadata": {}, "outputs": [], "source": [ - "background_model = sm.BackgroundModel(components=sm.Polynomial(coefficients=[0.001]))" + "background_model = edyn.BackgroundModel(components=edyn.Polynomial(coefficients=[0.001]))" ] }, { @@ -151,7 +150,7 @@ "metadata": {}, "outputs": [], "source": [ - "instrument_model = sm.InstrumentModel(\n", + "instrument_model = edyn.InstrumentModel(\n", " background_model=background_model,\n", ")" ] @@ -319,17 +318,17 @@ "metadata": {}, "outputs": [], "source": [ - "delta_function = sm.DeltaFunction(name='DeltaFunction', area=0.2)\n", - "lorentzian = sm.Lorentzian(name='Lorentzian', area=0.5, width=0.3)\n", - "component_collection = sm.ComponentCollection(\n", + "delta_function = edyn.DeltaFunction(name='DeltaFunction', area=0.2)\n", + "lorentzian = edyn.Lorentzian(name='Lorentzian', area=0.5, width=0.3)\n", + "component_collection = edyn.ComponentCollection(\n", " components=[delta_function, lorentzian],\n", ")\n", "\n", - "sample_model = sm.SampleModel(\n", + "sample_model = edyn.SampleModel(\n", " components=component_collection,\n", ")\n", "\n", - "background_model = sm.BackgroundModel(components=sm.Polynomial(coefficients=[0.001]))" + "background_model = edyn.BackgroundModel(components=edyn.Polynomial(coefficients=[0.001]))" ] }, { @@ -347,7 +346,7 @@ "metadata": {}, "outputs": [], "source": [ - "instrument_model = sm.InstrumentModel(\n", + "instrument_model = edyn.InstrumentModel(\n", " background_model=background_model,\n", " resolution_model=vanadium_analysis.sample_model,\n", ")\n", @@ -461,7 +460,7 @@ "metadata": {}, "outputs": [], "source": [ - "brownian_diffusion_model = sm.BrownianTranslationalDiffusion(\n", + "brownian_diffusion_model = edyn.BrownianTranslationalDiffusion(\n", " name='Brownian Translational Diffusion',\n", " lorentzian_name='Lorentzian',\n", " diffusion_coefficient=2.4e-9,\n", @@ -615,20 +614,20 @@ "metadata": {}, "outputs": [], "source": [ - "delta_function = sm.DeltaFunction(name='DeltaFunction', area=0.2)\n", - "component_collection = sm.ComponentCollection(\n", + "delta_function = edyn.DeltaFunction(name='DeltaFunction', area=0.2)\n", + "component_collection = edyn.ComponentCollection(\n", " components=[delta_function],\n", ")\n", - "diffusion_model = sm.BrownianTranslationalDiffusion(\n", + "diffusion_model = edyn.BrownianTranslationalDiffusion(\n", " name='Brownian Translational Diffusion', diffusion_coefficient=2.4e-9, scale=0.5\n", ")\n", "\n", - "sample_model = sm.SampleModel(\n", + "sample_model = edyn.SampleModel(\n", " components=component_collection,\n", " diffusion_models=diffusion_model,\n", ")\n", "\n", - "background_model = sm.BackgroundModel(components=sm.Polynomial(coefficients=[0.001]))" + "background_model = edyn.BackgroundModel(components=edyn.Polynomial(coefficients=[0.001]))" ] }, { @@ -638,7 +637,7 @@ "metadata": {}, "outputs": [], "source": [ - "instrument_model = sm.InstrumentModel(\n", + "instrument_model = edyn.InstrumentModel(\n", " background_model=background_model,\n", " resolution_model=vanadium_analysis.sample_model,\n", ")" diff --git a/docs/docs/tutorials/tutorial2_nanoparticles.ipynb b/docs/docs/tutorials/tutorial2_nanoparticles.ipynb index cca884bb2..c74f1f135 100644 --- a/docs/docs/tutorials/tutorial2_nanoparticles.ipynb +++ b/docs/docs/tutorials/tutorial2_nanoparticles.ipynb @@ -43,8 +43,6 @@ "import scipp as sc\n", "\n", "import easydynamics as edyn\n", - "import easydynamics.sample_model as sm\n", - "from easydynamics.utils.utils import hbar\n", "\n", "# Make the plots interactive\n", "%matplotlib widget" @@ -135,20 +133,20 @@ "metadata": {}, "outputs": [], "source": [ - "res_sample_model = sm.SampleModel()\n", - "res_components = sm.ComponentCollection()\n", - "res_gauss = sm.Gaussian(area=40, width=0.02)\n", + "res_sample_model = edyn.SampleModel()\n", + "res_components = edyn.ComponentCollection()\n", + "res_gauss = edyn.Gaussian(area=40, width=0.02)\n", "\n", "res_components.append_component(res_gauss)\n", "res_sample_model.components = res_components\n", "\n", - "background_model = sm.BackgroundModel()\n", - "polynomial = sm.Polynomial(coefficients=[1.5])\n", + "background_model = edyn.BackgroundModel()\n", + "polynomial = edyn.Polynomial(coefficients=[1.5])\n", "polynomial.coefficients[0].min = 0.0\n", "background_model.components = polynomial\n", "\n", "\n", - "res_instrument_model = sm.InstrumentModel(\n", + "res_instrument_model = edyn.InstrumentModel(\n", " background_model=background_model,\n", ")\n", "\n", @@ -246,21 +244,21 @@ "metadata": {}, "outputs": [], "source": [ - "sample_model = sm.SampleModel()\n", - "water_delta_function = sm.DeltaFunction(name='Water delta function', area=100)\n", - "water_lorentzian = sm.Lorentzian(name='Water Lorentzian', area=10, width=0.2)\n", + "sample_model = edyn.SampleModel()\n", + "water_delta_function = edyn.DeltaFunction(name='Water delta function', area=100)\n", + "water_lorentzian = edyn.Lorentzian(name='Water Lorentzian', area=10, width=0.2)\n", "sample_model.append_component(water_delta_function)\n", "sample_model.append_component(water_lorentzian)\n", "sample_model.temperature = 150\n", "\n", "\n", - "background_model = sm.BackgroundModel()\n", - "polynomial = sm.Polynomial(name='Polynomial', coefficients=[0.15])\n", + "background_model = edyn.BackgroundModel()\n", + "polynomial = edyn.Polynomial(name='Polynomial', coefficients=[0.15])\n", "polynomial.coefficients[0].min = 0.0\n", "background_model.components = polynomial\n", "\n", "\n", - "instrument_model = sm.InstrumentModel(\n", + "instrument_model = edyn.InstrumentModel(\n", " background_model=background_model,\n", " resolution_model=res_analysis.sample_model,\n", ")\n", @@ -380,25 +378,25 @@ "outputs": [], "source": [ "# Now make a new analysis with this sample model\n", - "mag_sample_model = sm.SampleModel()\n", - "water_delta_function = sm.DeltaFunction(name='Water delta function', area=100)\n", - "water_lorentzian = sm.Lorentzian(name='Water Lorentzian', area=100, width=0.2)\n", + "mag_sample_model = edyn.SampleModel()\n", + "water_delta_function = edyn.DeltaFunction(name='Water delta function', area=100)\n", + "water_lorentzian = edyn.Lorentzian(name='Water Lorentzian', area=100, width=0.2)\n", "mag_sample_model.append_component(water_delta_function)\n", "mag_sample_model.append_component(water_lorentzian)\n", "\n", "# Add all the magnetic components\n", - "DHO1 = sm.DampedHarmonicOscillator(name='DHO1', area=5, center=0.35, width=0.2)\n", - "DHO2 = sm.DampedHarmonicOscillator(name='DHO2', area=1, center=1.1, width=0.1)\n", - "mag_lorz = sm.Lorentzian(name='Magnetic Lorentzian', area=30, width=0.01)\n", + "DHO1 = edyn.DampedHarmonicOscillator(name='DHO1', area=5, center=0.35, width=0.2)\n", + "DHO2 = edyn.DampedHarmonicOscillator(name='DHO2', area=1, center=1.1, width=0.1)\n", + "mag_lorz = edyn.Lorentzian(name='Magnetic Lorentzian', area=30, width=0.01)\n", "mag_sample_model.append_component(DHO1)\n", "mag_sample_model.append_component(DHO2)\n", "mag_sample_model.append_component(mag_lorz)\n", "\n", - "background_model = sm.BackgroundModel()\n", - "polynomial = sm.Polynomial(name='Polynomial', coefficients=[0.15])\n", + "background_model = edyn.BackgroundModel()\n", + "polynomial = edyn.Polynomial(name='Polynomial', coefficients=[0.15])\n", "background_model.components = polynomial\n", "\n", - "instrument_model = sm.InstrumentModel(\n", + "instrument_model = edyn.InstrumentModel(\n", " background_model=background_model,\n", " resolution_model=res_analysis.sample_model,\n", ")\n", @@ -544,7 +542,7 @@ "print(width1)\n", "print(width2)\n", "print(width)\n", - "tau = hbar / width\n", + "tau = edyn.hbar / width\n", "tau.convert_unit('ns')\n", "print(tau)" ] diff --git a/src/easydynamics/__init__.py b/src/easydynamics/__init__.py index f4c956e5b..0b8e27cbd 100644 --- a/src/easydynamics/__init__.py +++ b/src/easydynamics/__init__.py @@ -1,19 +1,91 @@ # SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""EasyDynamics library.""" +""" +EasyDynamics library. + +Everything public is re-exported here, so ``import easydynamics as edyn`` reaches all of it and a +reader never has to look up which sub-package a name came from. The sub-packages remain importable +for anyone who prefers them; this is only the front door. +""" from easydynamics.analysis import Analysis +from easydynamics.analysis import BoundsSuggestion +from easydynamics.analysis import BoundsSuggestions +from easydynamics.analysis import MultiQPosteriorSampler +from easydynamics.analysis import ParameterAnalysis +from easydynamics.analysis import ParameterLabels +from easydynamics.analysis import ParameterPosterior +from easydynamics.analysis import PosteriorSampler +from easydynamics.analysis import PosteriorSummary +from easydynamics.analysis.analysis1d import Analysis1d from easydynamics.analysis.fit_binding import FitBinding -from easydynamics.analysis.parameter_analysis import ParameterAnalysis +from easydynamics.base_classes import EasyDynamicsBase +from easydynamics.base_classes import EasyDynamicsModelBase +from easydynamics.convolution import Convolution from easydynamics.experiment import Experiment -from easydynamics.settings.convolution_settings import ConvolutionSettings -from easydynamics.settings.detailed_balance_settings import DetailedBalanceSettings +from easydynamics.sample_model import BackgroundModel +from easydynamics.sample_model import BrownianTranslationalDiffusion +from easydynamics.sample_model import ComponentCollection +from easydynamics.sample_model import DampedHarmonicOscillator +from easydynamics.sample_model import DeltaFunction +from easydynamics.sample_model import DeltaLorentz +from easydynamics.sample_model import Exponential +from easydynamics.sample_model import ExpressionComponent +from easydynamics.sample_model import Gaussian +from easydynamics.sample_model import InstrumentModel +from easydynamics.sample_model import JumpTranslationalDiffusion +from easydynamics.sample_model import Lorentzian +from easydynamics.sample_model import Polynomial +from easydynamics.sample_model import ResolutionModel +from easydynamics.sample_model import SampleModel +from easydynamics.sample_model import Voigt +from easydynamics.settings import ConvolutionSettings +from easydynamics.settings import DetailedBalanceSettings +from easydynamics.utils import detailed_balance_factor +from easydynamics.utils import plot_corner +from easydynamics.utils import plot_posterior_predictive +from easydynamics.utils import plot_trace +from easydynamics.utils import slicerplot_with_residuals +from easydynamics.utils.utils import hbar __all__ = [ 'Analysis', + 'Analysis1d', + 'BackgroundModel', + 'BoundsSuggestion', + 'BoundsSuggestions', + 'BrownianTranslationalDiffusion', + 'ComponentCollection', + 'Convolution', 'ConvolutionSettings', + 'DampedHarmonicOscillator', + 'DeltaFunction', + 'DeltaLorentz', 'DetailedBalanceSettings', + 'EasyDynamicsBase', + 'EasyDynamicsModelBase', 'Experiment', + 'Exponential', + 'ExpressionComponent', 'FitBinding', + 'Gaussian', + 'InstrumentModel', + 'JumpTranslationalDiffusion', + 'Lorentzian', + 'MultiQPosteriorSampler', 'ParameterAnalysis', + 'ParameterLabels', + 'ParameterPosterior', + 'Polynomial', + 'PosteriorSampler', + 'PosteriorSummary', + 'ResolutionModel', + 'SampleModel', + 'Voigt', + 'detailed_balance_factor', + 'hbar', + 'plot_corner', + 'plot_posterior_predictive', + 'plot_trace', + 'slicerplot_with_residuals', ] diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index 46645afa1..89af3fc87 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -45,7 +45,6 @@ class Analysis(AnalysisBase): ```python import pooch import easydynamics as edyn - import easydynamics.sample_model as sm file_path = pooch.retrieve( url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/master/docs/docs/tutorials/data/vanadium_data_example.h5', @@ -54,10 +53,10 @@ class Analysis(AnalysisBase): experiment = edyn.Experiment('Vanadium') experiment.load_hdf5(filename=file_path) - sample_model = sm.SampleModel(components=sm.DeltaFunction(area=1)) - resolution_model = sm.ResolutionModel(components=sm.Gaussian(width=0.1)) - background_model = sm.BackgroundModel(components=sm.Polynomial(coefficients=[0.001])) - instrument_model = sm.InstrumentModel( + sample_model = edyn.SampleModel(components=edyn.DeltaFunction(area=1)) + resolution_model = edyn.ResolutionModel(components=edyn.Gaussian(width=0.1)) + background_model = edyn.BackgroundModel(components=edyn.Polynomial(coefficients=[0.001])) + instrument_model = edyn.InstrumentModel( resolution_model=resolution_model, background_model=background_model, ) diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index 5c8a68be3..2814e269d 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -45,8 +45,6 @@ class Analysis1d(AnalysisBase): ```python import pooch import easydynamics as edyn - import easydynamics.sample_model as sm - from easydynamics.analysis.analysis1d import Analysis1d file_path = pooch.retrieve( url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/master/docs/docs/tutorials/data/vanadium_data_example.h5', @@ -55,15 +53,15 @@ class Analysis1d(AnalysisBase): experiment = edyn.Experiment('Vanadium') experiment.load_hdf5(filename=file_path) - sample_model = sm.SampleModel(components=sm.DeltaFunction(area=1)) - resolution_model = sm.ResolutionModel(components=sm.Gaussian(width=0.1)) - background_model = sm.BackgroundModel(components=sm.Polynomial(coefficients=[0.001])) - instrument_model = sm.InstrumentModel( + sample_model = edyn.SampleModel(components=edyn.DeltaFunction(area=1)) + resolution_model = edyn.ResolutionModel(components=edyn.Gaussian(width=0.1)) + background_model = edyn.BackgroundModel(components=edyn.Polynomial(coefficients=[0.001])) + instrument_model = edyn.InstrumentModel( resolution_model=resolution_model, background_model=background_model, ) - analysis = Analysis1d( + analysis = edyn.Analysis1d( display_name='Vanadium 1D Analysis', experiment=experiment, sample_model=sample_model, diff --git a/src/easydynamics/analysis/fit_binding.py b/src/easydynamics/analysis/fit_binding.py index 2b3cf7d39..58a190f90 100644 --- a/src/easydynamics/analysis/fit_binding.py +++ b/src/easydynamics/analysis/fit_binding.py @@ -32,9 +32,8 @@ class FitBinding(EasyDynamicsBase): values): ```python import easydynamics as edyn - import easydynamics.sample_model as sm - fit_func = sm.Polynomial( + fit_func = edyn.Polynomial( coefficients=[3.7, -0.5], x_unit='1/angstrom', y_unit='meV', @@ -49,7 +48,7 @@ class FitBinding(EasyDynamicsBase): ``'delta_area'``). With ``targets=None`` all predictions are fitted against default dataset keys derived from the model's component names: ```python - brownian = sm.BrownianTranslationalDiffusion( + brownian = edyn.BrownianTranslationalDiffusion( diffusion_coefficient=2.4e-9, scale=0.5, lorentzian_name='Lorentzian', @@ -63,7 +62,7 @@ class FitBinding(EasyDynamicsBase): ```python binding = edyn.FitBinding(model=brownian, targets=['width']) - delta_lorentz = sm.DeltaLorentz(A_0=0.5, lorentzian_width=0.1) + delta_lorentz = edyn.DeltaLorentz(A_0=0.5, lorentzian_width=0.1) binding = edyn.FitBinding( model=delta_lorentz, targets={ diff --git a/src/easydynamics/analysis/parameter_analysis.py b/src/easydynamics/analysis/parameter_analysis.py index 1ac496842..ffe5fd2cf 100644 --- a/src/easydynamics/analysis/parameter_analysis.py +++ b/src/easydynamics/analysis/parameter_analysis.py @@ -41,10 +41,9 @@ class ParameterAnalysis(EasyDynamicsModelBase): dataset keys using a ``FitBinding``: ```python import easydynamics as edyn - import easydynamics.sample_model as sm # analysis is an edyn.Analysis object with previously fitted parameters - diffusion_model = sm.BrownianTranslationalDiffusion(diffusion_coefficient=2.4e-9, scale=0.5) + diffusion_model = edyn.BrownianTranslationalDiffusion(diffusion_coefficient=2.4e-9, scale=0.5) binding = edyn.FitBinding( model=diffusion_model, targets={'width': 'Lorentzian width'}, @@ -65,7 +64,7 @@ class ParameterAnalysis(EasyDynamicsModelBase): (or pass ``x_unit=None`` / ``y_unit=None`` to fit raw values): ```python area_binding = edyn.FitBinding( - model=sm.Polynomial(coefficients=[0.5, 0.0], x_unit='1/angstrom', y_unit='meV'), + model=edyn.Polynomial(coefficients=[0.5, 0.0], x_unit='1/angstrom', y_unit='meV'), targets='Lorentzian area', ) param_analysis = edyn.ParameterAnalysis( diff --git a/src/easydynamics/convolution/convolution.py b/src/easydynamics/convolution/convolution.py index a2da80a20..578df564f 100644 --- a/src/easydynamics/convolution/convolution.py +++ b/src/easydynamics/convolution/convolution.py @@ -42,16 +42,15 @@ class Convolution(NumericalConvolutionBase): ``Gaussian``, ``Lorentzian``, or ``Voigt``: ```python import numpy as np - import easydynamics.sample_model as sm - from easydynamics.convolution import Convolution + import easydynamics as edyn - sample_components = sm.ComponentCollection( - components=[sm.DeltaFunction(area=0.5), sm.Lorentzian(area=1.0, width=0.3)] + sample_components = edyn.ComponentCollection( + components=[edyn.DeltaFunction(area=0.5), edyn.Lorentzian(area=1.0, width=0.3)] ) - resolution_components = sm.ComponentCollection(components=[sm.Gaussian(width=0.05)]) + resolution_components = edyn.ComponentCollection(components=[edyn.Gaussian(width=0.05)]) energy = np.linspace(-2, 2, 100) - convolver = Convolution( + convolver = edyn.Convolution( sample_components=sample_components, resolution_components=resolution_components, energy=energy, diff --git a/src/easydynamics/sample_model/background_model.py b/src/easydynamics/sample_model/background_model.py index 031d9493c..0699c34c9 100644 --- a/src/easydynamics/sample_model/background_model.py +++ b/src/easydynamics/sample_model/background_model.py @@ -20,11 +20,11 @@ class BackgroundModel(ModelBase): A constant background independent of Q: ```python import numpy as np - import easydynamics.sample_model as sm + import easydynamics as edyn Q = np.linspace(0.5, 2, 7) - background_model = sm.BackgroundModel( - components=sm.Polynomial(coefficients=[0.001]), + background_model = edyn.BackgroundModel( + components=edyn.Polynomial(coefficients=[0.001]), Q=Q, ) energy = np.linspace(-2, 2, 100) @@ -35,10 +35,10 @@ class BackgroundModel(ModelBase): Higher-order polynomials can model a sloping or curved baseline: ```python - import easydynamics.sample_model as sm + import easydynamics as edyn - background_model = sm.BackgroundModel( - components=sm.Polynomial(coefficients=[1.0, 0.1, 0.01]), + background_model = edyn.BackgroundModel( + components=edyn.Polynomial(coefficients=[1.0, 0.1, 0.01]), ) ``` """ diff --git a/src/easydynamics/sample_model/component_collection.py b/src/easydynamics/sample_model/component_collection.py index b63135fb8..adf3bc148 100644 --- a/src/easydynamics/sample_model/component_collection.py +++ b/src/easydynamics/sample_model/component_collection.py @@ -33,11 +33,11 @@ class ComponentCollection(EasyDynamicsList, EasyDynamicsModelBase): ```python import numpy as np - import easydynamics.sample_model as sm + import easydynamics as edyn - component1 = sm.Gaussian(name='Gaussian1', area=1.0, width=1.0) - component2 = sm.Lorentzian(name='Lorentzian1', area=2.0, width=0.5) - collection = sm.ComponentCollection(components=[component1, component2]) + component1 = edyn.Gaussian(name='Gaussian1', area=1.0, width=1.0) + component2 = edyn.Lorentzian(name='Lorentzian1', area=2.0, width=0.5) + collection = edyn.ComponentCollection(components=[component1, component2]) ``` **Evaluating, appending, and removing components** @@ -46,7 +46,7 @@ class ComponentCollection(EasyDynamicsList, EasyDynamicsModelBase): x = np.linspace(-5, 5, 100) values = collection.evaluate(x) - component3 = sm.Gaussian(name='Gaussian2', area=0.5, width=0.8) + component3 = edyn.Gaussian(name='Gaussian2', area=0.5, width=0.8) collection.append(component3) collection.remove('Gaussian1') diff --git a/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py b/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py index 90a63562f..707823f5a 100644 --- a/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py +++ b/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py @@ -34,9 +34,9 @@ class DampedHarmonicOscillator(CreateParametersMixin, ModelComponent): (at ±center) are captured by the model: ```python import numpy as np - import easydynamics.sample_model as sm + import easydynamics as edyn - dho = sm.DampedHarmonicOscillator(area=1.0, center=10.0, width=1.0) + dho = edyn.DampedHarmonicOscillator(area=1.0, center=10.0, width=1.0) x = np.linspace(-20, 20, 200) values = dho.evaluate(x) ``` @@ -44,9 +44,9 @@ class DampedHarmonicOscillator(CreateParametersMixin, ModelComponent): **Modifying parameters after construction** ```python - import easydynamics.sample_model as sm + import easydynamics as edyn - dho = sm.DampedHarmonicOscillator(area=2.0, center=5.0, width=0.5, name='Phonon') + dho = edyn.DampedHarmonicOscillator(area=2.0, center=5.0, width=0.5, name='Phonon') dho.area = 3.0 dho.center = 8.0 dho.width = 0.3 diff --git a/src/easydynamics/sample_model/components/delta_function.py b/src/easydynamics/sample_model/components/delta_function.py index 60c343a3d..539f2970f 100644 --- a/src/easydynamics/sample_model/components/delta_function.py +++ b/src/easydynamics/sample_model/components/delta_function.py @@ -37,9 +37,9 @@ class DeltaFunction(CreateParametersMixin, ModelComponent): convolutions, making it useful for modelling the elastic line in QENS: ```python import numpy as np - import easydynamics.sample_model as sm + import easydynamics as edyn - delta = sm.DeltaFunction(area=1.0) + delta = edyn.DeltaFunction(area=1.0) x = np.linspace(-2, 2, 100) values = delta.evaluate(x) # all zeros except at the bin nearest to center ``` @@ -48,9 +48,9 @@ class DeltaFunction(CreateParametersMixin, ModelComponent): Pass a numeric value for ``center`` to place the elastic line at a specific energy transfer: ```python - import easydynamics.sample_model as sm + import easydynamics as edyn - delta = sm.DeltaFunction(area=0.7, center=0.5) + delta = edyn.DeltaFunction(area=0.7, center=0.5) delta.area = 0.5 ``` """ diff --git a/src/easydynamics/sample_model/components/exponential.py b/src/easydynamics/sample_model/components/exponential.py index 08dd3b437..941394b2c 100644 --- a/src/easydynamics/sample_model/components/exponential.py +++ b/src/easydynamics/sample_model/components/exponential.py @@ -28,9 +28,9 @@ class Exponential(CreateParametersMixin, ModelComponent): By default the center is fixed at 0. A negative ``rate`` gives a decaying exponential: ```python import numpy as np - import easydynamics.sample_model as sm + import easydynamics as edyn - exp = sm.Exponential(amplitude=1.0, rate=-0.5) + exp = edyn.Exponential(amplitude=1.0, rate=-0.5) x = np.linspace(0, 5, 100) values = exp.evaluate(x) ``` @@ -39,9 +39,9 @@ class Exponential(CreateParametersMixin, ModelComponent): Pass a numeric value for ``center`` to leave it free during fitting: ```python - import easydynamics.sample_model as sm + import easydynamics as edyn - exp = sm.Exponential(amplitude=2.0, center=1.0, rate=-1.0, name='Background') + exp = edyn.Exponential(amplitude=2.0, center=1.0, rate=-1.0, name='Background') exp.amplitude = 3.0 exp.rate = -0.5 ``` diff --git a/src/easydynamics/sample_model/components/expression_component.py b/src/easydynamics/sample_model/components/expression_component.py index 5fff46b3b..25e261049 100644 --- a/src/easydynamics/sample_model/components/expression_component.py +++ b/src/easydynamics/sample_model/components/expression_component.py @@ -41,9 +41,9 @@ class ExpressionComponent(ModelComponent): construction: ```python import numpy as np - import easydynamics.sample_model as sm + import easydynamics as edyn - expr = sm.ExpressionComponent( + expr = edyn.ExpressionComponent( 'A * exp(-(x - x0)**2 / (2*sigma**2))', parameters={'A': 10, 'x0': 0, 'sigma': 1}, x_unit='meV', @@ -68,7 +68,7 @@ class ExpressionComponent(ModelComponent): the unit of the evaluated expression is derived from the parameter units and x_unit (see ``output_unit``), and a warning is issued if it does not match y_unit: ```python - expr = sm.ExpressionComponent( + expr = edyn.ExpressionComponent( 'A * exp(-(x - x0)**2 / (2*sigma**2))', parameters={'A': 10, 'x0': 0, 'sigma': 1}, parameter_units={'A': '1/meV', 'x0': 'meV', 'sigma': 'meV'}, @@ -82,7 +82,7 @@ class ExpressionComponent(ModelComponent): The symbols ``hbar`` (in meV*s) and ``kb`` (in meV/K) are provided automatically as read-only constants (DescriptorNumbers) when they appear in the expression: ```python - boltzmann = sm.ExpressionComponent( + boltzmann = edyn.ExpressionComponent( 'exp(-x / (kb * T))', parameters={'T': 300.0}, parameter_units={'T': 'K'}, diff --git a/src/easydynamics/sample_model/components/gaussian.py b/src/easydynamics/sample_model/components/gaussian.py index 364e89aba..db9ad082a 100644 --- a/src/easydynamics/sample_model/components/gaussian.py +++ b/src/easydynamics/sample_model/components/gaussian.py @@ -36,9 +36,9 @@ class Gaussian(CreateParametersMixin, ModelComponent): By default the center is fixed at 0, which is the typical setup for a QENS elastic line: ```python import numpy as np - import easydynamics.sample_model as sm + import easydynamics as edyn - g = sm.Gaussian(area=1.0, width=0.5) + g = edyn.Gaussian(area=1.0, width=0.5) x = np.linspace(-2, 2, 100) values = g.evaluate(x) ``` @@ -48,9 +48,9 @@ class Gaussian(CreateParametersMixin, ModelComponent): Pass a numeric value for ``center`` to leave it free during fitting, and use the property setters to update parameter values after construction: ```python - import easydynamics.sample_model as sm + import easydynamics as edyn - g = sm.Gaussian(area=2.0, center=0.5, width=0.3, name='Peak') + g = edyn.Gaussian(area=2.0, center=0.5, width=0.3, name='Peak') g.area = 3.0 g.width = 0.2 ``` diff --git a/src/easydynamics/sample_model/components/lorentzian.py b/src/easydynamics/sample_model/components/lorentzian.py index fe36340fc..6aade54f2 100644 --- a/src/easydynamics/sample_model/components/lorentzian.py +++ b/src/easydynamics/sample_model/components/lorentzian.py @@ -35,9 +35,9 @@ class Lorentzian(CreateParametersMixin, ModelComponent): By default the center is fixed at 0, which is the typical setup for a QENS quasi-elastic line: ```python import numpy as np - import easydynamics.sample_model as sm + import easydynamics as edyn - l = sm.Lorentzian(area=1.0, width=0.3) + l = edyn.Lorentzian(area=1.0, width=0.3) x = np.linspace(-2, 2, 100) values = l.evaluate(x) ``` @@ -46,9 +46,9 @@ class Lorentzian(CreateParametersMixin, ModelComponent): Pass a numeric value for ``center`` to leave it free during fitting: ```python - import easydynamics.sample_model as sm + import easydynamics as edyn - l = sm.Lorentzian(area=2.0, center=0.5, width=0.3, name='QE peak') + l = edyn.Lorentzian(area=2.0, center=0.5, width=0.3, name='QE peak') l.area = 3.0 l.width = 0.2 ``` diff --git a/src/easydynamics/sample_model/components/polynomial.py b/src/easydynamics/sample_model/components/polynomial.py index e30ff96b1..4e592cf62 100644 --- a/src/easydynamics/sample_model/components/polynomial.py +++ b/src/easydynamics/sample_model/components/polynomial.py @@ -33,9 +33,9 @@ class Polynomial(ModelComponent): ```python import numpy as np - import easydynamics.sample_model as sm + import easydynamics as edyn - poly = sm.Polynomial(coefficients=[1.5]) + poly = edyn.Polynomial(coefficients=[1.5]) x = np.linspace(-5, 5, 100) values = poly.evaluate(x) ``` @@ -44,9 +44,9 @@ class Polynomial(ModelComponent): Coefficients are ordered as ``[c0, c1, ...]``, where ``c0`` is the constant term: ```python - import easydynamics.sample_model as sm + import easydynamics as edyn - poly = sm.Polynomial(coefficients=[2.0, 0.1], name='Background') + poly = edyn.Polynomial(coefficients=[2.0, 0.1], name='Background') poly.coefficients = [1.5, 0.05] ``` @@ -54,17 +54,17 @@ class Polynomial(ModelComponent): Powers that are not listed are filled with coefficients fixed to zero: ```python - import easydynamics.sample_model as sm + import easydynamics as edyn - poly = sm.Polynomial(coefficients={2: 1.5}) # 1.5*x^2, with c0 and c1 fixed at 0 + poly = edyn.Polynomial(coefficients={2: 1.5}) # 1.5*x^2, with c0 and c1 fixed at 0 ``` **Changing the degree after construction** ```python - import easydynamics.sample_model as sm + import easydynamics as edyn - poly = sm.Polynomial(coefficients=[2.0, 0.1]) + poly = edyn.Polynomial(coefficients=[2.0, 0.1]) poly.add_coefficient(0.05) # now 2.0 + 0.1*x + 0.05*x^2 removed = poly.remove_coefficient() # returns 0.05, back to 2.0 + 0.1*x ``` diff --git a/src/easydynamics/sample_model/components/voigt.py b/src/easydynamics/sample_model/components/voigt.py index ee8c29046..d0bd070d7 100644 --- a/src/easydynamics/sample_model/components/voigt.py +++ b/src/easydynamics/sample_model/components/voigt.py @@ -35,9 +35,9 @@ class Voigt(CreateParametersMixin, ModelComponent): fixed at 0: ```python import numpy as np - import easydynamics.sample_model as sm + import easydynamics as edyn - v = sm.Voigt(area=1.0, gaussian_width=0.1, lorentzian_width=0.3) + v = edyn.Voigt(area=1.0, gaussian_width=0.1, lorentzian_width=0.3) x = np.linspace(-2, 2, 100) values = v.evaluate(x) ``` @@ -47,9 +47,9 @@ class Voigt(CreateParametersMixin, ModelComponent): Pass a numeric value for ``center`` to leave it free during fitting, and use the property setters to adjust the two width components after construction: ```python - import easydynamics.sample_model as sm + import easydynamics as edyn - v = sm.Voigt(area=2.0, center=0.5, gaussian_width=0.2, lorentzian_width=0.4, name='Peak') + v = edyn.Voigt(area=2.0, center=0.5, gaussian_width=0.2, lorentzian_width=0.4, name='Peak') v.gaussian_width = 0.1 v.lorentzian_width = 0.2 ``` diff --git a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py index b15d00120..cfcefae92 100644 --- a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py @@ -33,10 +33,10 @@ class BrownianTranslationalDiffusion(DiffusionModelBase): construction or later via ``create_component_collections``: ```python import numpy as np - import easydynamics.sample_model as sm + import easydynamics as edyn Q = np.linspace(0.5, 2, 7) - diffusion_model = sm.BrownianTranslationalDiffusion( + diffusion_model = edyn.BrownianTranslationalDiffusion( scale=1.0, diffusion_coefficient=2.4e-9, Q=Q, diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index 1e855243f..2e255d424 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -42,10 +42,10 @@ class DeltaLorentz(DiffusionModelBase): Set ``allow_Q_variation`` to allow individual parameters to vary with Q: ```python import numpy as np - import easydynamics.sample_model as sm + import easydynamics as edyn Q = np.linspace(0.5, 2, 7) - model = sm.DeltaLorentz( + model = edyn.DeltaLorentz( display_name='DiffusionModel', scale=1.0, mean_u_squared=0.02, diff --git a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py index 67a693205..ad615e3b2 100644 --- a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -35,10 +35,10 @@ class JumpTranslationalDiffusion(DiffusionModelBase): Pass the diffusion coefficient (in m²/s) and relaxation time (in ps) along with Q values: ```python import numpy as np - import easydynamics.sample_model as sm + import easydynamics as edyn Q = np.linspace(0.5, 2, 7) - diffusion_model = sm.JumpTranslationalDiffusion( + diffusion_model = edyn.JumpTranslationalDiffusion( scale=1.0, diffusion_coefficient=2.4e-9, relaxation_time=1.0, diff --git a/src/easydynamics/sample_model/instrument_model.py b/src/easydynamics/sample_model/instrument_model.py index e6d45447a..f8a9fa6ee 100644 --- a/src/easydynamics/sample_model/instrument_model.py +++ b/src/easydynamics/sample_model/instrument_model.py @@ -33,13 +33,13 @@ class InstrumentModel(NewBase): ```python import numpy as np - import easydynamics.sample_model as sm + import easydynamics as edyn Q = np.linspace(0.5, 2, 7) - resolution_model = sm.ResolutionModel(components=sm.Gaussian(width=0.05)) - background_model = sm.BackgroundModel(components=sm.Polynomial(coefficients=[0.001])) + resolution_model = edyn.ResolutionModel(components=edyn.Gaussian(width=0.05)) + background_model = edyn.BackgroundModel(components=edyn.Polynomial(coefficients=[0.001])) - instrument_model = sm.InstrumentModel( + instrument_model = edyn.InstrumentModel( Q=Q, resolution_model=resolution_model, background_model=background_model, diff --git a/src/easydynamics/sample_model/resolution_model.py b/src/easydynamics/sample_model/resolution_model.py index a9fc9e1ee..3949f843c 100644 --- a/src/easydynamics/sample_model/resolution_model.py +++ b/src/easydynamics/sample_model/resolution_model.py @@ -27,11 +27,11 @@ class ResolutionModel(ModelBase): ``Polynomial``, and ``Exponential`` components are not allowed in a ResolutionModel: ```python import numpy as np - import easydynamics.sample_model as sm + import easydynamics as edyn Q = np.linspace(0.5, 2, 7) - resolution_model = sm.ResolutionModel( - components=sm.Gaussian(width=0.05, area=1.0), + resolution_model = edyn.ResolutionModel( + components=edyn.Gaussian(width=0.05, area=1.0), Q=Q, ) energy = np.linspace(-2, 2, 100) @@ -43,7 +43,7 @@ class ResolutionModel(ModelBase): After fitting vanadium data with a SampleModel, use ``from_sample_model`` to convert it directly into a ResolutionModel: ```python - resolution_model = sm.ResolutionModel.from_sample_model(fitted_sample_model) + resolution_model = edyn.ResolutionModel.from_sample_model(fitted_sample_model) ``` """ diff --git a/src/easydynamics/sample_model/sample_model.py b/src/easydynamics/sample_model/sample_model.py index 6d699688b..fc6b77624 100644 --- a/src/easydynamics/sample_model/sample_model.py +++ b/src/easydynamics/sample_model/sample_model.py @@ -34,15 +34,15 @@ class SampleModel(ModelBase): A single component is copied to each Q value automatically: ```python import numpy as np - import easydynamics.sample_model as sm + import easydynamics as edyn Q = np.linspace(0.5, 2, 7) energy = np.linspace(-2, 2, 100) - sample_model = sm.SampleModel( + sample_model = edyn.SampleModel( components=[ - sm.DeltaFunction(display_name='Elastic', area=0.5), - sm.Lorentzian(display_name='QE', area=0.5, width=0.3), + edyn.DeltaFunction(display_name='Elastic', area=0.5), + edyn.Lorentzian(display_name='QE', area=0.5, width=0.3), ], Q=Q, ) @@ -54,11 +54,11 @@ class SampleModel(ModelBase): Pass ``temperature`` to apply the detailed balance factor automatically: ```python import numpy as np - import easydynamics.sample_model as sm + import easydynamics as edyn Q = np.linspace(0.5, 2, 7) - btd = sm.BrownianTranslationalDiffusion(diffusion_coefficient=2.4e-9, scale=0.5) - sample_model = sm.SampleModel(diffusion_models=btd, Q=Q, temperature=10) + btd = edyn.BrownianTranslationalDiffusion(diffusion_coefficient=2.4e-9, scale=0.5) + sample_model = edyn.SampleModel(diffusion_models=btd, Q=Q, temperature=10) intensity = sample_model.evaluate(np.linspace(-2, 2, 100)) ``` """ diff --git a/src/easydynamics/utils/detailed_balance.py b/src/easydynamics/utils/detailed_balance.py index 90fdf6504..a3b540cdc 100644 --- a/src/easydynamics/utils/detailed_balance.py +++ b/src/easydynamics/utils/detailed_balance.py @@ -75,9 +75,9 @@ def detailed_balance_factor( **Basic usage** ```python - from easydynamics.utils.detailed_balance import detailed_balance_factor + import easydynamics as edyn - dbf = detailed_balance_factor(1.0, 300) # 1 meV at 300 K + dbf = edyn.detailed_balance_factor(1.0, 300) # 1 meV at 300 K ``` **Specifying units and disabling temperature normalisation** diff --git a/src/easydynamics/utils/plotting.py b/src/easydynamics/utils/plotting.py index 35d604c5b..cb8ee0a98 100644 --- a/src/easydynamics/utils/plotting.py +++ b/src/easydynamics/utils/plotting.py @@ -31,14 +31,14 @@ def slicerplot_with_residuals( ```python import scipp as sc - from easydynamics.utils.plotting import slicerplot_with_residuals + import easydynamics as edyn dg = sc.DataGroup({ 'Data': my_data, 'Model': my_model, 'Residuals': my_residuals, }) - fig = slicerplot_with_residuals(dg, residuals_key='Residuals', keep='energy') + fig = edyn.slicerplot_with_residuals(dg, residuals_key='Residuals', keep='energy') ``` Parameters diff --git a/tests/unit/easydynamics/test_public_api.py b/tests/unit/easydynamics/test_public_api.py new file mode 100644 index 000000000..fb81e81e4 --- /dev/null +++ b/tests/unit/easydynamics/test_public_api.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +""" +Tests for the flat public namespace. + +Tutorials and docstring examples reach everything through ``import easydynamics as edyn``, which +only works while the front door keeps up with the sub-packages. These check that it does. +""" + +import importlib +import json +import pathlib +import re + +import pytest + +import easydynamics as edyn + +SUB_PACKAGES = [ + 'easydynamics.analysis', + 'easydynamics.base_classes', + 'easydynamics.convolution', + 'easydynamics.experiment', + 'easydynamics.sample_model', + 'easydynamics.settings', + 'easydynamics.utils', +] + +TUTORIALS = pathlib.Path(__file__).resolve().parents[3] / 'docs' / 'docs' / 'tutorials' + + +class TestFrontDoor: + def test_everything_declared_is_importable(self): + # THEN EXPECT no name in __all__ that cannot actually be reached + missing = [name for name in edyn.__all__ if not hasattr(edyn, name)] + assert missing == [] + + @pytest.mark.parametrize('module_name', SUB_PACKAGES) + def test_sub_package_exports_are_re_exported(self, module_name): + # THEN + module = importlib.import_module(module_name) + + # EXPECT anything public in a sub-package is on the front door too, so a tutorial never + # has to import from the sub-package to reach it + missing = [name for name in getattr(module, '__all__', []) if name not in edyn.__all__] + assert missing == [], f'{module_name} exports not re-exported: {missing}' + + def test_re_exports_are_the_same_objects(self): + # WHEN + from easydynamics.sample_model import Gaussian + + # THEN EXPECT the front door is an alias, not a copy + assert edyn.Gaussian is Gaussian + + def test_all_is_sorted_and_unique(self): + # THEN EXPECT a list that stays easy to scan and cannot hide a duplicate + assert edyn.__all__ == sorted(edyn.__all__) + assert len(edyn.__all__) == len(set(edyn.__all__)) + + +class TestTutorialImportStyle: + @pytest.mark.parametrize('notebook', sorted(TUTORIALS.glob('*.ipynb')), ids=lambda p: p.name) + def test_notebooks_use_only_the_flat_namespace(self, notebook): + # THEN + cells = json.loads(notebook.read_text(encoding='utf-8'))['cells'] + imports = [ + line.strip() + for cell in cells + if cell['cell_type'] == 'code' + for line in ''.join(cell['source']).splitlines() + if re.match(r'^\s*(import|from)\s+easydynamics', line) + ] + + # EXPECT one way in, so a reader never has to scroll back to find where a name came from + assert set(imports) <= {'import easydynamics as edyn'}, ( + f'{notebook.name} imports EasyDynamics some other way: {imports}' + ) From d12733cddbfaa20fecd0958ba2f7a719206b3c1c Mon Sep 17 00:00:00 2001 From: Henrik Jacobsen Date: Mon, 17 Aug 2026 21:56:08 +0200 Subject: [PATCH 4/5] Fix the full-repository review findings (#240) * Add Bayesian posterior sampling to Analysis1d Expose the EasyScience Fitter on Analysis1d and add MCMC posterior sampling on top of it, using the BUMPS DREAM sampler introduced in easyscience 2.5.1 (easyscience.fitting.Sampler). Least-squares fitting reports a single point with a curvature-derived uncertainty, which is only trustworthy when parameters are uncorrelated and roughly Gaussian. Sampling maps the whole posterior instead, so correlated and skewed parameters get honest credible intervals. The sampling machinery lives in a mixin with three hooks (build the fitter, bind the data, list the chain parameters) so that Analysis and ParameterAnalysis can reuse it. ParameterAnalysis is not an AnalysisBase and builds a MultiFitter over binding models rather than over itself, so a shared base class would not have worked. Notable details: - fit() now uses a cached Fitter instead of building one per call, and the cache is invalidated through the existing dirty-flag pattern. - Bounds are the prior in DREAM, so sampling refuses to run with any infinite bound. suggest_bounds() proposes finite ones from the fitted values and uncertainties; it is advisory until .apply() is called and never loosens a bound that is already finite, so physical limits survive. A zero-width suggestion is flagged rather than invented. - Sampling restores parameter values afterwards, since BUMPS leaves them wherever the last likelihood evaluation put them. - Chains are reported under Parameter.name, not the internal unique_name. Those names are per-session, so save_chain() writes a sidecar mapping them to stable names and load_chain() uses it; loading without one warns rather than mislabelling the columns. - After sampling, a warning fires when the posterior has piled up against a bound, which catches both bounds that are too tight and degenerate parameters that drift until a bound stops them. - BUMPS crashes with a bare IndexError inside its own outlier removal when chains scatter, which in practice means a degenerate model. That is re-raised with the likely cause and a workaround. Co-Authored-By: Claude Opus 5 (1M context) * Add Bayesian posterior sampling to Analysis and ParameterAnalysis Extends the sampling introduced for Analysis1d to the remaining two Analysis classes, using the mixin hooks added with it. No new sampling machinery: each class supplies its fitter, its data, and its chain parameters, and everything else is shared. Analysis gains sample_posterior(fit_method=...), mirroring fit(): - 'independent' gives each Q index its own chain, delegating to the Analysis1d objects, and returns one result per Q (or a single result when a Q_index is given). - 'simultaneous' runs one chain over every Q at once through a MultiFitter, refreshing each per-Q convolver against its masked energy grid first, exactly as the simultaneous fit does. ParameterAnalysis samples the binding models. Its fit() built the MultiFitter inline, so the per-target data, functions, and models are now resolved by a shared _build_fit_inputs() that both paths use, which also guarantees fitting and sampling see the same targets in the same order with the same unit conversions. Parameter labels needed rethinking. A multi-Q analysis holds one copy of each parameter per Q, all sharing a name, so a summary showed several identical rows and a name could not pick a parameter out. Labels are now produced by an overridable parameter_label(): Analysis qualifies by Q index, ParameterAnalysis by binding model, and both only when the bare name is actually ambiguous, so single-Q and single-binding cases keep their short names. The summary and bounds tables size themselves to the longest label rather than truncating. Also fixes Analysis.fit's docstring, which promised a single FitResults for a simultaneous fit. MultiFitter splits its combined result back up by dataset, so a list has always been returned. Tutorial 1 gains a Bayesian section on the two-step diffusion fit, where the posterior turns out to be about twelve times tighter than the reported least-squares uncertainties. That gap is real and worth explaining: the width fit has a reduced chi-squared near 150, so lmfit inflates its uncertainties by the square root of that, while the sampler takes the stated uncertainties at face value. Sampling the full simultaneous diffusion model was measured at over ten minutes, so the tutorial uses the ParameterAnalysis step instead. Co-Authored-By: Claude Opus 5 (1M context) * Label the posterior plot axes with units and quantities The summary table already reported each parameter's unit, but the plots did not, so a diffusion coefficient came out as a bare number. Units are now threaded through to plot_trace and plot_corner, and the posterior predictive plot gets axis labels taken from the analysis' own energy and intensity units. Details that needed care: - Matplotlib parks a shared exponent at the end of the axis, on top of the axis label. It is now folded into the label, sharing one set of parentheses with the unit, so a diffusion coefficient reads "diffusion_coefficient (1e-8 m^2/s)" rather than stacking two parentheticals or overlapping. - Dimensionless and empty units are skipped. A polynomial coefficient labelled "dimensionless" is noise. - The top-left panel of a corner plot is a histogram, so its vertical axis counts draws rather than carrying a parameter. It is now labelled "counts" instead of being left blank, which read as an omission. - Corner tick counts are capped, since four labelled ticks per panel is as much as a small panel can carry legibly. Co-Authored-By: Claude Opus 5 (1M context) * Qualify parameter labels by model name, and cover the remaining branches Two fixes found by writing the tests codecov asked for. ParameterAnalysis qualified an ambiguous parameter with the owning model's display_name, but for several models -- the diffusion models among them -- display_name is the class name, so two models constructed as name='Diffusion A' and name='Diffusion B' both came back as "BrownianTranslationalDiffusion" and the label did not disambiguate anything. It now uses the model's name, matching the choice to report parameters under their name rather than their display name, and falls back to the unique name only when the names collide too. The rest is test coverage for branches that were reachable but untested: the label fallbacks, the BUMPS outlier crash being re-raised as a degeneracy hint, a chain column that matches no parameter, loading a chain through its sidecar, the mixin's unimplemented hooks, and the scientific-notation exponent being folded into an axis label. Co-Authored-By: Claude Opus 5 (1M context) * Warm the tutorial data cache before running notebooks in parallel The notebook tests run with '-n auto', and five of the notebooks fetch vanadium_data_example.h5 through pooch. On a cold cache the workers race: one is still writing the file into the cache while another opens it, which fails on Windows with "PermissionError: Permission denied". This failed twice in a row on windows-latest, always on that file, always with the other sixteen notebooks passing. The race is pre-existing, but adding a fifth notebook that wants the same file, and lengthening tutorial 1, made it reliable rather than rare. Fetching every tutorial data file once, before the parallel run starts, leaves the workers with nothing to do but read, which is safe. The prefetch reads the URLs and hashes out of the notebooks themselves, so it cannot drift from what they actually download, and it never fails the run: a file it cannot fetch is left to the notebook that needs it, which reports the problem with far more context. Co-Authored-By: Claude Opus 5 (1M context) * Rebuild the fitter when a binding changes shape, and stabilise the integration tests Two problems found while reviewing the previous commits. Caching the MultiFitter on ParameterAnalysis introduced a regression. A FitBinding can be edited in place -- binding.targets = ... -- which ParameterAnalysis cannot observe. Changing the number of targets left the cached fitter holding one fit function against two datasets, and fit() died with "FitError: list index out of range". It rebuilt every call before, so this worked previously. The targets the fitter was built for are now recorded and compared, which is enough to catch an edit that cannot be observed directly. The integration tests then failed in CI on macOS, inside BUMPS' outlier removal, on an identifiable model. That matters beyond the test: the error message claimed the crash means degenerate parameters, and this shows short chains do it too. The message now names both causes, and the integration tests switch the outlier removal off, as they already do for the burn-point trimming. Co-Authored-By: Claude Opus 5 (1M context) * Address the review findings on the sampling API Six issues found reviewing the previous commits. The sidecar could be written with the wrong labels. A subset run built the name map inside the block that holds the other parameters fixed, where nothing looks ambiguous, so a multi-Q chain recorded unqualified names that no longer matched on reload. The map is now built outside that block, where the free set is the user's real one. extend_sampling() accepted a different parameter subset. BUMPS resumes from a stored chain whose width is fixed, so that could only fail deep inside the sampler; it is now refused up front. The IndexError relabelling was unconditional, so an IndexError from this package would have been reported as a BUMPS modelling problem. It now only applies when the traceback passes through bumps. Labelling a chain was quadratic in the parameter count: collecting the parameters and scanning for their owner both happened per parameter, and each walks every sub-model. 75 parameters took 0.39 s, and every summary and plot pays it. The parameters are now collected once per pass, and Analysis keeps an owner index alongside its analysis list. The same case now measures at 0.00 s. Asking an Analysis for a summary after sampling independently reported that nothing had been sampled, moments after it had. It now says where the chains actually are. Applying bounds many orders of magnitude wider than the parameter is still allowed -- it is what the fit implied -- but no longer silent, so a scripted apply() cannot hide a degeneracy the table would have shown. Co-Authored-By: Claude Opus 5 (1M context) * Cover the review fixes, and drop a redundant guard Three lines the review fixes added were not reachable from the unit tests. Two are now covered: extending after a run that died before storing results, where the chain-shape guard has nothing to compare against, and a parameter shared across every Q index, which is left out of the owner map because no single Q identifies it. The third was the non-finite check in the absurd-width test, and it was redundant rather than untested: an infinite width already compares greater than any threshold, and the zero-scale case returns before it. Removed, so the behaviour is unchanged and there is no dead branch. Co-Authored-By: Claude Opus 5 (1M context) * Gather the per-Q chains on Analysis after independent sampling Sampling with fit_method='independent' left the results only on the Analysis1d objects, so the Analysis that produced them could not report on them. It now gathers them, but only where gathering is sound. posterior_summary() collects every Q into one table, labelled by Q index, and set_parameters_to_posterior_median() applies each chain to its own Q. Both are per-parameter marginal operations, and a marginal is well defined within its own chain, so combining them across separate chains says nothing that was not sampled. plot_corner() deliberately does not aggregate. Independent sampling draws each Q separately, so no draw pairs a parameter at one Q with a parameter at another, and a corner plot built from them would show correlations that are an artefact of how the sampling was run rather than anything measured. It says so and points at the per-Q corner plots, which are real. plot_trace() likewise, the chains being separate runs of different lengths rather than one trace. posterior_results exposes the per-Q chains directly, and a simultaneous chain still takes precedence over stale per-Q ones. Co-Authored-By: Claude Opus 5 (1M context) * Step through the per-Q corner plots with a slider Independent chains share no draws, so there is no joint distribution across Q to plot, and combining them would show correlations that came from how the sampling was run rather than from the data. Refusing outright was correct but unhelpful: the correlations within each Q are real and worth looking at. Analysis.plot_corner() now shows one Q at a time. Pass Q_index for a particular one, or leave it out in a notebook for a slider across the Q values that were sampled. A simultaneous chain is unaffected; it already covers every Q in one figure. Outside a notebook the error names the sampled Q indices rather than only saying no. The slider is built with append_display_data rather than the Output widget's context manager. The context manager is the obvious choice and captures nothing under some kernels, which would have shipped a slider with a permanently blank panel beside it. Verified by executing a notebook against a real kernel, and the test asserts the panel actually holds a figure, since an empty panel is the regression that matters. Co-Authored-By: Claude Opus 5 (1M context) * Show the per-Q corner slider in the Bayesian tutorial The slider was described in the tutorial's caveats but never demonstrated: every notebook call to plot_corner() went through the single-chain path, because the Bayesian tutorial used Analysis1d and tutorial 1 used ParameterAnalysis, neither of which has a Q dimension. So the only things exercising it were the unit tests. The tutorial now builds the full multi-Q Analysis, samples a few Q values, gathers them with posterior_summary(), and shows the slider. It samples Q indices 4, 8 and 12 rather than all sixteen. Sampling every Q measured at 70 s against 16 s for three, and the subset also shows two things worth showing: that sampling is slow enough to be worth trying a few Q values first, and that the slider offers only the Q values that were actually sampled. Verified against a real kernel that the cell emits a widget view, rather than only that the notebook ran without raising. Co-Authored-By: Claude Opus 5 (1M context) * Put the corner slider under the figure Matches where plopp puts its slicer controls, which is also where the existing slicerplot_with_residuals puts them via the figure's bottom bar. Co-Authored-By: Claude Opus 5 (1M context) * Reach the whole library through one namespace Review feedback: the import style was inconsistent enough that a reader had to scroll back to the imports cell to find out where a name came from. Surveying it, the tutorials used four styles, and the last two existed only because there was no other way to reach those names: import easydynamics as edyn 32 uses import easydynamics.sample_model as sm 151 uses from easydynamics.convolution import Convolution forced from easydynamics.utils.utils import hbar forced easydynamics.__all__ held six names, so Analysis1d, Convolution, detailed_balance_factor and hbar could only be had by importing the module that defines them. The inconsistency was structural rather than careless, and no amount of tidying the notebooks alone would have fixed it. Everything public is now re-exported from easydynamics, 37 names, so `import easydynamics as edyn` reaches all of it. The sub-packages stay importable and the internal layout is untouched: only the front door is flat. Flat is comfortable at this size, there were no name collisions, and the sample_model grouping was already imprecise, holding InstrumentModel, ResolutionModel and BackgroundModel. The tutorials and the docstring examples that render into the API reference now use that one style throughout. A test keeps the front door in step with the sub-packages and the notebooks in step with the convention, which is also written down in CONTRIBUTING. Co-Authored-By: Claude Opus 5 (1M context) * Compose the posterior sampler instead of mixing it in Review feedback: bayesian_sampling.py had a lot in it that belonged elsewhere, and it was unclear why it was a mixin at all. It was a mixin because ParameterAnalysis is not an AnalysisBase and fits its binding models rather than itself, so a shared base class does not work. That was a reason, not a good one: it injected some forty methods into every Analysis class. The sampler is now composed. An Analysis exposes one `bayesian` property, and hands the sampler the few things that differ between the Analysis classes -- the data, the free parameters, their labels, and a hook to refresh cached computation -- so PosteriorSampler needs no knowledge of how any Analysis is built, and no Analysis inherits sampling machinery it does not use. Labelling moves to posterior_labels.py. Building it once for a fixed set of parameters also removes the quadratic cost the old code needed a scoped cache to avoid: the counts and lookups are computed in the constructor rather than per column. Plotting stays in posterior_plotting.py, where it already lived. The sampler keeps three short delegates so a chain can still be plotted from the object holding it, but none of the drawing happens there. The public API becomes analysis.bayesian.sample() and friends, and the explicit suggest_bounds().apply() step stays: in DREAM the bounds are the prior, and an unbounded parameter gives a confident-looking interval set by nothing. Co-Authored-By: Claude Opus 5 (1M context) * Export the multi-Q sampler and drop the mixin's name The section headers still pointed at a class that no longer exists, and MultiQPosteriorSampler was reachable only through Analysis.bayesian. Co-Authored-By: Claude Opus 5 (1M context) * Point the front door at the composed sampler The flat namespace still re-exported the mixin that the refactor removed, and not the sampler classes that replaced it. Co-Authored-By: Claude Opus 5 (1M context) * Unwrap the security-issue line again Prettier 3.9, which CI installs, measures the shield emoji differently from the older release cached here and wants the line whole. Co-Authored-By: Claude Opus 5 (1M context) * Warm the tutorial data cache before running notebooks in parallel The notebook tests run with '-n auto', and five of the notebooks fetch vanadium_data_example.h5 through pooch. On a cold cache the workers race: one is still writing the file into the cache while another opens it, which fails on Windows with "PermissionError: Permission denied". This failed twice in a row on windows-latest, always on that file, always with the other sixteen notebooks passing. The race is pre-existing, but adding a fifth notebook that wants the same file, and lengthening tutorial 1, made it reliable rather than rare. Fetching every tutorial data file once, before the parallel run starts, leaves the workers with nothing to do but read, which is safe. The prefetch reads the URLs and hashes out of the notebooks themselves, so it cannot drift from what they actually download, and it never fails the run: a file it cannot fetch is left to the notebook that needs it, which reports the problem with far more context. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 46d745a4a73e8025c37e02e490fab67cfdb5ff22) * Mark setup, action and expectation apart in the new tests The sampling tests labelled the action WHEN and had no THEN, so a reader could not see where the arrangement stopped and the call under test began. Setup is WHEN, the action is THEN, the assertions are EXPECT, and steps that genuinely collapse onto one statement carry one combined marker instead. Comments only; no test changed what it does. Co-Authored-By: Claude Opus 5 (1M context) * Mark setup, action and expectation apart in the multi-Q tests Same pass as on the single-Q tests: setup is WHEN, the action is THEN, the assertions are EXPECT, and a step that collapses onto one statement carries one combined marker. Comments only; no test changed what it does. Co-Authored-By: Claude Opus 5 (1M context) * Mark setup, action and expectation apart in the namespace tests Co-Authored-By: Claude Opus 5 (1M context) * Give the sampler its own test file Tests were split by feature rather than by the file they exercise, so posterior_sampling.py had no test file of its own and Analysis1d had two. The sampler's tests now live in test_posterior_sampling.py under one TestPosteriorSampler, with the old class names as section banners, and the four tests that are really about Analysis1d's cached fitter move into TestAnalysis1d. No test changed what it does; the same 31 + 4 tests run as before. Co-Authored-By: Claude Opus 5 (1M context) * Put each test in the file of the class it exercises Analysis and ParameterAnalysis each had a second test file, and the sampler had none of its own. The sampler's tests, whichever analysis drives them, now live in test_posterior_sampling.py under TestPosteriorSampler and TestMultiQPosteriorSampler; the fitter, chain parameter and label tests move into TestAnalysis and TestParameterAnalysis. Old class names became section banners. The multi-Q and ParameterAnalysis helpers keep distinct names in the merged file, since their signatures differ from the single-Q ones. The same 1660 tests run as before. Co-Authored-By: Claude Opus 5 (1M context) * Refuse silent chain corruption and harden the posterior sampler - extend() now verifies the chain holds the same parameters, not just the same number, and refuses to resume after a failed run or after the model or data changed - Parameter objects passed to sample(parameters=...) are validated against the free set the same way strings are - sampling with no free parameters and degenerate (min >= max) bounds raise clear errors before reaching BUMPS - parameters_at_bounds keys by unique_name so same-named per-Q parameters no longer collide, and guards empty draws - suggest_bounds flags non-finite fitted uncertainties for attention - save() refuses to write an empty label sidecar; loading one warns like a missing sidecar - colliding display labels get positional suffixes in the sidecar so save/load resolves each column to its own parameter - plot_posterior_predictive omits error bars when the data carries no variances (new Experiment.has_variances) - posterior plots validate draws/logp up front, name NaN columns, and share x-limits per corner column - document that sampling runs are not seedable Co-Authored-By: Claude Fable 5 * Version collections so stale plans and caches rebuild Mutations made through a live ComponentCollection were invisible to the boolean dirty flags: an appended component was silently left out of the convolution, and a mutated template never reached evaluate(). - EasyDynamicsList gains a version counter bumped by every mutation; ModelBase exposes state_version combining it with its own changes - convolution plans snapshot collection versions, the detailed-balance settings version and the energy_offset identity, so in-place edits, settings toggles and offset rebinds all rebuild the plan - the plan invalidation set no longer watches two nonexistent attributes, and superseded plan objects are pruned from the global registry instead of leaking - energy assignment with a mismatched scipp unit is refused, collection x_units are validated at construction, slicing a ComponentCollection works, delta-in-resolution is enforced on every path with ValueError, empty resolutions and single-point grids raise clearly - normalize_area rejects negative areas, empty-collection evaluate validates output, duplicate names raise AmbiguousNameError Co-Authored-By: Claude Fable 5 * Repair detailed balance, diffusion collections and bound handling - detailed balance now works with scipp output mode and DataArray input, and its docstring matches the errors it actually raises - diffusion models install the collections create_component_collections builds, so the returned parameters are the live ones; DeltaLorentz no longer orphans its per-Q parameter lists, and its amplitudes carry the model name so Q tracking keeps working - Polynomial unit conversion rescales bounds with the values instead of silently clamping bounded coefficients - component setters raise on values that violate parameter bounds rather than storing the clamped value; construction keeps its warning - a calibrated ResolutionModel refuses mutations that would silently rebuild it from the unfitted template, and from_sample_model strips elastic delta functions with a warning instead of refusing the standard QENS model - SampleModel validates before mutating caller-owned diffusion models, normalizes temperature units to str, and single-point DeltaFunction evaluation raises instead of inventing a bin width - ExpressionComponent rejects symbols that shadow class attributes and documents the sympify trust requirement Co-Authored-By: Claude Fable 5 * Track model versions per consumer and wire missing invalidation The shared component_collections_is_dirty flag was consumed by its first reader, so in an independent multi-Q fit only Q0 rebuilt its convolver and every later Q fitted through a stale one. - each Analysis1d records the state_version of its sample and resolution models and rebuilds its convolver on mismatch - the detailed_balance_settings setter fires a change hook like every sibling setter, reaching per-Q children and cached convolvers - Analysis.rebin invalidates the fitter and sampler like Analysis1d - simultaneous fits run through the configured fitter instead of a throwaway MultiFitter - ParameterAnalysis fitter staleness includes target names and dataset keys, binding mutations invalidate, and the bindings list is copied - Analysis.get_all_variables override removes property side effects from bounds checks and includes extra parameters - residuals are omitted with a warning on a custom energy grid, plot(names=[]) raises clearly and only needed bindings are evaluated, the no-variance path masks non-finite values, verify_Q_index rejects bools and Experiment.rebin no longer mutates the caller's dict Co-Authored-By: Claude Fable 5 * Declare runtime dependencies and align packaging, docs and tooling - declare h5py, numpy, scipy and scipp, which the package imports directly but only received transitively - export Analysis1d and FitBinding from easydynamics.analysis, hbar from easydynamics.utils and DeltaLorentz from its own sub-package, as the front-door docstring promises - test_public_api covers the nested sub-packages and absorbs the import smoke test - the tutorial-data prefetch parses each pooch.retrieve call site instead of zipping url and hash lists, and covers nested notebooks, as does notebook-strip; notebook-exec waits for the prefetch - fix the mkdocs edit_uri path, refresh stale noqa prose in the delta_lorentz tutorial, replace the placeholder functional test with a real smoke test, and give the network-bound integration test a marker and a meaningful 3-sigma tolerance Co-Authored-By: Claude Fable 5 * Evaluate expressions in a coherent scale and express them in y_unit An ExpressionComponent whose derived output unit was convertible to y_unit warned and mislabelled instead of converting, and evaluation at raw parameter values silently mixed unit scales inside sums: with D in m^2/s, x in 1/angstrom and tau in ps, the jump-diffusion denominator 1 + D*x**2*tau evaluated as 1 + 1e-9 where the physical value is 1.1. When the output unit is convertible to y_unit, every symbol value is now scaled by its unit's SI multiplier before evaluation and the result is expressed in y_unit. Scale-homogeneous expressions are unchanged; dimensionally incompatible output units still warn. Co-Authored-By: Claude Fable 5 * Cover counts-bearing units in the expression conversion tests Co-Authored-By: Claude Fable 5 * Keep the multi-Q sampler pointed at the chain the user actually ran - sampling one Q index independently now clears a stale simultaneous chain, so summary(), set_parameters_to_median() and plot_corner() report the run the user just made instead of the old one - extend() and save() after an independent run explain that the chains live on the per-Q analyses instead of resuming or saving the stale simultaneous chain; a genuinely failed run keeps its own message - Q_index arguments are validated like every Analysis method, so a negative index raises instead of silently wrapping - the gathered summary resolves each per-Q chain through its own saved labels, so chains loaded from disk keep names and units - warnings are attributed to the caller on both the single-Q and multi-Q paths, and the corner-plot slider forwards plot kwargs - the multi-Q integration tests share one independent sampling run, assert the straight line is actually recovered, and the extend test no longer mutates the shared fixture Co-Authored-By: Claude Fable 5 * Add marginal posteriors, correlation heatmaps and sampling progress - plot_marginal(parameter) renders one parameter's posterior histogram with the median and the 16/84 percentile interval summary() reports, resolving labels the same way sample(parameters=...) does - plot_correlations() renders the Pearson correlation matrix of the chain with annotated cells, a diverging colormap and masked cells for constant columns - sample(progress=True) and extend(progress=True) report sampling progress through the Sampler's progress_callback, closing the line with an explicit done marker because BUMPS' own step estimate assumes the wrong chain count - the 95 percent predictive band needed no change: credible_interval already exists on plot_posterior_predictive Co-Authored-By: Claude Fable 5 * Align test files with the layout conventions and hoist imports - one test class per class under test: the topic-split classes in the posterior, expression-component and fit-binding test files are consolidated under banner sections, and stray module-level tests move into their classes with WHEN/THEN/EXPECT markers - tests exercising the Analysis and ParameterAnalysis contracts move from test_posterior_sampling.py into their own files - the labelling cost test counts property reads instead of asserting wall-clock time, so a quadratic regression fails deterministically and a loaded CI runner cannot flake it - plot_corner's y-axis offset path and corner_with_slider's empty-chain error gain the tests they were missing - function-level imports move to the top of their files across tests and src; the deliberate lazy imports that guard the analysis-utils import cycle now say so in comments Co-Authored-By: Claude Fable 5 * Justify the exact no-op comparisons in the coefficient rescale Co-Authored-By: Claude Fable 5 * Move the remaining stray tests into their classes Co-Authored-By: Claude Fable 5 * Give every posterior plot a Q slider over independent chains After independent per-Q sampling the multi-Q sampler now presents a Q slider instead of refusing: - plot_posterior_predictive builds the per-Q data, median and credible band into a scipp DataGroup and renders it through plopp exactly like plot_data_and_model; plopp cannot shade a band on sliced lines, so the slider view draws labelled band edges while the Q_index path keeps the shaded band - plot_trace, plot_marginal and plot_correlations take Q_index for a single figure, show a slider in a notebook, and otherwise name the sampled Q indices - the matplotlib sliders render every figure once up front and only swap PNG bytes on a move, so dragging tracks smoothly with continuous updates instead of re-rendering per change - per-Q energy grids are NaN-padded onto the common grid through the finite mask, so masked points draw as gaps Co-Authored-By: Claude Fable 5 * Write the progress line through sys.stdout Co-Authored-By: Claude Fable 5 * Show the new posterior plots in the Bayesian tutorial The tutorial now demonstrates plot_marginal and plot_correlations from the sampled chain, progress=True on the sampling call, the 95 percent predictive band option, the Q slider that every posterior plot offers over independent chains, and notes that runs are not seedable. Co-Authored-By: Claude Fable 5 * Apply the formatting fixes Co-Authored-By: Claude Fable 5 * Satisfy the docstring and formatting checks The progress reporter closes through try/finally instead of a bare re-raise, and the plotting validation errors are documented in the form the docstring linter expects. Co-Authored-By: Claude Fable 5 * Document propagated exceptions the way the docstring linter expects Co-Authored-By: Claude Fable 5 * Satisfy the linters across the merged fix branch Formatting from pixi run fix, docstring Raises sections limited to directly raised exceptions with propagated ones described in prose, and the polynomial rollback restructured as try/finally so its Raises contract stays honest. Co-Authored-By: Claude Fable 5 * Keep prettier current and contained to the repository nonpy-format-check disagreed between machines because npm, finding no package.json here, walked up the directory tree and resolved prettier against whatever a parent directory happened to pin, while CI installed the latest. The prettier tasks now refresh the install before every run, with --prefix . so the resolution cannot leave the repository and @latest so local runs track the same version CI gets. The files the old local version had skipped are reformatted once, and in-repo git worktrees are excluded from the sweep. Co-Authored-By: Claude Fable 5 * Give the Bayesian tutorial the widget backend its sliders need The Q-slider cells go through the plopp slicer, which refuses the inline backend; every plopp-using tutorial already runs %matplotlib widget. Co-Authored-By: Claude Fable 5 * Drop the duplicate line and unused import the tie-merge reintroduced Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 5 (1M context) --- .prettierignore | 3 + docs/docs/tutorials/delta_lorentz.ipynb | 2 +- docs/mkdocs.yml | 2 +- pixi.lock | 4 + pixi.toml | 20 +- pyproject.toml | 9 +- src/easydynamics/__init__.py | 6 +- src/easydynamics/analysis/__init__.py | 4 + src/easydynamics/analysis/analysis.py | 74 ++++- src/easydynamics/analysis/analysis1d.py | 56 +++- src/easydynamics/analysis/analysis_base.py | 6 + .../analysis/parameter_analysis.py | 76 +++-- .../analysis/posterior_sampling.py | 22 +- .../base_classes/easydynamics_list.py | 134 +++++++- src/easydynamics/base_classes/name_mixin.py | 4 +- .../convolution/analytical_convolution.py | 96 ++++-- src/easydynamics/convolution/convolution.py | 69 ++++- .../convolution/convolution_base.py | 106 ++++++- .../convolution/numerical_convolution_base.py | 118 +++++-- src/easydynamics/exceptions.py | 20 +- src/easydynamics/experiment/experiment.py | 3 - .../sample_model/component_collection.py | 74 ++++- .../components/damped_harmonic_oscillator.py | 15 +- .../sample_model/components/delta_function.py | 38 ++- .../components/expression_component.py | 151 +++++++-- .../sample_model/components/gaussian.py | 17 +- .../sample_model/components/lorentzian.py | 19 +- .../sample_model/components/mixins.py | 36 +++ .../sample_model/components/polynomial.py | 81 ++++- .../sample_model/components/voigt.py | 33 +- .../sample_model/diffusion_model/__init__.py | 2 + .../brownian_translational_diffusion.py | 6 +- .../diffusion_model/delta_lorentz.py | 81 +++-- .../diffusion_model/diffusion_model_base.py | 7 +- .../jump_translational_diffusion.py | 14 +- src/easydynamics/sample_model/model_base.py | 93 +++++- .../sample_model/resolution_model.py | 161 +++++++++- src/easydynamics/sample_model/sample_model.py | 58 ++-- .../settings/convolution_settings.py | 25 +- .../settings/detailed_balance_settings.py | 37 +++ src/easydynamics/utils/__init__.py | 2 + src/easydynamics/utils/detailed_balance.py | 56 +++- src/easydynamics/utils/utils.py | 7 +- tests/functional/test_dummy.py | 22 +- .../fitting/test_bayesian_sampling.py | 4 +- .../fitting/test_bayesian_sampling_multi_q.py | 4 +- .../test_fitting_with_diffusion_model.py | 9 +- .../easydynamics/analysis/test_analysis.py | 180 ++++++++++- .../easydynamics/analysis/test_analysis1d.py | 291 ++++++++++++++---- .../analysis/test_analysis_base.py | 30 +- .../easydynamics/analysis/test_fit_binding.py | 22 +- .../analysis/test_parameter_analysis.py | 195 +++++++++++- .../easydynamics/analysis/test_posterior.py | 92 +++--- .../analysis/test_posterior_labels.py | 46 ++- .../analysis/test_posterior_sampling.py | 36 +-- .../base_classes/test_easydynamics_list.py | 115 +++++++ .../base_classes/test_name_mixin.py | 18 ++ .../convolution/test_convolution.py | 251 ++++++++++++++- .../convolution/test_convolution_base.py | 83 +++++ .../test_numerical_convolution_base.py | 166 +++++++++- .../experiment/test_experiment.py | 13 + .../test_damped_harmonic_oscillator.py | 18 ++ .../components/test_delta_function.py | 17 + .../components/test_expression_component.py | 104 +++++-- .../sample_model/components/test_gaussian.py | 29 ++ .../components/test_lorentzian.py | 18 ++ .../sample_model/components/test_mixins.py | 39 ++- .../components/test_model_component.py | 4 +- .../components/test_polynomial.py | 57 +++- .../sample_model/components/test_voigt.py | 18 ++ .../test_brownian_translational_diffusion.py | 16 +- .../diffusion_model/test_delta_lorentz.py | 89 ++++-- .../test_diffusion_model_base.py | 34 +- .../test_jump_translational_diffusion.py | 16 +- .../sample_model/test_component_collection.py | 99 +++++- .../sample_model/test_instrument_model.py | 35 ++- .../sample_model/test_model_base.py | 73 +++++ .../sample_model/test_resolution_model.py | 61 +++- .../sample_model/test_sample_model.py | 134 ++++++-- .../settings/test_convolution_settings.py | 13 +- .../test_detailed_balance_settings.py | 30 ++ tests/unit/easydynamics/test_exceptions.py | 17 + tests/unit/easydynamics/test_import.py | 7 - tests/unit/easydynamics/test_public_api.py | 10 +- .../utils/test_detailed_balance.py | 149 +++++---- .../easydynamics/utils/test_fit_target.py | 95 +++--- .../utils/test_posterior_plotting.py | 51 ++- tests/unit/easydynamics/utils/test_utils.py | 73 +++-- tools/prefetch_tutorial_data.py | 34 +- 89 files changed, 3900 insertions(+), 864 deletions(-) delete mode 100644 tests/unit/easydynamics/test_import.py diff --git a/.prettierignore b/.prettierignore index ec19b5ef1..2c912ce56 100644 --- a/.prettierignore +++ b/.prettierignore @@ -28,6 +28,9 @@ node_modules # Tox .tox +# Git worktrees checked out inside the repository +.worktrees/ + # Misc .benchmarks .cache diff --git a/docs/docs/tutorials/delta_lorentz.ipynb b/docs/docs/tutorials/delta_lorentz.ipynb index 47cf6bd90..128e7434e 100644 --- a/docs/docs/tutorials/delta_lorentz.ipynb +++ b/docs/docs/tutorials/delta_lorentz.ipynb @@ -65,7 +65,7 @@ "id": "0aee03b1", "metadata": {}, "source": [ - "Both `A_0` and `lorentzian_width` are here allowed to vary with Q. We here change a few of them just to show how this impacts the model. The `# noqa` comment is because we are accessing private members of the model (ones beginning with `_`), which is generally discouraged. Because of these changes, in the figure below, the delta function at Q=1.25 Å^-1 is much larger than the other ones, and the Lorentzian at Q=1.75 Å^-1 is much narrower and taller than the other ones." + "Both `A_0` and `lorentzian_width` are here allowed to vary with Q. We here change a few of them just to show how this impacts the model. The `# ruff: ignore[private-member-access]` comment is because we are accessing private members of the model (ones beginning with `_`), which is generally discouraged. Because of these changes, in the figure below, the delta function at Q=1.25 Å^-1 is much larger than the other ones, and the Lorentzian at Q=1.75 Å^-1 is much narrower and taller than the other ones." ] }, { diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 3db04ace0..6575f0c95 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -4,7 +4,7 @@ site_url: https://easyscience.github.io/dynamics-lib # Repository repo_url: https://github.com/easyscience/dynamics-lib -edit_uri: edit/develop/docs/ +edit_uri: edit/develop/docs/docs/ # Copyright copyright: © 2025-2026 EasyDynamics diff --git a/pixi.lock b/pixi.lock index f07f02cf8..772d51ce2 100644 --- a/pixi.lock +++ b/pixi.lock @@ -7536,15 +7536,19 @@ packages: requires_dist: - darkdetect - easyscience>=2.5.1 + - h5py - ipykernel - ipympl - ipython - ipywidgets - jupyterlab - matplotlib + - numpy - pixi-kernel - plopp - pooch + - scipp + - scipy - sympy - build ; extra == 'dev' - copier ; extra == 'dev' diff --git a/pixi.toml b/pixi.toml index db46523e1..007abbce4 100644 --- a/pixi.toml +++ b/pixi.toml @@ -119,7 +119,10 @@ docstring-lint-check = 'pydoclint --quiet src/' notebook-lint-check = 'nbqa ruff docs/docs/tutorials/' py-lint-check = 'ruff check src/ tests/ docs/docs/tutorials/' py-format-check = 'ruff format --check src/ tests/ docs/docs/tutorials/' -nonpy-format-check = 'npx prettier --list-different --config=prettierrc.toml --ignore-unknown .' +# Refreshes prettier first, so the local version can never drift behind the one CI installs. +nonpy-format-check = { cmd = 'npx prettier --list-different --config=prettierrc.toml --ignore-unknown .', depends-on = [ + 'prettier-install', +] } nonpy-format-check-modified = 'python tools/nonpy_prettier_modified.py' check = 'pre-commit run --hook-stage manual --all-files' @@ -134,7 +137,9 @@ notebook-lint-fix = 'nbqa ruff --fix docs/docs/tutorials/' py-lint-fix = 'ruff check --fix src/ tests/ docs/docs/tutorials/' py-lint-fix-unsafe = 'ruff check --fix --unsafe-fixes src/ tests/ docs/docs/tutorials/' py-format-fix = 'ruff format src/ tests/ docs/docs/tutorials/' -nonpy-format-fix = 'npx prettier --write --list-different --config=prettierrc.toml --ignore-unknown .' +nonpy-format-fix = { cmd = 'npx prettier --write --list-different --config=prettierrc.toml --ignore-unknown .', depends-on = [ + 'prettier-install', +] } nonpy-format-fix-modified = 'python tools/nonpy_prettier_modified.py --write' success-message = 'echo "✅ All auto-formatting steps completed successfully!"' @@ -178,9 +183,11 @@ cov = { depends-on = [ ######################## notebook-convert = 'jupytext docs/docs/tutorials/*.py --from py:percent --to ipynb' -notebook-strip = 'nbstripout docs/docs/tutorials/*.ipynb' +notebook-strip = 'nbstripout docs/docs/tutorials/**/*.ipynb' notebook-tweak = 'python tools/tweak_notebooks.py docs/docs/tutorials/' -notebook-exec = 'python -m pytest --nbmake docs/docs/tutorials/ --nbmake-timeout=1200 --overwrite --color=yes -n auto -v' +notebook-exec = { cmd = 'python -m pytest --nbmake docs/docs/tutorials/ --nbmake-timeout=1200 --overwrite --color=yes -n auto -v', depends-on = [ + 'prefetch-tutorial-data', +] } notebook-prepare = { depends-on = [ #'notebook-convert', @@ -270,7 +277,10 @@ default-build = 'python -m build' dist-build = 'python -m build --wheel --outdir dist' npm-config = 'npm config set registry https://registry.npmjs.org/' -prettier-install = 'npm install --no-save --no-audit --no-fund prettier prettier-plugin-toml' +# --prefix . keeps the install inside this repository: without it, npm walks up the directory +# tree and a stray package.json in a parent directory can silently pin an old prettier. +# @latest keeps local runs on the same version CI installs. +prettier-install = 'npm install --prefix . --no-save --no-audit --no-fund prettier@latest prettier-plugin-toml@latest' clean-pycache = "find . -type d -name '__pycache__' -prune -exec rm -rf '{}' +" diff --git a/pyproject.toml b/pyproject.toml index 3330304ea..c8ff0397c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,10 @@ classifiers = [ requires-python = '>=3.12' dependencies = [ 'easyscience>=2.5.1', # The base library of the EasyScience framework. 2.5.1 adds fitting.Sampler + 'numpy', # Numerical arrays (used directly throughout the library) + 'scipy', # Numerical routines (convolution, interpolation, special functions) + 'scipp', # Labelled multi-dimensional arrays; backs Experiment data handling + 'h5py', # HDF5 backend for scipp's HDF5 I/O (Experiment.load_hdf5) 'matplotlib', # Plotting (posterior trace, corner, and predictive plots) 'pooch', # Data downloader 'darkdetect', # Detecting dark mode (system-level) @@ -167,7 +171,10 @@ fail_under = 0 # Minimum coverage percentage to pass [tool.pytest.ini_options] addopts = '--import-mode=importlib' -markers = ['fast: mark test as fast (should be run on every push)'] +markers = [ + 'fast: mark test as fast (should be run on every push)', + 'network: mark test as downloading data files (deselect with -m "not network" when offline)', +] testpaths = ['tests'] ######################## diff --git a/src/easydynamics/__init__.py b/src/easydynamics/__init__.py index 0b8e27cbd..de7391225 100644 --- a/src/easydynamics/__init__.py +++ b/src/easydynamics/__init__.py @@ -9,16 +9,16 @@ """ from easydynamics.analysis import Analysis +from easydynamics.analysis import Analysis1d from easydynamics.analysis import BoundsSuggestion from easydynamics.analysis import BoundsSuggestions +from easydynamics.analysis import FitBinding from easydynamics.analysis import MultiQPosteriorSampler from easydynamics.analysis import ParameterAnalysis from easydynamics.analysis import ParameterLabels from easydynamics.analysis import ParameterPosterior from easydynamics.analysis import PosteriorSampler from easydynamics.analysis import PosteriorSummary -from easydynamics.analysis.analysis1d import Analysis1d -from easydynamics.analysis.fit_binding import FitBinding from easydynamics.base_classes import EasyDynamicsBase from easydynamics.base_classes import EasyDynamicsModelBase from easydynamics.convolution import Convolution @@ -42,11 +42,11 @@ from easydynamics.settings import ConvolutionSettings from easydynamics.settings import DetailedBalanceSettings from easydynamics.utils import detailed_balance_factor +from easydynamics.utils import hbar from easydynamics.utils import plot_corner from easydynamics.utils import plot_posterior_predictive from easydynamics.utils import plot_trace from easydynamics.utils import slicerplot_with_residuals -from easydynamics.utils.utils import hbar __all__ = [ 'Analysis', diff --git a/src/easydynamics/analysis/__init__.py b/src/easydynamics/analysis/__init__.py index c6eb02a92..2cb853cef 100644 --- a/src/easydynamics/analysis/__init__.py +++ b/src/easydynamics/analysis/__init__.py @@ -2,6 +2,8 @@ # SPDX-License-Identifier: BSD-3-Clause from easydynamics.analysis.analysis import Analysis +from easydynamics.analysis.analysis1d import Analysis1d +from easydynamics.analysis.fit_binding import FitBinding from easydynamics.analysis.parameter_analysis import ParameterAnalysis from easydynamics.analysis.posterior import BoundsSuggestion from easydynamics.analysis.posterior import BoundsSuggestions @@ -13,8 +15,10 @@ __all__ = [ 'Analysis', + 'Analysis1d', 'BoundsSuggestion', 'BoundsSuggestions', + 'FitBinding', 'MultiQPosteriorSampler', 'ParameterAnalysis', 'ParameterLabels', diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index 89af3fc87..f06a818bd 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -1,10 +1,12 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +import warnings from copy import copy from typing import Any import numpy as np +import plopp as pp import scipp as sc from easyscience.fitting.minimizers.utils import FitResults from easyscience.fitting.multi_fitter import MultiFitter @@ -298,6 +300,10 @@ def rebin( self.instrument_model.clear_Q(confirm=True) self._analysis_list_is_dirty = True + self._owner_index = None + # The cached MultiFitter holds the old Analysis1d objects, and the Sampler binds its data + # at construction, so both are stale after a rebin. + self._invalidate_fitter() def calculate( self, @@ -448,11 +454,6 @@ def plot_data_and_model( self._verify_bool(add_background, 'add_background') self._verify_bool(plot_residuals, 'plot_residuals') - if energy is None: - energy = self.energy - - import plopp as pp - data_and_model = self.data_and_model_to_datagroup( energy=energy, add_background=add_background, @@ -464,7 +465,8 @@ def plot_data_and_model( plot_kwargs_defaults['keep'] = 'energy' plot_kwargs_defaults.update(kwargs) - if plot_residuals: + # Residuals may have been omitted (with a warning) for a custom energy grid. + if plot_residuals and 'Residuals' in data_and_model: fig = slicerplot_with_residuals( data_and_model, residuals_key='Residuals', @@ -531,8 +533,20 @@ def data_and_model_to_datagroup( self._verify_bool(include_components, 'include_components') self._verify_bool(include_residuals, 'include_residuals') + custom_energy = energy is not None energy = self._verify_energy(energy) if energy is not None else self.energy + if include_residuals and custom_energy: + # Residuals are data - model on the experiment grid; mixing them with a model on a + # custom grid would make the DataGroup internally inconsistent. + warnings.warn( + 'Residuals are computed on the experiment energy grid and are omitted ' + 'when a custom energy grid is given.', + UserWarning, + stacklevel=2, + ) + include_residuals = False + data_and_model = { 'Data': self.experiment.binned_data, 'Model': self._create_model_array(energy=energy), @@ -683,8 +697,6 @@ def plot_parameters( plot_kwargs_defaults.update(kwargs) - import plopp as pp - return pp.plot( data_to_plot, **plot_kwargs_defaults, @@ -766,6 +778,18 @@ def _on_convolution_settings_changed(self) -> None: self._owner_index = None self._invalidate_fitter() + def _on_detailed_balance_settings_changed(self) -> None: + """ + Update the detailed balance settings when they change. + + The per-Q analyses hold the settings object they were built with, so replacing it on this + Analysis requires rebuilding the list for the new object to reach every Q index. + """ + super()._on_detailed_balance_settings_changed() + self._analysis_list_is_dirty = True + self._owner_index = None + self._invalidate_fitter() + def _ensure_analysis_list_current(self) -> None: """Rebuild the analysis list if any dependency has changed since it was last built.""" if self._analysis_list_is_dirty and self.Q is not None: @@ -953,17 +977,36 @@ def _fit_all_Q_simultaneously(self) -> FitResults: energy=self.experiment.get_masked_energy(Q_index=analysis1d.Q_index, mask=mask_var) ) - mf = MultiFitter( - fit_objects=self.analysis_list, - fit_functions=self.get_fit_functions(), - ) - - return mf.fit( + # Use the configured fitter rather than a throwaway MultiFitter, so minimizer and + # tolerance settings applied through the ``fitter`` property take effect. + return self.fitter.fit( x=xs, y=ys, weights=ws, ) + def get_all_variables(self) -> list[Parameter]: + """ + Get all variables used in the analysis, across every Q index. + + Overrides the easyscience fallback, which scans every attribute of the object and would + therefore build the MultiFitter and the Sampler as side effects of merely listing variables + (and fail outright on an empty analysis). + + Returns + ------- + list[Parameter] + A list of all variables, including any extra parameters. + """ + variables = self.sample_model.get_all_variables() + + variables.extend(self.instrument_model.get_all_variables()) + + if self._extra_parameters: + variables.extend(self._extra_parameters) + + return variables + def get_fit_functions(self) -> list[callable]: """ Get fit functions for all Q indices, which can be used for simultaneous fitting. @@ -1057,9 +1100,10 @@ def _create_components_dataset( ############# def __repr__(self) -> str: + # The property ensures the list is current, so n_analyses is not reported stale. return ( f'{self.__class__.__name__}(' f'display_name={self.display_name!r}, ' f'unique_name={self.unique_name!r}, ' - f'n_analyses={len(self._analysis_list)})' + f'n_analyses={len(self.analysis_list)})' ) diff --git a/src/easydynamics/analysis/analysis1d.py b/src/easydynamics/analysis/analysis1d.py index 2814e269d..6ac0b7eb3 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -1,9 +1,11 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +import warnings from typing import Any import numpy as np +import plopp as pp import scipp as sc from easyscience.fitting.fitter import Fitter as EasyScienceFitter from easyscience.fitting.minimizers.utils import FitResults @@ -120,6 +122,9 @@ def __init__( self._fit_result = None self._convolver = None self._convolver_is_dirty = True + # The model state_versions the convolver was built against; None until it is built. + # Tracked per Analysis1d so sibling analyses sharing a model each notice a change. + self._convolver_model_versions = None self._fitter = None self._fitter_is_dirty = True self._bayesian = None @@ -360,15 +365,34 @@ def _prepare_for_sampling(self) -> None: The energy grid is fixed for the duration of a fit or a sampling run, so the convolution objects are built once here and reused for every model evaluation. + + Staleness is detected by comparing the models' ``state_version`` against the versions the + convolver was built with. Unlike polling the models' dirty flags, reading a version + consumes nothing, so every Analysis1d sharing a model notices the change — not just the + first one to ask. """ - if ( - self.sample_model.component_collections_is_dirty - or self.instrument_model.resolution_model.component_collections_is_dirty - ): + current = self._model_state_versions() + if None in current or current != self._convolver_model_versions: self._convolver_is_dirty = True self._ensure_convolver_current() + def _model_state_versions(self) -> tuple: + """ + Get the current ``state_version`` of each model the convolver depends on. + + Returns + ------- + tuple + The ``(sample_model, resolution_model)`` state versions. ``None`` entries, for models + that do not expose ``state_version`` yet, never compare equal to a recorded build + version, so the convolver is then conservatively rebuilt. + """ + return ( + getattr(self.sample_model, 'state_version', None), + getattr(self.instrument_model.resolution_model, 'state_version', None), + ) + def as_fit_function( self, _x: np.ndarray | sc.Variable | None = None, @@ -455,8 +479,6 @@ def plot_data_and_model( InteractiveFigure A plot of the data and model. """ - import plopp as pp - data_and_model = self.data_and_model_to_datagroup( energy=energy, add_background=add_background, @@ -467,7 +489,8 @@ def plot_data_and_model( plot_kwargs_defaults = self._build_plot_style_defaults(data_and_model) plot_kwargs_defaults.update(kwargs) - if plot_residuals: + # Residuals may have been omitted (with a warning) for a custom energy grid. + if plot_residuals and 'Residuals' in data_and_model: fig = slicerplot_with_residuals( data_and_model, residuals_key='Residuals', @@ -539,10 +562,22 @@ def data_and_model_to_datagroup( raise ValueError('Q_index must be set to create DataGroup.') energy = self._verify_energy(energy) + custom_energy = energy is not None if energy is None: energy = self._masked_energy + if include_residuals and custom_energy: + # Residuals are data - model on the experiment grid; mixing them with a model on a + # custom grid would make the DataGroup internally inconsistent. + warnings.warn( + 'Residuals are computed on the experiment energy grid and are omitted ' + 'when a custom energy grid is given.', + UserWarning, + stacklevel=2, + ) + include_residuals = False + data_and_model = { 'Data': self.experiment.get_masked_binned_data(Q_index=self.Q_index), 'Model': self._create_model_array(energy=energy), @@ -589,6 +624,7 @@ def rebin(self, dimensions: dict[str, int | sc.Variable]) -> None: def refresh_convolver(self, energy: sc.Variable | None = None) -> None: """Refresh the pre-built Convolution object for the current Q index.""" + self._convolver_model_versions = self._model_state_versions() self._convolver = self._create_convolver(energy=energy) self._convolver_is_dirty = False @@ -660,9 +696,15 @@ def _on_convolution_settings_changed(self) -> None: super()._on_convolution_settings_changed() self._convolver_is_dirty = True + def _on_detailed_balance_settings_changed(self) -> None: + """Mark the convolver as dirty when the detailed balance settings change.""" + super()._on_detailed_balance_settings_changed() + self._convolver_is_dirty = True + def _ensure_convolver_current(self) -> None: """Rebuild the convolver if any dependency has changed since it was last built.""" if self._convolver_is_dirty: + self._convolver_model_versions = self._model_state_versions() self._convolver = self._create_convolver() self._convolver_is_dirty = False diff --git a/src/easydynamics/analysis/analysis_base.py b/src/easydynamics/analysis/analysis_base.py index 48b4abc89..9fe0a531b 100644 --- a/src/easydynamics/analysis/analysis_base.py +++ b/src/easydynamics/analysis/analysis_base.py @@ -375,6 +375,7 @@ def detailed_balance_settings(self, value: DetailedBalanceSettings) -> None: if not isinstance(value, DetailedBalanceSettings): raise TypeError('detailed_balance_settings must be a DetailedBalanceSettings') self._detailed_balance_settings = value + self._on_detailed_balance_settings_changed() @property def extra_parameters(self) -> list[Parameter]: @@ -494,6 +495,11 @@ def _on_convolution_settings_changed(self) -> None: For subclasses that implement convolution, this method can be overridden """ + def _on_detailed_balance_settings_changed(self) -> None: + """ + For subclasses that apply detailed balance, this method can be overridden + """ + def _verify_energy(self, energy: sc.Variable | None) -> sc.Variable | None: """ Verify that the provided energy is the correct type. diff --git a/src/easydynamics/analysis/parameter_analysis.py b/src/easydynamics/analysis/parameter_analysis.py index ffe5fd2cf..1e99f006a 100644 --- a/src/easydynamics/analysis/parameter_analysis.py +++ b/src/easydynamics/analysis/parameter_analysis.py @@ -270,8 +270,8 @@ def fit(self) -> FitResults: The results of the fit """ - xs, ys, ws, _, models = self._build_fit_inputs() - self._invalidate_fitter_if_targets_changed(models) + xs, ys, ws, _, _ = self._build_fit_inputs() + self._invalidate_fitter_if_targets_changed() return self.fitter.fit(x=xs, y=ys, weights=ws) def _build_fit_inputs(self) -> tuple[list, list, list, list, list]: @@ -343,43 +343,42 @@ def _build_fitter(self) -> MultiFitter: A MultiFitter over the per-target models and fit functions. """ _, _, _, funcs, models = self._build_fit_inputs() - self._fitter_targets = self._target_signature(models) + self._fitter_targets = self._target_signature() return MultiFitter(fit_objects=models, fit_functions=funcs) - @staticmethod - def _target_signature(models: list) -> tuple: + def _target_signature(self) -> tuple: """ - Summarize which models the fitter was built for, in target order. + Summarize what the fitter was built for, in target order. - Parameters - ---------- - models : list - The model behind each fit target. + Each entry records the target's model, prediction name, and dataset key. The targets + themselves must be part of the signature, not just the models: swapping which predictions a + binding fits (``binding.targets = ['width'] -> ['area']``) keeps the model list identical + while changing both the frozen fit functions and the data they are fitted against. Returns ------- tuple A comparable signature of the current targets. """ - return tuple(model.unique_name for model in models) + return tuple( + (binding.model.unique_name, target.name, target.dataset_key) + for binding in self.bindings + for target in binding.get_targets() + ) - def _invalidate_fitter_if_targets_changed(self, models: list) -> None: + def _invalidate_fitter_if_targets_changed(self) -> None: """ Rebuild the cached fitter when the bindings no longer resolve to the same targets. A FitBinding can be edited in place -- ``binding.targets = ...`` -- which this object - cannot observe. Doing so changes how many datasets there are, while the cached MultiFitter - still holds the old fit functions, and the fit then dies deep inside the minimizer. Compare - the targets the fitter was built for against the current ones instead. - - Parameters - ---------- - models : list - The model behind each fit target, as currently resolved. + cannot observe. Doing so changes which functions are fitted against which datasets, while + the cached MultiFitter still holds the old fit functions, and the fit then either dies deep + inside the minimizer or silently fits stale functions. Compare the targets the fitter was + built for against the current ones instead. """ if self._fitter is None: return - if self._target_signature(models) != getattr(self, '_fitter_targets', None): + if self._target_signature() != getattr(self, '_fitter_targets', None): self._invalidate_fitter() def _sampling_data(self) -> tuple[list, list, list]: @@ -391,8 +390,8 @@ def _sampling_data(self) -> tuple[list, list, list]: tuple[list, list, list] The ``(x, y, weights)`` triple, one entry per fit target. """ - xs, ys, ws, _, models = self._build_fit_inputs() - self._invalidate_fitter_if_targets_changed(models) + xs, ys, ws, _, _ = self._build_fit_inputs() + self._invalidate_fitter_if_targets_changed() return xs, ys, ws def _chain_parameters(self) -> list[Parameter]: @@ -445,10 +444,6 @@ def plot( if self.parameters is None: raise ValueError('No parameters available to plot.') - full_model_dataset = None - if self.bindings: - full_model_dataset = self.calculate_model_dataset(self.bindings) - # If no names are provided, default to plot all parameters that have bindings. # If no bindings are provided, plot all parameters. if names is None: @@ -462,6 +457,23 @@ def plot( names = self._normalize_names(names) + if not names: + raise ValueError( + 'names must not be an empty list. Pass parameter names to plot, ' + 'or None to plot all parameters with bindings.' + ) + + # Evaluate only the bindings whose targets are actually being plotted. + full_model_dataset = None + if self.bindings: + relevant_bindings = [ + b + for b in self.bindings + if any(target.dataset_key in names for target in b.get_targets()) + ] + if relevant_bindings: + full_model_dataset = self.calculate_model_dataset(relevant_bindings) + # Check that the units of the specified parameters are consistent. units = [self.parameters[name].unit for name in names] first_unit = units[0] @@ -589,12 +601,14 @@ def append_binding(self, binding: FitBinding) -> None: if not isinstance(binding, FitBinding): raise TypeError('binding must be a FitBinding object.') self._bindings.append(binding) + self._invalidate_fitter() def clear_bindings(self) -> None: """ Clear all FitBindings from the list of bindings for the parameter analysis. """ self._bindings.clear() + self._invalidate_fitter() def get_all_variables(self) -> list: """ @@ -638,7 +652,8 @@ def _verify_bindings(self, bindings: FitBinding | list[FitBinding] | None) -> li if isinstance(bindings, FitBinding): return [bindings] if isinstance(bindings, list) and all(isinstance(b, FitBinding) for b in bindings): - return bindings + # Copy so later mutation of the caller's list cannot silently change the bindings. + return list(bindings) raise TypeError('bindings must be a FitBinding, a list of FitBindings, or None.') def _verify_parameters(self, parameters: sc.Dataset | Analysis | None) -> sc.Dataset | None: @@ -799,7 +814,10 @@ def _get_xyweight_from_dataset( q_values = self._parameters[parameter_name].coords['Q'].values if variances is None: - return q_values, values, np.ones_like(values) + # Apply the same finite filtering as the variance path: NaN values arise when a + # parameter is absent for a given Q, and must not leak into a fit. + finite_mask = np.isfinite(values) + return q_values[finite_mask], values[finite_mask], np.ones_like(values[finite_mask]) # NaN variances arise when a parameter is absent for a given Q (parameters_to_dataset # fills np.nan for missing parameters). Filter those rows silently; other non-finite or diff --git a/src/easydynamics/analysis/posterior_sampling.py b/src/easydynamics/analysis/posterior_sampling.py index 04ca3104a..7d2615938 100644 --- a/src/easydynamics/analysis/posterior_sampling.py +++ b/src/easydynamics/analysis/posterior_sampling.py @@ -783,6 +783,8 @@ def plot_trace(self, **kwargs: dict[str, Any]) -> Figure: Figure The matplotlib Figure. """ + # Deliberately imported lazily, to guard against an import cycle between the + # analysis and utils packages. from easydynamics.utils.posterior_plotting import plot_trace results = self._require_results() @@ -809,6 +811,8 @@ def plot_corner(self, **kwargs: dict[str, Any]) -> Figure: Figure The matplotlib Figure. """ + # Deliberately imported lazily, to guard against an import cycle between the + # analysis and utils packages. from easydynamics.utils.posterior_plotting import plot_corner results = self._require_results() @@ -909,6 +913,8 @@ def plot_posterior_predictive( ValueError If n_draws is not a positive integer. """ + # Deliberately imported lazily, to guard against an import cycle between the + # analysis and utils packages. from easydynamics.utils.posterior_plotting import plot_posterior_predictive if not isinstance(n_draws, int) or isinstance(n_draws, bool) or n_draws < 1: @@ -1203,8 +1209,8 @@ def sample( Notes ----- - An ``IndexError`` or ``TypeError`` propagates from the Q_index validation if Q_index is - out of range or not an int. + An ``IndexError`` or ``TypeError`` propagates from the Q_index validation if Q_index is out + of range or not an int. """ if fit_method not in ('independent', 'simultaneous'): raise ValueError("Invalid fit method. Choose 'independent' or 'simultaneous'.") @@ -1400,9 +1406,11 @@ def plot_corner(self, Q_index: int | None = None, **kwargs: dict[str, Any]) -> F Notes ----- - An ``IndexError`` or ``TypeError`` propagates from the Q_index validation if Q_index is - out of range or not an int. + An ``IndexError`` or ``TypeError`` propagates from the Q_index validation if Q_index is out + of range or not an int. """ + # Deliberately imported lazily, to guard against an import cycle between the + # analysis and utils packages. from easydynamics.utils.posterior_plotting import corner_with_slider verify_Q_index(Q_index=Q_index, Q=self._analysis.Q, allow_none=True) @@ -1614,9 +1622,9 @@ def plot_posterior_predictive( ----- A ``NotImplementedError`` propagates when the latest chain is simultaneous: it binds every dataset at once, and no per-Q chain exists for Q_index to pick out. A ``RuntimeError`` - propagates if a slider is asked for outside a notebook or nothing has been sampled yet, - and an ``IndexError`` or ``TypeError`` from the Q_index validation if Q_index is out of - range or not an int. + propagates if a slider is asked for outside a notebook or nothing has been sampled yet, and + an ``IndexError`` or ``TypeError`` from the Q_index validation if Q_index is out of range + or not an int. """ if not isinstance(n_draws, int) or isinstance(n_draws, bool) or n_draws < 1: raise ValueError(f'n_draws must be a positive integer. Got {n_draws}.') diff --git a/src/easydynamics/base_classes/easydynamics_list.py b/src/easydynamics/base_classes/easydynamics_list.py index 74e48a5b9..7c80b2e95 100644 --- a/src/easydynamics/base_classes/easydynamics_list.py +++ b/src/easydynamics/base_classes/easydynamics_list.py @@ -12,7 +12,7 @@ from easydynamics.base_classes.easydynamics_modelbase import EasyDynamicsModelBase from easydynamics.exceptions import AmbiguousNameError -ProtectedType_ = TypeVar('T', bound=EasyDynamicsBase | EasyDynamicsModelBase) +ProtectedType_ = TypeVar('ProtectedType_', bound=EasyDynamicsBase | EasyDynamicsModelBase) class EasyDynamicsList(EasyList[ProtectedType_]): @@ -49,6 +49,10 @@ def __init__( if display_name is None: display_name = unique_name + # Must exist before super().__init__, which appends the initial items through the + # version-bumping mutators below. + self._version = 0 + super().__init__( *args, protected_types=protected_types, @@ -57,6 +61,31 @@ def __init__( **kwargs, ) + # A freshly constructed list always reports version 0, regardless of how many + # initial items were added during construction. + self._version = 0 + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def version(self) -> int: + """ + Get the mutation version of the list. + + Starts at 0 for a freshly constructed list and is incremented by every mutating operation + (append, insert, extend, remove, pop, clear, sort, item assignment and deletion). Consumers + can record the version and later compare it to detect in-place mutations without callbacks. + Read-only; reading never mutates the list. + + Returns + ------- + int + The current mutation version. + """ + return self._version + # ------------------------------------------------------------------ # List methods # ------------------------------------------------------------------ @@ -87,6 +116,7 @@ def insert(self, index: int, value: ProtectedType_) -> None: return super().insert(index, value) + self._bump_version() def append(self, value: ProtectedType_) -> None: """ @@ -126,14 +156,32 @@ def pop(self, index: int | str = -1) -> ProtectedType_: # Overwritten to update warning if isinstance(index, int): - return self._data.pop(index) + item = self._data.pop(index) + self._bump_version() + return item if isinstance(index, str): for i, item in enumerate(self._data): if self._get_key(item) == index: - return self._data.pop(i) + popped = self._data.pop(i) + self._bump_version() + return popped raise KeyError(f'No item with name "{index}" found') raise TypeError('Index must be an int or str') + def sort(self, key: object = None, reverse: bool = False) -> None: + """ + Sort the list in place according to the given key function. + + Parameters + ---------- + key : object, default=None + Mapping function to sort by. + reverse : bool, default=False + Whether to reverse the sort order. + """ + super().sort(key=key, reverse=reverse) + self._bump_version() + # ------------------------------------------------------------------ # Other methods # ------------------------------------------------------------------ @@ -172,6 +220,30 @@ def get_duplicate_names(self) -> list[str]: # Private methods # ------------------------------------------------------------------ + def _bump_version(self) -> None: + """Record that the list was mutated, so version-based consumers rebuild.""" + self._version += 1 + + def _copy_with_items(self, items: list[ProtectedType_]) -> EasyDynamicsList[ProtectedType_]: + """ + Create a new instance of this list class containing the given items. + + Used by slicing. Subclasses whose constructor signature differs from EasyDynamicsList's + (e.g. ComponentCollection) must override this so slicing returns a working instance of the + same class. + + Parameters + ---------- + items : list[ProtectedType_] + The items the new list should contain. + + Returns + ------- + EasyDynamicsList[ProtectedType_] + A new list of the same class containing the items. + """ + return self.__class__(items, protected_types=self._protected_types) + def _get_key(self, obj: EasyDynamicsBase | EasyDynamicsModelBase) -> str: """ Get the name of an object. @@ -241,7 +313,7 @@ def __getitem__( if isinstance(idx, int): return self._data[idx] if isinstance(idx, slice): - return self.__class__(self._data[idx], protected_types=self._protected_types) + return self._copy_with_items(self._data[idx]) if isinstance(idx, str): matches = [r for r in self._data if self._get_key(r) == idx] if len(matches) == 1: @@ -251,3 +323,57 @@ def __getitem__( raise KeyError(f'No item with name "{idx}" found') raise TypeError('Index must be an int, slice, or str') + + def __setitem__(self, idx: int | slice, value: ProtectedType_ | list[ProtectedType_]) -> None: + """ + Set an item (or slice of items) in the list. + + Mirrors the duplicate handling of append/insert: assigning an item that is already in the + list (to a different position) warns and is ignored. + + Parameters + ---------- + idx : int | slice + The index or slice to assign to. + value : ProtectedType_ | list[ProtectedType_] + The new item (or items, for a slice) to assign. Items must be instances of one of the + protected types. + + Notes + ----- + A ``TypeError`` propagates from the type validation or the base assignment if idx or value + has an invalid type, and a ``ValueError`` propagates from the base assignment if slice + assignment changes the slice length. + """ + if isinstance(idx, int): + self._validate_type(value) + if value is not self._data[idx] and value in self: + warnings.warn( + ( + f'Item with name "{self._get_key(value)}" already ' + f'in EasyDynamicsList, it will be ignored' + ), + UserWarning, + stacklevel=2, + ) + return + + super().__setitem__(idx, value) + self._bump_version() + + def __delitem__(self, idx: int | slice | str) -> None: + """ + Delete an item by index, slice, or name. + + Parameters + ---------- + idx : int | slice | str + Index, slice, or name of the item to delete. + + Notes + ----- + A ``KeyError`` propagates from the base deletion if idx is a string that does not match any + item, and a ``TypeError`` propagates from it if idx is not an int, slice, or string. + """ + super().__delitem__(idx) + self._bump_version() diff --git a/src/easydynamics/base_classes/name_mixin.py b/src/easydynamics/base_classes/name_mixin.py index 608ce561d..52b3472af 100644 --- a/src/easydynamics/base_classes/name_mixin.py +++ b/src/easydynamics/base_classes/name_mixin.py @@ -29,9 +29,11 @@ def __init__( If name is not a string. """ - super().__init__(*args, **kwargs) + # Validate before delegating to the parent class so an invalid name fails fast, + # before the parent registers the object in the global map. if not isinstance(name, str): raise TypeError('Name must be a string.') + super().__init__(*args, **kwargs) self._name = name @property diff --git a/src/easydynamics/convolution/analytical_convolution.py b/src/easydynamics/convolution/analytical_convolution.py index efcdf6f52..1f7e4e7ab 100644 --- a/src/easydynamics/convolution/analytical_convolution.py +++ b/src/easydynamics/convolution/analytical_convolution.py @@ -11,7 +11,6 @@ from easydynamics.sample_model import Gaussian from easydynamics.sample_model import Lorentzian from easydynamics.sample_model import Voigt -from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components.model_component import ModelComponent @@ -20,12 +19,13 @@ class AnalyticalConvolution(ConvolutionBase): Analytical convolution of a ModelComponent or ComponentCollection with a ResolutionModel. Possible analytical convolutions are any combination of delta functions, Gaussians, Lorentzians - and Voigt profiles. + and Voigt profiles. Dispatch is subclass-tolerant: a subclass of e.g. Lorentzian is convolved + with the Lorentzian rules. """ - # Mapping of supported component type pairs to convolution methods. + # Mapping of supported canonical component-type-name pairs to convolution methods. # Delta functions are handled separately. - _CONVOLUTIONS: ClassVar[dict[str, object]] = { + _CONVOLUTIONS: ClassVar[dict[tuple[str, str], str]] = { ('Gaussian', 'Gaussian'): '_convolute_gaussian_gaussian', ('Gaussian', 'Lorentzian'): '_convolute_gaussian_lorentzian', ('Gaussian', 'Voigt'): '_convolute_gaussian_voigt', @@ -34,6 +34,65 @@ class AnalyticalConvolution(ConvolutionBase): ('Voigt', 'Voigt'): '_convolute_voigt_voigt', } + # The analytical base types used to resolve a component (or a subclass of one of them) + # to its canonical dispatch name. + _ANALYTICAL_TYPES: ClassVar[tuple[type[ModelComponent], ...]] = (Gaussian, Lorentzian, Voigt) + + def __init__(self, *args: object, **kwargs: object) -> None: + """ + Initialize the AnalyticalConvolution. + + Accepts the same arguments as ConvolutionBase, but requires sample_components and + resolution_components to be provided. + + Parameters + ---------- + *args : object + Positional arguments passed to ConvolutionBase. + **kwargs : object + Keyword arguments passed to ConvolutionBase. + + Raises + ------ + TypeError + If sample_components or resolution_components is None. + """ + super().__init__(*args, **kwargs) + # ConvolutionBase tolerates None collections, but an analytical convolver cannot + # convolve without both models — fail early with a clear error. + if self._sample_components is None: + raise TypeError( + 'sample_components must be a ComponentCollection or ModelComponent, not None.' + ) + if self._resolution_components is None: + raise TypeError( + 'resolution_components must be a ComponentCollection or ModelComponent, not None.' + ) + + @classmethod + def _canonical_type_name(cls, component: ModelComponent) -> str: + """ + Resolve a component to the canonical analytical type name used for dispatch. + + A subclass of one of the analytical types (Gaussian, Lorentzian, Voigt) resolves to its + base type's name, so subclasses are convolved with the base type's rules. + + Parameters + ---------- + component : ModelComponent + The component to resolve. + + Returns + ------- + str + The canonical type name, or the component's own class name if it is not an analytical + type. + """ + for analytical_type in cls._ANALYTICAL_TYPES: + if isinstance(component, analytical_type): + return analytical_type.__name__ + return type(component).__name__ + def convolution( self, ) -> np.ndarray: @@ -90,8 +149,8 @@ def _convolute_analytic_pair( The convolution of two Voigt profiles results in another Voigt profile, with the Gaussian widths summed in quadrature and the Lorentzian widths summed. - The convolution of a delta function with any component or ComponentCollection results in - the same component or ComponentCollection shifted by the delta center. + The convolution of a delta function with any component results in the same component + shifted by the delta center. All areas are multiplied in the convolution. @@ -127,15 +186,15 @@ def _convolute_analytic_pair( resolution_component, ) - pair = (type(sample_component).__name__, type(resolution_component).__name__) + sample_name = self._canonical_type_name(sample_component) + resolution_name = self._canonical_type_name(resolution_component) + + pair = (sample_name, resolution_name) swapped = False if pair not in self._CONVOLUTIONS: # Try reversing the pair - pair = ( - type(resolution_component).__name__, - type(sample_component).__name__, - ) + pair = (resolution_name, sample_name) swapped = True func_name = self._CONVOLUTIONS.get(pair) @@ -154,26 +213,25 @@ def _convolute_analytic_pair( def _convolute_delta_any( self, sample_component: DeltaFunction, - resolution_components: ComponentCollection | ModelComponent, + resolution_component: ModelComponent, ) -> np.ndarray: """ - Convolution of delta function with any ModelComponent or ComponentCollection results in the - same component or ComponentCollection shifted by the delta center. The areas are - multiplied. + Convolution of a delta function with a resolution component results in the same component + shifted by the delta center. The areas are multiplied. Parameters ---------- sample_component : DeltaFunction - The sample component to be convolved. - resolution_components : ComponentCollection | ModelComponent - The resolution model to convolve with. + The sample delta function to be convolved. + resolution_component : ModelComponent + The resolution component to convolve with. Returns ------- np.ndarray The evaluated convolution values at self.energy. """ - return sample_component.area.value * resolution_components.evaluate( + return sample_component.area.value * resolution_component.evaluate( self.energy_with_offset.values - sample_component.center.value ) diff --git a/src/easydynamics/convolution/convolution.py b/src/easydynamics/convolution/convolution.py index 578df564f..7bf29c64e 100644 --- a/src/easydynamics/convolution/convolution.py +++ b/src/easydynamics/convolution/convolution.py @@ -79,13 +79,14 @@ class Convolution(NumericalConvolutionBase): # needs to be rebuilt. # Note: the public 'energy' property setter always writes to '_energy', so '_energy' alone # is sufficient — listing 'energy' separately would cause a double invalidation. + # In-place mutations of the collections, settings-flag changes, and energy_offset + # rebinds are detected separately via the plan-state snapshot and the settings' plan + # versions (see NumericalConvolutionBase._convolution_plan_is_current). _invalidate_plan_on_change: ClassVar[set[str]] = { '_energy', '_sample_components', '_resolution_components', '_temperature', - '_energy_unit', - '_normalize_detailed_balance', '_detailed_balance_settings', } @@ -223,7 +224,7 @@ def _check_if_pair_is_analytic( Raises ------ - TypeError + ValueError If the resolution component is a DeltaFunction. Returns @@ -233,8 +234,8 @@ def _check_if_pair_is_analytic( """ if isinstance(resolution_component, DeltaFunction): - raise TypeError( - 'resolution components contains delta functions. This is not supported.' + raise ValueError( + 'resolution_components contains delta functions. This is not supported.' ) analytical_types = (Gaussian, Lorentzian, Voigt) @@ -243,11 +244,49 @@ def _check_if_pair_is_analytic( and isinstance(resolution_component, analytical_types) ) + def _prune_plan_object(self, obj: object) -> None: + """ + Remove a plan-internal object from the easyscience global map. + + The plan collections and sub-convolvers are private, per-plan objects recreated on every + rebuild; pruning the previous generation keeps the global map from growing with every + rebuild. + + Parameters + ---------- + obj : object + The object to prune, or None for a no-op. + """ + if obj is not None: + self._global_object.map.prune(obj.unique_name) + def _build_convolution_plan(self) -> None: """ Separate sample model components into analytical pairs, delta functions, and the rest. + + Raises + ------ + ValueError + If the resolution collection is empty or contains a DeltaFunction. """ + if self._resolution_components.is_empty: + raise ValueError( + 'resolution_components is empty. Convolution with an empty resolution ' + 'model is not defined; add at least one resolution component.' + ) + self._validate_no_delta_in_resolution(self._resolution_components) + + # Previous plan collections are recreated below; remove them from the global map so + # rebuilds do not leak registry entries. + self._prune_plan_object(getattr(self, '_analytical_sample_components', None)) + self._prune_plan_object(getattr(self, '_delta_sample_components', None)) + self._prune_plan_object(getattr(self, '_numerical_sample_components', None)) + + # Keep the (otherwise unused) inherited dense grid in sync with the current energy + # and settings so it can never hold stale state. + self._energy_grid = self._create_energy_grid() + analytical_sample_components = ComponentCollection(x_unit=self.x_unit, y_unit=self.y_unit) delta_sample_components = ComponentCollection(x_unit=self.x_unit, y_unit=self.y_unit) numerical_sample_components = ComponentCollection(x_unit=self.x_unit, y_unit=self.y_unit) @@ -299,6 +338,11 @@ def _set_convolvers(self) -> None: convolution method. """ + # Previous sub-convolvers are recreated below; remove them from the global map so + # rebuilds do not leak registry entries. + self._prune_plan_object(getattr(self, '_analytical_convolver', None)) + self._prune_plan_object(getattr(self, '_numerical_convolver', None)) + if self._analytical_sample_components: self._analytical_convolver = AnalyticalConvolution( energy=self.energy, @@ -337,15 +381,22 @@ def convert_y_unit(self, unit: str) -> None: The new y-axis unit. """ super().convert_y_unit(unit) - # The sub-convolvers share this convolver's component objects, which were already - # converted by super(); only their y-unit labels need updating. + # The sub-convolvers and plan collections share this convolver's component objects, + # which were already converted by super(); only their y-unit labels need updating. if getattr(self, '_analytical_convolver', None) is not None: self._analytical_convolver._relabel_y_unit(self.y_unit) # ruff: ignore[private-member-access] if getattr(self, '_numerical_convolver', None) is not None: self._numerical_convolver._relabel_y_unit(self.y_unit) # ruff: ignore[private-member-access] + for collection in ( + getattr(self, '_analytical_sample_components', None), + getattr(self, '_delta_sample_components', None), + getattr(self, '_numerical_sample_components', None), + ): + if collection is not None: + collection._y_unit = self.y_unit # ruff: ignore[private-member-access] # Update some setters so the internal sample models are updated - def __setattr__(self, name: str, value: any) -> None: + def __setattr__(self, name: str, value: object) -> None: """ Custom setattr to invalidate convolution plan on relevant attribute changes, and build a new plan. @@ -357,7 +408,7 @@ def __setattr__(self, name: str, value: any) -> None: ---------- name : str The name of the attribute to set. - value : any + value : object The value to set the attribute to. """ super().__setattr__(name, value) diff --git a/src/easydynamics/convolution/convolution_base.py b/src/easydynamics/convolution/convolution_base.py index 21eec5e10..9ba0cc96c 100644 --- a/src/easydynamics/convolution/convolution_base.py +++ b/src/easydynamics/convolution/convolution_base.py @@ -9,6 +9,7 @@ from easydynamics.base_classes import EasyDynamicsModelBase from easydynamics.sample_model.component_collection import ComponentCollection +from easydynamics.sample_model.components.delta_function import DeltaFunction from easydynamics.sample_model.components.model_component import ModelComponent from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import convert_parameter_unit @@ -59,10 +60,16 @@ def __init__( Raises ------ TypeError - If energy is not a numpy ndarray or a scipp Variable or if energy_unit is not a string - or scipp unit, or if energy_offset is not a number or a Parameter, or if - sample_components is not a ComponentCollection or ModelComponent, or if - resolution_components is not a ComponentCollection or ModelComponent. + If energy is not a numpy ndarray or a scipp Variable or if x_unit is not a string or + scipp unit, or if energy_offset is not a number or a Parameter, or if sample_components + is not a ComponentCollection or ModelComponent, or if resolution_components is not a + ComponentCollection or ModelComponent. + + Notes + ----- + A ``ValueError`` propagates from the validation helpers if resolution_components contains a + DeltaFunction, or if the x_unit of the sample or resolution components does not match the + convolver's x_unit. """ super().__init__( @@ -118,8 +125,72 @@ def __init__( x_unit=resolution_components.x_unit, y_unit=resolution_components.y_unit, ) + self._validate_no_delta_in_resolution(resolution_components) self._resolution_components = resolution_components + self._validate_component_x_units() + + # ------------------------------------------------------------------ + # Validation helpers + # ------------------------------------------------------------------ + + @staticmethod + def _validate_no_delta_in_resolution( + resolution_components: ComponentCollection | None, + ) -> None: + """ + Validate that the resolution collection contains no DeltaFunction components. + + Convolving with a delta function in the resolution is not supported on any path + (analytical, numerical, or delta), so the invariant is enforced when the resolution is + bound to the convolver. + + Parameters + ---------- + resolution_components : ComponentCollection | None + The resolution collection to validate. None is skipped. + + Raises + ------ + ValueError + If resolution_components contains a DeltaFunction. + """ + if resolution_components is None: + return + if any(isinstance(component, DeltaFunction) for component in resolution_components): + raise ValueError( + 'resolution_components contains delta functions. This is not supported.' + ) + + def _validate_component_x_units(self) -> None: + """ + Validate that sample and resolution collections use the convolver's x_unit. + + Components in a different (even compatible) x_unit would be evaluated with raw numbers in + the wrong unit, silently producing wrong results. + + Raises + ------ + ValueError + If a collection's x_unit differs from the convolver's x_unit. + """ + if self._x_unit is None: + return + for label, collection in ( + ('sample_components', self._sample_components), + ('resolution_components', self._resolution_components), + ): + if collection is None or collection.x_unit is None: + continue + # Compare as sc.Unit so unit aliases (e.g. 'ueV' vs 'micro-eV') are not false + # mismatches. + if sc.Unit(str(collection.x_unit)) != sc.Unit(str(self._x_unit)): + raise ValueError( + f'{label} has x_unit {str(collection.x_unit)!r}, which does not match the ' + f'convolver x_unit {str(self._x_unit)!r}. Convert the components with ' + f'convert_x_unit before constructing the convolver.' + ) + @property def energy_offset(self) -> Parameter: """ @@ -192,12 +263,15 @@ def energy(self, energy: np.ndarray | sc.Variable) -> None: Parameters ---------- energy : np.ndarray | sc.Variable - 1D array of energy values where the convolution is evaluated. + 1D array of energy values where the convolution is evaluated. A scipp Variable must + carry the convolver's x_unit; the x_unit itself can only be changed via convert_x_unit. Raises ------ TypeError If energy is not a numpy ndarray or a scipp Variable. + ValueError + If energy is a scipp Variable whose unit differs from the convolver's x_unit. """ if isinstance(energy, Numeric): @@ -210,8 +284,15 @@ def energy(self, energy: np.ndarray | sc.Variable) -> None: self._energy = energy_to_scipp(energy, self._energy.unit) if isinstance(energy, sc.Variable): + # Compare as sc.Unit so unit aliases (e.g. 'ueV' vs 'micro-eV') are not false + # mismatches. + if self._x_unit is not None and energy.unit != sc.Unit(str(self._x_unit)): + raise ValueError( + f'energy has unit {str(energy.unit)!r}, which does not match the convolver ' + f'x_unit {str(self._x_unit)!r}. Use convert_x_unit to change the unit, or ' + f'provide energy in {str(self._x_unit)!r}.' + ) self._energy = energy - self._x_unit = energy.unit def convert_x_unit(self, unit: str | sc.Unit) -> None: """ @@ -237,7 +318,9 @@ def convert_x_unit(self, unit: str | sc.Unit) -> None: old_offset_unit = str(self.energy_offset.unit) def _convert_energy(target_unit: str | sc.Unit) -> None: - self.energy = sc.to_unit(self.energy, target_unit) + # Assign the backing field directly: the public setter rejects unit changes + # (convert_x_unit is the one supported route for those). + self._energy = sc.to_unit(self._energy, target_unit) conversions = [ (_convert_energy, unit, old_x_unit), @@ -249,7 +332,8 @@ def _convert_energy(target_unit: str | sc.Unit) -> None: conversions.append((self.resolution_components.convert_x_unit, unit, old_x_unit)) convert_units_with_rollback(conversions) - self._x_unit = unit + # Keep the str contract for x_unit even when an sc.Unit was passed. + self._x_unit = str(unit) if isinstance(unit, sc.Unit) else unit def convert_y_unit(self, unit: str | sc.Unit) -> None: """ @@ -362,6 +446,11 @@ def resolution_components( ------ TypeError If resolution_components is not a ComponentCollection or ModelComponent. + + Notes + ----- + A ``ValueError`` propagates from the validation helper if resolution_components contains a + DeltaFunction. """ if not isinstance(resolution_components, (ComponentCollection, ModelComponent)): raise TypeError( @@ -374,4 +463,5 @@ def resolution_components( x_unit=resolution_components.x_unit, y_unit=resolution_components.y_unit, ) + self._validate_no_delta_in_resolution(resolution_components) self._resolution_components = resolution_components diff --git a/src/easydynamics/convolution/numerical_convolution_base.py b/src/easydynamics/convolution/numerical_convolution_base.py index e5cadda98..77447b1dc 100644 --- a/src/easydynamics/convolution/numerical_convolution_base.py +++ b/src/easydynamics/convolution/numerical_convolution_base.py @@ -81,8 +81,8 @@ def __init__( Raises ------ TypeError - If temperature is not None, a number, or a Parameter, or if temperature_unit is not a - string or sc.Unit. + If sample_components or resolution_components is None, or if temperature is not None, a + number, or a Parameter, or if temperature_unit is not a string or sc.Unit. """ super().__init__( energy=energy, @@ -95,6 +95,17 @@ def __init__( unique_name=unique_name, ) + # ConvolutionBase tolerates None collections, but numerical convolvers cannot + # convolve without both models — fail early with a clear error. + if self._sample_components is None: + raise TypeError( + 'sample_components must be a ComponentCollection or ModelComponent, not None.' + ) + if self._resolution_components is None: + raise TypeError( + 'resolution_components must be a ComponentCollection or ModelComponent, not None.' + ) + if temperature is not None and not isinstance(temperature, (Numeric, Parameter)): raise TypeError('Temperature must be None, a number or a Parameter.') @@ -126,10 +137,13 @@ def _convolution_plan_is_current(self) -> bool: """ Check whether this convolver's plan is up to date. - Plan validity is tracked per convolver so several convolvers can share one - ConvolutionSettings object: each convolver stores the settings' plan version it last - rebuilt against (None after a convolver-local invalidation such as a new energy grid), and - the settings bump their version whenever an accuracy knob changes. + Plan validity is tracked per convolver so several convolvers can share one settings object: + each convolver stores the plan versions of its ConvolutionSettings and + DetailedBalanceSettings it last rebuilt against (None after a convolver-local invalidation + such as a new energy grid), and the settings bump their versions whenever a knob changes. + In addition, a snapshot of the component collections' mutation versions and the + energy_offset binding is compared, so in-place mutations of a live collection (e.g. + append_component) or rebinding the offset to a new Parameter also invalidate the plan. Returns ------- @@ -139,11 +153,40 @@ def _convolution_plan_is_current(self) -> bool: seen_version = getattr(self, '_plan_seen_version', None) if seen_version is None: return False - return self.convolution_settings._plan_valid_for(seen_version) # ruff: ignore[private-member-access] + if not self.convolution_settings._plan_valid_for(seen_version): # ruff: ignore[private-member-access] + return False + seen_db_version = getattr(self, '_plan_seen_db_version', None) + if not self.detailed_balance_settings._plan_valid_for(seen_db_version): # ruff: ignore[private-member-access] + return False + return getattr(self, '_plan_seen_state', None) == self._plan_state_snapshot() def _mark_convolution_plan_current(self) -> None: """Record that this convolver's plan matches its current state and settings.""" self._plan_seen_version = self.convolution_settings._plan_version # ruff: ignore[private-member-access] + self._plan_seen_db_version = self.detailed_balance_settings._plan_version # ruff: ignore[private-member-access] + self._plan_seen_state = self._plan_state_snapshot() + + def _plan_state_snapshot(self) -> tuple: + """ + Snapshot the mutable state the convolution plan was built from. + + Captures the identity and mutation version of the sample and resolution collections (so + both rebinding and in-place mutation are detected) and the identity of the energy_offset + Parameter (so rebinding to a new Parameter invalidates the plan while numeric assignment + mutating the shared Parameter does not). + + Returns + ------- + tuple + A comparable snapshot of the plan-relevant state. + """ + return ( + id(self._sample_components), + self._sample_components.version, + id(self._resolution_components), + self._resolution_components.version, + id(self._energy_offset), + ) @property def convolution_settings(self) -> ConvolutionSettings: @@ -196,6 +239,22 @@ def energy(self, energy: np.ndarray) -> None: ConvolutionBase.energy.fset(self, energy) self._plan_seen_version = None + def convert_x_unit(self, unit: str | sc.Unit) -> None: + """ + Convert the energy axis, energy_offset, and all components to the specified unit, and + invalidate this convolver's plan. + + The dense grid is rebuilt lazily on the next convolution. Other convolvers sharing the same + ConvolutionSettings are unaffected. + + Parameters + ---------- + unit : str | sc.Unit + The unit of the energy. + """ + super().convert_x_unit(unit) + self._plan_seen_version = None + @property def upsample_factor(self) -> Numeric | None: """ @@ -222,7 +281,7 @@ def upsample_factor(self, factor: Numeric | None) -> None: self.convolution_settings.upsample_factor = factor @property - def extension_factor(self) -> float: + def extension_factor(self) -> float | None: """ Get the extension factor. @@ -231,23 +290,24 @@ def extension_factor(self) -> float: Returns ------- - float - The extension factor. + float | None + The extension factor, or None if unset (only valid while upsample_factor is None). """ return self.convolution_settings.extension_factor @extension_factor.setter - def extension_factor(self, factor: Numeric) -> None: + def extension_factor(self, factor: Numeric | None) -> None: """ Set the extension factor. The extension factor determines how much the energy range is extended on both sides before - convolution. 0.2 means extending by 20% of the original energy span on each side. + convolution. 0.2 means extending by 20% of the original energy span on each side. None is + accepted but requires upsample_factor to be None as well before the next convolution. Parameters ---------- - factor : Numeric + factor : Numeric | None The new extension factor. """ self.convolution_settings.extension_factor = factor @@ -331,6 +391,9 @@ def detailed_balance_settings(self, value: DetailedBalanceSettings) -> None: if not isinstance(value, DetailedBalanceSettings): raise TypeError('detailed_balance_settings must be a DetailedBalanceSettings') self._detailed_balance_settings = value + # Convolver-local invalidation: other convolvers sharing the new settings object are + # unaffected. + self._plan_seen_version = None def _create_energy_grid( self, @@ -352,6 +415,11 @@ def _create_energy_grid( EnergyGrid The dense grid created by upsampling and extending energy. """ + # Validate up front so both the upsampled and the non-upsampled path raise the same + # clear error (a single point has no spacing, so no grid can be built from it). + if len(self.energy.values) < 2: + raise ValueError('Energy array must have at least two points.') + if self.upsample_factor is None: # Check if the array is uniformly spaced. energy_diff = np.diff(self.energy.values) @@ -435,13 +503,22 @@ def _check_width_thresholds( # Handle ComponentCollection or ModelComponent components = model if isinstance(model, ComponentCollection) else [model] + # Cover plain-width components as well as Voigt-style components with separate + # gaussian_width/lorentzian_width parameters. + width_attribute_names = ('width', 'gaussian_width', 'lorentzian_width') + for comp in components: - if hasattr(comp, 'width'): - if comp.width.value > LARGE_WIDTH_THRESHOLD * self._energy_grid.energy_span_dense: + for attribute_name in width_attribute_names: + width_param = getattr(comp, attribute_name, None) + if width_param is None: + continue + width_label = attribute_name.replace('_', ' ') + if width_param.value > LARGE_WIDTH_THRESHOLD * self._energy_grid.energy_span_dense: warnings.warn( ( - f"The width of the {model_name} component '{comp.unique_name}' " - f'({comp.width.value}) is large compared to the span of the input ' + f'The {width_label} of the {model_name} component ' + f"'{comp.unique_name}' " + f'({width_param.value}) is large compared to the span of the input ' f'array ({self._energy_grid.energy_span_dense}). ' f'This may lead to inaccuracies in the convolution. ' f'Increase extension_factor to improve accuracy.' @@ -449,11 +526,12 @@ def _check_width_thresholds( UserWarning, stacklevel=3, ) - if comp.width.value < SMALL_WIDTH_THRESHOLD * self._energy_grid.energy_dense_step: + if width_param.value < SMALL_WIDTH_THRESHOLD * self._energy_grid.energy_dense_step: warnings.warn( ( - f"The width of the {model_name} component '{comp.unique_name}' " - f'({comp.width.value}) is small compared to the spacing of the input ' + f'The {width_label} of the {model_name} component ' + f"'{comp.unique_name}' " + f'({width_param.value}) is small compared to the spacing of the input ' f'array ({self._energy_grid.energy_dense_step}). ' f'This may lead to inaccuracies in the convolution. ' f'Increase upsample_factor to improve accuracy.' diff --git a/src/easydynamics/exceptions.py b/src/easydynamics/exceptions.py index e21f30a29..2298cd8e7 100644 --- a/src/easydynamics/exceptions.py +++ b/src/easydynamics/exceptions.py @@ -3,7 +3,23 @@ class AmbiguousNameError(Exception): - def __init__(self, name: str, matches: list[str]) -> None: + """Raised when a name lookup matches more than one element.""" + + def __init__(self, name: str, matches: list[object]) -> None: + """ + Initialize the AmbiguousNameError. + + Parameters + ---------- + name : str + The ambiguous name that was looked up. + matches : list[object] + The elements whose name matched. The elements' unique names are used in the message so + the matches can be told apart. + """ self.name = name self.matches = matches - super().__init__(f"Ambiguous name '{name}' matches {len(matches)} elements: {matches}") + match_names = [ + match.unique_name if hasattr(match, 'unique_name') else str(match) for match in matches + ] + super().__init__(f"Ambiguous name '{name}' matches {len(matches)} elements: {match_names}") diff --git a/src/easydynamics/experiment/experiment.py b/src/easydynamics/experiment/experiment.py index 5be71f109..058df9abe 100644 --- a/src/easydynamics/experiment/experiment.py +++ b/src/easydynamics/experiment/experiment.py @@ -420,9 +420,6 @@ def rebin(self, dimensions: dict[str, int | sc.Variable]) -> None: ) if isinstance(value, float) and value.is_integer(): # I allow eg. 2.0 as well as 2 value = int(value) - # This line can be removed when scipp resize support - # resizing with coordinates - dimensions[dim] = value if not (isinstance(value, (int, sc.Variable))): raise TypeError( f'Dimension values must be integers or sc.Variable. ' diff --git a/src/easydynamics/sample_model/component_collection.py b/src/easydynamics/sample_model/component_collection.py index adf3bc148..18250080f 100644 --- a/src/easydynamics/sample_model/component_collection.py +++ b/src/easydynamics/sample_model/component_collection.py @@ -12,6 +12,7 @@ from easydynamics.base_classes.easydynamics_list import EasyDynamicsList from easydynamics.base_classes.easydynamics_modelbase import EasyDynamicsModelBase +from easydynamics.exceptions import AmbiguousNameError from easydynamics.sample_model.components.model_component import ModelComponent from easydynamics.utils.fit_target import FitTarget from easydynamics.utils.utils import convert_units_with_rollback @@ -69,7 +70,10 @@ def __init__( Parameters ---------- components : ModelComponent | list[ModelComponent] | None, default=None - Initial model components to add to the ComponentCollection. + Initial model components to add to the ComponentCollection. Components are stored by + reference (not copied), so their Parameters stay shared with the objects passed in; + mutating a component mutates it everywhere it is used. Pass a copy if independent + parameters are needed. x_unit : str | sc.Unit, default='meV' Unit of the x-axis (energy, Q, etc.). y_unit : str | sc.Unit, default='dimensionless' @@ -224,11 +228,15 @@ def append_component(self, component: ModelComponent | ComponentCollection) -> N Append a model component or the components from another ComponentCollection to this ComponentCollection. + Components are appended by reference (not copied): their Parameters stay shared with the + passed-in objects, so a fit through one collection updates the same Parameters seen by any + other holder of the component. Pass a copy if independent parameters are needed. + Parameters ---------- component : ModelComponent | ComponentCollection The component to append. If a ComponentCollection is provided, all of its components - will be appended. + will be appended (also by reference). """ if isinstance(component, ComponentCollection): self.extend(component) @@ -281,8 +289,8 @@ def normalize_area(self) -> None: Raises ------ ValueError - If there are no components in the model or if the total area is zero or not finite, - which would prevent normalization. + If there are no components in the model, if any component area is negative, or if the + total area is zero, negative or not finite, which would prevent normalization. """ if not self: raise ValueError('No components in the model to normalize.') @@ -307,12 +315,19 @@ def normalize_area(self) -> None: # units normalize correctly. Dividing each value by the total expressed in the # reference unit makes the areas sum to 1 in that unit. reference_unit = str(area_params[0].unit) - total_area_value = sum( - convert_value_unit(p.value, p.unit, reference_unit) for p in area_params - ) + area_values = [convert_value_unit(p.value, p.unit, reference_unit) for p in area_params] + + negative = [p.name for p, value in zip(area_params, area_values, strict=True) if value < 0] + if negative: + raise ValueError( + f'Negative area(s) found for {negative}; cannot normalize. ' + 'Areas must be non-negative for normalization to be meaningful.' + ) + + total_area_value = sum(area_values) - if total_area_value == 0: - raise ValueError('Total area is zero; cannot normalize.') + if total_area_value <= 0: + raise ValueError('Total area is not positive; cannot normalize.') if not np.isfinite(total_area_value): raise ValueError('Total area is not finite; cannot normalize.') @@ -350,18 +365,27 @@ def evaluate( output : str, default='numpy' 'numpy' returns np.ndarray; 'scipp' returns sc.Variable with y_unit. + Raises + ------ + ValueError + If output is not 'numpy' or 'scipp'. + Returns ------- np.ndarray | sc.Variable Evaluated model values. """ if not self: + # Mirror the validation and 1D output shape of the non-empty path. + if output not in ('numpy', 'scipp'): + raise ValueError(f"output must be 'numpy' or 'scipp', got {output!r}") if isinstance(x, (sc.Variable, sc.DataArray)): - values = np.zeros_like(x.values, dtype=float) dim = x.dims[0] if x.dims else 'x' + raw = x.values if x.dims else x.value else: - values = np.zeros_like(x, dtype=float) dim = 'x' + raw = x + values = np.zeros_like(np.atleast_1d(np.asarray(raw, dtype=float)), dtype=float) if output == 'scipp': return sc.array(dims=[dim], values=values, unit=self.y_unit) return values @@ -396,6 +420,8 @@ def evaluate_component( If name is not a string. KeyError If no component with the given name exists in the collection. + AmbiguousNameError + If more than one component with the given name exists in the collection. Returns ------- @@ -409,6 +435,8 @@ def evaluate_component( matches = [comp for comp in self if comp.name == name] if not matches: raise KeyError(f"No component named '{name}' exists.") + if len(matches) > 1: + raise AmbiguousNameError(name, matches) return matches[0].evaluate(x, output=output) def fix_all_parameters(self) -> None: @@ -425,6 +453,30 @@ def free_all_parameters(self) -> None: # Private methods # ------------------------------------------------------------------ + def _copy_with_items(self, items: list[ModelComponent]) -> ComponentCollection: + """ + Create a new collection of this class containing the given components. + + Used by slicing. Overridden because ComponentCollection's constructor signature differs + from EasyDynamicsList's. The new collection carries this collection's units and references + the same component objects (no copies). + + Parameters + ---------- + items : list[ModelComponent] + The components the new collection should contain. + + Returns + ------- + ComponentCollection + A new collection of the same class containing the components. + """ + return self.__class__( + components=list(items), + x_unit=self.x_unit, + y_unit=self.y_unit, + ) + def _warn_if_duplicate_names(self) -> None: """Warn if any two components share the same name.""" names = [c.name for c in self] diff --git a/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py b/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py index 707823f5a..b4dfe9a45 100644 --- a/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py +++ b/src/easydynamics/sample_model/components/damped_harmonic_oscillator.py @@ -130,14 +130,13 @@ def area(self, value: Numeric) -> None: value : Numeric New area value (in current area unit = x_unit * y_unit). - Raises - ------ - TypeError - If *value* is not a numeric type. + Notes + ----- + A ``TypeError`` propagates from the shared value setter if *value* is not a numeric type, + and a ``ValueError`` propagates from it if *value* violates the area parameter's bounds + (e.g. a negative value when the area was created non-negative, giving it ``min=0``). """ - if not isinstance(value, Numeric): - raise TypeError('area must be a number') - self._area.value = value + self._set_bounded_parameter_value(self._area, value, 'area') @property def center(self) -> Parameter: @@ -203,7 +202,7 @@ def width(self, value: Numeric) -> None: raise TypeError('width must be a number') if float(value) <= 0: raise ValueError('width must be positive') - self._width.value = value + self._set_bounded_parameter_value(self._width, value, 'width') def _evaluate_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndarray: r""" diff --git a/src/easydynamics/sample_model/components/delta_function.py b/src/easydynamics/sample_model/components/delta_function.py index 539f2970f..0cd2cbe9a 100644 --- a/src/easydynamics/sample_model/components/delta_function.py +++ b/src/easydynamics/sample_model/components/delta_function.py @@ -11,7 +11,10 @@ from easydynamics.sample_model.components.model_component import ModelComponent from easydynamics.utils.utils import Numeric -EPSILON = 1e-8 # tolerance for bin-edge comparisons +# Absolute tolerance for deciding whether the center falls inside the x range. It is expressed +# in the unit x is evaluated in (typically meV), so it only serves to absorb floating-point +# noise at the grid edges — it is not a physically meaningful width. +EPSILON = 1e-8 if TYPE_CHECKING: import scipp as sc @@ -121,14 +124,13 @@ def area(self, value: Numeric) -> None: value : Numeric New area value (in current area unit = x_unit * y_unit). - Raises - ------ - TypeError - If *value* is not a numeric type. + Notes + ----- + A ``TypeError`` propagates from the shared value setter if *value* is not a numeric type, + and a ``ValueError`` propagates from it if *value* violates the area parameter's bounds + (e.g. a negative value when the area was created non-negative, giving it ``min=0``). """ - if not isinstance(value, Numeric): - raise TypeError('area must be a number') - self._area.value = value + self._set_bounded_parameter_value(self._area, value, 'area') @property def center(self) -> Parameter: @@ -184,12 +186,25 @@ def _evaluate_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndar Zero everywhere, with a single non-zero bin nearest the center when center falls within the x range. + Raises + ------ + ValueError + If x_vals contains a single point. A delta function's evaluated height is ``area / + bin_width``, and a single point defines no bin width. + Notes ----- When ``center`` falls within the x range, the bin nearest to ``center`` receives ``area / bin_width`` rather than zero. In convolutions, the DeltaFunction acts as an identity element (handled by the Convolution class). """ + if x_vals.size == 1: + raise ValueError( + 'A DeltaFunction cannot be evaluated at a single x value: its evaluated height ' + 'is area / bin_width, and a single point defines no bin width. Evaluate on a ' + 'grid of at least two x values.' + ) + center = self._resolve_param_value(self._center, eval_unit) area = self._resolve_param_value(self._area, self._eval_area_unit(eval_unit)) @@ -205,14 +220,11 @@ def _evaluate_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndar i = np.argmin(np.abs(x_sorted - center)) # left half-width - if i == 0: - left = x_sorted[1] - x_sorted[0] if x_sorted.size > 1 else 0.5 - else: - left = x_sorted[i] - x_sorted[i - 1] + left = x_sorted[i] - x_sorted[i - 1] if i > 0 else x_sorted[1] - x_sorted[0] # right half-width if i == x_sorted.size - 1: - right = x_sorted[-1] - x_sorted[-2] if x_sorted.size > 1 else 0.5 + right = x_sorted[-1] - x_sorted[-2] else: right = x_sorted[i + 1] - x_sorted[i] diff --git a/src/easydynamics/sample_model/components/expression_component.py b/src/easydynamics/sample_model/components/expression_component.py index 25e261049..6b1927804 100644 --- a/src/easydynamics/sample_model/components/expression_component.py +++ b/src/easydynamics/sample_model/components/expression_component.py @@ -29,9 +29,15 @@ class ExpressionComponent(ModelComponent): Model component defined by a symbolic expression. The expression must contain ``x`` as the independent variable. All other symbols are treated as - free parameters, which can be accessed and set as attributes after construction. Supported - functions include ``exp``, ``sin``, ``cos``, ``sqrt``, ``erf``, and others — see the - ``_ALLOWED_FUNCS`` class variable for the full list. + free parameters, which can be accessed and set as attributes after construction. Symbol names + that collide with an existing attribute of the class (e.g. ``name`` or ``evaluate``) are + rejected at construction. Supported functions include ``exp``, ``sin``, ``cos``, ``sqrt``, + ``erf``, and others — see the ``_ALLOWED_FUNCS`` class variable for the full list. + + .. warning:: + The expression string is parsed with ``sympy.sympify``, which evaluates the string and + can execute arbitrary code. Only pass expression strings from a trusted source — never + feed it unsanitized user input. Examples -------- @@ -66,7 +72,10 @@ class ExpressionComponent(ModelComponent): Parameters are dimensionless by default. Units can be given per parameter at construction, or relabelled later with ``set_unit`` (the numeric value is kept as-is). When units are in use, the unit of the evaluated expression is derived from the parameter units and x_unit (see - ``output_unit``), and a warning is issued if it does not match y_unit: + ``output_unit``). A derived unit that differs from y_unit but is convertible to it is handled + automatically: the expression is evaluated in a coherent (SI) scale, so parameter units of + mixed scales combine correctly, and the result is expressed in y_unit. A warning is issued only + when the derived unit is dimensionally incompatible with y_unit: ```python expr = edyn.ExpressionComponent( 'A * exp(-(x - x0)**2 / (2*sigma**2))', @@ -166,15 +175,19 @@ def __init__( expression : str The symbolic expression as a string. Must contain 'x' as the independent variable. The symbols ``hbar`` and ``kb`` are provided automatically as read-only physical constants - (in meV*s and meV/K respectively) unless overridden via *parameters*. + (in meV*s and meV/K respectively) unless overridden via *parameters*. The string is + parsed with ``sympy.sympify``, which can execute arbitrary code — only use expression + strings from a trusted source. Symbol names that collide with an existing attribute of + the class (e.g. ``name``, ``evaluate``) are rejected. parameters : dict[str, Numeric] | None, default=None Dictionary of parameter names and their initial values. Parameters that are not given a unit are dimensionless. parameter_units : dict[str, str | sc.Unit] | None, default=None Optional units per parameter name. Each entry sets the unit of the named parameter without rescaling its value (see :meth:`set_unit`), and takes precedence over the unit - of a Parameter instance given in *parameters*. When units are in use, a warning is - issued if the expression's output unit does not match y_unit. + of a Parameter instance given in *parameters*. When units are in use, an output unit + convertible to y_unit rescales the evaluated values into y_unit; a warning is issued + only if the output unit is incompatible with y_unit. x_unit : str | sc.Unit, default='meV' Unit of the x-axis. y_unit : str | sc.Unit, default='dimensionless' @@ -189,8 +202,9 @@ def __init__( Raises ------ ValueError - If the expression is invalid or does not contain 'x', or if parameter_units names a - parameter that is not in the expression. + If the expression is invalid or does not contain 'x', if a symbol name collides with an + existing attribute of the class, or if parameter_units names a parameter that is not in + the expression. TypeError If any parameter value is not numeric, or if parameter_units is not a dictionary. """ @@ -267,6 +281,16 @@ def __init__( if name in self._RESERVED_NAMES: continue + # A symbol shadowing an existing attribute (e.g. 'name', 'evaluate', 'x_unit') + # would silently diverge: reads resolve to the class attribute (since __getattr__ + # only fires when normal lookup fails) while writes hit the parameter. Reject it. + if hasattr(type(self), name) or name in self.__dict__: + raise ValueError( + f"Symbol '{name}' in the expression collides with an existing attribute " + f'of {type(self).__name__}; it could not be accessed as a parameter. ' + f'Rename the symbol in the expression.' + ) + # Physical constants are provided automatically, unless the user explicitly # supplies a parameter with the same name. if name in self._PHYSICAL_CONSTANTS and name not in parameters: @@ -389,16 +413,32 @@ def _evaluate_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndar f'convert x to {self.x_unit} before evaluating.' ) + # When the derived output unit is convertible to y_unit, evaluate in the coherent SI + # scale: every symbol's value is scaled by its unit's SI multiplier, so mixed-scale + # parameter units combine correctly even inside sums (e.g. 1 + D*x**2*tau with D in m^2/s, + # x in 1/angstrom and tau in ps), and the result is expressed in y_unit. Scale-homogeneous + # expressions give the same numbers either way. + scale_into_si = self._output_converts_to_y_unit() + args = [] for name in self._symbol_names: if name == 'x': - args.append(x_vals) + value = x_vals + unit = self.x_unit elif name in self._constants: - args.append(self._constants[name].value) + value = self._constants[name].value + unit = self._constants[name].unit else: - args.append(self._parameters[name].value) + value = self._parameters[name].value + unit = self._parameters[name].unit + if scale_into_si and unit is not None: + value = value * self._si_multiplier(unit) + args.append(value) - return self._func(*args) + result = self._func(*args) + if scale_into_si: + result = result / self._si_multiplier(self.y_unit or 'dimensionless') + return result def get_all_variables(self) -> list[Parameter]: """ @@ -417,8 +457,9 @@ def set_unit(self, name: str, unit: str | sc.Unit) -> None: This relabels the unit: the numeric value, bounds, and variance are kept as-is. Use ``Parameter.convert_unit`` instead to rescale a value into a compatible unit. Issues a - warning if the resulting output unit of the expression no longer matches y_unit. Raises the - same exceptions as :meth:`_relabel_parameter_unit` on invalid input. + warning if the resulting output unit of the expression is incompatible with y_unit (a + convertible output unit rescales evaluated values into y_unit instead). Raises the same + exceptions as :meth:`_relabel_parameter_unit` on invalid input. Parameters ---------- @@ -661,18 +702,75 @@ def _propagate_unit(self, node: sp.Basic) -> sc.Unit: f'Cannot determine units for expression node {node} of type {type(node).__name__}.' ) + def _units_in_use(self) -> bool: + """ + Whether the expression carries unit information at all. + + Returns + ------- + bool + True when the expression uses physical constants or any parameter has a unit other than + dimensionless. Unit-agnostic expressions (all parameters dimensionless) evaluate + without any unit handling. + """ + return bool(self._constants) or any( + str(parameter.unit) != 'dimensionless' for parameter in self._parameters.values() + ) + + @staticmethod + def _si_multiplier(unit: str | sc.Unit) -> float: + """ + Scale factor from one of *unit* to the coherent SI value of the same dimension. + + Parameters + ---------- + unit : str | sc.Unit + The unit whose scale to extract, e.g. 1e-10 for angstrom. + + Returns + ------- + float + The multiplier relative to the coherent SI base units. + """ + return float(sc.Unit(str(unit)).to_dict().get('multiplier', 1.0)) + + def _output_converts_to_y_unit(self) -> bool: + """ + Whether evaluation should run in a coherent scale and express the result in y_unit. + + Returns + ------- + bool + True when units are in use and the derived output unit differs from y_unit but is + convertible to it. False when units are not in use, the output unit cannot be + determined, the units already agree (no conversion needed), or they are dimensionally + incompatible (construction warned; values are evaluated raw and labelled as-is). + """ + if not self._units_in_use(): + return False + try: + output_unit = sc.Unit(self.output_unit) + except sc.UnitError: + return False + y_unit = sc.Unit(self.y_unit) if self.y_unit is not None else sc.Unit('dimensionless') + if output_unit == y_unit: + return False + try: + sc.to_unit(sc.scalar(1.0, unit=output_unit), y_unit) + except sc.UnitError: + return False + return True + def _warn_if_output_unit_mismatch(self) -> None: """ - Warn if the expression's output unit does not match y_unit. + Warn if the expression's output unit cannot be expressed in y_unit. The check only runs when units are in use, i.e. when the expression uses physical constants or any parameter has a unit other than dimensionless. Unit-agnostic expressions (all - parameters dimensionless) stay silent. + parameters dimensionless) stay silent. An output unit that differs from y_unit but is + convertible to it does not warn: evaluated values are rescaled into y_unit. """ - units_in_use = bool(self._constants) or any( - str(parameter.unit) != 'dimensionless' for parameter in self._parameters.values() - ) - if not units_in_use: + if not self._units_in_use(): return try: @@ -686,11 +784,16 @@ def _warn_if_output_unit_mismatch(self) -> None: return y_unit = sc.Unit(self.y_unit) if self.y_unit is not None else sc.Unit('dimensionless') - if output_unit != y_unit: + if output_unit == y_unit: + return + try: + sc.to_unit(sc.scalar(1.0, unit=output_unit), y_unit) + except sc.UnitError: warnings.warn( f'The expression evaluates to unit {output_unit}, which does not match ' - f'y_unit {y_unit}. The evaluated values are labelled with y_unit; adjust the ' - f'parameter units or y_unit to make them consistent.', + f'y_unit {y_unit} and cannot be converted to it. The evaluated values are ' + f'labelled with y_unit; adjust the parameter units or y_unit to make them ' + f'consistent.', UserWarning, stacklevel=3, ) diff --git a/src/easydynamics/sample_model/components/gaussian.py b/src/easydynamics/sample_model/components/gaussian.py index db9ad082a..65a09a528 100644 --- a/src/easydynamics/sample_model/components/gaussian.py +++ b/src/easydynamics/sample_model/components/gaussian.py @@ -126,14 +126,13 @@ def area(self, value: Numeric) -> None: value : Numeric New area value (in current area unit = x_unit * y_unit). - Raises - ------ - TypeError - If *value* is not a numeric type. + Notes + ----- + A ``TypeError`` propagates from the shared value setter if *value* is not a numeric type, + and a ``ValueError`` propagates from it if *value* violates the area parameter's bounds + (e.g. a negative value when the area was created non-negative, giving it ``min=0``). """ - if not isinstance(value, Numeric): - raise TypeError('area must be a number') - self._area.value = value + self._set_bounded_parameter_value(self._area, value, 'area') @property def center(self) -> Parameter: @@ -193,13 +192,13 @@ def width(self, value: Numeric) -> None: TypeError If *value* is not a numeric type. ValueError - If *value* is not positive. + If *value* is not positive, or violates the width parameter's bounds. """ if not isinstance(value, Numeric): raise TypeError('width must be a number') if float(value) <= 0: raise ValueError('width must be positive') - self._width.value = value + self._set_bounded_parameter_value(self._width, value, 'width') def _evaluate_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndarray: r""" diff --git a/src/easydynamics/sample_model/components/lorentzian.py b/src/easydynamics/sample_model/components/lorentzian.py index 6aade54f2..5e9ccd74b 100644 --- a/src/easydynamics/sample_model/components/lorentzian.py +++ b/src/easydynamics/sample_model/components/lorentzian.py @@ -22,7 +22,7 @@ class Lorentzian(CreateParametersMixin, ModelComponent): $$ I(x) = \frac{A}{\pi} \frac{\Gamma}{(x - x_0)^2 + \Gamma^2} $$ - where $A$ is the area, $x_0$ is the center, and $\Gamma$ is the hald width at half max (HWHM). + where $A$ is the area, $x_0$ is the center, and $\Gamma$ is the half width at half max (HWHM). area has unit = x_unit * y_unit; center and width have unit = x_unit. If the center is not provided, it will be centered at 0 and fixed, which is typically what you @@ -124,14 +124,13 @@ def area(self, value: Numeric) -> None: value : Numeric New area value (in current area unit = x_unit * y_unit). - Raises - ------ - TypeError - If *value* is not a numeric type. + Notes + ----- + A ``TypeError`` propagates from the shared value setter if *value* is not a numeric type, + and a ``ValueError`` propagates from it if *value* violates the area parameter's bounds + (e.g. a negative value when the area was created non-negative, giving it ``min=0``). """ - if not isinstance(value, Numeric): - raise TypeError('area must be a number') - self._area.value = value + self._set_bounded_parameter_value(self._area, value, 'area') @property def center(self) -> Parameter: @@ -191,13 +190,13 @@ def width(self, value: Numeric) -> None: TypeError If *value* is not a numeric type. ValueError - If *value* is not positive. + If *value* is not positive, or violates the width parameter's bounds. """ if not isinstance(value, Numeric): raise TypeError('width must be a number') if float(value) <= 0: raise ValueError('width must be positive') - self._width.value = value + self._set_bounded_parameter_value(self._width, value, 'width') def _evaluate_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndarray: r""" diff --git a/src/easydynamics/sample_model/components/mixins.py b/src/easydynamics/sample_model/components/mixins.py index 2fc0b8071..531ab0091 100644 --- a/src/easydynamics/sample_model/components/mixins.py +++ b/src/easydynamics/sample_model/components/mixins.py @@ -21,6 +21,42 @@ class CreateParametersMixin: area_unit = x_unit * y_unit, so when y_unit='dimensionless', area_unit = x_unit. """ + @staticmethod + def _set_bounded_parameter_value(param: Parameter, value: Numeric, label: str) -> None: + """ + Assign a value to a bounded parameter, raising instead of silently clamping. + + easyscience's ``Parameter.value`` setter silently clamps out-of-bounds values to the + nearest bound, which corrupts the parameter (e.g. assigning -1.0 to an area with ``min=0`` + stores 0.0). Component setters route assignments through this helper so a bounds violation + raises a clear error instead. + + Parameters + ---------- + param : Parameter + The parameter to assign to. + value : Numeric + The new value. + label : str + Name of the parameter used in error messages (e.g. ``'area'``, ``'width'``). + + Raises + ------ + TypeError + If *value* is not a numeric type. + ValueError + If *value* violates the parameter's bounds. + """ + if not isinstance(value, Numeric): + raise TypeError(f'{label} must be a number') + value = float(value) + if value < param.min or value > param.max: + raise ValueError( + f'Cannot set {label} to {value}: it violates the parameter bounds ' + f'[{param.min}, {param.max}]. Adjust the bounds first if this value is intended.' + ) + param.value = value + def _create_area_parameter( self, area: Numeric, diff --git a/src/easydynamics/sample_model/components/polynomial.py b/src/easydynamics/sample_model/components/polynomial.py index 4e592cf62..5bcd6f474 100644 --- a/src/easydynamics/sample_model/components/polynomial.py +++ b/src/easydynamics/sample_model/components/polynomial.py @@ -5,6 +5,7 @@ import warnings from collections.abc import Sequence +from contextlib import suppress import numpy as np import scipp as sc @@ -379,12 +380,39 @@ def get_all_variables(self) -> list[DescriptorBase]: """ return list(self._coefficients) + @staticmethod + def _rescale_coefficient(param: Parameter, factor: float) -> None: + """ + Rescale a coefficient's value and bounds by a positive factor without clamping. + + The bounds are temporarily widened to infinity so the value assignment cannot be silently + clamped by the Parameter's min/max (easyscience clamps out-of-bounds values instead of + raising), then the original bounds are rescaled by the same factor. + + Parameters + ---------- + param : Parameter + The coefficient Parameter to rescale. + factor : float + The (strictly positive) rescaling factor. + """ + old_min = param.min + old_max = param.max + param.min = -np.inf + param.max = np.inf + param.value = param.value * factor + param.min = old_min * factor + param.max = old_max * factor + def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: """ Convert the x-axis unit by rescaling coefficients with power-law factors. Each coefficient ``c_i`` is rescaled by ``(old_scale / new_scale) ** i`` so the evaluated - polynomial output is unchanged after the conversion. + polynomial output is unchanged after the conversion. The coefficient bounds (min/max) are + rescaled by the same factor, so bounded coefficients convert without being clamped. If any + step fails, the already-converted coefficients are rolled back best-effort before the + exception propagates. Parameters ---------- @@ -400,21 +428,37 @@ def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: if not isinstance(new_x_unit, (str, sc.Unit)): raise UnitError('new_x_unit must be a string or a scipp unit.') - conversion_value_before = self._x_unit_helper.value - self._x_unit_helper = sc.to_unit(self._x_unit_helper, unit=new_x_unit) - conversion_value_after = self._x_unit_helper.value - for i, param in enumerate(self._coefficients): - param.value *= (conversion_value_before / conversion_value_after) ** i - + new_helper = sc.to_unit(self._x_unit_helper, unit=new_x_unit) + scale = self._x_unit_helper.value / new_helper.value + + rescaled: list[tuple[Parameter, float]] = [] + converted = False + try: + for i, param in enumerate(self._coefficients): + factor = scale**i + # Exact comparison on purpose: only a factor of exactly 1.0 (same unit, or the + # constant term's scale**0) is a guaranteed no-op worth skipping. + if factor != 1.0: # ruff: ignore[float-equality-comparison] + self._rescale_coefficient(param, factor) + rescaled.append((param, factor)) + converted = True + finally: + if not converted: + for param, factor in rescaled: + with suppress(Exception): + self._rescale_coefficient(param, 1.0 / factor) + + self._x_unit_helper = new_helper self._x_unit = str(new_x_unit) if isinstance(new_x_unit, sc.Unit) else new_x_unit def convert_y_unit(self, new_y_unit: str | sc.Unit) -> None: """ Rescale all coefficients so the evaluated output remains the same physical value. - All coefficients are multiplied by the conversion factor from ``old_y_unit`` to - ``new_y_unit`` so that ``I(x) [new_y_unit]`` represents the same physical quantity as - ``I(x) [old_y_unit]``. + All coefficients (values and bounds) are multiplied by the conversion factor from + ``old_y_unit`` to ``new_y_unit`` so that ``I(x) [new_y_unit]`` represents the same physical + quantity as ``I(x) [old_y_unit]``. If any step fails, the already-converted coefficients + are rolled back best-effort before the exception propagates. Parameters ---------- @@ -438,8 +482,21 @@ def convert_y_unit(self, new_y_unit: str | sc.Unit) -> None: y_helper_new = sc.to_unit(y_helper, new_y_str) scale = y_helper_new.value / y_helper.value - for param in self._coefficients: - param.value *= scale + rescaled: list[Parameter] = [] + converted = False + try: + for param in self._coefficients: + # Exact comparison on purpose: only a scale of exactly 1.0 (converting to the + # same unit) is a guaranteed no-op worth skipping. + if scale != 1.0: # ruff: ignore[float-equality-comparison] + self._rescale_coefficient(param, scale) + rescaled.append(param) + converted = True + finally: + if not converted: + for param in rescaled: + with suppress(Exception): + self._rescale_coefficient(param, 1.0 / scale) self._y_unit = new_y_str def __repr__(self) -> str: diff --git a/src/easydynamics/sample_model/components/voigt.py b/src/easydynamics/sample_model/components/voigt.py index d0bd070d7..4a196ea7c 100644 --- a/src/easydynamics/sample_model/components/voigt.py +++ b/src/easydynamics/sample_model/components/voigt.py @@ -57,10 +57,10 @@ class Voigt(CreateParametersMixin, ModelComponent): def __init__( self, - area: Numeric | Parameter = 1.0, - center: Numeric | Parameter | None = None, - gaussian_width: Numeric | Parameter = 1.0, - lorentzian_width: Numeric | Parameter = 1.0, + area: Numeric = 1.0, + center: Numeric | None = None, + gaussian_width: Numeric = 1.0, + lorentzian_width: Numeric = 1.0, x_unit: str | sc.Unit = 'meV', y_unit: str | sc.Unit = 'dimensionless', name: str = 'Voigt', @@ -72,13 +72,13 @@ def __init__( Parameters ---------- - area : Numeric | Parameter, default=1.0 + area : Numeric, default=1.0 Integrated area under the Voigt profile. Unit is ``x_unit * y_unit``. - center : Numeric | Parameter | None, default=None + center : Numeric | None, default=None Peak position in x_unit. If None, defaults to 0 and the center parameter is fixed. - gaussian_width : Numeric | Parameter, default=1.0 + gaussian_width : Numeric, default=1.0 Gaussian component standard deviation (sigma) in x_unit. Must be strictly positive. - lorentzian_width : Numeric | Parameter, default=1.0 + lorentzian_width : Numeric, default=1.0 Lorentzian component HWHM (gamma) in x_unit. Must be strictly positive. x_unit : str | sc.Unit, default='meV' Unit of the x-axis. center, gaussian_width, and lorentzian_width are stored in this @@ -139,14 +139,13 @@ def area(self, value: Numeric) -> None: value : Numeric New area value (in current area unit = x_unit * y_unit). - Raises - ------ - TypeError - If *value* is not a numeric type. + Notes + ----- + A ``TypeError`` propagates from the shared value setter if *value* is not a numeric type, + and a ``ValueError`` propagates from it if *value* violates the area parameter's bounds + (e.g. a negative value when the area was created non-negative, giving it ``min=0``). """ - if not isinstance(value, Numeric): - raise TypeError('area must be a number') - self._area.value = value + self._set_bounded_parameter_value(self._area, value, 'area') @property def center(self) -> Parameter: @@ -213,7 +212,7 @@ def gaussian_width(self, value: Numeric) -> None: raise TypeError('gaussian_width must be a number') if float(value) <= 0: raise ValueError('gaussian_width must be positive') - self._gaussian_width.value = value + self._set_bounded_parameter_value(self._gaussian_width, value, 'gaussian_width') @property def lorentzian_width(self) -> Parameter: @@ -247,7 +246,7 @@ def lorentzian_width(self, value: Numeric) -> None: raise TypeError('lorentzian_width must be a number') if float(value) <= 0: raise ValueError('lorentzian_width must be positive') - self._lorentzian_width.value = value + self._set_bounded_parameter_value(self._lorentzian_width, value, 'lorentzian_width') def _evaluate_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndarray: """ diff --git a/src/easydynamics/sample_model/diffusion_model/__init__.py b/src/easydynamics/sample_model/diffusion_model/__init__.py index ceee6588b..778abc129 100644 --- a/src/easydynamics/sample_model/diffusion_model/__init__.py +++ b/src/easydynamics/sample_model/diffusion_model/__init__.py @@ -4,11 +4,13 @@ from easydynamics.sample_model.diffusion_model.brownian_translational_diffusion import ( BrownianTranslationalDiffusion, ) +from easydynamics.sample_model.diffusion_model.delta_lorentz import DeltaLorentz from easydynamics.sample_model.diffusion_model.jump_translational_diffusion import ( JumpTranslationalDiffusion, ) __all__ = [ 'BrownianTranslationalDiffusion', + 'DeltaLorentz', 'JumpTranslationalDiffusion', ] diff --git a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py index cfcefae92..c820a37f6 100644 --- a/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/brownian_translational_diffusion.py @@ -237,6 +237,9 @@ def create_component_collections( Create ComponentCollection components for the Brownian translational diffusion model at given Q values. + The created collections are installed on the model (they become the collections returned by + ``get_component_collections``), so the returned list is the live one. + Returns ------- list[ComponentCollection] @@ -295,7 +298,8 @@ def create_component_collections( component_collection_list[i].append_component(lorentzian_component) - return component_collection_list + self._component_collections = component_collection_list + return self._component_collections # ------------------------------------------------------------------ # Private methods diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py index 2e255d424..0424b5f98 100644 --- a/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py +++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentz.py @@ -179,19 +179,10 @@ def __init__( # -------------------------------------------------------------- self._allow_Q_variation = self._create_Q_variation_dict(allow_Q_variation) - self._A_0_list = [] - self._A_1_list = [] - self._lorentzian_width_list = [] - if self.Q is not None: - if self._allow_Q_variation['A_0'] is True: - self._A_0_list, self._A_1_list = self._create_A0_A1_parameter_lists(self.A_0) - - if self._allow_Q_variation['lorentzian_width'] is True: - self._lorentzian_width_list = self._create_lorentzian_width_parameter_list( - self.lorentzian_width, - ) - - self._component_collections = self.create_component_collections() + # create_component_collections creates the per-Q parameter lists (A_0/A_1 and + # lorentzian_width) itself, so the components it builds are backed by the very + # parameters stored in those lists. + self.create_component_collections() # ------------------------------------------------------------------ # Properties @@ -492,7 +483,13 @@ def create_component_collections( self, ) -> list[ComponentCollection]: r""" - Create ComponentCollections for the DeltaLorentz model at given Q values. + Create ComponentCollections for the DeltaLorentz model at given Q values. + + The per-Q parameter lists (A_0/A_1 and lorentzian_width, when Q-variation is enabled) are + recreated here so the built components are backed by the very parameters stored in the + lists, keeping ``calculate_width``/``calculate_EISF``/``calculate_QISF`` in sync with the + components. The created collections are installed on the model (they become the collections + returned by ``get_component_collections``), so the returned list is the live one. Returns ------- @@ -501,24 +498,31 @@ def create_component_collections( value. """ if self.Q is None: - return [] + self._A_0_list = [] + self._A_1_list = [] + self._lorentzian_width_list = [] + self._component_collections = [] + return self._component_collections Q = self.Q.values if self._allow_Q_variation['A_0'] is True: - A_0_list, A_1_list = self._create_A0_A1_parameter_lists(self.A_0) - self._A_0_list = A_0_list - self._A_1_list = A_1_list + self._A_0_list, self._A_1_list = self._create_A0_A1_parameter_lists(self.A_0) + else: + self._A_0_list = [] + self._A_1_list = [] if self._allow_Q_variation['lorentzian_width'] is True: - lorentzian_width_list = self._create_lorentzian_width_parameter_list( + self._lorentzian_width_list = self._create_lorentzian_width_parameter_list( self.lorentzian_width ) - self._lorentzian_width_list = lorentzian_width_list + else: + self._lorentzian_width_list = [] component_collection_list = [None] * len(Q) for i, Q_value in enumerate(Q): component_collection_list[i] = ComponentCollection( + name=f'{self.name}_Q{Q_value:.2f}', display_name=f'{self.display_name}_Q{Q_value:.2f}', x_unit=self.x_unit, y_unit=self.y_unit, @@ -588,7 +592,8 @@ def create_component_collections( component_collection_list[i].append_component(delta_component) - return component_collection_list + self._component_collections = component_collection_list + return self._component_collections def get_fit_targets(self) -> list[FitTarget]: """ @@ -877,8 +882,13 @@ def _create_A0_A1_parameter_lists( A_0_list = [] A_1_list = [] for _ in range(len(self.Q)): + # Like the per-Q width parameters (named ' width'), the per-Q + # amplitudes carry the model name so they do not collide with other models' + # parameters. The name is the same at every Q on purpose: parameters are tracked + # across Q by name (unique within a Q, shared across Q). a0 = Parameter( - name='A_0', + name=f'{self.name} A_0', + display_name='A_0', value=float(A_0.value), fixed=False, min=0.0, @@ -886,7 +896,7 @@ def _create_A0_A1_parameter_lists( ) a1 = Parameter.from_dependency( - name='A_1', + name=f'{self.name} A_1', dependency_expression='1 - A_0', dependency_map={'A_0': a0}, ) @@ -931,27 +941,12 @@ def _create_lorentzian_width_parameter_list( def _on_Q_change(self) -> None: """ - Handle changes to the Q values. Updates the A_0, A_1 and lorentzian_width parameters if - they are allowed to vary with Q. - """ - if self.Q is None: - self._A_0_list = [] - self._A_1_list = [] - self._lorentzian_width_list = [] - else: - if self._allow_Q_variation['A_0'] is True: - self._A_0_list, self._A_1_list = self._create_A0_A1_parameter_lists(self.A_0) - else: - self._A_0_list = [] - self._A_1_list = [] + Handle changes to the Q values. - if self._allow_Q_variation['lorentzian_width'] is True: - self._lorentzian_width_list = self._create_lorentzian_width_parameter_list( - self.lorentzian_width - ) - else: - self._lorentzian_width_list = [] - self._component_collections = self.create_component_collections() + Rebuilds the component collections; the per-Q A_0, A_1 and lorentzian_width parameter lists + are recreated inside ``create_component_collections``. + """ + self.create_component_collections() def _convert_extra_x_unit_parameters(self, unit_str: str) -> None: """ diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py index c8bafd083..0e4b3ed2b 100644 --- a/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py +++ b/src/easydynamics/sample_model/diffusion_model/diffusion_model_base.py @@ -203,8 +203,11 @@ def Q(self, value: Q_type | None) -> None: if len(old_Q) != len(new_Q) or not sc.allclose(old_Q, new_Q): raise ValueError( - 'New Q values are not similar to the old ones. ' - 'To change Q values, first run clear_Q().' + f'New Q values are not similar to the old ones on diffusion model ' + f'{self.name!r}. This typically happens when a diffusion model that was ' + f'previously used with different Q values (e.g. in another SampleModel) is ' + f'reused. Run clear_Q(confirm=True) on the diffusion model first, then set ' + f'the new Q values.' ) @property diff --git a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py index ad615e3b2..5c29ceb73 100644 --- a/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py +++ b/src/easydynamics/sample_model/diffusion_model/jump_translational_diffusion.py @@ -22,7 +22,7 @@ class JumpTranslationalDiffusion(DiffusionModelBase): The model consists of a Lorentzian function for each Q-value, where the width is given by - $$ \Gamma(Q) = \frac{Q^2}{1+D t Q^2}. $$ + $$ \Gamma(Q) = \frac{\hbar D Q^2}{1+D t Q^2}. $$ where $D$ is the diffusion coefficient and $t$ is the relaxation time. Q is assumed to have units of 1/angstrom. Creates ComponentCollections with Lorentzian components for given @@ -87,10 +87,10 @@ def __init__( Display name of the diffusion model. lorentzian_name : str | None, default=None Name of the Lorentzian component. If None, it will be set to the name of the diffusion - model with '_Lorentzian' appended. By default, None. + model. By default, None. lorentzian_display_name : str | None, default=None - Display name of the Lorentzian component. If None, it will be set to the display name - of the diffusion model with '_Lorentzian' appended. By default, None + Display name of the Lorentzian component. If None, it will be set to the + lorentzian_name. By default, None unique_name : str | None, default=None Unique name of the diffusion model. If None, a unique name will be generated. By default, None. @@ -305,6 +305,9 @@ def create_component_collections( """ Create ComponentCollection components for the diffusion model at given Q values. + The created collections are installed on the model (they become the collections returned by + ``get_component_collections``), so the returned list is the live one. + Returns ------- list[ComponentCollection] @@ -361,7 +364,8 @@ def create_component_collections( component_collection_list[i].append_component(lorentzian_component) - return component_collection_list + self._component_collections = component_collection_list + return self._component_collections ################################ # Private methods diff --git a/src/easydynamics/sample_model/model_base.py b/src/easydynamics/sample_model/model_base.py index 0e0e51cc7..15040a852 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -75,6 +75,13 @@ def __init__( self._components = ComponentCollection(x_unit=self.x_unit, y_unit=self.y_unit) self._component_collections: list[ComponentCollection] = [] + # Counter part of state_version: bumped whenever the dirty flag is raised. + self._state_counter = 0 + # Template-collection version the per-Q collections were last built from. Compared + # against self._components.version so in-place mutations of the live template + # collection (reachable via the `components` property) are detected without + # callbacks. + self._built_components_version = self._components.version self._component_collections_is_dirty = True if isinstance(components, (ModelComponent, ComponentCollection)): self.append_component(components) @@ -97,7 +104,7 @@ def evaluate( Raises ------ ValueError - If there are no components in the model to evaluate. + If Q is not set on the model, or if there are no components in the model to evaluate. Returns ------- @@ -107,6 +114,11 @@ def evaluate( """ self._ensure_component_collections_current() if not self._component_collections: + if self.Q is None: + raise ValueError( + 'Q is not set on the model, so there are no per-Q component collections ' + 'to evaluate. Set Q before evaluating.' + ) raise ValueError('No components in the model to evaluate.') return [ collection.evaluate(x, output=output) for collection in self._component_collections @@ -149,14 +161,18 @@ def clear_components(self) -> None: # ------------------------------------------------------------------ @property - def components(self) -> list[ModelComponent]: + def components(self) -> ComponentCollection: """ - Get the components of the SampleModel. + Get the template ComponentCollection of the SampleModel. + + This is the live template collection: mutating it in place (e.g. via ``append_component``) + is detected through its ``version`` and triggers a rebuild of the per-Q collections on next + use. Returns ------- - list[ModelComponent] - The components of the SampleModel. + ComponentCollection + The template component collection of the SampleModel. """ return self._components @@ -187,12 +203,72 @@ def component_collections_is_dirty(self) -> bool: """ Return whether component collections need to be rebuilt before use. + Collections are stale when the dirty flag was raised (Q or component changes through the + model's methods) or when the live template collection was mutated in place since the + collections were last built. + Returns ------- bool ``True`` if component collections have not been built yet or are stale. """ - return self._component_collections_is_dirty + return ( + self._component_collections_is_dirty + or self._built_components_version != self._components.version + ) + + @property + def _component_collections_is_dirty(self) -> bool: + """ + Get the dirty flag for the per-Q component collections. + + Implemented as a property so every write is intercepted: raising the flag bumps the state + counter (making ``state_version`` change), and clearing it records the template collection + version the collections were built from. + + Returns + ------- + bool + The raw dirty flag (does not account for in-place template mutations; use + ``component_collections_is_dirty`` for the full staleness check). + """ + return self._component_collections_dirty_flag + + @_component_collections_is_dirty.setter + def _component_collections_is_dirty(self, value: bool) -> None: + """ + Set the dirty flag for the per-Q component collections. + + Parameters + ---------- + value : bool + ``True`` marks the collections stale and bumps the state counter. ``False`` marks them + current and records the template collection version they now correspond to. + """ + value = bool(value) + if value: + self._state_counter += 1 + else: + self._built_components_version = self._components.version + self._component_collections_dirty_flag = value + + @property + def state_version(self) -> int: + """ + Get a monotonic version of everything affecting the per-Q component collections. + + The value changes whenever Q changes, components are added/removed/replaced through the + model's methods, or the live template collection (``components``) is mutated in place. + Implemented as an internal counter plus the template collection's mutation version, so it + only ever increases. Reading never rebuilds, clears or mutates anything; equal values mean + the collections' inputs are unchanged. + + Returns + ------- + int + The current state version. + """ + return self._state_counter + self._components.version @property def Q(self) -> sc.Variable | None: @@ -396,8 +472,11 @@ def normalize_area(self) -> None: def _ensure_component_collections_current(self) -> None: """ Rebuild component collections if any dependency has changed since they were last built. + + Uses the full staleness check, so both flag-raising changes (Q, component methods) and + in-place mutations of the live template collection trigger a rebuild. """ - if self._component_collections_is_dirty: + if self.component_collections_is_dirty: self._generate_component_collections() self._component_collections_is_dirty = False diff --git a/src/easydynamics/sample_model/resolution_model.py b/src/easydynamics/sample_model/resolution_model.py index 3949f843c..b33024ab3 100644 --- a/src/easydynamics/sample_model/resolution_model.py +++ b/src/easydynamics/sample_model/resolution_model.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +import warnings from copy import copy import scipp as sc @@ -74,6 +75,8 @@ def __init__( Q : Q_type | None, default=None Q values for the model. If None, Q is not set. """ + # Set before super().__init__, which may call append_component (overridden below). + self._calibrated = False super().__init__( display_name=display_name, unique_name=unique_name, @@ -99,7 +102,14 @@ def append_component(self, component: ModelComponent | ComponentCollection) -> N ------ TypeError If the component is a DeltaFunction, Polynomial, or Exponential. + + Notes + ----- + A ``RuntimeError`` propagates from the calibration guard if the model holds calibrated + per-Q collections from ``from_sample_model``; a template change would schedule a rebuild + that silently discards them. """ + self._assert_not_calibrated('append a component') components = component if isinstance(component, ComponentCollection) else (component,) for comp in components: @@ -110,6 +120,84 @@ def append_component(self, component: ModelComponent | ComponentCollection) -> N super().append_component(component) + def remove_component(self, name: str) -> None: + """ + Remove a component from the ResolutionModel by its name. + + Parameters + ---------- + name : str + The name of the component to remove. + + Notes + ----- + A ``RuntimeError`` propagates from the calibration guard if the model holds calibrated + per-Q collections from ``from_sample_model``; a template change would schedule a rebuild + that silently discards them. + """ + self._assert_not_calibrated('remove a component') + super().remove_component(name) + + def clear_components(self) -> None: + """ + Clear all components from the ResolutionModel. + + Notes + ----- + A ``RuntimeError`` propagates from the calibration guard if the model holds calibrated + per-Q collections from ``from_sample_model``; a template change would schedule a rebuild + that silently discards them. + """ + self._assert_not_calibrated('clear the components') + super().clear_components() + + def clear_Q(self, confirm: bool = False) -> None: + """ + Clear the Q values of the ResolutionModel, removing all component collections and their + associated Parameters. + + Parameters + ---------- + confirm : bool, default=False + Confirmation to clear Q values. + + Notes + ----- + A ``ValueError`` propagates from the base implementation if confirm is not True, and a + ``RuntimeError`` propagates from the calibration guard if the model holds calibrated per-Q + collections from ``from_sample_model``; clearing Q would discard them. + """ + self._assert_not_calibrated('clear Q') + super().clear_Q(confirm=confirm) + + def _assert_not_calibrated(self, action: str) -> None: + """ + Raise if this model holds calibrated per-Q collections installed by from_sample_model. + + The per-Q collections installed by ``from_sample_model`` hold the fitted (calibrated) + resolution, but the template components do not. Any mutation that schedules a rebuild would + silently replace the calibrated collections with unfitted template copies, so such + mutations fail loudly instead. + + Parameters + ---------- + action : str + Description of the attempted mutation, used in the error message. + + Raises + ------ + RuntimeError + If the model is calibrated. + """ + if self._calibrated: + raise RuntimeError( + f'Cannot {action} on a ResolutionModel created by from_sample_model: its per-Q ' + f'collections hold the fitted (calibrated) resolution, and this change would ' + f'rebuild them from the unfitted template, silently discarding the calibration. ' + f'Create a new ResolutionModel (or rerun from_sample_model on an updated ' + f'SampleModel) instead.' + ) + @classmethod def from_sample_model( cls, @@ -120,6 +208,18 @@ def from_sample_model( """ Create a ResolutionModel from a SampleModel. + DeltaFunction components (the standard QENS elastic line) are stripped from both the + template and the per-Q collections, with a warning: a delta carries no resolution + broadening (it is the identity under convolution), so the fitted broadened components are + the resolution. Polynomial and Exponential components are rejected, as backgrounds do not + belong in a resolution model. + + When the SampleModel has Q values, the fitted per-Q collections are installed as the + calibrated resolution and the model is locked: mutations that would rebuild the collections + from the (unfitted) template — ``append_component``, ``remove_component``, + ``clear_components``, ``clear_Q`` — raise a RuntimeError instead of silently discarding the + calibration. + Parameters ---------- sample_model : SampleModel @@ -137,8 +237,11 @@ def from_sample_model( Raises ------ TypeError - If sample_model is not a SampleModel, or if normalize_area or fix_parameters are not - bool. + If sample_model is not a SampleModel, if normalize_area or fix_parameters are not bool, + or if the SampleModel contains Polynomial or Exponential components. + ValueError + If a per-Q collection contains only DeltaFunction components, leaving no resolution + shape after stripping. """ if not isinstance(sample_model, SampleModel): raise TypeError( @@ -151,11 +254,22 @@ def from_sample_model( if not isinstance(fix_parameters, bool): raise TypeError('fix_parameters must be True or False.') + template = ComponentCollection( + x_unit=sample_model.x_unit, + y_unit=sample_model.y_unit, + ) + stripped_deltas = 0 + for component in sample_model.components: + if isinstance(component, DeltaFunction): + stripped_deltas += 1 + continue + template.append_component(component) + resolution_model = cls( display_name=sample_model.display_name, x_unit=sample_model.x_unit, y_unit=sample_model.y_unit, - components=sample_model.components, + components=template, Q=sample_model.Q, ) @@ -163,10 +277,33 @@ def from_sample_model( # Prepare the per-Q collections detached from the model so no EasyScience # callback can schedule a rebuild halfway through, then install them and # clear the dirty flag in one final step. - collections = [ - copy(sample_model.get_component_collection(Q_index=index)) - for index in range(len(sample_model.Q)) - ] + collections = [] + for index in range(len(sample_model.Q)): + source = copy(sample_model.get_component_collection(Q_index=index)) + filtered = ComponentCollection( + name=source.name, + display_name=source.display_name, + x_unit=source.x_unit, + y_unit=source.y_unit, + ) + for component in source: + if isinstance(component, DeltaFunction): + stripped_deltas += 1 + continue + if isinstance(component, (Polynomial, Exponential)): + raise TypeError( + f'Component in ResolutionModel cannot be a ' + f'{component.__class__.__name__}' + ) + filtered.append_component(component) + if len(filtered) == 0: + raise ValueError( + f'The SampleModel collection at Q index {index} contains only ' + f'DeltaFunction components; after stripping them no resolution shape ' + f'is left. Fit the resolution data with at least one broadened ' + f'component (e.g. a Gaussian).' + ) + collections.append(filtered) for collection in collections: if normalize_area: collection.normalize_area() @@ -174,6 +311,16 @@ def from_sample_model( collection.fix_all_parameters() resolution_model._component_collections = collections resolution_model._component_collections_is_dirty = False + resolution_model._calibrated = True + + if stripped_deltas: + warnings.warn( + f'Stripped {stripped_deltas} DeltaFunction component(s) from the SampleModel ' + f'when building the ResolutionModel: a delta function carries no resolution ' + f'broadening (it is the identity under convolution).', + UserWarning, + stacklevel=2, + ) return resolution_model diff --git a/src/easydynamics/sample_model/sample_model.py b/src/easydynamics/sample_model/sample_model.py index fc6b77624..20ae75da7 100644 --- a/src/easydynamics/sample_model/sample_model.py +++ b/src/easydynamics/sample_model/sample_model.py @@ -16,6 +16,7 @@ from easydynamics.utils.utils import Numeric from easydynamics.utils.utils import Q_type from easydynamics.utils.utils import _validate_and_convert_Q +from easydynamics.utils.utils import _validate_unit from easydynamics.utils.utils import convert_units_with_rollback @@ -125,42 +126,48 @@ def __init__( self._diffusion_models = diffusion_models Q = _validate_and_convert_Q(Q) - for dm in self.diffusion_models: - dm.Q = Q # Ensure diffusion models have the same Q as the SampleModel - - super().__init__( - display_name=display_name, - unique_name=unique_name, - x_unit=x_unit, - y_unit=y_unit, - components=components, - Q=Q, - ) + # Validate (and build) everything else before mutating the passed diffusion models + # below, so a failed construction does not leave them changed (their Q set and their + # component collections rebuilt). + temperature_unit = _validate_unit(temperature_unit) if temperature is None: - self._temperature = None + temperature_parameter = None else: if not isinstance(temperature, Numeric): raise TypeError('temperature must be a number or None') if temperature < 0: raise ValueError('temperature must be non-negative') - self._temperature = Parameter( + temperature_parameter = Parameter( name='Temperature', value=temperature, unit=temperature_unit, display_name='Temperature', fixed=True, ) - self._temperature_unit = temperature_unit if detailed_balance_settings is None: - self._detailed_balance_settings = DetailedBalanceSettings() - elif isinstance(detailed_balance_settings, DetailedBalanceSettings): - self._detailed_balance_settings = detailed_balance_settings - else: + detailed_balance_settings = DetailedBalanceSettings() + elif not isinstance(detailed_balance_settings, DetailedBalanceSettings): raise TypeError('detailed_balance_settings must be a DetailedBalanceSettings or None') + for dm in self.diffusion_models: + dm.Q = Q # Ensure diffusion models have the same Q as the SampleModel + + super().__init__( + display_name=display_name, + unique_name=unique_name, + x_unit=x_unit, + y_unit=y_unit, + components=components, + Q=Q, + ) + + self._temperature = temperature_parameter + self._temperature_unit = temperature_unit + self._detailed_balance_settings = detailed_balance_settings + # ------------------------------------------------------------------ # Component management # ------------------------------------------------------------------ @@ -326,14 +333,14 @@ def temperature(self, value: Numeric | None) -> None: self._temperature.value = value @property - def temperature_unit(self) -> str | sc.Unit: + def temperature_unit(self) -> str: """ Get the temperature unit. Returns ------- - str | sc.Unit - The unit of the temperature parameter. + str + The unit of the temperature parameter, normalized to a string. """ return self._temperature_unit @@ -378,6 +385,7 @@ def convert_temperature_unit(self, unit: str | sc.Unit) -> None: if self.temperature is None: raise ValueError('Temperature is not set, cannot convert unit.') + unit = _validate_unit(unit) # normalize to str, as easyscience expects old_unit = self.temperature.unit try: @@ -555,7 +563,13 @@ def evaluate( divide_by_temperature=self.detailed_balance_settings.normalize_detailed_balance, energy_unit=self.x_unit, ) - y = [yi * DBF for yi in y] + if output == 'scipp': + # DBF is a plain numpy array (a dimensionless factor when + # normalize_detailed_balance is True), so multiply the values and keep the + # unit label the collections produced, consistent with numpy output. + y = [sc.array(dims=yi.dims, values=yi.values * DBF, unit=yi.unit) for yi in y] + else: + y = [yi * DBF for yi in y] return y diff --git a/src/easydynamics/settings/convolution_settings.py b/src/easydynamics/settings/convolution_settings.py index 1ac059c73..46bdcb6ec 100644 --- a/src/easydynamics/settings/convolution_settings.py +++ b/src/easydynamics/settings/convolution_settings.py @@ -140,44 +140,51 @@ def upsample_factor(self, factor: Numeric | None) -> None: self._invalidate_plan() @property - def extension_factor(self) -> float: + def extension_factor(self) -> float | None: """ Get the extension factor. The extension factor determines how much the energy range is extended on both sides before - convolution. 0.2 means extending by 20% of the original energy span on each side + convolution. 0.2 means extending by 20% of the original energy span on each side. None is + only valid while upsampling is disabled (upsample_factor=None). Returns ------- - float - The extension factor. + float | None + The extension factor, or None if unset. """ return self._extension_factor @extension_factor.setter - def extension_factor(self, factor: Numeric) -> None: + def extension_factor(self, factor: Numeric | None) -> None: """ Set the extension factor and recreate the dense grid. The extension factor determines how much the energy range is extended on both sides before - convolution. 0.2 means extending by 20% of the original energy span on each side. + convolution. 0.2 means extending by 20% of the original energy span on each side. None is + accepted (matching the constructor), but convolvers require a numeric extension factor + whenever upsample_factor is set. Parameters ---------- - factor : Numeric + factor : Numeric | None The new extension factor. Raises ------ TypeError - If factor is not a number. + If factor is neither a number nor None. ValueError If factor is negative. """ + if factor is None: + self._extension_factor = None + self._invalidate_plan() + return if not isinstance(factor, Numeric): - raise TypeError('Extension factor must be a number.') + raise TypeError('Extension factor must be a number or None.') if factor < 0.0: raise ValueError('Extension factor must be non-negative.') diff --git a/src/easydynamics/settings/detailed_balance_settings.py b/src/easydynamics/settings/detailed_balance_settings.py index d8c4f7e9e..ea85c6e35 100644 --- a/src/easydynamics/settings/detailed_balance_settings.py +++ b/src/easydynamics/settings/detailed_balance_settings.py @@ -76,6 +76,41 @@ def __init__( unique_name=unique_name, ) + # Plan-invalidation bookkeeping for convolvers sharing this settings object. + # Mirrors ConvolutionSettings: _plan_version is bumped whenever a flag changes; + # each convolver records the version it last rebuilt against and rebuilds when the + # versions differ. + self._plan_version = 0 + + # ------------------------------------------------------------------ + # Plan invalidation + # ------------------------------------------------------------------ + + def _invalidate_plan(self) -> None: + """ + Invalidate the convolution plan for every convolver sharing these settings. + + Bumps the plan version, so every convolver that recorded an earlier version rebuilds its + plan before the next convolution. + """ + self._plan_version += 1 + + def _plan_valid_for(self, seen_version: int) -> bool: + """ + Check whether a convolver that last rebuilt at seen_version can skip rebuilding. + + Parameters + ---------- + seen_version : int + The plan version the convolver recorded when it last rebuilt its plan. + + Returns + ------- + bool + True if no invalidation happened since the convolver's rebuild. + """ + return seen_version == self._plan_version + # ------------------------------------------------------------------ # Properties # ------------------------------------------------------------------ @@ -110,6 +145,7 @@ def use_detailed_balance(self, value: bool) -> None: if not isinstance(value, bool): raise TypeError('use_detailed_balance must be True or False') self._use_detailed_balance = value + self._invalidate_plan() @property def normalize_detailed_balance(self) -> bool: @@ -143,6 +179,7 @@ def normalize_detailed_balance(self, value: bool) -> None: if not isinstance(value, bool): raise TypeError('normalize_detailed_balance must be True or False') self._normalize_detailed_balance = value + self._invalidate_plan() def __repr__(self) -> str: """ diff --git a/src/easydynamics/utils/__init__.py b/src/easydynamics/utils/__init__.py index 1c3402ced..bb782a682 100644 --- a/src/easydynamics/utils/__init__.py +++ b/src/easydynamics/utils/__init__.py @@ -6,9 +6,11 @@ from easydynamics.utils.posterior_plotting import plot_corner from easydynamics.utils.posterior_plotting import plot_posterior_predictive from easydynamics.utils.posterior_plotting import plot_trace +from easydynamics.utils.utils import hbar __all__ = [ 'detailed_balance_factor', + 'hbar', 'plot_corner', 'plot_posterior_predictive', 'plot_trace', diff --git a/src/easydynamics/utils/detailed_balance.py b/src/easydynamics/utils/detailed_balance.py index a3b540cdc..374f839a6 100644 --- a/src/easydynamics/utils/detailed_balance.py +++ b/src/easydynamics/utils/detailed_balance.py @@ -23,7 +23,7 @@ def detailed_balance_factor( - energy: float | list | np.ndarray | sc.Variable, + energy: float | list | np.ndarray | sc.Variable | sc.DataArray, temperature: float | sc.Variable | Parameter, energy_unit: str | sc.Unit = 'meV', temperature_unit: str | sc.Unit = 'K', @@ -37,10 +37,12 @@ def detailed_balance_factor( Parameters ---------- - energy : float | list | np.ndarray | sc.Variable - The energy transfer. If number, assumed to be in meV unless energy_unit is set. + energy : float | list | np.ndarray | sc.Variable | sc.DataArray + The energy transfer. If number, assumed to be in meV unless energy_unit is set. If a + DataArray, its single coordinate is used as the energy axis. temperature : float | sc.Variable | Parameter - The temperature. If number, assumed to be in K unless temperature_unit is set. + The temperature. Must be a single scalar value. If number, assumed to be in K unless + temperature_unit is set. energy_unit : str | sc.Unit, default='meV' Unit for energy if energy is given as a number or list. temperature_unit : str | sc.Unit, default='K' @@ -52,13 +54,13 @@ def detailed_balance_factor( Raises ------ TypeError - If energy or temperature is not a number, list, numpy array, or scipp Variable, or if - energy_unit or temperature_unit is not a string or scipp Unit, or if divide_by_temperature - is not a boolean. + If energy or temperature is not one of the accepted types, or if energy_unit or + temperature_unit is not a string or scipp Unit, or if divide_by_temperature is not a + boolean. ValueError - If temperature is negative, or if energy is a numpy array with more than 1 dimension, or if - temperature is a scipp Variable that does not have a single dimension named 'temperature', - or if energy is a scipp Variable that does not have a single dimension named 'energy'. + If temperature is negative or is not a single scalar value, if energy is a list or numpy + array with more than 1 dimension, or if energy is a scipp DataArray without exactly one + coordinate. UnitError If the provided energy_unit or temperature_unit is invalid, or if the units of energy or temperature cannot be converted to the expected units. @@ -109,6 +111,12 @@ def detailed_balance_factor( value=temperature, unit=temperature_unit, name='temperature' ) + if temperature.sizes != {}: + raise ValueError( + f'temperature must be a single scalar value, ' + f'got an array with sizes {dict(temperature.sizes)}.' + ) + if temperature.value < 0: raise ValueError('Temperature must be non-negative.') @@ -190,7 +198,7 @@ def detailed_balance_factor( def _convert_to_scipp_variable( - value: float | list | np.ndarray | Parameter | sc.Variable, + value: float | list | np.ndarray | Parameter | sc.Variable | sc.DataArray, name: str, unit: str | None = None, ) -> sc.Variable: @@ -199,9 +207,11 @@ def _convert_to_scipp_variable( Parameters ---------- - value : float | list | np.ndarray | Parameter | sc.Variable - The value to convert. Can be a number, list, numpy array, Parameter, or scipp Variable. If - a number or list, the unit must be specified in the unit argument. + value : float | list | np.ndarray | Parameter | sc.Variable | sc.DataArray + The value to convert. Can be a number, list, numpy array, Parameter, scipp Variable, or + scipp DataArray. If a number or list, the unit must be specified in the unit argument. A + DataArray must have exactly one coordinate, which is used as the value (consistent with how + components treat DataArray input to ``evaluate``). name : str The name of the variable, used for error messages. unit : str | None, default=None @@ -213,6 +223,9 @@ def _convert_to_scipp_variable( ------ TypeError If value is not one of the accepted types, or if unit is not a string when needed. + ValueError + If value is a list or numpy array with more than 1 dimension, or a DataArray without + exactly one coordinate. UnitError If the provided unit is invalid. @@ -221,6 +234,16 @@ def _convert_to_scipp_variable( sc.Variable The input value converted to a scipp Variable with appropriate units. """ + if isinstance(value, sc.DataArray): + coords = dict(value.coords) + if len(coords) != 1: + coord_names = ', '.join(coords.keys()) + raise ValueError( + f'scipp.DataArray must have exactly one coordinate to be used as {name}. ' + f'Found {len(coords)} coordinates: {coord_names}.' + ) + value = next(iter(coords.values())) + if isinstance(value, sc.Variable): return value @@ -237,6 +260,11 @@ def _convert_to_scipp_variable( raise TypeError(f'{name} must be a number, list, numpy array or scipp Variable') raise TypeError(f'{name} must be a number, list, numpy array, Parameter or scipp Variable') + if array_value.ndim > 1: + raise ValueError( + f'{name} must be at most one-dimensional, got {array_value.ndim} dimensions.' + ) + # Create appropriate scipp variable based on shape if array_value.shape == () or (array_value.shape == (1,)): # Scalar or single-element array diff --git a/src/easydynamics/utils/utils.py b/src/easydynamics/utils/utils.py index 06661df25..4ebc4a304 100644 --- a/src/easydynamics/utils/utils.py +++ b/src/easydynamics/utils/utils.py @@ -44,14 +44,15 @@ def verify_Q_index(Q_index: int, Q: sc.Variable | None, allow_none: bool = False Raises ------ TypeError - If Q_index is not an int (or not an int or None when allow_none=True). + If Q_index is not an int (or not an int or None when allow_none=True). Booleans are + rejected explicitly, since ``True`` would otherwise silently mean index 1. IndexError If Q_index is negative, or out of range when Q is available. """ if allow_none and Q_index is None: return - if Q_index is None or not isinstance(Q_index, int): + if Q_index is None or isinstance(Q_index, bool) or not isinstance(Q_index, int): if allow_none: raise TypeError(f'Q_index must be an int or None, got {type(Q_index).__name__}') raise TypeError(f'Q_index must be an int, got {type(Q_index).__name__}') @@ -275,6 +276,8 @@ def _in_notebook() -> bool: True if in a Jupyter notebook, False otherwise. """ try: + # Imported here deliberately: IPython may be absent at runtime, and the except + # clause below turns that into the answer "not a notebook". from IPython import get_ipython shell = get_ipython().__class__.__name__ diff --git a/tests/functional/test_dummy.py b/tests/functional/test_dummy.py index b45b191fe..6a2a5cd51 100644 --- a/tests/functional/test_dummy.py +++ b/tests/functional/test_dummy.py @@ -1,8 +1,22 @@ # SPDX-FileCopyrightText: 2025-2026 EasyDynamics contributors # SPDX-License-Identifier: BSD-3-Clause +import numpy as np -def test_dummy(): - calculated = 2 + 2 - expected = 4 - assert calculated == expected +import easydynamics as edyn + + +def test_smoke_build_and_evaluate_model(): + # WHEN a minimal sample model with a single Lorentzian component + lorentzian = edyn.Lorentzian(area=1.0, width=0.1) + model = edyn.SampleModel(components=lorentzian) + + # THEN evaluating the component on a small energy grid + energy = np.linspace(-1.0, 1.0, 101) + y = lorentzian.evaluate(energy) + + # EXPECT the package installs, the model builds, and the evaluation is finite and peaked + assert model is not None + assert y.shape == energy.shape + assert np.all(np.isfinite(y)) + assert y.max() > 0.0 diff --git a/tests/integration/fitting/test_bayesian_sampling.py b/tests/integration/fitting/test_bayesian_sampling.py index 58fc48ab6..81c98ea57 100644 --- a/tests/integration/fitting/test_bayesian_sampling.py +++ b/tests/integration/fitting/test_bayesian_sampling.py @@ -18,6 +18,8 @@ mpl.use('Agg') +import matplotlib.pyplot as plt + from easydynamics.analysis.analysis1d import Analysis1d from easydynamics.experiment import Experiment from easydynamics.sample_model import InstrumentModel @@ -192,8 +194,6 @@ def test_subset_sampling_produces_a_single_column(self): def test_plots_render(self, sampled_analysis): # WHEN - import matplotlib.pyplot as plt - n_parameters = len(sampled_analysis.get_free_parameters()) # THEN diff --git a/tests/integration/fitting/test_bayesian_sampling_multi_q.py b/tests/integration/fitting/test_bayesian_sampling_multi_q.py index 74061b57e..52a4a93d8 100644 --- a/tests/integration/fitting/test_bayesian_sampling_multi_q.py +++ b/tests/integration/fitting/test_bayesian_sampling_multi_q.py @@ -19,6 +19,8 @@ mpl.use('Agg') +import matplotlib.pyplot as plt + import easydynamics as edyn import easydynamics.sample_model as sm @@ -142,8 +144,6 @@ def test_sampling_leaves_the_fitted_values_untouched(self): def test_plots_render(self, simultaneously_sampled): # WHEN - import matplotlib.pyplot as plt - n_parameters = len(simultaneously_sampled._chain_parameters()) # THEN diff --git a/tests/integration/fitting/test_fitting_with_diffusion_model.py b/tests/integration/fitting/test_fitting_with_diffusion_model.py index f9912c249..067692de1 100644 --- a/tests/integration/fitting/test_fitting_with_diffusion_model.py +++ b/tests/integration/fitting/test_fitting_with_diffusion_model.py @@ -3,6 +3,7 @@ import numpy as np import pooch +import pytest from easydynamics.analysis.analysis import Analysis from easydynamics.experiment import Experiment @@ -19,6 +20,10 @@ from easydynamics.sample_model.resolution_model import ResolutionModel from easydynamics.sample_model.sample_model import SampleModel +# Every test here downloads its data files through pooch; deselect with -m 'not network' +# when running offline. +pytestmark = pytest.mark.network + class TestFittingWithDiffusionModel: def test_fitting_with_diffusion_model(self): @@ -146,7 +151,7 @@ def test_fitting_with_diffusion_model(self): pars = diffusion_model.get_all_parameters() - tol = 10 * pars[0].error + tol = 3 * pars[0].error assert np.isclose(pars[0].value, 1.1258025622851164e-08, atol=tol) - tol = 10 * pars[1].error + tol = 3 * pars[1].error assert np.isclose(pars[1].value, 0.6937774083152299, atol=tol) diff --git a/tests/unit/easydynamics/analysis/test_analysis.py b/tests/unit/easydynamics/analysis/test_analysis.py index b8bda4f69..ab34bf879 100644 --- a/tests/unit/easydynamics/analysis/test_analysis.py +++ b/tests/unit/easydynamics/analysis/test_analysis.py @@ -1,12 +1,15 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +from collections import Counter from unittest.mock import MagicMock from unittest.mock import patch import numpy as np import pytest import scipp as sc +from easyscience.fitting.multi_fitter import MultiFitter +from easyscience.variable import Parameter import easydynamics as edyn import easydynamics.sample_model as sm @@ -16,6 +19,7 @@ from easydynamics.sample_model import SampleModel from easydynamics.sample_model.components.gaussian import Gaussian from easydynamics.settings.convolution_settings import ConvolutionSettings +from easydynamics.settings.detailed_balance_settings import DetailedBalanceSettings Q_VALUES = [0.5, 1.0, 1.5] @@ -466,11 +470,19 @@ def test_plot_data_and_model_with_residuals( @pytest.mark.parametrize('include_residuals', [True, False]) def test_data_and_model_to_datagroup(self, analysis, include_residuals): - # WHEN + # WHEN a custom energy grid is passed energy = sc.array(dims=['energy'], values=[20.0, 30.0, 40.0], unit='meV') - datagroup = analysis.data_and_model_to_datagroup( - energy=energy, include_residuals=include_residuals - ) + + # THEN residuals cannot be computed on a custom grid, so they are omitted with a warning + if include_residuals: + with pytest.warns(UserWarning, match='omitted'): + datagroup = analysis.data_and_model_to_datagroup( + energy=energy, include_residuals=include_residuals + ) + else: + datagroup = analysis.data_and_model_to_datagroup( + energy=energy, include_residuals=include_residuals + ) # EXPECT assert isinstance(datagroup, sc.DataGroup) @@ -478,12 +490,20 @@ def test_data_and_model_to_datagroup(self, analysis, include_residuals): assert 'Model' in datagroup assert sc.identical(datagroup['Data'], analysis.experiment.binned_data) assert sc.identical(datagroup['Model'], analysis._create_model_array(energy=energy)) - if include_residuals: - assert 'Residuals' in datagroup - assert sc.identical( - datagroup['Residuals'], - analysis.experiment.binned_data - analysis._create_model_array(), - ) + assert 'Residuals' not in datagroup + + def test_data_and_model_to_datagroup_residuals_on_experiment_grid(self, analysis): + # WHEN no custom energy grid is given + + # THEN + datagroup = analysis.data_and_model_to_datagroup(include_residuals=True) + + # EXPECT residuals present and consistent with the data and model on the same grid + assert 'Residuals' in datagroup + assert sc.identical( + datagroup['Residuals'], + analysis.experiment.binned_data - analysis._create_model_array(), + ) def test_data_and_model_to_datagroup_no_data_raises(self, analysis): # WHEN @@ -800,6 +820,137 @@ def test_on_convolution_settings_changed(self, analysis): assert analysis1d.convolution_settings.upsample_factor == 7 assert analysis1d.convolution_settings.extension_factor == pytest.approx(0.3) + def test_on_detailed_balance_settings_changed(self, analysis): + # WHEN the analysis list has been built with the old settings + _ = analysis.analysis_list + assert analysis._analysis_list_is_dirty is False + new_settings = DetailedBalanceSettings( + use_detailed_balance=False, normalize_detailed_balance=False + ) + + # THEN (this calls _on_detailed_balance_settings_changed internally) + analysis.detailed_balance_settings = new_settings + + # EXPECT the parent holds the new settings object and the per-Q analyses are rebuilt + # around it, so the change actually reaches every Q index + assert analysis.detailed_balance_settings is new_settings + assert analysis._analysis_list_is_dirty is True + for analysis1d in analysis.analysis_list: + assert analysis1d.detailed_balance_settings is new_settings + + def test_detailed_balance_settings_change_invalidates_the_fitter(self, analysis): + # WHEN + original = analysis.fitter + + # THEN + analysis.detailed_balance_settings = DetailedBalanceSettings(use_detailed_balance=False) + + # EXPECT + assert analysis.fitter is not original + + def test_rebin_invalidates_the_fitter_and_the_sampler(self, analysis): + # WHEN the fitter and sampler exist from before the rebin + original_fitter = analysis.fitter + sampler = analysis.bayesian + + # THEN - energy rebin leaves Q unchanged, so no confirm required + with ( + patch.object(analysis.experiment, 'rebin'), + patch.object(sampler, 'invalidate') as mock_invalidate, + ): + analysis.rebin({'energy': 2}) + + # EXPECT neither keeps referencing the pre-rebin Analysis1d objects and data + assert analysis.fitter is not original_fitter + mock_invalidate.assert_called_once() + + def test_simultaneous_fit_uses_the_configured_fitter(self, analysis): + # WHEN the cached fitter has been configured (e.g. its minimizer switched) + fake_fitter = MagicMock() + fake_fitter.fit.return_value = 'simultaneous_result' + analysis._fitter = fake_fitter + analysis._fitter_is_dirty = False + + # THEN + result = analysis.fit(fit_method='simultaneous') + + # EXPECT the configured fitter object performed the fit, not a throwaway MultiFitter + fake_fitter.fit.assert_called_once() + assert result == 'simultaneous_result' + + def test_uses_a_multifitter(self, multi_q_analysis): + # EXPECT + assert isinstance(multi_q_analysis.fitter, MultiFitter) + assert len(multi_q_analysis.fitter.fit_object) == len(Q_VALUES) + + def test_get_all_variables(self, analysis): + # WHEN + extra_par = Parameter(name='extra_par', value=1.0) + analysis._extra_parameters = [extra_par] + + # THEN + variables = analysis.get_all_variables() + + # EXPECT variables across every Q index plus the extra parameters + expected = analysis.sample_model.get_all_variables() + expected.extend(analysis.instrument_model.get_all_variables()) + expected.append(extra_par) + assert Counter(variables) == Counter(expected) + + def test_get_all_variables_on_an_empty_analysis(self): + # WHEN + analysis = Analysis(display_name='Empty') + + # THEN EXPECT no failure and no variables + assert analysis.get_all_variables() == [] + assert analysis.get_parameters_near_bounds() == [] + + def test_get_parameters_near_bounds_builds_no_fitter_or_sampler(self, analysis): + # WHEN neither the fitter nor the sampler exists yet + assert analysis._fitter is None + assert analysis._bayesian is None + + # THEN + analysis.get_parameters_near_bounds() + + # EXPECT listing parameters did not build them as side effects + assert analysis._fitter is None + assert analysis._bayesian is None + + ############# + # The bayesian sampler (the Analysis side of the contract) + ############# + + def test_bayesian_returns_the_cached_sampler(self, analysis): + # THEN + sampler = analysis.bayesian + + # EXPECT the same object on second access + assert sampler is analysis.bayesian + + def test_bayesian_is_invalidated_when_the_experiment_changes(self, analysis): + # WHEN + sampler = analysis.bayesian + new_experiment = Experiment(data=analysis.experiment.data.copy(deep=True)) + + # THEN + with patch.object(sampler, 'invalidate') as mock_invalidate: + analysis.experiment = new_experiment + + # EXPECT + mock_invalidate.assert_called_once() + + def test_bayesian_is_invalidated_when_the_sample_model_changes(self, analysis): + # WHEN + sampler = analysis.bayesian + + # THEN + with patch.object(sampler, 'invalidate') as mock_invalidate: + analysis.sample_model = SampleModel(components=Gaussian()) + + # EXPECT + mock_invalidate.assert_called_once() + def test_fit_single_Q_valid(self, analysis): # WHEN analysis.analysis_list[1].fit = MagicMock(return_value='fit_result_Q1') @@ -1173,6 +1324,13 @@ def test_repr(self, analysis): assert 'display_name=' in repr_str assert 'n_analyses=' in repr_str + def test_repr_reports_a_current_analysis_count(self, analysis): + # WHEN the analysis list has not been built yet + assert analysis._analysis_list == [] + + # THEN EXPECT repr ensures the list is current instead of reporting a stale count + assert 'n_analyses=3' in repr(analysis) + ############# # Chain parameters and labels ############# @@ -1243,8 +1401,6 @@ def test_single_q_analysis_keeps_plain_names(self): def test_parameter_from_outside_the_analysis_keeps_its_name(self, multi_q_analysis): # WHEN a parameter belongs to no Q index of this analysis - from easyscience.variable import Parameter - stranger = Parameter(name='Gaussian width', value=1.0) # EXPECT it is returned unqualified rather than mislabelled diff --git a/tests/unit/easydynamics/analysis/test_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index 99ab53ab5..40ae70a0b 100644 --- a/tests/unit/easydynamics/analysis/test_analysis1d.py +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -19,6 +19,12 @@ from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.sample_model.components.gaussian import Gaussian from easydynamics.sample_model.components.polynomial import Polynomial +from easydynamics.settings.detailed_balance_settings import DetailedBalanceSettings + +# The per-consumer convolver staleness tracking relies on the ModelBase.state_version contract; +# until it lands, the conservative fallback rebuilds on every prepare, so 'no rebuild' tests +# cannot pass. +HAS_STATE_VERSION = hasattr(SampleModel, 'state_version') class TestAnalysis1d: @@ -413,10 +419,16 @@ def test_data_and_model_to_datagroup(self, analysis1d, include_residuals): # WHEN energy = sc.array(dims=['energy'], values=[20.0, 30.0, 40.0], unit='meV') - # THEN - datagroup = analysis1d.data_and_model_to_datagroup( - energy=energy, include_residuals=include_residuals - ) + # THEN residuals cannot be computed on a custom grid, so they are omitted with a warning + if include_residuals: + with pytest.warns(UserWarning, match='omitted'): + datagroup = analysis1d.data_and_model_to_datagroup( + energy=energy, include_residuals=include_residuals + ) + else: + datagroup = analysis1d.data_and_model_to_datagroup( + energy=energy, include_residuals=include_residuals + ) # EXPECT assert isinstance(datagroup, sc.DataGroup) @@ -427,14 +439,20 @@ def test_data_and_model_to_datagroup(self, analysis1d, include_residuals): analysis1d.experiment.binned_data['Q', analysis1d.Q_index], ) assert sc.identical(datagroup['Model'], analysis1d._create_model_array(energy=energy)) - if include_residuals: - assert 'Residuals' in datagroup - assert sc.identical( - datagroup['Residuals'], - datagroup['Data'] - analysis1d._create_model_array(), - ) - else: - assert 'Residuals' not in datagroup + assert 'Residuals' not in datagroup + + def test_data_and_model_to_datagroup_residuals_on_experiment_grid(self, analysis1d): + # WHEN no custom energy grid is given + + # THEN + datagroup = analysis1d.data_and_model_to_datagroup(include_residuals=True) + + # EXPECT residuals present and consistent with the data and model on the same grid + assert 'Residuals' in datagroup + assert sc.identical( + datagroup['Residuals'], + datagroup['Data'] - analysis1d._create_model_array(), + ) def test_data_and_model_to_datagroup_no_data_raises(self, analysis1d): # WHEN @@ -1054,15 +1072,13 @@ def test_fit_marks_convolver_dirty_when_sample_model_components_change(self, ana # EXPECT - convolver was rebuilt (_ensure_convolver_current called _create_convolver) analysis1d._create_convolver.assert_called_once() + @pytest.mark.skipif(not HAS_STATE_VERSION, reason='pending ModelBase.state_version contract') def test_fit_does_not_rebuild_convolver_when_nothing_changed(self, analysis1d): """fit() should not call _create_convolver if nothing has changed since last fit.""" - # WHEN - build convolver and clear all dirty flags + # WHEN - a first fit has built the convolver against the current model state analysis1d._create_convolver = MagicMock(return_value=None) - analysis1d._convolver_is_dirty = False - analysis1d.sample_model._component_collections_is_dirty = False - analysis1d.instrument_model.resolution_model._component_collections_is_dirty = False - # THEN - call fit() with nothing changed + # THEN - fit once to sync, then fit again with nothing changed with patch( 'easydynamics.analysis.analysis1d.EasyScienceFitter', return_value=MagicMock(fit=MagicMock(return_value=MagicMock())), @@ -1076,8 +1092,10 @@ def test_fit_does_not_rebuild_convolver_when_nothing_changed(self, analysis1d): ) ) analysis1d.fit() + analysis1d._create_convolver.reset_mock() + analysis1d.fit() - # EXPECT - _create_convolver was NOT called (convolver reused) + # EXPECT - _create_convolver was NOT called again (convolver reused) analysis1d._create_convolver.assert_not_called() def test_rebin_rebins_experiment(self, analysis1d): @@ -1164,7 +1182,161 @@ def test_fit_marks_convolver_dirty_when_resolution_model_components_change(self, # EXPECT analysis1d._create_convolver.assert_called_once() - # ───── Regression tests ───── + ############# + # Convolver staleness across analyses sharing a model + ############# + + @pytest.fixture + def sibling_analyses(self): + """Two Analysis1d objects sharing one SampleModel and one InstrumentModel.""" + Q = sc.array(dims=['Q'], values=[1.0, 2.0], unit='1/Angstrom') + energy = sc.linspace('energy', -5.0, 5.0, num=11, unit='meV') + values = np.ones((2, 11)) + data_array = sc.DataArray( + data=sc.array(dims=['Q', 'energy'], values=values, variances=values), + coords={'Q': Q, 'energy': energy}, + ) + experiment = Experiment(data=data_array) + sample_model = SampleModel(components=Gaussian()) + instrument_model = InstrumentModel( + resolution_model=ResolutionModel(components=Gaussian(width=0.5)) + ) + return [ + Analysis1d( + display_name=f'Sibling{q_index}', + experiment=experiment, + sample_model=sample_model, + instrument_model=instrument_model, + Q_index=q_index, + ) + for q_index in (0, 1) + ] + + def test_in_place_model_edit_rebuilds_the_convolvers_of_all_siblings(self, sibling_analyses): + """Regression: the first sibling to prepare must not consume the staleness signal.""" + # WHEN both siblings have built their convolvers against the shared model + first, second = sibling_analyses + first._prepare_for_sampling() + second._prepare_for_sampling() + first_convolver = first._convolver + second_convolver = second._convolver + assert first_convolver is not None + assert second_convolver is not None + + # THEN the shared model is edited in place (not through any Analysis1d setter) + first.sample_model.append_component(Gaussian(name='ExtraGaussian')) + first._prepare_for_sampling() + second._prepare_for_sampling() + + # EXPECT both siblings rebuilt their convolvers, not only the first one to prepare + assert first._convolver is not first_convolver + assert second._convolver is not second_convolver + + def test_in_place_resolution_edit_rebuilds_the_convolvers_of_all_siblings( + self, sibling_analyses + ): + # WHEN both siblings have built their convolvers against the shared resolution model + first, second = sibling_analyses + first._prepare_for_sampling() + second._prepare_for_sampling() + first_convolver = first._convolver + second_convolver = second._convolver + + # THEN the shared resolution model is edited in place + first.instrument_model.resolution_model.append_component(Gaussian(name='ExtraResolution')) + first._prepare_for_sampling() + second._prepare_for_sampling() + + # EXPECT both siblings rebuilt their convolvers + assert first._convolver is not first_convolver + assert second._convolver is not second_convolver + + @pytest.mark.skipif(not HAS_STATE_VERSION, reason='pending ModelBase.state_version contract') + def test_prepare_does_not_rebuild_when_the_models_are_unchanged(self, sibling_analyses): + # WHEN a convolver has been built against the current model state + first, _ = sibling_analyses + first._prepare_for_sampling() + convolver = first._convolver + + # THEN preparing again with nothing changed + first._prepare_for_sampling() + + # EXPECT the convolver is reused, not rebuilt + assert first._convolver is convolver + + ############# + # Detailed balance settings + ############# + + def test_detailed_balance_settings_change_marks_convolver_dirty(self, analysis1d): + # WHEN + analysis1d._convolver_is_dirty = False + + # THEN a new settings object is assigned + analysis1d.detailed_balance_settings = DetailedBalanceSettings(use_detailed_balance=False) + + # EXPECT + assert analysis1d._convolver_is_dirty is True + + ############# + # The bayesian sampler (the Analysis1d side of the contract) + ############# + + def test_bayesian_returns_the_cached_sampler(self, analysis1d): + # THEN + sampler = analysis1d.bayesian + + # EXPECT the same object on second access + assert sampler is analysis1d.bayesian + + def test_bayesian_is_invalidated_when_the_Q_index_changes(self, analysis1d): + # WHEN + sampler = analysis1d.bayesian + + # THEN a different Q index means different data + with patch.object(sampler, 'invalidate') as mock_invalidate: + analysis1d.Q_index = 1 + + # EXPECT + mock_invalidate.assert_called_once() + + def test_bayesian_is_invalidated_when_the_experiment_changes(self, analysis1d): + # WHEN + sampler = analysis1d.bayesian + new_experiment = Experiment(data=analysis1d.experiment.data.copy(deep=True)) + + # THEN + with patch.object(sampler, 'invalidate') as mock_invalidate: + analysis1d.experiment = new_experiment + + # EXPECT + mock_invalidate.assert_called_once() + + def test_bayesian_is_invalidated_when_the_sample_model_changes(self, analysis1d): + # WHEN + sampler = analysis1d.bayesian + + # THEN + with patch.object(sampler, 'invalidate') as mock_invalidate: + analysis1d.sample_model = SampleModel(components=Gaussian()) + + # EXPECT + mock_invalidate.assert_called_once() + + def test_bayesian_is_invalidated_on_rebin(self, analysis1d): + # WHEN + sampler = analysis1d.bayesian + + # THEN rebinning changes the data the sampler was bound to + with patch.object(sampler, 'invalidate') as mock_invalidate: + analysis1d.rebin({'Q': 1}) + + # EXPECT + mock_invalidate.assert_called_once() + + ############# + # Regression tests + ############# @pytest.fixture def analysis1d_with_nan(self): @@ -1207,12 +1379,9 @@ def test_data_and_model_to_datagroup_with_nan_excludes_nan_from_data( # Before the fix, 'Data' contained the full 3-point grid (including NaN) # and computing Residuals crashed on the dimension mismatch. # WHEN - energy = sc.array(dims=['energy'], values=[20.0, 30.0, 40.0], unit='meV') # THEN - datagroup = analysis1d_with_nan.data_and_model_to_datagroup( - energy=energy, include_residuals=True - ) + datagroup = analysis1d_with_nan.data_and_model_to_datagroup(include_residuals=True) # EXPECT assert isinstance(datagroup, sc.DataGroup) @@ -1228,41 +1397,47 @@ def test_repr(self, analysis1d): assert 'display_name=' in repr_str assert 'Q_index=' in repr_str + ############# + # Change handlers + ############# -def _coverage_analysis1d(): - Q = sc.array(dims=['Q'], values=[1, 2, 3], unit='1/Angstrom') - energy = sc.array(dims=['energy'], values=[10.0, 20.0, 30.0], unit='meV') - data = sc.array( - dims=['Q', 'energy'], - values=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], - variances=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], - ) - data_array = sc.DataArray(data=data, coords={'Q': Q, 'energy': energy}) - return Analysis1d( - display_name='CoverageAnalysis', - experiment=Experiment(data=data_array), - sample_model=SampleModel(components=Gaussian()), - instrument_model=InstrumentModel(), - Q_index=0, - ) + @staticmethod + def _coverage_analysis1d(): + Q = sc.array(dims=['Q'], values=[1, 2, 3], unit='1/Angstrom') + energy = sc.array(dims=['energy'], values=[10.0, 20.0, 30.0], unit='meV') + data = sc.array( + dims=['Q', 'energy'], + values=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], + variances=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], + ) + data_array = sc.DataArray(data=data, coords={'Q': Q, 'energy': energy}) + return Analysis1d( + display_name='CoverageAnalysis', + experiment=Experiment(data=data_array), + sample_model=SampleModel(components=Gaussian()), + instrument_model=InstrumentModel(), + Q_index=0, + ) + + def test_on_Q_index_changed_with_none_clears_masked_energy(self): + # WHEN an analysis whose Q_index has been cleared + analysis1d = self._coverage_analysis1d() + analysis1d._Q_index = None + + # THEN the Q-index-changed handler runs + analysis1d._on_Q_index_changed() + + # EXPECT masked energy cleared and convolver marked dirty + assert analysis1d._masked_energy is None + assert analysis1d._convolver_is_dirty is True + + def test_on_experiment_changed_refreshes_masked_energy_when_Q_index_set(self): + # WHEN an analysis with a Q_index already set + analysis1d = self._coverage_analysis1d() + # THEN the experiment-changed handler runs + analysis1d._on_experiment_changed() -def test_on_Q_index_changed_with_none_clears_masked_energy(): - # GIVEN an analysis whose Q_index has been cleared - analysis1d = _coverage_analysis1d() - analysis1d._Q_index = None - # WHEN the Q-index-changed handler runs - analysis1d._on_Q_index_changed() - # EXPECT masked energy cleared and convolver marked dirty - assert analysis1d._masked_energy is None - assert analysis1d._convolver_is_dirty is True - - -def test_on_experiment_changed_refreshes_masked_energy_when_Q_index_set(): - # GIVEN an analysis with a Q_index already set - analysis1d = _coverage_analysis1d() - # WHEN the experiment-changed handler runs - analysis1d._on_experiment_changed() - # EXPECT masked energy refreshed and convolver marked dirty - assert analysis1d._masked_energy is not None - assert analysis1d._convolver_is_dirty is True + # EXPECT masked energy refreshed and convolver marked dirty + assert analysis1d._masked_energy is not None + assert analysis1d._convolver_is_dirty is True diff --git a/tests/unit/easydynamics/analysis/test_analysis_base.py b/tests/unit/easydynamics/analysis/test_analysis_base.py index afddabc83..499348934 100644 --- a/tests/unit/easydynamics/analysis/test_analysis_base.py +++ b/tests/unit/easydynamics/analysis/test_analysis_base.py @@ -378,6 +378,33 @@ def test_detailed_balance_settings_setter_invalid(self, analysis_base): ): analysis_base.detailed_balance_settings = 'invalid_settings' + def test_detailed_balance_settings_calls_on_detailed_balance_settings_changed( + self, analysis_base + ): + # WHEN + new_settings = DetailedBalanceSettings( + use_detailed_balance=False, normalize_detailed_balance=False + ) + with patch.object( + analysis_base, '_on_detailed_balance_settings_changed' + ) as mock_on_detailed_balance_settings_changed: + # THEN + analysis_base.detailed_balance_settings = new_settings + + # EXPECT the change hook fires, like every sibling setter's does + mock_on_detailed_balance_settings_changed.assert_called_once() + + def test_detailed_balance_settings_setter_invalid_fires_no_hook(self, analysis_base): + # WHEN / THEN + with ( + patch.object(analysis_base, '_on_detailed_balance_settings_changed') as mock_hook, + pytest.raises(TypeError), + ): + analysis_base.detailed_balance_settings = 'invalid_settings' + + # EXPECT + mock_hook.assert_not_called() + @pytest.mark.parametrize( 'extra_parameters', [ @@ -393,9 +420,6 @@ def test_detailed_balance_settings_setter_invalid(self, analysis_base): ], ) def test_extra_parameters_property(self, analysis_base, extra_parameters): - # WHEN - analysis_base.extra_parameters = extra_parameters - # THEN analysis_base.extra_parameters = extra_parameters diff --git a/tests/unit/easydynamics/analysis/test_fit_binding.py b/tests/unit/easydynamics/analysis/test_fit_binding.py index 901ab9e50..e1a4afcbe 100644 --- a/tests/unit/easydynamics/analysis/test_fit_binding.py +++ b/tests/unit/easydynamics/analysis/test_fit_binding.py @@ -26,9 +26,9 @@ def diffusion_binding(self): model = BrownianTranslationalDiffusion(lorentzian_name='Lorentzian') return FitBinding(model=model) - # ------------------------------------------------------------------ + ############# # Initialization and validation - # ------------------------------------------------------------------ + ############# def test_initialization(self, component_binding): # WHEN THEN EXPECT @@ -76,9 +76,9 @@ def test_diffusion_non_string_dataset_key_raises(self): with pytest.raises(TypeError, match='dataset keys'): FitBinding(model=model, targets={'width': 123}) - # ------------------------------------------------------------------ + ############# # Properties - # ------------------------------------------------------------------ + ############# def test_model_setter_revalidates_targets(self): # WHEN: a binding using DeltaLorentz's delta_area prediction @@ -105,9 +105,9 @@ def test_targets_setter_invalid_raises(self, diffusion_binding): with pytest.raises(ValueError, match='Unknown prediction'): diffusion_binding.targets = ['nonsense'] - # ------------------------------------------------------------------ + ############# # get_targets - # ------------------------------------------------------------------ + ############# def test_component_target(self, component_binding): # WHEN @@ -241,9 +241,9 @@ def test_delta_lorentz_delta_area_function(self): # EXPECT np.testing.assert_allclose(target.function(Q), model.calculate_EISF(Q) * model.scale.value) - # ------------------------------------------------------------------ + ############# # dunder methods - # ------------------------------------------------------------------ + ############# def test_repr(self, diffusion_binding): # WHEN THEN @@ -254,9 +254,9 @@ def test_repr(self, diffusion_binding): assert 'model=' in repr_str assert 'targets=' in repr_str - -class TestFitBindingWorkflows: - """End-to-end regression tests for the standard ParameterAnalysis workflows.""" + ############# + # Workflows: end-to-end regression tests for the standard ParameterAnalysis workflows + ############# def test_polynomial_targets_gaussian_area(self): # WHEN: fitting a Polynomial to a 'Gaussian area' dataset key diff --git a/tests/unit/easydynamics/analysis/test_parameter_analysis.py b/tests/unit/easydynamics/analysis/test_parameter_analysis.py index 1b6b80178..647c73c22 100644 --- a/tests/unit/easydynamics/analysis/test_parameter_analysis.py +++ b/tests/unit/easydynamics/analysis/test_parameter_analysis.py @@ -9,6 +9,7 @@ import pytest import scipp as sc from easyscience.fitting.multi_fitter import MultiFitter +from easyscience.variable import Parameter import easydynamics as edyn import easydynamics.sample_model as sm @@ -20,6 +21,10 @@ from easydynamics.sample_model.diffusion_model.brownian_translational_diffusion import ( BrownianTranslationalDiffusion, ) +from easydynamics.sample_model.diffusion_model.delta_lorentz import DeltaLorentz +from easydynamics.sample_model.diffusion_model.jump_translational_diffusion import ( + JumpTranslationalDiffusion, +) from easydynamics.utils.fit_target import FitTarget Q = np.array([0.5, 0.8, 1.1, 1.4, 1.7, 2.0]) @@ -574,6 +579,38 @@ def test_plot_no_bindings( # 6. Return value propagated assert result == mock_plot.return_value + def test_plot_with_empty_names_raises_a_clear_error(self, parameter_analysis): + # WHEN / THEN / EXPECT: an empty list is an error, not a bare IndexError + with ( + patch( + 'easydynamics.analysis.parameter_analysis._in_notebook', + return_value=True, + ), + pytest.raises(ValueError, match='names must not be an empty list'), + ): + parameter_analysis.plot(names=[]) + + def test_plot_evaluates_only_the_bindings_being_plotted( + self, parameter_analysis, mock_model_dataset + ): + # WHEN only the first binding's target is requested + parameter_analysis.calculate_model_dataset = MagicMock(return_value=mock_model_dataset) + + # THEN + with ( + patch( + 'easydynamics.analysis.parameter_analysis._in_notebook', + return_value=True, + ), + patch('easydynamics.analysis.parameter_analysis.pp.plot'), + ): + parameter_analysis.plot(names=['parameter1']) + + # EXPECT the diffusion binding is not evaluated for a plot that does not show it + parameter_analysis.calculate_model_dataset.assert_called_once_with([ + parameter_analysis.bindings[0] + ]) + @pytest.mark.parametrize( 'set_pars_none, bindings, expected_exception, match', [ @@ -1139,6 +1176,26 @@ def test_get_xyweight_from_dataset_no_variances(self, parameter_analysis): np.testing.assert_allclose(y, [1.0, 2.0]) np.testing.assert_allclose(w, [1.0, 1.0]) + def test_get_xyweight_from_dataset_no_variances_filters_nan_values(self, parameter_analysis): + # WHEN a dataset without variances contains a NaN value + Q = sc.array(dims=['Q'], values=[0.1, 0.2], unit='1/angstrom') + parameter_analysis.parameters = sc.Dataset( + data={ + 'parameter1': sc.DataArray( + data=sc.array(dims=['Q'], values=[1.0, np.nan], unit='meV'), + coords={'Q': Q}, + ), + } + ) + + # THEN + x, y, w = parameter_analysis._get_xyweight_from_dataset('parameter1') + + # EXPECT the NaN row is filtered like on the with-variances path + np.testing.assert_allclose(x, [0.1]) + np.testing.assert_allclose(y, [1.0]) + np.testing.assert_allclose(w, [1.0]) + def test_get_xyweight_from_dataset_all_nan_variances_raises(self, parameter_analysis): # WHEN Q = sc.array(dims=['Q'], values=[0.1, 0.2], unit='1/angstrom') @@ -1279,6 +1336,134 @@ def test_shrinking_the_targets_also_rebuilds(self): # EXPECT assert len(analysis.fit()) == 1 + def test_swapping_targets_of_the_same_model_rebuilds_the_fitter(self): + # WHEN a binding's targets are swapped in place without changing how many there are: + # the model list stays identical, so a model-only signature would miss the change and + # fit the stale width function against the area data + binding = edyn.FitBinding( + model=sm.BrownianTranslationalDiffusion( + name='Brownian', + lorentzian_name='Lorentzian', + diffusion_coefficient=2.4e-9, + scale=0.5, + ), + targets=['width'], + ) + analysis = edyn.ParameterAnalysis(parameters=make_dataset(), bindings=[binding]) + analysis.fit() + original = analysis._fitter + + # THEN + binding.targets = ['area'] + analysis.fit() + + # EXPECT the fitter was rebuilt for the new target + assert analysis._fitter is not original + + def test_append_binding_invalidates_the_fitter(self, analysis): + # WHEN + original = analysis.fitter + new_binding = edyn.FitBinding( + model=sm.Polynomial(coefficients=[1.0], x_unit='1/angstrom', y_unit='meV'), + targets='Lorentzian width', + ) + + # THEN + analysis.append_binding(new_binding) + + # EXPECT + assert analysis.fitter is not original + + def test_clear_bindings_invalidates_the_fitter(self, analysis): + # WHEN + _ = analysis.fitter + assert analysis._fitter_is_dirty is False + + # THEN + analysis.clear_bindings() + + # EXPECT + assert analysis._fitter_is_dirty is True + + def test_bindings_list_is_copied_from_the_caller(self): + # WHEN a caller passes a list and mutates it afterwards + binding = edyn.FitBinding( + model=sm.Polynomial(coefficients=[1.0], x_unit='1/angstrom', y_unit='meV'), + targets='Lorentzian width', + ) + caller_list = [binding] + analysis = edyn.ParameterAnalysis(parameters=make_dataset(), bindings=caller_list) + + # THEN + caller_list.clear() + + # EXPECT the analysis still holds the binding it was given + assert analysis.bindings == [binding] + + ############# + # The bayesian sampler (the ParameterAnalysis side of the contract) + ############# + + def test_bayesian_returns_the_cached_sampler(self, analysis): + # THEN + sampler = analysis.bayesian + + # EXPECT the same object on second access + assert sampler is analysis.bayesian + + def test_bayesian_is_invalidated_when_the_parameters_change(self, analysis): + # WHEN + sampler = analysis.bayesian + + # THEN + with patch.object(sampler, 'invalidate') as mock_invalidate: + analysis.parameters = make_dataset() + + # EXPECT + mock_invalidate.assert_called_once() + + def test_bayesian_is_invalidated_when_the_bindings_change(self, analysis): + # WHEN + sampler = analysis.bayesian + + # THEN + with patch.object(sampler, 'invalidate') as mock_invalidate: + analysis.bindings = analysis.bindings[:1] + + # EXPECT + mock_invalidate.assert_called_once() + + def test_bayesian_is_invalidated_when_a_binding_is_appended(self, analysis): + # WHEN + sampler = analysis.bayesian + new_binding = edyn.FitBinding( + model=sm.Polynomial(coefficients=[1.0], x_unit='1/angstrom', y_unit='meV'), + targets='Lorentzian width', + ) + + # THEN + with patch.object(sampler, 'invalidate') as mock_invalidate: + analysis.append_binding(new_binding) + + # EXPECT + mock_invalidate.assert_called_once() + + def test_missing_parameters_dataset_raises(self): + # WHEN + parameter_analysis = edyn.ParameterAnalysis() + + # THEN EXPECT + with pytest.raises(ValueError, match='No parameters Dataset'): + parameter_analysis.bayesian.sample(samples=10) + + def test_missing_bindings_raises(self): + # WHEN + parameter_analysis = edyn.ParameterAnalysis(parameters=make_dataset()) + + # THEN EXPECT + with pytest.raises(ValueError, match='No fit bindings'): + parameter_analysis.bayesian.sample(samples=10) + ############# # Chain parameters and labels ############# @@ -1345,8 +1530,6 @@ def test_single_binding_keeps_plain_names(self): def test_parameter_from_outside_the_analysis_keeps_its_name(self, analysis): # WHEN a parameter belongs to none of the binding models - from easyscience.variable import Parameter - stranger = Parameter(name='Width line_c0', value=1.0) # THEN EXPECT it is returned unqualified rather than mislabelled @@ -1403,8 +1586,6 @@ def test_colliding_names_with_distinct_models_use_the_display_name(self): def test_ambiguous_name_owned_by_no_model_keeps_its_name(self): # WHEN a parameter shares an ambiguous name but belongs to none of the models - from easyscience.variable import Parameter - analysis = edyn.ParameterAnalysis( parameters=make_dataset(), bindings=[ @@ -1454,8 +1635,6 @@ def _dataset_from_targets(model, Q, unit_overrides=None): def test_delta_lorentz_three_target_simultaneous_fit(self): # WHEN: synthetic width, area, and delta area curves from a known DeltaLorentz - from easydynamics.sample_model.diffusion_model.delta_lorentz import DeltaLorentz - Q = np.linspace(0.4, 2.0, 9) truth = DeltaLorentz(scale=2.0, mean_u_squared=0.3, A_0=0.6, lorentzian_width=0.12) dataset = self._dataset_from_targets(truth, Q) @@ -1473,10 +1652,6 @@ def test_delta_lorentz_three_target_simultaneous_fit(self): def test_jump_diffusion_width_only_fit(self): # WHEN: synthetic widths from a known jump diffusion model - from easydynamics.sample_model.diffusion_model.jump_translational_diffusion import ( - JumpTranslationalDiffusion, - ) - Q = np.linspace(0.4, 2.0, 9) truth = JumpTranslationalDiffusion(diffusion_coefficient=2.4e-9, relaxation_time=2.0) dataset = self._dataset_from_targets(truth, Q) diff --git a/tests/unit/easydynamics/analysis/test_posterior.py b/tests/unit/easydynamics/analysis/test_posterior.py index 436283a4c..40a6a3b66 100644 --- a/tests/unit/easydynamics/analysis/test_posterior.py +++ b/tests/unit/easydynamics/analysis/test_posterior.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +import warnings + import numpy as np import pytest from easyscience.variable import Parameter @@ -132,13 +134,15 @@ def test_non_numeric_setting_raises(self): suggest_bounds_for_parameters([make_parameter()], n_sigma='wide') -class TestBoundsSuggestionsApply: +class TestBoundsSuggestions: + ############# + # Applying suggestions + ############# + def test_apply_sets_bounds_and_reports_changes(self): - # WHEN + # WHEN nothing has changed yet, since apply has not been called parameter = make_parameter(value=10.0, error=0.5) suggestions = suggest_bounds_for_parameters([parameter]) - - # WHEN nothing has changed until apply is called assert parameter.max == np.inf # THEN @@ -161,6 +165,45 @@ def test_apply_skips_parameters_needing_attention(self): assert changed == [] assert parameter.min == -np.inf + ############# + # Absurd-bounds warning + ############# + + def test_applying_a_wildly_wide_bound_warns(self): + # WHEN a fit returns an enormous uncertainty, which is what a degenerate parameter looks + # like coming out of least squares + parameter = make_parameter(name='Delta area', value=1.0, error=1e9) + suggestions = suggest_bounds_for_parameters([parameter]) + + # THEN EXPECT it is still applied, since it is what the fit implied, but not silently + with pytest.warns(UserWarning, match='far wider than the parameter'): + changed = suggestions.apply() + assert changed == [parameter] + + def test_a_sane_bound_applies_without_warning(self): + # WHEN + parameter = make_parameter(name='sane', value=10.0, error=0.5) + suggestions = suggest_bounds_for_parameters([parameter]) + + # THEN EXPECT + with warnings.catch_warnings(): + warnings.simplefilter('error') + suggestions.apply() + + def test_a_zero_valued_parameter_is_not_called_absurd(self): + # WHEN there is no magnitude to compare the width against + parameter = make_parameter(name='zero', value=0.0, error=1.0) + suggestions = suggest_bounds_for_parameters([parameter]) + + # THEN EXPECT no warning, since the ratio is meaningless rather than alarming + with warnings.catch_warnings(): + warnings.simplefilter('error') + suggestions.apply() + + ############# + # Repr and iteration + ############# + def test_repr_lists_parameters_and_flags_attention(self): # WHEN good = make_parameter(name='good', value=10.0, error=0.5) @@ -347,6 +390,8 @@ def test_unmatched_column_falls_back_to_the_supplied_name(self): assert entry.unit == '' assert np.isnan(entry.value) + +class TestPosteriorSummary: def test_lookup_of_missing_name_raises(self): # WHEN summary = summarize_draws(np.zeros((5, 1)), ['x'], [None]) @@ -366,8 +411,6 @@ def test_repr_contains_the_parameter_name(self): assert 'Gaussian area' in text assert 'median' in text - -class TestPosteriorSummaryContainer: def test_len_and_iteration(self): # WHEN parameters = [make_parameter(name='a'), make_parameter(name='b')] @@ -381,40 +424,3 @@ def test_len_and_iteration(self): def test_repr_with_no_entries(self): # WHEN THEN EXPECT assert 'no parameters' in repr(summarize_draws(np.zeros((3, 0)), [], [])) - - -class TestAbsurdBoundsWarning: - def test_applying_a_wildly_wide_bound_warns(self): - # WHEN a fit returns an enormous uncertainty, which is what a degenerate parameter looks - # like coming out of least squares - parameter = make_parameter(name='Delta area', value=1.0, error=1e9) - suggestions = suggest_bounds_for_parameters([parameter]) - - # THEN EXPECT it is still applied, since it is what the fit implied, but not silently - with pytest.warns(UserWarning, match='far wider than the parameter'): - changed = suggestions.apply() - assert changed == [parameter] - - def test_a_sane_bound_applies_without_warning(self): - # WHEN - parameter = make_parameter(name='sane', value=10.0, error=0.5) - suggestions = suggest_bounds_for_parameters([parameter]) - - # THEN EXPECT - import warnings as warnings_module - - with warnings_module.catch_warnings(): - warnings_module.simplefilter('error') - suggestions.apply() - - def test_a_zero_valued_parameter_is_not_called_absurd(self): - # WHEN there is no magnitude to compare the width against - parameter = make_parameter(name='zero', value=0.0, error=1.0) - suggestions = suggest_bounds_for_parameters([parameter]) - - # THEN EXPECT no warning, since the ratio is meaningless rather than alarming - import warnings as warnings_module - - with warnings_module.catch_warnings(): - warnings_module.simplefilter('error') - suggestions.apply() diff --git a/tests/unit/easydynamics/analysis/test_posterior_labels.py b/tests/unit/easydynamics/analysis/test_posterior_labels.py index 618c8284c..0751c7895 100644 --- a/tests/unit/easydynamics/analysis/test_posterior_labels.py +++ b/tests/unit/easydynamics/analysis/test_posterior_labels.py @@ -11,7 +11,25 @@ def make_parameter(name, unit='meV'): return Parameter(name=name, value=1.0, unit=unit) -class TestLabelling: +class _CountingParameter: + """A Parameter stand-in that counts how often its name is read.""" + + def __init__(self, name, unique_name): + self._name = name + self.unique_name = unique_name + self.name_accesses = 0 + + @property + def name(self): + self.name_accesses += 1 + return self._name + + +class TestParameterLabels: + ############# + # Labelling + ############# + def test_unique_names_are_left_alone(self): # WHEN nothing is ambiguous, a qualifier would only cost width parameters = [make_parameter('area'), make_parameter('width')] @@ -54,8 +72,10 @@ def test_without_a_qualifier_names_stay_bare(self): # EXPECT assert labels.label(first) == 'width' + ############# + # Chain columns + ############# -class TestChainColumns: def test_columns_resolve_by_unique_name(self): # WHEN parameters = [make_parameter('area'), make_parameter('width')] @@ -131,19 +151,25 @@ def test_name_map_records_labels_against_unique_names(self): second.unique_name: 'width (Q_index=1)', } + ############# + # Cost + ############# -class TestCost: def test_labelling_does_not_rescan_per_parameter(self): # WHEN there are many parameters. Computing the name counts per parameter is quadratic, - # which was seconds of work for an analysis with many Q values. - parameters = [make_parameter(f'p{i // 2}') for i in range(400)] + # which was seconds of work for an analysis with many Q values, so labelling one parameter + # must not read every parameter's name again. + parameters = [_CountingParameter(f'p{i // 2}', f'Parameter_{i}') for i in range(400)] labels = ParameterLabels(parameters, qualify=lambda _p: 'q') + for parameter in parameters: + parameter.name_accesses = 0 - # THEN EXPECT labelling all of them stays cheap - import time - - start = time.perf_counter() + # THEN names = [labels.label(p) for p in parameters] - assert time.perf_counter() - start < 0.5 + + # EXPECT a bounded number of name reads per label() call: a quadratic implementation + # recounting the names inside label() would read all 400 names on every call + total_accesses = sum(p.name_accesses for p in parameters) + assert total_accesses <= 4 * len(parameters) assert len(names) == len(parameters) assert np.all([n.endswith('(q)') for n in names]) diff --git a/tests/unit/easydynamics/analysis/test_posterior_sampling.py b/tests/unit/easydynamics/analysis/test_posterior_sampling.py index ab3759f95..e46edc22f 100644 --- a/tests/unit/easydynamics/analysis/test_posterior_sampling.py +++ b/tests/unit/easydynamics/analysis/test_posterior_sampling.py @@ -8,7 +8,9 @@ for PosteriorSampler, and an Analysis for the multi-Q subclass. """ +import json import types +import warnings from types import SimpleNamespace from unittest.mock import MagicMock from unittest.mock import patch @@ -18,11 +20,12 @@ import pytest import scipp as sc from easyscience.fitting import AvailableMinimizers -from easyscience.fitting.multi_fitter import MultiFitter from easyscience.variable import Parameter mpl.use('Agg') +import matplotlib.pyplot as plt + import easydynamics as edyn import easydynamics.sample_model as sm from easydynamics.analysis.analysis1d import Analysis1d @@ -560,8 +563,6 @@ def test_save_without_a_chain_raises(self, analysis): def test_save_writes_the_parameter_name_sidecar(self, analysis, tmp_path): # WHEN - import json - bound_all(analysis) # THEN @@ -798,10 +799,6 @@ def test_load_chain_uses_the_sidecar_when_present(self, analysis, tmp_path): def test_trace_and_corner_render_from_a_chain(self, analysis): # WHEN - import matplotlib as mpl - import matplotlib.pyplot as plt - - mpl.use('Agg') bound_all(analysis) n_parameters = len(analysis.get_free_parameters()) @@ -1219,22 +1216,6 @@ def mutate(**_kwargs): # EXPECT assert [float(p.value) for p in parameters] == pytest.approx(before) - def test_missing_parameters_dataset_raises(self): - # WHEN - parameter_analysis = edyn.ParameterAnalysis() - - # THEN EXPECT - with pytest.raises(ValueError, match='No parameters Dataset'): - parameter_analysis.bayesian.sample(samples=10) - - def test_missing_bindings_raises(self): - # WHEN - parameter_analysis = edyn.ParameterAnalysis(parameters=make_dataset()) - - # THEN EXPECT - with pytest.raises(ValueError, match='No fit bindings'): - parameter_analysis.bayesian.sample(samples=10) - class TestMultiQPosteriorSampler: ############# @@ -1325,13 +1306,6 @@ def test_refreshes_every_convolver_before_sampling(self, multi_q_analysis): # EXPECT the sampler sees the same prepared convolvers a simultaneous fit would assert all(not a._convolver_is_dirty for a in multi_q_analysis.analysis_list) - def test_uses_a_multifitter(self, multi_q_analysis): - # WHEN - - # EXPECT - assert isinstance(multi_q_analysis.fitter, MultiFitter) - assert len(multi_q_analysis.fitter.fit_object) == len(Q_VALUES) - ############# # Independent sampling ############# @@ -1995,8 +1969,6 @@ class warnings_as_errors: """Context manager asserting that no UserWarning is emitted inside the block.""" def __enter__(self): - import warnings - self._ctx = warnings.catch_warnings(record=True) self._caught = self._ctx.__enter__() warnings.simplefilter('always') diff --git a/tests/unit/easydynamics/base_classes/test_easydynamics_list.py b/tests/unit/easydynamics/base_classes/test_easydynamics_list.py index 3d81ec60e..aa7e4d37f 100644 --- a/tests/unit/easydynamics/base_classes/test_easydynamics_list.py +++ b/tests/unit/easydynamics/base_classes/test_easydynamics_list.py @@ -257,3 +257,118 @@ def test_getitem_invalid_type(self, easy_dynamics_list): # WHEN THEN EXPECT with pytest.raises(TypeError, match=r'Index must be an int, slice, or str'): easy_dynamics_list[1.5] + + ############# + # Item assignment + ############# + + def test_setitem(self, easy_dynamics_list): + """Test assigning an item by index replaces it and nothing else.""" + # WHEN + new_gaussian = Gaussian(name='ReplacementGaussian') + + # THEN + easy_dynamics_list[0] = new_gaussian + + # EXPECT + assert easy_dynamics_list[0] is new_gaussian + assert len(easy_dynamics_list) == 2 + + def test_setitem_invalid_type_raises(self, easy_dynamics_list): + # WHEN THEN EXPECT + with pytest.raises(TypeError): + easy_dynamics_list[0] = 'Not a ModelComponent' + + def test_setitem_repeated_component_warns(self, easy_dynamics_list): + """Test that item assignment warns and ignores like append/insert do.""" + # WHEN THEN EXPECT assigning an item already in the list warns and is ignored + with pytest.warns(UserWarning, match=r'already in EasyDynamicsList'): + easy_dynamics_list[1] = easy_dynamics_list[0] + + assert easy_dynamics_list[1] is not easy_dynamics_list[0] + + ############# + # Versioning + ############# + + def test_version_starts_at_zero(self, easy_dynamics_list): + # WHEN a freshly constructed list, even with initial items + # THEN EXPECT version is 0 + assert easy_dynamics_list.version == 0 + + def test_version_is_read_only(self, easy_dynamics_list): + # WHEN THEN EXPECT + with pytest.raises(AttributeError): + easy_dynamics_list.version = 5 + + def test_version_bumps_on_every_mutator(self, easy_dynamics_list): + """Every mutating operation increments version; reads do not.""" + # WHEN + version = easy_dynamics_list.version + + # THEN append + easy_dynamics_list.append(Gaussian(name='V1')) + # EXPECT + assert easy_dynamics_list.version == version + 1 + + # THEN insert + easy_dynamics_list.insert(0, Gaussian(name='V2')) + # EXPECT + assert easy_dynamics_list.version == version + 2 + + # THEN extend (one bump per item) + easy_dynamics_list.extend([Gaussian(name='V3'), Gaussian(name='V4')]) + # EXPECT + assert easy_dynamics_list.version == version + 4 + + # THEN item assignment + easy_dynamics_list[0] = Gaussian(name='V5') + # EXPECT + assert easy_dynamics_list.version == version + 5 + + # THEN pop by index and by name + easy_dynamics_list.pop(0) + easy_dynamics_list.pop('V1') + # EXPECT + assert easy_dynamics_list.version == version + 7 + + # THEN remove and del + item = easy_dynamics_list[0] + easy_dynamics_list.remove(item) + del easy_dynamics_list[0] + # EXPECT + assert easy_dynamics_list.version == version + 9 + + # THEN sort + easy_dynamics_list.sort(key=lambda c: c.name) + # EXPECT + assert easy_dynamics_list.version == version + 10 + + # THEN clear + n_items = len(easy_dynamics_list) + easy_dynamics_list.clear() + # EXPECT one bump per removed item, and reading version mutates nothing + assert easy_dynamics_list.version == version + 10 + n_items + assert easy_dynamics_list.version == version + 10 + n_items + + def test_version_does_not_bump_on_ignored_duplicate(self, easy_dynamics_list): + # WHEN + version = easy_dynamics_list.version + + # THEN an insert that is ignored because the item is already in the list + with pytest.warns(UserWarning, match=r'already in EasyDynamicsList'): + easy_dynamics_list.insert(1, easy_dynamics_list[0]) + + # EXPECT no mutation happened, so no version bump + assert easy_dynamics_list.version == version + + def test_version_does_not_bump_on_failed_mutation(self, easy_dynamics_list): + # WHEN + version = easy_dynamics_list.version + + # THEN EXPECT failed mutations leave the version unchanged + with pytest.raises(TypeError): + easy_dynamics_list.append('Not a ModelComponent') + with pytest.raises(KeyError): + easy_dynamics_list.pop('Nonexistent') + assert easy_dynamics_list.version == version diff --git a/tests/unit/easydynamics/base_classes/test_name_mixin.py b/tests/unit/easydynamics/base_classes/test_name_mixin.py index 5ea931633..1913e519e 100644 --- a/tests/unit/easydynamics/base_classes/test_name_mixin.py +++ b/tests/unit/easydynamics/base_classes/test_name_mixin.py @@ -2,6 +2,8 @@ # SPDX-License-Identifier: BSD-3-Clause import pytest +from easyscience import global_object +from easyscience.base_classes.new_base import NewBase from easydynamics.base_classes.name_mixin import NameMixin @@ -62,3 +64,19 @@ def test_name_setter_invalid_type(self, name_mixin, invalid_name): # WHEN THEN EXPECT with pytest.raises(TypeError, match=r'Name must be a string.'): name_mixin.name = invalid_name + + def test_invalid_name_fails_before_global_registration(self): + """Regression: name validation must run before the parent registers the object.""" + + # WHEN a class whose MRO reaches the registering NewBase through NameMixin + class _RegisteredWithName(NameMixin, NewBase): + pass + + vertices_before = set(global_object.map.vertices()) + + # THEN EXPECT construction fails on the invalid name + with pytest.raises(TypeError, match=r'Name must be a string'): + _RegisteredWithName(name=123) + + # EXPECT no half-constructed object was registered in the global map + assert set(global_object.map.vertices()) == vertices_before diff --git a/tests/unit/easydynamics/convolution/test_convolution.py b/tests/unit/easydynamics/convolution/test_convolution.py index 15305ae54..ea50c2f0e 100644 --- a/tests/unit/easydynamics/convolution/test_convolution.py +++ b/tests/unit/easydynamics/convolution/test_convolution.py @@ -8,6 +8,7 @@ import numpy as np import pytest import scipp as sc +from easyscience.variable import Parameter from easydynamics.convolution.analytical_convolution import AnalyticalConvolution from easydynamics.convolution.convolution import Convolution @@ -20,6 +21,7 @@ from easydynamics.sample_model import Polynomial from easydynamics.sample_model import Voigt from easydynamics.sample_model.component_collection import ComponentCollection +from easydynamics.settings.detailed_balance_settings import DetailedBalanceSettings class TestConvolution: @@ -402,7 +404,7 @@ def test_check_if_pair_is_analytic(self, default_convolution, function1, functio def test_check_if_pair_is_analytic_raises_with_delta_in_resolution(self, default_convolution): """ - Test that _check_if_pair_is_analytic raises TypeError when + Test that _check_if_pair_is_analytic raises ValueError when resolution component is DeltaFunction. """ # WHEN @@ -412,7 +414,7 @@ def test_check_if_pair_is_analytic_raises_with_delta_in_resolution(self, default # THEN EXPECT with pytest.raises( - TypeError, + ValueError, match='This is not supported', ): conv._check_if_pair_is_analytic( @@ -632,6 +634,251 @@ def test_convert_y_unit_propagates_to_sub_convolvers(self): assert conv.y_unit == '1/eV' assert conv._analytical_convolver._y_unit == '1/eV' + ############# + # Plan invalidation regressions + ############# + + def test_invalidate_plan_on_change_names_are_real_attributes(self, default_convolution): + "Regression: the tracked-attribute set used to contain names that never exist" + # WHEN THEN EXPECT every tracked name is an actual attribute of a built convolver + for name in Convolution._invalidate_plan_on_change: + assert hasattr(default_convolution, name), name + + def test_in_place_sample_append_contributes_to_convolution(self): + "Regression: appending to the live sample collection used to leave output unchanged" + # WHEN a convolver that has already produced output + energy = np.linspace(-10, 10, 1001) + sample_components = ComponentCollection( + components=[Gaussian(name='G', area=2.0, center=0.1, width=0.4)] + ) + resolution_components = ComponentCollection( + components=[Gaussian(name='R', area=1.0, center=0.0, width=0.5)] + ) + conv = Convolution( + energy=energy, + sample_components=sample_components, + resolution_components=resolution_components, + ) + result_before = conv.convolution() + + # THEN mutating the live sample collection in place + conv.sample_components.append_component( + Lorentzian(name='L', area=1.0, center=0.0, width=0.3) + ) + result_after = conv.convolution() + + # EXPECT the new component contributes, matching a freshly built convolver + fresh = Convolution( + energy=energy, + sample_components=sample_components, + resolution_components=resolution_components, + ) + assert not np.allclose(result_after, result_before) + np.testing.assert_allclose(result_after, fresh.convolution(), rtol=1e-10) + + def test_in_place_resolution_append_contributes_to_convolution(self): + "Regression: appending to the live resolution collection used to leave output unchanged" + # WHEN a convolver that has already produced output + energy = np.linspace(-10, 10, 1001) + sample_components = ComponentCollection( + components=[Gaussian(name='G', area=2.0, center=0.1, width=0.4)] + ) + resolution_components = ComponentCollection( + components=[Gaussian(name='R', area=1.0, center=0.0, width=0.5)] + ) + conv = Convolution( + energy=energy, + sample_components=sample_components, + resolution_components=resolution_components, + ) + result_before = conv.convolution() + + # THEN mutating the live resolution collection in place + conv.resolution_components.append_component( + Gaussian(name='R2', area=0.5, center=0.0, width=0.2) + ) + result_after = conv.convolution() + + # EXPECT the new component contributes, matching a freshly built convolver + fresh = Convolution( + energy=energy, + sample_components=sample_components, + resolution_components=resolution_components, + ) + assert not np.allclose(result_after, result_before) + np.testing.assert_allclose(result_after, fresh.convolution(), rtol=1e-10) + + def test_detailed_balance_toggle_changes_output(self): + "Regression: toggling use_detailed_balance after construction used to be ignored" + # WHEN a convolver built with detailed balance off + energy = np.linspace(-10, 10, 1001) + sample_components = ComponentCollection( + components=[Lorentzian(name='L', area=2.0, center=0.0, width=0.4)] + ) + resolution_components = ComponentCollection( + components=[Gaussian(name='R', area=1.0, center=0.0, width=0.5)] + ) + conv = Convolution( + energy=energy, + sample_components=sample_components, + resolution_components=resolution_components, + temperature=300.0, + detailed_balance_settings=DetailedBalanceSettings(use_detailed_balance=False), + ) + result_off = conv.convolution() + + # THEN toggling detailed balance on after construction + conv.detailed_balance_settings.use_detailed_balance = True + result_on = conv.convolution() + + # EXPECT the output changes and matches a convolver built with detailed balance on + fresh = Convolution( + energy=energy, + sample_components=sample_components, + resolution_components=resolution_components, + temperature=300.0, + detailed_balance_settings=DetailedBalanceSettings(use_detailed_balance=True), + ) + assert not np.allclose(result_on, result_off) + np.testing.assert_allclose(result_on, fresh.convolution(), rtol=1e-10) + + def test_energy_offset_rebind_reaches_sub_convolvers(self): + "Regression: rebinding energy_offset used to leave sub-convolvers on the old Parameter" + # WHEN a convolver with analytical, numerical and delta components and offset 0 + energy = np.linspace(-10, 10, 1001) + sample_components = ComponentCollection( + components=[ + Gaussian(name='G', area=2.0, center=0.1, width=0.4), + DampedHarmonicOscillator(name='DHO', area=2.0, center=1.0, width=0.1), + DeltaFunction(name='D', area=1.0, center=0.3), + ] + ) + resolution_components = ComponentCollection( + components=[Gaussian(name='R', area=1.0, center=0.0, width=0.5)] + ) + conv = Convolution( + energy=energy, + sample_components=sample_components, + resolution_components=resolution_components, + ) + result_before = conv.convolution() + + # THEN rebinding the offset to a brand-new Parameter + conv.energy_offset = Parameter(name='energy_offset', value=1.0, unit='meV') + result_after = conv.convolution() + + # EXPECT every path (analytical, numerical, delta) sees the new offset, matching a + # freshly built convolver + fresh = Convolution( + energy=energy, + sample_components=sample_components, + resolution_components=resolution_components, + energy_offset=1.0, + ) + assert not np.allclose(result_after, result_before) + np.testing.assert_allclose(result_after, fresh.convolution(), rtol=1e-10) + + ############# + # Dispatch and validation regressions + ############# + + def test_subclass_of_analytical_component_convolves_like_base(self): + "Regression: a Lorentzian subclass was routed analytically but rejected by dispatch" + + # WHEN a subclass of Lorentzian in the sample model + class MyLorentzian(Lorentzian): + pass + + energy = np.linspace(-10, 10, 1001) + conv = Convolution( + energy=energy, + sample_components=MyLorentzian(name='MyL', area=2.0, center=0.1, width=0.4), + resolution_components=Gaussian(name='R', area=1.0, center=0.0, width=0.5), + ) + + # THEN it is routed to the analytical convolver and convolved with the base rules + result = conv.convolution() + + # EXPECT + assert len(conv._analytical_sample_components) == 1 + reference = Convolution( + energy=energy, + sample_components=Lorentzian(name='L', area=2.0, center=0.1, width=0.4), + resolution_components=Gaussian(name='R2', area=1.0, center=0.0, width=0.5), + ) + np.testing.assert_allclose(result, reference.convolution(), rtol=1e-10) + + def test_empty_resolution_raises(self): + "Regression: an empty resolution used to silently produce zeros" + # WHEN THEN EXPECT at construction + with pytest.raises(ValueError, match=r'resolution_components is empty'): + Convolution( + energy=np.linspace(-10, 10, 101), + sample_components=Gaussian(name='G', area=1.0, center=0.0, width=0.4), + resolution_components=ComponentCollection(), + ) + + def test_emptying_resolution_in_place_raises_on_next_convolution(self, default_convolution): + # WHEN the live resolution collection is emptied after construction + conv = default_convolution + conv.resolution_components.pop('GaussianRes') + + # THEN EXPECT the next convolution rebuilds the plan and refuses to silently + # return zeros + with pytest.raises(ValueError, match=r'resolution_components is empty'): + conv.convolution() + + ############# + # Registry and label housekeeping + ############# + + def test_plan_rebuilds_do_not_leak_registry_entries(self, default_convolution): + "Regression: every plan rebuild used to register new objects in the global map forever" + # WHEN a convolver that has built its plan at least once + conv = default_convolution + conv.convolution() + vertices_before = len(conv._global_object.map.vertices()) + + # THEN forcing several full plan rebuilds + for _ in range(3): + conv._plan_seen_version = None + conv.convolution() + + # EXPECT the global map did not grow + assert len(conv._global_object.map.vertices()) == vertices_before + + def test_convert_y_unit_updates_plan_collection_labels(self): + "Regression: plan-collection y_unit labels used to stay stale until the next rebuild" + # WHEN a convolver with analytical, numerical and delta components in 1/meV + energy = np.linspace(-10, 10, 1001) + sample_components = ComponentCollection( + components=[ + Gaussian(name='G', area=1.0, center=0.0, width=0.4, y_unit='1/meV'), + DampedHarmonicOscillator( + name='DHO', area=1.0, center=1.0, width=0.1, y_unit='1/meV' + ), + DeltaFunction(name='D', area=1.0, center=0.0, y_unit='1/meV'), + ], + y_unit='1/meV', + ) + resolution_components = ComponentCollection( + components=[Gaussian(name='R', area=1.0, center=0.0, width=0.5)] + ) + conv = Convolution( + energy=energy, + sample_components=sample_components, + resolution_components=resolution_components, + y_unit='1/meV', + ) + + # THEN + conv.convert_y_unit('1/eV') + + # EXPECT the plan collections' labels follow without waiting for a rebuild + assert conv._analytical_sample_components.y_unit == '1/eV' + assert conv._numerical_sample_components.y_unit == '1/eV' + assert conv._delta_sample_components.y_unit == '1/eV' + def test_convert_y_unit_propagates_to_numerical_convolver(self): # WHEN: a DHO sample component forces a numerical convolver energy = np.linspace(-10, 10, 5001) diff --git a/tests/unit/easydynamics/convolution/test_convolution_base.py b/tests/unit/easydynamics/convolution/test_convolution_base.py index 7bc26fff7..fedf214e0 100644 --- a/tests/unit/easydynamics/convolution/test_convolution_base.py +++ b/tests/unit/easydynamics/convolution/test_convolution_base.py @@ -8,6 +8,7 @@ from scipp import UnitError from easydynamics.convolution.convolution_base import ConvolutionBase +from easydynamics.sample_model import DeltaFunction from easydynamics.sample_model import Gaussian from easydynamics.sample_model.component_collection import ComponentCollection @@ -403,3 +404,85 @@ def test_convert_y_unit_without_sample_components(self): # EXPECT assert cb.y_unit == '1/meV' + + ############# + # Unit-consistency validation + ############# + + def test_energy_setter_scipp_with_matching_unit(self, convolution_base): + # WHEN + new_energy = sc.array(dims=['energy'], values=np.linspace(-3, 3, 7), unit='meV') + + # THEN + convolution_base.energy = new_energy + + # EXPECT: accepted and x_unit stays a str + assert sc.identical(convolution_base.energy, new_energy) + assert isinstance(convolution_base.x_unit, str) + assert convolution_base.x_unit == 'meV' + + def test_energy_setter_scipp_with_different_unit_raises(self, convolution_base): + "Regression: a mismatched scipp energy used to silently overwrite x_unit with sc.Unit" + # WHEN + new_energy = sc.array(dims=['energy'], values=np.linspace(-3, 3, 7), unit='ueV') + + # THEN EXPECT: unit changes must go through convert_x_unit + with pytest.raises(ValueError, match=r'Use convert_x_unit'): + convolution_base.energy = new_energy + + # EXPECT: nothing changed + assert convolution_base.x_unit == 'meV' + assert np.allclose(convolution_base.energy.values, np.linspace(-10, 10, 100)) + + def test_init_sample_components_x_unit_mismatch_raises(self): + # WHEN sample components in ueV but the convolver in meV + sample = ComponentCollection(components=Gaussian(name='G', x_unit='ueV'), x_unit='ueV') + + # THEN EXPECT + with pytest.raises(ValueError, match=r'sample_components has x_unit'): + ConvolutionBase( + energy=np.linspace(-10, 10, 100), + sample_components=sample, + resolution_components=ComponentCollection(), + x_unit='meV', + ) + + def test_init_resolution_components_x_unit_mismatch_raises(self): + # WHEN resolution components in ueV but the convolver in meV + resolution = ComponentCollection(components=Gaussian(name='R', x_unit='ueV'), x_unit='ueV') + + # THEN EXPECT + with pytest.raises(ValueError, match=r'resolution_components has x_unit'): + ConvolutionBase( + energy=np.linspace(-10, 10, 100), + sample_components=ComponentCollection(), + resolution_components=resolution, + x_unit='meV', + ) + + ############# + # Delta functions in the resolution + ############# + + def test_init_with_delta_in_resolution_raises(self): + # WHEN + resolution = ComponentCollection(components=DeltaFunction(name='D')) + + # THEN EXPECT + with pytest.raises(ValueError, match=r'delta functions'): + ConvolutionBase( + energy=np.linspace(-10, 10, 100), + sample_components=ComponentCollection(), + resolution_components=resolution, + ) + + def test_resolution_components_setter_with_delta_raises(self, convolution_base): + # WHEN + resolution = ComponentCollection(components=DeltaFunction(name='D')) + + # THEN EXPECT + with pytest.raises(ValueError, match=r'delta functions'): + convolution_base.resolution_components = resolution + + # EXPECT: the previous resolution model is kept + assert convolution_base.resolution_components is not resolution diff --git a/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py b/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py index 8fd4722d9..3d80f983b 100644 --- a/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py +++ b/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py @@ -9,6 +9,7 @@ from easydynamics.convolution.energy_grid import EnergyGrid from easydynamics.convolution.numerical_convolution_base import NumericalConvolutionBase from easydynamics.sample_model import Gaussian +from easydynamics.sample_model import Voigt from easydynamics.sample_model.component_collection import ComponentCollection from easydynamics.settings.convolution_settings import ConvolutionSettings from easydynamics.settings.detailed_balance_settings import DetailedBalanceSettings @@ -621,6 +622,149 @@ def test_check_width_no_warnings(self, default_numerical_convolution_base): model_name='ComponentCollection', ) + def test_init_with_none_sample_components_raises(self): + # WHEN THEN EXPECT: None components fail early with a clear error + with pytest.raises(TypeError, match=r'sample_components must be .* not None'): + NumericalConvolutionBase( + energy=np.linspace(-10, 10, 101), + sample_components=None, + resolution_components=ComponentCollection(display_name='ResolutionModel'), + ) + + def test_init_with_none_resolution_components_raises(self): + # WHEN THEN EXPECT: None components fail early with a clear error + with pytest.raises(TypeError, match=r'resolution_components must be .* not None'): + NumericalConvolutionBase( + energy=np.linspace(-10, 10, 101), + sample_components=ComponentCollection(display_name='ComponentCollection'), + resolution_components=None, + ) + + @pytest.mark.parametrize('upsample_factor', [None, 5], ids=['no_upsampling', 'upsample_5']) + def test_single_point_energy_raises_clear_error(self, upsample_factor): + """ + Regression: a single energy point used to hit an IndexError (upsample None) or + silently return zeros (default path) instead of the intended ValueError. + """ + # WHEN THEN EXPECT (the grid is built eagerly during construction) + with pytest.raises(ValueError, match=r'at least two points'): + NumericalConvolutionBase( + energy=np.array([1.0]), + sample_components=ComponentCollection(display_name='ComponentCollection'), + resolution_components=ComponentCollection(display_name='ResolutionModel'), + convolution_settings=ConvolutionSettings(upsample_factor=upsample_factor), + ) + + def test_extension_factor_setter_accepts_none(self, default_numerical_convolution_base): + # WHEN + default_numerical_convolution_base.upsample_factor = None + + # THEN + default_numerical_convolution_base.extension_factor = None + + # EXPECT + assert default_numerical_convolution_base.extension_factor is None + + def test_check_width_thresholds_covers_voigt_widths(self, default_numerical_convolution_base): + """ + Regression: width warnings used to gate on 'width' only, silently skipping Voigt + components with gaussian_width/lorentzian_width. + """ + # WHEN a Voigt with one very narrow and one very wide width + voigt = Voigt( + name='NarrowWideVoigt', + area=1.0, + center=0.0, + gaussian_width=1e-6, + lorentzian_width=15.0, + ) + + # THEN EXPECT both widths trigger their warning + with pytest.warns(UserWarning) as record: + default_numerical_convolution_base._check_width_thresholds( + model=voigt, + model_name='sample model', + ) + messages = [str(w.message) for w in record] + assert any('gaussian width' in m and 'upsample_factor' in m for m in messages) + assert any('lorentzian width' in m and 'extension_factor' in m for m in messages) + + ############# + # Plan invalidation + ############# + + def test_detailed_balance_flag_toggle_invalidates_plan( + self, default_numerical_convolution_base + ): + "Regression: toggling detailed balance flags used to be silently ignored" + # WHEN a convolver with a current plan + conv = default_numerical_convolution_base + conv._mark_convolution_plan_current() + assert conv._convolution_plan_is_current() is True + + # THEN + conv.detailed_balance_settings.use_detailed_balance = False + + # EXPECT + assert conv._convolution_plan_is_current() is False + + def test_detailed_balance_settings_rebind_invalidates_plan( + self, default_numerical_convolution_base + ): + # WHEN a convolver with a current plan + conv = default_numerical_convolution_base + conv._mark_convolution_plan_current() + assert conv._convolution_plan_is_current() is True + + # THEN + conv.detailed_balance_settings = DetailedBalanceSettings() + + # EXPECT + assert conv._convolution_plan_is_current() is False + + def test_in_place_collection_mutation_invalidates_plan( + self, default_numerical_convolution_base + ): + "Regression: appending to a live collection used to leave the plan current" + # WHEN a convolver with a current plan + conv = default_numerical_convolution_base + conv._mark_convolution_plan_current() + assert conv._convolution_plan_is_current() is True + + # THEN + conv.sample_components.append_component(Gaussian(name='LiveGaussian')) + + # EXPECT + assert conv._convolution_plan_is_current() is False + + def test_energy_offset_rebind_invalidates_plan(self, default_numerical_convolution_base): + "Regression: rebinding energy_offset to a new Parameter used to cause split-brain" + # WHEN a convolver with a current plan + conv = default_numerical_convolution_base + conv._mark_convolution_plan_current() + + # THEN a numeric assignment mutates the shared Parameter: plan stays current + conv.energy_offset = 1.5 + assert conv._convolution_plan_is_current() is True + + # THEN rebinding to a new Parameter object invalidates the plan + conv.energy_offset = Parameter(name='energy_offset', value=1.5, unit='meV') + + # EXPECT + assert conv._convolution_plan_is_current() is False + + def test_convert_x_unit_invalidates_plan(self, default_numerical_convolution_base): + # WHEN a convolver with a current plan + conv = default_numerical_convolution_base + conv._mark_convolution_plan_current() + + # THEN + conv.convert_x_unit('eV') + + # EXPECT + assert conv._convolution_plan_is_current() is False + assert conv.x_unit == 'eV' + def test_repr(self, default_numerical_convolution_base): """ Test the __repr__ method of NumericalConvolutionBase. @@ -646,14 +790,14 @@ def test_repr(self, default_numerical_convolution_base): assert 'temperature=None' in repr_str assert 'normalize_detailed_balance=True' in repr_str - -def test_create_energy_grid_raises_when_extension_factor_none_with_upsampling(): - # GIVEN upsampling enabled but no extension_factor, the dense energy grid cannot be built - # WHEN THEN EXPECT (the grid is built eagerly during construction) - with pytest.raises(ValueError, match=r'extension_factor must be a number'): - NumericalConvolutionBase( - energy=np.linspace(-10, 10, 101), - sample_components=ComponentCollection(display_name='ComponentCollection'), - resolution_components=ComponentCollection(display_name='ResolutionModel'), - convolution_settings=ConvolutionSettings(upsample_factor=5, extension_factor=None), - ) + def test_create_energy_grid_raises_when_extension_factor_none_with_upsampling(self): + # WHEN upsampling is enabled but there is no extension_factor, the dense energy grid + # cannot be built + # THEN EXPECT (the grid is built eagerly during construction) + with pytest.raises(ValueError, match=r'extension_factor must be a number'): + NumericalConvolutionBase( + energy=np.linspace(-10, 10, 101), + sample_components=ComponentCollection(display_name='ComponentCollection'), + resolution_components=ComponentCollection(display_name='ResolutionModel'), + convolution_settings=ConvolutionSettings(upsample_factor=5, extension_factor=None), + ) diff --git a/tests/unit/easydynamics/experiment/test_experiment.py b/tests/unit/easydynamics/experiment/test_experiment.py index 1aebc0436..cae367c77 100644 --- a/tests/unit/easydynamics/experiment/test_experiment.py +++ b/tests/unit/easydynamics/experiment/test_experiment.py @@ -255,6 +255,19 @@ def test_rebin_with_bin_edge_coordinate(self): assert rebinned_data.sizes['Q'] == 10 assert rebinned_data.sizes['energy'] == 7 + def test_rebin_does_not_mutate_the_callers_dimensions_dict(self, experiment): + "Regression: rebin must not write int-converted values back into the caller's dict" + # WHEN + dimensions = {'Q': 6.0, 'energy': 7} + original = dict(dimensions) + + # THEN + experiment.rebin(dimensions) + + # EXPECT the caller's dict is unchanged (6.0 not silently replaced by 6) + assert dimensions == original + assert isinstance(dimensions['Q'], float) + def test_rebin_no_data_raises(self): "Test rebinning data when no data is present" # WHEN diff --git a/tests/unit/easydynamics/sample_model/components/test_damped_harmonic_oscillator.py b/tests/unit/easydynamics/sample_model/components/test_damped_harmonic_oscillator.py index 12f73235f..7525f1585 100644 --- a/tests/unit/easydynamics/sample_model/components/test_damped_harmonic_oscillator.py +++ b/tests/unit/easydynamics/sample_model/components/test_damped_harmonic_oscillator.py @@ -143,6 +143,24 @@ def test_width_must_be_positive(self, dho: DampedHarmonicOscillator): with pytest.raises(ValueError, match='width must be positive'): dho.width = -0.5 + def test_area_setter_out_of_bounds_raises(self, dho: DampedHarmonicOscillator): + # WHEN the fixture's area was created non-negative, so it carries min=0 + original_area = dho.area.value + + # THEN EXPECT a negative assignment raises instead of being silently clamped to 0 + with pytest.raises(ValueError, match='violates the parameter bounds'): + dho.area = -1.0 + assert dho.area.value == pytest.approx(original_area) + + def test_width_setter_below_minimum_raises(self, dho: DampedHarmonicOscillator): + # WHEN the width parameter carries an absolute minimum (1e-10) + original_width = dho.width.value + + # THEN EXPECT a tiny positive width below the bound raises instead of being clamped + with pytest.raises(ValueError, match='violates the parameter bounds'): + dho.width = 1e-12 + assert dho.width.value == pytest.approx(original_width) + def test_evaluate(self, dho: DampedHarmonicOscillator): # WHEN x = np.array([0.0, 1.5, 3.0]) diff --git a/tests/unit/easydynamics/sample_model/components/test_delta_function.py b/tests/unit/easydynamics/sample_model/components/test_delta_function.py index 85fbf1f6f..7972812fd 100644 --- a/tests/unit/easydynamics/sample_model/components/test_delta_function.py +++ b/tests/unit/easydynamics/sample_model/components/test_delta_function.py @@ -136,6 +136,23 @@ def test_evaluate_unsorted_grid(self): # EXPECT: spike at x=0 with bin width from the sorted grid [0, 1, 2] -> 1.0 np.testing.assert_allclose(result, [1.0, 0.0, 0.0]) + def test_evaluate_single_point_raises(self): + # WHEN: a single x value defines no bin width for the area / bin_width spike + delta = DeltaFunction(area=1.0) + + # THEN EXPECT + with pytest.raises(ValueError, match='single x value'): + delta.evaluate(0.0) + + def test_area_setter_out_of_bounds_raises(self, delta_function: DeltaFunction): + # WHEN the fixture's area was created non-negative, so it carries min=0 + original_area = delta_function.area.value + + # THEN EXPECT a negative assignment raises instead of being silently clamped to 0 + with pytest.raises(ValueError, match='violates the parameter bounds'): + delta_function.area = -1.0 + assert delta_function.area.value == pytest.approx(original_area) + def test_evaluate_out_of_bounds(self, delta_function: DeltaFunction): # WHEN x = np.linspace(1, 2, 100) # center is at 0.5, so out of bounds diff --git a/tests/unit/easydynamics/sample_model/components/test_expression_component.py b/tests/unit/easydynamics/sample_model/components/test_expression_component.py index ba34ff5ff..4243ca8dd 100644 --- a/tests/unit/easydynamics/sample_model/components/test_expression_component.py +++ b/tests/unit/easydynamics/sample_model/components/test_expression_component.py @@ -14,6 +14,9 @@ from easydynamics.sample_model import Gaussian from easydynamics.sample_model import Lorentzian +GAUSSIAN_EXPRESSION = 'A / (sigma*sqrt(2*pi)) * exp(-(x - x0)**2 / (2*sigma**2))' +GAUSSIAN_UNITS = {'A': 'meV', 'x0': 'meV', 'sigma': 'meV'} + class TestExpressionComponent: @pytest.fixture @@ -113,6 +116,15 @@ def test_invalid_function_raises(self): with pytest.raises(ValueError, match='Unsupported function'): ExpressionComponent('A * unknown_func(x)') + @pytest.mark.parametrize('colliding', ['name', 'expression', 'x_unit']) + def test_symbol_colliding_with_attribute_raises(self, colliding): + # WHEN a symbol shadows an existing class attribute, attribute reads would resolve to + # the class attribute while writes hit the parameter, silently diverging + + # THEN EXPECT the collision is rejected at construction + with pytest.raises(ValueError, match='collides with an existing attribute'): + ExpressionComponent(f'{colliding} * x', parameters={colliding: 1.0}) + @pytest.mark.parametrize( 'parameters', [ @@ -359,22 +371,17 @@ def test_erf(self): expected = np.array([-0.84270079, 0.0, 0.84270079]) # erf(-1), erf(0), erf(1) np.testing.assert_allclose(result, expected, rtol=1e-5) + def test_evaluate_raises_when_input_unit_differs_from_x_unit(self): + # WHEN an ExpressionComponent with x_unit meV + expr = ExpressionComponent('A * x', parameters={'A': 2.0}, x_unit='meV') + x = sc.array(dims=['x'], values=[1.0, 2.0], unit='ueV') + # THEN EXPECT a UnitError when evaluating with x in a different unit + with pytest.raises(sc.UnitError, match=r'cannot auto-convert its parameters'): + expr.evaluate(x) -def test_evaluate_raises_when_input_unit_differs_from_x_unit(): - # GIVEN an ExpressionComponent with x_unit meV - expr = ExpressionComponent('A * x', parameters={'A': 2.0}, x_unit='meV') - x = sc.array(dims=['x'], values=[1.0, 2.0], unit='ueV') - # WHEN evaluating with x in a different unit THEN EXPECT a UnitError - with pytest.raises(sc.UnitError, match=r'cannot auto-convert its parameters'): - expr.evaluate(x) - - -GAUSSIAN_EXPRESSION = 'A / (sigma*sqrt(2*pi)) * exp(-(x - x0)**2 / (2*sigma**2))' -GAUSSIAN_UNITS = {'A': 'meV', 'x0': 'meV', 'sigma': 'meV'} - - -class TestExpressionComponentUnitCorrectness: - """Compare unit-aware expressions against the built-in components.""" + ############# + # Unit correctness: comparisons against the built-in components + ############# @pytest.fixture def gaussian_expr(self): @@ -459,8 +466,10 @@ def test_unit_agnostic_expression_does_not_warn(self): x_unit='meV', ) + ############# + # Output unit + ############# -class TestExpressionComponentOutputUnit: def test_output_unit_gaussian_is_dimensionless(self): # WHEN: area in meV divided by sigma in meV expr = ExpressionComponent( @@ -632,12 +641,71 @@ def test_set_unit_warns_when_breaking_consistency(self): x_unit='meV', ) - # THEN EXPECT: relabelling A breaks the output unit + # THEN EXPECT: relabelling A to an incompatible dimension breaks the output unit with pytest.warns(UserWarning, match='does not match'): + expr.set_unit('A', 's/meV') + + def test_set_unit_to_a_convertible_output_unit_rescales_instead_of_warning(self): + # WHEN: a consistent expression whose output stays dimensionless-compatible + expr = ExpressionComponent( + 'A * (x - x0)', + parameters={'A': 1.0, 'x0': 0.5}, + parameter_units={'A': '1/meV', 'x0': 'meV'}, + x_unit='meV', + ) + + # THEN: relabelling A to 1/ueV makes the output meV/ueV, which converts to dimensionless + with warnings.catch_warnings(): + warnings.simplefilter('error') expr.set_unit('A', '1/ueV') + # EXPECT: evaluated values carry the 1000x conversion into y_unit + assert expr.evaluate(np.array([1.5]))[0] == pytest.approx(1000.0) + + def test_convertible_output_unit_is_rescaled_into_y_unit(self): + # WHEN: the jump-diffusion width in SI-flavoured parameter units, wanted in meV + with warnings.catch_warnings(): + warnings.simplefilter('error') + expr = ExpressionComponent( + 'hbar * D * x**2 / (1 + D * x**2 * tau)', + parameters={'D': 1e-9, 'tau': 1.0}, + parameter_units={'D': 'm^2/s', 'tau': 'ps'}, + x_unit='1/angstrom', + y_unit='meV', + ) + + # THEN + value = expr.evaluate(np.array([1.0]))[0] + + # EXPECT: hbar * D * Q^2 / (1 + D * Q^2 * tau) expressed in meV. With + # hbar = 6.582120e-13 meV*s, D = 1e-9 m^2/s = 1e11 angstrom^2/s and tau = 1e-12 s the + # denominator is 1 + 0.1 and the numerator 6.582120e-2 meV. + assert value == pytest.approx(6.582120e-2 / 1.1, rel=1e-5) + + def test_conversion_handles_non_si_dimensions_like_counts(self): + # WHEN: an intensity-scaled jump-diffusion width, wanted in counts*meV + with warnings.catch_warnings(): + warnings.simplefilter('error') + expr = ExpressionComponent( + 'counts * hbar * D * x**2 / (1 + D * x**2 * tau)', + parameters={'counts': 1.0, 'D': 4.6e-10, 'tau': 22.0}, + parameter_units={'counts': 'counts', 'D': 'm^2/s', 'tau': 'ps'}, + x_unit='1/angstrom', + y_unit='counts*meV', + ) + + # THEN + value = expr.evaluate(np.array([1.0]))[0] + + # EXPECT: counts is a non-SI dimension scipp carries in the unit powers; only the scale + # multiplier is converted. D*Q^2*tau = 4.6e-10 m^2/s * 1e20 /m^2 * 22e-12 s = 1.012. + expected = 6.582120e-13 * 4.6e-10 * 1e20 / (1.0 + 4.6e-10 * 1e20 * 22e-12) + assert value == pytest.approx(expected, rel=1e-5) + + ############# + # Physical constants + ############# -class TestExpressionComponentPhysicalConstants: def test_kb_constant_value_and_unit(self): # WHEN expr = ExpressionComponent( diff --git a/tests/unit/easydynamics/sample_model/components/test_gaussian.py b/tests/unit/easydynamics/sample_model/components/test_gaussian.py index 3ef9b01b7..266c6802c 100644 --- a/tests/unit/easydynamics/sample_model/components/test_gaussian.py +++ b/tests/unit/easydynamics/sample_model/components/test_gaussian.py @@ -131,6 +131,35 @@ def test_width_must_be_positive(self, gaussian: Gaussian): with pytest.raises(ValueError, match='width must be positive'): gaussian.width = -0.5 + def test_area_setter_out_of_bounds_raises(self, gaussian: Gaussian): + # WHEN the fixture's area was created non-negative, so it carries min=0 + original_area = gaussian.area.value + + # THEN EXPECT a negative assignment raises instead of being silently clamped to 0 + with pytest.raises(ValueError, match='violates the parameter bounds'): + gaussian.area = -1.0 + assert gaussian.area.value == pytest.approx(original_area) + + def test_area_setter_allows_negative_when_unbounded(self): + # WHEN a Gaussian constructed with a negative area gets no lower bound + with pytest.warns(UserWarning, match='may not be physically meaningful'): + gaussian = Gaussian(area=-2.0) + + # THEN + gaussian.area = -1.0 + + # EXPECT + assert gaussian.area.value == pytest.approx(-1.0) + + def test_width_setter_below_minimum_raises(self, gaussian: Gaussian): + # WHEN the width parameter carries an absolute minimum (1e-10) + original_width = gaussian.width.value + + # THEN EXPECT a tiny positive width below the bound raises instead of being clamped + with pytest.raises(ValueError, match='violates the parameter bounds'): + gaussian.width = 1e-12 + assert gaussian.width.value == pytest.approx(original_width) + def test_evaluate(self, gaussian: Gaussian): # WHEN x = np.array([0.0, 0.5, 1.0]) diff --git a/tests/unit/easydynamics/sample_model/components/test_lorentzian.py b/tests/unit/easydynamics/sample_model/components/test_lorentzian.py index 97e02aad9..3a25d3073 100644 --- a/tests/unit/easydynamics/sample_model/components/test_lorentzian.py +++ b/tests/unit/easydynamics/sample_model/components/test_lorentzian.py @@ -124,6 +124,24 @@ def test_width_must_be_positive(self, lorentzian: Lorentzian): with pytest.raises(ValueError, match='width must be positive'): lorentzian.width = -0.5 + def test_area_setter_out_of_bounds_raises(self, lorentzian: Lorentzian): + # WHEN the fixture's area was created non-negative, so it carries min=0 + original_area = lorentzian.area.value + + # THEN EXPECT a negative assignment raises instead of being silently clamped to 0 + with pytest.raises(ValueError, match='violates the parameter bounds'): + lorentzian.area = -1.0 + assert lorentzian.area.value == pytest.approx(original_area) + + def test_width_setter_below_minimum_raises(self, lorentzian: Lorentzian): + # WHEN the width parameter carries an absolute minimum (1e-10) + original_width = lorentzian.width.value + + # THEN EXPECT a tiny positive width below the bound raises instead of being clamped + with pytest.raises(ValueError, match='violates the parameter bounds'): + lorentzian.width = 1e-12 + assert lorentzian.width.value == pytest.approx(original_width) + def test_evaluate(self, lorentzian: Lorentzian): # WHEN x = np.array([0.0, 0.5, 1.0]) diff --git a/tests/unit/easydynamics/sample_model/components/test_mixins.py b/tests/unit/easydynamics/sample_model/components/test_mixins.py index d7a8487ca..d275e6666 100644 --- a/tests/unit/easydynamics/sample_model/components/test_mixins.py +++ b/tests/unit/easydynamics/sample_model/components/test_mixins.py @@ -13,7 +13,9 @@ class TestCreateParametersMixin: def dummy_model(self): return CreateParametersMixin() - # ------------- Area---------------------- + ############# + # Area + ############# @pytest.mark.parametrize('unit', ['meV', 'eV']) @pytest.mark.parametrize('area_input', [2, 2.0]) def test_create_area_parameter_from_numeric(self, dummy_model, area_input, unit): @@ -53,7 +55,40 @@ def test_negative_area_warns(self, dummy_model): assert area_param.min == -float('inf') # No min constraint for negative area - # ------------- Center---------------------- + ############# + # Bounded value assignment + ############# + def test_set_bounded_parameter_value_within_bounds(self, dummy_model): + # WHEN + param = Parameter(name='p', value=1.0, min=0.0, max=2.0) + + # THEN + dummy_model._set_bounded_parameter_value(param, 1.5, 'p') + + # EXPECT + assert param.value == pytest.approx(1.5) + + @pytest.mark.parametrize('out_of_bounds', [-1.0, 3.0], ids=['below_min', 'above_max']) + def test_set_bounded_parameter_value_out_of_bounds_raises(self, dummy_model, out_of_bounds): + # WHEN + param = Parameter(name='p', value=1.0, min=0.0, max=2.0) + + # THEN EXPECT the assignment raises instead of silently clamping, leaving the value + with pytest.raises(ValueError, match='violates the parameter bounds'): + dummy_model._set_bounded_parameter_value(param, out_of_bounds, 'p') + assert param.value == pytest.approx(1.0) + + def test_set_bounded_parameter_value_invalid_type_raises(self, dummy_model): + # WHEN + param = Parameter(name='p', value=1.0, min=0.0, max=2.0) + + # THEN EXPECT + with pytest.raises(TypeError, match='p must be a number'): + dummy_model._set_bounded_parameter_value(param, 'invalid', 'p') + + ############# + # Center + ############# @pytest.mark.parametrize('unit', ['meV', 'eV']) @pytest.mark.parametrize('center_input', [0, 0.0]) def test_create_center_parameter_from_numeric(self, dummy_model, center_input, unit): diff --git a/tests/unit/easydynamics/sample_model/components/test_model_component.py b/tests/unit/easydynamics/sample_model/components/test_model_component.py index 40e05e03a..f806afc75 100644 --- a/tests/unit/easydynamics/sample_model/components/test_model_component.py +++ b/tests/unit/easydynamics/sample_model/components/test_model_component.py @@ -231,7 +231,9 @@ def test_evaluate_with_compatible_unit_gives_correct_result(self): assert g_mev.width.value == pytest.approx(0.5) assert g_mev.area.value == pytest.approx(1.0) - # ───── Regression tests ───── + ############# + # Regression tests + ############# def test_convert_x_unit_rollback_on_failure(self, dummy: DummyComponent): # Conversion to 'm' (length) is incompatible with 'meV' (energy) → triggers rollback diff --git a/tests/unit/easydynamics/sample_model/components/test_polynomial.py b/tests/unit/easydynamics/sample_model/components/test_polynomial.py index a5ca6a599..692c32993 100644 --- a/tests/unit/easydynamics/sample_model/components/test_polynomial.py +++ b/tests/unit/easydynamics/sample_model/components/test_polynomial.py @@ -184,6 +184,34 @@ def test_convert_x_unit_raises_invalid_unit(self, polynomial: Polynomial): with pytest.raises(Exception, match='unit must be '): polynomial.convert_x_unit(123) + def test_convert_x_unit_rescales_bounded_coefficient_without_clamping(self): + # WHEN a linear coefficient with a lower bound that the converted value would cross + # (regression: the value was multiplied in place and easyscience silently clamped + # it to the bound, corrupting the coefficient irreversibly) + bounded = Parameter(name='c1', value=1.0, min=0.5) + polynomial = Polynomial(coefficients=[0.0, bounded], x_unit='meV') + + # THEN + polynomial.convert_x_unit('microeV') + + # EXPECT the value and the bound are rescaled together instead of clamping + assert bounded.value == pytest.approx(1e-3) + assert bounded.min == pytest.approx(0.5e-3) + # and the evaluated polynomial is physically unchanged: 1000 microeV = 1 meV + assert polynomial.evaluate(np.array([1000.0]))[0] == pytest.approx(1.0) + + def test_convert_y_unit_rescales_bounded_coefficient_without_clamping(self): + # WHEN a coefficient with an upper bound that the converted value would cross + bounded = Parameter(name='c0', value=1.0, max=2.0) + polynomial = Polynomial(coefficients=[bounded], x_unit='meV', y_unit='1/meV') + + # THEN + polynomial.convert_y_unit('1/eV') + + # EXPECT the value and the bound are rescaled together instead of clamping + assert bounded.value == pytest.approx(1e3) + assert bounded.max == pytest.approx(2e3) + def test_copy(self, polynomial: Polynomial): # WHEN THEN polynomial_copy = copy(polynomial) @@ -299,7 +327,9 @@ def test_convert_y_unit_rollback_on_failure(self): assert np.isclose(p.coefficients[0].value, 1.0) assert np.isclose(p.coefficients[1].value, 2.0) - # --- Serialization --- + ############# + # Serialization + ############# def test_to_dict(self, polynomial: Polynomial): # WHEN @@ -359,7 +389,9 @@ def test_from_dict_invalid_dict_raises(self): with pytest.raises(ValueError, match='must be a dictionary representing'): Polynomial.from_dict({'not': 'valid'}) - # --- Sparse dict initialization --- + ############# + # Sparse dict initialization + ############# def test_sparse_dict_single_term(self): # WHEN @@ -421,7 +453,9 @@ def test_sparse_dict_preserves_x_unit(self): assert p.x_unit == 'ueV' assert p.y_unit == 'counts' - # --- add_coefficient --- + ############# + # add_coefficient + ############# def test_add_coefficient_increases_degree(self, polynomial: Polynomial): # WHEN @@ -470,7 +504,9 @@ def test_add_coefficient_appears_in_all_variables(self, polynomial: Polynomial): # THEN EXPECT: the new coefficient is fittable assert len(polynomial.get_all_variables()) == 4 - # --- remove_coefficient --- + ############# + # remove_coefficient + ############# def test_remove_coefficient_decreases_degree(self, polynomial: Polynomial): # WHEN @@ -533,10 +569,9 @@ def test_add_coefficient_then_convert_x_unit(self, polynomial: Polynomial): assert polynomial.x_unit == 'ueV' np.testing.assert_allclose(after, before, rtol=1e-8) - -def test_suppress_warnings_setter_raises_for_non_bool(): - # GIVEN a Polynomial - p = Polynomial(coefficients=[1.0, 2.0], x_unit='meV') - # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r'Suppress_warnings must be True or False'): - p.suppress_warnings = 'yes' + def test_suppress_warnings_setter_raises_for_non_bool(self): + # WHEN a Polynomial + p = Polynomial(coefficients=[1.0, 2.0], x_unit='meV') + # THEN EXPECT + with pytest.raises(TypeError, match=r'Suppress_warnings must be True or False'): + p.suppress_warnings = 'yes' diff --git a/tests/unit/easydynamics/sample_model/components/test_voigt.py b/tests/unit/easydynamics/sample_model/components/test_voigt.py index 42eb5099c..8c66b886e 100644 --- a/tests/unit/easydynamics/sample_model/components/test_voigt.py +++ b/tests/unit/easydynamics/sample_model/components/test_voigt.py @@ -201,6 +201,24 @@ def test_lorentzian_width_must_be_positive(self, voigt: Voigt): ): voigt.lorentzian_width = -0.7 + def test_area_setter_out_of_bounds_raises(self, voigt: Voigt): + # WHEN the fixture's area was created non-negative, so it carries min=0 + original_area = voigt.area.value + + # THEN EXPECT a negative assignment raises instead of being silently clamped to 0 + with pytest.raises(ValueError, match='violates the parameter bounds'): + voigt.area = -1.0 + assert voigt.area.value == pytest.approx(original_area) + + def test_width_setters_below_minimum_raise(self, voigt: Voigt): + # WHEN the width parameters carry an absolute minimum (1e-10) + + # THEN EXPECT tiny positive widths below the bound raise instead of being clamped + with pytest.raises(ValueError, match='violates the parameter bounds'): + voigt.gaussian_width = 1e-12 + with pytest.raises(ValueError, match='violates the parameter bounds'): + voigt.lorentzian_width = 1e-12 + def test_center_is_fixed_if_set_to_None(self, voigt: Voigt): # WHEN assert voigt.center.fixed is False diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py index 4b2137540..c8958afbb 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_brownian_translational_diffusion.py @@ -4,7 +4,6 @@ import numpy as np import pytest import scipp as sc -from easyscience.variable import DescriptorNumber from scipp import UnitError from scipp.constants import hbar as scipp_hbar @@ -12,10 +11,6 @@ BrownianTranslationalDiffusion, ) -hbar_1 = DescriptorNumber('hbar', 1.0) -hbar = DescriptorNumber.from_scipp('hbar', scipp_hbar) -angstrom = DescriptorNumber('angstrom', 1e-10, unit='m') - class TestBrownianTranslationalDiffusion: @pytest.fixture @@ -258,6 +253,17 @@ def test_create_component_collections(self, brownian_diffusion_model, Q): # area.unit = area_unit = x_unit * y_unit assert component.area.unit == 'meV' + def test_create_component_collections_installs_collections(self): + # WHEN + model = BrownianTranslationalDiffusion(Q=np.array([1.0, 2.0])) + + # THEN + collections = model.create_component_collections() + + # EXPECT the returned collections are the installed (live) ones, so callers that + # follow the docstring get the same objects the model itself uses + assert collections is model.get_component_collections() + def test_write_width_dependency_expression(self, brownian_diffusion_model): # WHEN THEN expression = brownian_diffusion_model._write_width_dependency_expression(0.5) diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py index 17074eb95..59f2d03f9 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentz.py @@ -265,9 +265,9 @@ def test_input_type_validation_raises(self, kwargs, expected_exception, expected with pytest.raises(expected_exception, match=expected_message): DeltaLorentz(**kwargs) - # ------------------------------------------------------------------ + ############# # Properties - # ------------------------------------------------------------------ + ############# @pytest.mark.parametrize( ('attribute', 'value', 'expected'), [ @@ -418,9 +418,9 @@ def test_setters_invalid( with pytest.raises(exception, match=message): setattr(delta_lorentz_model, attribute, value) - # ------------------------------------------------------------------ + ############# # Other methods - # ------------------------------------------------------------------ + ############# def test_calculate_width_without_Q(self, delta_lorentz_model): # WHEN THEN @@ -565,6 +565,47 @@ def test_create_component_collections_with_no_Q_variation( ) assert 'A_0' in collection[1].area.dependency_expression + def test_create_component_collections_installs_and_stays_in_sync( + self, delta_lorentz_model_with_Q + ): + # WHEN + model = delta_lorentz_model_with_Q + + # THEN + collections = model.create_component_collections() + + # EXPECT the returned collections are the installed (live) ones (regression: they + # were returned without being installed, while the per-Q parameter lists were + # replaced, desynchronizing calculate_width from the installed components) + assert collections is model.get_component_collections() + + # THEN setting a per-Q width parameter + model._lorentzian_width_list[0].value = 0.5 + + # EXPECT the change is visible in the installed component and in calculate_width + assert collections[0][0].width.value == pytest.approx(0.5) + assert model.calculate_width()[0] == pytest.approx(0.5) + + # EXPECT the same holds for the per-Q amplitude parameters + model._A_0_list[0].value = 0.25 + assert model.calculate_EISF()[0] == pytest.approx(0.25) + assert collections[0][1].area.value == pytest.approx(0.25) + + def test_per_Q_parameter_and_collection_names(self, delta_lorentz_model_with_Q): + # WHEN + model = delta_lorentz_model_with_Q + + # THEN + collections = model.get_component_collections() + + # EXPECT the per-Q amplitudes carry the model name (like the widths carry the + # Lorentzian name), and the per-Q collections get a name, not just a display name + for a0, a1 in zip(model._A_0_list, model._A_1_list, strict=True): + assert a0.name == 'DeltaLorentz A_0' + assert a1.name == 'DeltaLorentz A_1' + assert collections[0].name == 'DeltaLorentz_Q0.50' + assert collections[0].display_name == 'DeltaLorentz_Q0.50' + @pytest.mark.parametrize( ('Q_index', 'expected_exception', 'expected_message'), [ @@ -923,7 +964,9 @@ def test_repr(self, delta_lorentz_model): # Regression: a stray ')' used to mangle this into 'x_unit=meV), y_unit=...' assert 'x_unit=meV, y_unit=dimensionless' in repr_str - # ───── Regression tests ───── + ############# + # Regression tests + ############# def test_calculate_width_with_Q_subset(self, delta_lorentz_model_with_Q): # WHEN: Q-varying widths with distinguishable per-Q values @@ -972,21 +1015,23 @@ def test_calculate_width_raises_after_clear_Q_when_allow_Q_variation( with pytest.raises(ValueError, match='Q must be provided'): delta_lorentz_model_with_Q.calculate_width() + ############# + # Fit targets and Q validation + ############# -def test_get_fit_targets_includes_delta_area(): - # GIVEN a DeltaLorentz model - model = DeltaLorentz(delta_name='Delta function', lorentzian_name='Lorentzian') - # WHEN - targets = model.get_fit_targets() - # EXPECT base area/width plus the delta_area prediction - assert [t.name for t in targets] == ['area', 'width', 'delta_area'] - delta_area = next(t for t in targets if t.name == 'delta_area') - assert delta_area.dataset_key == 'Delta function area' - - -def test_calculate_width_raises_when_Q_variation_enabled_but_Q_unset(): - # GIVEN Q-variation enabled for the width but Q never set on the model (empty per-Q list) - model = DeltaLorentz(lorentzian_width=0.1, allow_Q_variation={'lorentzian_width': True}) - # WHEN a Q is requested THEN EXPECT the empty per-Q width list to be reported - with pytest.raises(ValueError, match=r'Lorentzian width Q-variation list is empty'): - model.calculate_width(np.array([1.0])) + def test_get_fit_targets_includes_delta_area(self): + # WHEN a DeltaLorentz model + model = DeltaLorentz(delta_name='Delta function', lorentzian_name='Lorentzian') + # THEN + targets = model.get_fit_targets() + # EXPECT base area/width plus the delta_area prediction + assert [t.name for t in targets] == ['area', 'width', 'delta_area'] + delta_area = next(t for t in targets if t.name == 'delta_area') + assert delta_area.dataset_key == 'Delta function area' + + def test_calculate_width_raises_when_Q_variation_enabled_but_Q_unset(self): + # WHEN Q-variation enabled for the width but Q never set on the model (empty per-Q list) + model = DeltaLorentz(lorentzian_width=0.1, allow_Q_variation={'lorentzian_width': True}) + # THEN EXPECT the empty per-Q width list to be reported when a Q is requested + with pytest.raises(ValueError, match=r'Lorentzian width Q-variation list is empty'): + model.calculate_width(np.array([1.0])) diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model_base.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model_base.py index d323b0ba6..ace30385e 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model_base.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_model_base.py @@ -355,21 +355,19 @@ def test_ensure_Q_uses_argument(self, diffusion_model): # EXPECT np.testing.assert_allclose(Q, [1.0, 2.0]) - -def test_get_fit_targets_declares_area_and_width(): - # GIVEN a diffusion model - model = DiffusionModelBase(lorentzian_name='Lorentzian') - # WHEN - targets = model.get_fit_targets() - # EXPECT area and width predictions with keys derived from the Lorentzian name - assert [t.name for t in targets] == ['area', 'width'] - assert targets[0].dataset_key == 'Lorentzian area' - assert targets[1].dataset_key == 'Lorentzian width' - - -def test_match_Q_indices_raises_when_Q_not_set(): - # GIVEN a diffusion model with no Q set - model = DiffusionModelBase() - # WHEN THEN EXPECT - with pytest.raises(ValueError, match=r'Q must be set in the model'): - model._match_Q_indices(np.array([1.0])) + def test_get_fit_targets_declares_area_and_width(self): + # WHEN a diffusion model + model = DiffusionModelBase(lorentzian_name='Lorentzian') + # THEN + targets = model.get_fit_targets() + # EXPECT area and width predictions with keys derived from the Lorentzian name + assert [t.name for t in targets] == ['area', 'width'] + assert targets[0].dataset_key == 'Lorentzian area' + assert targets[1].dataset_key == 'Lorentzian width' + + def test_match_Q_indices_raises_when_Q_not_set(self): + # WHEN a diffusion model with no Q set + model = DiffusionModelBase() + # THEN EXPECT + with pytest.raises(ValueError, match=r'Q must be set in the model'): + model._match_Q_indices(np.array([1.0])) diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py index 7fcf4dacd..4aa53200b 100644 --- a/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_translational_diffusion.py @@ -4,7 +4,6 @@ import numpy as np import pytest import scipp as sc -from easyscience.variable import DescriptorNumber from scipp import UnitError from scipp.constants import hbar as scipp_hbar @@ -12,10 +11,6 @@ JumpTranslationalDiffusion, ) -hbar_1 = DescriptorNumber('hbar', 1.0) -hbar = DescriptorNumber.from_scipp('hbar', scipp_hbar) -angstrom = DescriptorNumber('angstrom', 1e-10, unit='m') - class TestJumpTranslationalDiffusion: @pytest.fixture @@ -248,6 +243,17 @@ def test_create_component_collections(self, jump_diffusion_model, Q): # area.unit = area_unit = x_unit * y_unit assert component.area.unit == 'meV' + def test_create_component_collections_installs_collections(self): + # WHEN + model = JumpTranslationalDiffusion(Q=np.array([1.0, 2.0])) + + # THEN + collections = model.create_component_collections() + + # EXPECT the returned collections are the installed (live) ones, so callers that + # follow the docstring get the same objects the model itself uses + assert collections is model.get_component_collections() + def test_write_width_dependency_expression(self, jump_diffusion_model): # WHEN THEN expression = jump_diffusion_model._write_width_dependency_expression(0.5) diff --git a/tests/unit/easydynamics/sample_model/test_component_collection.py b/tests/unit/easydynamics/sample_model/test_component_collection.py index 233117031..be3407f14 100644 --- a/tests/unit/easydynamics/sample_model/test_component_collection.py +++ b/tests/unit/easydynamics/sample_model/test_component_collection.py @@ -9,6 +9,7 @@ from easyscience.variable import Parameter from scipy.integrate import simpson +from easydynamics.exceptions import AmbiguousNameError from easydynamics.sample_model import ComponentCollection from easydynamics.sample_model import ExpressionComponent from easydynamics.sample_model import Gaussian @@ -113,7 +114,9 @@ def test_init_with_invalid_unit_raises(self): with pytest.raises(TypeError, match='unit must be'): ComponentCollection(x_unit=123) - # ───── Component Management ───── + ############# + # Component Management + ############# def test_append_component(self, component_collection): # WHEN @@ -308,7 +311,9 @@ def test_evaluate_component_invalid_name_type_raises(self, component_collection) ): component_collection.evaluate_component(x, 123) - # ───── Utilities ───── + ############# + # Utilities + ############# def test_normalize_area(self, component_collection): # WHEN THEN @@ -676,7 +681,95 @@ def test_evaluate_scipp_output_with_y_unit(self): assert isinstance(result, sc.Variable) assert result.unit == sc.Unit('1/meV') - # ───── Regression tests ───── + ############# + # Versioning + ############# + + def test_version_starts_at_zero_and_bumps_on_mutation(self): + # WHEN a freshly constructed collection with initial components + collection = ComponentCollection(components=[Gaussian(name='G1'), Lorentzian(name='L1')]) + + # EXPECT it starts at version 0 + assert collection.version == 0 + + # THEN structural mutations bump the version + collection.append_component(Gaussian(name='G2')) + assert collection.version == 1 + collection.pop('G2') + assert collection.version == 2 + + ############# + # Slicing + ############# + + def test_getitem_slice_returns_working_collection(self, component_collection): + "Regression: slicing used to crash because the base slice path called the wrong ctor" + # WHEN THEN + sliced = component_collection[:1] + + # EXPECT a working collection of the same class, carrying the units, sharing the + # component objects + assert type(sliced) is ComponentCollection + assert len(sliced) == 1 + assert sliced[0] is component_collection[0] + assert sliced.x_unit == component_collection.x_unit + assert sliced.y_unit == component_collection.y_unit + + # EXPECT the slice is usable + x = np.linspace(-5, 5, 11) + np.testing.assert_allclose(sliced.evaluate(x), component_collection[0].evaluate(x)) + + ############# + # Regression tests + ############# + + def test_normalize_area_negative_area_raises(self, component_collection): + "Regression: negative areas used to be silently clamped by normalization" + # WHEN + component_collection[0].area.min = -10.0 + component_collection[0].area = -2.0 + + # THEN EXPECT + with pytest.raises(ValueError, match=r'Negative area'): + component_collection.normalize_area() + + def test_evaluate_empty_invalid_output_raises(self): + "Regression: the empty-collection path used to skip output validation" + # WHEN + collection = ComponentCollection(display_name='EmptyModel') + + # THEN EXPECT + with pytest.raises(ValueError, match=r"output must be 'numpy' or 'scipp'"): + collection.evaluate(np.linspace(-1, 1, 5), output='invalid') + + def test_evaluate_empty_scalar_shape_matches_non_empty_path(self): + "Regression: empty and non-empty paths must agree on the output shape for scalar x" + # WHEN an empty and a non-empty collection evaluated at a scalar + empty = ComponentCollection(display_name='EmptyModel') + non_empty = ComponentCollection(components=Gaussian(name='G')) + + # THEN + empty_result = empty.evaluate(0.5) + non_empty_result = non_empty.evaluate(0.5) + + # EXPECT both return 1D arrays of the same shape + assert empty_result.shape == non_empty_result.shape == (1,) + assert np.all(empty_result == pytest.approx(0.0)) + + def test_evaluate_component_ambiguous_name_raises(self): + "Regression: duplicate names used to silently evaluate the first match" + # WHEN a collection with two components sharing a name + with pytest.warns(UserWarning, match='Duplicate component names'): + collection = ComponentCollection( + components=[ + Gaussian(name='SameName', area=1.0), + Gaussian(name='SameName', area=2.0), + ] + ) + + # THEN EXPECT + with pytest.raises(AmbiguousNameError, match=r"Ambiguous name 'SameName'"): + collection.evaluate_component(np.linspace(-1, 1, 5), 'SameName') def test_evaluate_scipp_output_multi_component_does_not_raise(self, component_collection): # WHEN: collection with two components (Gaussian + Lorentzian) diff --git a/tests/unit/easydynamics/sample_model/test_instrument_model.py b/tests/unit/easydynamics/sample_model/test_instrument_model.py index a802b1961..6df773e10 100644 --- a/tests/unit/easydynamics/sample_model/test_instrument_model.py +++ b/tests/unit/easydynamics/sample_model/test_instrument_model.py @@ -551,36 +551,39 @@ def test_on_energy_offset_change(self, instrument_model): assert offset.value == new_offset def test_on_resolution_model_change(self, instrument_model, resolution_model): - # WHEN + # WHEN a resolution model that does not know the instrument's Q yet new_resolution_model = resolution_model + assert new_resolution_model.Q is None # THEN - instrument_model._resolution_model = new_resolution_model - instrument_model._on_resolution_model_change() + instrument_model.resolution_model = new_resolution_model - # EXPECT - assert instrument_model._resolution_model is new_resolution_model + # EXPECT the change callback propagated the instrument's Q to the new model + assert instrument_model.resolution_model is new_resolution_model + np.testing.assert_array_equal(new_resolution_model.Q.values, np.array([1.0, 2.0, 3.0])) def test_on_background_model_change(self, instrument_model, background_model): - # WHEN + # WHEN a background model that does not know the instrument's Q yet new_background_model = background_model + assert new_background_model.Q is None # THEN - instrument_model._background_model = new_background_model - instrument_model._on_background_model_change() + instrument_model.background_model = new_background_model - # EXPECT - assert instrument_model._background_model is new_background_model + # EXPECT the change callback propagated the instrument's Q to the new model + assert instrument_model.background_model is new_background_model + np.testing.assert_array_equal(new_background_model.Q.values, np.array([1.0, 2.0, 3.0])) def test_repr_contains_expected_fields(self, instrument_model): # WHEN THEN repr_str = repr(instrument_model) - # EXPECT + # EXPECT values pinned from the fixture's known construction inputs, so a wrong + # attribute value cannot satisfy its own interpolation assert repr_str.startswith('InstrumentModel(') - assert f'unique_name={instrument_model.unique_name!r}' in repr_str - assert f'x_unit={instrument_model.x_unit}' in repr_str - assert 'Q_len=3' in repr_str - assert f'resolution_model={instrument_model._resolution_model!r}' in repr_str - assert f'background_model={instrument_model._background_model!r}' in repr_str assert repr_str.endswith(')') + assert "unique_name='" in repr_str + assert 'x_unit=meV' in repr_str + assert 'Q_len=3' in repr_str + assert 'resolution_model=ResolutionModel(' in repr_str + assert 'background_model=BackgroundModel(' in repr_str diff --git a/tests/unit/easydynamics/sample_model/test_model_base.py b/tests/unit/easydynamics/sample_model/test_model_base.py index 670895edb..5bd222185 100644 --- a/tests/unit/easydynamics/sample_model/test_model_base.py +++ b/tests/unit/easydynamics/sample_model/test_model_base.py @@ -543,3 +543,76 @@ def test_convert_y_unit_invalid_raises(self, model_base): # WHEN THEN EXPECT with pytest.raises(TypeError): model_base.convert_y_unit(123) + + ############# + # State versioning + ############# + + def test_evaluate_without_Q_names_the_cause(self): + "Regression: the error used to claim 'no components' when Q was the missing piece" + # WHEN a model with components but no Q + model = ModelBase(display_name='M', components=Gaussian(name='G')) + + # THEN EXPECT + with pytest.raises(ValueError, match='Q is not set'): + model.evaluate(np.array([0.0, 1.0])) + + def test_state_version_reading_does_not_mutate(self, model_base): + # WHEN + version = model_base.state_version + + # THEN EXPECT repeated reads return the same value and rebuild nothing + assert model_base.state_version == version + assert model_base.component_collections_is_dirty is True + assert model_base._component_collections == [] + + def test_state_version_changes_on_component_and_Q_changes(self, model_base): + # WHEN + version = model_base.state_version + + # THEN appending a component through the model + model_base.append_component(Gaussian(name='SVGaussian')) + # EXPECT + assert model_base.state_version > version + version = model_base.state_version + + # THEN removing a component through the model + model_base.remove_component('SVGaussian') + # EXPECT + assert model_base.state_version > version + version = model_base.state_version + + # THEN clearing Q + model_base.clear_Q(confirm=True) + # EXPECT + assert model_base.state_version > version + + def test_state_version_changes_on_in_place_template_mutation(self, model_base): + # WHEN collections are current, so the dirty flag alone would report clean + _ = model_base.get_component_collection(0) + assert model_base.component_collections_is_dirty is False + version = model_base.state_version + + # THEN mutating the live template collection in place, bypassing the model's methods + model_base.components.append_component(Gaussian(name='LiveGaussian')) + + # EXPECT the mutation is visible without any callback + assert model_base.state_version > version + assert model_base.component_collections_is_dirty is True + + def test_evaluate_includes_component_appended_to_live_collection(self, model_base): + "Regression: components appended via the live template collection were invisible" + # WHEN a model whose collections were already built and evaluated + x = np.linspace(-5, 5, 101) + result_before = model_base.evaluate(x) + + # THEN appending directly to the live template collection and evaluating again + model_base.components.append_component( + Gaussian(name='LiveGaussian', area=10.0, center=0.0, width=1.0) + ) + result_after = model_base.evaluate(x) + + # EXPECT the new component contributes to the output at every Q + extra = Gaussian(name='Reference', area=10.0, center=0.0, width=1.0).evaluate(x) + for before, after in zip(result_before, result_after, strict=True): + np.testing.assert_allclose(after, before + extra, rtol=1e-10) diff --git a/tests/unit/easydynamics/sample_model/test_resolution_model.py b/tests/unit/easydynamics/sample_model/test_resolution_model.py index 102363f60..5a5d2773d 100644 --- a/tests/unit/easydynamics/sample_model/test_resolution_model.py +++ b/tests/unit/easydynamics/sample_model/test_resolution_model.py @@ -305,18 +305,61 @@ def test_from_sample_model_invalid_arguments( **valid_kwargs, ) - def test_from_sample_model_invalid_components(self, sample_model): - # WHEN - invalid_component = DeltaFunction(name='InvalidDelta') - sample_model.append_component(invalid_component) + def test_from_sample_model_strips_delta_functions(self, sample_model): + # WHEN a sample model with the standard QENS elastic delta line + sample_model.append_component(DeltaFunction(name='Elastic')) - # THEN EXPECT - with pytest.raises( - TypeError, - match='cannot be a DeltaFunction', - ): + # THEN + with pytest.warns(UserWarning, match='Stripped'): + resolution_model = ResolutionModel.from_sample_model(sample_model) + + # EXPECT no DeltaFunction in the template or the per-Q collections, and the + # remaining components still normalized to unit area + assert not any(isinstance(c, DeltaFunction) for c in resolution_model.components) + for Q_index in range(len(resolution_model.Q)): + collection = resolution_model.get_component_collection(Q_index) + assert not any(isinstance(c, DeltaFunction) for c in collection) + assert sum(c.area.value for c in collection) == pytest.approx(1.0) + + def test_from_sample_model_background_component_raises(self, sample_model): + # WHEN a sample model carrying a background component + sample_model.append_component(Polynomial(name='Background')) + + # THEN EXPECT backgrounds are rejected, not silently installed as resolution + with pytest.raises(TypeError, match='cannot be a Polynomial'): + ResolutionModel.from_sample_model(sample_model) + + def test_from_sample_model_delta_only_raises(self): + # WHEN a sample model whose only component is the elastic delta + sample_model = SampleModel( + components=DeltaFunction(name='Elastic'), + Q=np.array([1.0]), + ) + + # THEN EXPECT stripping the delta would leave no resolution shape + with pytest.raises(ValueError, match='contains only'): ResolutionModel.from_sample_model(sample_model) + def test_from_sample_model_locks_calibrated_collections(self, sample_model): + # WHEN + resolution_model = ResolutionModel.from_sample_model(sample_model) + calibrated = resolution_model.get_component_collection(0) + + # THEN EXPECT mutations that would rebuild the collections from the unfitted + # template fail loudly instead of silently discarding the calibration + with pytest.raises(RuntimeError, match='calibrated'): + resolution_model.append_component(Gaussian(name='Extra')) + with pytest.raises(RuntimeError, match='calibrated'): + resolution_model.remove_component('TestGaussian1Name') + with pytest.raises(RuntimeError, match='calibrated'): + resolution_model.clear_components() + with pytest.raises(RuntimeError, match='calibrated'): + resolution_model.clear_Q(confirm=True) + + # EXPECT the calibrated collections survive untouched + assert resolution_model.get_component_collection(0) is calibrated + assert resolution_model._component_collections_is_dirty is False + def test_y_unit_setter_raises(self, resolution_model): # WHEN / THEN / EXPECT with pytest.raises(AttributeError): diff --git a/tests/unit/easydynamics/sample_model/test_sample_model.py b/tests/unit/easydynamics/sample_model/test_sample_model.py index 36ac3fb72..ab986e160 100644 --- a/tests/unit/easydynamics/sample_model/test_sample_model.py +++ b/tests/unit/easydynamics/sample_model/test_sample_model.py @@ -517,6 +517,82 @@ def test_evaluate_doesnt_call_dbf_when_disabled( np.testing.assert_allclose(result[0], np.array([1.0, 2.0, 3.0])) np.testing.assert_allclose(result[1], np.array([4.0, 5.0, 6.0])) + def test_evaluate_scipp_output_with_detailed_balance(self, sample_model): + # WHEN the fixture has temperature set, so detailed balance is applied + x = np.linspace(-2.0, 2.0, 21) + + # THEN (regression: multiplying an sc.Variable with the numpy DBF used to raise) + balanced = sample_model.evaluate(x, output='scipp') + + # EXPECT scipp output matches numpy output, with the model's y_unit kept + reference = sample_model.evaluate(x, output='numpy') + assert len(balanced) == 3 + for scipp_values, numpy_values in zip(balanced, reference, strict=True): + assert isinstance(scipp_values, sc.Variable) + assert scipp_values.unit == sc.Unit('dimensionless') + np.testing.assert_allclose(scipp_values.values, numpy_values) + + # THEN disabling detailed balance + sample_model.use_detailed_balance = False + unbalanced = sample_model.evaluate(x, output='scipp') + + # EXPECT the detailed balance factor really was applied above + assert not np.allclose(balanced[0].values, unbalanced[0].values) + + def test_evaluate_dataarray_input_with_and_without_detailed_balance(self, sample_model): + # WHEN + x = np.linspace(-2.0, 2.0, 21) + data_array = sc.DataArray( + data=sc.array(dims=['energy'], values=np.zeros_like(x)), + coords={'energy': sc.array(dims=['energy'], values=x, unit='meV')}, + ) + + # THEN (regression: detailed balance used to reject DataArray x, which the + # component pipeline explicitly supports) + with_temperature = sample_model.evaluate(data_array) + reference_with = sample_model.evaluate(x) + + sample_model.temperature = None + without_temperature = sample_model.evaluate(data_array) + reference_without = sample_model.evaluate(x) + + # EXPECT DataArray input matches plain numpy input in both modes + for result, reference in zip(with_temperature, reference_with, strict=True): + np.testing.assert_allclose(result, reference) + for result, reference in zip(without_temperature, reference_without, strict=True): + np.testing.assert_allclose(result, reference) + + def test_init_invalid_temperature_does_not_mutate_diffusion_models(self): + # WHEN a diffusion model without Q and an invalid temperature + diffusion_model = BrownianTranslationalDiffusion() + + # THEN EXPECT construction fails on the temperature validation + with pytest.raises(TypeError, match='temperature must be a number or None'): + SampleModel( + diffusion_models=diffusion_model, + Q=np.array([1.0, 2.0]), + temperature='cold', + ) + + # EXPECT the failed construction did not mutate the passed diffusion model + assert diffusion_model.Q is None + assert diffusion_model.get_component_collections() == [] + + def test_temperature_unit_is_normalized_to_str(self, sample_model): + # WHEN constructed with a scipp Unit instead of a string + model = SampleModel(temperature=10.0, temperature_unit=sc.Unit('K')) + + # EXPECT the stored unit is normalized to a string + assert isinstance(model.temperature_unit, str) + assert model.temperature_unit == 'K' + + # THEN converting with a scipp Unit + sample_model.convert_temperature_unit(sc.Unit('mK')) + + # EXPECT the stored unit is normalized to a string as well + assert isinstance(sample_model.temperature_unit, str) + assert sample_model.temperature_unit == 'mK' + def test_generate_component_collections(self, sample_model): # WHEN THEN sample_model._generate_component_collections() @@ -615,33 +691,31 @@ def test_convert_y_unit(self): assert model.components[0].y_unit == '1/eV' assert g.area.value == pytest.approx(1e3) - -def test_remove_diffusion_model_raises_with_duplicate_names(): - # GIVEN a SampleModel with two DiffusionModels sharing a name - Q = np.linspace(0.5, 2.0, 3) - model = SampleModel( - Q=Q, - diffusion_models=[ - BrownianTranslationalDiffusion(name='dup'), - BrownianTranslationalDiffusion(name='dup'), - ], - ) - # WHEN THEN EXPECT - with pytest.raises(ValueError, match=r'Multiple DiffusionModels share the name'): - model.remove_diffusion_model('dup') - - -def test_convert_x_unit_rolls_back_when_diffusion_model_conversion_fails(): - # GIVEN a SampleModel whose diffusion model raises during x-unit conversion - Q = np.linspace(0.5, 2.0, 3) - brownian = BrownianTranslationalDiffusion() - model = SampleModel(Q=Q, diffusion_models=brownian) - original_unit = model.x_unit - # WHEN the conversion fails partway through - with ( - patch.object(brownian, 'convert_x_unit', side_effect=RuntimeError('boom')), - pytest.raises(RuntimeError, match='boom'), - ): - model.convert_x_unit('ueV') - # EXPECT the model's own x_unit to be rolled back to the original - assert model.x_unit == original_unit + def test_remove_diffusion_model_raises_with_duplicate_names(self): + # WHEN a SampleModel with two DiffusionModels sharing a name + Q = np.linspace(0.5, 2.0, 3) + model = SampleModel( + Q=Q, + diffusion_models=[ + BrownianTranslationalDiffusion(name='dup'), + BrownianTranslationalDiffusion(name='dup'), + ], + ) + # THEN EXPECT + with pytest.raises(ValueError, match=r'Multiple DiffusionModels share the name'): + model.remove_diffusion_model('dup') + + def test_convert_x_unit_rolls_back_when_diffusion_model_conversion_fails(self): + # WHEN a SampleModel whose diffusion model raises during x-unit conversion + Q = np.linspace(0.5, 2.0, 3) + brownian = BrownianTranslationalDiffusion() + model = SampleModel(Q=Q, diffusion_models=brownian) + original_unit = model.x_unit + # THEN EXPECT the conversion fails partway through + with ( + patch.object(brownian, 'convert_x_unit', side_effect=RuntimeError('boom')), + pytest.raises(RuntimeError, match='boom'), + ): + model.convert_x_unit('ueV') + # EXPECT the model's own x_unit to be rolled back to the original + assert model.x_unit == original_unit diff --git a/tests/unit/easydynamics/settings/test_convolution_settings.py b/tests/unit/easydynamics/settings/test_convolution_settings.py index a5c0ceb38..d5d210202 100644 --- a/tests/unit/easydynamics/settings/test_convolution_settings.py +++ b/tests/unit/easydynamics/settings/test_convolution_settings.py @@ -168,16 +168,25 @@ def test_extension_factor_setter_valid(self, default_convolution_settings, value assert default_convolution_settings.extension_factor == pytest.approx(float(value)) assert default_convolution_settings._plan_version == version_before + 1 + def test_extension_factor_setter_none(self, default_convolution_settings): + # WHEN + version_before = default_convolution_settings._plan_version + + # THEN None is accepted, matching the None-capable constructor + default_convolution_settings.extension_factor = None + + # EXPECT: value stored and the plan invalidated for all convolvers + assert default_convolution_settings.extension_factor is None + assert default_convolution_settings._plan_version == version_before + 1 + @pytest.mark.parametrize( 'value, expected_exception, match', [ ('0.2', TypeError, 'must be a number'), - (None, TypeError, 'must be a number'), (-0.1, ValueError, 'must be non-negative'), ], ids=[ 'not_numeric', - 'none', 'negative', ], ) diff --git a/tests/unit/easydynamics/settings/test_detailed_balance_settings.py b/tests/unit/easydynamics/settings/test_detailed_balance_settings.py index dba4d9d68..fa7821d35 100644 --- a/tests/unit/easydynamics/settings/test_detailed_balance_settings.py +++ b/tests/unit/easydynamics/settings/test_detailed_balance_settings.py @@ -109,6 +109,36 @@ def test_setters_invalid( with pytest.raises(expected_exception, match=match): default_detailed_balance_settings.normalize_detailed_balance = value + ############# + # Plan invalidation + ############# + + def test_setters_bump_plan_version(self, default_detailed_balance_settings): + "Regression: flag toggles used to be invisible to convolvers holding these settings" + # WHEN + settings = default_detailed_balance_settings + version_before = settings._plan_version + + # THEN toggling each flag + settings.use_detailed_balance = False + settings.normalize_detailed_balance = False + + # EXPECT one bump per changed flag + assert settings._plan_version == version_before + 2 + + def test_invalidate_plan_bumps_version(self, default_detailed_balance_settings): + # WHEN + settings = default_detailed_balance_settings + version_before = settings._plan_version + + # THEN + settings._invalidate_plan() + + # EXPECT + assert settings._plan_version == version_before + 1 + assert settings._plan_valid_for(version_before) is False + assert settings._plan_valid_for(settings._plan_version) is True + def test_repr_default(self, default_detailed_balance_settings): # WHEN repr_str = repr(default_detailed_balance_settings) diff --git a/tests/unit/easydynamics/test_exceptions.py b/tests/unit/easydynamics/test_exceptions.py index bc7731a5e..8547024c6 100644 --- a/tests/unit/easydynamics/test_exceptions.py +++ b/tests/unit/easydynamics/test_exceptions.py @@ -3,6 +3,7 @@ from easydynamics.exceptions import AmbiguousNameError +from easydynamics.sample_model import Gaussian class TestAmbiguousNameError: @@ -21,6 +22,22 @@ def test_initialization(self): "Ambiguous name 'test' matches 3 elements: ['test1', 'test2', 'test3']" ) + def test_object_matches_print_their_names(self): + "Regression: the message used to print raw objects instead of their names" + # WHEN matches are objects with unique names, as raised by EasyDynamicsList + matches = [ + Gaussian(name='SameName', unique_name='UniqueGaussian1'), + Gaussian(name='SameName', unique_name='UniqueGaussian2'), + ] + + # THEN + error = AmbiguousNameError('SameName', matches) + + # EXPECT the message names the matches instead of dumping object reprs + assert str(error) == ( + "Ambiguous name 'SameName' matches 2 elements: ['UniqueGaussian1', 'UniqueGaussian2']" + ) + def test_empty_matches(self): # WHEN name = 'unknown' diff --git a/tests/unit/easydynamics/test_import.py b/tests/unit/easydynamics/test_import.py deleted file mode 100644 index 11f87bdaf..000000000 --- a/tests/unit/easydynamics/test_import.py +++ /dev/null @@ -1,7 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - - -def test_import_easydynamics(): - # WHEN THEN EXPECT: importing raises no error - import easydynamics # ruff: ignore[unused-import] diff --git a/tests/unit/easydynamics/test_public_api.py b/tests/unit/easydynamics/test_public_api.py index fb81e81e4..e9dc71b4a 100644 --- a/tests/unit/easydynamics/test_public_api.py +++ b/tests/unit/easydynamics/test_public_api.py @@ -16,6 +16,7 @@ import pytest import easydynamics as edyn +from easydynamics.sample_model import Gaussian SUB_PACKAGES = [ 'easydynamics.analysis', @@ -23,6 +24,8 @@ 'easydynamics.convolution', 'easydynamics.experiment', 'easydynamics.sample_model', + 'easydynamics.sample_model.components', + 'easydynamics.sample_model.diffusion_model', 'easydynamics.settings', 'easydynamics.utils', ] @@ -31,6 +34,10 @@ class TestFrontDoor: + def test_import_easydynamics(self): + # WHEN THEN EXPECT: importing raises no error + import easydynamics # ruff: ignore[unused-import] + def test_everything_declared_is_importable(self): # THEN EXPECT no name in __all__ that cannot actually be reached missing = [name for name in edyn.__all__ if not hasattr(edyn, name)] @@ -47,9 +54,6 @@ def test_sub_package_exports_are_re_exported(self, module_name): assert missing == [], f'{module_name} exports not re-exported: {missing}' def test_re_exports_are_the_same_objects(self): - # WHEN - from easydynamics.sample_model import Gaussian - # THEN EXPECT the front door is an alias, not a copy assert edyn.Gaussian is Gaussian diff --git a/tests/unit/easydynamics/utils/test_detailed_balance.py b/tests/unit/easydynamics/utils/test_detailed_balance.py index 2d2284d36..f50d6ecad 100644 --- a/tests/unit/easydynamics/utils/test_detailed_balance.py +++ b/tests/unit/easydynamics/utils/test_detailed_balance.py @@ -17,29 +17,29 @@ class TestDetailedBalanceFactor: # Input validation tests def test_energy_unit_not_string_error(self): - # When + # WHEN energy = 2.0 T = 100 energy_unit = 5 - # Then Expect + # THEN EXPECT with pytest.raises(TypeError, match=r'energy_unit must be a string.'): detailed_balance_factor(energy, T, energy_unit=energy_unit) @pytest.mark.parametrize('temperature_unit', [5, 5.0, {}, []]) def test_temperature_unit_not_string_error(self, temperature_unit): - # When + # WHEN energy = 2.0 T = 100 - # Then Expect + # THEN EXPECT with pytest.raises(TypeError, match=r'temperature_unit must be a string.'): detailed_balance_factor(energy, T, temperature_unit=temperature_unit) def test_divide_by_temperature_not_bool_error(self): - # When + # WHEN energy = 2.0 T = 100 divide_by_temperature = 'yes' - # Then Expect + # THEN EXPECT with pytest.raises(TypeError, match=r'divide_by_temperature must be True or False.'): detailed_balance_factor(energy, T, divide_by_temperature=divide_by_temperature) @@ -61,11 +61,11 @@ def test_divide_by_temperature_not_bool_error(self): ], ) def test_energy_inputs(self, energy): - # When + # WHEN T = 100 - # Then + # THEN result = detailed_balance_factor(energy, T) - # Expect + # EXPECT if isinstance(energy, (np.ndarray)): energy_array = energy elif isinstance(energy, list): @@ -80,12 +80,12 @@ def test_energy_inputs(self, energy): np.testing.assert_allclose(result, expected, rtol=1e-5) def test_scipp_variable_input(self): - # When + # WHEN energy = sc.array(dims=['x'], values=[1.0, 2.0, 3.0], unit='meV') T = sc.scalar(value=100, unit='K') - # Then + # THEN result = detailed_balance_factor(energy, T) - # Expect + # EXPECT expected_values = ( np.array([1.0, 2.0, 3.0]) / (1 - np.exp(-np.array([1.0, 2.0, 3.0]) / (kB_meV_per_K * 100))) @@ -96,13 +96,58 @@ def test_scipp_variable_input(self): assert result.shape == (3,) np.testing.assert_allclose(result, expected_values, rtol=1e-5) + def test_dataarray_energy_input(self): + # WHEN + energy_values = np.array([1.0, 2.0, 3.0]) + data_array = sc.DataArray( + data=sc.array(dims=['energy'], values=np.zeros_like(energy_values)), + coords={ + 'energy': sc.array(dims=['energy'], values=energy_values, unit='meV'), + }, + ) + + # THEN + result = detailed_balance_factor(data_array, 100) + + # EXPECT the DataArray's single coordinate is used as the energy axis + expected = detailed_balance_factor(energy_values, 100) + np.testing.assert_allclose(result, expected) + + def test_dataarray_energy_with_multiple_coords_raises(self): + # WHEN + values = np.array([1.0, 2.0, 3.0]) + data_array = sc.DataArray( + data=sc.array(dims=['energy'], values=np.zeros_like(values)), + coords={ + 'energy': sc.array(dims=['energy'], values=values, unit='meV'), + 'other': sc.array(dims=['energy'], values=values, unit='meV'), + }, + ) + + # THEN EXPECT + with pytest.raises(ValueError, match='exactly one coordinate'): + detailed_balance_factor(data_array, 100) + + def test_two_dimensional_energy_raises(self): + # WHEN THEN EXPECT the documented ValueError, not a scipp DimensionError + with pytest.raises(ValueError, match='at most one-dimensional'): + detailed_balance_factor(np.ones((2, 2)), 100) + + def test_non_scalar_temperature_raises(self): + # WHEN + temperature = sc.array(dims=['temperature'], values=[100.0, 200.0], unit='K') + + # THEN EXPECT a clear error instead of a failure on `.value` + with pytest.raises(ValueError, match='temperature must be a single scalar value'): + detailed_balance_factor(np.array([1.0]), temperature) + def test_parameter_temperature(self): - # When + # WHEN energy = np.array([1.0, 2.0, 3.0]) T_param = Parameter(name='T', value=150, unit='K') - # Then + # THEN result = detailed_balance_factor(energy, T_param) - # Expect + # EXPECT expected = energy / (1 - np.exp(-energy / (kB_meV_per_K * 150))) / (kB_meV_per_K * 150) assert isinstance(result, np.ndarray) @@ -111,76 +156,76 @@ def test_parameter_temperature(self): # Physical edge cases def test_zero_temperature(self): - # When + # WHEN temperature = 0 energy = np.array([-1.0, 0.0, 1.0]) - # Then + # THEN result = detailed_balance_factor(energy, temperature, divide_by_temperature=False) - # Expect + # EXPECT expected = np.maximum(energy, 0.0) np.testing.assert_array_equal(result, expected) def test_zero_temperature_divide_by_T_error(self): - # When + # WHEN temperature = 0 energy = np.array([-1.0, 0.0, 1.0]) - # Then Expect + # THEN EXPECT with pytest.raises(ZeroDivisionError, match='Cannot divide by T when T = 0'): detailed_balance_factor(energy, temperature, divide_by_temperature=True) def test_zero_temperature_single_value(self): - # When + # WHEN temperature = 0 energy = 2.0 - # Then + # THEN result = detailed_balance_factor(energy, temperature, divide_by_temperature=False) - # Expect + # EXPECT expected = 2.0 assert result == expected def test_negative_temperature_raises(self): - # When Then Expect + # WHEN THEN EXPECT with pytest.raises(ValueError, match='Temperature must be non-negative'): detailed_balance_factor(1.0, -10) # Numerical tests def test_small_energy_limit(self): - # When + # WHEN T = 300 energy = np.array([1e-5, 1e-6, 1e-7, 1e-8, 1e-9]) - # Then + # THEN result = detailed_balance_factor(energy=energy, temperature=T, divide_by_temperature=False) - # Expect + # EXPECT x = energy / (kB_meV_per_K * T) expected = (1 + x / 2 + x**2 / 12) * (kB_meV_per_K * T) np.testing.assert_allclose(result, expected, rtol=1e-5) def test_large_energy_limit(self): - # When + # WHEN energy = np.linspace(1e2, 1e3, 5) T = 1 - # Then + # THEN result = detailed_balance_factor(energy=energy, temperature=T, divide_by_temperature=False) - # Expect + # EXPECT np.testing.assert_allclose(result, energy, atol=1e-10) def test_intermediate_energy(self): - # When + # WHEN energy = np.linspace(1, 10, 100) T = 100 - # Then + # THEN result = detailed_balance_factor(energy=energy, temperature=T, divide_by_temperature=False) - # Expect + # EXPECT expected = energy / (1 - np.exp(-energy / (kB_meV_per_K * T))) np.testing.assert_allclose(result, expected, rtol=1e-5) @pytest.mark.parametrize('divide_by_T', [True, False]) def test_detailed_balance_is_fulfilled(self, divide_by_T): # Detailed balance means DBF(E)/DBF(-E) = exp(E/(kB*T)) - # When + # WHEN T = 10 energy = np.linspace(0.01, 100, 101) - # Then + # THEN detailed_balance_positive = detailed_balance_factor( energy=energy, temperature=T, divide_by_temperature=divide_by_T ) @@ -189,7 +234,7 @@ def test_detailed_balance_is_fulfilled(self, divide_by_T): ) ratio = detailed_balance_positive / detailed_balance_negative - # Expect + # EXPECT expected_ratio = np.exp(energy / (kB_meV_per_K * T)) np.testing.assert_allclose(ratio, expected_ratio, rtol=1e-5) @@ -197,27 +242,27 @@ def test_detailed_balance_is_fulfilled(self, divide_by_T): 'energy_unit', ['microeV', sc.Unit('microeV')], ids=['str', 'scipp.Unit'] ) def test_energy_unit(self, energy_unit): - # When + # WHEN energy = np.linspace(1e3, 10 * 1e3, 100) T = 100 - # Then + # THEN result = detailed_balance_factor( energy=energy, temperature=T, divide_by_temperature=False, energy_unit=energy_unit, ) - # Expect + # EXPECT expected = energy / (1 - np.exp(-energy / 1000 / (kB_meV_per_K * T))) np.testing.assert_allclose(result, expected, rtol=1e-5) def test_energy_unit_warning(self): - # When + # WHEN energy = sc.linspace('energy', 1e3, 10 * 1e3, num=100, unit='microeV') energy_unit = 'meV' T = 100 - # Then + # THEN with pytest.warns( UserWarning, match='Input energy has unit [µμ]eV, but energy_unit was set to meV. Using [µμ]eV.', @@ -228,33 +273,33 @@ def test_energy_unit_warning(self): divide_by_temperature=False, energy_unit=energy_unit, ) - # Expect + # EXPECT expected = energy.values / (1 - np.exp(-energy.values / 1000 / (kB_meV_per_K * T))) np.testing.assert_allclose(result, expected, rtol=1e-5) @pytest.mark.parametrize('temperature_unit', ['mK', sc.Unit('mK')], ids=['str', 'scipp.Unit']) def test_temperature_unit(self, temperature_unit): - # When + # WHEN energy = np.linspace(1, 10, 100) temperature = 100 * 1000 temperature_unit = 'mK' - # Then + # THEN result = detailed_balance_factor( energy=energy, temperature=temperature, temperature_unit=temperature_unit, divide_by_temperature=False, ) - # Expect + # EXPECT expected = energy / (1 - np.exp(-energy / (kB_meV_per_K * temperature / 1000))) np.testing.assert_allclose(result, expected, rtol=1e-5) def test_temperature_unit_warning(self): - # When + # WHEN energy = np.linspace(1, 10, 100) temperature = sc.scalar(value=100, unit='mK') temperature_unit = 'K' - # Then + # THEN with pytest.warns( UserWarning, match='Input temperature has unit mK, but temperature_unit was set to K. Using mK.', @@ -265,18 +310,18 @@ def test_temperature_unit_warning(self): temperature_unit=temperature_unit, divide_by_temperature=False, ) - # Expect + # EXPECT expected = energy / (1 - np.exp(-energy / (kB_meV_per_K * 0.1))) np.testing.assert_allclose(result, expected, rtol=1e-5) def test_incompatible_energy_unit_raises(self): - # When + # WHEN energy = 2.0 T = 100 energy_unit = 'm' temperature_unit = 'K' - # Then Expect + # THEN EXPECT with pytest.raises( UnitError, match='The unit of energy is wrong', @@ -289,13 +334,13 @@ def test_incompatible_energy_unit_raises(self): ) def test_incompatible_temperature_unit_raises(self): - # When + # WHEN energy = 2.0 T = 100 energy_unit = 'meV' temperature_unit = 's' - # Then Expect + # THEN EXPECT with pytest.raises( UnitError, match='The unit of temperature is wrong', diff --git a/tests/unit/easydynamics/utils/test_fit_target.py b/tests/unit/easydynamics/utils/test_fit_target.py index bde22cb33..8c02658f7 100644 --- a/tests/unit/easydynamics/utils/test_fit_target.py +++ b/tests/unit/easydynamics/utils/test_fit_target.py @@ -8,51 +8,50 @@ from easydynamics.utils.fit_target import FitTarget -def test_fit_target_holds_prediction_metadata(): - # WHEN a FitTarget is created - target = FitTarget( - name='width', - dataset_key='Lorentzian width', - function=lambda x: x * 2, - label='DeltaLorentz width', - x_unit='1/angstrom', - y_unit='meV', - ) - # EXPECT its attributes to be preserved and the function callable - assert target.name == 'width' - assert target.dataset_key == 'Lorentzian width' - assert target.function(3) == 6 - assert target.label == 'DeltaLorentz width' - assert target.x_unit == '1/angstrom' - assert target.y_unit == 'meV' - - -def test_fit_target_allows_none_key_and_units(): - # WHEN a component-style FitTarget without a default key/units is created - target = FitTarget( - name='value', - dataset_key=None, - function=lambda x: x, - label='value', - x_unit=None, - y_unit=None, - ) - # EXPECT the optional fields to be None - assert target.dataset_key is None - assert target.x_unit is None - assert target.y_unit is None - - -def test_fit_target_is_frozen(): - # GIVEN a FitTarget - target = FitTarget( - name='value', - dataset_key=None, - function=lambda x: x, - label='value', - x_unit=None, - y_unit=None, - ) - # WHEN THEN EXPECT: it is immutable - with pytest.raises(FrozenInstanceError): - target.name = 'other' +class TestFitTarget: + def test_fit_target_holds_prediction_metadata(self): + # WHEN a FitTarget is created + target = FitTarget( + name='width', + dataset_key='Lorentzian width', + function=lambda x: x * 2, + label='DeltaLorentz width', + x_unit='1/angstrom', + y_unit='meV', + ) + # EXPECT its attributes to be preserved and the function callable + assert target.name == 'width' + assert target.dataset_key == 'Lorentzian width' + assert target.function(3) == 6 + assert target.label == 'DeltaLorentz width' + assert target.x_unit == '1/angstrom' + assert target.y_unit == 'meV' + + def test_fit_target_allows_none_key_and_units(self): + # WHEN a component-style FitTarget without a default key/units is created + target = FitTarget( + name='value', + dataset_key=None, + function=lambda x: x, + label='value', + x_unit=None, + y_unit=None, + ) + # EXPECT the optional fields to be None + assert target.dataset_key is None + assert target.x_unit is None + assert target.y_unit is None + + def test_fit_target_is_frozen(self): + # WHEN + target = FitTarget( + name='value', + dataset_key=None, + function=lambda x: x, + label='value', + x_unit=None, + y_unit=None, + ) + # THEN EXPECT: it is immutable + with pytest.raises(FrozenInstanceError): + target.name = 'other' diff --git a/tests/unit/easydynamics/utils/test_posterior_plotting.py b/tests/unit/easydynamics/utils/test_posterior_plotting.py index 1bb3bc4c3..892f1b6b2 100644 --- a/tests/unit/easydynamics/utils/test_posterior_plotting.py +++ b/tests/unit/easydynamics/utils/test_posterior_plotting.py @@ -162,6 +162,40 @@ def test_columns_share_limits_between_histogram_and_hexbin_panels(self, draws): column_limits = [grid[row, col].get_xlim() for row in range(col, 3)] assert all(limits == pytest.approx(column_limits[0]) for limits in column_limits) + ############# + # Scientific notation + ############# + + def test_shared_exponent_is_folded_into_the_label(self): + # WHEN the values are small enough that matplotlib factors out an exponent, which it parks + # on top of the axis label + draws = np.random.default_rng(0).normal(size=(200, 2)) * 1e-8 + 1.15e-8 + + # THEN + fig = plot_corner(draws=draws, names=['D', 'scale'], units=['m^2/s', '']) + + # EXPECT the exponent and the unit share one parenthetical, and the overlapping offset + # text is hidden + xlabel = fig.axes[-2].get_xlabel() + assert xlabel.startswith('D (1e') + assert 'm^2/s' in xlabel + assert not fig.axes[-2].xaxis.get_offset_text().get_visible() + + def test_shared_exponent_is_folded_into_the_y_label_too(self): + # WHEN the values are small enough that the left column's y axes also factor out an + # exponent + draws = np.random.default_rng(0).normal(size=(200, 2)) * 1e-8 + 1.15e-8 + + # THEN + fig = plot_corner(draws=draws, names=['D', 'scale'], units=['m^2/s', '']) + + # EXPECT the hexbin panel in the left column folds the exponent into its y label and + # hides the overlapping offset text + axis = fig.axes[2] + ylabel = axis.get_ylabel() + assert ylabel.startswith('scale (1e') + assert not axis.yaxis.get_offset_text().get_visible() + class TestPlotMarginal: @pytest.fixture @@ -578,20 +612,3 @@ def test_invalid_credible_interval_raises(self, arrays, interval): # THEN EXPECT with pytest.raises(ValueError, match='credible_interval'): predictive_with_slider(**arrays, credible_interval=interval) - - -class TestScientificNotation: - def test_shared_exponent_is_folded_into_the_label(self): - # WHEN the values are small enough that matplotlib factors out an exponent, which it parks - # on top of the axis label - draws = np.random.default_rng(0).normal(size=(200, 2)) * 1e-8 + 1.15e-8 - - # THEN - fig = plot_corner(draws=draws, names=['D', 'scale'], units=['m^2/s', '']) - - # EXPECT the exponent and the unit share one parenthetical, and the overlapping offset - # text is hidden - xlabel = fig.axes[-2].get_xlabel() - assert xlabel.startswith('D (1e') - assert 'm^2/s' in xlabel - assert not fig.axes[-2].xaxis.get_offset_text().get_visible() diff --git a/tests/unit/easydynamics/utils/test_utils.py b/tests/unit/easydynamics/utils/test_utils.py index a0bcaef97..023c4cfc5 100644 --- a/tests/unit/easydynamics/utils/test_utils.py +++ b/tests/unit/easydynamics/utils/test_utils.py @@ -54,6 +54,22 @@ def test_upper_bound_deferred_when_Q_is_none(self): # THEN EXPECT: a non-negative index is accepted; the bound check is deferred verify_Q_index(100, None) + @pytest.mark.parametrize('bool_index', [True, False], ids=['True', 'False']) + def test_bool_raises(self, bool_index): + # WHEN THEN EXPECT: bools are ints in Python, but Q_index=True must not mean index 1 + with pytest.raises(TypeError, match='Q_index must be an int'): + verify_Q_index(bool_index, None) + + def test_bool_raises_even_when_none_is_allowed(self): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match='Q_index must be an int or None'): + verify_Q_index(True, None, allow_none=True) + + def test_allow_none_rejects_non_int(self): + # WHEN THEN EXPECT: a non-int, non-None Q_index is rejected even when None is allowed + with pytest.raises(TypeError, match=r'Q_index must be an int or None'): + verify_Q_index('not an int', Q=None, allow_none=True) + class TestConvertValueUnit: def test_same_unit_returns_value_unchanged(self): @@ -302,40 +318,37 @@ def raise_import_error(*args, **kwargs): # ruff: ignore[unused-function-argumen assert _in_notebook() is False -def test_verify_Q_index_allow_none_rejects_non_int(): - # WHEN THEN EXPECT: a non-int, non-None Q_index is rejected even when None is allowed - with pytest.raises(TypeError, match=r'Q_index must be an int or None'): - verify_Q_index('not an int', Q=None, allow_none=True) - +class TestConvertParameterUnit: + def test_dependent_parameter_sets_desired_unit(self): + # WHEN converting the unit of a dependent parameter (cannot be converted directly) + param = Mock() + param.independent = False + convert_parameter_unit(param, 'meV') -def test_convert_parameter_unit_dependent_sets_desired_unit(): - # GIVEN a dependent parameter (cannot be converted directly) - param = Mock() - param.independent = False - # WHEN converting its unit - convert_parameter_unit(param, 'meV') - # EXPECT the desired unit is recorded instead of an in-place conversion - param.set_desired_unit.assert_called_once_with('meV') - param.convert_unit.assert_not_called() + # EXPECT the desired unit is recorded instead of an in-place conversion + param.set_desired_unit.assert_called_once_with('meV') + param.convert_unit.assert_not_called() -def test_energy_to_scipp_returns_variable_with_unit(): - # WHEN converting a numpy energy array - result = energy_to_scipp(np.array([1.0, 2.0, 3.0]), 'meV') - # EXPECT a scipp Variable on the 'energy' dimension with the given unit - assert isinstance(result, sc.Variable) - assert result.unit == sc.Unit('meV') - assert result.dims == ('energy',) - np.testing.assert_allclose(result.values, [1.0, 2.0, 3.0]) +class TestEnergyToScipp: + def test_returns_variable_with_unit(self): + # THEN + result = energy_to_scipp(np.array([1.0, 2.0, 3.0]), 'meV') + # EXPECT a scipp Variable on the 'energy' dimension with the given unit + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('meV') + assert result.dims == ('energy',) + np.testing.assert_allclose(result.values, [1.0, 2.0, 3.0]) -def test_assert_valid_unit_rejects_non_unit_type(): - # WHEN THEN EXPECT - with pytest.raises(TypeError, match=r'unit must be a string or sc.Unit'): - _assert_valid_unit(123) +class TestAssertValidUnit: + def test_rejects_non_unit_type(self): + # THEN EXPECT + with pytest.raises(TypeError, match=r'unit must be a string or sc.Unit'): + _assert_valid_unit(123) -def test_assert_valid_unit_rejects_invalid_unit_string(): - # WHEN THEN EXPECT - with pytest.raises(ValueError, match=r'is not a valid scipp unit'): - _assert_valid_unit('not_a_real_unit') + def test_rejects_invalid_unit_string(self): + # THEN EXPECT + with pytest.raises(ValueError, match=r'is not a valid scipp unit'): + _assert_valid_unit('not_a_real_unit') diff --git a/tools/prefetch_tutorial_data.py b/tools/prefetch_tutorial_data.py index 839b1897a..12ebc8b84 100644 --- a/tools/prefetch_tutorial_data.py +++ b/tools/prefetch_tutorial_data.py @@ -23,8 +23,12 @@ TUTORIALS = Path(__file__).resolve().parent.parent / 'docs' / 'docs' / 'tutorials' -# Matches the pooch.retrieve(url=..., known_hash=...) calls the notebooks use, in either order. -URL_PATTERN = re.compile(r"url\s*=\s*f?['\"]([^'\"]+)['\"]") +# Matches one pooch.retrieve(...) call site; the notebooks keep these calls free of nested +# parentheses, so everything up to the first closing parenthesis is the argument list. +RETRIEVE_PATTERN = re.compile(r'pooch\.retrieve\s*\(([^)]*)\)') +# Matches the url=... and known_hash=... keyword arguments inside a single call. The optional +# ``f`` prefix on the URL is captured so templated URLs can be recognised and skipped. +URL_PATTERN = re.compile(r"url\s*=\s*(f?)['\"]([^'\"]+)['\"]") HASH_PATTERN = re.compile(r"known_hash\s*=\s*['\"]([^'\"]+)['\"]") @@ -32,26 +36,32 @@ def find_downloads() -> dict[str, str]: """ Collect the ``(url, known_hash)`` pairs the notebooks fetch. + Each ``pooch.retrieve(...)`` call site is parsed on its own, so a cell with several calls + cannot pair one call's URL with another call's hash. Calls whose URL is an f-string, or that + lack a literal ``known_hash``, are skipped rather than guessed at; the notebook will simply + fetch those itself. + Returns ------- dict[str, str] Mapping of URL to expected hash, deduplicated across notebooks. """ downloads: dict[str, str] = {} - for notebook in sorted(TUTORIALS.glob('*.ipynb')): + for notebook in sorted(TUTORIALS.rglob('*.ipynb')): + if '.ipynb_checkpoints' in notebook.parts: + continue cells = json.loads(notebook.read_text(encoding='utf-8'))['cells'] for cell in cells: if cell['cell_type'] != 'code': continue source = ''.join(cell['source']) - if 'pooch.retrieve' not in source: - continue - urls = URL_PATTERN.findall(source) - hashes = HASH_PATTERN.findall(source) - # Only pairs are usable; a templated URL without a literal hash is skipped rather than - # guessed at, and the notebook will simply fetch it itself. - for url, known_hash in zip(urls, hashes, strict=False): - downloads[url] = known_hash + for call in RETRIEVE_PATTERN.finditer(source): + arguments = call.group(1) + url_match = URL_PATTERN.search(arguments) + hash_match = HASH_PATTERN.search(arguments) + if url_match is None or hash_match is None or url_match.group(1) == 'f': + continue + downloads[url_match.group(2)] = hash_match.group(1) return downloads @@ -78,7 +88,7 @@ def main() -> int: name = url.rsplit('/', 1)[-1] try: pooch.retrieve(url=url, known_hash=known_hash) - except Exception as error: # noqa: BLE001 - report and continue, the notebook will retry + except Exception as error: # ruff: ignore[blind-except] - report and continue, the notebook will retry failures += 1 sys.stdout.write(f'could not prefetch {name}, leaving it to the notebook: {error}\n') else: From f941728b94e7177fb29535c8cd918dca51a11022 Mon Sep 17 00:00:00 2001 From: Henrik Jacobsen Date: Mon, 17 Aug 2026 22:16:54 +0200 Subject: [PATCH 5/5] update pixi (#242) --- pixi.lock | 1747 +++++++++++++++++++++++++++-------------------------- 1 file changed, 874 insertions(+), 873 deletions(-) diff --git a/pixi.lock b/pixi.lock index 772d51ce2..d844f8856 100644 --- a/pixi.lock +++ b/pixi.lock @@ -28,27 +28,27 @@ environments: p1: - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/argon2-cffi-bindings-25.1.0-py314h5bd0f2a_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314h3de4e8d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314hcd2bdb6_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.8-h280c20c_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py314h4a8dc5f_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py314h8d76f0c_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.21-py314h42812f9_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbde042b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-py310h44b86e0_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-h7cc23a3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbc21106_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h7b12aa8_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-h39a168f_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-ha411449_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-h018ffa1_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h373387f_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h280c20c_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.7.0-h3435931_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.22-h280c20c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda @@ -62,7 +62,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-26.6.0-hc039f44_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py314h0f05182_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-h242f9ac_102_cp314.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py314h67df5f8_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hda471dd_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda @@ -71,7 +71,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.8-py314h5bd0f2a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h09e67af_11.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/argon2-cffi-25.1.0-pyhd8ed1ab_0.conda @@ -80,7 +80,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/async-lru-2.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.6.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.7.0-py314h680f03e_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.15.0-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.4.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.4.0-hac0b51c_0.conda @@ -88,9 +88,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_102.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda @@ -121,7 +121,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_events-0.12.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.20.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.5.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_server-2.28.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda @@ -138,7 +138,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.26.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda @@ -147,9 +147,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_101.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_102.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda @@ -188,7 +188,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/12/b3/88c0ef22878c86035f058df0ac6c171319ffd0aa52a406455ed3a3847566/ipympl-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/17/3d/26e14cf47c56c9ba3c3e12cae21f24716bc3182bb52260213ec0c819d0b9/python_engineio-4.13.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl @@ -208,8 +207,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/42/f7/7a3935abdebd5cf18705a5f0335dd6a3a18bef3baa7cb9edc3b6b9922cc8/typing_inspection-0.4.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl @@ -219,13 +219,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/0d/a17e966e620545c1548125af0b29ac812dd17b197a18a7462ac12fa859ee/matplotlib-3.11.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6b/01/f804208061b504894546fddc479f6075e0f00dfe88cb1703dafaa8c3c67e/python_engineio-4.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6b/be/92dd42844fe8a78c2c4a87f8078b9263dcc20aabe86b8420302a6fabaf4a/scipp-26.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/76/bb/32871c9e393f174a60930a29873b6a4217b3f1c65667cad303ef146caedc/chardet-7.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/77/2a/83d779d2dfb61f101d7b1c10073d18984e37262d8b4f171c99911a952430/virtualenv-21.7.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7c/c6/76ee9dacedcd8c67d8fa53dd975613733bdd28242a4c41518ff1c8aeaa64/jupytext-1.19.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl @@ -257,11 +257,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a8/4e/c09876f08fa9faaa5e1178f3d77b7af3f343258689bd6f3b72593b2f74e3/mkdocs_markdownextradata_plugin-0.2.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b2/6d/77546d8c26f038fce314a507106954f76270f6c182488bcf9ac9721175df/mkdocstrings_python-2.0.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/52/bc858b1665d0dec3a2511f4e6f5c18ea85c0977563d624d597c95d6d0fd7/jupyterquiz-2.9.6.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl @@ -272,23 +274,20 @@ environments: - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/69/427c34e61827818942b48ececd7c892b8f58ba4ce4cfc89ba9fd8dbe8a8d/docstring_parser_fork-0.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c6/3d/020a6b6248c3d4a37797db068256f0b3f15b01bc481327ba888c50309aa8/mkdocs_plugin_inline_svg-0.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/27/bdaaf32952f052c15c048fab82d971d30f92b63d54b61486f04a241fa994/plopp-26.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e6/90/90a65e6ae1b6e66183b48874d32509fc306c994beecc7924a6fa3d9f8955/easyscience-2.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl @@ -299,11 +298,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f4/88/360064c4c7d9d0664561dae03b74c871d2f5332b329f5c99f1c997fb869a/chardet-7.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f4/a4/61adb19f3c74b0dc0e411de4f06ebef564b1f179928f9dffcbd4b378f2ef/jupyter_notebook_parser-0.1.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f6/b6/f65ac785d4ac90dcf7c831ac6256f5dd4a19780f4e1575b2c0d6eeebe319/griffelib-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl @@ -319,7 +319,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/async-lru-2.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.6.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.7.0-py314h680f03e_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.15.0-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.4.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.4.0-hac0b51c_0.conda @@ -327,9 +327,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_102.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda @@ -360,7 +360,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_events-0.12.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.20.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.5.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_server-2.28.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda @@ -377,7 +377,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.26.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda @@ -386,9 +386,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_101.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_102.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda @@ -419,24 +419,24 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-25.1.0-py314h0612a62_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py314h3daef5d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py314hee34562_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.8-h1a92334_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.1.1-py314h7bede21_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.1.1-py314h618e29d_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.21-py314he609de1_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hc7cc350_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-hfd3d5f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-py310h579977c_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-h34f8a20_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h2062a1b_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-h1dcdb26_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-h5295a6a_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-h2ddc9cb_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321h26f1114_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h1a92334_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.7.0-hcf2aa1b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h8f3e76b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.22-h1a92334_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda @@ -448,9 +448,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.6.0-h00e74ec_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py314ha14b1ff_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.1-py314h6590101_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.2.1-py314ha06c032_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-h156bc91_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.2-py314h63b12ec_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.2.2-py314hddd3963_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-hf4d206d_102_cp314.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.3-py314h6e9b3f0_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py312h022ad19_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda @@ -459,7 +459,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.8-py314h6c2aa35_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h10816f8_11.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl @@ -471,7 +471,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/12/b3/88c0ef22878c86035f058df0ac6c171319ffd0aa52a406455ed3a3847566/ipympl-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/17/3d/26e14cf47c56c9ba3c3e12cae21f24716bc3182bb52260213ec0c819d0b9/python_engineio-4.13.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl @@ -485,15 +484,16 @@ environments: - pypi: https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/06/f4f9f5b55219128cd61b86528674d2b268a12549ee10d0626b3feef3ba89/scipp-26.8.0-cp314-cp314-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/38/7e/7b91c89a4cf0f543a83be978657afb20c86af6d725253e319589dcc4ce52/lmfit-1.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/42/f7/7a3935abdebd5cf18705a5f0335dd6a3a18bef3baa7cb9edc3b6b9922cc8/typing_inspection-0.4.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/53/a2/69acfe84ec1f32930e801a5782a07fc5c79c8c6599a507b806d859d5da8e/matplotlib-3.11.1-cp314-cp314-macosx_11_0_arm64.whl @@ -505,19 +505,20 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/b7/9366ed44ced9b7ef357ab48c94205280276db9d7f064aa3012a97227e966/h5py-3.16.0-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/6b/01/f804208061b504894546fddc479f6075e0f00dfe88cb1703dafaa8c3c67e/python_engineio-4.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/77/2a/83d779d2dfb61f101d7b1c10073d18984e37262d8b4f171c99911a952430/virtualenv-21.7.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7a/40/0f95e04cb1820e0a582cd6d86bbf26be8302a94ccf330f8ba5f69735389d/chardet-7.6.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7c/c6/76ee9dacedcd8c67d8fa53dd975613733bdd28242a4c41518ff1c8aeaa64/jupytext-1.19.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/bf/3adcb9b3091b36de729dad91c107179c8c7c51adb2b08c31177bb540bef1/copier-9.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/80/44/e002bad11c7c9dc293141395bb2652f2c45a3dcac737c8385a1088ccbafe/format_docstring-0.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/f5/2f77f0bc663c13371d1c00ab8e550e2c9b11fec3c63ebf12a8336c0f534e/bumps-1.0.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/85/84/5690a64afecf9967c3844ec96842d549e6f3ef72009bfd5524b69111245a/chardet-7.5.1-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/85/d7/9b6ac05350ab7f7d3a730ff143ff3e2cada54514117c37be37e26dc91242/docstripy-0.7.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl @@ -541,11 +542,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a8/4e/c09876f08fa9faaa5e1178f3d77b7af3f343258689bd6f3b72593b2f74e3/mkdocs_markdownextradata_plugin-0.2.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b2/6d/77546d8c26f038fce314a507106954f76270f6c182488bcf9ac9721175df/mkdocstrings_python-2.0.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/52/bc858b1665d0dec3a2511f4e6f5c18ea85c0977563d624d597c95d6d0fd7/jupyterquiz-2.9.6.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl @@ -556,7 +559,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/69/427c34e61827818942b48ececd7c892b8f58ba4ce4cfc89ba9fd8dbe8a8d/docstring_parser_fork-0.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c6/3d/020a6b6248c3d4a37797db068256f0b3f15b01bc481327ba888c50309aa8/mkdocs_plugin_inline_svg-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl @@ -564,14 +566,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/27/bdaaf32952f052c15c048fab82d971d30f92b63d54b61486f04a241fa994/plopp-26.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz - - pypi: https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e6/90/90a65e6ae1b6e66183b48874d32509fc306c994beecc7924a6fa3d9f8955/easyscience-2.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/be/b257e12f9710819fde40adc972578bee6b72c5992da1bc8369bef2597756/nbmake-1.5.5-py3-none-any.whl @@ -582,9 +582,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/a4/61adb19f3c74b0dc0e411de4f06ebef564b1f179928f9dffcbd4b378f2ef/jupyter_notebook_parser-0.1.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f6/b6/f65ac785d4ac90dcf7c831ac6256f5dd4a19780f4e1575b2c0d6eeebe319/griffelib-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl @@ -599,7 +599,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/async-lru-2.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.6.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.7.0-py314h680f03e_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.15.0-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.4.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.4.0-hac0b51c_0.conda @@ -607,10 +607,10 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_102.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda @@ -641,7 +641,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_events-0.12.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.20.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.5.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_server-2.28.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda @@ -657,7 +657,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pandocfilters-1.5.0-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.26.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda @@ -665,9 +665,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyh09c184e_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_101.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_102.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda @@ -698,15 +698,15 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py314h5a2d7ad_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py314he701e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py314h85cf176_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_10.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py314h5a2d7ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py314h5a2d7ad_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.21-py314hb98de8c_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.7.0-h3d046cb_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.22-h6a83c73_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda @@ -715,7 +715,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-26.6.0-h80d1838_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py314hc5dbbe4_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.6-h4b44e0e_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.6-h53f6dd8_102_cp314.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-312-py314hcaaf0b2_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-3.0.5-py314h51f0985_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py314h2359020_1.conda @@ -730,9 +730,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/winpty-0.4.3-4.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h3a581c9_11.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - pypi: ./ - - pypi: https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl @@ -743,7 +742,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/17/3d/26e14cf47c56c9ba3c3e12cae21f24716bc3182bb52260213ec0c819d0b9/python_engineio-4.13.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl @@ -761,8 +759,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/3e/14/615a450205e1b56d16c6783f5ccd116cde05550faad70ae077c955654a75/h5py-3.16.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/42/f7/7a3935abdebd5cf18705a5f0335dd6a3a18bef3baa7cb9edc3b6b9922cc8/typing_inspection-0.4.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl @@ -772,12 +771,14 @@ environments: - pypi: https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6b/01/f804208061b504894546fddc479f6075e0f00dfe88cb1703dafaa8c3c67e/python_engineio-4.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/77/2a/83d779d2dfb61f101d7b1c10073d18984e37262d8b4f171c99911a952430/virtualenv-21.7.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7c/a9/ff4fef15ed25fc3f945a3b981ae0f43c8559b3fbedb40267e59e583d105b/chardet-7.6.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/7c/c6/76ee9dacedcd8c67d8fa53dd975613733bdd28242a4c41518ff1c8aeaa64/jupytext-1.19.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl @@ -808,10 +809,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a8/4e/c09876f08fa9faaa5e1178f3d77b7af3f343258689bd6f3b72593b2f74e3/mkdocs_markdownextradata_plugin-0.2.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b2/6d/77546d8c26f038fce314a507106954f76270f6c182488bcf9ac9721175df/mkdocstrings_python-2.0.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/52/bc858b1665d0dec3a2511f4e6f5c18ea85c0977563d624d597c95d6d0fd7/jupyterquiz-2.9.6.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl @@ -821,7 +825,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/69/427c34e61827818942b48ececd7c892b8f58ba4ce4cfc89ba9fd8dbe8a8d/docstring_parser_fork-0.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/c6/3d/020a6b6248c3d4a37797db068256f0b3f15b01bc481327ba888c50309aa8/mkdocs_plugin_inline_svg-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl @@ -830,7 +833,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/ce/04/d719a0a36930ecc8dfc801ff340f9dcfc4223f8ca5d39d06b4020032fff8/matplotlib-3.11.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/ce/27/bdaaf32952f052c15c048fab82d971d30f92b63d54b61486f04a241fa994/plopp-26.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl @@ -838,10 +840,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e6/90/90a65e6ae1b6e66183b48874d32509fc306c994beecc7924a6fa3d9f8955/easyscience-2.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e9/8f/d871b357287caae0483d2cd235fae476da3768dd7d56e1fe733ffd3f707c/chardet-7.5.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/eb/be/b257e12f9710819fde40adc972578bee6b72c5992da1bc8369bef2597756/nbmake-1.5.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ed/90/8e7ce41fc38f53d855c03f007df81297563b777f80c5d12a5c48f0455a22/scipp-26.8.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl @@ -853,9 +853,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/a4/61adb19f3c74b0dc0e411de4f06ebef564b1f179928f9dffcbd4b378f2ef/jupyter_notebook_parser-0.1.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/f6/b6/f65ac785d4ac90dcf7c831ac6256f5dd4a19780f4e1575b2c0d6eeebe319/griffelib-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl @@ -871,24 +871,24 @@ environments: p1: - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/argon2-cffi-bindings-25.1.0-py312h4c3975b_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/backports.zstd-1.6.0-py312h90b7ffd_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py312hdb49522_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/backports.zstd-1.7.0-py312h3f22e6b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py312he9c40d5_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.8-h280c20c_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py312h460c074_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py312h703531f_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.21-py312h8285ef7_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbde042b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-py310h44b86e0_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-h7cc23a3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbc21106_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h7b12aa8_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-h39a168f_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-ha411449_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-h018ffa1_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h373387f_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h280c20c_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.7.0-h3435931_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda @@ -907,7 +907,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-26.6.0-hc039f44_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py312h5253ce2_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-h8ab3286_1_cpython.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py312h8a5da7c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hda471dd_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda @@ -916,7 +916,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.8-py312h4c3975b_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h09e67af_11.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/argon2-cffi-25.1.0-pyhd8ed1ab_0.conda @@ -932,9 +932,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.13-py312hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.13-py312hd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda @@ -965,7 +965,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_events-0.12.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.20.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.5.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_server-2.28.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda @@ -982,7 +982,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.26.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda @@ -991,9 +991,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.12.13-hd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.12.13-hd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda @@ -1026,7 +1026,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/10/56/89866e9995fdb2c8e8ff1336c4ecd4c86ba0f7e4622ccfacad2c13b2ba7e/chardet-7.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl @@ -1034,7 +1033,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/17/3d/26e14cf47c56c9ba3c3e12cae21f24716bc3182bb52260213ec0c819d0b9/python_engineio-4.13.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/20/16/e777eadfa0c0305878c36fae1d5e6db474fbb15dae202b9ec378809dfb4d/nbstripout-0.9.1-py3-none-any.whl @@ -1052,8 +1050,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/42/f7/7a3935abdebd5cf18705a5f0335dd6a3a18bef3baa7cb9edc3b6b9922cc8/typing_inspection-0.4.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl @@ -1063,16 +1062,18 @@ environments: - pypi: https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/6b/01/f804208061b504894546fddc479f6075e0f00dfe88cb1703dafaa8c3c67e/python_engineio-4.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/77/2a/83d779d2dfb61f101d7b1c10073d18984e37262d8b4f171c99911a952430/virtualenv-21.7.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7c/c6/76ee9dacedcd8c67d8fa53dd975613733bdd28242a4c41518ff1c8aeaa64/jupytext-1.19.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7d/a2/c4d99299e9ce7fad561f8bb56babbbbdd3bb6b4fbd7c0ec674c1dbdd2cc5/chardet-7.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/bf/3adcb9b3091b36de729dad91c107179c8c7c51adb2b08c31177bb540bef1/copier-9.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/80/44/e002bad11c7c9dc293141395bb2652f2c45a3dcac737c8385a1088ccbafe/format_docstring-0.4.0-py3-none-any.whl @@ -1101,10 +1102,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a8/4e/c09876f08fa9faaa5e1178f3d77b7af3f343258689bd6f3b72593b2f74e3/mkdocs_markdownextradata_plugin-0.2.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b2/6d/77546d8c26f038fce314a507106954f76270f6c182488bcf9ac9721175df/mkdocstrings_python-2.0.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/52/bc858b1665d0dec3a2511f4e6f5c18ea85c0977563d624d597c95d6d0fd7/jupyterquiz-2.9.6.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl @@ -1115,10 +1118,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/69/427c34e61827818942b48ececd7c892b8f58ba4ce4cfc89ba9fd8dbe8a8d/docstring_parser_fork-0.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c6/3d/020a6b6248c3d4a37797db068256f0b3f15b01bc481327ba888c50309aa8/mkdocs_plugin_inline_svg-0.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/cf/b4ad2cc81b6672ea29ea04e64e350a9f9b493b0908ccd884c67eeff8f7b2/matplotlib-3.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl @@ -1126,7 +1128,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ce/27/bdaaf32952f052c15c048fab82d971d30f92b63d54b61486f04a241fa994/plopp-26.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl @@ -1135,7 +1136,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - pypi: 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 - pypi: https://files.pythonhosted.org/packages/e1/b5/3a92142b4f3f476e4e25206aee760a5aee0d2fc31f6226d2ae2e2e869742/scipp-26.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e6/90/90a65e6ae1b6e66183b48874d32509fc306c994beecc7924a6fa3d9f8955/easyscience-2.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/be/b257e12f9710819fde40adc972578bee6b72c5992da1bc8369bef2597756/nbmake-1.5.5-py3-none-any.whl @@ -1146,9 +1146,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f4/a4/61adb19f3c74b0dc0e411de4f06ebef564b1f179928f9dffcbd4b378f2ef/jupyter_notebook_parser-0.1.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f6/b6/f65ac785d4ac90dcf7c831ac6256f5dd4a19780f4e1575b2c0d6eeebe319/griffelib-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl @@ -1170,9 +1170,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.13-py312hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.13-py312hd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda @@ -1203,7 +1203,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_events-0.12.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.20.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.5.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_server-2.28.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda @@ -1220,7 +1220,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.26.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda @@ -1229,9 +1229,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.12.13-hd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.12.13-hd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda @@ -1262,23 +1262,23 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-25.1.0-py312h4409184_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/backports.zstd-1.6.0-py312h87c4bb7_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py312h0dfefe5_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/backports.zstd-1.7.0-py312h1a36842_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py312ha52686f_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.8-h1a92334_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.1.1-py312h652e2b1_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.1.1-py312hc892d8b_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.21-py312h6510ced_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hc7cc350_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-hfd3d5f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-py310h579977c_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-h34f8a20_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h2062a1b_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-h1dcdb26_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-h5295a6a_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-h2ddc9cb_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321h26f1114_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h1a92334_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.7.0-hcf2aa1b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h8f3e76b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.22-h1a92334_1.conda @@ -1291,9 +1291,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.6.0-h00e74ec_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py312hb3ab3e3_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.1-py312h55b240b_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.2.1-py312h22cf174_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.12.13-h8561d8f_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.2-py312hb3d15f4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.2.2-py312h8b921b1_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.12.13-hd1323d7_1_cpython.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.3-py312h04c11ed_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py312h022ad19_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda @@ -1302,7 +1302,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.8-py312h2bbb03f_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h10816f8_11.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl @@ -1313,7 +1313,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/12/b3/88c0ef22878c86035f058df0ac6c171319ffd0aa52a406455ed3a3847566/ipympl-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/17/3d/26e14cf47c56c9ba3c3e12cae21f24716bc3182bb52260213ec0c819d0b9/python_engineio-4.13.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl @@ -1330,14 +1329,16 @@ environments: - pypi: https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/7e/7b91c89a4cf0f543a83be978657afb20c86af6d725253e319589dcc4ce52/lmfit-1.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/42/f7/7a3935abdebd5cf18705a5f0335dd6a3a18bef3baa7cb9edc3b6b9922cc8/typing_inspection-0.4.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/44/99/934fb862d102c8756008597f4398323f32cef329f16e87fbb3bf76d4f4be/chardet-7.6.0-cp312-cp312-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl @@ -1348,13 +1349,14 @@ environments: - pypi: https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/67/c1/80e24e592c87779dd35c1718911479d47526bbc0e1cfc0d20ea88ae94057/scipp-26.8.0-cp312-cp312-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6b/01/f804208061b504894546fddc479f6075e0f00dfe88cb1703dafaa8c3c67e/python_engineio-4.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/77/2a/83d779d2dfb61f101d7b1c10073d18984e37262d8b4f171c99911a952430/virtualenv-21.7.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7c/c6/76ee9dacedcd8c67d8fa53dd975613733bdd28242a4c41518ff1c8aeaa64/jupytext-1.19.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl @@ -1383,12 +1385,14 @@ environments: - pypi: https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a8/4e/c09876f08fa9faaa5e1178f3d77b7af3f343258689bd6f3b72593b2f74e3/mkdocs_markdownextradata_plugin-0.2.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b0/42/c84efcc1d4caebafb1ecd8be4643f39c85c47a80fe254d92b8b43b1eadaf/h5py-3.16.0-cp312-cp312-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/b2/6d/77546d8c26f038fce314a507106954f76270f6c182488bcf9ac9721175df/mkdocstrings_python-2.0.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/52/bc858b1665d0dec3a2511f4e6f5c18ea85c0977563d624d597c95d6d0fd7/jupyterquiz-2.9.6.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl @@ -1398,22 +1402,18 @@ environments: - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/69/427c34e61827818942b48ececd7c892b8f58ba4ce4cfc89ba9fd8dbe8a8d/docstring_parser_fork-0.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c4/8e/847935c588455b0d82fa57a5a8ced4c73a928e30f2012639228e566e3283/chardet-7.5.1-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/c6/3d/020a6b6248c3d4a37797db068256f0b3f15b01bc481327ba888c50309aa8/mkdocs_plugin_inline_svg-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/27/bdaaf32952f052c15c048fab82d971d30f92b63d54b61486f04a241fa994/plopp-26.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e6/90/90a65e6ae1b6e66183b48874d32509fc306c994beecc7924a6fa3d9f8955/easyscience-2.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl @@ -1426,9 +1426,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f4/a4/61adb19f3c74b0dc0e411de4f06ebef564b1f179928f9dffcbd4b378f2ef/jupyter_notebook_parser-0.1.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f6/b6/f65ac785d4ac90dcf7c831ac6256f5dd4a19780f4e1575b2c0d6eeebe319/griffelib-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl @@ -1449,10 +1449,10 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.13-py312hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.13-py312hd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda @@ -1483,7 +1483,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_events-0.12.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.20.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.5.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_server-2.28.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda @@ -1499,7 +1499,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pandocfilters-1.5.0-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.26.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda @@ -1507,9 +1507,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyh09c184e_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.12.13-hd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.12.13-hd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda @@ -1540,14 +1540,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py312he06e257_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/backports.zstd-1.6.0-py312h06d0912_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py312hc6d9e41_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/backports.zstd-1.7.0-py312h06d0912_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py312ha763cb9_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_10.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py312he06e257_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py312he06e257_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.21-py312ha1a9051_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.7.0-h3d046cb_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.22-h6a83c73_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_0.conda @@ -1557,7 +1557,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-26.6.0-h80d1838_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py312he5662c2_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.12.13-h0159041_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.12.13-hb12b558_1_cpython.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-312-py312h829343e_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-3.0.5-py312h275cf98_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py312h05f76fc_1.conda @@ -1572,19 +1572,18 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/winpty-0.4.3-4.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h3a581c9_11.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - pypi: ./ - - pypi: https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/c1/0976b235cf29ead553e22f2fb6385a8252b533715e00d0ae52ed7b900582/h5py-3.16.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0d/53/8da1f4758286efd8faf71356facddb382788ecf1bbd7c70d63e2e18a4898/chardet-7.6.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/88c0ef22878c86035f058df0ac6c171319ffd0aa52a406455ed3a3847566/ipympl-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/17/3d/26e14cf47c56c9ba3c3e12cae21f24716bc3182bb52260213ec0c819d0b9/python_engineio-4.13.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl @@ -1604,10 +1603,11 @@ environments: - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/f6/5e7d38c91b3b104dc455ec2e6e475b83b689ac6623acfbebdefd3be932ad/scipp-26.8.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/42/f7/7a3935abdebd5cf18705a5f0335dd6a3a18bef3baa7cb9edc3b6b9922cc8/typing_inspection-0.4.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl @@ -1618,13 +1618,14 @@ environments: - pypi: https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6b/01/f804208061b504894546fddc479f6075e0f00dfe88cb1703dafaa8c3c67e/python_engineio-4.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/77/2a/83d779d2dfb61f101d7b1c10073d18984e37262d8b4f171c99911a952430/virtualenv-21.7.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7c/c6/76ee9dacedcd8c67d8fa53dd975613733bdd28242a4c41518ff1c8aeaa64/jupytext-1.19.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl @@ -1656,11 +1657,14 @@ environments: - pypi: https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a8/4e/c09876f08fa9faaa5e1178f3d77b7af3f343258689bd6f3b72593b2f74e3/mkdocs_markdownextradata_plugin-0.2.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/b2/6d/77546d8c26f038fce314a507106954f76270f6c182488bcf9ac9721175df/mkdocstrings_python-2.0.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/52/bc858b1665d0dec3a2511f4e6f5c18ea85c0977563d624d597c95d6d0fd7/jupyterquiz-2.9.6.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl @@ -1671,22 +1675,18 @@ environments: - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/69/427c34e61827818942b48ececd7c892b8f58ba4ce4cfc89ba9fd8dbe8a8d/docstring_parser_fork-0.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/c6/3d/020a6b6248c3d4a37797db068256f0b3f15b01bc481327ba888c50309aa8/mkdocs_plugin_inline_svg-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/27/bdaaf32952f052c15c048fab82d971d30f92b63d54b61486f04a241fa994/plopp-26.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/dc/9a/9e17c1c6fbc65f9cba07951d359a24c8f7b17d3ca26bd54f33fd98b70f2e/chardet-7.5.1-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e6/90/90a65e6ae1b6e66183b48874d32509fc306c994beecc7924a6fa3d9f8955/easyscience-2.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/be/b257e12f9710819fde40adc972578bee6b72c5992da1bc8369bef2597756/nbmake-1.5.5-py3-none-any.whl @@ -1696,9 +1696,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/a4/61adb19f3c74b0dc0e411de4f06ebef564b1f179928f9dffcbd4b378f2ef/jupyter_notebook_parser-0.1.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f6/b6/f65ac785d4ac90dcf7c831ac6256f5dd4a19780f4e1575b2c0d6eeebe319/griffelib-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl @@ -1713,27 +1713,27 @@ environments: p1: - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/argon2-cffi-bindings-25.1.0-py314h5bd0f2a_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314h3de4e8d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314hcd2bdb6_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.8-h280c20c_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py314h4a8dc5f_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py314h8d76f0c_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.21-py314h42812f9_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbde042b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-py310h44b86e0_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-h7cc23a3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbc21106_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h7b12aa8_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-h39a168f_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-ha411449_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-h018ffa1_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h373387f_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h280c20c_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.7.0-h3435931_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.22-h280c20c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda @@ -1747,7 +1747,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-26.6.0-hc039f44_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py314h0f05182_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-h242f9ac_102_cp314.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py314h67df5f8_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hda471dd_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda @@ -1756,7 +1756,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.8-py314h5bd0f2a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h09e67af_11.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/argon2-cffi-25.1.0-pyhd8ed1ab_0.conda @@ -1765,7 +1765,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/async-lru-2.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.6.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.7.0-py314h680f03e_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.15.0-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.4.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.4.0-hac0b51c_0.conda @@ -1773,9 +1773,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_102.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda @@ -1806,7 +1806,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_events-0.12.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.20.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.5.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_server-2.28.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda @@ -1823,7 +1823,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.26.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda @@ -1832,9 +1832,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_101.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_102.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda @@ -1873,7 +1873,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/12/b3/88c0ef22878c86035f058df0ac6c171319ffd0aa52a406455ed3a3847566/ipympl-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/17/3d/26e14cf47c56c9ba3c3e12cae21f24716bc3182bb52260213ec0c819d0b9/python_engineio-4.13.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl @@ -1893,8 +1892,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/42/f7/7a3935abdebd5cf18705a5f0335dd6a3a18bef3baa7cb9edc3b6b9922cc8/typing_inspection-0.4.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl @@ -1904,13 +1904,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/0d/a17e966e620545c1548125af0b29ac812dd17b197a18a7462ac12fa859ee/matplotlib-3.11.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6b/01/f804208061b504894546fddc479f6075e0f00dfe88cb1703dafaa8c3c67e/python_engineio-4.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6b/be/92dd42844fe8a78c2c4a87f8078b9263dcc20aabe86b8420302a6fabaf4a/scipp-26.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/76/bb/32871c9e393f174a60930a29873b6a4217b3f1c65667cad303ef146caedc/chardet-7.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/77/2a/83d779d2dfb61f101d7b1c10073d18984e37262d8b4f171c99911a952430/virtualenv-21.7.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7c/c6/76ee9dacedcd8c67d8fa53dd975613733bdd28242a4c41518ff1c8aeaa64/jupytext-1.19.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl @@ -1942,11 +1942,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a8/4e/c09876f08fa9faaa5e1178f3d77b7af3f343258689bd6f3b72593b2f74e3/mkdocs_markdownextradata_plugin-0.2.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b2/6d/77546d8c26f038fce314a507106954f76270f6c182488bcf9ac9721175df/mkdocstrings_python-2.0.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/52/bc858b1665d0dec3a2511f4e6f5c18ea85c0977563d624d597c95d6d0fd7/jupyterquiz-2.9.6.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl @@ -1957,23 +1959,20 @@ environments: - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/69/427c34e61827818942b48ececd7c892b8f58ba4ce4cfc89ba9fd8dbe8a8d/docstring_parser_fork-0.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c6/3d/020a6b6248c3d4a37797db068256f0b3f15b01bc481327ba888c50309aa8/mkdocs_plugin_inline_svg-0.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/27/bdaaf32952f052c15c048fab82d971d30f92b63d54b61486f04a241fa994/plopp-26.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e6/90/90a65e6ae1b6e66183b48874d32509fc306c994beecc7924a6fa3d9f8955/easyscience-2.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl @@ -1984,11 +1983,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f4/88/360064c4c7d9d0664561dae03b74c871d2f5332b329f5c99f1c997fb869a/chardet-7.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f4/a4/61adb19f3c74b0dc0e411de4f06ebef564b1f179928f9dffcbd4b378f2ef/jupyter_notebook_parser-0.1.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f6/b6/f65ac785d4ac90dcf7c831ac6256f5dd4a19780f4e1575b2c0d6eeebe319/griffelib-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl @@ -2004,7 +2004,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/async-lru-2.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.6.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.7.0-py314h680f03e_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.15.0-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.4.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.4.0-hac0b51c_0.conda @@ -2012,9 +2012,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_102.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda @@ -2045,7 +2045,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_events-0.12.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.20.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.5.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_server-2.28.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda @@ -2062,7 +2062,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.26.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda @@ -2071,9 +2071,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_101.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_102.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda @@ -2104,24 +2104,24 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-25.1.0-py314h0612a62_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py314h3daef5d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py314hee34562_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.8-h1a92334_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.1.1-py314h7bede21_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.1.1-py314h618e29d_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.21-py314he609de1_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hc7cc350_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-hfd3d5f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-py310h579977c_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-h34f8a20_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h2062a1b_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-h1dcdb26_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-h5295a6a_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-h2ddc9cb_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321h26f1114_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h1a92334_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.7.0-hcf2aa1b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h8f3e76b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.22-h1a92334_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda @@ -2133,9 +2133,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.6.0-h00e74ec_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py314ha14b1ff_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.1-py314h6590101_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.2.1-py314ha06c032_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-h156bc91_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.2-py314h63b12ec_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.2.2-py314hddd3963_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-hf4d206d_102_cp314.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.3-py314h6e9b3f0_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py312h022ad19_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda @@ -2144,7 +2144,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.8-py314h6c2aa35_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h10816f8_11.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl @@ -2156,7 +2156,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/12/b3/88c0ef22878c86035f058df0ac6c171319ffd0aa52a406455ed3a3847566/ipympl-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/17/3d/26e14cf47c56c9ba3c3e12cae21f24716bc3182bb52260213ec0c819d0b9/python_engineio-4.13.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl @@ -2170,15 +2169,16 @@ environments: - pypi: https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/06/f4f9f5b55219128cd61b86528674d2b268a12549ee10d0626b3feef3ba89/scipp-26.8.0-cp314-cp314-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/38/7e/7b91c89a4cf0f543a83be978657afb20c86af6d725253e319589dcc4ce52/lmfit-1.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/42/f7/7a3935abdebd5cf18705a5f0335dd6a3a18bef3baa7cb9edc3b6b9922cc8/typing_inspection-0.4.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/53/a2/69acfe84ec1f32930e801a5782a07fc5c79c8c6599a507b806d859d5da8e/matplotlib-3.11.1-cp314-cp314-macosx_11_0_arm64.whl @@ -2190,19 +2190,20 @@ environments: - pypi: https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/b7/9366ed44ced9b7ef357ab48c94205280276db9d7f064aa3012a97227e966/h5py-3.16.0-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/6b/01/f804208061b504894546fddc479f6075e0f00dfe88cb1703dafaa8c3c67e/python_engineio-4.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/77/2a/83d779d2dfb61f101d7b1c10073d18984e37262d8b4f171c99911a952430/virtualenv-21.7.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7a/40/0f95e04cb1820e0a582cd6d86bbf26be8302a94ccf330f8ba5f69735389d/chardet-7.6.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7c/c6/76ee9dacedcd8c67d8fa53dd975613733bdd28242a4c41518ff1c8aeaa64/jupytext-1.19.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/bf/3adcb9b3091b36de729dad91c107179c8c7c51adb2b08c31177bb540bef1/copier-9.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/80/44/e002bad11c7c9dc293141395bb2652f2c45a3dcac737c8385a1088ccbafe/format_docstring-0.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/f5/2f77f0bc663c13371d1c00ab8e550e2c9b11fec3c63ebf12a8336c0f534e/bumps-1.0.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/85/84/5690a64afecf9967c3844ec96842d549e6f3ef72009bfd5524b69111245a/chardet-7.5.1-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/85/d7/9b6ac05350ab7f7d3a730ff143ff3e2cada54514117c37be37e26dc91242/docstripy-0.7.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl @@ -2226,11 +2227,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a8/4e/c09876f08fa9faaa5e1178f3d77b7af3f343258689bd6f3b72593b2f74e3/mkdocs_markdownextradata_plugin-0.2.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b2/6d/77546d8c26f038fce314a507106954f76270f6c182488bcf9ac9721175df/mkdocstrings_python-2.0.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/52/bc858b1665d0dec3a2511f4e6f5c18ea85c0977563d624d597c95d6d0fd7/jupyterquiz-2.9.6.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl @@ -2241,7 +2244,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/69/427c34e61827818942b48ececd7c892b8f58ba4ce4cfc89ba9fd8dbe8a8d/docstring_parser_fork-0.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c6/3d/020a6b6248c3d4a37797db068256f0b3f15b01bc481327ba888c50309aa8/mkdocs_plugin_inline_svg-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl @@ -2249,14 +2251,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/27/bdaaf32952f052c15c048fab82d971d30f92b63d54b61486f04a241fa994/plopp-26.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz - - pypi: https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e6/90/90a65e6ae1b6e66183b48874d32509fc306c994beecc7924a6fa3d9f8955/easyscience-2.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/be/b257e12f9710819fde40adc972578bee6b72c5992da1bc8369bef2597756/nbmake-1.5.5-py3-none-any.whl @@ -2267,9 +2267,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/a4/61adb19f3c74b0dc0e411de4f06ebef564b1f179928f9dffcbd4b378f2ef/jupyter_notebook_parser-0.1.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f6/b6/f65ac785d4ac90dcf7c831ac6256f5dd4a19780f4e1575b2c0d6eeebe319/griffelib-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl @@ -2284,7 +2284,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/async-lru-2.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.6.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.7.0-py314h680f03e_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.15.0-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.4.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.4.0-hac0b51c_0.conda @@ -2292,10 +2292,10 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_102.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda @@ -2326,7 +2326,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_events-0.12.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.20.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.5.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_server-2.28.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda @@ -2342,7 +2342,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pandocfilters-1.5.0-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.26.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda @@ -2350,9 +2350,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyh09c184e_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_101.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_102.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda @@ -2383,15 +2383,15 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py314h5a2d7ad_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py314he701e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py314h85cf176_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_10.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py314h5a2d7ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py314h5a2d7ad_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.21-py314hb98de8c_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.7.0-h3d046cb_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.22-h6a83c73_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda @@ -2400,7 +2400,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-26.6.0-h80d1838_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py314hc5dbbe4_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.6-h4b44e0e_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.6-h53f6dd8_102_cp314.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-312-py314hcaaf0b2_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-3.0.5-py314h51f0985_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py314h2359020_1.conda @@ -2415,9 +2415,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/winpty-0.4.3-4.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h3a581c9_11.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - pypi: ./ - - pypi: https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl @@ -2428,7 +2427,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/17/3d/26e14cf47c56c9ba3c3e12cae21f24716bc3182bb52260213ec0c819d0b9/python_engineio-4.13.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl @@ -2446,8 +2444,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/3e/14/615a450205e1b56d16c6783f5ccd116cde05550faad70ae077c955654a75/h5py-3.16.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/42/f7/7a3935abdebd5cf18705a5f0335dd6a3a18bef3baa7cb9edc3b6b9922cc8/typing_inspection-0.4.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl @@ -2457,12 +2456,14 @@ environments: - pypi: https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6b/01/f804208061b504894546fddc479f6075e0f00dfe88cb1703dafaa8c3c67e/python_engineio-4.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/77/2a/83d779d2dfb61f101d7b1c10073d18984e37262d8b4f171c99911a952430/virtualenv-21.7.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7c/a9/ff4fef15ed25fc3f945a3b981ae0f43c8559b3fbedb40267e59e583d105b/chardet-7.6.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/7c/c6/76ee9dacedcd8c67d8fa53dd975613733bdd28242a4c41518ff1c8aeaa64/jupytext-1.19.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl @@ -2493,10 +2494,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a8/4e/c09876f08fa9faaa5e1178f3d77b7af3f343258689bd6f3b72593b2f74e3/mkdocs_markdownextradata_plugin-0.2.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b2/6d/77546d8c26f038fce314a507106954f76270f6c182488bcf9ac9721175df/mkdocstrings_python-2.0.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/52/bc858b1665d0dec3a2511f4e6f5c18ea85c0977563d624d597c95d6d0fd7/jupyterquiz-2.9.6.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl @@ -2506,7 +2510,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/69/427c34e61827818942b48ececd7c892b8f58ba4ce4cfc89ba9fd8dbe8a8d/docstring_parser_fork-0.0.16-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/c6/3d/020a6b6248c3d4a37797db068256f0b3f15b01bc481327ba888c50309aa8/mkdocs_plugin_inline_svg-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl @@ -2515,7 +2518,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/ce/04/d719a0a36930ecc8dfc801ff340f9dcfc4223f8ca5d39d06b4020032fff8/matplotlib-3.11.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/ce/27/bdaaf32952f052c15c048fab82d971d30f92b63d54b61486f04a241fa994/plopp-26.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl @@ -2523,10 +2525,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e6/90/90a65e6ae1b6e66183b48874d32509fc306c994beecc7924a6fa3d9f8955/easyscience-2.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e9/8f/d871b357287caae0483d2cd235fae476da3768dd7d56e1fe733ffd3f707c/chardet-7.5.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/eb/be/b257e12f9710819fde40adc972578bee6b72c5992da1bc8369bef2597756/nbmake-1.5.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ed/90/8e7ce41fc38f53d855c03f007df81297563b777f80c5d12a5c48f0455a22/scipp-26.8.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl @@ -2538,9 +2538,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/a4/61adb19f3c74b0dc0e411de4f06ebef564b1f179928f9dffcbd4b378f2ef/jupyter_notebook_parser-0.1.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/f6/b6/f65ac785d4ac90dcf7c831ac6256f5dd4a19780f4e1575b2c0d6eeebe319/griffelib-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl @@ -2556,21 +2556,21 @@ environments: p1: - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/argon2-cffi-bindings-25.1.0-py314h5bd0f2a_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314h3de4e8d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314hcd2bdb6_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py314h4a8dc5f_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py314h8d76f0c_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.21-py314h42812f9_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbde042b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-py310h44b86e0_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-h7cc23a3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbc21106_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h373387f_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.7.0-h3435931_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.22-h280c20c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.1.0-h934c35e_1.conda @@ -2581,7 +2581,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py314h0f05182_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-h242f9ac_102_cp314.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py314h67df5f8_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hda471dd_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda @@ -2590,7 +2590,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.8-py314h5bd0f2a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h09e67af_11.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/argon2-cffi-25.1.0-pyhd8ed1ab_0.conda @@ -2599,7 +2599,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/async-lru-2.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.6.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.7.0-py314h680f03e_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.15.0-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.4.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.4.0-hac0b51c_0.conda @@ -2607,9 +2607,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_102.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda @@ -2640,7 +2640,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_events-0.12.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.20.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.5.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_server-2.28.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda @@ -2657,7 +2657,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.26.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda @@ -2666,9 +2666,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_101.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_102.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda @@ -2701,7 +2701,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/88c0ef22878c86035f058df0ac6c171319ffd0aa52a406455ed3a3847566/ipympl-0.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/17/3d/26e14cf47c56c9ba3c3e12cae21f24716bc3182bb52260213ec0c819d0b9/python_engineio-4.13.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/24/18/d8544811ab076f876c4892b3714f5b0dad335e1dc33aef826df431b8325d/plotly-6.9.0-py3-none-any.whl @@ -2713,10 +2712,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/56/f2/d341201e61008b5531928ee542f05f8b6eb96bbd3d1772b19037a581ccde/easydynamics-0.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5b/69/93b34728cc386efdde0c342f8c680b9187dea7beb7adaf6b58a0713be101/mpld3-0.5.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/64/0d/a17e966e620545c1548125af0b29ac812dd17b197a18a7462ac12fa859ee/matplotlib-3.11.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/6b/01/f804208061b504894546fddc479f6075e0f00dfe88cb1703dafaa8c3c67e/python_engineio-4.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6b/be/92dd42844fe8a78c2c4a87f8078b9263dcc20aabe86b8420302a6fabaf4a/scipp-26.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl @@ -2737,6 +2736,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/27/bdaaf32952f052c15c048fab82d971d30f92b63d54b61486f04a241fa994/plopp-26.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d9/be/7e6bf4088d003432e9a511656b90e3ec2abf3ff54a6057d2fa6e8ecfcbf1/easydynamics-0.9.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e6/90/90a65e6ae1b6e66183b48874d32509fc306c994beecc7924a6fa3d9f8955/easyscience-2.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl @@ -2757,7 +2757,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/async-lru-2.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.6.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.7.0-py314h680f03e_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.15.0-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.4.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.4.0-hac0b51c_0.conda @@ -2765,9 +2765,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_102.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda @@ -2798,7 +2798,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_events-0.12.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.20.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.5.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_server-2.28.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda @@ -2815,7 +2815,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.26.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda @@ -2824,9 +2824,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_101.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_102.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda @@ -2857,18 +2857,18 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-25.1.0-py314h0612a62_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py314h3daef5d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py314hee34562_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.1.1-py314h7bede21_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.1.1-py314h618e29d_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.21-py314he609de1_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hc7cc350_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-hfd3d5f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-py310h579977c_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-h34f8a20_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321h26f1114_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.7.0-hcf2aa1b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.22-h1a92334_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda @@ -2877,9 +2877,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py314ha14b1ff_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.1-py314h6590101_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.2.1-py314ha06c032_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-h156bc91_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.2-py314h63b12ec_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.2.2-py314hddd3963_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-hf4d206d_102_cp314.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.3-py314h6e9b3f0_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py312h022ad19_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda @@ -2888,12 +2888,11 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.8-py314h6c2aa35_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h10816f8_11.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/88c0ef22878c86035f058df0ac6c171319ffd0aa52a406455ed3a3847566/ipympl-0.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/17/3d/26e14cf47c56c9ba3c3e12cae21f24716bc3182bb52260213ec0c819d0b9/python_engineio-4.13.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/24/18/d8544811ab076f876c4892b3714f5b0dad335e1dc33aef826df431b8325d/plotly-6.9.0-py3-none-any.whl @@ -2907,11 +2906,11 @@ environments: - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/53/a2/69acfe84ec1f32930e801a5782a07fc5c79c8c6599a507b806d859d5da8e/matplotlib-3.11.1-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/56/f2/d341201e61008b5531928ee542f05f8b6eb96bbd3d1772b19037a581ccde/easydynamics-0.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5b/69/93b34728cc386efdde0c342f8c680b9187dea7beb7adaf6b58a0713be101/mpld3-0.5.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/6a/b7/9366ed44ced9b7ef357ab48c94205280276db9d7f064aa3012a97227e966/h5py-3.16.0-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/6b/01/f804208061b504894546fddc479f6075e0f00dfe88cb1703dafaa8c3c67e/python_engineio-4.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/f5/2f77f0bc663c13371d1c00ab8e550e2c9b11fec3c63ebf12a8336c0f534e/bumps-1.0.5-py3-none-any.whl @@ -2930,6 +2929,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ce/27/bdaaf32952f052c15c048fab82d971d30f92b63d54b61486f04a241fa994/plopp-26.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d9/be/7e6bf4088d003432e9a511656b90e3ec2abf3ff54a6057d2fa6e8ecfcbf1/easydynamics-0.9.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e6/90/90a65e6ae1b6e66183b48874d32509fc306c994beecc7924a6fa3d9f8955/easyscience-2.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl @@ -2947,7 +2947,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/async-lru-2.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.6.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.7.0-py314h680f03e_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.15.0-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.4.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.4.0-hac0b51c_0.conda @@ -2955,10 +2955,10 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_102.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda @@ -2989,7 +2989,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_events-0.12.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.20.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.5.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_server-2.28.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda @@ -3005,7 +3005,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pandocfilters-1.5.0-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.26.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda @@ -3013,9 +3013,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyh09c184e_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_101.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_102.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda @@ -3046,15 +3046,15 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py314h5a2d7ad_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py314he701e3d_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py314h85cf176_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_10.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py314h5a2d7ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py314h5a2d7ad_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.21-py314hb98de8c_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.7.0-h3d046cb_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.22-h6a83c73_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda @@ -3062,7 +3062,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/msgspec-0.21.1-py314h5a2d7ad_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py314hc5dbbe4_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.6-h4b44e0e_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.6-h53f6dd8_102_cp314.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-312-py314hcaaf0b2_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-3.0.5-py314h51f0985_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py314h2359020_1.conda @@ -3077,13 +3077,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/winpty-0.4.3-4.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h3a581c9_11.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/88c0ef22878c86035f058df0ac6c171319ffd0aa52a406455ed3a3847566/ipympl-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/17/3d/26e14cf47c56c9ba3c3e12cae21f24716bc3182bb52260213ec0c819d0b9/python_engineio-4.13.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/24/18/d8544811ab076f876c4892b3714f5b0dad335e1dc33aef826df431b8325d/plotly-6.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl @@ -3093,10 +3092,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/56/f2/d341201e61008b5531928ee542f05f8b6eb96bbd3d1772b19037a581ccde/easydynamics-0.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/5b/69/93b34728cc386efdde0c342f8c680b9187dea7beb7adaf6b58a0713be101/mpld3-0.5.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/6b/01/f804208061b504894546fddc479f6075e0f00dfe88cb1703dafaa8c3c67e/python_engineio-4.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl @@ -3118,6 +3117,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/ce/04/d719a0a36930ecc8dfc801ff340f9dcfc4223f8ca5d39d06b4020032fff8/matplotlib-3.11.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/ce/27/bdaaf32952f052c15c048fab82d971d30f92b63d54b61486f04a241fa994/plopp-26.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/d9/be/7e6bf4088d003432e9a511656b90e3ec2abf3ff54a6057d2fa6e8ecfcbf1/easydynamics-0.9.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e6/90/90a65e6ae1b6e66183b48874d32509fc306c994beecc7924a6fa3d9f8955/easyscience-2.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl @@ -3178,57 +3178,57 @@ packages: run_exports: {} size: 35598 timestamp: 1762509505285 -- conda: https://conda.anaconda.org/conda-forge/linux-64/backports.zstd-1.6.0-py312h90b7ffd_0.conda - sha256: 95b3d6d44c17c4061db703289f39915646e455f75f0c8c9d949bf081d2e61579 - md5: 55811da425538da800b89c0c588652fa +- conda: https://conda.anaconda.org/conda-forge/linux-64/backports.zstd-1.7.0-py312h3f22e6b_0.conda + sha256: c10df0467f534472f0aa39013850d6dd9dd38ea5a6fb5c0812afb6f3fc768924 + md5: e7eb25765bdf21397cc6e30828871625 depends: - python - - libgcc >=14 - __glibc >=2.17,<3.0.a0 + - libgcc >=15 - python_abi 3.12.* *_cp312 - zstd >=1.5.7,<1.6.0a0 license: BSD-3-Clause AND MIT AND EPL-2.0 purls: - pkg:pypi/backports-zstd?source=hash-mapping run_exports: {} - size: 239892 - timestamp: 1781450817988 -- conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py312hdb49522_1.conda - sha256: 49df13a1bb5e388ca0e4e87022260f9501ed4192656d23dc9d9a1b4bf3787918 - md5: 64088dffd7413a2dd557ce837b4cbbdb + size: 240967 + timestamp: 1786861419155 +- conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py312he9c40d5_3.conda + sha256: 32ae6e002843704af9f39395f3116815fa66f2b27de1bd9044fb2a2d53fbe3d3 + md5: d176f3ed2824f930b524c45eb8f158bb depends: - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - - libstdcxx >=14 + - libgcc >=15 + - libstdcxx >=15 - python >=3.12,<3.13.0a0 - python_abi 3.12.* *_cp312 constrains: - - libbrotlicommon 1.2.0 hb03c661_1 + - libbrotlicommon 1.2.0 h39a168f_3 license: MIT license_family: MIT purls: - - pkg:pypi/brotli?source=hash-mapping + - pkg:pypi/brotli?source=compressed-mapping run_exports: {} - size: 368300 - timestamp: 1764017300621 -- conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314h3de4e8d_1.conda - sha256: 3ad3500bff54a781c29f16ce1b288b36606e2189d0b0ef2f67036554f47f12b0 - md5: 8910d2c46f7e7b519129f486e0fe927a + size: 367032 + timestamp: 1786622975850 +- conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314hcd2bdb6_3.conda + sha256: e52ff7e1e3c5f4423421fbcd1f1ebf1d6ce123e22890ceb225d6552b7bbc551f + md5: bd1be0851060138e038f6f4e09cc1eb4 depends: - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - - libstdcxx >=14 + - libgcc >=15 + - libstdcxx >=15 - python >=3.14,<3.15.0a0 - python_abi 3.14.* *_cp314 constrains: - - libbrotlicommon 1.2.0 hb03c661_1 + - libbrotlicommon 1.2.0 h39a168f_3 license: MIT license_family: MIT purls: - - pkg:pypi/brotli?source=hash-mapping + - pkg:pypi/brotli?source=compressed-mapping run_exports: {} - size: 367376 - timestamp: 1764017265553 + size: 367948 + timestamp: 1786622843866 - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda sha256: 1a0d382c515ebf55f8ee1f38c8b81bc95af5c2acc42ad53b66bc5df932032f96 md5: e675fabcf81499adc7edf58124fb1e01 @@ -3259,13 +3259,13 @@ packages: - c-ares >=1.34.8,<2.0a0 size: 226755 timestamp: 1786116641939 -- conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py312h460c074_0.conda - sha256: e4e3f48195393953bfacdfd4670e1c2cf5231b7bb11f29ccc724f1697c1c4449 - md5: a9d08f233128bc42fae8fe17c13df103 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py312h703531f_2.conda + sha256: b649eacfd07be8fb889ef609601436dff831b2e9d095ef029601f3f10946c7d6 + md5: 3101547f7c22db267bd40988f67d7ab1 depends: - __glibc >=2.17,<3.0.a0 - - libffi >=3.5.2,<3.6.0a0 - - libgcc >=14 + - libffi >=3.7.0,<3.8.0a0 + - libgcc >=15 - pycparser - python >=3.12,<3.13.0a0 - python_abi 3.12.* *_cp312 @@ -3274,15 +3274,15 @@ packages: purls: - pkg:pypi/cffi?source=hash-mapping run_exports: {} - size: 301815 - timestamp: 1785810903512 -- conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py314h4a8dc5f_0.conda - sha256: eec96c89f9f445da65cfc315e9c8572968335e127a18f17104bc9a90df103058 - md5: f10f311ad713701678c05ecc19c22784 + size: 302100 + timestamp: 1786775110054 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py314h8d76f0c_2.conda + sha256: 9d181949eead0d4092ed9ffa3b7ec066a15572d515327939c8a074c224262528 + md5: 314853abf64fc052dea08bd17df17b65 depends: - __glibc >=2.17,<3.0.a0 - - libffi >=3.5.2,<3.6.0a0 - - libgcc >=14 + - libffi >=3.7.0,<3.8.0a0 + - libgcc >=15 - pycparser - python >=3.14,<3.15.0a0 - python_abi 3.14.* *_cp314 @@ -3291,8 +3291,8 @@ packages: purls: - pkg:pypi/cffi?source=hash-mapping run_exports: {} - size: 306515 - timestamp: 1785810913099 + size: 306626 + timestamp: 1786775112485 - conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.21-py312h8285ef7_0.conda sha256: b8dbe25820064a099f315bbb8f45f5bac3fddb63e96af3cbf0c93a830733ef34 md5: e6778419a1851f6e15820558abddfa04 @@ -3325,44 +3325,44 @@ packages: run_exports: {} size: 2842115 timestamp: 1780390153580 -- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda - sha256: d7c260b7e1cf22ce04d6ba8a86eabf4e6c50bc96a5c27fe2ecb32298af3e88eb - md5: 4ef4b977bb216a3001a3334696a80850 +- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-py310h44b86e0_2.conda + sha256: 9f07834f0c546ab14d885ce0366285f61f44e326c0edd1fc63b8294e113ae432 + md5: 72a381cbad04f24b1c2a43ef707f45b4 depends: + - __glibc >=2.17,<3.0.a0 - libstdcxx >=14 - libgcc >=14 - - __glibc >=2.17,<3.0.a0 license: MIT license_family: MIT purls: [] run_exports: weak: - icu >=78.3,<79.0a0 - size: 14455340 - timestamp: 1784916378180 -- conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda - sha256: 0960d06048a7185d3542d850986d807c6e37ca2e644342dd0c72feefcf26c2a4 - md5: b38117a3c920364aff79f870c984b4a3 + size: 14459115 + timestamp: 1786545741408 +- conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-h7cc23a3_1.conda + sha256: dd053c96dcb0dcfd59422aefea9d2fe937a190167f34fba7893ec1e10a7e8963 + md5: ba55d1b89fd7775e67de8291029b4059 depends: - __glibc >=2.17,<3.0.a0 - - libgcc >=13 + - libgcc >=15 license: LGPL-2.1-or-later purls: [] run_exports: weak: - keyutils >=1.6.3,<2.0a0 - size: 134088 - timestamp: 1754905959823 -- conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbde042b_1.conda - sha256: 9b07046870772f28740e3f6149f09ff222843733087a33c5540b169c6289652d - md5: 54157a1c8c0bb70f62dd0b17fba7e7f2 + size: 135295 + timestamp: 1786739238128 +- conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbc21106_2.conda + sha256: 2a5c38c85e63df84c4e69ee71439841ce570d259ae3060627bb9a49a938d66f4 + md5: 53318d715316929a574f83591308b1f8 depends: - __glibc >=2.17,<3.0.a0 - keyutils >=1.6.3,<2.0a0 - libedit >=3.1.20250104,<3.2.0a0 - libedit >=3.1.20250104,<4.0a0 - - libgcc >=14 - - libstdcxx >=14 + - libgcc >=15 + - libstdcxx >=15 - openssl >=3.5.7,<4.0a0 license: MIT license_family: MIT @@ -3370,8 +3370,8 @@ packages: run_exports: weak: - krb5 >=1.22.2,<1.23.0a0 - size: 1388990 - timestamp: 1781859420533 + size: 1394333 + timestamp: 1786762112514 - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda sha256: 27d83f1188cd19bcb7754a078b3fa7f4cfb8527f8eb2fde54dd01fc529d1adec md5: 449500f2c089da11c40f5c21312e3e07 @@ -3405,66 +3405,66 @@ packages: - libabseil =*=cxx17* size: 1437712 timestamp: 1780524559298 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda - sha256: 318f36bd49ca8ad85e6478bd8506c88d82454cc008c1ac1c6bf00a3c42fa610e - md5: 72c8fd1af66bd67bf580645b426513ed +- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-h39a168f_3.conda + sha256: e5864f257f839ffc27d681659bac95901f524f602b9121e5dcc5e2df18437f2d + md5: 7a2499a177753582fb7ae7e9dc4a908a depends: - __glibc >=2.17,<3.0.a0 - - libgcc >=14 + - libgcc >=15 license: MIT license_family: MIT purls: [] run_exports: weak: - libbrotlicommon >=1.2.0,<1.3.0a0 - size: 79965 - timestamp: 1764017188531 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda - sha256: 12fff21d38f98bc446d82baa890e01fd82e3b750378fedc720ff93522ffb752b - md5: 366b40a69f0ad6072561c1d09301c886 + size: 80265 + timestamp: 1786622773969 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-ha411449_3.conda + sha256: dad31b6d104973deb89710929a35651033aad692d4e7793cdb5b786a9bd54678 + md5: 6ab3315dc56618d652c1da42a648a129 depends: - __glibc >=2.17,<3.0.a0 - - libbrotlicommon 1.2.0 hb03c661_1 - - libgcc >=14 + - libbrotlicommon 1.2.0 h39a168f_3 + - libgcc >=15 license: MIT license_family: MIT purls: [] run_exports: weak: - libbrotlidec >=1.2.0,<1.3.0a0 - size: 34632 - timestamp: 1764017199083 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda - sha256: a0c15c79997820bbd3fbc8ecf146f4fe0eca36cc60b62b63ac6cf78857f1dd0d - md5: 4ffbb341c8b616aa2494b6afb26a0c5f + size: 34828 + timestamp: 1786622783405 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-h018ffa1_3.conda + sha256: d37124d0f51816e7d5e3a94bfc9ed3d6174d077f9b4f832d20c5a08b52bebf1f + md5: 2ac965638d4c6b2b38383bb1aebaf543 depends: - __glibc >=2.17,<3.0.a0 - - libbrotlicommon 1.2.0 hb03c661_1 - - libgcc >=14 + - libbrotlicommon 1.2.0 h39a168f_3 + - libgcc >=15 license: MIT license_family: MIT purls: [] run_exports: weak: - libbrotlienc >=1.2.0,<1.3.0a0 - size: 298378 - timestamp: 1764017210931 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda - sha256: d789471216e7aba3c184cd054ed61ce3f6dac6f87a50ec69291b9297f8c18724 - md5: c277e0a4d549b03ac1e9d6cbbe3d017b + size: 298639 + timestamp: 1786622792145 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h373387f_1.conda + sha256: 6473eb8caf2aae830f37caa93db9b26dddf7ac84b63229e8bf7fc0e5c3ab95b0 + md5: 50708d3b951d0f8e2d7f2df5b5edc040 depends: - ncurses + - libgcc >=14 - __glibc >=2.17,<3.0.a0 - - libgcc >=13 - - ncurses >=6.5,<7.0a0 + - ncurses >=6.6,<7.0a0 license: BSD-2-Clause license_family: BSD purls: [] run_exports: weak: - libedit >=3.1.20250104,<3.2.0a0 - size: 134676 - timestamp: 1738479519902 + size: 135098 + timestamp: 1786616658086 - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h280c20c_3.conda sha256: e4418f68d01a6307c4431b585c71b584f40189877364e542ee87deb777f5e85b md5: 7bc31538d6e3fb349e897353969237ec @@ -3493,9 +3493,9 @@ packages: run_exports: {} size: 77856 timestamp: 1781203599810 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda - sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6 - md5: a360c33a5abe61c07959e449fa1453eb +- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.7.0-h3435931_0.conda + sha256: ac38603008bf1e99b8ed379b1a656a67a70e2841f2b6a069c630cdf6316012d2 + md5: 0abe40a9880086ca4d2e5daf09dceff9 depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 @@ -3504,9 +3504,9 @@ packages: purls: [] run_exports: weak: - - libffi >=3.5.2,<3.6.0a0 - size: 58592 - timestamp: 1769456073053 + - libffi >=3.7.0,<3.8.0a0 + size: 67576 + timestamp: 1783520858222 - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda sha256: d5cb8475131c31680f8fd30512c418f373064e272e452063276a8fb14c9fa42f md5: 5a7d954665c707c93311657cd779c705 @@ -3550,9 +3550,9 @@ packages: - liblzma >=5.8.3,<6.0a0 size: 112995 timestamp: 1786348617826 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda - sha256: fe171ed5cf5959993d43ff72de7596e8ac2853e9021dec0344e583734f1e0843 - md5: 2c21e66f50753a083cbe6b80f38268fa +- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_2.conda + sha256: 46820b4a835e175940ae20bec00fdddaff804cda27a1408ab1b95e77a2196437 + md5: fcfed1dc5053eb1901b66e7b1fc32588 depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 @@ -3560,8 +3560,8 @@ packages: license_family: BSD purls: [] run_exports: {} - size: 92400 - timestamp: 1769482286018 + size: 92759 + timestamp: 1786650399772 - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda sha256: 663444d77a42f2265f54fb8b48c5450bfff4388d9c0f8253dd7855f0d993153f md5: 2a45e7f8af083626f009645a6481f12d @@ -3844,24 +3844,25 @@ packages: run_exports: {} size: 231303 timestamp: 1769678156552 -- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda - sha256: a44655c1c3e1d43ed8704890a91e12afd68130414ea2c0872e154e5633a13d7e - md5: 7eccb41177e15cc672e1babe9056018e +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-h8ab3286_1_cpython.conda + build_number: 1 + sha256: df3d1e5f972e79e78b61730c09135c795f6e7aa45b7777835c95f451e85eff0d + md5: c6e02a78e3b6427c633328fea9399534 depends: - __glibc >=2.17,<3.0.a0 - bzip2 >=1.0.8,<2.0a0 - ld_impl_linux-64 >=2.36.1 - - libexpat >=2.7.4,<3.0a0 - - libffi >=3.5.2,<3.6.0a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.7.0,<3.8.0a0 - libgcc >=14 - - liblzma >=5.8.2,<6.0a0 + - liblzma >=5.8.3,<6.0a0 - libnsl >=2.0.1,<2.1.0a0 - - libsqlite >=3.51.2,<4.0a0 - - libuuid >=2.41.3,<3.0a0 - - libxcrypt >=4.4.36 - - libzlib >=1.3.1,<2.0a0 - - ncurses >=6.5,<7.0a0 - - openssl >=3.5.5,<4.0a0 + - libsqlite >=3.53.4,<4.0a0 + - libuuid >=2.42.2,<3.0a0 + - libxcrypt >=4.4.38 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 - readline >=8.3,<9.0a0 - tk >=8.6.13,<8.7.0a0 - tzdata @@ -3874,22 +3875,22 @@ packages: - python_abi 3.12.* *_cp312 noarch: - python - size: 31608571 - timestamp: 1772730708989 -- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_101_cp314.conda - build_number: 101 - sha256: ee8f2006e1724b1f2e9e0ccc5a7cfdcab973460faa2f63ac1f6e44fdad4c0344 - md5: 78975a41cf3c525da654f17e35bfca9e + size: 31527849 + timestamp: 1786445051525 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-h242f9ac_102_cp314.conda + build_number: 102 + sha256: f5ff5c1fac471dfed4fc4288856a63eb9d771e6042d9f70420d75b9f488400d8 + md5: 9b6c336ef7195fbee1c10c09bfcf901b depends: - __glibc >=2.17,<3.0.a0 - bzip2 >=1.0.8,<2.0a0 - ld_impl_linux-64 >=2.36.1 - libexpat >=2.8.1,<3.0a0 - - libffi >=3.5.2,<3.6.0a0 + - libffi >=3.7.0,<3.8.0a0 - libgcc >=14 - liblzma >=5.8.3,<6.0a0 - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.53.3,<4.0a0 + - libsqlite >=3.53.4,<4.0a0 - libuuid >=2.42.2,<3.0a0 - libzlib >=1.3.2,<2.0a0 - ncurses >=6.6,<7.0a0 @@ -3906,8 +3907,8 @@ packages: - python_abi 3.14.* *_cp314 noarch: - python - size: 36869055 - timestamp: 1784910110714 + size: 36866750 + timestamp: 1786444737142 python_site_packages_path: lib/python3.14/site-packages - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py312h8a5da7c_1.conda sha256: cb142bfd92f6e55749365ddc244294fa7b64db6d08c45b018ff1c658907bfcbf @@ -4087,20 +4088,20 @@ packages: - zeromq >=4.3.5,<4.4.0a0 size: 311184 timestamp: 1779123989774 -- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 - md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_7.conda + sha256: 47d682b9f6d6ec9eb1a6e6c3e75ea6273e899e78fb7fc59f81d39745009fbc60 + md5: aa459086047c0e5e27023ab19f8cb86a depends: - __glibc >=2.17,<3.0.a0 - - libzlib >=1.3.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 license: BSD-3-Clause license_family: BSD purls: [] run_exports: weak: - zstd >=1.5.7,<1.6.0a0 - size: 601375 - timestamp: 1764777111296 + size: 601301 + timestamp: 1786599621503 - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda sha256: 2a7204314663eeda5dec482a956f0e2eaf289bd5b9953eaaaad0e81aa64638f2 md5: 3845f3d75991bae0fb90884662f4327c @@ -4232,17 +4233,17 @@ packages: run_exports: {} size: 7684321 timestamp: 1772555330347 -- conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.6.0-py314h680f03e_0.conda +- conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.7.0-py314h680f03e_0.conda noarch: generic - sha256: 709cac7434d1c5a8828105036212a2a36022a07d807e89e2e99cac939c2d2526 - md5: 40d89d8546ad6e139e73ec8f6d56068b + sha256: 19514d89d1e725e44b0650d31a4d43da8e070e4e93e4131e3b16bd139404f2b2 + md5: 92adf685875ab717f68ad4172ef6de27 depends: - python >=3.14 license: BSD-3-Clause AND MIT AND EPL-2.0 purls: [] run_exports: {} - size: 7526 - timestamp: 1781450817767 + size: 7541 + timestamp: 1786861415851 - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.15.0-pyha770c72_0.conda sha256: aed4b9dcf68ec2a75e5645fed14d77fd884d38d2e52bfa6ef4b278d90cd88781 md5: 3b261da3fe9b4168738712832410b022 @@ -4338,18 +4339,18 @@ packages: run_exports: {} size: 137015 timestamp: 1784717699092 -- conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda - sha256: 8d8813ef655b4e75e4fb897abd83ad548882efad7b4e836b021b797f42780799 - md5: d154b40b109e503430979e8a8d099eaf +- conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda + sha256: cb60ef3e0631c8bacb4f7057196dee4496091a22baa3bb4b9bccb12c7e1c921b + md5: e0ac3accc64e23e40969d660e5f58ac8 depends: - python >=3.10 license: MIT license_family: MIT purls: - - pkg:pypi/charset-normalizer?source=hash-mapping + - pkg:pypi/charset-normalizer?source=compressed-mapping run_exports: {} - size: 61418 - timestamp: 1783505332569 + size: 64487 + timestamp: 1786835648298 - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda sha256: ab29d57dc70786c1269633ba3dff20288b81664d3ff8d21af995742e2bb03287 md5: 962b9857ee8e7018c22f2776ffa0b2d7 @@ -4375,30 +4376,30 @@ packages: run_exports: {} size: 14690 timestamp: 1753453984907 -- conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.13-py312hd8ed1ab_0.conda +- conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.13-py312hd8ed1ab_1.conda noarch: generic - sha256: d3e9bbd7340199527f28bbacf947702368f31de60c433a16446767d3c6aaf6fe - md5: f54c1ffb8ecedb85a8b7fcde3a187212 + sha256: b7ea8ebc1b2059159cbd49e0c9d1815713c73c4a55156b060c28dd61cfbdf9c2 + md5: 171d0cc7f621a0371ea273a05abdb46c depends: - python >=3.12,<3.13.0a0 - python_abi * *_cp312 license: Python-2.0 purls: [] run_exports: {} - size: 46463 - timestamp: 1772728929620 -- conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_101.conda + size: 45930 + timestamp: 1786443506006 +- conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_102.conda noarch: generic - sha256: 436618a5a090c9f7ade0c5f883e8602a130b3991bc88b06badd6f805d7dbff00 - md5: 424c465894c8af725105fe6ad74f6aec + sha256: 4d97fdae803c1ed7c704289d1f856d60e5502f24e93e92f9d45fbea37fd9e9a7 + md5: 6b344ff499096c0b873d202fea1e2fcd depends: - python >=3.14,<3.15.0a0 - python_abi * *_cp314 license: Python-2.0 purls: [] run_exports: {} - size: 49508 - timestamp: 1784909547134 + size: 49916 + timestamp: 1786444134021 - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 sha256: 9717a059677553562a8f38ff07f3b9f61727bd614f505658b0a5ecbcf8df89be md5: 961b3a227b437d82ad7054484cfa71b2 @@ -4979,9 +4980,9 @@ packages: run_exports: {} size: 22052 timestamp: 1768574057200 -- conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.2-pyhd8ed1ab_0.conda - sha256: a65dfe3aa15281377d3a589f0e86c463e6d8261b481bc892f7a40566ab1c675c - md5: 74e2ef595d91aaffcf24815b4865bbc0 +- conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.3-pyhd8ed1ab_0.conda + sha256: dabfff705b000188a9f67c9af68bb607cdb2e46fd7c6283307a502994c2af46f + md5: e5527f195a1a1925b9207e9d47752480 depends: - async-lru >=1.0.0 - httpx >=0.25.0,<1 @@ -5002,10 +5003,10 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/jupyterlab?source=hash-mapping + - pkg:pypi/jupyterlab?source=compressed-mapping run_exports: {} - size: 14035048 - timestamp: 1784641255275 + size: 13178193 + timestamp: 1786398793317 - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda sha256: dc24b900742fdaf1e077d9a3458fd865711de80bca95fe3c6d46610c532c6ef0 md5: fd312693df06da3578383232528c468d @@ -5138,6 +5139,7 @@ packages: - traitlets >=5.1 - python license: BSD-3-Clause + license_family: BSD purls: - pkg:pypi/nbformat?source=compressed-mapping run_exports: {} @@ -5250,18 +5252,19 @@ packages: run_exports: {} size: 39509 timestamp: 1764156429044 -- conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.1-pyhcf101f3_0.conda - sha256: efa221d8ebb76e5ba98c1e8080e8ba580e59fc9f62cff54b0282efc2d05cd826 - md5: c786a34c15b34520d62affc418ae78bd +- conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.3-pyhcf101f3_0.conda + sha256: 810511c90649ca59fa0174958a210fd5051c0a7848cfbd42c87054a6836726b5 + md5: 31474b00d0ca5accac14eda09ba11216 depends: - python >=3.10 - python license: MIT + license_family: MIT purls: - pkg:pypi/platformdirs?source=compressed-mapping run_exports: {} - size: 26817 - timestamp: 1786197727673 + size: 26956 + timestamp: 1786708411066 - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.26.0-pyhd8ed1ab_0.conda sha256: 794eec057361b41db1b06a9677eb8632adc0de81f7dcfe113bca8f0b04a23553 md5: 3aa7e2d85645e61627c98082747dfdfe @@ -5378,54 +5381,53 @@ packages: run_exports: {} size: 233310 timestamp: 1751104122689 -- conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.1-pyhcf101f3_0.conda - sha256: 2243b305387413fa716827dce44011ea121be002e7ec24404ba00651db0279cd - md5: d066c36f0c7658ef422c60d598ea8495 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.2-pyhcf101f3_0.conda + sha256: fc4a704822df22defce49d0fb811fdc036a1fd3b579aeaa601228e9cfd198b3d + md5: aa75b7f096d17621bc307b3025b29461 depends: - python >=3.10 - python license: BSD-3-Clause - license_family: BSD purls: - - pkg:pypi/fastjsonschema?source=hash-mapping + - pkg:pypi/fastjsonschema?source=compressed-mapping run_exports: {} - size: 252180 - timestamp: 1785242896823 -- conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.12.13-hd8ed1ab_0.conda - sha256: 97327b9509ae3aae28d27217a5d7bd31aff0ab61a02041e9c6f98c11d8a53b29 - md5: 32780d6794b8056b78602103a04e90ef + size: 254446 + timestamp: 1786892280524 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.12.13-hd8ed1ab_1.conda + sha256: cf0972372c4469881e13e9342ab51aed8b25d0d5dff45fcd0fe847b3dd11f97c + md5: 87225cc6af32ec67528efa39c4945aaf depends: - cpython 3.12.13.* - python_abi * *_cp312 license: Python-2.0 purls: [] run_exports: {} - size: 46449 - timestamp: 1772728979370 -- conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_101.conda - sha256: f96f61b33fe7d0f599ba5e23d9e5231fad9c8a37a1c141dfa8edbd0ba78de9e3 - md5: 7f742295acd62ee0688e7e6924b71e67 + size: 45874 + timestamp: 1786443525835 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_102.conda + sha256: b15fb3e5daae788ca78844ee4ee9f1a1b07911cd4e83c484d8c1ce2794c6c93b + md5: 364ec4bb854ab4ca9ca4e626a55ea8da depends: - cpython 3.14.6.* - python_abi * *_cp314 license: Python-2.0 purls: [] run_exports: {} - size: 49484 - timestamp: 1784909578801 -- conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.1.0-pyhd8ed1ab_0.conda - sha256: a0dfe07d0bc1d8c47a38b79ad4a8eb1bc7b86fb33ee5293ebb45dfdc46191f4e - md5: 982ed0cbfc0fe09f25861e3d111e9717 + size: 49897 + timestamp: 1786444152084 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.2.0-pyhd8ed1ab_0.conda + sha256: 4f8ecadbd9d282b0208d6e84640573fcb6ab462307d737eedd28341964e18cc6 + md5: e5407c82510aab6a4baa31fcd4249655 depends: - python >=3.10 - typing_extensions license: BSD-2-Clause license_family: BSD purls: - - pkg:pypi/python-json-logger?source=hash-mapping + - pkg:pypi/python-json-logger?source=compressed-mapping run_exports: {} - size: 19249 - timestamp: 1781036004580 + size: 19367 + timestamp: 1786872772907 - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda sha256: 3f05db78cf8be33cf6dbc469664b8e3a01f3980d61d6d6bef48669b171896d8a md5: eefc8d916bd2e708d76d40398ef9a1ee @@ -5637,6 +5639,7 @@ packages: depends: - python >=3.10 license: MIT + license_family: MIT purls: - pkg:pypi/soupsieve?source=compressed-mapping run_exports: {} @@ -5905,9 +5908,9 @@ packages: run_exports: {} size: 34218 timestamp: 1762509977830 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/backports.zstd-1.6.0-py312h87c4bb7_0.conda - sha256: e1aad5d00ad9566a06e9ac0912efec406c6d844b6d48e0696db18f0a655323a2 - md5: 4447051eb9b01fed8c9cda74ecc800cd +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/backports.zstd-1.7.0-py312h1a36842_0.conda + sha256: 74dc6a99be98d2541234ac525a7dc52b6503d88ab2b3b0935855410b739a1a96 + md5: 6aa737ecb1ce6597bad0a3cc4ab305e3 depends: - python - __osx >=11.0 @@ -5917,44 +5920,42 @@ packages: purls: - pkg:pypi/backports-zstd?source=hash-mapping run_exports: {} - size: 240925 - timestamp: 1781450816363 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py312h0dfefe5_1.conda - sha256: 6178775a86579d5e8eec6a7ab316c24f1355f6c6ccbe84bb341f342f1eda2440 - md5: 311fcf3f6a8c4eb70f912798035edd35 + size: 238682 + timestamp: 1786861431410 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py312ha52686f_3.conda + sha256: 19c11e5f1ef25fccae45c1b326566f8b5035d4bcacec88bb713e5c519f37656b + md5: ab3ab7833ae782f52d4af754e57a573c depends: - __osx >=11.0 - - libcxx >=19 + - libcxx >=21 - python >=3.12,<3.13.0a0 - - python >=3.12,<3.13.0a0 *_cpython - python_abi 3.12.* *_cp312 constrains: - - libbrotlicommon 1.2.0 hc919400_1 + - libbrotlicommon 1.2.0 h1dcdb26_3 license: MIT license_family: MIT purls: - - pkg:pypi/brotli?source=hash-mapping + - pkg:pypi/brotli?source=compressed-mapping run_exports: {} - size: 359503 - timestamp: 1764018572368 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py314h3daef5d_1.conda - sha256: 5c2e471fd262fcc3c5a9d5ea4dae5917b885e0e9b02763dbd0f0d9635ed4cb99 - md5: f9501812fe7c66b6548c7fcaa1c1f252 + size: 364943 + timestamp: 1786622957275 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py314hee34562_3.conda + sha256: 3cdfc0a96de4717fc309e39133e2a192f4e1df96680577e1d48804021d1726eb + md5: d3a28add84f2412a562355f89ef5e0f5 depends: - __osx >=11.0 - - libcxx >=19 + - libcxx >=21 - python >=3.14,<3.15.0a0 - - python >=3.14,<3.15.0a0 *_cp314 - python_abi 3.14.* *_cp314 constrains: - - libbrotlicommon 1.2.0 hc919400_1 + - libbrotlicommon 1.2.0 h1dcdb26_3 license: MIT license_family: MIT purls: - - pkg:pypi/brotli?source=hash-mapping + - pkg:pypi/brotli?source=compressed-mapping run_exports: {} - size: 359854 - timestamp: 1764018178608 + size: 365272 + timestamp: 1786623009364 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda sha256: 8ec22f0ba25cbfc2e64d70cf29459eccd7ffdf6436f6a6ff15bbfef799f7d4f6 md5: b50612e7d190b8061ab4e7dc119cf4d5 @@ -5983,40 +5984,38 @@ packages: - c-ares >=1.34.8,<2.0a0 size: 197274 timestamp: 1786116660078 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.1.1-py312h652e2b1_0.conda - sha256: 7740a7c1709bf8fd2c8c23744b5cd9124c0c153849a4f554a63877ec256c6afe - md5: 5289d47a0a43af9468f348df6a380989 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.1.1-py312hc892d8b_2.conda + sha256: e2f5e72590b5cea4fce92278194d48493c1d9e14234fc7ca6dc840a53a37c32c + md5: c303ee33bf6d58dd5b0832c0d6ebad40 depends: - __osx >=11.0 - - libffi >=3.5.2,<3.6.0a0 + - libffi >=3.7.0,<3.8.0a0 - pycparser - python >=3.12,<3.13.0a0 - - python >=3.12,<3.13.0a0 *_cpython - python_abi 3.12.* *_cp312 license: MIT license_family: MIT purls: - pkg:pypi/cffi?source=hash-mapping run_exports: {} - size: 292358 - timestamp: 1785811365068 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.1.1-py314h7bede21_0.conda - sha256: b6c9358ee7cabfb417e6a8d2758c2241fe6b24f9fd316561a449ccac1959f8e1 - md5: 4648b9514e3cd095788963779cebcc7a + size: 288187 + timestamp: 1786775145970 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.1.1-py314h618e29d_2.conda + sha256: 77b0bb0f3fd2d28b6bbd216d370d98320835b8e1df4c86a478a1d0391d1913fe + md5: 1572fc59fc2b1461f47b0d6907acef30 depends: - __osx >=11.0 - - libffi >=3.5.2,<3.6.0a0 + - libffi >=3.7.0,<3.8.0a0 - pycparser - python >=3.14,<3.15.0a0 - - python >=3.14,<3.15.0a0 *_cp314 - python_abi 3.14.* *_cp314 license: MIT license_family: MIT purls: - pkg:pypi/cffi?source=hash-mapping run_exports: {} - size: 297593 - timestamp: 1785811327918 + size: 292307 + timestamp: 1786775143512 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.21-py312h6510ced_0.conda sha256: 80589b2da39c84c0786f13a0a4c98cde9a879207af0482bd1f9b29d2f6fe277b md5: 178381b74c5e84ea90751c5e4291c41e @@ -6049,9 +6048,9 @@ packages: run_exports: {} size: 2776045 timestamp: 1780390212997 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hc7cc350_2.conda - sha256: f0b22bc30e4cc29e29ba3234cb38497fe8def2c2aae4b775d42fe5b378a018c9 - md5: 6133ddbb17ba2b50700dd88e9303ce27 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-py310h579977c_2.conda + sha256: 6cdb5dee54c72e56ab189fb3ad33cb28533553d42590e7e831160248f4416a43 + md5: a5efc0b42bb8b42e97d0a29ae3e3c187 depends: - __osx >=11.0 license: MIT @@ -6060,14 +6059,14 @@ packages: run_exports: weak: - icu >=78.3,<79.0a0 - size: 14070698 - timestamp: 1784916459058 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-hfd3d5f3_1.conda - sha256: c740e4a2e7247776a9883158fdab50ae0732c8f67f96d8f1db8ad9da5e0b5222 - md5: 8780f41b013d19219faef9c82260744b + size: 14070242 + timestamp: 1786545847761 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-h34f8a20_2.conda + sha256: aaea1d42b07769920db2af7471ece9399d2613448863da64ad8272998db5db34 + md5: 15235dd10450d67bc25ccafd5b46d2bc depends: - __osx >=11.0 - - libcxx >=19 + - libcxx >=21 - libedit >=3.1.20250104,<3.2.0a0 - libedit >=3.1.20250104,<4.0a0 - openssl >=3.5.7,<4.0a0 @@ -6077,8 +6076,8 @@ packages: run_exports: weak: - krb5 >=1.22.2,<1.23.0a0 - size: 1159780 - timestamp: 1781859501654 + size: 1165740 + timestamp: 1786762145768 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h2062a1b_1.conda sha256: 450026eb01a52acd0ff122e331ec9b8546c93790143214b73e1c14bc2b075b22 md5: 8adfdc0215e979a0ce31be676883e0b3 @@ -6097,9 +6096,9 @@ packages: - libabseil =*=cxx17* size: 1273408 timestamp: 1780524599788 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda - sha256: a7cb9e660531cf6fbd4148cff608c85738d0b76f0975c5fc3e7d5e92840b7229 - md5: 006e7ddd8a110771134fcc4e1e3a6ffa +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-h1dcdb26_3.conda + sha256: 5e62b856b2e77ce98db44133bacab1281b42c1040dcbd69dbacfb80890cff5b0 + md5: b457450ba3f27c4749783c0204bd17b0 depends: - __osx >=11.0 license: MIT @@ -6108,36 +6107,36 @@ packages: run_exports: weak: - libbrotlicommon >=1.2.0,<1.3.0a0 - size: 79443 - timestamp: 1764017945924 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda - sha256: 2eae444039826db0454b19b52a3390f63bfe24f6b3e63089778dd5a5bf48b6bf - md5: 079e88933963f3f149054eec2c487bc2 + size: 80027 + timestamp: 1786622846050 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-h5295a6a_3.conda + sha256: 510c9fce0d9ffcf41741dd96fc05db381672109d5665ef002c2e58f0d4ca0118 + md5: e07a99c6fdd984f4d588060f2f936bf4 depends: - __osx >=11.0 - - libbrotlicommon 1.2.0 hc919400_1 + - libbrotlicommon 1.2.0 h1dcdb26_3 license: MIT license_family: MIT purls: [] run_exports: weak: - libbrotlidec >=1.2.0,<1.3.0a0 - size: 29452 - timestamp: 1764017979099 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda - sha256: 01436c32bb41f9cb4bcf07dda647ce4e5deb8307abfc3abdc8da5317db8189d1 - md5: b2b7c8288ca1a2d71ff97a8e6a1e8883 + size: 29935 + timestamp: 1786622857695 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-h2ddc9cb_3.conda + sha256: eac412417eee2e93c62e9d53559f1f9c14f40b6c41e2c432af8b517596898dd1 + md5: 954c78a9f591bfb12c79beaff7338ec8 depends: - __osx >=11.0 - - libbrotlicommon 1.2.0 hc919400_1 + - libbrotlicommon 1.2.0 h1dcdb26_3 license: MIT license_family: MIT purls: [] run_exports: weak: - libbrotlienc >=1.2.0,<1.3.0a0 - size: 290754 - timestamp: 1764018009077 + size: 295650 + timestamp: 1786622868044 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda sha256: a2e7abab5add9750fab064c024394de48e49f97631c605ad5db5c8ac3fc769ef md5: 89f76a2a21a3ec3ec983b5eb237c4113 @@ -6149,21 +6148,21 @@ packages: run_exports: {} size: 569349 timestamp: 1781670209146 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda - sha256: 66aa216a403de0bb0c1340a88d1a06adaff66bae2cfd196731aa24db9859d631 - md5: 44083d2d2c2025afca315c7a172eab2b +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321h26f1114_1.conda + sha256: 257c926f19e32bdb981fc674c76966049b6fc73f706bae58e9fed8757ad1da70 + md5: 843ef89082f368cb889305084d3b483c depends: - ncurses - __osx >=11.0 - - ncurses >=6.5,<7.0a0 + - ncurses >=6.6,<7.0a0 license: BSD-2-Clause license_family: BSD purls: [] run_exports: weak: - libedit >=3.1.20250104,<3.2.0a0 - size: 107691 - timestamp: 1738479560845 + size: 107742 + timestamp: 1786616721640 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h1a92334_3.conda sha256: c0100064506ae8abb432c5a506d474f10af2cf48c33d62bc221fb28b6d6ff6ac md5: 19e86c8a6a47e92bb2e70ca12e758c5c @@ -6190,9 +6189,9 @@ packages: run_exports: {} size: 69362 timestamp: 1781203631990 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda - sha256: 6686a26466a527585e6a75cc2a242bf4a3d97d6d6c86424a441677917f28bec7 - md5: 43c04d9cb46ef176bb2a4c77e324d599 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.7.0-hcf2aa1b_0.conda + sha256: 2c6ac9a6cd65af89b2bd448518bb1e13b44a2e48c0d469398e37bcfc0092e832 + md5: 92e8690d170d46d768c32553458c0105 depends: - __osx >=11.0 license: MIT @@ -6200,9 +6199,9 @@ packages: purls: [] run_exports: weak: - - libffi >=3.5.2,<3.6.0a0 - size: 40979 - timestamp: 1769456747661 + - libffi >=3.7.0,<3.8.0a0 + size: 43734 + timestamp: 1783521647536 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda sha256: 23d0630046a3e8b164d8f80f2b74ed2605af2e7050ab9913018056402fae4311 md5: 8ab10323068b107661a4b9a4af84f3b5 @@ -6217,17 +6216,17 @@ packages: - liblzma >=5.8.3,<6.0a0 size: 91720 timestamp: 1786348695846 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda - sha256: 1089c7f15d5b62c622625ec6700732ece83be8b705da8c6607f4dabb0c4bd6d2 - md5: 57c4be259f5e0b99a5983799a228ae55 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_2.conda + sha256: 04cc136c5a956a73aa14e0a160b5822b0b29714e67b299fa1b1fd16dbcba5366 + md5: ff33a4dbd93abc8a798cc4e0e7c8136d depends: - __osx >=11.0 license: BSD-2-Clause license_family: BSD purls: [] run_exports: {} - size: 73690 - timestamp: 1769482560514 + size: 73289 + timestamp: 1786651074391 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h8f3e76b_0.conda sha256: 2bc7bc3978066f2c274ebcbf711850cc9ab92e023e433b9631958a098d11e10a md5: 6ea18834adbc3b33df9bd9fb45eaf95b @@ -6448,12 +6447,12 @@ packages: run_exports: {} size: 245502 timestamp: 1769678303655 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.1-py312h55b240b_0.conda - sha256: c69e1e2c7277d4704adf4c6e46e9c231db4a9f6de6c39a5f500b21be8705c97b - md5: c0ce814629aa18f83303246068ec41d2 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.2-py312hb3d15f4_0.conda + sha256: 9cca32cfd35320a2744ad9d4eff1a767e2f8f8d43ecaabbe9a55d803fa68bca7 + md5: c62ffd3ef5d459a0b63c863c50fb55c6 depends: - __osx >=11.3 - - libffi >=3.5.2,<3.6.0a0 + - libffi >=3.7.0,<3.8.0a0 - python >=3.12,<3.13.0a0 - python_abi 3.12.* *_cp312 - setuptools @@ -6462,14 +6461,14 @@ packages: purls: - pkg:pypi/pyobjc-core?source=hash-mapping run_exports: {} - size: 2161205 - timestamp: 1782232045788 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.1-py314h6590101_0.conda - sha256: d400216f2f724b20e56a47837d0924df8558a426af8042494bf59f9a317cdcc4 - md5: 3701b8006ce23a111d15ca1d514e9055 + size: 2158366 + timestamp: 1786667262155 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.2-py314h63b12ec_0.conda + sha256: 18577d2164b23888bc4a6b78f1bdfa12efa7253bf0e3f871ccf49fda8d18acd8 + md5: a34643ba983b0d8aed611d7aab0233db depends: - __osx >=11.3 - - libffi >=3.5.2,<3.6.0a0 + - libffi >=3.7.0,<3.8.0a0 - python >=3.14,<3.15.0a0 - python_abi 3.14.* *_cp314 - setuptools @@ -6478,53 +6477,54 @@ packages: purls: - pkg:pypi/pyobjc-core?source=hash-mapping run_exports: {} - size: 2165860 - timestamp: 1782232267924 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.2.1-py312h22cf174_0.conda - sha256: 3ee723cd632ee35fcd4f335509f0f6677a91147c262139fbd85863e9ea4e8116 - md5: c6a2979b4ed58ed1e57a0b4238987cf8 + size: 2183843 + timestamp: 1786667130341 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.2.2-py312h8b921b1_0.conda + sha256: 65a3260eaf9fe7969f60c120cedd662c384e14ebaff7dd66f6a46b4f89d10798 + md5: 84badc45caa397906295394bf020dba8 depends: - __osx >=11.3 - - libffi >=3.5.2,<3.6.0a0 - - pyobjc-core 12.2.1.* + - libffi >=3.7.0,<3.8.0a0 + - pyobjc-core 12.2.2.* - python >=3.12,<3.13.0a0 - python_abi 3.12.* *_cp312 license: MIT license_family: MIT purls: - - pkg:pypi/pyobjc-framework-cocoa?source=hash-mapping + - pkg:pypi/pyobjc-framework-cocoa?source=compressed-mapping run_exports: {} - size: 383474 - timestamp: 1782265775966 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.2.1-py314ha06c032_0.conda - sha256: 7368f25512f0920c4e5ad39b27cc34ecba1502b2bfbf7be0058352b31bb4e0f5 - md5: c28c88396a6c6fb064aa7bd1a7d7df4f + size: 385117 + timestamp: 1786682054070 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.2.2-py314hddd3963_0.conda + sha256: 4be5be6b7d204c4a18fa4eb679a0b2bc5136632df145ca4e8c22c3fae2f97b2e + md5: ac045b8c9dd989c9fcc86199fc68f7b1 depends: - __osx >=11.3 - - libffi >=3.5.2,<3.6.0a0 - - pyobjc-core 12.2.1.* + - libffi >=3.7.0,<3.8.0a0 + - pyobjc-core 12.2.2.* - python >=3.14,<3.15.0a0 - python_abi 3.14.* *_cp314 license: MIT license_family: MIT purls: - - pkg:pypi/pyobjc-framework-cocoa?source=hash-mapping + - pkg:pypi/pyobjc-framework-cocoa?source=compressed-mapping run_exports: {} - size: 383812 - timestamp: 1782265953125 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.12.13-h8561d8f_0_cpython.conda - sha256: e658e647a4a15981573d6018928dec2c448b10c77c557c29872043ff23c0eb6a - md5: 8e7608172fa4d1b90de9a745c2fd2b81 + size: 382991 + timestamp: 1786682078806 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.12.13-hd1323d7_1_cpython.conda + build_number: 1 + sha256: b375287c4fa8737c0a44af917d4c2b2cb9cd79e85d224733cd2b46165e9988b1 + md5: c83039bc99cd4b90204ca5c96f7fddda depends: - __osx >=11.0 - bzip2 >=1.0.8,<2.0a0 - - libexpat >=2.7.4,<3.0a0 - - libffi >=3.5.2,<3.6.0a0 - - liblzma >=5.8.2,<6.0a0 - - libsqlite >=3.51.2,<4.0a0 - - libzlib >=1.3.1,<2.0a0 - - ncurses >=6.5,<7.0a0 - - openssl >=3.5.5,<4.0a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.7.0,<3.8.0a0 + - liblzma >=5.8.3,<6.0a0 + - libsqlite >=3.53.4,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 - readline >=8.3,<9.0a0 - tk >=8.6.13,<8.7.0a0 - tzdata @@ -6537,20 +6537,20 @@ packages: - python_abi 3.12.* *_cp312 noarch: - python - size: 12127424 - timestamp: 1772730755512 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-h156bc91_101_cp314.conda - build_number: 101 - sha256: fc70ae73df7798bce7cac7adef7fdfb874208b2623a0e8ccb4354194b8508769 - md5: 6e9670f5238dfb27ef4f6364ed536cc0 + size: 13473493 + timestamp: 1786444752563 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-hf4d206d_102_cp314.conda + build_number: 102 + sha256: 9767b5eee5cef50716708787bb3b4225d2a974bcd50653e856f9db5910a4b17e + md5: 8da4ea285021110e2617f7538c386afc depends: - __osx >=11.0 - bzip2 >=1.0.8,<2.0a0 - libexpat >=2.8.1,<3.0a0 - - libffi >=3.5.2,<3.6.0a0 + - libffi >=3.7.0,<3.8.0a0 - liblzma >=5.8.3,<6.0a0 - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.53.3,<4.0a0 + - libsqlite >=3.53.4,<4.0a0 - libzlib >=1.3.2,<2.0a0 - ncurses >=6.6,<7.0a0 - openssl >=3.5.7,<4.0a0 @@ -6566,8 +6566,8 @@ packages: - python_abi 3.14.* *_cp314 noarch: - python - size: 14035244 - timestamp: 1784909523029 + size: 13960847 + timestamp: 1786444540722 python_site_packages_path: lib/python3.14/site-packages - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.3-py312h04c11ed_1.conda sha256: 737959262d03c9c305618f2d48c7f1691fb996f14ae420bfd05932635c99f873 @@ -6737,20 +6737,20 @@ packages: - zeromq >=4.3.5,<4.4.0a0 size: 245404 timestamp: 1779124076307 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - sha256: 9485ba49e8f47d2b597dd399e88f4802e100851b27c21d7525625b0b4025a5d9 - md5: ab136e4c34e97f34fb621d2592a393d8 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda + sha256: da867f5092eb0cb746d353694f0098031fd9817a4ce7d5743121209ae0f406ca + md5: 4ec2684c73812cc2c3d78379384a39cc depends: - __osx >=11.0 - - libzlib >=1.3.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 license: BSD-3-Clause license_family: BSD purls: [] run_exports: weak: - zstd >=1.5.7,<1.6.0a0 - size: 433413 - timestamp: 1764777166076 + size: 433687 + timestamp: 1786599629846 - conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py312he06e257_2.conda sha256: 38c5e43d991b0c43713fa2ceba3063afa4ccad2dd4c8eb720143de54d461a338 md5: 5dc3781bbc4ddce0bf250a04c1a192c2 @@ -6785,9 +6785,9 @@ packages: run_exports: {} size: 38653 timestamp: 1762509771011 -- conda: https://conda.anaconda.org/conda-forge/win-64/backports.zstd-1.6.0-py312h06d0912_0.conda - sha256: 9926f274d8b642f5421e4536952cb158912517f40acf1df3a8fbd891c5f600ed - md5: 0d8bcdc0af72309fb998811f5f4db2c5 +- conda: https://conda.anaconda.org/conda-forge/win-64/backports.zstd-1.7.0-py312h06d0912_0.conda + sha256: 492b36f6c1380562f16e7ac0b2aae2f74a6d66eb4806689a791ccdbd0b4fb162 + md5: d7c56279ddf11b597934de1b928e799d depends: - python - vc >=14.3,<15 @@ -6797,13 +6797,13 @@ packages: - zstd >=1.5.7,<1.6.0a0 license: BSD-3-Clause AND MIT AND EPL-2.0 purls: - - pkg:pypi/backports-zstd?source=hash-mapping + - pkg:pypi/backports-zstd?source=compressed-mapping run_exports: {} - size: 238542 - timestamp: 1781450836106 -- conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py312hc6d9e41_1.conda - sha256: 2bb6f384a51929ef2d5d6039fcf6c294874f20aaab2f63ca768cbe462ed4b379 - md5: e8e7a6346a9e50d19b4daf41f367366f + size: 239485 + timestamp: 1786861404460 +- conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py312ha763cb9_3.conda + sha256: ab6bc41db5efb67b68d54dc2631131e210ac8c1930ab30c4c6a6e2470c16be0c + md5: 4d0e6b94ff31b79f35a7f341e4eb73b0 depends: - python >=3.12,<3.13.0a0 - python_abi 3.12.* *_cp312 @@ -6811,17 +6811,17 @@ packages: - vc >=14.3,<15 - vc14_runtime >=14.44.35208 constrains: - - libbrotlicommon 1.2.0 hfd05255_1 + - libbrotlicommon 1.2.0 hf02afa3_3 license: MIT license_family: MIT purls: - - pkg:pypi/brotli?source=hash-mapping + - pkg:pypi/brotli?source=compressed-mapping run_exports: {} - size: 335482 - timestamp: 1764018063640 -- conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py314he701e3d_1.conda - sha256: 6854ee7675135c57c73a04849c29cbebc2fb6a3a3bfee1f308e64bf23074719b - md5: 1302b74b93c44791403cbeee6a0f62a3 + size: 336846 + timestamp: 1786622959392 +- conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py314h85cf176_3.conda + sha256: f96c411313beb92a6a9066b823f7e8ea085f3e3889219b44306c44d59f99e611 + md5: b1ff58c1f0deedd3f25c103e37049cee depends: - python >=3.14,<3.15.0a0 - python_abi 3.14.* *_cp314 @@ -6829,14 +6829,14 @@ packages: - vc >=14.3,<15 - vc14_runtime >=14.44.35208 constrains: - - libbrotlicommon 1.2.0 hfd05255_1 + - libbrotlicommon 1.2.0 hf02afa3_3 license: MIT license_family: MIT purls: - pkg:pypi/brotli?source=hash-mapping run_exports: {} - size: 335782 - timestamp: 1764018443683 + size: 336902 + timestamp: 1786623039339 - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_10.conda sha256: 04767466ee9227c9c57ab2c6503e0149177d34111c7418d2f420297acb1eb229 md5: c3301c058362f340100d91cd8be0393f @@ -6852,9 +6852,9 @@ packages: - bzip2 >=1.0.8,<2.0a0 size: 55919 timestamp: 1785906343696 -- conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py312he06e257_0.conda - sha256: c0a2b4e5de7c7673f2dd67e366b922681c7f89f3fa2320fe401450394a2f4b30 - md5: 5fa9c7ba107a4f84619bd0756a46017d +- conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py312he06e257_2.conda + sha256: 3f05fb00a62130f31e92046c1539743f0546da11c23787415346180fa508d2b8 + md5: e8ff864975e371017362605ac10498ab depends: - pycparser - python >=3.12,<3.13.0a0 @@ -6865,13 +6865,13 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/cffi?source=hash-mapping + - pkg:pypi/cffi?source=compressed-mapping run_exports: {} - size: 344465 - timestamp: 1785811084647 -- conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py314h5a2d7ad_0.conda - sha256: 5723b26b4b270ae35856ca0c32840d2c4e9689b0ab50360340570ed8691c0d05 - md5: 119a8675da30183b83520fe9c4c3f0c2 + size: 345248 + timestamp: 1786775168321 +- conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py314h5a2d7ad_2.conda + sha256: e0e10d676eb67a8a4c8991cec89fb6f150f919eb1266a1b39253a857b3dc0454 + md5: 672d6ff72c6265b25eeef94ce21e71ac depends: - pycparser - python >=3.14,<3.15.0a0 @@ -6882,10 +6882,10 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/cffi?source=hash-mapping + - pkg:pypi/cffi?source=compressed-mapping run_exports: {} - size: 347670 - timestamp: 1785811045491 + size: 346657 + timestamp: 1786775160153 - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.21-py312ha1a9051_0.conda sha256: a41f403ca4b7b0c001140ac7a39fce1c0494ba95e8359f7a47590ed4377728a2 md5: 5628287239c9ef6df2d66dc143f7c8dd @@ -6918,9 +6918,9 @@ packages: run_exports: {} size: 4022782 timestamp: 1780390190830 -- conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_1.conda - sha256: c55745796e762ba9e817ab1fc0f21f1a049e202f90fa762df39578f37923f6c2 - md5: 00335c2c4a98656554771aaf6f1a7400 +- conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_2.conda + sha256: 63ff03324e903eb01a715ccf357df56d66224e61952fd6615d86490ebefb3285 + md5: 93f5a01dec294a2228f757fe2f3432d4 depends: - openssl >=3.5.7,<4.0a0 - ucrt >=10.0.20348.0 @@ -6932,8 +6932,8 @@ packages: run_exports: weak: - krb5 >=1.22.2,<1.23.0a0 - size: 750320 - timestamp: 1781859644591 + size: 753425 + timestamp: 1786762169034 - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda sha256: 1a54d874addda73b6f7164d5f3905821277a1831bcc05edd74b3085391688571 md5: ccc490c81ffe14181861beac0e8f3169 @@ -6949,9 +6949,9 @@ packages: run_exports: {} size: 71631 timestamp: 1781203724164 -- conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda - sha256: 59d01f2dfa8b77491b5888a5ab88ff4e1574c9359f7e229da254cdfe27ddc190 - md5: 720b39f5ec0610457b725eb3f396219a +- conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.7.0-h3d046cb_0.conda + sha256: 2ea8d2fe7b84ca37653777e15ac1e7abd35f0c90d3efbe7f6c4de9b489606369 + md5: 92bdfc0e5012660892b0e0eaf3069a5c depends: - ucrt >=10.0.20348.0 - vc >=14.3,<15 @@ -6961,9 +6961,9 @@ packages: purls: [] run_exports: weak: - - libffi >=3.5.2,<3.6.0a0 - size: 45831 - timestamp: 1769456418774 + - libffi >=3.7.0,<3.8.0a0 + size: 50247 + timestamp: 1783521107166 - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_1.conda sha256: d36c4a1e1f80fd08e18a407e03622ff2f34dfdd022da6488ad19603dea19e6d5 md5: 880a0c8549479b198af21ba5dc49b109 @@ -6980,9 +6980,9 @@ packages: - liblzma >=5.8.3,<6.0a0 size: 105809 timestamp: 1786348717883 -- conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_1.conda - sha256: 40dcd0b9522a6e0af72a9db0ced619176e7cfdb114855c7a64f278e73f8a7514 - md5: e4a9fc2bba3b022dad998c78856afe47 +- conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_2.conda + sha256: f07e451de3db1836b87f7aedf95c8e65cdb06c0e6105329ba24bb5f7b5c75e2a + md5: 5ae92fd6614edd024576e14069d7ad4c depends: - ucrt >=10.0.20348.0 - vc >=14.3,<15 @@ -6991,8 +6991,8 @@ packages: license_family: BSD purls: [] run_exports: {} - size: 89411 - timestamp: 1769482314283 + size: 89109 + timestamp: 1786650384519 - conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.22-h6a83c73_1.conda sha256: de45b71224da77a1c3a7dd48d8885eb957c9f05455d4f0828463293e7144330f md5: 7d5abf7ca1bd00b43d273f44d93d05dc @@ -7165,17 +7165,18 @@ packages: run_exports: {} size: 249950 timestamp: 1769678167309 -- conda: https://conda.anaconda.org/conda-forge/win-64/python-3.12.13-h0159041_0_cpython.conda - sha256: a02b446d8b7b167b61733a3de3be5de1342250403e72a63b18dac89e99e6180e - md5: 2956dff38eb9f8332ad4caeba941cfe7 +- conda: https://conda.anaconda.org/conda-forge/win-64/python-3.12.13-hb12b558_1_cpython.conda + build_number: 1 + sha256: 14a64c3256f018e185490fd64bbdf29c327a6143432fb6545363ddf49ceababb + md5: a028e8d8ad74ff4e53af5411cb34e9c2 depends: - bzip2 >=1.0.8,<2.0a0 - - libexpat >=2.7.4,<3.0a0 - - libffi >=3.5.2,<3.6.0a0 - - liblzma >=5.8.2,<6.0a0 - - libsqlite >=3.51.2,<4.0a0 - - libzlib >=1.3.1,<2.0a0 - - openssl >=3.5.5,<4.0a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.7.0,<3.8.0a0 + - liblzma >=5.8.3,<6.0a0 + - libsqlite >=3.53.4,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - openssl >=3.5.7,<4.0a0 - tk >=8.6.13,<8.7.0a0 - tzdata - ucrt >=10.0.20348.0 @@ -7190,19 +7191,19 @@ packages: - python_abi 3.12.* *_cp312 noarch: - python - size: 15840187 - timestamp: 1772728877265 -- conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.6-h4b44e0e_101_cp314.conda - build_number: 101 - sha256: 3a9ae901cd853d507d97aa8b72af4b9a572a3f92dcc5bad8a1318f77ff4e0e64 - md5: 67bbf51f88a2053513d7c78f485f7479 + size: 15891265 + timestamp: 1786443666090 +- conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.6-h53f6dd8_102_cp314.conda + build_number: 102 + sha256: 9faac11f2b7813585f428310df3768e2a465205d76c3829f8ccbd05e6b98764f + md5: 048ad2c1b1faf11cda7bb8c7ec998b0c depends: - bzip2 >=1.0.8,<2.0a0 - libexpat >=2.8.1,<3.0a0 - - libffi >=3.5.2,<3.6.0a0 + - libffi >=3.7.0,<3.8.0a0 - liblzma >=5.8.3,<6.0a0 - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.53.3,<4.0a0 + - libsqlite >=3.53.4,<4.0a0 - libzlib >=1.3.2,<2.0a0 - openssl >=3.5.7,<4.0a0 - python_abi 3.14.* *_cp314 @@ -7219,8 +7220,8 @@ packages: - python_abi 3.14.* *_cp314 noarch: - python - size: 18338767 - timestamp: 1784911044838 + size: 17903528 + timestamp: 1786444689733 python_site_packages_path: Lib/site-packages - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-312-py312h829343e_0.conda sha256: e4560c30234075bf17c641cb651279ff6c6f2bad581dfc37ed780f159909b2d3 @@ -7515,22 +7516,22 @@ packages: - zeromq >=4.3.5,<4.3.6.0a0 size: 265717 timestamp: 1779124031378 -- conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - sha256: 368d8628424966fd8f9c8018326a9c779e06913dd39e646cf331226acc90e5b2 - md5: 053b84beec00b71ea8ff7a4f84b55207 +- conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda + sha256: ca7daae4f218a11fab82cc2857f0ea518ec3f46acec60490485347a4c22c6b3e + md5: e4ac308c39d6d0e131154976da67cf3b depends: - vc >=14.3,<15 - vc14_runtime >=14.44.35208 - ucrt >=10.0.20348.0 - - libzlib >=1.3.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 license: BSD-3-Clause license_family: BSD purls: [] run_exports: weak: - zstd >=1.5.7,<1.6.0a0 - size: 388453 - timestamp: 1764777142545 + size: 387535 + timestamp: 1786599623274 - pypi: ./ name: easydynamics requires_dist: @@ -7583,11 +7584,6 @@ packages: - validate-pyproject[all] ; extra == 'dev' - versioningit ; extra == 'dev' requires_python: '>=3.12' -- pypi: https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl - name: ruff - version: 0.16.2 - sha256: bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa - requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl name: distlib version: 0.4.3 @@ -7762,6 +7758,11 @@ packages: requires_dist: - tomli ; python_full_version <= '3.11' and extra == 'toml' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/0d/53/8da1f4758286efd8faf71356facddb382788ecf1bbd7c70d63e2e18a4898/chardet-7.6.0-cp312-cp312-win_amd64.whl + name: chardet + version: 7.6.0 + sha256: 406936df1328a3284fef366eaa2bfd1cccd0ef1b10cb99781dd5b022ea644b84 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl name: build version: 1.5.0 @@ -7777,11 +7778,6 @@ packages: - virtualenv>=20.17 ; python_full_version >= '3.10' and python_full_version < '3.14' and extra == 'virtualenv' - virtualenv>=20.31 ; python_full_version >= '3.14' and extra == 'virtualenv' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/10/56/89866e9995fdb2c8e8ff1336c4ecd4c86ba0f7e4622ccfacad2c13b2ba7e/chardet-7.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: chardet - version: 7.5.1 - sha256: ecbe0e0a9fff7825fc48650ef297ede49c71a7abc411a0638416207a70bf78c0 - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl name: pyparsing version: 3.3.2 @@ -7961,19 +7957,6 @@ packages: version: 1.2.1 sha256: 74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/17/3d/26e14cf47c56c9ba3c3e12cae21f24716bc3182bb52260213ec0c819d0b9/python_engineio-4.13.4-py3-none-any.whl - name: python-engineio - version: 4.13.4 - sha256: 272de73124e255d3d2bba6f86358c1a1ba618f938f337a0c868b60550fe38719 - requires_dist: - - simple-websocket>=0.10.0 - - requests>=2.21.0 ; extra == 'client' - - websocket-client>=0.54.0 ; extra == 'client' - - aiohttp>=3.11 ; extra == 'asyncio-client' - - tox ; extra == 'dev' - - sphinx ; extra == 'docs' - - furo ; extra == 'docs' - requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl name: yarl version: 1.24.5 @@ -8359,11 +8342,6 @@ packages: - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl - name: ruff - version: 0.16.2 - sha256: a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700 - requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl name: tokenize-rt version: 6.2.0 @@ -8505,6 +8483,11 @@ packages: - pytest-mock>=3.10.0 ; extra == 'test' - pytest>=7.0.0 ; extra == 'test' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl + name: ruff + version: 0.16.3 + sha256: e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9 + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/3e/f6/5e7d38c91b3b104dc455ec2e6e475b83b689ac6623acfbebdefd3be932ad/scipp-26.8.0-cp312-cp312-win_amd64.whl name: scipp version: 26.8.0 @@ -8536,6 +8519,18 @@ packages: version: 4.0.15 sha256: 8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366 requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl + name: virtualenv + version: 21.7.4 + sha256: 376ec93cd6aab3044fa395d7db226db38043b7b5748948044b2a87168525e843 + requires_dist: + - distlib>=0.3.7,<1 + - filelock>=3.24.2,<4 ; python_full_version >= '3.10' + - filelock>=3.16.1,<=3.19.1 ; python_full_version < '3.10' + - platformdirs>=3.9.1,<5 + - python-discovery>=1.4.2 + - typing-extensions>=4.13.2 ; python_full_version < '3.11' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl name: pydantic-core version: 2.46.4 @@ -8543,13 +8538,6 @@ packages: requires_dist: - typing-extensions>=4.14.1 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/42/f7/7a3935abdebd5cf18705a5f0335dd6a3a18bef3baa7cb9edc3b6b9922cc8/typing_inspection-0.4.3-py3-none-any.whl - name: typing-inspection - version: 0.4.3 - sha256: 5f42b23858a91e0b4ef521f5418f03a0da3c9216fd2995ef5e73463100e676cd - requires_dist: - - typing-extensions>=4.15.0 - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl name: mpmath version: 1.3.0 @@ -8563,6 +8551,11 @@ packages: - sphinx ; extra == 'docs' - gmpy2>=2.1.0a4 ; platform_python_implementation != 'PyPy' and extra == 'gmpy' - pytest>=4.6 ; extra == 'tests' +- pypi: https://files.pythonhosted.org/packages/44/99/934fb862d102c8756008597f4398323f32cef329f16e87fbb3bf76d4f4be/chardet-7.6.0-cp312-cp312-macosx_11_0_arm64.whl + name: chardet + version: 7.6.0 + sha256: a12023d48d0e207791c01161d03cb3c0d85c6a15f345eb9d3d56063a63d1e40f + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl name: pillow version: 12.3.0 @@ -8594,6 +8587,17 @@ packages: - trove-classifiers>=2024.10.12 ; extra == 'tests' - defusedxml ; extra == 'xmp' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl + name: pre-commit + version: 4.6.2 + sha256: e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e + requires_dist: + - cfgv>=2.0.0 + - identify>=1.0.0 + - nodeenv>=0.11.1 + - pyyaml>=5.1 + - virtualenv>=20.10.0 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl name: kiwisolver version: 1.5.0 @@ -8709,55 +8713,6 @@ packages: - pytest-cov ; extra == 'test' - pytz ; extra == 'test' requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/56/f2/d341201e61008b5531928ee542f05f8b6eb96bbd3d1772b19037a581ccde/easydynamics-0.9.1-py3-none-any.whl - name: easydynamics - version: 0.9.1 - sha256: d8442e37373d6c1ec7186b3c0ec6486ab01c7069bbee76a3c6e850efd1179769 - requires_dist: - - darkdetect - - easyscience - - ipykernel - - ipympl - - ipython - - ipywidgets - - jupyterlab - - pixi-kernel - - plopp - - pooch - - sympy - - build ; extra == 'dev' - - copier ; extra == 'dev' - - docstring-parser-fork!=0.0.15 ; extra == 'dev' - - docstripy ; extra == 'dev' - - format-docstring ; extra == 'dev' - - gitpython ; extra == 'dev' - - interrogate ; extra == 'dev' - - jinja2 ; extra == 'dev' - - jupyterquiz ; extra == 'dev' - - jupytext ; extra == 'dev' - - mike ; extra == 'dev' - - mkdocs ; extra == 'dev' - - mkdocs-autorefs ; extra == 'dev' - - mkdocs-jupyter ; extra == 'dev' - - mkdocs-markdownextradata-plugin ; extra == 'dev' - - mkdocs-material ; extra == 'dev' - - mkdocs-plugin-inline-svg ; extra == 'dev' - - mkdocstrings-python ; extra == 'dev' - - nbmake ; extra == 'dev' - - nbqa ; extra == 'dev' - - nbstripout ; extra == 'dev' - - pre-commit ; extra == 'dev' - - pydoclint ; extra == 'dev' - - pytest ; extra == 'dev' - - pytest-cov ; extra == 'dev' - - pytest-xdist ; extra == 'dev' - - pyyaml ; extra == 'dev' - - radon ; extra == 'dev' - - ruff ; extra == 'dev' - - spdx-headers ; extra == 'dev' - - validate-pyproject[all] ; extra == 'dev' - - versioningit ; extra == 'dev' - requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl name: msgpack version: 1.2.1 @@ -8890,6 +8845,13 @@ packages: - mkdocs-section-index ; extra == 'docs' - mkdocs-literate-nav ; extra == 'docs' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl + name: typing-inspection + version: 0.4.4 + sha256: 65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147 + requires_dist: + - typing-extensions>=4.15.0 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/67/c1/80e24e592c87779dd35c1718911479d47526bbc0e1cfc0d20ea88ae94057/scipp-26.8.0-cp312-cp312-macosx_14_0_arm64.whl name: scipp version: 26.8.0 @@ -9006,13 +8968,6 @@ packages: - xlsxwriter>=3.2.0 ; extra == 'all' - zstandard>=0.23.0 ; extra == 'all' requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl - name: python-discovery - version: 1.5.1 - sha256: ac07f44cade589d954e9d6a1e1468539fdddd2cf676beb51da73e0f156b7c932 - requires_dist: - - filelock>=3.15.4 - requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/6a/b7/9366ed44ced9b7ef357ab48c94205280276db9d7f064aa3012a97227e966/h5py-3.16.0-cp314-cp314-macosx_11_0_arm64.whl name: h5py version: 3.16.0 @@ -9030,6 +8985,19 @@ packages: version: 1.2.1 sha256: 020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/6b/01/f804208061b504894546fddc479f6075e0f00dfe88cb1703dafaa8c3c67e/python_engineio-4.13.5-py3-none-any.whl + name: python-engineio + version: 4.13.5 + sha256: 05c9f4951d242ad33d613b4245299562e5f64e4199f00e5390f9888505831704 + requires_dist: + - simple-websocket>=0.10.0 + - requests>=2.21.0 ; extra == 'client' + - websocket-client>=0.54.0 ; extra == 'client' + - aiohttp>=3.11 ; extra == 'asyncio-client' + - tox ; extra == 'dev' + - sphinx ; extra == 'docs' + - furo ; extra == 'docs' + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/6b/be/92dd42844fe8a78c2c4a87f8078b9263dcc20aabe86b8420302a6fabaf4a/scipp-26.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl name: scipp version: 26.8.0 @@ -9151,6 +9119,13 @@ packages: version: 2.7.1 sha256: 9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl + name: python-discovery + version: 1.5.2 + sha256: 3e338c2d0f15dfaeea57493f4c2c6caebe0e998ea815c30ae8bf8ee21f1112d3 + requires_dist: + - filelock>=3.15.4 + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl name: jinja2-ansible-filters version: 1.3.2 @@ -9200,23 +9175,6 @@ packages: - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/76/bb/32871c9e393f174a60930a29873b6a4217b3f1c65667cad303ef146caedc/chardet-7.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: chardet - version: 7.5.1 - sha256: 9c378ccd8c0fab30171ed7c54d501f72c4294d9b98c71ea1ff7852aa9ccac399 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/77/2a/83d779d2dfb61f101d7b1c10073d18984e37262d8b4f171c99911a952430/virtualenv-21.7.3-py3-none-any.whl - name: virtualenv - version: 21.7.3 - sha256: 26dfda3c34f29bf1a3ca167426a67658d59979b9954e705aef60a5f724ce1773 - requires_dist: - - distlib>=0.3.7,<1 - - filelock>=3.24.2,<4 ; python_full_version >= '3.10' - - filelock>=3.16.1,<=3.19.1 ; python_full_version < '3.10' - - platformdirs>=3.9.1,<5 - - python-discovery>=1.4.2 - - typing-extensions>=4.13.2 ; python_full_version < '3.11' - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl name: fonttools version: 4.63.0 @@ -9251,10 +9209,20 @@ packages: - skia-pathops>=0.5.0 ; extra == 'all' - uharfbuzz>=0.45.0 ; extra == 'all' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/7a/40/0f95e04cb1820e0a582cd6d86bbf26be8302a94ccf330f8ba5f69735389d/chardet-7.6.0-cp314-cp314-macosx_11_0_arm64.whl + name: chardet + version: 7.6.0 + sha256: fc1e1571321baf8927582fe34363ad7f02279f11c8c2839c14b4c76894148db6 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl name: trove-classifiers version: 2026.6.1.19 sha256: ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3 +- pypi: https://files.pythonhosted.org/packages/7c/a9/ff4fef15ed25fc3f945a3b981ae0f43c8559b3fbedb40267e59e583d105b/chardet-7.6.0-cp314-cp314-win_amd64.whl + name: chardet + version: 7.6.0 + sha256: 0f304de7041afaec0195ad6464937cd112392002e9d72ed15d55f20a9abd3a13 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/7c/c6/76ee9dacedcd8c67d8fa53dd975613733bdd28242a4c41518ff1c8aeaa64/jupytext-1.19.5-py3-none-any.whl name: jupytext version: 1.19.5 @@ -9333,6 +9301,11 @@ packages: - pytest-xdist ; extra == 'test-integration' - bash-kernel ; extra == 'test-ui' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/7d/a2/c4d99299e9ce7fad561f8bb56babbbbdd3bb6b4fbd7c0ec674c1dbdd2cc5/chardet-7.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: chardet + version: 7.6.0 + sha256: 2cf0adaca8b1c4bacfade9d0a1e4f8f70b1bb122833d6f07ab90e3adc84eb13a + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl name: contourpy version: 1.3.3 @@ -9525,11 +9498,6 @@ packages: - trove-classifiers>=2024.10.12 ; extra == 'tests' - defusedxml ; extra == 'xmp' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/85/84/5690a64afecf9967c3844ec96842d549e6f3ef72009bfd5524b69111245a/chardet-7.5.1-cp314-cp314-macosx_11_0_arm64.whl - name: chardet - version: 7.5.1 - sha256: 6eefafa763b7099c3c0a86c343097d69b766b3fe5705edba9400bae26450af1f - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/85/d7/9b6ac05350ab7f7d3a730ff143ff3e2cada54514117c37be37e26dc91242/docstripy-0.7.2-py3-none-any.whl name: docstripy version: 0.7.2 @@ -10112,6 +10080,11 @@ packages: - pytest-regressions ; extra == 'testing' - pytest-timeout ; extra == 'testing' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl + name: filelock + version: 3.32.3 + sha256: 7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl name: frozenlist version: 1.8.0 @@ -10190,6 +10163,21 @@ packages: requires_dist: - numpy>=1.21.2 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/b2/6d/77546d8c26f038fce314a507106954f76270f6c182488bcf9ac9721175df/mkdocstrings_python-2.0.7-py3-none-any.whl + name: mkdocstrings-python + version: 2.0.7 + sha256: 1fce5fbfe4ffa6e8136a35351cdc97c3bf55219c7efbd3f92a82260f93235d60 + requires_dist: + - mkdocstrings>=0.30 + - mkdocs-autorefs>=1.4 + - griffelib>=2.0 + - typing-extensions>=4.0 ; python_full_version < '3.11' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl + name: ruff + version: 0.16.3 + sha256: e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948 + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl name: mdurl version: 0.1.2 @@ -10300,11 +10288,6 @@ packages: version: 5.0.3 sha256: c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl - name: filelock - version: 3.32.2 - sha256: 87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82 - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl name: fonttools version: 4.63.0 @@ -10353,16 +10336,6 @@ packages: - multidict>=4.0 - propcache>=0.2.1 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/c4/8e/847935c588455b0d82fa57a5a8ced4c73a928e30f2012639228e566e3283/chardet-7.5.1-cp312-cp312-macosx_11_0_arm64.whl - name: chardet - version: 7.5.1 - sha256: 8a001a8f030625b705d9a4e68116e573462bd38192cc6c1bfa318b45606747ac - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - name: ruff - version: 0.16.2 - sha256: ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f - requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/c6/3d/020a6b6248c3d4a37797db068256f0b3f15b01bc481327ba888c50309aa8/mkdocs_plugin_inline_svg-0.1.0-py3-none-any.whl name: mkdocs-plugin-inline-svg version: 0.1.0 @@ -10370,6 +10343,11 @@ packages: requires_dist: - mkdocs requires_python: '>=3.5' +- pypi: https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: ruff + version: 0.16.3 + sha256: 294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl name: numpy version: 2.5.2 @@ -10535,16 +10513,6 @@ packages: version: 0.4.6 sha256: 4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*' -- pypi: https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl - name: mkdocstrings-python - version: 2.0.5 - sha256: 30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d - requires_dist: - - mkdocstrings>=0.30 - - mkdocs-autorefs>=1.4 - - griffelib>=2.0 - - typing-extensions>=4.0 ; python_full_version < '3.11' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl name: mando version: 0.7.1 @@ -10631,6 +10599,55 @@ packages: - trove-classifiers>=2024.10.12 ; extra == 'tests' - defusedxml ; extra == 'xmp' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/d9/be/7e6bf4088d003432e9a511656b90e3ec2abf3ff54a6057d2fa6e8ecfcbf1/easydynamics-0.9.2-py3-none-any.whl + name: easydynamics + version: 0.9.2 + sha256: 72919e1bfc42048a0d1fe6fe29d20c3c02968cc0cfbc0b2c9fb9a2fbc7fa7a11 + requires_dist: + - darkdetect + - easyscience + - ipykernel + - ipympl + - ipython + - ipywidgets + - jupyterlab + - pixi-kernel + - plopp + - pooch + - sympy + - build ; extra == 'dev' + - copier ; extra == 'dev' + - docstring-parser-fork!=0.0.15 ; extra == 'dev' + - docstripy ; extra == 'dev' + - format-docstring ; extra == 'dev' + - gitpython ; extra == 'dev' + - interrogate ; extra == 'dev' + - jinja2 ; extra == 'dev' + - jupyterquiz ; extra == 'dev' + - jupytext ; extra == 'dev' + - mike ; extra == 'dev' + - mkdocs ; extra == 'dev' + - mkdocs-autorefs ; extra == 'dev' + - mkdocs-jupyter ; extra == 'dev' + - mkdocs-markdownextradata-plugin ; extra == 'dev' + - mkdocs-material ; extra == 'dev' + - mkdocs-plugin-inline-svg ; extra == 'dev' + - mkdocstrings-python ; extra == 'dev' + - nbmake ; extra == 'dev' + - nbqa ; extra == 'dev' + - nbstripout ; extra == 'dev' + - pre-commit ; extra == 'dev' + - pydoclint ; extra == 'dev' + - pytest ; extra == 'dev' + - pytest-cov ; extra == 'dev' + - pytest-xdist ; extra == 'dev' + - pyyaml ; extra == 'dev' + - radon ; extra == 'dev' + - ruff ; extra == 'dev' + - spdx-headers ; extra == 'dev' + - validate-pyproject[all] ; extra == 'dev' + - versioningit ; extra == 'dev' + requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl name: cfgv version: 3.5.0 @@ -10650,11 +10667,6 @@ packages: requires_dist: - pyyaml>=3.10 ; extra == 'watchmedo' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/dc/9a/9e17c1c6fbc65f9cba07951d359a24c8f7b17d3ca26bd54f33fd98b70f2e/chardet-7.5.1-cp312-cp312-win_amd64.whl - name: chardet - version: 7.5.1 - sha256: fad6fbc154113e3b17bb757c34b21477e4b6d69fdd4ce51ff2b3f29a42f08b5b - requires_python: '>=3.10' - pypi: 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 name: scipy version: 1.18.0 @@ -10734,15 +10746,6 @@ packages: - nodejs ; extra == 'all' - pythreejs ; extra == 'all' requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl - name: griffelib - version: 2.1.0 - sha256: cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00 - requires_dist: - - pip>=24.0 ; extra == 'pypi' - - platformdirs>=4.2 ; extra == 'pypi' - - wheel>=0.42 ; extra == 'pypi' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/e6/90/90a65e6ae1b6e66183b48874d32509fc306c994beecc7924a6fa3d9f8955/easyscience-2.5.1-py3-none-any.whl name: easyscience version: 2.5.1 @@ -10811,11 +10814,6 @@ packages: version: 1.5.0 sha256: 80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/e9/8f/d871b357287caae0483d2cd235fae476da3768dd7d56e1fe733ffd3f707c/chardet-7.5.1-cp314-cp314-win_amd64.whl - name: chardet - version: 7.5.1 - sha256: 46d10bbb7ba7ba345694fe0276a61290d4cc25d3624c03282311dbc58c1d49b4 - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl name: yarl version: 1.24.5 @@ -10993,6 +10991,11 @@ packages: version: 2.5.2 sha256: 6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/f4/88/360064c4c7d9d0664561dae03b74c871d2f5332b329f5c99f1c997fb869a/chardet-7.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: chardet + version: 7.6.0 + sha256: cedbc584789eb2edfde20fd03669972a833ce6019e60014ae613f9bfc440e8e3 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/f4/a4/61adb19f3c74b0dc0e411de4f06ebef564b1f179928f9dffcbd4b378f2ef/jupyter_notebook_parser-0.1.4-py2.py3-none-any.whl name: jupyter-notebook-parser version: 0.1.4 @@ -11017,6 +11020,15 @@ packages: - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/f6/b6/f65ac785d4ac90dcf7c831ac6256f5dd4a19780f4e1575b2c0d6eeebe319/griffelib-2.2.0-py3-none-any.whl + name: griffelib + version: 2.2.0 + sha256: d71c3bc2bbed9f958488634fe788b843a9f705d6d2838ca32cd6c25eeb64dfc4 + requires_dist: + - pip>=24.0 ; extra == 'pypi' + - platformdirs>=4.2 ; extra == 'pypi' + - wheel>=0.42 ; extra == 'pypi' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl name: py version: 1.11.0 @@ -11122,17 +11134,6 @@ packages: - xlsxwriter>=3.2.0 ; extra == 'all' - zstandard>=0.23.0 ; extra == 'all' requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl - name: pre-commit - version: 4.6.1 - sha256: 0e3b2942510d1fb34eec167a3ec57331bf8442122f1153a9fb8b58f5c49b2717 - requires_dist: - - cfgv>=2.0.0 - - identify>=1.0.0 - - nodeenv>=0.11.1 - - pyyaml>=5.1 - - virtualenv>=20.10.0 - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl name: aiosignal version: 1.4.0