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/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 new file mode 100644 index 000000000..d76a2c3b9 --- /dev/null +++ b/docs/docs/tutorials/bayesian.ipynb @@ -0,0 +1,455 @@ +{ + "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", + "\n", + "# Make the plots interactive; the Q sliders need the widget backend\n", + "%matplotlib widget" + ] + }, + { + "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 = edyn.ComponentCollection()\n", + "vanadium_components.append_component(edyn.Gaussian(width=0.1, area=1, name='Res. Gauss'))\n", + "\n", + "instrument_model = edyn.InstrumentModel(\n", + " background_model=edyn.BackgroundModel(components=edyn.Polynomial(coefficients=[0.001])),\n", + ")\n", + "\n", + "analysis = edyn.Analysis1d(\n", + " display_name='Vanadium Analysis',\n", + " experiment=vanadium_experiment,\n", + " sample_model=edyn.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.\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%." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "861fa264", + "metadata": {}, + "outputs": [], + "source": [ + "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.')" + ] + }, + { + "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": "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", + "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.\n", + "\n", + "The band defaults to the 68% credible interval; `credible_interval=95.0` widens it to 95%." + ] + }, + { + "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": "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 = 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=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", + "\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", + "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.\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." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "default", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} 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..128e7434e 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", @@ -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/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/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/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 bb6403252..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", @@ -531,6 +530,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", @@ -550,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]))" ] }, { @@ -573,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/docs/mkdocs.yml b/docs/mkdocs.yml index 64e94f967..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 @@ -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..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,35 +7516,40 @@ 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: - darkdetect - - easyscience + - 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' @@ -7578,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 @@ -7757,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 @@ -7772,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 @@ -7956,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 @@ -8354,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 @@ -8500,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 @@ -8531,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 @@ -8538,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 @@ -8558,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 @@ -8589,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 @@ -8704,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 @@ -8885,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 @@ -9001,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 @@ -9025,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 @@ -9146,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 @@ -9195,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 @@ -9246,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 @@ -9328,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 @@ -9520,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 @@ -10107,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 @@ -10185,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 @@ -10295,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 @@ -10348,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 @@ -10365,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 @@ -10530,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 @@ -10626,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 @@ -10645,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 @@ -10729,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 @@ -10806,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 @@ -10988,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 @@ -11012,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 @@ -11117,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 diff --git a/pixi.toml b/pixi.toml index f26b4fb7e..007abbce4 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'] } @@ -113,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' @@ -128,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!"' @@ -172,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', @@ -264,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 b39261f87..c8ff0397c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,17 +23,22 @@ 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 + '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) + '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] @@ -166,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 f4c956e5b..de7391225 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.fit_binding import FitBinding -from easydynamics.analysis.parameter_analysis import ParameterAnalysis +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.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 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 __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/__init__.py b/src/easydynamics/analysis/__init__.py index 289ec02f5..2cb853cef 100644 --- a/src/easydynamics/analysis/__init__.py +++ b/src/easydynamics/analysis/__init__.py @@ -2,9 +2,27 @@ # 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 +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', + 'Analysis1d', + 'BoundsSuggestion', + 'BoundsSuggestions', + 'FitBinding', + 'MultiQPosteriorSampler', 'ParameterAnalysis', + 'ParameterLabels', + 'ParameterPosterior', + 'PosteriorSampler', + 'PosteriorSummary', ] diff --git a/src/easydynamics/analysis/analysis.py b/src/easydynamics/analysis/analysis.py index 8fb3d703d..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 @@ -14,6 +16,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 +34,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** @@ -39,7 +47,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', @@ -48,10 +55,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, ) @@ -117,6 +124,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 +182,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 ############# @@ -224,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, @@ -283,8 +363,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: @@ -373,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, @@ -389,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', @@ -456,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), @@ -608,8 +697,6 @@ def plot_parameters( plot_kwargs_defaults.update(kwargs) - import plopp as pp - return pp.plot( data_to_plot, **plot_kwargs_defaults, @@ -661,6 +748,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 +757,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 +766,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 +775,20 @@ 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 _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.""" @@ -695,6 +802,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 +822,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. @@ -773,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. @@ -877,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 e50b09737..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 @@ -12,6 +14,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 +35,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** @@ -39,8 +47,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', @@ -49,15 +55,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, @@ -116,6 +122,12 @@ 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 super().__init__( display_name=display_name, @@ -245,27 +257,141 @@ 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. + + 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. + """ + 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, @@ -353,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, @@ -365,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', @@ -437,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), @@ -483,9 +620,11 @@ 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.""" + self._convolver_model_versions = self._model_state_versions() self._convolver = self._create_convolver(energy=energy) self._convolver_is_dirty = False @@ -523,10 +662,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,25 +677,34 @@ 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.""" 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/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 7e24108e1..1e99f006a 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 @@ -38,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'}, @@ -62,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( @@ -98,6 +100,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 +139,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 +164,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 +261,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, _, _ = 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]: + """ + 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,17 +324,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 + + ############# + # The contract PosteriorSampler relies on + ############# + + def _build_fitter(self) -> MultiFitter: + """ + Build the MultiFitter over the binding models. - return mf.fit( - x=xs, - y=ys, - weights=ws, + 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() + return MultiFitter(fit_objects=models, fit_functions=funcs) + + def _target_signature(self) -> tuple: + """ + Summarize what the fitter was built for, in target order. + + 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( + (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) -> 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 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() != 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, _, _ = self._build_fit_inputs() + self._invalidate_fitter_if_targets_changed() + 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] ) -> InteractiveFigure: @@ -250,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: @@ -267,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] @@ -394,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: """ @@ -443,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: @@ -604,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.py b/src/easydynamics/analysis/posterior.py new file mode 100644 index 000000000..e2c197a8b --- /dev/null +++ b/src/easydynamics/analysis/posterior.py @@ -0,0 +1,717 @@ +# 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 + +import warnings +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy as np + +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 + +# 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. 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. + 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. 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 + ------- + 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: + """ + 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 _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, + 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, 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 + 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 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 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 + ---------- + 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..7d2615938 --- /dev/null +++ b/src/easydynamics/analysis/posterior_sampling.py @@ -0,0 +1,2070 @@ +# 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 inspect +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 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 + from collections.abc import Callable + + 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 + +# 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: + # 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.' + ) + 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=_stacklevel_above_module(), + ) + + ############# + # Results + ############# + + 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 + One entry per sampled parameter. + """ + 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=names, + parameters_by_column=parameters, + ) + + 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=_stacklevel_above_module(), + ) + 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. + """ + # 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() + 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. + """ + # 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() + 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. + """ + # 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: + 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 + + +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. + """ + # 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) + 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. + + 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=_stacklevel_above_module(), + ) + + +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/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 a2da80a20..7bf29c64e 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, @@ -80,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', } @@ -224,7 +224,7 @@ def _check_if_pair_is_analytic( Raises ------ - TypeError + ValueError If the resolution component is a DeltaFunction. Returns @@ -234,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) @@ -244,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) @@ -300,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, @@ -338,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. @@ -358,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 d064326f5..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. ' @@ -585,6 +582,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/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..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 @@ -33,11 +34,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 +47,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') @@ -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 90a63562f..b4dfe9a45 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 @@ -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 60c343a3d..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 @@ -37,9 +40,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 +51,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 ``` """ @@ -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/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..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 -------- @@ -41,9 +47,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', @@ -66,9 +72,12 @@ 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 = 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 +91,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'}, @@ -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 364e89aba..65a09a528 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 ``` @@ -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 fe36340fc..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 @@ -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 ``` @@ -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 e30ff96b1..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 @@ -33,9 +34,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 +45,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 +55,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 ``` @@ -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 ee8c29046..4a196ea7c 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 ``` @@ -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 b15d00120..c820a37f6 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, @@ -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 1e855243f..0424b5f98 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, @@ -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 67a693205..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 @@ -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, @@ -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/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/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 a9fc9e1ee..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 @@ -27,11 +28,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 +44,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) ``` """ @@ -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 6d699688b..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 @@ -34,15 +35,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 +55,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)) ``` """ @@ -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 5e644a06b..bb782a682 100644 --- a/src/easydynamics/utils/__init__.py +++ b/src/easydynamics/utils/__init__.py @@ -3,5 +3,16 @@ 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 +from easydynamics.utils.utils import hbar -__all__ = ['detailed_balance_factor', 'slicerplot_with_residuals'] +__all__ = [ + 'detailed_balance_factor', + 'hbar', + 'plot_corner', + 'plot_posterior_predictive', + 'plot_trace', + 'slicerplot_with_residuals', +] diff --git a/src/easydynamics/utils/detailed_balance.py b/src/easydynamics/utils/detailed_balance.py index 90fdf6504..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. @@ -75,9 +77,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** @@ -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/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/src/easydynamics/utils/posterior_plotting.py b/src/easydynamics/utils/posterior_plotting.py new file mode 100644 index 000000000..d1e45b146 --- /dev/null +++ b/src/easydynamics/utils/posterior_plotting.py @@ -0,0 +1,833 @@ +# 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 io +import warnings +from typing import TYPE_CHECKING +from typing import Any + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib import colormaps +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( + 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.' + ) + + +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/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 new file mode 100644 index 000000000..81c98ea57 --- /dev/null +++ b/tests/integration/fitting/test_bayesian_sampling.py @@ -0,0 +1,244 @@ +# 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. 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 + +import matplotlib as mpl +import numpy as np +import pytest +import scipp as sc + +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 +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, + # '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 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): + # 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 = analysis.bayesian.extend( + additional_samples=500, thin=2, sampler_kwargs={'trim': False, 'outliers': 'none'} + ) + + # 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 + 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/integration/fitting/test_bayesian_sampling_multi_q.py b/tests/integration/fitting/test_bayesian_sampling_multi_q.py new file mode 100644 index 000000000..52a4a93d8 --- /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 matplotlib.pyplot as plt + +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 + 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/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 fdedd845e..ab34bf879 100644 --- a/tests/unit/easydynamics/analysis/test_analysis.py +++ b/tests/unit/easydynamics/analysis/test_analysis.py @@ -1,19 +1,27 @@ # 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 from easydynamics.analysis.analysis import Analysis 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 from easydynamics.settings.convolution_settings import ConvolutionSettings +from easydynamics.settings.detailed_balance_settings import DetailedBalanceSettings + +Q_VALUES = [0.5, 1.0, 1.5] class TestAnalysis: @@ -70,6 +78,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 @@ -435,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) @@ -447,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 @@ -769,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') @@ -1141,3 +1323,125 @@ def test_repr(self, analysis): assert 'Analysis' in repr_str 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 + ############# + + 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 + 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_analysis1d.py b/tests/unit/easydynamics/analysis/test_analysis1d.py index 85804738f..40ae70a0b 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 @@ -18,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: @@ -132,6 +139,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 @@ -341,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) @@ -355,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 @@ -982,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())), @@ -1004,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): @@ -1092,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): @@ -1135,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) @@ -1156,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 -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 + # 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() + + # 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 031f813cf..647c73c22 100644 --- a/tests/unit/easydynamics/analysis/test_parameter_analysis.py +++ b/tests/unit/easydynamics/analysis/test_parameter_analysis.py @@ -8,7 +8,11 @@ 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 from easydynamics.analysis.analysis import Analysis from easydynamics.analysis.fit_binding import FitBinding from easydynamics.analysis.parameter_analysis import ParameterAnalysis @@ -17,8 +21,14 @@ 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]) + 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 +42,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): @@ -524,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', [ @@ -1089,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') @@ -1150,6 +1257,357 @@ 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 + + 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 + ############# + + 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 + 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 + 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.""" @@ -1177,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) @@ -1196,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 new file mode 100644 index 000000000..40a6a3b66 --- /dev/null +++ b/tests/unit/easydynamics/analysis/test_posterior.py @@ -0,0 +1,426 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import warnings + +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 TestBoundsSuggestions: + ############# + # Applying suggestions + ############# + + def test_apply_sets_bounds_and_reports_changes(self): + # 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]) + 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 + + ############# + # 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) + 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_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 + 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) + + +class TestPosteriorSummary: + 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 + + 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)), [], [])) 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..0751c7895 --- /dev/null +++ b/tests/unit/easydynamics/analysis/test_posterior_labels.py @@ -0,0 +1,175 @@ +# 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 _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')] + + # 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' + + ############# + # Chain columns + ############# + + 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)', + } + + ############# + # Cost + ############# + + 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, 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 + names = [labels.label(p) for p in parameters] + + # 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 new file mode 100644 index 000000000..e46edc22f --- /dev/null +++ b/tests/unit/easydynamics/analysis/test_posterior_sampling.py @@ -0,0 +1,1982 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +""" +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 json +import types +import warnings +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.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 +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), + ) + + +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 + ############# + + 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 + 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() + + ############# + # 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 + 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) + + 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]) + + ############# + # 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) + + +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) + + ############# + # 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.""" + + def __enter__(self): + 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/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 2329e29a2..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 @@ -637,6 +650,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/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 new file mode 100644 index 000000000..e9dc71b4a --- /dev/null +++ b/tests/unit/easydynamics/test_public_api.py @@ -0,0 +1,82 @@ +# 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 +from easydynamics.sample_model import Gaussian + +SUB_PACKAGES = [ + 'easydynamics.analysis', + 'easydynamics.base_classes', + 'easydynamics.convolution', + 'easydynamics.experiment', + 'easydynamics.sample_model', + 'easydynamics.sample_model.components', + 'easydynamics.sample_model.diffusion_model', + 'easydynamics.settings', + 'easydynamics.utils', +] + +TUTORIALS = pathlib.Path(__file__).resolve().parents[3] / 'docs' / 'docs' / 'tutorials' + + +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)] + 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): + # 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}' + ) 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 new file mode 100644 index 000000000..892f1b6b2 --- /dev/null +++ b/tests/unit/easydynamics/utils/test_posterior_plotting.py @@ -0,0 +1,614 @@ +# 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 + +mpl.use('Agg') + +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) +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_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'): + 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_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 + + # 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) + + ############# + # 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 + 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 + + 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) 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 new file mode 100644 index 000000000..12ebc8b84 --- /dev/null +++ b/tools/prefetch_tutorial_data.py @@ -0,0 +1,102 @@ +# 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 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*['\"]([^'\"]+)['\"]") + + +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.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']) + 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 + + +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: # 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: + 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())