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..1b4af94d6 100644 --- a/docs/docs/tutorials/components.ipynb +++ b/docs/docs/tutorials/components.ipynb @@ -7,7 +7,7 @@ "source": [ "# Components\n", "\n", - "Components are the basic ingredients for all models. Currently, the available components are Gaussian, Lorentzian, Voigt (the convolution of a Gaussian with a Lorentzian), delta function, damped harmonic oscillator and polynomial. This notebooks shows how to use the components. \n", + "Components are the basic ingredients for all models. Currently, the available components are Gaussian, Lorentzian, Voigt (the convolution of a Gaussian with a Lorentzian), delta function, damped harmonic oscillator, stretched exponential and polynomial. This notebooks shows how to use the components. \n", "\n", "Note in particular that a Gaussian, Lorentzian, Voigt or delta function where the center has not been given will be centered at 0." ] @@ -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", @@ -201,11 +203,36 @@ "plt.legend()\n", "plt.show()" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e09b85d0", + "metadata": {}, + "outputs": [], + "source": [ + "stretched_lorz = edyn.StretchedExponential(area=1.0, relaxation_time=10.0, beta=1.0)\n", + "lorentzian = edyn.Lorentzian(center=0.0, width=0.6582 / 10, area=1.0)\n", + "stretched = edyn.StretchedExponential(area=1.0, relaxation_time=10.0, beta=0.5)\n", + "\n", + "x = np.linspace(-1, 1, 1001)\n", + "y = stretched_lorz.evaluate(x)\n", + "y2 = lorentzian.evaluate(x)\n", + "y3 = stretched.evaluate(x)\n", + "plt.figure()\n", + "plt.plot(x, y, label='Stretched exponential (β=1)')\n", + "plt.plot(x, y2, label='Lorentzian', linestyle='dotted')\n", + "plt.plot(x, y3, label='Stretched exponential (β=0.5)')\n", + "plt.xlabel('Energy (meV)')\n", + "plt.ylabel('Intensity (arb. units)')\n", + "plt.legend()\n", + "plt.show()" + ] } ], "metadata": { "kernelspec": { - "display_name": "default", + "display_name": "DEFAULT", "language": "python", "name": "python3" }, 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 c1046f984..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." ] }, { @@ -75,8 +75,8 @@ "metadata": {}, "outputs": [], "source": [ - "diffusion_model._A_0_list[3].value = 0.02 # noqa\n", - "diffusion_model._lorentzian_width_list[5].value = 0.1 # noqa" + "diffusion_model._A_0_list[3].value = 0.02 # ruff: ignore[private-member-access]\n", + "diffusion_model._lorentzian_width_list[5].value = 0.1 # ruff: ignore[private-member-access]" ] }, { 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..10e87adb5 100644 --- a/docs/docs/tutorials/index.md +++ b/docs/docs/tutorials/index.md @@ -53,6 +53,9 @@ tutorials. - [DeltaLorentz](delta_lorentz.ipynb) – Learn how to create and use a model with a Delta function and a Lorentzian that have a shared Debye-Waller-like Q-dependence. +- [Mittag-Leffler relaxation](mittag_leffler.ipynb) – Learn how to use + the multiscale relaxation model of Hassani et al. (2022), both as a + single lineshape and as a diffusion model across Q. - [Sample model](sample_model.ipynb) – Learn how to create a model of the scattering from your sample including model components and diffusion models. @@ -62,3 +65,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/mittag_leffler.ipynb b/docs/docs/tutorials/mittag_leffler.ipynb new file mode 100644 index 000000000..69b84864d --- /dev/null +++ b/docs/docs/tutorials/mittag_leffler.ipynb @@ -0,0 +1,561 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "mittag-leffler-0", + "metadata": {}, + "source": [ + "# Mittag-Leffler relaxation\n", + "\n", + "The multiscale relaxation model of\n", + "\n", + "> A. N. Hassani, L. Haris, M. Appel, T. Seydel, A. M. Stadler and G. R. Kneller,\n", + "> *Multiscale relaxation dynamics and diffusion of myelin basic protein in solution studied by\n", + "> quasielastic neutron scattering*, J. Chem. Phys. **156**, 025102 (2022),\n", + "> [doi:10.1063/5.0077100](https://doi.org/10.1063/5.0077100).\n", + "\n", + "Internal protein dynamics does not relax exponentially. It relaxes with a power-law tail, which\n", + "reflects a broad distribution of relaxation rates rather than a single one. The paper models that\n", + "with a Mittag-Leffler (ML) relaxation function (their Eq. 28)\n", + "\n", + "$$ \\phi_{ML}(t) = E_\\alpha\\left(-(|t|/\\tau_R)^\\alpha\\right), \\qquad 0 < \\alpha \\le 1 $$\n", + "\n", + "which decays as $(t/\\tau_R)^{-\\alpha}/\\Gamma(1-\\alpha)$ at long times and becomes a simple\n", + "exponential at $\\alpha = 1$. For a protein in solution this internal relaxation is multiplied by\n", + "the global translational diffusion of the whole molecule (their Eqs. 38-39),\n", + "\n", + "$$ F^{(+)}(t) = e^{-\\epsilon|t|}\\left(EISF + (1 - EISF)\\,\\phi_{ML}(|t|)\\right),\n", + "\\qquad \\epsilon = \\hbar D q^2 $$\n", + "\n", + "and the measured spectrum is its Fourier transform, their Eq. (41):\n", + "\n", + "$$ S^{(+)}(x) = EISF\\,\\frac{1}{\\pi}\\frac{\\epsilon}{x^2+\\epsilon^2}\n", + "+ (1 - EISF)\\,\\tilde{\\phi}^{(\\epsilon)}_{ML}(|x|). $$\n", + "\n", + "Note that the elastic term is a **Lorentzian of half width $\\epsilon$**, not a delta function:\n", + "global diffusion broadens it.\n", + "\n", + "EasyDynamics provides this at two levels.\n", + "\n", + "- `DiffusionDampedMittagLeffler` is the quasi-elastic lineshape\n", + " $\\tilde{\\phi}^{(\\epsilon)}_{ML}$ of their Eq. (42), a `ModelComponent` with a free `damping`\n", + " that you can fit however you like.\n", + "- `MittagLefflerDiffusion` is the full Eq. (41) across $Q$, a `DiffusionModel` in which\n", + " `damping` is constrained to $\\hbar D q^2$ with a single global $D$.\n", + "\n", + "## Two notes on Eq. (42)\n", + "\n", + "The component evaluates Eq. (42) with two deliberate differences from the printed equation.\n", + "\n", + "1. **The printed equation is written for $\\tau_R = 1$.** You can see this without any derivation:\n", + " it forms $(\\omega^2+\\epsilon^2)^\\alpha + 1$, and you cannot add a pure number to something\n", + " carrying dimensions of frequency$^{2\\alpha}$ unless the frequency is already dimensionless. The\n", + " component evaluates the printed expression at the reduced variables $\\omega/\\Gamma$ and\n", + " $\\epsilon/\\Gamma$ and divides by $\\Gamma = \\hbar/\\tau_R$, which restores a general relaxation\n", + " time while leaving the printed expression itself untouched. $\\Gamma$ is the component's\n", + " `width`.\n", + "2. **The $1/\\pi$ prefactor is missing from the printed equation.** Applying the paper's own\n", + " Eq. (40), $\\tilde{\\phi}^{(\\epsilon)} = \\frac{1}{\\pi}\\Re\\{\\hat{\\phi}(\\epsilon+i\\omega)\\}$, to\n", + " the ML Laplace transform of their Eq. (32) reproduces Eq. (42) exactly, prefactor included.\n", + " Without it the lineshape integrates to $\\pi$ rather than to 1, which would be inconsistent with\n", + " the normalised elastic term in Eq. (41). It is included here, so `scale` is the integrated area\n", + " of the profile." + ] + }, + { + "cell_type": "code", + "id": "mittag-leffler-1", + "metadata": {}, + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "from scipy.optimize import curve_fit\n", + "\n", + "import easydynamics as edyn\n", + "\n", + "%matplotlib widget" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "mittag-leffler-2", + "metadata": {}, + "source": [ + "## The component\n", + "\n", + "`DiffusionDampedMittagLeffler` has four parameters: `scale` (the integrated area), `alpha` (the\n", + "form parameter $\\alpha$), `width` (the relaxation rate $\\Gamma = \\hbar/\\tau_R$, as an energy) and\n", + "`damping` (the diffusion damping $\\epsilon$, also as an energy).\n", + "\n", + "The form parameter is what makes this more than a Lorentzian. At $\\alpha = 1$ the relaxation is\n", + "exponential and the profile is exactly a Lorentzian of half width at half maximum\n", + "`width + damping`. Lowering $\\alpha$ broadens the distribution of relaxation rates, which\n", + "sharpens the peak and fattens the wings at the same time." + ] + }, + { + "cell_type": "code", + "id": "mittag-leffler-3", + "metadata": {}, + "source": [ + "x = np.linspace(-2, 2, 801)\n", + "\n", + "plt.figure()\n", + "for alpha in [1.0, 0.8, 0.6, 0.4]:\n", + " mittag_leffler = edyn.DiffusionDampedMittagLeffler(\n", + " display_name=f'alpha={alpha}',\n", + " scale=1.0,\n", + " alpha=alpha,\n", + " width=0.1,\n", + " damping=0.02,\n", + " )\n", + " plt.plot(x, mittag_leffler.evaluate(x), label=mittag_leffler.display_name)\n", + "\n", + "# alpha=1 must be a plain Lorentzian of HWHM width+damping, shown here as a check.\n", + "lorentzian = edyn.Lorentzian(display_name='Lorentzian, HWHM=0.12', area=1.0, width=0.12)\n", + "plt.plot(x, lorentzian.evaluate(x), 'k--', label=lorentzian.display_name)\n", + "\n", + "plt.yscale('log')\n", + "plt.xlabel('Energy (meV)')\n", + "plt.ylabel('Intensity (arb. units)')\n", + "plt.legend()\n", + "plt.show()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "mittag-leffler-4", + "metadata": {}, + "source": [ + "`damping` must be strictly positive. It is what keeps the profile finite at zero energy transfer:\n", + "without it the ML spectrum diverges as $|\\omega|^{\\alpha-1}$, as the paper notes below its\n", + "Eq. (34). Increasing it broadens the profile without changing its area." + ] + }, + { + "cell_type": "code", + "id": "mittag-leffler-5", + "metadata": {}, + "source": [ + "plt.figure()\n", + "for damping in [0.005, 0.02, 0.1, 0.4]:\n", + " mittag_leffler = edyn.DiffusionDampedMittagLeffler(\n", + " scale=1.0, alpha=0.6, width=0.1, damping=damping\n", + " )\n", + " plt.plot(x, mittag_leffler.evaluate(x), label=f'damping={damping} meV')\n", + "\n", + "plt.yscale('log')\n", + "plt.xlabel('Energy (meV)')\n", + "plt.ylabel('Intensity (arb. units)')\n", + "plt.legend()\n", + "plt.show()\n", + "\n", + "# The scale parameter is the integrated area. The wings fall off as slowly as |x|**(-1-alpha),\n", + "# so this check needs a very wide, logarithmically spaced grid to converge.\n", + "half_grid = np.logspace(-5, 9, 30000)\n", + "xx = np.concatenate([-half_grid[::-1], half_grid])\n", + "for alpha in [1.0, 0.6]:\n", + " mittag_leffler = edyn.DiffusionDampedMittagLeffler(\n", + " scale=1.0, alpha=alpha, width=0.1, damping=0.02\n", + " )\n", + " area = np.trapezoid(mittag_leffler.evaluate(xx), xx)\n", + " print(f'alpha={alpha}: area under the curve = {area:.4f}')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "mittag-leffler-6", + "metadata": {}, + "source": [ + "## The diffusion model\n", + "\n", + "`MittagLefflerDiffusion` assembles Eq. (41) at every $Q$: an elastic `Lorentzian` of area\n", + "$K\\cdot EISF$ and half width $\\epsilon = \\hbar D q^2$, plus a `DiffusionDampedMittagLeffler` of\n", + "scale $K\\cdot(1-EISF)$ and the same damping $\\epsilon$. The diffusion coefficient $D$ is the\n", + "single global parameter that ties the $Q$ values together, exactly as in\n", + "`BrownianTranslationalDiffusion`.\n", + "\n", + "The numbers below are those of MBP in $D_2O$ buffer at 283 K: $D = 3.31$ Ų/ns from dynamic light\n", + "scattering (Table I of the paper), and a $Q$ range matching IN16B's\n", + "0.8 Å⁻¹ < $q$ < 1.8 Å⁻¹." + ] + }, + { + "cell_type": "code", + "id": "mittag-leffler-7", + "metadata": {}, + "source": [ + "Q = np.linspace(0.8, 1.8, 6)\n", + "\n", + "# hbar in meV*ps, used to turn a relaxation time in ps into a rate in meV.\n", + "HBAR_MEV_PS = 0.6582119569\n", + "\n", + "model = edyn.MittagLefflerDiffusion(\n", + " display_name='MBP in D2O, 283 K',\n", + " scale=1.0,\n", + " diffusion_coefficient=3.31e-11, # 3.31 A^2/ns, from DLS\n", + " A_0=0.02,\n", + " relaxation_rate=HBAR_MEV_PS / 50.0, # tau_R = 50 ps\n", + " alpha=0.85,\n", + " Q=Q,\n", + ")\n", + "print(model)\n", + "print(model.get_component_collections()[0].list_component_names())" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "mittag-leffler-8", + "metadata": {}, + "source": [ + "energy = np.linspace(-0.15, 0.15, 1201)\n", + "collections = model.get_component_collections()\n", + "cmap = plt.cm.viridis\n", + "\n", + "plt.figure()\n", + "for i, collection in enumerate(collections):\n", + " plt.plot(\n", + " energy,\n", + " collection.evaluate(energy),\n", + " color=cmap(i / (len(collections) - 1)),\n", + " label=f'Q={Q[i]:.1f} 1/A',\n", + " )\n", + "plt.yscale('log')\n", + "plt.xlabel('Energy (meV)')\n", + "plt.ylabel('S(Q, E) (arb. units)')\n", + "plt.legend()\n", + "plt.show()\n", + "\n", + "# The two components at a single Q: the elastic Lorentzian and the Mittag-Leffler term.\n", + "plt.figure()\n", + "collection = collections[-1]\n", + "for name in collection.list_component_names():\n", + " plt.plot(energy, collection.evaluate_component(energy, name), label=name)\n", + "plt.plot(energy, collection.evaluate(energy), 'k--', label='Sum')\n", + "plt.yscale('log')\n", + "plt.xlabel('Energy (meV)')\n", + "plt.ylabel('S(Q, E) (arb. units)')\n", + "plt.title(f'Q = {Q[-1]:.1f} 1/A')\n", + "plt.legend()\n", + "plt.show()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "mittag-leffler-9", + "metadata": {}, + "source": [ + "The damping $\\epsilon = \\hbar D q^2$ is shared by both components by construction, and it is what\n", + "`calculate_width` returns. At these $Q$ values it is a few µeV, comparable with IN16B's 3.5 µeV\n", + "resolution — which is exactly the difficulty the paper describes: the slow relaxation modes\n", + "overlap with the elastic line and with global diffusion." + ] + }, + { + "cell_type": "code", + "id": "mittag-leffler-10", + "metadata": {}, + "source": [ + "Q_fine = np.linspace(0.1, 2.0, 101)\n", + "\n", + "plt.figure()\n", + "plt.plot(Q_fine, model.calculate_width(Q_fine) * 1e3)\n", + "plt.xlabel('Q (1/A)')\n", + "plt.ylabel('epsilon = hbar*D*Q^2 (ueV)')\n", + "plt.axhline(3.5, color='k', linestyle='dashed', label='IN16B resolution, 3.5 ueV')\n", + "plt.legend()\n", + "plt.show()\n", + "\n", + "for collection, epsilon in zip(collections, model.calculate_width(), strict=True):\n", + " print(\n", + " f'epsilon = {epsilon * 1e3:6.3f} ueV | '\n", + " f'Lorentzian width = {collection[0].width.value * 1e3:6.3f} ueV | '\n", + " f'ML damping = {collection[1].damping.value * 1e3:6.3f} ueV'\n", + " )" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "mittag-leffler-11", + "metadata": {}, + "source": [ + "### Parameters that vary with Q\n", + "\n", + "In the paper only $D$ is global. The relaxation time $\\tau$, the form parameter $\\alpha$ and the\n", + "EISF are all fitted **independently at each $q$** (their Fig. 5). Pass `allow_Q_variation` to get\n", + "that: each $Q$ then owns its own parameters, while $D$ and the overall scale stay shared." + ] + }, + { + "cell_type": "code", + "id": "mittag-leffler-12", + "metadata": {}, + "source": [ + "model_per_Q = edyn.MittagLefflerDiffusion(\n", + " display_name='MBP in D2O, 283 K, per-Q parameters',\n", + " scale=1.0,\n", + " diffusion_coefficient=3.31e-11,\n", + " A_0=0.02,\n", + " relaxation_rate=HBAR_MEV_PS / 50.0,\n", + " alpha=0.85,\n", + " allow_Q_variation={'A_0': True, 'relaxation_rate': True, 'alpha': True},\n", + " Q=Q,\n", + ")\n", + "\n", + "print('Global parameters :', [p.name for p in model_per_Q.get_global_variables()])\n", + "print('Free parameters :', len(model_per_Q.get_free_parameters()))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "mittag-leffler-13", + "metadata": {}, + "source": [ + "The paper does not tabulate the fitted $\\tau(q)$, $\\alpha(q)$ and $EISF(q)$ — they appear only as\n", + "plots in its Fig. 5. The values used below are therefore **indicative**, read off that figure for\n", + "MBP in $D_2O$ at 283 K, and are here only to show the machinery and the trends the paper\n", + "describes: $\\tau$ falls with $q$ because higher $q$ probes more localised and faster motion, while\n", + "$\\alpha$ rises towards 1 because localised motion relaxes more nearly exponentially." + ] + }, + { + "cell_type": "code", + "id": "mittag-leffler-14", + "metadata": {}, + "source": [ + "# Indicative values read off Fig. 5 of the paper; not published numbers.\n", + "tau_ps = np.array([110.0, 75.0, 55.0, 42.0, 33.0, 27.0])\n", + "alpha_per_Q = np.array([0.80, 0.83, 0.86, 0.88, 0.90, 0.92])\n", + "eisf_per_Q = np.array([0.04, 0.03, 0.02, 0.02, 0.01, 0.01])\n", + "\n", + "for i in range(len(Q)):\n", + " model_per_Q._relaxation_rate_list[i].value = HBAR_MEV_PS / tau_ps[i] # ruff: ignore[private-member-access]\n", + " model_per_Q._alpha_list[i].value = alpha_per_Q[i] # ruff: ignore[private-member-access]\n", + " model_per_Q._A_0_list[i].value = eisf_per_Q[i] # ruff: ignore[private-member-access]\n", + "\n", + "fig, axes = plt.subplots(3, 1, sharex=True, figsize=(5, 7))\n", + "axes[0].plot(Q, HBAR_MEV_PS / model_per_Q.calculate_relaxation_rate(), 'o-')\n", + "axes[0].set_ylabel('tau (ps)')\n", + "axes[0].set_ylim(0, 130)\n", + "axes[1].plot(Q, model_per_Q.calculate_alpha(), 'o-')\n", + "axes[1].set_ylabel('alpha')\n", + "axes[1].set_ylim(0, 1.2)\n", + "axes[2].plot(Q, model_per_Q.calculate_EISF(), 'o-')\n", + "axes[2].set_ylabel('EISF')\n", + "axes[2].set_ylim(0, 0.2)\n", + "axes[2].set_xlabel('Q (1/A)')\n", + "fig.suptitle('Layout of Fig. 5, with indicative values')\n", + "plt.show()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "mittag-leffler-15", + "metadata": {}, + "source": [ + "## Comparing with the paper\n", + "\n", + "Most of the figures in the paper cannot be reproduced from the paper alone.\n", + "\n", + "- **Figs. 1 and 2** show measured spectra and intermediate scattering functions. They need the raw\n", + " IN16B data, which lives at [doi.ill.fr/10.5291/ILL-DATA.8-04-874](http://doi.ill.fr/10.5291/ILL-DATA.8-04-874).\n", + "- **Fig. 4** shows circular dichroism results, which are not tabulated.\n", + "- **Figs. 5 and 6** are built from the per-$q$ fit parameters, which are only ever shown as plots.\n", + " The layout of Fig. 5 is reproduced above and Fig. 6 below, but with indicative parameters rather\n", + " than the published ones.\n", + "\n", + "Two figures *are* reproducible from what the paper publishes, and both are done below from the\n", + "library's own code: the **Arrhenius plot of Fig. 3** from the diffusion coefficients in Table I,\n", + "and the **energy barrier distribution of Fig. 7** from the closed-form Eq. (48)." + ] + }, + { + "cell_type": "markdown", + "id": "mittag-leffler-16", + "metadata": {}, + "source": [ + "### Fig. 3, left panel: Arrhenius plot of the diffusion coefficient\n", + "\n", + "Table I of the paper lists the diffusion coefficients measured by dynamic light scattering for\n", + "both solvents. Fitting $D(T) = D_0 e^{-\\Delta G / k_B T}$ (their Eq. 44) to them recovers the\n", + "activation energies quoted in the text. MBP coagulates at 323 K in pure $D_2O$, so the paper\n", + "replaces the measured 0.31 Ų/ns there by the value extrapolated from lower temperatures, and so\n", + "do we." + ] + }, + { + "cell_type": "code", + "id": "mittag-leffler-17", + "metadata": {}, + "source": [ + "# Table I of Hassani et al. (2022). D_DLS in A^2/ns.\n", + "temperature = np.array([283.0, 293.0, 303.0, 313.0, 323.0])\n", + "D_D2O = np.array([3.31, 5.72, 7.49, 9.85, 11.94]) # 323 K: extrapolated, MBP coagulates\n", + "D_TFE = np.array([2.05, 3.17, 4.42, 6.32, 8.15])\n", + "\n", + "BOLTZMANN_KCAL = 1.987204259e-3 # kcal/(mol*K)\n", + "\n", + "\n", + "def arrhenius(T: np.ndarray, D_0: float, delta_G: float) -> np.ndarray:\n", + " \"\"\"Eq. (44): D(T) = D_0 exp(-dG / kB T), with dG in kcal/mol.\"\"\"\n", + " return D_0 * np.exp(-delta_G / (BOLTZMANN_KCAL * T))\n", + "\n", + "\n", + "plt.figure()\n", + "T_fine = np.linspace(280.0, 326.0, 200)\n", + "for label, D, colour, quoted in [\n", + " ('MBP D2O', D_D2O, 'tab:blue', 5.10),\n", + " ('MBP 30% TFE', D_TFE, 'tab:orange', 6.05),\n", + "]:\n", + " (D_0, delta_G), _ = curve_fit(arrhenius, temperature, D, p0=[1e5, 5.0])\n", + " plt.plot(1000.0 / temperature, D, 'o', color=colour, label=label)\n", + " plt.plot(1000.0 / T_fine, arrhenius(T_fine, D_0, delta_G), '-', color=colour)\n", + " print(f'{label:12s}: fitted dG = {delta_G:.2f} kcal/mol (paper quotes {quoted:.2f})')\n", + "\n", + "plt.yscale('log')\n", + "plt.xlabel('1000/T (1/K)')\n", + "plt.ylabel('D (A^2/ns)')\n", + "plt.legend()\n", + "plt.title('Fig. 3, left panel')\n", + "plt.show()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "mittag-leffler-18", + "metadata": {}, + "source": [ + "Both activation energies come out at the values quoted in the paper, 5.10 and 6.05 kcal/mol. Note\n", + "that Eq. (44) has to be fitted to $D$ itself rather than to $\\ln D$: least squares on the\n", + "logarithm weights the low-temperature points much more heavily and gives 5.69 and 6.28 kcal/mol\n", + "instead." + ] + }, + { + "cell_type": "markdown", + "id": "mittag-leffler-19", + "metadata": {}, + "source": [ + "### Fig. 7, right panel: the distribution of energy barriers\n", + "\n", + "Reading the ML relaxation as diffusion in Zwanzig's rough harmonic potential, the paper derives a\n", + "distribution of dimensionless barrier heights $h = \\Delta E / k_B T$ (their Eq. 48),\n", + "\n", + "$$ P_{ML}(h) = \\frac{2h\\sin(\\pi\\alpha)}\n", + "{\\pi\\left(e^{-\\alpha h^2} + e^{\\alpha h^2} + 2\\cos(\\pi\\alpha)\\right)}. $$\n", + "\n", + "This is a closed form in $h$ and $\\alpha$ only, so it reproduces exactly. As $\\alpha \\to 1$ it\n", + "collapses onto $\\delta(h)$ — a smooth potential with no barriers — and as $\\alpha \\to 0$ it\n", + "broadens to include arbitrarily high ones. That is the physical reading of the form parameter:\n", + "**a smaller $\\alpha$ means a rougher energy landscape.**" + ] + }, + { + "cell_type": "code", + "id": "mittag-leffler-20", + "metadata": {}, + "source": [ + "h = np.linspace(0.0, 10.0, 201)\n", + "alpha_grid = np.linspace(0.02, 1.0, 100)\n", + "\n", + "barrier_model = edyn.MittagLefflerDiffusion(Q=np.array([1.0]))\n", + "distribution = np.empty((alpha_grid.size, h.size))\n", + "for i, alpha in enumerate(alpha_grid):\n", + " barrier_model.alpha = alpha\n", + " distribution[i] = barrier_model.calculate_energy_barrier_distribution(h)[0]\n", + "\n", + "fig = plt.figure()\n", + "ax = fig.add_subplot(projection='3d')\n", + "H, A = np.meshgrid(h, alpha_grid)\n", + "ax.plot_surface(H, A, distribution, cmap='Blues_r', linewidth=0, antialiased=True)\n", + "ax.set_xlabel('h')\n", + "ax.set_ylabel('alpha')\n", + "ax.set_zlabel('P(h)')\n", + "ax.set_zlim(0, 1)\n", + "ax.set_title('Fig. 7, right panel')\n", + "plt.show()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "mittag-leffler-21", + "metadata": {}, + "source": [ + "### Fig. 6: relaxation rate spectra\n", + "\n", + "The same model has a closed-form distribution of relaxation *rates* (their Eq. 37),\n", + "\n", + "$$ p_{ML}(\\lambda) = \\frac{\\sin(\\pi\\alpha)}\n", + "{\\pi\\lambda\\left((\\lambda\\tau_R)^{-\\alpha} + (\\lambda\\tau_R)^{\\alpha} + 2\\cos(\\pi\\alpha)\\right)}, $$\n", + "\n", + "which is what the paper plots against $q$ in its Fig. 6. It depends on the fitted $\\tau(q)$ and\n", + "$\\alpha(q)$, so the surface below uses the indicative values set above rather than the published\n", + "ones. The trend the paper reports is nevertheless the one that shows: as $q$ grows the peak moves\n", + "to higher rates and the distribution narrows, because $\\alpha$ approaches 1." + ] + }, + { + "cell_type": "code", + "id": "mittag-leffler-22", + "metadata": {}, + "source": [ + "# lambda in 1/ps, converted to the model's meV via hbar.\n", + "rate_per_ps = np.linspace(0.001, 0.15, 200)\n", + "spectrum = model_per_Q.calculate_relaxation_rate_spectrum(rate_per_ps * HBAR_MEV_PS)\n", + "\n", + "fig = plt.figure()\n", + "ax = fig.add_subplot(projection='3d')\n", + "RATE, QQ = np.meshgrid(rate_per_ps, Q)\n", + "ax.plot_surface(RATE, QQ, spectrum, cmap='autumn', linewidth=0, antialiased=True)\n", + "ax.set_xlabel('lambda (1/ps)')\n", + "ax.set_ylabel('Q (1/A)')\n", + "ax.set_zlabel('p(lambda)')\n", + "ax.set_title('Layout of Fig. 6, with indicative values')\n", + "plt.show()" + ], + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "default", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} 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 7efb5705f..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" @@ -58,7 +57,7 @@ "\n", "file_path = pooch.retrieve(\n", " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/master/docs/docs/tutorials/data/fake_simple_data.hdf5',\n", - " known_hash='7aea2b5c315b0bd6f0a82ed925d07eaed93f5d61b13686935893c0b4e71999b6',\n", + " known_hash='98d26bad84840d04c117a2c12806be6cfae74690ac81bb94c49c4b95814eb7c9',\n", ")\n", "\n", "experiment.load_hdf5(filename=file_path)" @@ -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 da7469571..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" @@ -48,7 +47,7 @@ "\n", "file_path = pooch.retrieve(\n", " url='https://github.com/easyscience/dynamics-lib/raw/refs/heads/master/docs/docs/tutorials/data/fake_advanced_data.hdf5',\n", - " known_hash='ee7310249df71a312ebc219f3e16b8da4e9aa37d29df919bbcaa541a38e1c39f',\n", + " known_hash='818202c758ae074df1f1848e6571f9640757c6f034c2b77d25cdc16edd68f359',\n", ")\n", "\n", "experiment.load_hdf5(filename=file_path)\n", @@ -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/docs/user-guide/concept.md b/docs/docs/user-guide/concept.md index 17014b924..7a9c8005a 100644 --- a/docs/docs/user-guide/concept.md +++ b/docs/docs/user-guide/concept.md @@ -19,8 +19,8 @@ a list of `ComponentCollection`s (one for each Q) describing the model of the measured data. A `ComponentCollection` is essentially a list of `ModelComponents`. A `ModelComponent` can be any of `Gaussian`, `Lorentzian`, `Voigt` (the convolution of a `Gaussian` and -`Lorentzian`), `DeltaFunction`, `DampedHarmonicOscillator` and -`Polynomium`. +`Lorentzian`), `DeltaFunction`, `DampedHarmonicOscillator`, +`StretchedExponential`, `DiffusionDampedMittagLeffler` and `Polynomial`. Each `ModelComponent` has a number of `Parameter`s. The `Gaussian`, for example, has `area`, `center` and `width`. Each of these `Parameter`s diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 64e94f967..b8659848e 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 @@ -212,11 +212,13 @@ nav: - Detailed balance: tutorials/detailed_balance.ipynb - DeltaLorentz: tutorials/delta_lorentz.ipynb - Diffusion model: tutorials/diffusion_model.ipynb + - Mittag-Leffler relaxation: tutorials/mittag_leffler.ipynb - Sample model: tutorials/sample_model.ipynb - Instrument model: tutorials/instrument_model.ipynb - 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 fc2b62956..0bf41847a 100644 --- a/pixi.lock +++ b/pixi.lock @@ -28,77 +28,75 @@ 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/bzip2-1.0.8-hda65f42_9.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py314h4a8dc5f_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-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-h33c6efd_0.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/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.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/libev-4.33-hd590300_2.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/libgcc-15.2.0-he0feb66_19.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.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/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_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.3-h0c1763c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.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 - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py314h67df5f8_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/msgspec-0.21.1-py314h5bd0f2a_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-26.4.0-hc039f44_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda + - 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_100_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 - conda: https://conda.anaconda.org/conda-forge/linux-64/rpds-py-2026.6.3-py314h1bee95f_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.7-py314h5bd0f2a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd70dff1_3.conda + - 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/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.1-pyhcf101f3_0.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 - conda: https://conda.anaconda.org/conda-forge/noarch/arrow-1.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.2-pyhd8ed1ab_0.conda - 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 - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.6.17-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - 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.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_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.3.1-pyhd8ed1ab_0.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 - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda @@ -106,7 +104,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.3.0-pyha191276_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.15.0-pyh53cf698_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.16.1-pyh53cf698_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/isoduration-20.11.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.20.0-pyhcf101f3_0.conda @@ -116,63 +114,63 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.26.0-hcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.2.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-lsp-2.3.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda - 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.1-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 - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.11.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio2-1.7.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - 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/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.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_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 - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - 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.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_100.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-tzdata-2026.2-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 - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.28.0-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.29.0-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-2.1.0-pyha191276_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.9.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyhc90fa1f_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.15.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.16.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_utils-0.1.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda @@ -184,65 +182,59 @@ 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/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0c/a4/cf8df35b6488c04cae9aa7338c7b531a87aae2515da3993d2906d49b1dc1/copier-9.16.0-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/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/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-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/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/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-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 - pypi: https://files.pythonhosted.org/packages/20/16/e777eadfa0c0305878c36fae1d5e6db474fbb15dae202b9ec378809dfb4d/nbstripout-0.9.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.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/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/28/88/4789719fbbe166d12d345b3ac66b96105f10001b16e00a9765ba29261a21/nbqa-1.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/29/dc/6377ecfaa5fef79430f74a1a16638b4e2aa30d4692bae2c19f9d76fe3b01/matplotlib-3.11.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-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/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.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/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-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/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/43/fe/ad0ecbe2393cb690a4b3100a8fea47ecfdb49f6e06f40cf2f626635adc0c/scipp-26.3.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/4a/8a/4cb9367a86f2b9526727ee94e5e6a3d86f9f7d7d947927b444e5bcd56a89/easyscience-2.3.1-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 - - pypi: https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/59/2d/d741fbbbcba7eb6f9f1a829373c558114d59cb882b95f941aa0dc060861f/plumbum-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.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/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-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/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6e/e9/e2ae007456069dbe01865c69a4203a7ada6f7e337b78fc2f12e51bd3fae7/jupytext-1.19.4-py3-none-any.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/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/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/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-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 + - pypi: https://files.pythonhosted.org/packages/7f/7e/8139f0faa2ff7aee2759300061e4b7e5e661775bcd5bf70810d0793af48a/plopp-26.9.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/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.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/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.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/d7/9b6ac05350ab7f7d3a730ff143ff3e2cada54514117c37be37e26dc91242/docstripy-0.7.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl @@ -252,93 +244,98 @@ environments: - pypi: https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl - 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/aa/1d/72a57601a784f38096362badf6796acb519f47fd1d43071e1224e6960b4f/pydoclint-0.9.0-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/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-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 + - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/ee/e9c95cda829131f71a8dff5ce0406059fd16e591c074414e31ada19ba7c3/validate_pyproject-0.25-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/bc/35/e2fa913ba2d692ccefbf2e21337fe3fd44200efd9a6ee1ba65766eff7d14/pydoclint-0.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bf/50/98b146aea0f1cd7531d25f12bea69fa9ce8d1662124f93fb30dc4511b65e/docstring_parser_fork-0.0.14-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/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_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/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/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-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 - pypi: https://files.pythonhosted.org/packages/eb/be/b257e12f9710819fde40adc972578bee6b72c5992da1bc8369bef2597756/nbmake-1.5.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/eb/d4/28a48cb4d7b77a33d4d7633de13c54cb0331d9fa71accb3ecbad20ce7196/plopp-26.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f0/31/2aaabe7d03f395c7b6d955f09a4d1440a2c49920abea42fde88cacc2ef97/dunamai-1.26.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/16/d905e7f53e661ce2c24686c38048d8e2b750ffc4350009d41c4e6c6c9826/h5py-3.16.0-cp314-cp314-manylinux_2_28_x86_64.whl - 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/f4/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.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/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-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/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 - pypi: https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fe/d7/a71b12b625c44cfcb95d41397752edccc0a731903081609cffd6720f671d/asteval-1.0.9-py3-none-any.whl p2: - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.1-pyhcf101f3_0.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/appnope-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/argon2-cffi-25.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/arrow-1.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.2-pyhd8ed1ab_0.conda - 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 - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.6.17-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - 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.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_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.3.1-pyhd8ed1ab_0.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 - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda @@ -346,7 +343,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.3.0-pyh01cf8df_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.15.0-pyh53cf698_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.16.1-pyh53cf698_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/isoduration-20.11.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.20.0-pyhcf101f3_0.conda @@ -356,64 +353,64 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.26.0-hcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.2.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-lsp-2.3.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda - 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.1-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 - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.11.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio2-1.7.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - 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/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.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_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 - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - 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.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_100.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-tzdata-2026.2-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 - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.28.0-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.29.0-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-2.1.0-pyh5552912_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-84.0.0-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.9.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyhc90fa1f_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.15.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.16.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_utils-0.1.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda @@ -422,110 +419,109 @@ 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/bzip2-1.0.8-hd037594_9.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py314h44086f9_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-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-hef89b57_0.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/libev-4.33-h93a5062_2.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/liblzma-5.8.3-h8088a28_0.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/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_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.3-h1b79a29_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.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/libuv-1.52.1-h1a92334_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.3-py314h6e9b3f0_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/msgspec-0.21.1-py314h6c2aa35_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.4.0-h00e74ec_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda + - 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_100_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 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rpds-py-2026.6.3-py314he1d1ac0_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.7-py314h6c2aa35_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hd3d0363_3.conda + - 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 - - pypi: https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0c/a4/cf8df35b6488c04cae9aa7338c7b531a87aae2515da3993d2906d49b1dc1/copier-9.16.0-py3-none-any.whl + - 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/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0f/0e/0eb94e64f5badef67f11fe1e448dde2a44f00940d8949f4adf71d560552e/scipp-26.3.1-cp314-cp314-macosx_14_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/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.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/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-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/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-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 - 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 - - pypi: https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.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/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl - pypi: https://files.pythonhosted.org/packages/28/88/4789719fbbe166d12d345b3ac66b96105f10001b16e00a9765ba29261a21/nbqa-1.9.1-py3-none-any.whl - 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/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-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/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/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-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/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/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-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/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/4a/8a/4cb9367a86f2b9526727ee94e5e6a3d86f9f7d7d947927b444e5bcd56a89/easyscience-2.3.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-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 - 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 - - pypi: https://files.pythonhosted.org/packages/59/2d/d741fbbbcba7eb6f9f1a829373c558114d59cb882b95f941aa0dc060861f/plumbum-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.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/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-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/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-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/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/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6e/e9/e2ae007456069dbe01865c69a4203a7ada6f7e337b78fc2f12e51bd3fae7/jupytext-1.19.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/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7c/03/b8cdb625a21f710dfa11bbca1f48fb4057d2c0286975f8b415bf80942c99/matplotlib-3.11.0-cp314-cp314-macosx_11_0_arm64.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/7e/8139f0faa2ff7aee2759300061e4b7e5e661775bcd5bf70810d0793af48a/plopp-26.9.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/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.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/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 - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl @@ -535,8 +531,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl @@ -545,19 +543,22 @@ 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/aa/1d/72a57601a784f38096362badf6796acb519f47fd1d43071e1224e6960b4f/pydoclint-0.9.0-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/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-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 + - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ee/e9c95cda829131f71a8dff5ce0406059fd16e591c074414e31ada19ba7c3/validate_pyproject-0.25-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/bc/35/e2fa913ba2d692ccefbf2e21337fe3fd44200efd9a6ee1ba65766eff7d14/pydoclint-0.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bf/50/98b146aea0f1cd7531d25f12bea69fa9ce8d1662124f93fb30dc4511b65e/docstring_parser_fork-0.0.14-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/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 @@ -565,62 +566,57 @@ environments: - 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/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/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-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/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/eb/be/b257e12f9710819fde40adc972578bee6b72c5992da1bc8369bef2597756/nbmake-1.5.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/eb/d4/28a48cb4d7b77a33d4d7633de13c54cb0331d9fa71accb3ecbad20ce7196/plopp-26.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f0/31/2aaabe7d03f395c7b6d955f09a4d1440a2c49920abea42fde88cacc2ef97/dunamai-1.26.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl - 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/f4/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.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/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.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/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-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 - pypi: https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/fe/d7/a71b12b625c44cfcb95d41397752edccc0a731903081609cffd6720f671d/asteval-1.0.9-py3-none-any.whl win-64: - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.1-pyhcf101f3_0.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 - conda: https://conda.anaconda.org/conda-forge/noarch/arrow-1.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.2-pyhd8ed1ab_0.conda - 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 - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-h4c7d964_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.6.17-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-h4c7d964_0.conda + - 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.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_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.3.1-pyhd8ed1ab_0.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 - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda @@ -628,7 +624,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.3.0-pyh6dadd2b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.15.0-pyhe2676ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.16.1-pyhe2676ad_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/isoduration-20.11.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.20.0-pyhcf101f3_0.conda @@ -638,61 +634,61 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.26.0-hcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.2.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-lsp-2.3.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyh6dadd2b_0.conda - 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.1-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 - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.11.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio2-1.7.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - 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.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_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 - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - 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.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_100.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-tzdata-2026.2-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 - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.28.0-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.29.0-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-2.1.0-pyh6dadd2b_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.9.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyh6dadd2b_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.15.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.16.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_utils-0.1.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda @@ -702,98 +698,93 @@ 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/bzip2-1.0.8-h0ad9c76_9.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py314h5a2d7ad_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_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/liblzma-5.8.3-hfd05255_0.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/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_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.3-hf5d6505_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.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 - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py314h2359020_1.conda - 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/nodejs-26.4.0-h80d1838_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda + - 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_100_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-2.0.15-py314h51f0985_1.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 - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312h343a6d4_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-2026.6.3-py314hc980628_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h6ed50ae_3.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.7-py314h5a2d7ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h967ab96_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.8-py314h5a2d7ad_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-ha367084_41.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36247-habf1de7_41.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - 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/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/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0c/a4/cf8df35b6488c04cae9aa7338c7b531a87aae2515da3993d2906d49b1dc1/copier-9.16.0-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/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/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-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/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/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/1a/f7/3a9e6389a7cfaeff76c56e40c2dabcb13110e21e82f837228c834ebe748c/matplotlib-3.11.0-cp314-cp314-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/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 - - pypi: https://files.pythonhosted.org/packages/1f/28/3f8aa247d29d010547d52207395cb057ebd0a40b88f64bc1dbac9e17a729/scipp-26.3.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/20/16/e777eadfa0c0305878c36fae1d5e6db474fbb15dae202b9ec378809dfb4d/nbstripout-0.9.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.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/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/28/88/4789719fbbe166d12d345b3ac66b96105f10001b16e00a9765ba29261a21/nbqa-1.9.1-py3-none-any.whl - 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/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-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/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.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/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/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-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/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/4a/8a/4cb9367a86f2b9526727ee94e5e6a3d86f9f7d7d947927b444e5bcd56a89/easyscience-2.3.1-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 - - pypi: https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/59/2d/d741fbbbcba7eb6f9f1a829373c558114d59cb882b95f941aa0dc060861f/plumbum-2.0.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/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.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/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl + - 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/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6e/e9/e2ae007456069dbe01865c69a4203a7ada6f7e337b78fc2f12e51bd3fae7/jupytext-1.19.4-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/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/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-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 + - pypi: https://files.pythonhosted.org/packages/7f/7e/8139f0faa2ff7aee2759300061e4b7e5e661775bcd5bf70810d0793af48a/plopp-26.9.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/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.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/d7/9b6ac05350ab7f7d3a730ff143ff3e2cada54514117c37be37e26dc91242/docstripy-0.7.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl @@ -805,9 +796,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl @@ -816,50 +810,52 @@ 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/aa/1d/72a57601a784f38096362badf6796acb519f47fd1d43071e1224e6960b4f/pydoclint-0.9.0-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/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.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 + - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ee/e9c95cda829131f71a8dff5ce0406059fd16e591c074414e31ada19ba7c3/validate_pyproject-0.25-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/bc/35/e2fa913ba2d692ccefbf2e21337fe3fd44200efd9a6ee1ba65766eff7d14/pydoclint-0.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bf/50/98b146aea0f1cd7531d25f12bea69fa9ce8d1662124f93fb30dc4511b65e/docstring_parser_fork-0.0.14-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/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 - 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/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ce/04/d719a0a36930ecc8dfc801ff340f9dcfc4223f8ca5d39d06b4020032fff8/matplotlib-3.11.1-cp314-cp314-win_amd64.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/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/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.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/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 - - pypi: https://files.pythonhosted.org/packages/eb/d4/28a48cb4d7b77a33d4d7633de13c54cb0331d9fa71accb3ecbad20ce7196/plopp-26.6.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.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 + - pypi: https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f0/31/2aaabe7d03f395c7b6d955f09a4d1440a2c49920abea42fde88cacc2ef97/dunamai-1.26.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f4/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.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/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/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.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 @@ -875,78 +871,76 @@ 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/bzip2-1.0.8-hda65f42_9.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py312h460c074_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-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-h33c6efd_0.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/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.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/libev-4.33-hd590300_2.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/libgcc-15.2.0-he0feb66_19.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_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/libnghttp2-1.68.1-h877daf1_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.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.3-h0c1763c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.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 - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.38-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py312h8a5da7c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/msgspec-0.21.1-py312h4c3975b_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-26.4.0-hc039f44_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda + - 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 - conda: https://conda.anaconda.org/conda-forge/linux-64/rpds-py-2026.6.3-py312h192e038_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.7-py312h4c3975b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd70dff1_3.conda + - 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/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.1-pyhcf101f3_0.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 - conda: https://conda.anaconda.org/conda-forge/noarch/arrow-1.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.2-pyhd8ed1ab_0.conda - 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/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 - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.6.17-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - 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.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/decorator-5.3.1-pyhd8ed1ab_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 - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda @@ -954,7 +948,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.3.0-pyha191276_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.15.0-pyh53cf698_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.16.1-pyh53cf698_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/isoduration-20.11.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.20.0-pyhcf101f3_0.conda @@ -964,63 +958,63 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.26.0-hcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.2.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-lsp-2.3.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda - 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.1-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 - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.11.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio2-1.7.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - 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/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.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_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 - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - 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.21.2-pyhe01879c_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-tzdata-2026.2-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 - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.28.0-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.29.0-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-2.1.0-pyha191276_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.9.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyhc90fa1f_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.15.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.16.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_utils-0.1.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda @@ -1031,79 +1025,76 @@ environments: - 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 - - pypi: https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0c/a4/cf8df35b6488c04cae9aa7338c7b531a87aae2515da3993d2906d49b1dc1/copier-9.16.0-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/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 - 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/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-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/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-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/1e/e7/cd78635d0ece7e4d3393f2c1d2ebabf6ff4bd615da142891b1d42ad58abf/scipp-26.3.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/20/16/e777eadfa0c0305878c36fae1d5e6db474fbb15dae202b9ec378809dfb4d/nbstripout-0.9.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.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/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/28/88/4789719fbbe166d12d345b3ac66b96105f10001b16e00a9765ba29261a21/nbqa-1.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/29/13/232505664e8e2a0c7a2eb0c505cfade9d715538f89a5d62bc4c272968f62/backrefs-8.0-py312-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/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-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/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-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/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.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/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/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4a/8a/4cb9367a86f2b9526727ee94e5e6a3d86f9f7d7d947927b444e5bcd56a89/easyscience-2.3.1-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 - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/59/2d/d741fbbbcba7eb6f9f1a829373c558114d59cb882b95f941aa0dc060861f/plumbum-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.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/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl + - 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/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_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/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/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6e/e9/e2ae007456069dbe01865c69a4203a7ada6f7e337b78fc2f12e51bd3fae7/jupytext-1.19.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/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_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/7e/8139f0faa2ff7aee2759300061e4b7e5e661775bcd5bf70810d0793af48a/plopp-26.9.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/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.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/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/85/d7/9b6ac05350ab7f7d3a730ff143ff3e2cada54514117c37be37e26dc91242/docstripy-0.7.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/8f/5e/f1e1dd319e35e962a4e00b33150a8868b6329cc1d19fd533436ba5488f09/uncertainties-3.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/94/95/7f522393c88313336b20d70fc849555757b2e5febc22b83b3a3f0fd4bce9/matplotlib-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/e9/1a19e42cd43cc1365e127db6aae85e1c671da1d9a5d746f4d34a50edb577/h5py-3.16.0-cp312-cp312-manylinux_2_28_x86_64.whl @@ -1112,80 +1103,82 @@ 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/aa/1d/72a57601a784f38096362badf6796acb519f47fd1d43071e1224e6960b4f/pydoclint-0.9.0-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/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-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 + - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/ee/e9c95cda829131f71a8dff5ce0406059fd16e591c074414e31ada19ba7c3/validate_pyproject-0.25-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/bc/35/e2fa913ba2d692ccefbf2e21337fe3fd44200efd9a6ee1ba65766eff7d14/pydoclint-0.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bf/50/98b146aea0f1cd7531d25f12bea69fa9ce8d1662124f93fb30dc4511b65e/docstring_parser_fork-0.0.14-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/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_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 - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - 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/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/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.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/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-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/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-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/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_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/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 - - pypi: https://files.pythonhosted.org/packages/eb/d4/28a48cb4d7b77a33d4d7633de13c54cb0331d9fa71accb3ecbad20ce7196/plopp-26.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f0/31/2aaabe7d03f395c7b6d955f09a4d1440a2c49920abea42fde88cacc2ef97/dunamai-1.26.2-py3-none-any.whl - 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/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/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.0-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/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-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 - pypi: https://files.pythonhosted.org/packages/fe/d7/a71b12b625c44cfcb95d41397752edccc0a731903081609cffd6720f671d/asteval-1.0.9-py3-none-any.whl p2: - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.1-pyhcf101f3_0.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/appnope-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/argon2-cffi-25.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/arrow-1.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.2-pyhd8ed1ab_0.conda - 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/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 - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.6.17-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - 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.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/decorator-5.3.1-pyhd8ed1ab_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 - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda @@ -1193,7 +1186,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.3.0-pyh01cf8df_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.15.0-pyh53cf698_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.16.1-pyh53cf698_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/isoduration-20.11.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.20.0-pyhcf101f3_0.conda @@ -1203,64 +1196,64 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.26.0-hcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.2.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-lsp-2.3.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda - 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.1-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 - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.11.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio2-1.7.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - 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/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.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_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 - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - 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.21.2-pyhe01879c_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-tzdata-2026.2-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 - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.28.0-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.29.0-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-2.1.0-pyh5552912_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-84.0.0-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.9.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyhc90fa1f_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.15.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.16.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_utils-0.1.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda @@ -1269,110 +1262,107 @@ 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/bzip2-1.0.8-hd037594_9.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py312h1b4d9a2_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-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-hef89b57_0.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/libev-4.33-h93a5062_2.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/liblzma-5.8.3-h8088a28_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 - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.3-h1b79a29_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.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/libuv-1.52.1-h1a92334_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.3-py312h04c11ed_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/msgspec-0.21.1-py312h2bbb03f_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.4.0-h00e74ec_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda + - 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 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rpds-py-2026.6.3-py312h8b1d842_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.7-py312h2bbb03f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hd3d0363_3.conda + - 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 - pypi: https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl - - pypi: https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0c/a4/cf8df35b6488c04cae9aa7338c7b531a87aae2515da3993d2906d49b1dc1/copier-9.16.0-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/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/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-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/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-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 - - pypi: https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.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/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/28/88/4789719fbbe166d12d345b3ac66b96105f10001b16e00a9765ba29261a21/nbqa-1.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/29/13/232505664e8e2a0c7a2eb0c505cfade9d715538f89a5d62bc4c272968f62/backrefs-8.0-py312-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/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-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/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/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-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/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/7b/537a61906eac58d94131273084d21d4eb219f5453f0ed30de3aca580a2b4/scipp-26.3.1-cp312-cp312-macosx_14_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/4a/8a/4cb9367a86f2b9526727ee94e5e6a3d86f9f7d7d947927b444e5bcd56a89/easyscience-2.3.1-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 - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-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/59/2d/d741fbbbcba7eb6f9f1a829373c558114d59cb882b95f941aa0dc060861f/plumbum-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.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/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6e/e9/e2ae007456069dbe01865c69a4203a7ada6f7e337b78fc2f12e51bd3fae7/jupytext-1.19.4-py3-none-any.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/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/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-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 + - pypi: https://files.pythonhosted.org/packages/7f/7e/8139f0faa2ff7aee2759300061e4b7e5e661775bcd5bf70810d0793af48a/plopp-26.9.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/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/82/34/bdd77418adb2178a1d59f044bd67bfebb115896e91b840b8a197eb3f4f4e/matplotlib-3.11.0-cp312-cp312-macosx_11_0_arm64.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/d7/9b6ac05350ab7f7d3a730ff143ff3e2cada54514117c37be37e26dc91242/docstripy-0.7.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl @@ -1383,11 +1373,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl @@ -1395,78 +1386,79 @@ 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/aa/1d/72a57601a784f38096362badf6796acb519f47fd1d43071e1224e6960b4f/pydoclint-0.9.0-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/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-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 + - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ee/e9c95cda829131f71a8dff5ce0406059fd16e591c074414e31ada19ba7c3/validate_pyproject-0.25-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/bc/35/e2fa913ba2d692ccefbf2e21337fe3fd44200efd9a6ee1ba65766eff7d14/pydoclint-0.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bf/50/98b146aea0f1cd7531d25f12bea69fa9ce8d1662124f93fb30dc4511b65e/docstring_parser_fork-0.0.14-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/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/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.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/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-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 - pypi: https://files.pythonhosted.org/packages/eb/be/b257e12f9710819fde40adc972578bee6b72c5992da1bc8369bef2597756/nbmake-1.5.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/eb/d4/28a48cb4d7b77a33d4d7633de13c54cb0331d9fa71accb3ecbad20ce7196/plopp-26.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/eb/f8/6d0c312c8d9738e7d9677f09fe5c986b3239e651a7b73a2deb38b65e4a71/matplotlib-3.11.1-cp312-cp312-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f0/31/2aaabe7d03f395c7b6d955f09a4d1440a2c49920abea42fde88cacc2ef97/dunamai-1.26.2-py3-none-any.whl - 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/f4/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.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/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/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-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 - pypi: https://files.pythonhosted.org/packages/fe/d7/a71b12b625c44cfcb95d41397752edccc0a731903081609cffd6720f671d/asteval-1.0.9-py3-none-any.whl win-64: - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.1-pyhcf101f3_0.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 - conda: https://conda.anaconda.org/conda-forge/noarch/arrow-1.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.2-pyhd8ed1ab_0.conda - 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/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 - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-h4c7d964_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.6.17-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-h4c7d964_0.conda + - 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.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/decorator-5.3.1-pyhd8ed1ab_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 - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda @@ -1474,7 +1466,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.3.0-pyh6dadd2b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.15.0-pyhe2676ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.16.1-pyhe2676ad_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/isoduration-20.11.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.20.0-pyhcf101f3_0.conda @@ -1484,61 +1476,61 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.26.0-hcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.2.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-lsp-2.3.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyh6dadd2b_0.conda - 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.1-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 - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.11.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio2-1.7.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - 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.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_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 - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - 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.21.2-pyhe01879c_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-tzdata-2026.2-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 - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.28.0-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.29.0-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-2.1.0-pyh6dadd2b_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.9.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyh6dadd2b_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.15.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.16.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_utils-0.1.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda @@ -1548,114 +1540,117 @@ 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/bzip2-1.0.8-h0ad9c76_9.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py312he06e257_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_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/liblzma-5.8.3-hfd05255_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.3-hf5d6505_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.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 - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py312h05f76fc_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/msgspec-0.21.1-py312he06e257_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-26.4.0-h80d1838_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda + - 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-2.0.15-py312h275cf98_1.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 - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312h343a6d4_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-2026.6.3-py312hd944d65_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h6ed50ae_3.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.7-py312he06e257_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h967ab96_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.8-py312he06e257_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-ha367084_41.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36247-habf1de7_41.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - 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/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/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0c/a4/cf8df35b6488c04cae9aa7338c7b531a87aae2515da3993d2906d49b1dc1/copier-9.16.0-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/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-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/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/1a/1f/86b4d15221096cb5500bcd73bf350745749e3ba056cdd7a7f75f126f154e/scipp-26.3.1-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-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 - - pypi: https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.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/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/28/88/4789719fbbe166d12d345b3ac66b96105f10001b16e00a9765ba29261a21/nbqa-1.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/29/13/232505664e8e2a0c7a2eb0c505cfade9d715538f89a5d62bc4c272968f62/backrefs-8.0-py312-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/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-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/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.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/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-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/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/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/4a/8a/4cb9367a86f2b9526727ee94e5e6a3d86f9f7d7d947927b444e5bcd56a89/easyscience-2.3.1-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/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 - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/59/2d/d741fbbbcba7eb6f9f1a829373c558114d59cb882b95f941aa0dc060861f/plumbum-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.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/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl + - 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/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6e/e9/e2ae007456069dbe01865c69a4203a7ada6f7e337b78fc2f12e51bd3fae7/jupytext-1.19.4-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/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/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.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/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/7e/8139f0faa2ff7aee2759300061e4b7e5e661775bcd5bf70810d0793af48a/plopp-26.9.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.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/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.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/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.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/d7/9b6ac05350ab7f7d3a730ff143ff3e2cada54514117c37be37e26dc91242/docstripy-0.7.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/88/89/56649bbaa2fd12e20f3be03dbcc135b0c8676d88bac17977599e3eb442a0/matplotlib-3.11.1-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/5e/f1e1dd319e35e962a4e00b33150a8868b6329cc1d19fd533436ba5488f09/uncertainties-3.2.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl @@ -1663,50 +1658,47 @@ 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/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.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/aa/1d/72a57601a784f38096362badf6796acb519f47fd1d43071e1224e6960b4f/pydoclint-0.9.0-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/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-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 + - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ee/e9c95cda829131f71a8dff5ce0406059fd16e591c074414e31ada19ba7c3/validate_pyproject-0.25-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/bc/35/e2fa913ba2d692ccefbf2e21337fe3fd44200efd9a6ee1ba65766eff7d14/pydoclint-0.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bf/50/98b146aea0f1cd7531d25f12bea69fa9ce8d1662124f93fb30dc4511b65e/docstring_parser_fork-0.0.14-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/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/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/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.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/e5/2f/a58a4443a4d052a4ea77557478336aefc26c7981f6408d37adba763aa758/matplotlib-3.11.0-cp312-cp312-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 - pypi: https://files.pythonhosted.org/packages/eb/be/b257e12f9710819fde40adc972578bee6b72c5992da1bc8369bef2597756/nbmake-1.5.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/eb/d4/28a48cb4d7b77a33d4d7633de13c54cb0331d9fa71accb3ecbad20ce7196/plopp-26.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f0/31/2aaabe7d03f395c7b6d955f09a4d1440a2c49920abea42fde88cacc2ef97/dunamai-1.26.2-py3-none-any.whl - 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/f4/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.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/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.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 @@ -1721,77 +1713,75 @@ 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/bzip2-1.0.8-hda65f42_9.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py314h4a8dc5f_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-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-h33c6efd_0.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/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.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/libev-4.33-hd590300_2.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/libgcc-15.2.0-he0feb66_19.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.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/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_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.3-h0c1763c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.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 - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py314h67df5f8_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/msgspec-0.21.1-py314h5bd0f2a_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-26.4.0-hc039f44_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda + - 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_100_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 - conda: https://conda.anaconda.org/conda-forge/linux-64/rpds-py-2026.6.3-py314h1bee95f_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.7-py314h5bd0f2a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd70dff1_3.conda + - 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/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.1-pyhcf101f3_0.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 - conda: https://conda.anaconda.org/conda-forge/noarch/arrow-1.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.2-pyhd8ed1ab_0.conda - 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 - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.6.17-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - 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.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_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.3.1-pyhd8ed1ab_0.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 - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda @@ -1799,7 +1789,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.3.0-pyha191276_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.15.0-pyh53cf698_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.16.1-pyh53cf698_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/isoduration-20.11.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.20.0-pyhcf101f3_0.conda @@ -1809,63 +1799,63 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.26.0-hcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.2.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-lsp-2.3.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda - 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.1-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 - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.11.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio2-1.7.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - 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/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.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_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 - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - 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.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_100.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-tzdata-2026.2-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 - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.28.0-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.29.0-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-2.1.0-pyha191276_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.9.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyhc90fa1f_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.15.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.16.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_utils-0.1.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda @@ -1877,65 +1867,59 @@ 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/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0c/a4/cf8df35b6488c04cae9aa7338c7b531a87aae2515da3993d2906d49b1dc1/copier-9.16.0-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/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/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-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/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/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-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 - pypi: https://files.pythonhosted.org/packages/20/16/e777eadfa0c0305878c36fae1d5e6db474fbb15dae202b9ec378809dfb4d/nbstripout-0.9.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.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/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/28/88/4789719fbbe166d12d345b3ac66b96105f10001b16e00a9765ba29261a21/nbqa-1.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/29/dc/6377ecfaa5fef79430f74a1a16638b4e2aa30d4692bae2c19f9d76fe3b01/matplotlib-3.11.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-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/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.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/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-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/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/43/fe/ad0ecbe2393cb690a4b3100a8fea47ecfdb49f6e06f40cf2f626635adc0c/scipp-26.3.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/4a/8a/4cb9367a86f2b9526727ee94e5e6a3d86f9f7d7d947927b444e5bcd56a89/easyscience-2.3.1-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 - - pypi: https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/59/2d/d741fbbbcba7eb6f9f1a829373c558114d59cb882b95f941aa0dc060861f/plumbum-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.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/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-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/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6e/e9/e2ae007456069dbe01865c69a4203a7ada6f7e337b78fc2f12e51bd3fae7/jupytext-1.19.4-py3-none-any.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/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/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/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-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 + - pypi: https://files.pythonhosted.org/packages/7f/7e/8139f0faa2ff7aee2759300061e4b7e5e661775bcd5bf70810d0793af48a/plopp-26.9.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/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.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/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.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/d7/9b6ac05350ab7f7d3a730ff143ff3e2cada54514117c37be37e26dc91242/docstripy-0.7.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl @@ -1945,93 +1929,98 @@ environments: - pypi: https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl - 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/aa/1d/72a57601a784f38096362badf6796acb519f47fd1d43071e1224e6960b4f/pydoclint-0.9.0-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/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-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 + - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/ee/e9c95cda829131f71a8dff5ce0406059fd16e591c074414e31ada19ba7c3/validate_pyproject-0.25-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/bc/35/e2fa913ba2d692ccefbf2e21337fe3fd44200efd9a6ee1ba65766eff7d14/pydoclint-0.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bf/50/98b146aea0f1cd7531d25f12bea69fa9ce8d1662124f93fb30dc4511b65e/docstring_parser_fork-0.0.14-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/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_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/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/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-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 - pypi: https://files.pythonhosted.org/packages/eb/be/b257e12f9710819fde40adc972578bee6b72c5992da1bc8369bef2597756/nbmake-1.5.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/eb/d4/28a48cb4d7b77a33d4d7633de13c54cb0331d9fa71accb3ecbad20ce7196/plopp-26.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f0/31/2aaabe7d03f395c7b6d955f09a4d1440a2c49920abea42fde88cacc2ef97/dunamai-1.26.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/16/d905e7f53e661ce2c24686c38048d8e2b750ffc4350009d41c4e6c6c9826/h5py-3.16.0-cp314-cp314-manylinux_2_28_x86_64.whl - 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/f4/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.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/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-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/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 - pypi: https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fe/d7/a71b12b625c44cfcb95d41397752edccc0a731903081609cffd6720f671d/asteval-1.0.9-py3-none-any.whl p2: - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.1-pyhcf101f3_0.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/appnope-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/argon2-cffi-25.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/arrow-1.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.2-pyhd8ed1ab_0.conda - 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 - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.6.17-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - 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.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_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.3.1-pyhd8ed1ab_0.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 - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda @@ -2039,7 +2028,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.3.0-pyh01cf8df_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.15.0-pyh53cf698_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.16.1-pyh53cf698_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/isoduration-20.11.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.20.0-pyhcf101f3_0.conda @@ -2049,64 +2038,64 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.26.0-hcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.2.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-lsp-2.3.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda - 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.1-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 - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.11.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio2-1.7.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - 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/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.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_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 - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - 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.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_100.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-tzdata-2026.2-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 - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.28.0-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.29.0-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-2.1.0-pyh5552912_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-84.0.0-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.9.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyhc90fa1f_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.15.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.16.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_utils-0.1.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda @@ -2115,110 +2104,109 @@ 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/bzip2-1.0.8-hd037594_9.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py314h44086f9_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-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-hef89b57_0.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/libev-4.33-h93a5062_2.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/liblzma-5.8.3-h8088a28_0.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/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_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.3-h1b79a29_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.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/libuv-1.52.1-h1a92334_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.3-py314h6e9b3f0_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/msgspec-0.21.1-py314h6c2aa35_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.4.0-h00e74ec_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda + - 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_100_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 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rpds-py-2026.6.3-py314he1d1ac0_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.7-py314h6c2aa35_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hd3d0363_3.conda + - 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 - - pypi: https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0c/a4/cf8df35b6488c04cae9aa7338c7b531a87aae2515da3993d2906d49b1dc1/copier-9.16.0-py3-none-any.whl + - 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/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0f/0e/0eb94e64f5badef67f11fe1e448dde2a44f00940d8949f4adf71d560552e/scipp-26.3.1-cp314-cp314-macosx_14_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/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.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/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-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/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-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 - 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 - - pypi: https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.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/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl - pypi: https://files.pythonhosted.org/packages/28/88/4789719fbbe166d12d345b3ac66b96105f10001b16e00a9765ba29261a21/nbqa-1.9.1-py3-none-any.whl - 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/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-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/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/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-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/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/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-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/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/4a/8a/4cb9367a86f2b9526727ee94e5e6a3d86f9f7d7d947927b444e5bcd56a89/easyscience-2.3.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-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 - 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 - - pypi: https://files.pythonhosted.org/packages/59/2d/d741fbbbcba7eb6f9f1a829373c558114d59cb882b95f941aa0dc060861f/plumbum-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.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/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-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/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-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/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/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6e/e9/e2ae007456069dbe01865c69a4203a7ada6f7e337b78fc2f12e51bd3fae7/jupytext-1.19.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/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7c/03/b8cdb625a21f710dfa11bbca1f48fb4057d2c0286975f8b415bf80942c99/matplotlib-3.11.0-cp314-cp314-macosx_11_0_arm64.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/7e/8139f0faa2ff7aee2759300061e4b7e5e661775bcd5bf70810d0793af48a/plopp-26.9.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/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.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/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 - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl @@ -2228,8 +2216,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl @@ -2238,19 +2228,22 @@ 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/aa/1d/72a57601a784f38096362badf6796acb519f47fd1d43071e1224e6960b4f/pydoclint-0.9.0-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/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-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 + - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ee/e9c95cda829131f71a8dff5ce0406059fd16e591c074414e31ada19ba7c3/validate_pyproject-0.25-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/bc/35/e2fa913ba2d692ccefbf2e21337fe3fd44200efd9a6ee1ba65766eff7d14/pydoclint-0.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bf/50/98b146aea0f1cd7531d25f12bea69fa9ce8d1662124f93fb30dc4511b65e/docstring_parser_fork-0.0.14-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/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 @@ -2258,62 +2251,57 @@ environments: - 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/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/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-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/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/eb/be/b257e12f9710819fde40adc972578bee6b72c5992da1bc8369bef2597756/nbmake-1.5.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/eb/d4/28a48cb4d7b77a33d4d7633de13c54cb0331d9fa71accb3ecbad20ce7196/plopp-26.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f0/31/2aaabe7d03f395c7b6d955f09a4d1440a2c49920abea42fde88cacc2ef97/dunamai-1.26.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl - 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/f4/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.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/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.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/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-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 - pypi: https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/fe/d7/a71b12b625c44cfcb95d41397752edccc0a731903081609cffd6720f671d/asteval-1.0.9-py3-none-any.whl win-64: - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.1-pyhcf101f3_0.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 - conda: https://conda.anaconda.org/conda-forge/noarch/arrow-1.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.2-pyhd8ed1ab_0.conda - 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 - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-h4c7d964_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.6.17-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-h4c7d964_0.conda + - 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.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_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.3.1-pyhd8ed1ab_0.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 - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda @@ -2321,7 +2309,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.3.0-pyh6dadd2b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.15.0-pyhe2676ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.16.1-pyhe2676ad_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/isoduration-20.11.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.20.0-pyhcf101f3_0.conda @@ -2331,61 +2319,61 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.26.0-hcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.2.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-lsp-2.3.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyh6dadd2b_0.conda - 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.1-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 - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.11.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio2-1.7.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - 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.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_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 - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - 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.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_100.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-tzdata-2026.2-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 - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.28.0-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.29.0-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-2.1.0-pyh6dadd2b_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.9.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyh6dadd2b_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.15.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.16.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_utils-0.1.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda @@ -2395,98 +2383,93 @@ 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/bzip2-1.0.8-h0ad9c76_9.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py314h5a2d7ad_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_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/liblzma-5.8.3-hfd05255_0.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/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_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.3-hf5d6505_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.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 - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py314h2359020_1.conda - 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/nodejs-26.4.0-h80d1838_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda + - 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_100_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-2.0.15-py314h51f0985_1.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 - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312h343a6d4_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-2026.6.3-py314hc980628_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h6ed50ae_3.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.7-py314h5a2d7ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h967ab96_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.8-py314h5a2d7ad_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-ha367084_41.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36247-habf1de7_41.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - 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/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/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0c/a4/cf8df35b6488c04cae9aa7338c7b531a87aae2515da3993d2906d49b1dc1/copier-9.16.0-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/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/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-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/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/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/1a/f7/3a9e6389a7cfaeff76c56e40c2dabcb13110e21e82f837228c834ebe748c/matplotlib-3.11.0-cp314-cp314-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/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 - - pypi: https://files.pythonhosted.org/packages/1f/28/3f8aa247d29d010547d52207395cb057ebd0a40b88f64bc1dbac9e17a729/scipp-26.3.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/20/16/e777eadfa0c0305878c36fae1d5e6db474fbb15dae202b9ec378809dfb4d/nbstripout-0.9.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.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/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/28/88/4789719fbbe166d12d345b3ac66b96105f10001b16e00a9765ba29261a21/nbqa-1.9.1-py3-none-any.whl - 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/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-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/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.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/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/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-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/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/4a/8a/4cb9367a86f2b9526727ee94e5e6a3d86f9f7d7d947927b444e5bcd56a89/easyscience-2.3.1-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 - - pypi: https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/59/2d/d741fbbbcba7eb6f9f1a829373c558114d59cb882b95f941aa0dc060861f/plumbum-2.0.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/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.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/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl + - 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/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6e/e9/e2ae007456069dbe01865c69a4203a7ada6f7e337b78fc2f12e51bd3fae7/jupytext-1.19.4-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/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/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-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 + - pypi: https://files.pythonhosted.org/packages/7f/7e/8139f0faa2ff7aee2759300061e4b7e5e661775bcd5bf70810d0793af48a/plopp-26.9.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/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.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/d7/9b6ac05350ab7f7d3a730ff143ff3e2cada54514117c37be37e26dc91242/docstripy-0.7.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl @@ -2498,9 +2481,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl @@ -2509,50 +2495,52 @@ 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/aa/1d/72a57601a784f38096362badf6796acb519f47fd1d43071e1224e6960b4f/pydoclint-0.9.0-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/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.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 + - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ee/e9c95cda829131f71a8dff5ce0406059fd16e591c074414e31ada19ba7c3/validate_pyproject-0.25-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/bc/35/e2fa913ba2d692ccefbf2e21337fe3fd44200efd9a6ee1ba65766eff7d14/pydoclint-0.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bf/50/98b146aea0f1cd7531d25f12bea69fa9ce8d1662124f93fb30dc4511b65e/docstring_parser_fork-0.0.14-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/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 - 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/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ce/04/d719a0a36930ecc8dfc801ff340f9dcfc4223f8ca5d39d06b4020032fff8/matplotlib-3.11.1-cp314-cp314-win_amd64.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/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/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.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/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 - - pypi: https://files.pythonhosted.org/packages/eb/d4/28a48cb4d7b77a33d4d7633de13c54cb0331d9fa71accb3ecbad20ce7196/plopp-26.6.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.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 + - pypi: https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f0/31/2aaabe7d03f395c7b6d955f09a4d1440a2c49920abea42fde88cacc2ef97/dunamai-1.26.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f4/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.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/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/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.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 @@ -2568,66 +2556,66 @@ 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/bzip2-1.0.8-hda65f42_9.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py314h4a8dc5f_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-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/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/ld_impl_linux-64-2.45.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/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-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/libgcc-15.2.0-he0feb66_19.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.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/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_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.3-h0c1763c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.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 - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py314h67df5f8_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/msgspec-0.21.1-py314h5bd0f2a_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + - 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_100_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 - conda: https://conda.anaconda.org/conda-forge/linux-64/rpds-py-2026.6.3-py314h1bee95f_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.7-py314h5bd0f2a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd70dff1_3.conda + - 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/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.1-pyhcf101f3_0.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 - conda: https://conda.anaconda.org/conda-forge/noarch/arrow-1.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.2-pyhd8ed1ab_0.conda - 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 - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.6.17-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - 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.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_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.3.1-pyhd8ed1ab_0.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 - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda @@ -2635,7 +2623,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.3.0-pyha191276_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.15.0-pyh53cf698_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.16.1-pyh53cf698_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/isoduration-20.11.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.20.0-pyhcf101f3_0.conda @@ -2645,63 +2633,63 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.26.0-hcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.2.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-lsp-2.3.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda - 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.1-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 - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.11.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio2-1.7.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - 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/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.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_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 - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - 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.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_100.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-tzdata-2026.2-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 - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.28.0-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.29.0-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-2.1.0-pyha191276_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.9.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyhc90fa1f_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.15.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.16.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_utils-0.1.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda @@ -2710,83 +2698,82 @@ 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 - pypi: https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-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/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/29/dc/6377ecfaa5fef79430f74a1a16638b4e2aa30d4692bae2c19f9d76fe3b01/matplotlib-3.11.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.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 - pypi: https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/38/7e/7b91c89a4cf0f543a83be978657afb20c86af6d725253e319589dcc4ce52/lmfit-1.3.4-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/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/43/fe/ad0ecbe2393cb690a4b3100a8fea47ecfdb49f6e06f40cf2f626635adc0c/scipp-26.3.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/4a/8a/4cb9367a86f2b9526727ee94e5e6a3d86f9f7d7d947927b444e5bcd56a89/easyscience-2.3.1-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/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/5b/69/93b34728cc386efdde0c342f8c680b9187dea7beb7adaf6b58a0713be101/mpld3-0.5.12-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-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/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/61/95/e1f555187f0a95a656e84fcf6657f17b8ab45071529496b1258a7a5c0298/easydynamics-0.8.0-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/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 + - pypi: https://files.pythonhosted.org/packages/7f/7e/8139f0faa2ff7aee2759300061e4b7e5e661775bcd5bf70810d0793af48a/plopp-26.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/82/f5/2f77f0bc663c13371d1c00ab8e550e2c9b11fec3c63ebf12a8336c0f534e/bumps-1.0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/5e/f1e1dd319e35e962a4e00b33150a8868b6329cc1d19fd533436ba5488f09/uncertainties-3.2.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.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/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/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_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/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 - - pypi: https://files.pythonhosted.org/packages/eb/d4/28a48cb4d7b77a33d4d7633de13c54cb0331d9fa71accb3ecbad20ce7196/plopp-26.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/16/d905e7f53e661ce2c24686c38048d8e2b750ffc4350009d41c4e6c6c9826/h5py-3.16.0-cp314-cp314-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f4/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.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/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/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fe/d7/a71b12b625c44cfcb95d41397752edccc0a731903081609cffd6720f671d/asteval-1.0.9-py3-none-any.whl p2: - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.1-pyhcf101f3_0.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/appnope-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/argon2-cffi-25.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/arrow-1.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.2-pyhd8ed1ab_0.conda - 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 - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.6.17-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - 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.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_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.3.1-pyhd8ed1ab_0.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 - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda @@ -2794,7 +2781,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.3.0-pyh01cf8df_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.15.0-pyh53cf698_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.16.1-pyh53cf698_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/isoduration-20.11.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.20.0-pyhcf101f3_0.conda @@ -2804,64 +2791,64 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.26.0-hcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.2.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-lsp-2.3.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda - 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.1-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 - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.11.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio2-1.7.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - 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/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.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_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 - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - 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.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_100.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-tzdata-2026.2-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 - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.28.0-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.29.0-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-2.1.0-pyh5552912_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-84.0.0-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.9.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyhc90fa1f_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.15.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.16.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_utils-0.1.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda @@ -2870,114 +2857,114 @@ 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/bzip2-1.0.8-hd037594_9.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py314h44086f9_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-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/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/liblzma-5.8.3-h8088a28_0.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/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_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.3-h1b79a29_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.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 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.3-py314h6e9b3f0_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/msgspec-0.21.1-py314h6c2aa35_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + - 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_100_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 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rpds-py-2026.6.3-py314he1d1ac0_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.7-py314h6c2aa35_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hd3d0363_3.conda + - 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 - - pypi: https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0f/0e/0eb94e64f5badef67f11fe1e448dde2a44f00940d8949f4adf71d560552e/scipp-26.3.1-cp314-cp314-macosx_14_0_arm64.whl + - 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/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 - pypi: https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl - pypi: https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-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/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-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/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/4a/8a/4cb9367a86f2b9526727ee94e5e6a3d86f9f7d7d947927b444e5bcd56a89/easyscience-2.3.1-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/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/5b/69/93b34728cc386efdde0c342f8c680b9187dea7beb7adaf6b58a0713be101/mpld3-0.5.12-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-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/61/95/e1f555187f0a95a656e84fcf6657f17b8ab45071529496b1258a7a5c0298/easydynamics-0.8.0-py3-none-any.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/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-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/7c/03/b8cdb625a21f710dfa11bbca1f48fb4057d2c0286975f8b415bf80942c99/matplotlib-3.11.0-cp314-cp314-macosx_11_0_arm64.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/7e/8139f0faa2ff7aee2759300061e4b7e5e661775bcd5bf70810d0793af48a/plopp-26.9.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/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/5e/f1e1dd319e35e962a4e00b33150a8868b6329cc1d19fd533436ba5488f09/uncertainties-3.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.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/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.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 + - 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/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/eb/d4/28a48cb4d7b77a33d4d7633de13c54cb0331d9fa71accb3ecbad20ce7196/plopp-26.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f4/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.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/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/fe/d7/a71b12b625c44cfcb95d41397752edccc0a731903081609cffd6720f671d/asteval-1.0.9-py3-none-any.whl win-64: - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.1-pyhcf101f3_0.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 - conda: https://conda.anaconda.org/conda-forge/noarch/arrow-1.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.2-pyhd8ed1ab_0.conda - 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 - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-h4c7d964_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.6.17-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-h4c7d964_0.conda + - 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.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_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.3.1-pyhd8ed1ab_0.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 - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda @@ -2985,7 +2972,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.3.0-pyh6dadd2b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.15.0-pyhe2676ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.16.1-pyhe2676ad_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/isoduration-20.11.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.20.0-pyhcf101f3_0.conda @@ -2995,61 +2982,61 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.26.0-hcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.0.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.2.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-lsp-2.3.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyh6dadd2b_0.conda - 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.1-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 - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.11.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio2-1.7.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - 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.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_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 - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - 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.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_100.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-tzdata-2026.2-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 - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.28.0-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.29.0-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-2.1.0-pyh6dadd2b_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.9.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyh6dadd2b_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.15.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.16.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_utils-0.1.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda @@ -3059,86 +3046,86 @@ 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/bzip2-1.0.8-h0ad9c76_9.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py314h5a2d7ad_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_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/liblzma-5.8.3-hfd05255_0.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/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_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.3-hf5d6505_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.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 - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py314h2359020_1.conda - 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_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_100_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-2.0.15-py314h51f0985_1.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 - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312h343a6d4_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-2026.6.3-py314hc980628_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h6ed50ae_3.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.7-py314h5a2d7ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h967ab96_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.8-py314h5a2d7ad_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-ha367084_41.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36247-habf1de7_41.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - 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 - - pypi: https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl + - 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/1a/f7/3a9e6389a7cfaeff76c56e40c2dabcb13110e21e82f837228c834ebe748c/matplotlib-3.11.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/1f/28/3f8aa247d29d010547d52207395cb057ebd0a40b88f64bc1dbac9e17a729/scipp-26.3.1-cp314-cp314-win_amd64.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 - - pypi: https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/38/7e/7b91c89a4cf0f543a83be978657afb20c86af6d725253e319589dcc4ce52/lmfit-1.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/14/615a450205e1b56d16c6783f5ccd116cde05550faad70ae077c955654a75/h5py-3.16.0-cp314-cp314-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/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4a/8a/4cb9367a86f2b9526727ee94e5e6a3d86f9f7d7d947927b444e5bcd56a89/easyscience-2.3.1-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/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/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/61/95/e1f555187f0a95a656e84fcf6657f17b8ab45071529496b1258a7a5c0298/easydynamics-0.8.0-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 + - pypi: https://files.pythonhosted.org/packages/7f/7e/8139f0faa2ff7aee2759300061e4b7e5e661775bcd5bf70810d0793af48a/plopp-26.9.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/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/5e/f1e1dd319e35e962a4e00b33150a8868b6329cc1d19fd533436ba5488f09/uncertainties-3.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.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/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.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/ce/04/d719a0a36930ecc8dfc801ff340f9dcfc4223f8ca5d39d06b4020032fff8/matplotlib-3.11.1-cp314-cp314-win_amd64.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 - - pypi: https://files.pythonhosted.org/packages/eb/d4/28a48cb4d7b77a33d4d7633de13c54cb0331d9fa71accb3ecbad20ce7196/plopp-26.6.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.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/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f4/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-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/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/d7/a71b12b625c44cfcb95d41397752edccc0a731903081609cffd6720f671d/asteval-1.0.9-py3-none-any.whl packages: @@ -3191,60 +3178,60 @@ 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=compressed-mapping + - 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 -- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda - sha256: 0b75d45f0bba3e95dc693336fa51f40ea28c980131fec438afb7ce6118ed05f6 - md5: d2ffd7602c02f2b316fd921d39876885 + size: 367948 + timestamp: 1786622843866 +- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda + sha256: 1a0d382c515ebf55f8ee1f38c8b81bc95af5c2acc42ad53b66bc5df932032f96 + md5: e675fabcf81499adc7edf58124fb1e01 depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 @@ -3254,29 +3241,31 @@ packages: run_exports: weak: - bzip2 >=1.0.8,<2.0a0 - size: 260182 - timestamp: 1771350215188 -- conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda - sha256: cc9accf72fa028d31c2a038460787751127317dcfa991f8d1f1babf216bb454e - md5: 920bb03579f15389b9e512095ad995b7 + size: 257808 + timestamp: 1785906269155 +- conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.8-h280c20c_1.conda + sha256: 5139b6afbfaca91c47104ba9a6a40f81211e1c9200e96b73ce3a56f0ca1902f4 + md5: 2cef891b791040aab83e218c7d137679 depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 + constrains: + - c-ares-static <0a0 license: MIT license_family: MIT purls: [] run_exports: weak: - - c-ares >=1.34.6,<2.0a0 - size: 207882 - timestamp: 1765214722852 -- conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py312h460c074_1.conda - sha256: 7dafe8173d5f94e46cf9cd597cc8ff476a8357fbbd4433a8b5697b2864845d9c - md5: 648ee28dcd4e07a1940a17da62eccd40 + - c-ares >=1.34.8,<2.0a0 + size: 226755 + timestamp: 1786116641939 +- 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 @@ -3285,15 +3274,15 @@ packages: purls: - pkg:pypi/cffi?source=hash-mapping run_exports: {} - size: 295716 - timestamp: 1761202958833 -- conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py314h4a8dc5f_1.conda - sha256: c6339858a0aaf5d939e00d345c98b99e4558f285942b27232ac098ad17ac7f8e - md5: cf45f4278afd6f4e6d03eda0f435d527 + 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 @@ -3302,8 +3291,8 @@ packages: purls: - pkg:pypi/cffi?source=hash-mapping run_exports: {} - size: 300271 - timestamp: 1761203085220 + size: 306626 + timestamp: 1786775112485 - conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.21-py312h8285ef7_0.conda sha256: b8dbe25820064a099f315bbb8f45f5bac3fddb63e96af3cbf0c93a830733ef34 md5: e6778419a1851f6e15820558abddfa04 @@ -3336,44 +3325,44 @@ packages: run_exports: {} size: 2842115 timestamp: 1780390153580 -- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda - sha256: fbf86c4a59c2ed05bbffb2ba25c7ed94f6185ec30ecb691615d42342baa1a16a - md5: c80d8a3b84358cb967fa81e7075fbc8a +- 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 - - libgcc >=14 - libstdcxx >=14 + - libgcc >=14 license: MIT license_family: MIT purls: [] run_exports: weak: - icu >=78.3,<79.0a0 - size: 12723451 - timestamp: 1773822285671 -- 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 @@ -3381,22 +3370,22 @@ packages: run_exports: weak: - krb5 >=1.22.2,<1.23.0a0 - size: 1388990 - timestamp: 1781859420533 -- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda - sha256: 3d584956604909ff5df353767f3a2a2f60e07d070b328d109f30ac40cd62df6c - md5: 18335a698559cdbcd86150a48bf54ba6 + 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 depends: - __glibc >=2.17,<3.0.a0 - zstd >=1.5.7,<1.6.0a0 constrains: - - binutils_impl_linux-64 2.45.1 + - binutils_impl_linux-64 2.46.1 license: GPL-3.0-only license_family: GPL purls: [] run_exports: {} - size: 728002 - timestamp: 1774197446916 + size: 745303 + timestamp: 1784214507189 - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h7b12aa8_1.conda sha256: 32933de2d4fa6e6ffd949052815b49cb65a0649ad70007155c533ab97ea8cefd md5: c4393db381bffa0a83a8d9e47b238106 @@ -3416,79 +3405,80 @@ 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 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda - sha256: 1cd6048169fa0395af74ed5d8f1716e22c19a81a8a36f934c110ca3ad4dd27b4 - md5: 172bf1cd1ff8629f2b1179945ed45055 + size: 135098 + timestamp: 1786616658086 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h280c20c_3.conda + sha256: e4418f68d01a6307c4431b585c71b584f40189877364e542ee87deb777f5e85b + md5: 7bc31538d6e3fb349e897353969237ec depends: - - libgcc-ng >=12 - license: BSD-2-Clause - license_family: BSD + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: BSD-2-Clause OR GPL-2.0-or-later + license_family: GPL purls: [] run_exports: weak: - libev >=4.33,<4.34.0a0 - size: 112766 - timestamp: 1702146165126 + size: 43220 + timestamp: 1785917328200 - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda sha256: 16feffd9ddbbe5b718515d38ee376c685ba95491cd901244e24671d20b952a77 md5: b24d3c612f71e7aa74158d92106318b2 @@ -3503,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 @@ -3514,40 +3504,27 @@ packages: purls: [] run_exports: weak: - - libffi >=3.5.2,<3.6.0a0 - size: 58592 - timestamp: 1769456073053 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda - sha256: 8e0a3b5e41272e5678499b5dfc4cddb673f9e935de01eb0767ce857001229f46 - md5: 57736f29cc2b0ec0b6c2952d3f101b6a + - 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 depends: - __glibc >=2.17,<3.0.a0 - _openmp_mutex >=4.5 constrains: - - libgcc-ng ==15.2.0=*_19 - - libgomp 15.2.0 he0feb66_19 + - libgomp 16.1.0 he0feb66_1 + - libgcc-ng ==16.1.0=*_1 license: GPL-3.0-only WITH GCC-exception-3.1 license_family: GPL purls: [] run_exports: {} - size: 1041084 - timestamp: 1778269013026 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda - sha256: 9dcf54adfaa5e861123c2da4f2f0451a685464ea7e5a41ad91cf67b31d658d98 - md5: 331ee9b72b9dff570d56b1302c5ab37d - depends: - - libgcc 15.2.0 he0feb66_19 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - purls: [] - run_exports: - strong: - - libgcc - size: 27694 - timestamp: 1778269016987 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda - sha256: 5abe4ab9d93f6c9757d654f1969ae2267d4505315c1f2f8fe705fd60af084f1b - md5: faac990cb7aedc7f3a2224f2c9b0c26c + size: 1057877 + timestamp: 1785375436766 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda + sha256: 62cb599ad0539d99386515326d9d5e8f51f75a60c69c2131b21df76edf35bd89 + md5: 88f2d91cb1533194c323534253094d23 depends: - __glibc >=2.17,<3.0.a0 license: GPL-3.0-only WITH GCC-exception-3.1 @@ -3556,11 +3533,11 @@ packages: run_exports: strong: - _openmp_mutex >=4.5 - size: 603817 - timestamp: 1778268942614 -- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda - sha256: ec30e52a3c1bf7d0425380a189d209a52baa03f22fb66dd3eb587acaa765bd6d - md5: b88d90cad08e6bc8ad540cb310a761fb + size: 640415 + timestamp: 1785375373755 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda + sha256: 9787df8c22a59c9a70d3e5a10db9ad663485e75e9ccc3f09bd092cb7b95e0dab + md5: 1390b7c5ac0b1d8e447bc5efa6d3c8c2 depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 @@ -3571,11 +3548,11 @@ packages: run_exports: weak: - liblzma >=5.8.3,<6.0a0 - size: 113478 - timestamp: 1775825492909 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda - sha256: fe171ed5cf5959993d43ff72de7596e8ac2853e9021dec0344e583734f1e0843 - md5: 2c21e66f50753a083cbe6b80f38268fa + size: 112995 + timestamp: 1786348617826 +- 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 @@ -3583,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 @@ -3632,34 +3609,35 @@ packages: - libsodium >=1.0.22,<1.0.23.0a0 size: 269272 timestamp: 1779163468406 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.3-h0c1763c_0.conda - sha256: 365376f4815e5e80def2b3462a2419708b7c292da0da85278386c2618621fff4 - md5: 4aed8e657e9ff156bdbe849b4df44389 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda + sha256: 72023efc207fe681e26b65fc9d668062cf0b4f0eacf3431e6eb099b95c1f2efd + md5: df088a279cd5e6fd2790b4c196434da1 depends: - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 - libgcc >=14 - libzlib >=1.3.2,<2.0a0 license: blessing purls: [] run_exports: weak: - - libsqlite >=3.53.3,<4.0a0 - size: 962119 - timestamp: 1782519076616 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda - sha256: dff1058c76ec6b8759e41cefa2508162d00e4a5e6721aa68ec3fd10094e702dc - md5: 5794b3bdc38177caf969dabd3af08549 + - libsqlite >=3.53.4,<4.0a0 + size: 964200 + timestamp: 1785016112246 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.1.0-h934c35e_1.conda + sha256: 79721dd08aeb0ab9e773f1f9ef41cf4e6c17477e3d72319147619045bce05a09 + md5: aed6cf89adc1e9b846e4367ac538e434 depends: - __glibc >=2.17,<3.0.a0 - - libgcc 15.2.0 he0feb66_19 + - libgcc 16.1.0 ha9f2e26_1 constrains: - - libstdcxx-ng ==15.2.0=*_19 + - libstdcxx-ng ==16.1.0=*_1 license: GPL-3.0-only WITH GCC-exception-3.1 license_family: GPL purls: [] run_exports: {} - size: 5852044 - timestamp: 1778269036376 + size: 6631744 + timestamp: 1785375462643 - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda sha256: 9b1bdce27a7e31f7d241aeecff67a1f3101d52a2b1e33ccc2cdf2613072bf81f md5: 01bb81d12c957de066ea7362007df642 @@ -3674,9 +3652,9 @@ packages: - libuuid >=2.42.2,<3.0a0 size: 40017 timestamp: 1781625522462 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_0.conda - sha256: e28e4519223f78b3163599ca89c3f2d80bfb53e907e7fc74e806e60d1efa578b - md5: 4e33d49bf4fc853855a3b00643aa5484 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_1.conda + sha256: 67761d0206f84140047a367eaf9befe03a7e157a29ee25aee0047b40801cc6b6 + md5: 01c4ed87826af55996768af6dbc936b4 depends: - libgcc >=14 - __glibc >=2.17,<3.0.a0 @@ -3686,35 +3664,36 @@ packages: run_exports: weak: - libuv >=1.52.1,<2.0a0 - size: 419935 - timestamp: 1779396012261 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda - sha256: 6ae68e0b86423ef188196fff6207ed0c8195dd84273cb5623b85aa08033a410c - md5: 5aa797f8787fe7a17d1b0821485b5adc + size: 420040 + timestamp: 1785914567661 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.38-h280c20c_0.conda + sha256: f7e9292dd219a6435bbb1223da9586c3e70d66d169c5a92f08db3f2127df04e9 + md5: f7a7ff5a6ab331e037abd34f379a631d depends: - - libgcc-ng >=12 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 license: LGPL-2.1-or-later purls: [] run_exports: weak: - - libxcrypt >=4.4.36 - size: 100393 - timestamp: 1702724383534 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda - sha256: 55044c403570f0dc26e6364de4dc5368e5f3fc7ff103e867c487e2b5ab2bcda9 - md5: d87ff7921124eccd67248aa483c23fec + - libxcrypt >=4.4.38 + size: 101957 + timestamp: 1785887123445 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + sha256: eb8a0db0aa570124f7d2a93d7c7f596e3390df5e047818d873baad32985fc736 + md5: 0de0122d9570a8ab637c6b73db268389 depends: - __glibc >=2.17,<3.0.a0 constrains: - - zlib 1.3.2 *_2 + - zlib 1.3.2 *_3 license: Zlib license_family: Other purls: [] run_exports: weak: - libzlib >=1.3.2,<2.0a0 - size: 63629 - timestamp: 1774072609062 + size: 63713 + timestamp: 1785362952714 - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py312h8a5da7c_1.conda sha256: 5f3aad1f3a685ed0b591faad335957dbdb1b73abfd6fc731a0d42718e0653b33 md5: 93a4752d42b12943a355b682ee43285b @@ -3779,9 +3758,9 @@ packages: run_exports: {} size: 220990 timestamp: 1776337508167 -- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda - sha256: fc89f74bbe362fb29fa3c037697a89bec140b346a2469a90f7936d1d7ea4d8a3 - md5: fc21868a1a5aacc937e7a18747acb8a5 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda + sha256: 5d46557214ed184381dafe835b7c94a474a1c3b307a08a250b1ea4779b44ffb3 + md5: ee6c0cd80a60961a1f48aa3e0b91f986 depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 @@ -3790,39 +3769,39 @@ packages: run_exports: weak: - ncurses >=6.6,<7.0a0 - size: 918956 - timestamp: 1777422145199 -- conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-26.4.0-hc039f44_0.conda - sha256: 50b1382dcccaacd34ff30a1fd7029d4f7d2df1a58149d679485a99262d77070f - md5: 0619acea812ebb49f7f42de91d3d2fc5 + size: 911196 + timestamp: 1786355078102 +- conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-26.6.0-hc039f44_0.conda + sha256: 049788cee5e10fe13846f806d8ae854642414aeb06018ad268ce9959b529be77 + md5: c99a655b4c729eef64e63140962b8cc9 depends: - __glibc >=2.28,<3.0.a0 - libstdcxx >=14 - libgcc >=14 - - libbrotlicommon >=1.2.0,<1.3.0a0 - - libbrotlienc >=1.2.0,<1.3.0a0 - - libbrotlidec >=1.2.0,<1.3.0a0 - - icu >=78.3,<79.0a0 - libuv >=1.52.1,<2.0a0 - - zstd >=1.5.7,<1.6.0a0 - - c-ares >=1.34.6,<2.0a0 - - openssl >=3.5.7,<4.0a0 - - libzlib >=1.3.2,<2.0a0 - - libsqlite >=3.53.2,<4.0a0 - libabseil >=20260526.0,<20260527.0a0 - libabseil * cxx17* + - zstd >=1.5.7,<1.6.0a0 + - libsqlite >=3.53.4,<4.0a0 + - icu >=78.3,<79.0a0 + - openssl >=3.5.7,<4.0a0 - libnghttp2 >=1.68.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - c-ares >=1.34.8,<2.0a0 license: MIT license_family: MIT purls: [] run_exports: weak: - - nodejs >=26.4.0,<27.0a0 - size: 19936812 - timestamp: 1782378393252 -- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda - sha256: d48f5c22b9897c01e4dff3680f1f57ceb02711ab9c62f74339b080419dfad34b - md5: 79dd2074b5cd5c5c6b2930514a11e22d + - nodejs >=26.6.0,<27.0a0 + size: 20013853 + timestamp: 1785852655297 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + sha256: 012096056b97abf1f68c46b7146bd2cbd68c1be762340b4f5dad4fbbe99177bc + md5: c5955c27917ff2234def47f075e71e02 depends: - __glibc >=2.17,<3.0.a0 - ca-certificates @@ -3833,8 +3812,8 @@ packages: run_exports: weak: - openssl >=3.6.3,<4.0a0 - size: 3159683 - timestamp: 1781069855778 + size: 3182423 + timestamp: 1785913583650 - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py312h5253ce2_0.conda sha256: d834fd656133c9e4eaf63ffe9a117c7d0917d86d89f7d64073f4e3a0020bd8a7 md5: dd94c506b119130aef5a9382aed648e7 @@ -3865,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 @@ -3895,23 +3875,23 @@ 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_100_cp314.conda - build_number: 100 - sha256: 6d28ac2b061179deb434d3d57afa98ffd20ec3c5d44ab8048a1ca33424b22d38 - md5: 0b9b2f83b5b600e1ac38becde8d0dd44 + 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.2,<4.0a0 - - libuuid >=2.42.1,<3.0a0 + - libsqlite >=3.53.4,<4.0a0 + - libuuid >=2.42.2,<3.0a0 - libzlib >=1.3.2,<2.0a0 - ncurses >=6.6,<7.0a0 - openssl >=3.5.7,<4.0a0 @@ -3927,8 +3907,8 @@ packages: - python_abi 3.14.* *_cp314 noarch: - python - size: 36717183 - timestamp: 1781255094700 + 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 @@ -4009,7 +3989,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/rpds-py?source=compressed-mapping + - pkg:pypi/rpds-py?source=hash-mapping run_exports: {} size: 300259 timestamp: 1782831325201 @@ -4026,30 +4006,30 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/rpds-py?source=compressed-mapping + - pkg:pypi/rpds-py?source=hash-mapping run_exports: {} size: 300129 timestamp: 1782831350283 -- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - sha256: cafeec44494f842ffeca27e9c8b0c27ed714f93ac77ddadc6aaf726b5554ebac - md5: cffd3bdd58090148f4cfcd831f4b26ab +- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd70dff1_3.conda + build_number: 103 + sha256: 43624eab22f5f29df7d6ffe914cf442f28fd559b55b290906255492826e636e8 + md5: 48a1049e710857572fc2a832aa394d9f depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 - - libzlib >=1.3.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 constrains: - - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 license: TCL - license_family: BSD purls: [] run_exports: weak: - tk >=8.6.13,<8.7.0a0 - size: 3301196 - timestamp: 1769460227866 -- conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.7-py312h4c3975b_0.conda - sha256: f54504d6eeef133ddc2b964b6a021f3faf085bb08bd70debc07f56d6b9b726f1 - md5: 55f526c3fb5302a1ce922612348442e1 + size: 3550916 + timestamp: 1784229071544 +- conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.8-py312h4c3975b_0.conda + sha256: 2ea8f8b10e869b675d2fba157b387eaf4eed1696bfc924bb7b28e67d7aa0a947 + md5: 5423c2b8f82d60320e65b9ff30babbdf depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 @@ -4058,13 +4038,13 @@ packages: license: Apache-2.0 license_family: Apache purls: - - pkg:pypi/tornado?source=hash-mapping + - pkg:pypi/tornado?source=compressed-mapping run_exports: {} - size: 864705 - timestamp: 1781006801632 -- conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.7-py314h5bd0f2a_0.conda - sha256: bbb7056f7c5fd606df16ed73ee68687050de2c02fd69a3f69a1cb533a7ed2ae8 - md5: 4a8e5889712641aabdf6695e292857fe + size: 869730 + timestamp: 1786226754702 +- conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.8-py314h5bd0f2a_0.conda + sha256: ebec0d90f99fa7bbe91eabab41ee77d3f36dbd58ec2f08aa4220fdd713610b6d + md5: faea84d2f2ccadf70cf9e55381d75b3e depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 @@ -4073,10 +4053,10 @@ packages: license: Apache-2.0 license_family: Apache purls: - - pkg:pypi/tornado?source=hash-mapping + - pkg:pypi/tornado?source=compressed-mapping run_exports: {} - size: 918368 - timestamp: 1781006801436 + size: 923237 + timestamp: 1786226770518 - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda sha256: 6d9ea2f731e284e9316d95fa61869fe7bbba33df7929f82693c121022810f4ad md5: a77f85f77be52ff59391544bfe73390a @@ -4108,23 +4088,23 @@ 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 -- conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - sha256: a3967b937b9abf0f2a99f3173fa4630293979bd1644709d89580e7c62a544661 - md5: aaa2a381ccc56eac91d63b6c1240312f + size: 601301 + timestamp: 1786599621503 +- conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda + sha256: 2a7204314663eeda5dec482a956f0e2eaf289bd5b9953eaaaad0e81aa64638f2 + md5: 3845f3d75991bae0fb90884662f4327c depends: - cpython - python-gil @@ -4132,11 +4112,11 @@ packages: license_family: MIT purls: [] run_exports: {} - size: 8191 - timestamp: 1744137672556 -- conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.1-pyhcf101f3_0.conda - sha256: 6119355a0b2a33e7b0dd31a740dbe01df66ffee0a643f80968afb1d53e7dbdb3 - md5: 7498bb2648f852c6a3cd5724ca80bdeb + size: 8144 + timestamp: 1784221492234 +- conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.2-pyhcf101f3_0.conda + sha256: e36998c5e860e26b22e5dbcd5726dd0c4eabad949c84d383cad8512757bbf6a1 + md5: fb568fbae6908ba86a090a85a089d11f depends: - exceptiongroup >=1.0.2 - idna >=2.8 @@ -4150,10 +4130,10 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/anyio?source=compressed-mapping + - pkg:pypi/anyio?source=hash-mapping run_exports: {} - size: 161308 - timestamp: 1782357087265 + size: 164465 + timestamp: 1783889660383 - conda: https://conda.anaconda.org/conda-forge/noarch/appnope-0.1.4-pyhd8ed1ab_1.conda sha256: 8f032b140ea4159806e4969a68b4a3c0a7cab1ad936eb958a2b5ffe5335e19bf md5: 54898d0f524c9dee622d44bbb081a8ab @@ -4197,9 +4177,9 @@ packages: run_exports: {} size: 113854 timestamp: 1760831179410 -- conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda - sha256: ee4da0f3fe9d59439798ee399ef3e482791e48784873d546e706d0935f9ff010 - md5: 9673a61a297b00016442e022d689faa6 +- conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.2-pyhd8ed1ab_0.conda + sha256: fbbd8ce60cbd5c16f3fe559eb644551f94285caff30985ea961ff851c1cf25ac + md5: 89d495168582cb00428dad699d149624 depends: - python >=3.10 constrains: @@ -4209,8 +4189,8 @@ packages: purls: - pkg:pypi/asttokens?source=hash-mapping run_exports: {} - size: 28797 - timestamp: 1763410017955 + size: 34639 + timestamp: 1783975742052 - conda: https://conda.anaconda.org/conda-forge/noarch/async-lru-2.3.0-pyhcf101f3_0.conda sha256: ea8486637cfb89dc26dc9559921640cd1d5fd37e5e02c33d85c94572139f2efe md5: b85e84cb64c762569cc1a760c2327e0a @@ -4253,18 +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: - - pkg:pypi/backports-zstd?source=compressed-mapping + 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 @@ -4290,7 +4269,7 @@ packages: - tinycss2 >=1.1.0,<1.5 license: Apache-2.0 AND MIT purls: - - pkg:pypi/bleach?source=compressed-mapping + - pkg:pypi/bleach?source=hash-mapping run_exports: {} size: 142246 timestamp: 1780675823953 @@ -4305,73 +4284,73 @@ packages: run_exports: {} size: 4406 timestamp: 1780675823953 -- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-h4c7d964_0.conda - sha256: 7f458e4a82514d7bebbfef23d92817794a16aaf1c748a15f04870d4fb49aeab2 - md5: b9696b2cf00dfeec138c70cee38ed192 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-h4c7d964_0.conda + sha256: 95e8e74062a5fe5f870ac8c90302b6e89945165fdaed7810606e84ddee6aac12 + md5: e27d2ac27b096dc51fedfcf775a53f9b depends: - __win license: ISC purls: [] run_exports: {} - size: 129352 - timestamp: 1781709016515 -- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - sha256: f8e3c730fa14ee3f170493779f06522c4acf89169f43db4f039727709b6419cf - md5: a9965dd99f683c5f444428f896635716 + size: 132136 + timestamp: 1784754918886 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + sha256: 0a0544cf95f64394fe4959286f5c71f5444ad58feb0602e53becb27448d24da6 + md5: 0f51e2391ade309db462a55611263e9c depends: - __unix license: ISC purls: [] run_exports: {} - size: 128866 - timestamp: 1781708962055 -- conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 + size: 131780 + timestamp: 1784754889428 +- conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_2.conda noarch: python - sha256: 561e6660f26c35d137ee150187d89767c988413c978e1b712d53f27ddf70ea17 - md5: 9b347a7ec10940d3f7941ff6c460b551 + sha256: 0d00dd61cb91b1bd1536600c64c9db4f94f3c699fec42864d0a2f4bc2ad8c3e8 + md5: 1990eb3f49022846fbc7fc9624a0ee43 depends: - cached_property >=1.5.2,<1.5.3.0a0 license: BSD-3-Clause license_family: BSD purls: [] run_exports: {} - size: 4134 - timestamp: 1615209571450 -- conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - sha256: 6dbf7a5070cc43d90a1e4c2ec0c541c69d8e30a0e25f50ce9f6e4a432e42c5d7 - md5: 576d629e47797577ab0f1b351297ef4a + size: 6836 + timestamp: 1783242914545 +- conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_2.conda + sha256: b1808a7811b5688d045b204425bb7ee824b340b40025b4ad0a39b4db1f4f1c98 + md5: f71e6840332854fd2eac6c3229768a9c depends: - - python >=3.6 + - python >=3.9 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/cached-property?source=hash-mapping run_exports: {} - size: 11065 - timestamp: 1615209567874 -- conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.6.17-pyhd8ed1ab_0.conda - sha256: 6c13620e458ba43278379d0cdacc30c497336bddfda81681fd50d114a65c702f - md5: c13824fedced67005d3832c152fe9c2f + size: 14752 + timestamp: 1783242913845 +- conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda + sha256: fb167de4388e64e52aa3907ed099afab944c1fa6e5f74b281a312dae1bcf7f3b + md5: 37e13edbe3b48f1095a9d085ef9cd83b depends: - python >=3.10 license: ISC purls: - - pkg:pypi/certifi?source=compressed-mapping + - pkg:pypi/certifi?source=hash-mapping run_exports: {} - size: 133877 - timestamp: 1781719949728 -- conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - sha256: 3f9483d62ce24ecd063f8a5a714448445dc8d9e201147c46699fc0033e824457 - md5: a9167b9571f3baa9d448faa2139d1089 + size: 137015 + timestamp: 1784717699092 +- 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: 58872 - timestamp: 1775127203018 + size: 64487 + timestamp: 1786835648298 - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda sha256: ab29d57dc70786c1269633ba3dff20288b81664d3ff8d21af995742e2bb03287 md5: 962b9857ee8e7018c22f2776ffa0b2d7 @@ -4397,42 +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_100.conda + size: 45930 + timestamp: 1786443506006 +- conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_102.conda noarch: generic - sha256: 7a548856ef5307890a8cadfc196655117658f8c24589ce175caa4c1c2ded9d13 - md5: b28fe35fd43d5f425c0dccbe5b5039fd + sha256: 4d97fdae803c1ed7c704289d1f856d60e5502f24e93e92f9d45fbea37fd9e9a7 + md5: 6b344ff499096c0b873d202fea1e2fcd depends: - python >=3.14,<3.15.0a0 - python_abi * *_cp314 license: Python-2.0 purls: [] run_exports: {} - size: 49333 - timestamp: 1781254618863 -- conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.3.1-pyhd8ed1ab_0.conda - sha256: 430bd9d731b265f0bedb3183ac3ecfaa1656390c092b6e864ff8cc1229843c8c - md5: 61dcf784d59ef0bd62c57d982b154ace - depends: - - python >=3.10 - license: BSD-2-Clause - license_family: BSD - purls: - - pkg:pypi/decorator?source=compressed-mapping - run_exports: {} - size: 16102 - timestamp: 1779115228886 + size: 49916 + timestamp: 1786444134021 - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 sha256: 9717a059677553562a8f38ff07f3b9f61727bd614f505658b0a5ecbcf8df89be md5: 961b3a227b437d82ad7054484cfa71b2 @@ -4496,21 +4463,21 @@ packages: run_exports: {} size: 39069 timestamp: 1767729720872 -- conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda - sha256: 84c64443368f84b600bfecc529a1194a3b14c3656ee2e832d15a20e0329b6da3 - md5: 164fc43f0b53b6e3a7bc7dce5e4f1dc9 +- conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.1-pyhcf101f3_0.conda + sha256: 307dd6ec90140c3cf4171071b0e5e870abec314f4565c1edd5bc433e942cdcc0 + md5: e652ac7756069c456d0da2a922cd7df5 depends: - python >=3.10 - hyperframe >=6.1,<7 - - hpack >=4.1,<5 + - hpack >=4.2,<5 - python license: MIT license_family: MIT purls: - pkg:pypi/h2?source=hash-mapping run_exports: {} - size: 95967 - timestamp: 1756364871835 + size: 100789 + timestamp: 1785796355216 - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda sha256: fdcea5d7cb314485d3907192ef024c704311548c5b0cbeb390cd1951051e29d2 md5: b395909221b9bd1df066e5930e18855b @@ -4519,7 +4486,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/hpack?source=compressed-mapping + - pkg:pypi/hpack?source=hash-mapping run_exports: {} size: 32884 timestamp: 1782283986153 @@ -4578,7 +4545,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/idna?source=compressed-mapping + - pkg:pypi/idna?source=hash-mapping run_exports: {} size: 163869 timestamp: 1781620148226 @@ -4592,7 +4559,7 @@ packages: license: Apache-2.0 license_family: APACHE purls: - - pkg:pypi/importlib-metadata?source=compressed-mapping + - pkg:pypi/importlib-metadata?source=hash-mapping run_exports: {} size: 34766 timestamp: 1779714582554 @@ -4621,7 +4588,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/ipykernel?source=compressed-mapping + - pkg:pypi/ipykernel?source=hash-mapping run_exports: {} size: 137725 timestamp: 1781101860049 @@ -4649,7 +4616,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/ipykernel?source=compressed-mapping + - pkg:pypi/ipykernel?source=hash-mapping run_exports: {} size: 138046 timestamp: 1781101760172 @@ -4677,16 +4644,15 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/ipykernel?source=compressed-mapping + - pkg:pypi/ipykernel?source=hash-mapping run_exports: {} size: 138635 timestamp: 1781101665847 -- conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.15.0-pyh53cf698_0.conda - sha256: d9fa14ec35119901bf702a9a3e8a5570daef2aada7d03878e4c24d9de912302b - md5: 2f4ea7f616b30363d8dde5cc15e7af2e +- conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.16.1-pyh53cf698_0.conda + sha256: 8470648b5e790d1881c09c02068d53e1bf0126f020db02a6dc2e73400cf1eeeb + md5: 23564ed27c9c7714905be96ac5786500 depends: - __unix - - decorator >=5.1.0 - ipython_pygments_lexers >=1.0.0 - jedi >=0.18.2 - matplotlib-inline >=0.1.6 @@ -4702,16 +4668,15 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/ipython?source=compressed-mapping + - pkg:pypi/ipython?source=hash-mapping run_exports: {} - size: 658801 - timestamp: 1782482716877 -- conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.15.0-pyhe2676ad_0.conda - sha256: c3df12e766572060be653933aae5f6a09ccebfa86cff3b10b9b880ace29088d9 - md5: a4c278221ad4da75ba1637f8d230e658 + size: 716078 + timestamp: 1785754203352 +- conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.16.1-pyhe2676ad_0.conda + sha256: 2a0cd24e5c0e1eb8b7baf06346906673f6b8abea151ce302d7d978a3c416aeec + md5: d7c95d926befcfa22bee69bb1980e2ce depends: - __win - - decorator >=5.1.0 - ipython_pygments_lexers >=1.0.0 - jedi >=0.18.2 - matplotlib-inline >=0.1.6 @@ -4729,8 +4694,8 @@ packages: purls: - pkg:pypi/ipython?source=hash-mapping run_exports: {} - size: 657739 - timestamp: 1782482745122 + size: 715162 + timestamp: 1785754256301 - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda sha256: 894682a42a7d659ae12878dbcb274516a7031bbea9104e92f8e88c1f2765a104 md5: bd80ba060603cc228d9d81c257093119 @@ -4766,7 +4731,7 @@ packages: - python license: Apache-2.0 AND MIT purls: - - pkg:pypi/jedi?source=compressed-mapping + - pkg:pypi/jedi?source=hash-mapping run_exports: {} size: 2715215 timestamp: 1782251948616 @@ -4792,7 +4757,7 @@ packages: license: Apache-2.0 license_family: APACHE purls: - - pkg:pypi/json5?source=compressed-mapping + - pkg:pypi/json5?source=hash-mapping run_exports: {} size: 39373 timestamp: 1781926894833 @@ -4860,9 +4825,9 @@ packages: run_exports: {} size: 4740 timestamp: 1767839954258 -- conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.0.2-pyhcf101f3_0.conda - sha256: a845bffdbcdd1749dd4a46e47be5d4e0e1c87cdda547e3a87d297a6963ab0821 - md5: 12b0a9513f6abaa944c313c4a01d5500 +- conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-builder-1.2.2-pyhcf101f3_0.conda + sha256: 46777dd25ae623f960ec9ed9eae44cc7f96f371f3bc96f85d3ebf95737bd4e88 + md5: 9672806e67ea8b09a61408597a418e20 depends: - jupyter_core - python >=3.10 @@ -4873,10 +4838,10 @@ packages: - nodejs >=22 license: BSD-3-Clause AND MIT AND ISC purls: - - pkg:pypi/jupyter-builder?source=hash-mapping + - pkg:pypi/jupyter-builder?source=compressed-mapping run_exports: {} - size: 858738 - timestamp: 1781271993460 + size: 862197 + timestamp: 1786385602872 - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter-lsp-2.3.1-pyhcf101f3_0.conda sha256: 3766e2ae59641c172cec8a821528bfa6bf9543ffaaeb8b358bfd5259dcf18e4e md5: 0c3b465ceee138b9c39279cc02e5c4a0 @@ -4907,7 +4872,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/jupyter-client?source=compressed-mapping + - pkg:pypi/jupyter-client?source=hash-mapping run_exports: {} size: 117954 timestamp: 1781019994076 @@ -5015,9 +4980,9 @@ packages: run_exports: {} size: 22052 timestamp: 1768574057200 -- conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab-4.6.1-pyhd8ed1ab_0.conda - sha256: ad4a462da846fb8e9feebe39170553fc6a94252f4d4cca8efaf0dfeefa907099 - md5: 7ba07211c94d434f3223a4418f8b1ff8 +- 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 @@ -5034,13 +4999,14 @@ packages: - tomli >=1.2.2 - tornado >=6.2.0 - traitlets + - typing_extensions >=4.4.0 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/jupyterlab?source=compressed-mapping run_exports: {} - size: 13793940 - timestamp: 1782739437310 + size: 13178193 + timestamp: 1786398793317 - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda sha256: dc24b900742fdaf1e077d9a3458fd865711de80bca95fe3c6d46610c532c6ef0 md5: fd312693df06da3578383232528c468d @@ -5101,9 +5067,9 @@ packages: run_exports: {} size: 15725 timestamp: 1778264403247 -- conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.2-pyhcf101f3_0.conda - sha256: b8e38b0c5090f8cf1826c3f77e9db682c5d7b56ab6a434e6e2b528abc591048c - md5: e92e7aa653b4c71d0cd3cd39eadc302f +- conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.4-pyhcf101f3_0.conda + sha256: 306af633cb4ac85d04024d7c243ac2031c488bf5b0912207e7304c27a5d65a85 + md5: 1c0ebec41ef9214160da1ee6a0880fce depends: - python >=3.10 - typing_extensions @@ -5111,10 +5077,10 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/mistune?source=compressed-mapping + - pkg:pypi/mistune?source=hash-mapping run_exports: {} - size: 86960 - timestamp: 1782218255079 + size: 93811 + timestamp: 1784722859728 - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda sha256: eceb424236fbbb9b337a857fe5448307b57a2a3fb2db389ae37e7a8b8cdca2ab md5: cf01a81d7960ad9c829bf2e794fcee9a @@ -5127,7 +5093,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/nbclient?source=compressed-mapping + - pkg:pypi/nbclient?source=hash-mapping run_exports: {} size: 29138 timestamp: 1780661039538 @@ -5162,22 +5128,23 @@ packages: run_exports: {} size: 202229 timestamp: 1775615493260 -- conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda - sha256: 7a5bd30a2e7ddd7b85031a5e2e14f290898098dc85bea5b3a5bf147c25122838 - md5: bbe1963f1e47f594070ffe87cdf612ea +- conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.11.0-pyhcf101f3_1.conda + sha256: 4aeff72162e4b3f77238c859547275b9a989ed6873bffbdbc4d688291a60919b + md5: 3e7eb1cc4a342b2e7f68199b50fbe3cb depends: - jsonschema >=2.6 - jupyter_core >=4.12,!=5.0.* - - python >=3.9 + - python >=3.10 - python-fastjsonschema >=2.15 - traitlets >=5.1 + - python license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/nbformat?source=hash-mapping + - pkg:pypi/nbformat?source=compressed-mapping run_exports: {} - size: 100945 - timestamp: 1733402844974 + size: 107583 + timestamp: 1786372859684 - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio2-1.7.2-pyhcf101f3_0.conda sha256: e6768ceef038f4d7e083de7e393f5dd7d672b937e2bda570b740f6399b686689 md5: fcd832bfd4749e9b246112b6894f97fc @@ -5217,19 +5184,19 @@ packages: run_exports: {} size: 30139 timestamp: 1734587755455 -- conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda - sha256: 3906abfb6511a3bb309e39b9b1b7bc38f50a723971de2395489fd1f379255890 - md5: 4c06a92e74452cfa53623a81592e8934 +- conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda + sha256: c432626b16768b8dab228bfb706f7060c2d462a21c516d240f68f2f902b5a044 + md5: 936687ed80f295a1f5dbcf8bd34c252c depends: - - python >=3.8 + - python >=3.9 - python license: Apache-2.0 license_family: APACHE purls: - pkg:pypi/packaging?source=hash-mapping run_exports: {} - size: 91574 - timestamp: 1777103621679 + size: 116363 + timestamp: 1785888127370 - conda: https://conda.anaconda.org/conda-forge/noarch/pandocfilters-1.5.0-pyhd8ed1ab_0.tar.bz2 sha256: 2bb9ba9857f4774b85900c2562f7e711d08dd48e2add9bee4e1612fbee27e16f md5: 457c2c8c08e54905d6954e79cb5b5db9 @@ -5285,9 +5252,9 @@ packages: run_exports: {} size: 39509 timestamp: 1764156429044 -- conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda - sha256: 9e5e1fd3506ccfc4d444fc4d2d39b0ed097d5d0e3bd3d4bdf6bcc81aaf66860d - md5: 2c5ef45db85d34799771629bd5860fd7 +- conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.3-pyhcf101f3_0.conda + sha256: 810511c90649ca59fa0174958a210fd5051c0a7848cfbd42c87054a6836726b5 + md5: 31474b00d0ca5accac14eda09ba11216 depends: - python >=3.10 - python @@ -5296,11 +5263,11 @@ packages: purls: - pkg:pypi/platformdirs?source=compressed-mapping run_exports: {} - size: 26308 - timestamp: 1779972894916 -- conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - sha256: 4d7ec90d4f9c1f3b4a50623fefe4ebba69f651b102b373f7c0e9dbbfa43d495c - md5: a11ab1f31af799dd93c3a39881528884 + size: 26956 + timestamp: 1786708411066 +- conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.26.0-pyhd8ed1ab_0.conda + sha256: 794eec057361b41db1b06a9677eb8632adc0de81f7dcfe113bca8f0b04a23553 + md5: 3aa7e2d85645e61627c98082747dfdfe depends: - python >=3.10 license: Apache-2.0 @@ -5308,23 +5275,23 @@ packages: purls: - pkg:pypi/prometheus-client?source=hash-mapping run_exports: {} - size: 57113 - timestamp: 1775771465170 -- conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - sha256: 4817651a276016f3838957bfdf963386438c70761e9faec7749d411635979bae - md5: edb16f14d920fb3faf17f5ce582942d6 + size: 61554 + timestamp: 1785016068982 +- conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda + sha256: efe8def2c93aa34cd8d3c9af1dc4c7d312791cf769d8b2b615e32733e6df6051 + md5: 39c92a39517316e5001d645ae63d9ab9 depends: - python >=3.10 - wcwidth constrains: - - prompt_toolkit 3.0.52 + - prompt_toolkit 3.0.53 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/prompt-toolkit?source=hash-mapping run_exports: {} - size: 273927 - timestamp: 1756321848365 + size: 276081 + timestamp: 1785160613307 - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda sha256: a7713dfe30faf17508ec359e0bc7e0983f5d94682492469bd462cdaae9c64d83 md5: 7d9daffbb8d8e0af0f769dbbcd173a54 @@ -5357,7 +5324,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/pycparser?source=compressed-mapping + - pkg:pypi/pycparser?source=hash-mapping run_exports: {} size: 55886 timestamp: 1779293633166 @@ -5414,57 +5381,56 @@ packages: run_exports: {} size: 233310 timestamp: 1751104122689 -- conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda - sha256: df9aa74e9e28e8d1309274648aac08ec447a92512c33f61a8de0afa9ce32ebe8 - md5: 23029aae904a2ba587daba708208012f +- conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.2-pyhcf101f3_0.conda + sha256: fc4a704822df22defce49d0fb811fdc036a1fd3b579aeaa601228e9cfd198b3d + md5: aa75b7f096d17621bc307b3025b29461 depends: - - python >=3.9 + - 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: 244628 - timestamp: 1755304154927 -- 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_100.conda - sha256: 84c129bdd6abcecac42a948f2670d17fe735d02d3a5a483a9b1f1bc33ba38c28 - md5: 224f69f177eb5aae6c9a6052846bf609 + 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: 49315 - timestamp: 1781254664376 -- 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 -- conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.2-pyhd8ed1ab_0.conda - sha256: e943f9c15a6bdba2e1b9f423ab913b3f6b02197b0ef9f8e6b7464d78b59965b9 - md5: f6ad7450fc21e00ecc23812baed6d2e4 + size: 19367 + timestamp: 1786872772907 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda + sha256: 3f05db78cf8be33cf6dbc469664b8e3a01f3980d61d6d6bef48669b171896d8a + md5: eefc8d916bd2e708d76d40398ef9a1ee depends: - python >=3.10 license: Apache-2.0 @@ -5472,8 +5438,8 @@ packages: purls: - pkg:pypi/tzdata?source=hash-mapping run_exports: {} - size: 146639 - timestamp: 1777068997932 + size: 146862 + timestamp: 1783704822814 - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda build_number: 8 sha256: 80677180dd3c22deb7426ca89d6203f1c7f1f256f2d5a94dc210f6e758229809 @@ -5529,13 +5495,13 @@ packages: license: Apache-2.0 license_family: APACHE purls: - - pkg:pypi/requests?source=compressed-mapping + - pkg:pypi/requests?source=hash-mapping run_exports: {} size: 68709 timestamp: 1778851103479 -- conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.28.0-pyhc364b38_0.conda - sha256: c09b9e798564b1eb1c7b79c3a869c5c5edaf7f868ca0213f37ac7dec1f014750 - md5: c39e9a3b9ce540e1b8e3b39091443952 +- conda: https://conda.anaconda.org/conda-forge/noarch/returns-0.29.0-pyhc364b38_0.conda + sha256: 8d74e05aca6b1dc0725ca059caead6fe7822454589cdae504fba9fd656d45084 + md5: cdf97266f8a07a2f9b064f3e665bcf00 depends: - python >=3.11 - typing_extensions >=4.0,<5.0 @@ -5545,8 +5511,8 @@ packages: purls: - pkg:pypi/returns?source=hash-mapping run_exports: {} - size: 100545 - timestamp: 1780583056095 + size: 101252 + timestamp: 1785767714360 - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda sha256: 2e4372f600490a6e0b3bac60717278448e323cab1c0fecd5f43f7c56535a99c5 md5: 36de09a8d3e5d5e6f4ee63af49e59706 @@ -5630,18 +5596,18 @@ packages: run_exports: {} size: 24108 timestamp: 1770937597662 -- conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda - sha256: 82088a6e4daa33329a30bc26dc19a98c7c1d3f05c0f73ce9845d4eab4924e9e1 - md5: 8e194e7b992f99a5015edbd4ebd38efd +- conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-84.0.0-pyh332efcf_0.conda + sha256: 9e200ee5f9ff19a4d94e4b51c4856d53dec849f91032f345cf0c6bc3d51a7183 + md5: 62ac906f1cd582c6c264c95625cb9d6f depends: - python >=3.10 license: MIT license_family: MIT purls: - - pkg:pypi/setuptools?source=hash-mapping + - pkg:pypi/setuptools?source=compressed-mapping run_exports: {} - size: 639697 - timestamp: 1773074868565 + size: 524488 + timestamp: 1786282924579 - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda sha256: 458227f759d5e3fcec5d9b7acce54e10c9e1f4f4b7ec978f3bfd54ce4ee9853d md5: 3339e3b65d58accf4ca4fb8748ab16b3 @@ -5667,9 +5633,9 @@ packages: run_exports: {} size: 15698 timestamp: 1762941572482 -- conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.4-pyhd8ed1ab_0.conda - sha256: 2afa5fe9331c09b4c4689ddf6ace8fc16c837eae547c57dab325b844072fdd77 - md5: 9e21f087f087f805debe877d88e00a14 +- conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.9.2-pyhd8ed1ab_0.conda + sha256: 056d7a2e91e303a9ee37e580f6dde0511fc3fb72476581cc337aacf2cc747613 + md5: ba33e6c8a46ee373fdf6dd8665212778 depends: - python >=3.10 license: MIT @@ -5677,8 +5643,8 @@ packages: purls: - pkg:pypi/soupsieve?source=compressed-mapping run_exports: {} - size: 38802 - timestamp: 1779635534390 + size: 39439 + timestamp: 1786202135509 - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda sha256: 570da295d421661af487f1595045760526964f41471021056e993e73089e9c41 md5: b1b505328da7a6b246787df4b5a49fbc @@ -5752,33 +5718,33 @@ packages: run_exports: {} size: 21561 timestamp: 1774492402955 -- conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.15.1-pyhcf101f3_0.conda - sha256: b89a823edf524956b94a2a4db974866e4501f05c68976eff458c5dcf07f88431 - md5: 37e3be7b6e2977d37b8fa5da229f5dc0 +- conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.16.1-pyhcf101f3_0.conda + sha256: 03dba5917f944c6684ab44c81daacac1624cd148e4b2cae215dcec594a210c48 + md5: a79bf97561232a31447b6246c2153ab5 depends: - python >=3.10 - python license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/traitlets?source=compressed-mapping + - pkg:pypi/traitlets?source=hash-mapping run_exports: {} - size: 115158 - timestamp: 1780507822178 -- conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - sha256: 7c2df5721c742c2a47b2c8f960e718c930031663ac1174da67c1ed5999f7938c - md5: edd329d7d3a4ab45dcf905899a7a6115 + size: 116935 + timestamp: 1785761789772 +- conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda + sha256: b141933ece3518f6d7b75dfb59451e2f26b405a44c18e2518a83e9a02e09315c + md5: c680b5747e8c4c8f23dca0bb7042a8fc depends: - - typing_extensions ==4.15.0 pyhcf101f3_0 + - typing_extensions ==4.16.0 pyhcf101f3_0 license: PSF-2.0 license_family: PSF purls: [] run_exports: {} - size: 91383 - timestamp: 1756220668932 -- conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - sha256: 032271135bca55aeb156cee361c81350c6f3fb203f57d024d7e5a1fc9ef18731 - md5: 0caa1af407ecff61170c9437a808404d + size: 94080 + timestamp: 1783002732887 +- conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda + sha256: 2d888f90af0686044882c74193ec80a90ec1943145d94a7b1b048958acda1848 + md5: c70ad746c22219b9700931707482992c depends: - python >=3.10 - python @@ -5787,8 +5753,8 @@ packages: purls: - pkg:pypi/typing-extensions?source=hash-mapping run_exports: {} - size: 51692 - timestamp: 1756220668932 + size: 52631 + timestamp: 1783002732887 - conda: https://conda.anaconda.org/conda-forge/noarch/typing_utils-0.1.0-pyhd8ed1ab_1.conda sha256: 3088d5d873411a56bf988eee774559335749aed6f6c28e07bf933256afb9eb6c md5: f6d7aa696c67756a650e91e15e88223c @@ -5801,14 +5767,14 @@ packages: run_exports: {} size: 15183 timestamp: 1733331395943 -- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - sha256: 1d30098909076af33a35017eed6f2953af1c769e273a0626a04722ac4acaba3c - md5: ad659d0a2b3e47e38d829aa8cad2d610 +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + sha256: b928c30ddcb0e3f544c6eade8352737e6e610e263276b90232db6a578ef899d8 + md5: fcb489df604d100968b737f2cb6076c6 license: LicenseRef-Public-Domain purls: [] run_exports: {} - size: 119135 - timestamp: 1767016325805 + size: 118849 + timestamp: 1784250406640 - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda sha256: e0eb6c8daf892b3056f08416a96d68b0a358b7c46b99c8a50481b22631a4dfc0 md5: e7cb0f5745e4c5035a460248334af7eb @@ -5845,7 +5811,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/wcwidth?source=compressed-mapping + - pkg:pypi/wcwidth?source=hash-mapping run_exports: {} size: 132415 timestamp: 1782771807703 @@ -5906,7 +5872,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/zipp?source=compressed-mapping + - pkg:pypi/zipp?source=hash-mapping run_exports: {} size: 24190 timestamp: 1779159948016 @@ -5942,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 @@ -5952,49 +5918,47 @@ packages: - python_abi 3.12.* *_cp312 license: BSD-3-Clause AND MIT AND EPL-2.0 purls: - - pkg:pypi/backports-zstd?source=compressed-mapping + - 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 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda - sha256: 540fe54be35fac0c17feefbdc3e29725cce05d7367ffedfaaa1bdda234b019df - md5: 620b85a3f45526a8bc4d23fd78fc22f0 + size: 365272 + timestamp: 1786623009364 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda + sha256: 8ec22f0ba25cbfc2e64d70cf29459eccd7ffdf6436f6a6ff15bbfef799f7d4f6 + md5: b50612e7d190b8061ab4e7dc119cf4d5 depends: - __osx >=11.0 license: bzip2-1.0.6 @@ -6003,55 +5967,55 @@ packages: run_exports: weak: - bzip2 >=1.0.8,<2.0a0 - size: 124834 - timestamp: 1771350416561 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda - sha256: 2995f2aed4e53725e5efbc28199b46bf311c3cab2648fc4f10c2227d6d5fa196 - md5: bcb3cba70cf1eec964a03b4ba7775f01 + size: 124965 + timestamp: 1785906749812 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.8-h1a92334_1.conda + sha256: 104b41473845649101ba8ebf8221c7431256465d34ce380b10e9a90558ed33ac + md5: 7d9390a4d4b43f91652823d870f68065 depends: - __osx >=11.0 + constrains: + - c-ares-static <0a0 license: MIT license_family: MIT purls: [] run_exports: weak: - - c-ares >=1.34.6,<2.0a0 - size: 180327 - timestamp: 1765215064054 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py312h1b4d9a2_1.conda - sha256: 597e986ac1a1bd1c9b29d6850e1cdea4a075ce8292af55568952ec670e7dd358 - md5: 503ac138ad3cfc09459738c0f5750705 + - c-ares >=1.34.8,<2.0a0 + size: 197274 + timestamp: 1786116660078 +- 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: 288080 - timestamp: 1761203317419 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py314h44086f9_1.conda - sha256: 5b5ee5de01eb4e4fd2576add5ec9edfc654fbaf9293e7b7ad2f893a67780aa98 - md5: 10dd19e4c797b8f8bdb1ec1fbb6821d7 + 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: 292983 - timestamp: 1761203354051 + size: 292307 + timestamp: 1786775143512 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.21-py312h6510ced_0.conda sha256: 80589b2da39c84c0786f13a0a4c98cde9a879207af0482bd1f9b29d2f6fe277b md5: 178381b74c5e84ea90751c5e4291c41e @@ -6084,9 +6048,9 @@ packages: run_exports: {} size: 2776045 timestamp: 1780390212997 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hef89b57_0.conda - sha256: 3a7907a17e9937d3a46dfd41cffaf815abad59a569440d1e25177c15fd0684e5 - md5: f1182c91c0de31a7abd40cedf6a5ebef +- 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 @@ -6095,14 +6059,14 @@ packages: run_exports: weak: - icu >=78.3,<79.0a0 - size: 12361647 - timestamp: 1773822915649 -- 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 @@ -6112,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 @@ -6132,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 @@ -6143,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 @@ -6184,32 +6148,34 @@ 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 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h93a5062_2.conda - sha256: 95cecb3902fbe0399c3a7e67a5bed1db813e5ab0e22f4023a5e0f722f2cc214f - md5: 36d33e440c31857372a72137f78bacf5 - license: BSD-2-Clause - license_family: BSD + size: 107742 + timestamp: 1786616721640 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h1a92334_3.conda + sha256: c0100064506ae8abb432c5a506d474f10af2cf48c33d62bc221fb28b6d6ff6ac + md5: 19e86c8a6a47e92bb2e70ca12e758c5c + depends: + - __osx >=11.0 + license: BSD-2-Clause OR GPL-2.0-or-later + license_family: GPL purls: [] run_exports: weak: - libev >=4.33,<4.34.0a0 - size: 107458 - timestamp: 1702146414478 + size: 39991 + timestamp: 1785917376956 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda sha256: 5af74261101e3c777399c6294b2b5d290e508153268eb2e9ff99c4d69834612f md5: a915151d5d3c5bf039f5ccc8402a436f @@ -6223,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 @@ -6233,12 +6199,12 @@ packages: purls: [] run_exports: weak: - - libffi >=3.5.2,<3.6.0a0 - size: 40979 - timestamp: 1769456747661 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda - sha256: 34878d87275c298f1a732c6806349125cebbf340d24c6c23727268184bba051e - md5: b1fd823b5ae54fbec272cea0811bd8a9 + - 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 depends: - __osx >=11.0 constrains: @@ -6248,19 +6214,19 @@ packages: run_exports: weak: - liblzma >=5.8.3,<6.0a0 - size: 92472 - timestamp: 1775825802659 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda - sha256: 1089c7f15d5b62c622625ec6700732ece83be8b705da8c6607f4dabb0c4bd6d2 - md5: 57c4be259f5e0b99a5983799a228ae55 + size: 91720 + timestamp: 1786348695846 +- 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 @@ -6292,22 +6258,23 @@ packages: - libsodium >=1.0.22,<1.0.23.0a0 size: 247352 timestamp: 1779164136206 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.3-h1b79a29_0.conda - sha256: a73a8acd97a6599fd6e561514db9f101ca7fd984cdc0cfd91ba74c8aa9dbe067 - md5: 7184d95871a58b8258a8ea124ed5aabc +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda + sha256: 745662565e103f290e9dc4263bbd88285082f8cf699854fe2d5f1e35a4a0d326 + md5: 0e3477c0c3e718dcf2eb74ccc8f68570 depends: - __osx >=11.0 + - icu >=78.3,<79.0a0 - libzlib >=1.3.2,<2.0a0 license: blessing purls: [] run_exports: weak: - - libsqlite >=3.53.3,<4.0a0 - size: 924912 - timestamp: 1782519136322 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_0.conda - sha256: e23176af832f637693ebbb9bbe7d29c0f4cba662dabd001081d2aa6fc9f7f661 - md5: fa9fef7d9f33724b7c3899c883c25a3e + - libsqlite >=3.53.4,<4.0a0 + size: 929203 + timestamp: 1785016131414 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_1.conda + sha256: 4f47de9de1990efd998edbbd6793f89c8f02ffde987ae8120b1c006acefd2a04 + md5: de09bd0f175611e94f21b28f8c708e80 depends: - __osx >=11.0 license: MIT @@ -6316,23 +6283,23 @@ packages: run_exports: weak: - libuv >=1.52.1,<2.0a0 - size: 122732 - timestamp: 1779396113397 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda - sha256: 361415a698514b19a852f5d1123c5da746d4642139904156ddfca7c922d23a05 - md5: bc5a5721b6439f2f62a84f2548136082 + size: 122729 + timestamp: 1785914645797 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + sha256: a18fa5d5bac452401459f966cf0d872224e8080c4ff93c77e168d43ab42ef9d7 + md5: f39288f0ea63ae962e1a2e4f355a0d75 depends: - __osx >=11.0 constrains: - - zlib 1.3.2 *_2 + - zlib 1.3.2 *_3 license: Zlib license_family: Other purls: [] run_exports: weak: - libzlib >=1.3.2,<2.0a0 - size: 47759 - timestamp: 1774072956767 + size: 47822 + timestamp: 1785277049190 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.3-py312h04c11ed_1.conda sha256: 330394fb9140995b29ae215a19fad46fcc6691bdd1b7654513d55a19aaa091c1 md5: 11d95ab83ef0a82cc2de12c1e0b47fe4 @@ -6397,9 +6364,9 @@ packages: run_exports: {} size: 216965 timestamp: 1776338889692 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda - sha256: 4ea6c620b87bd1d42bb2ccc2c87cd2483fa2d7f9e905b14c223f11ff3f4c455d - md5: 343d10ed5b44030a2f67193905aea159 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda + sha256: 7024a48c8c0d0114ed4ab53c76bf9275d50e91ba7cea367a9aead638d3c29c68 + md5: 3dfa0d0316dc246cd44937a557de4501 depends: - __osx >=11.0 license: X11 AND BSD-3-Clause @@ -6407,38 +6374,38 @@ packages: run_exports: weak: - ncurses >=6.6,<7.0a0 - size: 805509 - timestamp: 1777423252320 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.4.0-h00e74ec_0.conda - sha256: 753af11e85c752b568ff9ec5bd9a4e3a34cdffa0312013b66f1fd9d0fecefe51 - md5: f74cc3f3941bdb01fb871aa93cbb9a52 + size: 804298 + timestamp: 1786355189145 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.6.0-h00e74ec_0.conda + sha256: 379ba26eb11cccb4d9a10989d9afef3f8bc4c9039cbe371dec3e3ee24594597b + md5: f0ba635c2293dff6fb86fc2150c8c110 depends: - libcxx >=19 - __osx >=12.0 - libnghttp2 >=1.68.1,<2.0a0 - - libuv >=1.52.1,<2.0a0 - - openssl >=3.5.7,<4.0a0 - - zstd >=1.5.7,<1.6.0a0 - libzlib >=1.3.2,<2.0a0 - - c-ares >=1.34.6,<2.0a0 - - libsqlite >=3.53.2,<4.0a0 - libbrotlicommon >=1.2.0,<1.3.0a0 - libbrotlienc >=1.2.0,<1.3.0a0 - libbrotlidec >=1.2.0,<1.3.0a0 + - libuv >=1.52.1,<2.0a0 - icu >=78.3,<79.0a0 + - openssl >=3.5.7,<4.0a0 - libabseil >=20260526.0,<20260527.0a0 - libabseil * cxx17* + - zstd >=1.5.7,<1.6.0a0 + - c-ares >=1.34.8,<2.0a0 + - libsqlite >=3.53.4,<4.0a0 license: MIT license_family: MIT purls: [] run_exports: weak: - - nodejs >=26.4.0,<27.0a0 - size: 18186333 - timestamp: 1782378498631 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda - sha256: b3e3ca895c336d4eb91c5d2f244a312bdb59a0de8cfa0cc4c179225ab2f6bbfb - md5: 8187a86242741725bfa74785fe812979 + - nodejs >=26.6.0,<27.0a0 + size: 18228083 + timestamp: 1785852758781 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda + sha256: 66be2283b5b37dcda1332b5e74c1782a8cb14fd2e62e0d38017c2d35bf73c119 + md5: 65d1906712b85d1679263c518d011b5b depends: - __osx >=11.0 - ca-certificates @@ -6448,8 +6415,8 @@ packages: run_exports: weak: - openssl >=3.6.3,<4.0a0 - size: 3102584 - timestamp: 1781069820667 + size: 3109132 + timestamp: 1785913735357 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py312hb3ab3e3_0.conda sha256: 6d0e21c76436374635c074208cfeee62a94d3c37d0527ad67fd8a7615e546a05 md5: fd856899666759403b3c16dcba2f56ff @@ -6480,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 @@ -6494,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 @@ -6510,15 +6477,15 @@ 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 @@ -6526,15 +6493,15 @@ packages: purls: - 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 @@ -6542,21 +6509,22 @@ packages: purls: - 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 @@ -6569,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_100_cp314.conda - build_number: 100 - sha256: 984081c9fae3a3944c6f2707bbbbc70e8b961f02cdb7c640d9745e2636235632 - md5: 4841be3d0cf616a860efc6e60af66f8b + 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.2,<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 @@ -6598,8 +6566,8 @@ packages: - python_abi 3.14.* *_cp314 noarch: - python - size: 14059371 - timestamp: 1781254578985 + 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 @@ -6677,7 +6645,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/rpds-py?source=compressed-mapping + - pkg:pypi/rpds-py?source=hash-mapping run_exports: {} size: 286191 timestamp: 1782831381815 @@ -6693,27 +6661,26 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/rpds-py?source=compressed-mapping + - pkg:pypi/rpds-py?source=hash-mapping run_exports: {} size: 285627 timestamp: 1782831308617 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - sha256: 799cab4b6cde62f91f750149995d149bc9db525ec12595e8a1d91b9317f038b3 - md5: a9d86bc62f39b94c4661716624eb21b0 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hd3d0363_3.conda + sha256: 47186bc7ab8d7e8bee86bbd1a917196f8c21cf63f081fc33cd6d1221af087580 + md5: 8e3cf0e455e6b54519f0b1c72c61780a depends: - __osx >=11.0 - - libzlib >=1.3.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 license: TCL - license_family: BSD purls: [] run_exports: weak: - tk >=8.6.13,<8.7.0a0 - size: 3127137 - timestamp: 1769460817696 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.7-py312h2bbb03f_0.conda - sha256: 483350b0e4a3ebec90f6418e454d22b453f54d1bac803c2aaa7a9035cfcd7493 - md5: d037e9adb0365ab53445f357bd9a035f + size: 3338712 + timestamp: 1784229090530 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.8-py312h2bbb03f_0.conda + sha256: a6e2cb20d031c9719b678de203d03b80e2f4a35d50fd0521e3eeb5e922ad4284 + md5: 6c4d153408507b7125fe3c4e5a24da83 depends: - __osx >=11.0 - python >=3.12,<3.13.0a0 @@ -6724,11 +6691,11 @@ packages: purls: - pkg:pypi/tornado?source=compressed-mapping run_exports: {} - size: 863360 - timestamp: 1781007464047 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.7-py314h6c2aa35_0.conda - sha256: 1a4f3d940cf239acde2fc61674b7cf80219a7f22a369e92b95b245be2d47caf1 - md5: cc1d84777343f00f01c08a2d9550f639 + size: 869700 + timestamp: 1786375852841 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.8-py314h6c2aa35_0.conda + sha256: 4af8e4394d249619b4f1641c82fd6c0a8b97d51991ca2986095bed400c2e5eb9 + md5: ab1bd70ec9c95d199f8ce456823004ab depends: - __osx >=11.0 - python >=3.14,<3.15.0a0 @@ -6739,8 +6706,8 @@ packages: purls: - pkg:pypi/tornado?source=hash-mapping run_exports: {} - size: 915857 - timestamp: 1781007345425 + size: 922067 + timestamp: 1786227125229 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda sha256: b03433b13d89f5567e828ea9f1a7d5c5d697bf374c28a4168d71e9464f5dafac md5: 78a0fe9e9c50d2c381e8ee47e3ea437d @@ -6770,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 @@ -6818,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 @@ -6832,11 +6799,11 @@ packages: purls: - 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 @@ -6844,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 @@ -6862,17 +6829,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 run_exports: {} - size: 335782 - timestamp: 1764018443683 -- conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_9.conda - sha256: 76dfb71df5e8d1c4eded2dbb5ba15bb8fb2e2b0fe42d94145d5eed4c75c35902 - md5: 4cb8e6b48f67de0b018719cdf1136306 + size: 336902 + timestamp: 1786623039339 +- conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_10.conda + sha256: 04767466ee9227c9c57ab2c6503e0149177d34111c7418d2f420297acb1eb229 + md5: c3301c058362f340100d91cd8be0393f depends: - ucrt >=10.0.20348.0 - vc >=14.3,<15 @@ -6883,11 +6850,11 @@ packages: run_exports: weak: - bzip2 >=1.0.8,<2.0a0 - size: 56115 - timestamp: 1771350256444 -- conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py312he06e257_1.conda - sha256: 3e3bdcb85a2e79fe47d9c8ce64903c76f663b39cb63b8e761f6f884e76127f82 - md5: 46f7dccfee37a52a97c0ed6f33fcf0a3 + size: 55919 + timestamp: 1785906343696 +- 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 @@ -6898,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: 291324 - timestamp: 1761203195397 -- conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py314h5a2d7ad_1.conda - sha256: 924f2f01fa7a62401145ef35ab6fc95f323b7418b2644a87fea0ea68048880ed - md5: c360170be1c9183654a240aadbedad94 + 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 @@ -6915,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: 294731 - timestamp: 1761203441365 + size: 346657 + timestamp: 1786775160153 - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.21-py312ha1a9051_0.conda sha256: a41f403ca4b7b0c001140ac7a39fce1c0494ba95e8359f7a47590ed4377728a2 md5: 5628287239c9ef6df2d66dc143f7c8dd @@ -6947,13 +6914,13 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/debugpy?source=compressed-mapping + - pkg:pypi/debugpy?source=hash-mapping 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 @@ -6965,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 @@ -6982,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 @@ -6994,12 +6961,12 @@ packages: purls: [] run_exports: weak: - - libffi >=3.5.2,<3.6.0a0 - size: 45831 - timestamp: 1769456418774 -- conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda - sha256: d636d1a25234063642f9c531a7bb58d84c1c496411280a36ea000bd122f078f1 - md5: 8f83619ab1588b98dd99c90b0bfc5c6d + - 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 depends: - ucrt >=10.0.20348.0 - vc >=14.3,<15 @@ -7011,11 +6978,11 @@ packages: run_exports: weak: - liblzma >=5.8.3,<6.0a0 - size: 106486 - timestamp: 1775825663227 -- conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_1.conda - sha256: 40dcd0b9522a6e0af72a9db0ced619176e7cfdb114855c7a64f278e73f8a7514 - md5: e4a9fc2bba3b022dad998c78856afe47 + size: 105809 + timestamp: 1786348717883 +- 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 @@ -7024,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 @@ -7040,9 +7007,9 @@ packages: - libsodium >=1.0.22,<1.0.23.0a0 size: 280234 timestamp: 1779164124739 -- conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.3-hf5d6505_0.conda - sha256: 692dfb73a22c873656d5e393b8f1e2b019a3c8a6486c97cb6900552e64e38c25 - md5: 051f1b2228e7517a2ef8cca5146c8967 +- conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_0.conda + sha256: 62e1c45ec71ab2e5deeeb0e47e7df6a609991e91d46348f16df50c68fee145c8 + md5: ca0d59f40a02a15e9b5d0ff8db0f85e3 depends: - ucrt >=10.0.20348.0 - vc >=14.3,<15 @@ -7051,26 +7018,26 @@ packages: purls: [] run_exports: weak: - - libsqlite >=3.53.3,<4.0a0 - size: 1315909 - timestamp: 1782519131898 -- conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.conda - sha256: 88609816e0cc7452bac637aaf65783e5edf4fee8a9f8e22bdc3a75882c536061 - md5: dbabbd6234dea34040e631f87676292f + - libsqlite >=3.53.4,<4.0a0 + size: 1313790 + timestamp: 1785016158097 +- conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda + sha256: 0629c2cc0404d3bb29d6baa7b4ba62da80797015e86de050db81ea5a07050527 + md5: 5d2ff29d465097458cc3ff6569151991 depends: - ucrt >=10.0.20348.0 - vc >=14.3,<15 - vc14_runtime >=14.44.35208 constrains: - - zlib 1.3.2 *_2 + - zlib 1.3.2 *_3 license: Zlib license_family: Other purls: [] run_exports: weak: - libzlib >=1.3.2,<2.0a0 - size: 58347 - timestamp: 1774072851498 + size: 58529 + timestamp: 1785276664143 - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py312h05f76fc_1.conda sha256: b744287a780211ac4595126ef96a44309c791f155d4724021ef99092bae4aace md5: a73298d225c7852f97403ca105d10a13 @@ -7139,20 +7106,20 @@ packages: run_exports: {} size: 201836 timestamp: 1776337750218 -- conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-26.4.0-h80d1838_0.conda - sha256: e2bd6ca2fd92252ffc735b684f3d6d13b1408827f4df3715fe21cd6b50c3d430 - md5: e682530a6c2c69b86a6c19de2e20353a +- conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-26.6.0-h80d1838_0.conda + sha256: 8209fff36e34d2dc47ab37f879cb596059b3e6d58307fccefe7b9d79935492f3 + md5: a375968cb2cf4a5b745c875b26d40b05 license: MIT license_family: MIT purls: [] run_exports: weak: - - nodejs >=26.4.0,<27.0a0 - size: 34001765 - timestamp: 1782378511507 -- conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda - sha256: cb6e7ba0d010ee0d3249ce9886de3d7613d26d9965d4c95666fa66b9c4c31001 - md5: e99f95734a326c0fd4d02bbd995150d4 + - nodejs >=26.6.0,<27.0a0 + size: 34057372 + timestamp: 1785852767251 +- conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_1.conda + sha256: 2ebff5a1b5793e82495bf33c91fba040e11ff23333c2385ac66d0c3aee2cc14c + md5: a978392692a910ba1c8920ccb1e784b3 depends: - ca-certificates - ucrt >=10.0.20348.0 @@ -7164,8 +7131,8 @@ packages: run_exports: weak: - openssl >=3.6.3,<4.0a0 - size: 9414790 - timestamp: 1781071745579 + size: 9427535 + timestamp: 1785915614585 - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py312he5662c2_0.conda sha256: edffc84c001a05b996b5f8607c8164432754e86ec9224e831cd00ebabdec04e7 md5: a2724c93b745fc7861948eb8b9f6679a @@ -7198,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 @@ -7223,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_100_cp314.conda - build_number: 100 - sha256: f1acb89cb1a6bec9a94ae9f8e7411839de009cd64d3ac6a6aec4f3d8a481099a - md5: 8333e3ca6f8d1ebcd30b678dd53f0a25 + 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.2,<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 @@ -7252,8 +7220,8 @@ packages: - python_abi 3.14.* *_cp314 noarch: - python - size: 18481352 - timestamp: 1781256034828 + 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 @@ -7267,7 +7235,7 @@ packages: license: PSF-2.0 license_family: PSF purls: - - pkg:pypi/pywin32?source=compressed-mapping + - pkg:pypi/pywin32?source=hash-mapping run_exports: {} size: 4459779 timestamp: 1781362887119 @@ -7283,13 +7251,13 @@ packages: license: PSF-2.0 license_family: PSF purls: - - pkg:pypi/pywin32?source=compressed-mapping + - pkg:pypi/pywin32?source=hash-mapping run_exports: {} size: 4472831 timestamp: 1781362876741 -- conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-2.0.15-py312h275cf98_1.conda - sha256: 61cc6c2c712ab4d2b8e7a73d884ef8d3262cb80cc93a4aa074e8b08aa7ddd648 - md5: 66255d136bd0daa41713a334db41d9f0 +- conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-3.0.5-py312h275cf98_0.conda + sha256: 2919b3af91632dd7c46e31ddd8bd9530c506036fbb401fd797cff96648895334 + md5: ee867037e3998db8e937c4f5229810c5 depends: - python >=3.12,<3.13.0a0 - python_abi 3.12.* *_cp312 @@ -7302,13 +7270,13 @@ packages: purls: - pkg:pypi/pywinpty?source=hash-mapping run_exports: {} - size: 215371 - timestamp: 1759557609855 -- conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-2.0.15-py314h51f0985_1.conda - sha256: 048e20641da680aedaab285640a2aca56b7b5baf7a18f8f164f2796e13628c1f - md5: dd84e8748bd3c85a5c751b0576488080 + size: 738882 + timestamp: 1785742512338 +- conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-3.0.5-py314h51f0985_0.conda + sha256: 025a42fa3df042590f9f5efdf8473e98e654672871c9db029436f32e021cb900 + md5: 9f34ac4f934a5e6bdffa81db44b3ce82 depends: - - python >=3.14.0rc3,<3.15.0a0 + - python >=3.14,<3.15.0a0 - python_abi 3.14.* *_cp314 - ucrt >=10.0.20348.0 - vc >=14.2,<15 @@ -7319,8 +7287,8 @@ packages: purls: - pkg:pypi/pywinpty?source=hash-mapping run_exports: {} - size: 216325 - timestamp: 1759557436167 + size: 731340 + timestamp: 1785742446062 - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py312h05f76fc_1.conda sha256: 1cab6cbd6042b2a1d8ee4d6b4ec7f36637a41f57d2f5c5cf0c12b7c4ce6a62f6 md5: 9f6ebef672522cb9d9a6257215ca5743 @@ -7386,7 +7354,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/rpds-py?source=compressed-mapping + - pkg:pypi/rpds-py?source=hash-mapping run_exports: {} size: 217956 timestamp: 1782831653430 @@ -7406,24 +7374,23 @@ packages: run_exports: {} size: 217789 timestamp: 1782831449604 -- conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h6ed50ae_3.conda - sha256: 0e79810fae28f3b69fe7391b0d43f5474d6bd91d451d5f2bde02f55ae481d5e3 - md5: 0481bfd9814bf525bd4b3ee4b51494c4 +- conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h967ab96_3.conda + sha256: 13fa29257d43f8e630a1e591ed77fae9bbbb236b011432f01e2034cf36e6bf03 + md5: aaf79e2af50a151fb5b5a3e3f38b7a69 depends: - - ucrt >=10.0.20348.0 - vc >=14.3,<15 - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 license: TCL - license_family: BSD purls: [] run_exports: weak: - tk >=8.6.13,<8.7.0a0 - size: 3526350 - timestamp: 1769460339384 -- conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.7-py312he06e257_0.conda - sha256: 110cf0c741e181ed929a2acdb488f2095badad973c50dd696af36c4162e063cf - md5: 1045d29f787812d3fac1fd80a1339710 + size: 3782314 + timestamp: 1784229072899 +- conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.8-py312he06e257_0.conda + sha256: b7efb0de7b09e7333c11d46e04eb9a68f5c72a916e440fc451664bc3457900d3 + md5: 9104ee965ddb1d19ca60a5116582ce55 depends: - python >=3.12,<3.13.0a0 - python_abi 3.12.* *_cp312 @@ -7435,11 +7402,11 @@ packages: purls: - pkg:pypi/tornado?source=compressed-mapping run_exports: {} - size: 865801 - timestamp: 1781006895319 -- conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.7-py314h5a2d7ad_0.conda - sha256: 6b9f5a195ca148f7c6b9a4a0a026631979b3112c43cd7c1064085ff833dfa4f0 - md5: b1b9bf11a82e608c5649d7462de94c5f + size: 869273 + timestamp: 1786226848377 +- conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.8-py314h5a2d7ad_0.conda + sha256: 1cac25c190b60a4b6fbe2853c4989a4ce086564ee5f995c17d4b32885993068d + md5: 94dfb208db7d79437cfe9b4390706b5c depends: - python >=3.14,<3.15.0a0 - python_abi 3.14.* *_cp314 @@ -7451,8 +7418,8 @@ packages: purls: - pkg:pypi/tornado?source=hash-mapping run_exports: {} - size: 919275 - timestamp: 1781006902968 + size: 925990 + timestamp: 1786226866739 - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda sha256: 3005729dce6f3d3f5ec91dfc49fc75a0095f9cd23bab49efb899657297ac91a5 md5: 71b24316859acd00bdb8b38f5e2ce328 @@ -7464,48 +7431,48 @@ packages: run_exports: {} size: 694692 timestamp: 1756385147981 -- conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - sha256: 17693b60cb54f80c60275f003f3bfc1b128af56dbfd65c4fae37c64eeb755ce1 - md5: 2eacea63f545b97342da520df6854276 +- conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-ha367084_41.conda + sha256: 35444c55a92e2f7f7ba26bc70f81e56e52344f7d064c0fd4b40a46a58517b79c + md5: aa805b5522c2a98fa286e551a1f48546 depends: - - vc14_runtime >=14.51.36231 + - vc14_runtime >=14.51.36247 track_features: - vc14 license: BSD-3-Clause license_family: BSD purls: [] run_exports: {} - size: 20362 - timestamp: 1781320968457 -- conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - sha256: 8153ed849c92e891eacac0f2f8d7ecb79f9b5fd7f7917fbb896f252a60a40390 - md5: 06a5bf5a1ca16cce0df6eaa91fc42bc2 + size: 21383 + timestamp: 1785359368566 +- conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36247-habf1de7_41.conda + sha256: 4e4cb599cdc41bf2109d1464c127b5bcbddf548ce3e322e612afb691338b48f8 + md5: ac5333bb3d429361f23adf704cc49a78 depends: - ucrt >=10.0.20348.0 - - vcomp14 14.51.36231 h1b9f54f_39 + - vcomp14 14.51.36247 habf1de7_41 constrains: - - vs2015_runtime 14.51.36231.* *_39 + - vs2015_runtime 14.51.36247.* *_41 license: LicenseRef-MicrosoftVisualCpp2015-2022Runtime license_family: Proprietary purls: [] run_exports: {} - size: 737434 - timestamp: 1781320964561 -- conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - sha256: 07fb14713c4bc62e2533a2e23a363abfb0e65650681fba0ae4c840e2219350f3 - md5: 8b53a83fda40ec679e4d63fa32fae989 + size: 767955 + timestamp: 1785359364369 +- conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda + sha256: 731e043390c9457299484d39e427221fc868a9249540a498a5a4f6456c7744d1 + md5: 350bb67a5c8e5f1c53347ac544ab6600 depends: - ucrt >=10.0.20348.0 constrains: - - vs2015_runtime 14.51.36231.* *_39 + - vs2015_runtime 14.51.36247.* *_41 license: LicenseRef-MicrosoftVisualCpp2015-2022Runtime license_family: Proprietary purls: [] run_exports: strong: - - vcomp14 >=14.51.36231 - size: 120684 - timestamp: 1781320948530 + - vcomp14 >=14.51.36247 + size: 155910 + timestamp: 1785359349999 - conda: https://conda.anaconda.org/conda-forge/win-64/winpty-0.4.3-4.tar.bz2 sha256: 9df10c5b607dd30e05ba08cbd940009305c75db242476f4e845ea06008b0a283 md5: 1cee351bf20b830d991dbe0bc8cd7dfe @@ -7549,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 + - plopp>=26.9.0 - pooch + - scipp + - scipy - sympy - build ; extra == 'dev' - copier ; extra == 'dev' @@ -7655,13 +7627,96 @@ packages: - pytest-xdist ; extra == 'test-no-images' - wurlitzer ; extra == 'test-no-images' requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - name: coverage - version: 7.14.3 - sha256: 621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727 +- pypi: https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl + name: pandas + version: 3.0.5 + sha256: 53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c requires_dist: - - tomli ; python_full_version <= '3.11' and extra == 'toml' - requires_python: '>=3.10' + - numpy>=1.26.0 ; python_full_version < '3.14' + - numpy>=2.3.3 ; python_full_version >= '3.14' + - python-dateutil>=2.8.2 + - tzdata ; sys_platform == 'win32' + - tzdata ; sys_platform == 'emscripten' + - hypothesis>=6.116.0 ; extra == 'test' + - pytest>=8.3.4,<9.1 ; extra == 'test' + - pytest-xdist>=3.6.1 ; extra == 'test' + - pyarrow>=13.0.0 ; extra == 'pyarrow' + - bottleneck>=1.4.2 ; extra == 'performance' + - numba>=0.60.0 ; extra == 'performance' + - numexpr>=2.10.2 ; extra == 'performance' + - scipy>=1.14.1 ; extra == 'computation' + - xarray>=2024.10.0 ; extra == 'computation' + - fsspec>=2024.10.0 ; extra == 'fss' + - s3fs>=2024.10.0 ; extra == 'aws' + - gcsfs>=2024.10.0 ; extra == 'gcp' + - odfpy>=1.4.1 ; extra == 'excel' + - openpyxl>=3.1.5 ; extra == 'excel' + - python-calamine>=0.3.0 ; extra == 'excel' + - pyxlsb>=1.0.10 ; extra == 'excel' + - xlrd>=2.0.1 ; extra == 'excel' + - xlsxwriter>=3.2.0 ; extra == 'excel' + - pyarrow>=13.0.0 ; extra == 'parquet' + - pyarrow>=13.0.0 ; extra == 'feather' + - pyiceberg>=0.8.1 ; extra == 'iceberg' + - tables>=3.10.1 ; extra == 'hdf5' + - pyreadstat>=1.2.8 ; extra == 'spss' + - sqlalchemy>=2.0.36 ; extra == 'postgresql' + - psycopg2>=2.9.10 ; extra == 'postgresql' + - adbc-driver-postgresql>=1.2.0 ; extra == 'postgresql' + - sqlalchemy>=2.0.36 ; extra == 'mysql' + - pymysql>=1.1.1 ; extra == 'mysql' + - sqlalchemy>=2.0.36 ; extra == 'sql-other' + - adbc-driver-postgresql>=1.2.0 ; extra == 'sql-other' + - adbc-driver-sqlite>=1.2.0 ; extra == 'sql-other' + - beautifulsoup4>=4.12.3 ; extra == 'html' + - html5lib>=1.1 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'xml' + - matplotlib>=3.9.3 ; extra == 'plot' + - jinja2>=3.1.5 ; extra == 'output-formatting' + - tabulate>=0.9.0 ; extra == 'output-formatting' + - pyqt5>=5.15.9 ; extra == 'clipboard' + - qtpy>=2.4.2 ; extra == 'clipboard' + - zstandard>=0.23.0 ; extra == 'compression' + - pytz>=2020.1 ; extra == 'timezone' + - adbc-driver-postgresql>=1.2.0 ; extra == 'all' + - adbc-driver-sqlite>=1.2.0 ; extra == 'all' + - beautifulsoup4>=4.12.3 ; extra == 'all' + - bottleneck>=1.4.2 ; extra == 'all' + - fastparquet>=2024.11.0 ; extra == 'all' + - fsspec>=2024.10.0 ; extra == 'all' + - gcsfs>=2024.10.0 ; extra == 'all' + - html5lib>=1.1 ; extra == 'all' + - hypothesis>=6.116.0 ; extra == 'all' + - jinja2>=3.1.5 ; extra == 'all' + - lxml>=5.3.0 ; extra == 'all' + - matplotlib>=3.9.3 ; extra == 'all' + - numba>=0.60.0 ; extra == 'all' + - numexpr>=2.10.2 ; extra == 'all' + - odfpy>=1.4.1 ; extra == 'all' + - openpyxl>=3.1.5 ; extra == 'all' + - psycopg2>=2.9.10 ; extra == 'all' + - pyarrow>=13.0.0 ; extra == 'all' + - pyiceberg>=0.8.1 ; extra == 'all' + - pymysql>=1.1.1 ; extra == 'all' + - pyqt5>=5.15.9 ; extra == 'all' + - pyreadstat>=1.2.8 ; extra == 'all' + - pytest>=8.3.4 ; extra == 'all' + - pytest-xdist>=3.6.1 ; extra == 'all' + - python-calamine>=0.3.0 ; extra == 'all' + - pytz>=2020.1 ; extra == 'all' + - pyxlsb>=1.0.10 ; extra == 'all' + - qtpy>=2.4.2 ; extra == 'all' + - scipy>=1.14.1 ; extra == 'all' + - s3fs>=2024.10.0 ; extra == 'all' + - sqlalchemy>=2.0.36 ; extra == 'all' + - tables>=3.10.1 ; extra == 'all' + - tabulate>=0.9.0 ; extra == 'all' + - xarray>=2024.10.0 ; extra == 'all' + - xlrd>=2.0.1 ; extra == 'all' + - xlsxwriter>=3.2.0 ; extra == 'all' + - zstandard>=0.23.0 ; extra == 'all' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl name: fonttools version: 4.63.0 @@ -7696,39 +7751,17 @@ packages: - skia-pathops>=0.5.0 ; extra == 'all' - uharfbuzz>=0.45.0 ; extra == 'all' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl - name: python-socketio - version: 5.16.3 - sha256: e7ad14202a5e6448824c7c2f86161d04e13dec05992257df5c709e6a2798c041 +- pypi: https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl + name: coverage + version: 7.15.4 + sha256: d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c requires_dist: - - bidict>=0.21.0 - - python-engineio>=4.13.2 - - requests>=2.21.0 ; extra == 'client' - - websocket-client>=0.54.0 ; extra == 'client' - - aiohttp>=3.4 ; extra == 'asyncio-client' - - tox ; extra == 'dev' - - sphinx ; extra == 'docs' - - furo ; extra == 'docs' - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/0c/a4/cf8df35b6488c04cae9aa7338c7b531a87aae2515da3993d2906d49b1dc1/copier-9.16.0-py3-none-any.whl - name: copier - version: 9.16.0 - sha256: fadc55a22db6fb0f99d52878d3df4df3fa9c314dd96b0d81fef785a5e803114b - requires_dist: - - colorama>=0.4.6 - - dunamai>=1.7.0 - - funcy>=1.17 - - jinja2-ansible-filters>=1.3.1 - - jinja2>=3.1.5 - - packaging>=23.0 - - pathspec>=0.9.0 - - platformdirs>=4.3.6 - - plumbum>=1.6.9 - - pydantic>=2.4.2 - - pygments>=2.7.1 - - pyyaml>=5.3.1 - - questionary>=1.8.1 - - typing-extensions>=4.0.0,<5.0.0 ; python_full_version < '3.11' + - 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 @@ -7745,32 +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/0f/0e/0eb94e64f5badef67f11fe1e448dde2a44f00940d8949f4adf71d560552e/scipp-26.3.1-cp314-cp314-macosx_14_0_arm64.whl - name: scipp - version: 26.3.1 - sha256: 1f103f6c5a33b08773206c613fe2dd9c02585f5c4e44b77311c54b7828a758ed - requires_dist: - - numpy>=2 - - pytest ; extra == 'test' - - matplotlib ; extra == 'test' - - beautifulsoup4 ; extra == 'test' - - ipython ; extra == 'test' - - h5py ; extra == 'extra' - - scipy>=1.7.0 ; extra == 'extra' - - graphviz ; extra == 'extra' - - pooch ; extra == 'extra' - - plopp ; extra == 'extra' - - matplotlib ; extra == 'extra' - - scipp[extra] ; extra == 'all' - - ipympl ; extra == 'all' - - ipython ; extra == 'all' - - ipywidgets ; extra == 'all' - - jupyterlab ; extra == 'all' - - jupyterlab-widgets ; extra == 'all' - - jupyter-nbextensions-configurator ; extra == 'all' - - nodejs ; extra == 'all' - - pythreejs ; extra == 'all' - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl name: pyparsing version: 3.3.2 @@ -7784,13 +7791,96 @@ packages: version: 1.9.0 sha256: ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl - name: coverage - version: 7.14.3 - sha256: a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7 +- 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 + name: pandas + version: 3.0.5 + sha256: d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34 requires_dist: - - tomli ; python_full_version <= '3.11' and extra == 'toml' - requires_python: '>=3.10' + - numpy>=1.26.0 ; python_full_version < '3.14' + - numpy>=2.3.3 ; python_full_version >= '3.14' + - python-dateutil>=2.8.2 + - tzdata ; sys_platform == 'win32' + - tzdata ; sys_platform == 'emscripten' + - hypothesis>=6.116.0 ; extra == 'test' + - pytest>=8.3.4,<9.1 ; extra == 'test' + - pytest-xdist>=3.6.1 ; extra == 'test' + - pyarrow>=13.0.0 ; extra == 'pyarrow' + - bottleneck>=1.4.2 ; extra == 'performance' + - numba>=0.60.0 ; extra == 'performance' + - numexpr>=2.10.2 ; extra == 'performance' + - scipy>=1.14.1 ; extra == 'computation' + - xarray>=2024.10.0 ; extra == 'computation' + - fsspec>=2024.10.0 ; extra == 'fss' + - s3fs>=2024.10.0 ; extra == 'aws' + - gcsfs>=2024.10.0 ; extra == 'gcp' + - odfpy>=1.4.1 ; extra == 'excel' + - openpyxl>=3.1.5 ; extra == 'excel' + - python-calamine>=0.3.0 ; extra == 'excel' + - pyxlsb>=1.0.10 ; extra == 'excel' + - xlrd>=2.0.1 ; extra == 'excel' + - xlsxwriter>=3.2.0 ; extra == 'excel' + - pyarrow>=13.0.0 ; extra == 'parquet' + - pyarrow>=13.0.0 ; extra == 'feather' + - pyiceberg>=0.8.1 ; extra == 'iceberg' + - tables>=3.10.1 ; extra == 'hdf5' + - pyreadstat>=1.2.8 ; extra == 'spss' + - sqlalchemy>=2.0.36 ; extra == 'postgresql' + - psycopg2>=2.9.10 ; extra == 'postgresql' + - adbc-driver-postgresql>=1.2.0 ; extra == 'postgresql' + - sqlalchemy>=2.0.36 ; extra == 'mysql' + - pymysql>=1.1.1 ; extra == 'mysql' + - sqlalchemy>=2.0.36 ; extra == 'sql-other' + - adbc-driver-postgresql>=1.2.0 ; extra == 'sql-other' + - adbc-driver-sqlite>=1.2.0 ; extra == 'sql-other' + - beautifulsoup4>=4.12.3 ; extra == 'html' + - html5lib>=1.1 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'xml' + - matplotlib>=3.9.3 ; extra == 'plot' + - jinja2>=3.1.5 ; extra == 'output-formatting' + - tabulate>=0.9.0 ; extra == 'output-formatting' + - pyqt5>=5.15.9 ; extra == 'clipboard' + - qtpy>=2.4.2 ; extra == 'clipboard' + - zstandard>=0.23.0 ; extra == 'compression' + - pytz>=2020.1 ; extra == 'timezone' + - adbc-driver-postgresql>=1.2.0 ; extra == 'all' + - adbc-driver-sqlite>=1.2.0 ; extra == 'all' + - beautifulsoup4>=4.12.3 ; extra == 'all' + - bottleneck>=1.4.2 ; extra == 'all' + - fastparquet>=2024.11.0 ; extra == 'all' + - fsspec>=2024.10.0 ; extra == 'all' + - gcsfs>=2024.10.0 ; extra == 'all' + - html5lib>=1.1 ; extra == 'all' + - hypothesis>=6.116.0 ; extra == 'all' + - jinja2>=3.1.5 ; extra == 'all' + - lxml>=5.3.0 ; extra == 'all' + - matplotlib>=3.9.3 ; extra == 'all' + - numba>=0.60.0 ; extra == 'all' + - numexpr>=2.10.2 ; extra == 'all' + - odfpy>=1.4.1 ; extra == 'all' + - openpyxl>=3.1.5 ; extra == 'all' + - psycopg2>=2.9.10 ; extra == 'all' + - pyarrow>=13.0.0 ; extra == 'all' + - pyiceberg>=0.8.1 ; extra == 'all' + - pymysql>=1.1.1 ; extra == 'all' + - pyqt5>=5.15.9 ; extra == 'all' + - pyreadstat>=1.2.8 ; extra == 'all' + - pytest>=8.3.4 ; extra == 'all' + - pytest-xdist>=3.6.1 ; extra == 'all' + - python-calamine>=0.3.0 ; extra == 'all' + - pytz>=2020.1 ; extra == 'all' + - pyxlsb>=1.0.10 ; extra == 'all' + - qtpy>=2.4.2 ; extra == 'all' + - scipy>=1.14.1 ; extra == 'all' + - s3fs>=2024.10.0 ; extra == 'all' + - sqlalchemy>=2.0.36 ; extra == 'all' + - tables>=3.10.1 ; extra == 'all' + - tabulate>=0.9.0 ; extra == 'all' + - xarray>=2024.10.0 ; extra == 'all' + - xlrd>=2.0.1 ; extra == 'all' + - xlsxwriter>=3.2.0 ; extra == 'all' + - zstandard>=0.23.0 ; extra == 'all' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/12/b3/88c0ef22878c86035f058df0ac6c171319ffd0aa52a406455ed3a3847566/ipympl-0.10.0-py3-none-any.whl name: ipympl version: 0.10.0 @@ -7840,11 +7930,6 @@ packages: - pytest-mock ; extra == 'tests' - coverage[toml] ; extra == 'tests' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl - name: filelock - version: 3.29.4 - sha256: dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767 - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl name: mkdocs-jupyter version: 0.26.3 @@ -7862,11 +7947,25 @@ packages: version: 0.5.2 sha256: 6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl + name: numpy + version: 2.5.2 + sha256: 7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d + requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl name: msgpack version: 1.2.1 sha256: 74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b requires_python: '>=3.10' +- 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 + sha256: e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75 + requires_dist: + - idna>=2.0 + - multidict>=4.0 + - propcache>=0.2.1 + requires_python: '>=3.10' - 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 name: msgpack version: 1.2.1 @@ -7904,66 +8003,6 @@ packages: - pytest-xdist ; extra == 'test-no-images' - wurlitzer ; extra == 'test-no-images' requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/1a/1f/86b4d15221096cb5500bcd73bf350745749e3ba056cdd7a7f75f126f154e/scipp-26.3.1-cp312-cp312-win_amd64.whl - name: scipp - version: 26.3.1 - sha256: 8b036876edf7895d17644f59711037d2d7d9ad048b1a503200646d8229fb1ad7 - requires_dist: - - numpy>=2 - - pytest ; extra == 'test' - - matplotlib ; extra == 'test' - - beautifulsoup4 ; extra == 'test' - - ipython ; extra == 'test' - - h5py ; extra == 'extra' - - scipy>=1.7.0 ; extra == 'extra' - - graphviz ; extra == 'extra' - - pooch ; extra == 'extra' - - plopp ; extra == 'extra' - - matplotlib ; extra == 'extra' - - scipp[extra] ; extra == 'all' - - ipympl ; extra == 'all' - - ipython ; extra == 'all' - - ipywidgets ; extra == 'all' - - jupyterlab ; extra == 'all' - - jupyterlab-widgets ; extra == 'all' - - jupyter-nbextensions-configurator ; extra == 'all' - - nodejs ; extra == 'all' - - pythreejs ; extra == 'all' - requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl - name: python-discovery - version: 1.4.2 - sha256: 475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500 - requires_dist: - - filelock>=3.15.4 - - platformdirs>=4.3.6,<5 - - furo>=2025.12.19 ; extra == 'docs' - - sphinx-autodoc-typehints>=3.6.3 ; extra == 'docs' - - sphinx>=9.1 ; extra == 'docs' - - sphinxcontrib-mermaid>=2 ; extra == 'docs' - - sphinxcontrib-towncrier>=0.4 ; extra == 'docs' - - towncrier>=25.8 ; extra == 'docs' - - covdefaults>=2.3 ; extra == 'testing' - - coverage>=7.5.4 ; extra == 'testing' - - pytest-mock>=3.14 ; extra == 'testing' - - pytest>=8.3.5 ; extra == 'testing' - - setuptools>=75.1 ; extra == 'testing' - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/1a/f7/3a9e6389a7cfaeff76c56e40c2dabcb13110e21e82f837228c834ebe748c/matplotlib-3.11.0-cp314-cp314-win_amd64.whl - name: matplotlib - version: 3.11.0 - sha256: 1c02da0a629dfa9debf52725ea06866b74c1fb70a895bae05e4493d34074f9f2 - requires_dist: - - contourpy>=1.0.1 - - cycler>=0.10 - - fonttools>=4.22.0 - - kiwisolver>=1.3.1 - - numpy>=1.25 - - packaging>=20.0 - - pillow>=9 - - pyparsing>=3 - - python-dateutil>=2.7 - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl name: versioningit version: 3.3.0 @@ -7973,6 +8012,13 @@ packages: - packaging>=17.1 - tomli>=1.2,<3.0 ; python_full_version < '3.11' requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl + name: backrefs + version: '8.0' + sha256: 9ec96efa080938be92323e8e730e57718c9c88eb15ad70bbef4e1766df591408 + requires_dist: + - regex ; extra == 'extras' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl name: dill version: 0.4.1 @@ -7981,58 +8027,6 @@ packages: - objgraph>=1.7.2 ; extra == 'graph' - gprof2dot>=2022.7.29 ; extra == 'profile' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/1e/e7/cd78635d0ece7e4d3393f2c1d2ebabf6ff4bd615da142891b1d42ad58abf/scipp-26.3.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - name: scipp - version: 26.3.1 - sha256: 7525c843f673ef5461d229095054a701aeb3233db29af137fdf4bbf0884ad9d4 - requires_dist: - - numpy>=2 - - pytest ; extra == 'test' - - matplotlib ; extra == 'test' - - beautifulsoup4 ; extra == 'test' - - ipython ; extra == 'test' - - h5py ; extra == 'extra' - - scipy>=1.7.0 ; extra == 'extra' - - graphviz ; extra == 'extra' - - pooch ; extra == 'extra' - - plopp ; extra == 'extra' - - matplotlib ; extra == 'extra' - - scipp[extra] ; extra == 'all' - - ipympl ; extra == 'all' - - ipython ; extra == 'all' - - ipywidgets ; extra == 'all' - - jupyterlab ; extra == 'all' - - jupyterlab-widgets ; extra == 'all' - - jupyter-nbextensions-configurator ; extra == 'all' - - nodejs ; extra == 'all' - - pythreejs ; extra == 'all' - requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/1f/28/3f8aa247d29d010547d52207395cb057ebd0a40b88f64bc1dbac9e17a729/scipp-26.3.1-cp314-cp314-win_amd64.whl - name: scipp - version: 26.3.1 - sha256: 26291c0a882b9d5aac868c6d6f2508b79baa821ed30060a22c50620dbcce9e75 - requires_dist: - - numpy>=2 - - pytest ; extra == 'test' - - matplotlib ; extra == 'test' - - beautifulsoup4 ; extra == 'test' - - ipython ; extra == 'test' - - h5py ; extra == 'extra' - - scipy>=1.7.0 ; extra == 'extra' - - graphviz ; extra == 'extra' - - pooch ; extra == 'extra' - - plopp ; extra == 'extra' - - matplotlib ; extra == 'extra' - - scipp[extra] ; extra == 'all' - - ipympl ; extra == 'all' - - ipython ; extra == 'all' - - ipywidgets ; extra == 'all' - - jupyterlab ; extra == 'all' - - jupyterlab-widgets ; extra == 'all' - - jupyter-nbextensions-configurator ; extra == 'all' - - nodejs ; extra == 'all' - - pythreejs ; extra == 'all' - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/20/16/e777eadfa0c0305878c36fae1d5e6db474fbb15dae202b9ec378809dfb4d/nbstripout-0.9.1-py3-none-any.whl name: nbstripout version: 0.9.1 @@ -8040,51 +8034,17 @@ packages: requires_dist: - nbformat requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl - name: gitpython - version: 3.1.50 - sha256: d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9 +- pypi: https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl + name: coverage + version: 7.15.4 + sha256: 37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97 requires_dist: - - gitdb>=4.0.1,<5 - - typing-extensions>=3.10.0.2 ; python_full_version < '3.10' - - coverage[toml] ; extra == 'test' - - ddt>=1.1.1,!=1.4.3 ; extra == 'test' - - mock ; python_full_version < '3.8' and extra == 'test' - - mypy==1.18.2 ; python_full_version >= '3.9' and extra == 'test' - - pre-commit ; extra == 'test' - - pytest>=7.3.1 ; extra == 'test' - - pytest-cov ; extra == 'test' - - pytest-instafail ; extra == 'test' - - pytest-mock ; extra == 'test' - - pytest-sugar ; extra == 'test' - - typing-extensions ; python_full_version < '3.11' and extra == 'test' - - sphinx>=7.4.7,<8 ; extra == 'doc' - - sphinx-rtd-theme ; extra == 'doc' - - sphinx-autodoc-typehints ; extra == 'doc' - requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: aiohttp - version: 3.14.1 - sha256: 3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c - requires_dist: - - aiohappyeyeballs>=2.5.0 - - aiosignal>=1.4.0 - - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' - - attrs>=17.3.0 - - frozenlist>=1.1.1 - - multidict>=4.5,<7.0 - - propcache>=0.2.0 - - typing-extensions>=4.4 ; python_full_version < '3.13' - - yarl>=1.17.0,<2.0 - - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - 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/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl - name: mkdocs - version: 1.6.1 - sha256: db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e + - tomli ; python_full_version <= '3.11' and extra == 'toml' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl + name: mkdocs + version: 1.6.1 + sha256: db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e requires_dist: - click>=7.0 - colorama>=0.4 ; sys_platform == 'win32' @@ -8117,6 +8077,83 @@ packages: - pyyaml==5.1 ; extra == 'min-versions' - watchdog==2.0 ; extra == 'min-versions' requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/24/18/d8544811ab076f876c4892b3714f5b0dad335e1dc33aef826df431b8325d/plotly-6.9.0-py3-none-any.whl + name: plotly + version: 6.9.0 + sha256: 36bebe2f1bb13884774fe61689c329071446f6ce4a8927fb1f0d6fb24f581236 + requires_dist: + - narwhals>=1.15.1 + - packaging + - anywidget ; extra == 'dev' + - build ; extra == 'dev' + - colorcet ; extra == 'dev' + - fiona<=1.9.6 ; python_full_version < '3.9' and extra == 'dev' + - geopandas ; extra == 'dev' + - inflect ; extra == 'dev' + - jupyterlab ; extra == 'dev' + - kaleido>=1.3.0 ; extra == 'dev' + - numpy>=1.22 ; extra == 'dev' + - orjson ; extra == 'dev' + - pandas ; extra == 'dev' + - pdfrw ; extra == 'dev' + - pillow ; extra == 'dev' + - plotly-geo ; extra == 'dev' + - polars[timezone] ; extra == 'dev' + - pyarrow ; extra == 'dev' + - pyshp ; extra == 'dev' + - pytest ; extra == 'dev' + - pytz ; extra == 'dev' + - requests ; extra == 'dev' + - ruff==0.11.12 ; extra == 'dev' + - scikit-image ; extra == 'dev' + - scipy ; extra == 'dev' + - shapely ; extra == 'dev' + - statsmodels ; extra == 'dev' + - vaex ; python_full_version < '3.10' and extra == 'dev' + - xarray ; extra == 'dev' + - build ; extra == 'dev-build' + - jupyterlab ; extra == 'dev-build' + - pytest ; extra == 'dev-build' + - requests ; extra == 'dev-build' + - ruff==0.11.12 ; extra == 'dev-build' + - pytest ; extra == 'dev-core' + - requests ; extra == 'dev-core' + - ruff==0.11.12 ; extra == 'dev-core' + - anywidget ; extra == 'dev-optional' + - build ; extra == 'dev-optional' + - colorcet ; extra == 'dev-optional' + - fiona<=1.9.6 ; python_full_version < '3.9' and extra == 'dev-optional' + - geopandas ; extra == 'dev-optional' + - inflect ; extra == 'dev-optional' + - jupyterlab ; extra == 'dev-optional' + - kaleido>=1.3.0 ; extra == 'dev-optional' + - numpy>=1.22 ; extra == 'dev-optional' + - orjson ; extra == 'dev-optional' + - pandas ; extra == 'dev-optional' + - pdfrw ; extra == 'dev-optional' + - pillow ; extra == 'dev-optional' + - plotly-geo ; extra == 'dev-optional' + - polars[timezone] ; extra == 'dev-optional' + - pyarrow ; extra == 'dev-optional' + - pyshp ; extra == 'dev-optional' + - pytest ; extra == 'dev-optional' + - pytz ; extra == 'dev-optional' + - requests ; extra == 'dev-optional' + - ruff==0.11.12 ; extra == 'dev-optional' + - scikit-image ; extra == 'dev-optional' + - scipy ; extra == 'dev-optional' + - shapely ; extra == 'dev-optional' + - statsmodels ; extra == 'dev-optional' + - vaex ; python_full_version < '3.10' and extra == 'dev-optional' + - xarray ; extra == 'dev-optional' + - numpy>=1,<2 ; extra == 'dev-pandas1' + - pandas>=1,<2 ; extra == 'dev-pandas1' + - setuptools<82 ; extra == 'dev-pandas1' + - pandas>=2,<3 ; extra == 'dev-pandas2' + - pandas>=3 ; python_full_version >= '3.11' and extra == 'dev-pandas3' + - numpy>=1.22 ; extra == 'express' + - kaleido>=1.3.0 ; extra == 'kaleido' + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl name: pytest version: 9.1.1 @@ -8137,13 +8174,6 @@ packages: - setuptools ; extra == 'dev' - xmlschema ; extra == 'dev' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - name: coverage - version: 7.14.3 - sha256: 1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5 - requires_dist: - - tomli ; python_full_version <= '3.11' and extra == 'toml' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl name: fonttools version: 4.63.0 @@ -8206,30 +8236,13 @@ packages: - markupsafe>=2.0.1 - mkdocs>=1.1 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl - name: yarl - version: 1.24.2 - sha256: 8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d +- pypi: https://files.pythonhosted.org/packages/29/13/232505664e8e2a0c7a2eb0c505cfade9d715538f89a5d62bc4c272968f62/backrefs-8.0-py312-none-any.whl + name: backrefs + version: '8.0' + sha256: 87f0fae8c5f207fe9f4b2887efc71d42f4900ac78faa1af08d675ef303692dc5 requires_dist: - - idna>=2.0 - - multidict>=4.0 - - propcache>=0.2.1 + - regex ; extra == 'extras' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/29/dc/6377ecfaa5fef79430f74a1a16638b4e2aa30d4692bae2c19f9d76fe3b01/matplotlib-3.11.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - name: matplotlib - version: 3.11.0 - sha256: 19c16c61dea63b3582918503e6b294193961261d9daa806d4ae2151f1ad05430 - requires_dist: - - contourpy>=1.0.1 - - cycler>=0.10 - - fonttools>=4.22.0 - - kiwisolver>=1.3.1 - - numpy>=1.25 - - packaging>=20.0 - - pillow>=9 - - pyparsing>=3 - - python-dateutil>=2.7 - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl name: pooch version: 1.9.0 @@ -8300,42 +8313,6 @@ packages: version: 1.8.0 sha256: f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl - name: mkdocs-material - version: 9.7.6 - sha256: 71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba - requires_dist: - - babel>=2.10 - - backrefs>=5.7.post1 - - colorama>=0.4 - - jinja2>=3.1 - - markdown>=3.2 - - mkdocs-material-extensions>=1.3 - - mkdocs>=1.6,<2 - - paginate>=0.5 - - pygments>=2.16 - - pymdown-extensions>=10.2 - - requests>=2.30 - - mkdocs-git-committers-plugin-2>=1.1 ; extra == 'git' - - mkdocs-git-revision-date-localized-plugin>=1.2.4 ; extra == 'git' - - cairosvg>=2.6 ; extra == 'imaging' - - pillow>=10.2 ; extra == 'imaging' - - mkdocs-minify-plugin>=0.7 ; extra == 'recommended' - - mkdocs-redirects>=1.2 ; extra == 'recommended' - - mkdocs-rss-plugin>=1.6 ; extra == 'recommended' - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl - name: virtualenv - version: 21.5.1 - sha256: 55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783 - 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/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl name: mergedeep version: 1.3.4 @@ -8346,30 +8323,30 @@ packages: version: 0.5.2 sha256: e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl - name: chardet - version: 7.4.3 - sha256: 4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101 +- pypi: https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl + name: aiohttp + version: 3.14.3 + sha256: 617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee + requires_dist: + - aiohappyeyeballs>=2.5.0 + - aiosignal>=1.4.0 + - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' + - attrs>=17.3.0 + - frozenlist>=1.1.1 + - multidict>=4.5,<7.0 + - propcache>=0.2.0 + - typing-extensions>=4.4 ; python_full_version < '3.13' + - yarl>=1.17.0,<2.0 + - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - 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/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl name: tokenize-rt version: 6.2.0 sha256: a152bf4f249c847a66497a4a95f63376ed68ac6abf092a2f7cfb29d044ecff44 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl - name: coverage - version: 7.14.3 - sha256: 96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665 - requires_dist: - - tomli ; python_full_version <= '3.11' and extra == 'toml' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl - name: backrefs - version: '7.0' - sha256: ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12 - requires_dist: - - regex ; extra == 'extras' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl name: fonttools version: 4.63.0 @@ -8404,102 +8381,38 @@ packages: - skia-pathops>=0.5.0 ; extra == 'all' - uharfbuzz>=0.45.0 ; extra == 'all' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl - name: pandas - version: 3.0.3 - sha256: 3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5 - requires_dist: - - numpy>=1.26.0 ; python_full_version < '3.14' - - numpy>=2.3.3 ; python_full_version >= '3.14' - - python-dateutil>=2.8.2 - - tzdata ; sys_platform == 'win32' - - tzdata ; sys_platform == 'emscripten' - - hypothesis>=6.116.0 ; extra == 'test' - - pytest>=8.3.4 ; extra == 'test' - - pytest-xdist>=3.6.1 ; extra == 'test' - - pyarrow>=13.0.0 ; extra == 'pyarrow' - - bottleneck>=1.4.2 ; extra == 'performance' - - numba>=0.60.0 ; extra == 'performance' - - numexpr>=2.10.2 ; extra == 'performance' - - scipy>=1.14.1 ; extra == 'computation' - - xarray>=2024.10.0 ; extra == 'computation' - - fsspec>=2024.10.0 ; extra == 'fss' - - s3fs>=2024.10.0 ; extra == 'aws' - - gcsfs>=2024.10.0 ; extra == 'gcp' - - odfpy>=1.4.1 ; extra == 'excel' - - openpyxl>=3.1.5 ; extra == 'excel' - - python-calamine>=0.3.0 ; extra == 'excel' - - pyxlsb>=1.0.10 ; extra == 'excel' - - xlrd>=2.0.1 ; extra == 'excel' - - xlsxwriter>=3.2.0 ; extra == 'excel' - - pyarrow>=13.0.0 ; extra == 'parquet' - - pyarrow>=13.0.0 ; extra == 'feather' - - pyiceberg>=0.8.1 ; extra == 'iceberg' - - tables>=3.10.1 ; extra == 'hdf5' - - pyreadstat>=1.2.8 ; extra == 'spss' - - sqlalchemy>=2.0.36 ; extra == 'postgresql' - - psycopg2>=2.9.10 ; extra == 'postgresql' - - adbc-driver-postgresql>=1.2.0 ; extra == 'postgresql' - - sqlalchemy>=2.0.36 ; extra == 'mysql' - - pymysql>=1.1.1 ; extra == 'mysql' - - sqlalchemy>=2.0.36 ; extra == 'sql-other' - - adbc-driver-postgresql>=1.2.0 ; extra == 'sql-other' - - adbc-driver-sqlite>=1.2.0 ; extra == 'sql-other' - - beautifulsoup4>=4.12.3 ; extra == 'html' - - html5lib>=1.1 ; extra == 'html' - - lxml>=5.3.0 ; extra == 'html' - - lxml>=5.3.0 ; extra == 'xml' - - matplotlib>=3.9.3 ; extra == 'plot' - - jinja2>=3.1.5 ; extra == 'output-formatting' - - tabulate>=0.9.0 ; extra == 'output-formatting' - - pyqt5>=5.15.9 ; extra == 'clipboard' - - qtpy>=2.4.2 ; extra == 'clipboard' - - zstandard>=0.23.0 ; extra == 'compression' - - pytz>=2020.1 ; extra == 'timezone' - - adbc-driver-postgresql>=1.2.0 ; extra == 'all' - - adbc-driver-sqlite>=1.2.0 ; extra == 'all' - - beautifulsoup4>=4.12.3 ; extra == 'all' - - bottleneck>=1.4.2 ; extra == 'all' - - fastparquet>=2024.11.0 ; extra == 'all' - - fsspec>=2024.10.0 ; extra == 'all' - - gcsfs>=2024.10.0 ; extra == 'all' - - html5lib>=1.1 ; extra == 'all' - - hypothesis>=6.116.0 ; extra == 'all' - - jinja2>=3.1.5 ; extra == 'all' - - lxml>=5.3.0 ; extra == 'all' - - matplotlib>=3.9.3 ; extra == 'all' - - numba>=0.60.0 ; extra == 'all' - - numexpr>=2.10.2 ; extra == 'all' - - odfpy>=1.4.1 ; extra == 'all' - - openpyxl>=3.1.5 ; extra == 'all' - - psycopg2>=2.9.10 ; extra == 'all' - - pyarrow>=13.0.0 ; extra == 'all' - - pyiceberg>=0.8.1 ; extra == 'all' - - pymysql>=1.1.1 ; extra == 'all' - - pyqt5>=5.15.9 ; extra == 'all' - - pyreadstat>=1.2.8 ; extra == 'all' - - pytest>=8.3.4 ; extra == 'all' - - pytest-xdist>=3.6.1 ; extra == 'all' - - python-calamine>=0.3.0 ; extra == 'all' - - pytz>=2020.1 ; extra == 'all' - - pyxlsb>=1.0.10 ; extra == 'all' - - qtpy>=2.4.2 ; extra == 'all' - - scipy>=1.14.1 ; extra == 'all' - - s3fs>=2024.10.0 ; extra == 'all' - - sqlalchemy>=2.0.36 ; extra == 'all' - - tables>=3.10.1 ; extra == 'all' - - tabulate>=0.9.0 ; extra == 'all' - - xarray>=2024.10.0 ; extra == 'all' - - xlrd>=2.0.1 ; extra == 'all' - - xlsxwriter>=3.2.0 ; extra == 'all' - - zstandard>=0.23.0 ; extra == 'all' - requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/38/7e/7b91c89a4cf0f543a83be978657afb20c86af6d725253e319589dcc4ce52/lmfit-1.3.4-py3-none-any.whl - name: lmfit - version: 1.3.4 - sha256: afce1593b42324d37ae2908249b0c55445e2f4c1a0474ff706a8e2f7b5d949fa +- pypi: https://files.pythonhosted.org/packages/38/06/f4f9f5b55219128cd61b86528674d2b268a12549ee10d0626b3feef3ba89/scipp-26.8.0-cp314-cp314-macosx_14_0_arm64.whl + name: scipp + version: 26.8.0 + sha256: 2cb7760d7b99a0c6418ea7a43292d44a4fbf7f973d0ce816eb4e2813d8ee6551 requires_dist: - - asteval>=1.0 + - numpy>=2 + - pytest ; extra == 'test' + - matplotlib ; extra == 'test' + - beautifulsoup4 ; extra == 'test' + - ipython ; extra == 'test' + - h5py ; extra == 'extra' + - scipy>=1.7.0 ; extra == 'extra' + - graphviz ; extra == 'extra' + - pooch ; extra == 'extra' + - plopp ; extra == 'extra' + - matplotlib ; extra == 'extra' + - scipp[extra] ; extra == 'all' + - ipympl ; extra == 'all' + - ipython ; extra == 'all' + - ipywidgets ; extra == 'all' + - jupyterlab ; extra == 'all' + - jupyterlab-widgets ; extra == 'all' + - jupyter-nbextensions-configurator ; extra == 'all' + - nodejs ; extra == 'all' + - pythreejs ; extra == 'all' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/38/7e/7b91c89a4cf0f543a83be978657afb20c86af6d725253e319589dcc4ce52/lmfit-1.3.4-py3-none-any.whl + name: lmfit + version: 1.3.4 + sha256: afce1593b42324d37ae2908249b0c55445e2f4c1a0474ff706a8e2f7b5d949fa + requires_dist: + - asteval>=1.0 - numpy>=1.24 - scipy>=1.10.0 - uncertainties>=3.2.2 @@ -8529,6 +8442,11 @@ packages: - pytest-cov ; extra == 'test' - lmfit[dev,doc,test] ; extra == 'all' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + name: numpy + version: 2.5.2 + sha256: 3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8 + requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl name: questionary version: 2.1.1 @@ -8565,18 +8483,54 @@ 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/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl - name: backrefs - version: '7.0' - sha256: a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9 +- 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 + sha256: dd391cdcd40842ee575a30e6648340fc39a0e7b18a9ac1c2674521fa5f2a4a0b requires_dist: - - regex ; extra == 'extras' - requires_python: '>=3.10' + - numpy>=2 + - pytest ; extra == 'test' + - matplotlib ; extra == 'test' + - beautifulsoup4 ; extra == 'test' + - ipython ; extra == 'test' + - h5py ; extra == 'extra' + - scipy>=1.7.0 ; extra == 'extra' + - graphviz ; extra == 'extra' + - pooch ; extra == 'extra' + - plopp ; extra == 'extra' + - matplotlib ; extra == 'extra' + - scipp[extra] ; extra == 'all' + - ipympl ; extra == 'all' + - ipython ; extra == 'all' + - ipywidgets ; extra == 'all' + - jupyterlab ; extra == 'all' + - jupyterlab-widgets ; extra == 'all' + - jupyter-nbextensions-configurator ; extra == 'all' + - nodejs ; extra == 'all' + - pythreejs ; extra == 'all' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl name: widgetsnbextension 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 @@ -8597,58 +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/43/fe/ad0ecbe2393cb690a4b3100a8fea47ecfdb49f6e06f40cf2f626635adc0c/scipp-26.3.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - name: scipp - version: 26.3.1 - sha256: 2ef08ba8d83542807f9f9833ba8f01583215c1629693bfadb1d6508cbdeb335c - requires_dist: - - numpy>=2 - - pytest ; extra == 'test' - - matplotlib ; extra == 'test' - - beautifulsoup4 ; extra == 'test' - - ipython ; extra == 'test' - - h5py ; extra == 'extra' - - scipy>=1.7.0 ; extra == 'extra' - - graphviz ; extra == 'extra' - - pooch ; extra == 'extra' - - plopp ; extra == 'extra' - - matplotlib ; extra == 'extra' - - scipp[extra] ; extra == 'all' - - ipympl ; extra == 'all' - - ipython ; extra == 'all' - - ipywidgets ; extra == 'all' - - jupyterlab ; extra == 'all' - - jupyterlab-widgets ; extra == 'all' - - jupyter-nbextensions-configurator ; extra == 'all' - - nodejs ; extra == 'all' - - pythreejs ; extra == 'all' - requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/44/7b/537a61906eac58d94131273084d21d4eb219f5453f0ed30de3aca580a2b4/scipp-26.3.1-cp312-cp312-macosx_14_0_arm64.whl - name: scipp - version: 26.3.1 - sha256: 2608ba21e2c550abe864598e8cfffe22d7e7be70ff9f9b03d44868e353b241c9 - requires_dist: - - numpy>=2 - - pytest ; extra == 'test' - - matplotlib ; extra == 'test' - - beautifulsoup4 ; extra == 'test' - - ipython ; extra == 'test' - - h5py ; extra == 'extra' - - scipy>=1.7.0 ; extra == 'extra' - - graphviz ; extra == 'extra' - - pooch ; extra == 'extra' - - plopp ; extra == 'extra' - - matplotlib ; extra == 'extra' - - scipp[extra] ; extra == 'all' - - ipympl ; extra == 'all' - - ipython ; extra == 'all' - - ipywidgets ; extra == 'all' - - jupyterlab ; extra == 'all' - - jupyterlab-widgets ; extra == 'all' - - jupyter-nbextensions-configurator ; extra == 'all' - - nodejs ; extra == 'all' - - pythreejs ; extra == 'all' - requires_python: '>=3.11' +- 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 @@ -8680,76 +8587,34 @@ packages: - trove-classifiers>=2024.10.12 ; extra == 'tests' - defusedxml ; extra == 'xmp' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl - name: ruff - version: 0.15.20 - sha256: c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632 - requires_python: '>=3.7' +- 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 sha256: 0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/4a/8a/4cb9367a86f2b9526727ee94e5e6a3d86f9f7d7d947927b444e5bcd56a89/easyscience-2.3.1-py3-none-any.whl - name: easyscience - version: 2.3.1 - sha256: 51dd343ff4bcf7c36e8fada32ed3ca2bdc7e1226ae08965a2c11d9fd936e3e40 +- pypi: https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl + name: coverage + version: 7.15.4 + sha256: 6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd requires_dist: - - asteval - - bumps - - dfo-ls - - ipykernel - - ipympl - - ipython - - ipywidgets - - jupyterlab - - lmfit - - matplotlib - - numpy - - pixi-kernel - - pooch - - scipp - - uncertainties - - build ; extra == 'dev' - - copier ; extra == 'dev' - - docformatter ; 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' - - 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.11' + - tomli ; python_full_version <= '3.11' and extra == 'toml' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl name: msgpack version: 1.2.1 sha256: d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl - name: chardet - version: 7.4.3 - sha256: acc46d1b8b7d5783216afe15db56d1c179b9a40e5a1558bc13164c4fd20674c4 - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl name: simple-websocket version: 1.1.0 @@ -8762,6 +8627,25 @@ packages: - pytest-cov ; extra == 'dev' - sphinx ; extra == 'docs' requires_python: '>=3.6' +- 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 + name: aiohttp + version: 3.14.3 + sha256: 543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42 + requires_dist: + - aiohappyeyeballs>=2.5.0 + - aiosignal>=1.4.0 + - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' + - attrs>=17.3.0 + - frozenlist>=1.1.1 + - multidict>=4.5,<7.0 + - propcache>=0.2.0 + - typing-extensions>=4.4 ; python_full_version < '3.13' + - yarl>=1.17.0,<2.0 + - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - 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/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl name: contourpy version: 1.3.3 @@ -8787,6 +8671,21 @@ packages: - pytest-xdist ; extra == 'test-no-images' - wurlitzer ; extra == 'test-no-images' requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/53/a2/69acfe84ec1f32930e801a5782a07fc5c79c8c6599a507b806d859d5da8e/matplotlib-3.11.1-cp314-cp314-macosx_11_0_arm64.whl + name: matplotlib + version: 3.11.1 + sha256: ac104be2768ffdd8655db9e71b768cbb45f2b9aa7b450cf1595e8f65d3822319 + requires_dist: + - contourpy>=1.0.1 + - cycler>=0.10 + - fonttools>=4.28.2 + - kiwisolver>=1.3.1 + - numpy>=1.25 + - packaging>=20.0 + - pillow>=9 + - pyparsing>=3 + - python-dateutil>=2.7 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl name: pluggy version: 1.6.0 @@ -8798,25 +8697,6 @@ packages: - pytest-benchmark ; extra == 'testing' - coverage ; extra == 'testing' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl - name: aiohttp - version: 3.14.1 - sha256: 27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2 - requires_dist: - - aiohappyeyeballs>=2.5.0 - - aiosignal>=1.4.0 - - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' - - attrs>=17.3.0 - - frozenlist>=1.1.1 - - multidict>=4.5,<7.0 - - propcache>=0.2.0 - - typing-extensions>=4.4 ; python_full_version < '3.13' - - yarl>=1.17.0,<2.0 - - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - 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/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl name: ipywidgets version: 8.1.8 @@ -8838,125 +8718,20 @@ packages: version: 1.2.1 sha256: 5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - name: pandas - version: 3.0.3 - sha256: 9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a - requires_dist: - - numpy>=1.26.0 ; python_full_version < '3.14' - - numpy>=2.3.3 ; python_full_version >= '3.14' - - python-dateutil>=2.8.2 - - tzdata ; sys_platform == 'win32' - - tzdata ; sys_platform == 'emscripten' - - hypothesis>=6.116.0 ; extra == 'test' - - pytest>=8.3.4 ; extra == 'test' - - pytest-xdist>=3.6.1 ; extra == 'test' - - pyarrow>=13.0.0 ; extra == 'pyarrow' - - bottleneck>=1.4.2 ; extra == 'performance' - - numba>=0.60.0 ; extra == 'performance' - - numexpr>=2.10.2 ; extra == 'performance' - - scipy>=1.14.1 ; extra == 'computation' - - xarray>=2024.10.0 ; extra == 'computation' - - fsspec>=2024.10.0 ; extra == 'fss' - - s3fs>=2024.10.0 ; extra == 'aws' - - gcsfs>=2024.10.0 ; extra == 'gcp' - - odfpy>=1.4.1 ; extra == 'excel' - - openpyxl>=3.1.5 ; extra == 'excel' - - python-calamine>=0.3.0 ; extra == 'excel' - - pyxlsb>=1.0.10 ; extra == 'excel' - - xlrd>=2.0.1 ; extra == 'excel' - - xlsxwriter>=3.2.0 ; extra == 'excel' - - pyarrow>=13.0.0 ; extra == 'parquet' - - pyarrow>=13.0.0 ; extra == 'feather' - - pyiceberg>=0.8.1 ; extra == 'iceberg' - - tables>=3.10.1 ; extra == 'hdf5' - - pyreadstat>=1.2.8 ; extra == 'spss' - - sqlalchemy>=2.0.36 ; extra == 'postgresql' - - psycopg2>=2.9.10 ; extra == 'postgresql' - - adbc-driver-postgresql>=1.2.0 ; extra == 'postgresql' - - sqlalchemy>=2.0.36 ; extra == 'mysql' - - pymysql>=1.1.1 ; extra == 'mysql' - - sqlalchemy>=2.0.36 ; extra == 'sql-other' - - adbc-driver-postgresql>=1.2.0 ; extra == 'sql-other' - - adbc-driver-sqlite>=1.2.0 ; extra == 'sql-other' - - beautifulsoup4>=4.12.3 ; extra == 'html' - - html5lib>=1.1 ; extra == 'html' - - lxml>=5.3.0 ; extra == 'html' - - lxml>=5.3.0 ; extra == 'xml' - - matplotlib>=3.9.3 ; extra == 'plot' - - jinja2>=3.1.5 ; extra == 'output-formatting' - - tabulate>=0.9.0 ; extra == 'output-formatting' - - pyqt5>=5.15.9 ; extra == 'clipboard' - - qtpy>=2.4.2 ; extra == 'clipboard' - - zstandard>=0.23.0 ; extra == 'compression' - - pytz>=2020.1 ; extra == 'timezone' - - adbc-driver-postgresql>=1.2.0 ; extra == 'all' - - adbc-driver-sqlite>=1.2.0 ; extra == 'all' - - beautifulsoup4>=4.12.3 ; extra == 'all' - - bottleneck>=1.4.2 ; extra == 'all' - - fastparquet>=2024.11.0 ; extra == 'all' - - fsspec>=2024.10.0 ; extra == 'all' - - gcsfs>=2024.10.0 ; extra == 'all' - - html5lib>=1.1 ; extra == 'all' - - hypothesis>=6.116.0 ; extra == 'all' - - jinja2>=3.1.5 ; extra == 'all' - - lxml>=5.3.0 ; extra == 'all' - - matplotlib>=3.9.3 ; extra == 'all' - - numba>=0.60.0 ; extra == 'all' - - numexpr>=2.10.2 ; extra == 'all' - - odfpy>=1.4.1 ; extra == 'all' - - openpyxl>=3.1.5 ; extra == 'all' - - psycopg2>=2.9.10 ; extra == 'all' - - pyarrow>=13.0.0 ; extra == 'all' - - pyiceberg>=0.8.1 ; extra == 'all' - - pymysql>=1.1.1 ; extra == 'all' - - pyqt5>=5.15.9 ; extra == 'all' - - pyreadstat>=1.2.8 ; extra == 'all' - - pytest>=8.3.4 ; extra == 'all' - - pytest-xdist>=3.6.1 ; extra == 'all' - - python-calamine>=0.3.0 ; extra == 'all' - - pytz>=2020.1 ; extra == 'all' - - pyxlsb>=1.0.10 ; extra == 'all' - - qtpy>=2.4.2 ; extra == 'all' - - scipy>=1.14.1 ; extra == 'all' - - s3fs>=2024.10.0 ; extra == 'all' - - sqlalchemy>=2.0.36 ; extra == 'all' - - tables>=3.10.1 ; extra == 'all' - - tabulate>=0.9.0 ; extra == 'all' - - xarray>=2024.10.0 ; extra == 'all' - - xlrd>=2.0.1 ; extra == 'all' - - xlsxwriter>=3.2.0 ; extra == 'all' - - zstandard>=0.23.0 ; extra == 'all' - requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl - name: coverage - version: 7.14.3 - sha256: 92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3 - requires_dist: - - tomli ; python_full_version <= '3.11' and extra == 'toml' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/59/2d/d741fbbbcba7eb6f9f1a829373c558114d59cb882b95f941aa0dc060861f/plumbum-2.0.1-py3-none-any.whl - name: plumbum - version: 2.0.1 - sha256: 27a454980f91689aae8f18242a36daaf2636219171cf0e6a849744aa1d6fff85 - requires_dist: - - typing-extensions ; python_full_version < '3.13' - - paramiko ; extra == 'ssh' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl - name: frozenlist - version: 1.8.0 - sha256: 3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0 - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl - name: mkdocs-material-extensions - version: 1.3.1 - sha256: adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31 - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/5b/69/93b34728cc386efdde0c342f8c680b9187dea7beb7adaf6b58a0713be101/mpld3-0.5.12-py3-none-any.whl - name: mpld3 - version: 0.5.12 - sha256: bea31799a4041029a906f53f2662bbf1c49903e0c0bc712b412354158ec7cf54 +- pypi: https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl + name: frozenlist + version: 1.8.0 + sha256: 3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl + name: mkdocs-material-extensions + version: 1.3.1 + sha256: adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31 + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/5b/69/93b34728cc386efdde0c342f8c680b9187dea7beb7adaf6b58a0713be101/mpld3-0.5.12-py3-none-any.whl + name: mpld3 + version: 0.5.12 + sha256: bea31799a4041029a906f53f2662bbf1c49903e0c0bc712b412354158ec7cf54 requires_dist: - jinja2 - matplotlib @@ -8967,19 +8742,6 @@ packages: requires_dist: - pyyaml>=3.10 ; extra == 'watchmedo' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl - name: python-engineio - version: 4.13.3 - sha256: 1f60ecaf1358190f0e26c48c578a60428dc02a8f1295bc3dbf53d1b31116821f - 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/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl name: pillow version: 12.3.0 @@ -9011,10 +8773,20 @@ packages: - trove-classifiers>=2024.10.12 ; extra == 'tests' - defusedxml ; extra == 'xmp' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl - name: pip - version: 26.1.2 - sha256: 382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab +- pypi: https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl + name: mkdocstrings + version: 1.0.6 + sha256: 2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7 + requires_dist: + - jinja2>=3.1 + - markdown>=3.6 + - markupsafe>=1.1 + - mkdocs>=1.6 + - mkdocs-autorefs>=1.4 + - pymdown-extensions>=6.3 + - mkdocstrings-crystal>=0.3.4 ; extra == 'crystal' + - mkdocstrings-python-legacy>=0.2.1 ; extra == 'python-legacy' + - mkdocstrings-python>=1.16.2 ; extra == 'python' requires_python: '>=3.10' - 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 name: pydantic-core @@ -9033,54 +8805,6 @@ packages: version: 0.5.2 sha256: d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/61/95/e1f555187f0a95a656e84fcf6657f17b8ab45071529496b1258a7a5c0298/easydynamics-0.8.0-py3-none-any.whl - name: easydynamics - version: 0.8.0 - sha256: cdc49e13e62d07287301cb2ea9d33f4c4e03634fe854e13d23a237fcb6ff340c - requires_dist: - - darkdetect - - easyscience - - ipykernel - - ipympl - - ipython - - ipywidgets - - jupyterlab - - pixi-kernel - - plopp - - pooch - - sympy - - build ; extra == 'dev' - - copier ; 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/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl name: propcache version: 0.5.2 @@ -9091,10 +8815,73 @@ packages: version: 0.5.2 sha256: 97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_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 + name: matplotlib + version: 3.11.1 + sha256: 5af0dcda57d471440a7b5b623e70e0a61003518443d9098f211a96ecfbbc25be + requires_dist: + - contourpy>=1.0.1 + - cycler>=0.10 + - fonttools>=4.28.2 + - kiwisolver>=1.3.1 + - numpy>=1.25 + - packaging>=20.0 + - pillow>=9 + - pyparsing>=3 + - python-dateutil>=2.7 + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl + name: markdown + version: 3.10.3 + sha256: fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea + requires_dist: + - coverage ; extra == 'testing' + - pyyaml ; extra == 'testing' + - mkdocs>=1.6 ; extra == 'docs' + - mkdocs-nature>=0.6 ; extra == 'docs' + - mdx-gh-links>=0.2 ; extra == 'docs' + - mkdocstrings[python]>=0.28.3 ; extra == 'docs' + - mkdocs-gen-files ; extra == 'docs' + - 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 + sha256: 2500ee23497b89332f67e28a2f5f9b9a6871fd6145f71c35f6e24da9f65ac812 + requires_dist: + - numpy>=2 + - pytest ; extra == 'test' + - matplotlib ; extra == 'test' + - beautifulsoup4 ; extra == 'test' + - ipython ; extra == 'test' + - h5py ; extra == 'extra' + - scipy>=1.7.0 ; extra == 'extra' + - graphviz ; extra == 'extra' + - pooch ; extra == 'extra' + - plopp ; extra == 'extra' + - matplotlib ; extra == 'extra' + - scipp[extra] ; extra == 'all' + - ipympl ; extra == 'all' + - ipython ; extra == 'all' + - ipywidgets ; extra == 'all' + - jupyterlab ; extra == 'all' + - jupyterlab-widgets ; extra == 'all' + - jupyter-nbextensions-configurator ; extra == 'all' + - nodejs ; extra == 'all' + - pythreejs ; extra == 'all' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl name: pandas - version: 3.0.3 - sha256: 6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9 + version: 3.0.5 + sha256: c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc requires_dist: - numpy>=1.26.0 ; python_full_version < '3.14' - numpy>=2.3.3 ; python_full_version >= '3.14' @@ -9102,7 +8889,7 @@ packages: - tzdata ; sys_platform == 'win32' - tzdata ; sys_platform == 'emscripten' - hypothesis>=6.116.0 ; extra == 'test' - - pytest>=8.3.4 ; extra == 'test' + - pytest>=8.3.4,<9.1 ; extra == 'test' - pytest-xdist>=3.6.1 ; extra == 'test' - pyarrow>=13.0.0 ; extra == 'pyarrow' - bottleneck>=1.4.2 ; extra == 'performance' @@ -9181,10 +8968,66 @@ packages: - xlsxwriter>=3.2.0 ; extra == 'all' - zstandard>=0.23.0 ; extra == 'all' requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-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 + name: h5py + version: 3.16.0 + sha256: 8c1eff849cdd53cbc73c214c30ebdb6f1bb8b64790b4b4fc36acdb5e43570210 + requires_dist: + - numpy>=1.21.2 + requires_python: '>=3.10' +- 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 + name: frozenlist + version: 1.8.0 + sha256: 494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383 + requires_python: '>=3.9' +- 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 + name: msgpack + 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 + sha256: 424dcec693fbe14e4261212023edefae669274276a285b11276dd1b3cf40cad6 + requires_dist: + - numpy>=2 + - pytest ; extra == 'test' + - matplotlib ; extra == 'test' + - beautifulsoup4 ; extra == 'test' + - ipython ; extra == 'test' + - h5py ; extra == 'extra' + - scipy>=1.7.0 ; extra == 'extra' + - graphviz ; extra == 'extra' + - pooch ; extra == 'extra' + - plopp ; extra == 'extra' + - matplotlib ; extra == 'extra' + - scipp[extra] ; extra == 'all' + - ipympl ; extra == 'all' + - ipython ; extra == 'all' + - ipywidgets ; extra == 'all' + - jupyterlab ; extra == 'all' + - jupyterlab-widgets ; extra == 'all' + - jupyter-nbextensions-configurator ; extra == 'all' + - nodejs ; extra == 'all' + - pythreejs ; extra == 'all' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl name: pandas - version: 3.0.3 - sha256: bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f + version: 3.0.5 + sha256: 80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41 requires_dist: - numpy>=1.26.0 ; python_full_version < '3.14' - numpy>=2.3.3 ; python_full_version >= '3.14' @@ -9192,7 +9035,7 @@ packages: - tzdata ; sys_platform == 'win32' - tzdata ; sys_platform == 'emscripten' - hypothesis>=6.116.0 ; extra == 'test' - - pytest>=8.3.4 ; extra == 'test' + - pytest>=8.3.4,<9.1 ; extra == 'test' - pytest-xdist>=3.6.1 ; extra == 'test' - pyarrow>=13.0.0 ; extra == 'pyarrow' - bottleneck>=1.4.2 ; extra == 'performance' @@ -9271,42 +9114,119 @@ packages: - xlsxwriter>=3.2.0 ; extra == 'all' - zstandard>=0.23.0 ; extra == 'all' requires_python: '>=3.11' -- 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 - sha256: 8c1eff849cdd53cbc73c214c30ebdb6f1bb8b64790b4b4fc36acdb5e43570210 - requires_dist: - - numpy>=1.21.2 - requires_python: '>=3.10' -- 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 - name: frozenlist - version: 1.8.0 - sha256: 494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383 - requires_python: '>=3.9' -- 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 - name: msgpack - version: 1.2.1 - sha256: 020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a +- pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl + name: aiohappyeyeballs + version: 2.7.1 + sha256: 9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl - name: mkdocstrings - version: 1.0.4 - sha256: 63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b +- 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: - - jinja2>=3.1 - - markdown>=3.6 - - markupsafe>=1.1 - - mkdocs>=1.6 - - mkdocs-autorefs>=1.4 - - pymdown-extensions>=6.3 - - mkdocstrings-crystal>=0.3.4 ; extra == 'crystal' - - mkdocstrings-python-legacy>=0.2.1 ; extra == 'python-legacy' - - mkdocstrings-python>=1.16.2 ; extra == 'python' + - 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 + sha256: e1082f5564917649c76fed239117820610516ec10f87735d0338688800a55b34 + requires_dist: + - jinja2 + - pyyaml + - pytest ; extra == 'test' + - pytest-cov ; extra == 'test' +- pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl + name: mike + version: 2.2.0 + sha256: e1f4981c1152eec7c2490a3401142292cc47d686194188416db2648fdfe1d040 + requires_dist: + - jinja2>=2.7 + - mkdocs~=1.0 + - pyparsing>=3.0 + - pyyaml>=5.1 + - pyyaml-env-tag + - verspec + - importlib-metadata ; python_full_version < '3.10' + - importlib-resources ; python_full_version < '3.10' + - coverage ; extra == 'dev' + - flake8-quotes ; extra == 'dev' + - flake8>=3.0 ; extra == 'dev' + - shtab ; extra == 'dev' + - coverage ; extra == 'test' + - flake8-quotes ; extra == 'test' + - flake8>=3.0 ; extra == 'test' + - shtab ; extra == 'test' +- pypi: https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl + name: aiohttp + version: 3.14.3 + sha256: 33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a + requires_dist: + - aiohappyeyeballs>=2.5.0 + - aiosignal>=1.4.0 + - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' + - attrs>=17.3.0 + - frozenlist>=1.1.1 + - multidict>=4.5,<7.0 + - propcache>=0.2.0 + - typing-extensions>=4.4 ; python_full_version < '3.13' + - yarl>=1.17.0,<2.0 + - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - 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/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: fonttools + version: 4.63.0 + sha256: 58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8 + requires_dist: + - lxml>=4.0 ; extra == 'lxml' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'woff' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'woff' + - zopfli>=0.1.4 ; extra == 'woff' + - unicodedata2>=17.0.0 ; python_full_version < '3.15' and extra == 'unicode' + - lz4>=1.7.4.2 ; extra == 'graphite' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'interpolatable' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'interpolatable' + - pycairo ; extra == 'interpolatable' + - matplotlib ; extra == 'plot' + - sympy ; extra == 'symfont' + - xattr ; sys_platform == 'darwin' and extra == 'type1' + - skia-pathops>=0.5.0 ; extra == 'pathops' + - uharfbuzz>=0.45.0 ; extra == 'repacker' + - lxml>=4.0 ; extra == 'all' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'all' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'all' + - zopfli>=0.1.4 ; extra == 'all' + - unicodedata2>=17.0.0 ; python_full_version < '3.15' and extra == 'all' + - lz4>=1.7.4.2 ; extra == 'all' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'all' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'all' + - pycairo ; extra == 'all' + - matplotlib ; extra == 'all' + - sympy ; extra == 'all' + - xattr ; sys_platform == 'darwin' and extra == 'all' + - 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/6e/e9/e2ae007456069dbe01865c69a4203a7ada6f7e337b78fc2f12e51bd3fae7/jupytext-1.19.4-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/7c/c6/76ee9dacedcd8c67d8fa53dd975613733bdd28242a4c41518ff1c8aeaa64/jupytext-1.19.5-py3-none-any.whl name: jupytext - version: 1.19.4 - sha256: 032d4ef4bd2e96addcac780b9b1d6b5a266ca39beceaaca95bfb4f06e0b77029 + version: 1.19.5 + sha256: af22351202171116b986fe1f4f9233a13ca4a85d5eec57a87900ed48521dbae4 requires_dist: - markdown-it-py>=1.0 - mdit-py-plugins @@ -9381,110 +9301,11 @@ packages: - pytest-xdist ; extra == 'test-integration' - bash-kernel ; extra == 'test-ui' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - name: aiohappyeyeballs - version: 2.7.1 - sha256: 9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 - requires_python: '>=3.10' -- 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 - sha256: e1082f5564917649c76fed239117820610516ec10f87735d0338688800a55b34 - requires_dist: - - jinja2 - - pyyaml - - pytest ; extra == 'test' - - pytest-cov ; extra == 'test' -- pypi: https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl - name: mike - version: 2.2.0 - sha256: e1f4981c1152eec7c2490a3401142292cc47d686194188416db2648fdfe1d040 - requires_dist: - - jinja2>=2.7 - - mkdocs~=1.0 - - pyparsing>=3.0 - - pyyaml>=5.1 - - pyyaml-env-tag - - verspec - - importlib-metadata ; python_full_version < '3.10' - - importlib-resources ; python_full_version < '3.10' - - coverage ; extra == 'dev' - - flake8-quotes ; extra == 'dev' - - flake8>=3.0 ; extra == 'dev' - - shtab ; extra == 'dev' - - coverage ; extra == 'test' - - flake8-quotes ; extra == 'test' - - flake8>=3.0 ; extra == 'test' - - shtab ; extra == 'test' -- 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 - sha256: 58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8 - requires_dist: - - lxml>=4.0 ; extra == 'lxml' - - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'woff' - - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'woff' - - zopfli>=0.1.4 ; extra == 'woff' - - unicodedata2>=17.0.0 ; python_full_version < '3.15' and extra == 'unicode' - - lz4>=1.7.4.2 ; extra == 'graphite' - - scipy ; platform_python_implementation != 'PyPy' and extra == 'interpolatable' - - munkres ; platform_python_implementation == 'PyPy' and extra == 'interpolatable' - - pycairo ; extra == 'interpolatable' - - matplotlib ; extra == 'plot' - - sympy ; extra == 'symfont' - - xattr ; sys_platform == 'darwin' and extra == 'type1' - - skia-pathops>=0.5.0 ; extra == 'pathops' - - uharfbuzz>=0.45.0 ; extra == 'repacker' - - lxml>=4.0 ; extra == 'all' - - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'all' - - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'all' - - zopfli>=0.1.4 ; extra == 'all' - - unicodedata2>=17.0.0 ; python_full_version < '3.15' and extra == 'all' - - lz4>=1.7.4.2 ; extra == 'all' - - scipy ; platform_python_implementation != 'PyPy' and extra == 'all' - - munkres ; platform_python_implementation == 'PyPy' and extra == 'all' - - pycairo ; extra == 'all' - - matplotlib ; extra == 'all' - - sympy ; extra == 'all' - - xattr ; sys_platform == 'darwin' and extra == 'all' - - skia-pathops>=0.5.0 ; extra == 'all' - - uharfbuzz>=0.45.0 ; extra == 'all' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - name: annotated-types - version: 0.7.0 - sha256: 1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 - requires_dist: - - typing-extensions>=4.0.0 ; python_full_version < '3.9' - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: yarl - version: 1.24.2 - sha256: 7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c - requires_dist: - - idna>=2.0 - - multidict>=4.0 - - propcache>=0.2.1 +- 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/7c/03/b8cdb625a21f710dfa11bbca1f48fb4057d2c0286975f8b415bf80942c99/matplotlib-3.11.0-cp314-cp314-macosx_11_0_arm64.whl - name: matplotlib - version: 3.11.0 - sha256: 25c2e5455efd8d99f41fb79871a31feb7d301569642e332ec58d72cfe9282bc3 - requires_dist: - - contourpy>=1.0.1 - - cycler>=0.10 - - fonttools>=4.22.0 - - kiwisolver>=1.3.1 - - numpy>=1.25 - - packaging>=20.0 - - pillow>=9 - - pyparsing>=3 - - python-dateutil>=2.7 - requires_python: '>=3.11' -- 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/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl name: contourpy version: 1.3.3 @@ -9510,56 +9331,132 @@ packages: - pytest-xdist ; extra == 'test-no-images' - wurlitzer ; extra == 'test-no-images' requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - name: scipy - version: 1.18.0 - sha256: 8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0 +- pypi: https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl + name: narwhals + version: 2.24.0 + sha256: 42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489 requires_dist: - - numpy>=2.0.0,<2.8 - - pytest>=8.0.0 ; extra == 'test' - - pytest-cov ; extra == 'test' - - pytest-timeout ; extra == 'test' - - pytest-xdist ; extra == 'test' - - asv ; extra == 'test' - - mpmath ; extra == 'test' - - gmpy2 ; extra == 'test' - - threadpoolctl ; extra == 'test' - - scikit-umfpack ; extra == 'test' - - pooch ; extra == 'test' - - hypothesis>=6.30 ; extra == 'test' - - array-api-strict>=2.3.1 ; extra == 'test' - - cython ; extra == 'test' - - meson ; extra == 'test' - - ninja ; sys_platform != 'emscripten' and extra == 'test' - - scipy-doctest>=2.0.0 ; extra == 'test' - - sphinx>=5.0.0,<8.2.0 ; extra == 'doc' - - intersphinx-registry ; extra == 'doc' - - pydata-sphinx-theme>=0.15.2 ; extra == 'doc' - - sphinx-copybutton ; extra == 'doc' - - sphinx-design>=0.4.0 ; extra == 'doc' - - matplotlib>=3.5 ; extra == 'doc' - - numpydoc ; extra == 'doc' - - jupytext ; extra == 'doc' - - myst-nb>=1.2.0 ; extra == 'doc' - - pooch ; extra == 'doc' - - jupyterlite-sphinx>=0.19.1 ; extra == 'doc' - - jupyterlite-pyodide-kernel ; extra == 'doc' - - linkify-it-py ; extra == 'doc' - - tabulate ; extra == 'doc' - - click<8.3.0 ; extra == 'dev' - - spin ; extra == 'dev' - - mypy==1.19.1 ; extra == 'dev' - - pyrefly==0.63.0 ; extra == 'dev' - - typing-extensions ; extra == 'dev' - - types-psutil ; extra == 'dev' - - pycodestyle ; extra == 'dev' - - ruff>=0.12.0 ; extra == 'dev' - - cython-lint>=0.12.2 ; extra == 'dev' - requires_python: '>=3.12' -- pypi: https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl - name: multidict - version: 6.7.1 - sha256: fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19 + - cudf-cu12>=24.10.0 ; sys_platform == 'linux' and extra == 'cudf' + - dask[dataframe]>=2024.8 ; extra == 'dask' + - duckdb>=1.1 ; extra == 'duckdb' + - ibis-framework>=6.0.0 ; extra == 'ibis' + - packaging>=21.3 ; extra == 'ibis' + - pyarrow-hotfix>=0.7 ; extra == 'ibis' + - modin>=0.22.0 ; extra == 'modin' + - pandas>=1.3.4 ; extra == 'pandas' + - polars>=0.20.4 ; extra == 'polars' + - pyarrow>=13.0.0 ; extra == 'pyarrow' + - pyspark>=3.5.0 ; extra == 'pyspark' + - pyspark[connect]>=3.5.0 ; extra == 'pyspark-connect' + - narwhals[duckdb] ; extra == 'sql' + - sqlparse>=0.5.5 ; extra == 'sql' + - sqlframe>=3.22.0,!=3.39.3 ; extra == 'sqlframe' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/7f/7e/8139f0faa2ff7aee2759300061e4b7e5e661775bcd5bf70810d0793af48a/plopp-26.9.0-py3-none-any.whl + name: plopp + version: 26.9.0 + sha256: 6fdff3744b81dce65a30092491bb219e69cbc573ad65c8ace0bb0714d86a97d2 + requires_dist: + - lazy-loader>=0.4 + - matplotlib>=3.8 + - scipp>=25.11.0 + - anywidget>=0.9.0 ; extra == 'all' + - ipympl>0.8.4 ; extra == 'all' + - pythreejs>=2.4.1 ; extra == 'all' + - mpltoolbox>=24.6.0 ; extra == 'all' + - ipywidgets>=8.1.5 ; extra == 'all' + - graphviz>=0.20.3 ; extra == 'all' + - graphviz>=0.20.3 ; extra == 'test' + - h5py>=3.12 ; extra == 'test' + - ipympl>=0.8.4 ; extra == 'test' + - ipywidgets>=8.1.5 ; extra == 'test' + - ipykernel>=6.26,<7 ; extra == 'test' + - mpltoolbox>=24.6.0 ; extra == 'test' + - pandas>=2.2.2 ; extra == 'test' + - plotly>=5.15.0 ; extra == 'test' + - pooch>=1.5 ; extra == 'test' + - pyarrow>=13.0.0 ; extra == 'test' + - pytest>=8.0 ; extra == 'test' + - pythreejs>=2.4.1 ; extra == 'test' + - scipy>=1.10.0 ; extra == 'test' + - xarray>=2024.5.0 ; extra == 'test' + - anywidget>=0.9.0 ; extra == 'test' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl + name: numpy + version: 2.5.2 + sha256: 28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4 + requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/7f/bf/3adcb9b3091b36de729dad91c107179c8c7c51adb2b08c31177bb540bef1/copier-9.17.1-py3-none-any.whl + name: copier + version: 9.17.1 + sha256: f6a48f296e2e536c635213e1000ea71099f9f58f3b464a60f2f0fd386e343628 + requires_dist: + - colorama>=0.4.6 + - dunamai>=1.7.0 + - funcy>=1.17 + - jinja2-ansible-filters>=1.3.1 + - jinja2>=3.1.6 + - packaging>=23.0 + - pathspec>=0.9.0 + - platformdirs>=4.3.6 + - plumbum>=1.6.9 + - pydantic>=2.4.2 + - pygments>=2.7.1 + - pyyaml>=5.3.1 + - questionary>=1.8.1 + - typing-extensions>=4.0.0,<5.0.0 ; python_full_version < '3.11' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + name: scipy + version: 1.18.0 + sha256: 8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0 + requires_dist: + - numpy>=2.0.0,<2.8 + - pytest>=8.0.0 ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-timeout ; extra == 'test' + - pytest-xdist ; extra == 'test' + - asv ; extra == 'test' + - mpmath ; extra == 'test' + - gmpy2 ; extra == 'test' + - threadpoolctl ; extra == 'test' + - scikit-umfpack ; extra == 'test' + - pooch ; extra == 'test' + - hypothesis>=6.30 ; extra == 'test' + - array-api-strict>=2.3.1 ; extra == 'test' + - cython ; extra == 'test' + - meson ; extra == 'test' + - ninja ; sys_platform != 'emscripten' and extra == 'test' + - scipy-doctest>=2.0.0 ; extra == 'test' + - sphinx>=5.0.0,<8.2.0 ; extra == 'doc' + - intersphinx-registry ; extra == 'doc' + - pydata-sphinx-theme>=0.15.2 ; extra == 'doc' + - sphinx-copybutton ; extra == 'doc' + - sphinx-design>=0.4.0 ; extra == 'doc' + - matplotlib>=3.5 ; extra == 'doc' + - numpydoc ; extra == 'doc' + - jupytext ; extra == 'doc' + - myst-nb>=1.2.0 ; extra == 'doc' + - pooch ; extra == 'doc' + - jupyterlite-sphinx>=0.19.1 ; extra == 'doc' + - jupyterlite-pyodide-kernel ; extra == 'doc' + - linkify-it-py ; extra == 'doc' + - tabulate ; extra == 'doc' + - click<8.3.0 ; extra == 'dev' + - spin ; extra == 'dev' + - mypy==1.19.1 ; extra == 'dev' + - pyrefly==0.63.0 ; extra == 'dev' + - typing-extensions ; extra == 'dev' + - types-psutil ; extra == 'dev' + - pycodestyle ; extra == 'dev' + - ruff>=0.12.0 ; extra == 'dev' + - cython-lint>=0.12.2 ; extra == 'dev' + requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl + name: multidict + version: 6.7.1 + sha256: fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19 requires_dist: - typing-extensions>=4.1.0 ; python_full_version < '3.11' requires_python: '>=3.9' @@ -9572,32 +9469,6 @@ packages: - jupyter-notebook-parser>=0.1.4 - tomli>=1.1.0 ; python_full_version < '3.11' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl - name: pre-commit - version: 4.6.0 - sha256: e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b - 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/82/34/bdd77418adb2178a1d59f044bd67bfebb115896e91b840b8a197eb3f4f4e/matplotlib-3.11.0-cp312-cp312-macosx_11_0_arm64.whl - name: matplotlib - version: 3.11.0 - sha256: 0515d495124be3124340e59f164d901ed4484e2246a5b74cfa483cac3b80bd97 - requires_dist: - - contourpy>=1.0.1 - - cycler>=0.10 - - fonttools>=4.22.0 - - kiwisolver>=1.3.1 - - numpy>=1.25 - - packaging>=20.0 - - pillow>=9 - - pyparsing>=3 - - python-dateutil>=2.7 - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/82/f5/2f77f0bc663c13371d1c00ab8e550e2c9b11fec3c63ebf12a8336c0f534e/bumps-1.0.5-py3-none-any.whl name: bumps version: 1.0.5 @@ -9664,11 +9535,25 @@ packages: requires_dist: - nbformat requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - name: ruff - version: 0.15.20 - sha256: 309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b - requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl + name: aiohttp + version: 3.14.3 + sha256: db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b + requires_dist: + - aiohappyeyeballs>=2.5.0 + - aiosignal>=1.4.0 + - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' + - attrs>=17.3.0 + - frozenlist>=1.1.1 + - multidict>=4.5,<7.0 + - propcache>=0.2.0 + - typing-extensions>=4.4 ; python_full_version < '3.13' + - yarl>=1.17.0,<2.0 + - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - 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/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl name: fonttools version: 4.63.0 @@ -9718,6 +9603,21 @@ packages: version: 3.1.2 sha256: 9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/88/89/56649bbaa2fd12e20f3be03dbcc135b0c8676d88bac17977599e3eb442a0/matplotlib-3.11.1-cp312-cp312-win_amd64.whl + name: matplotlib + version: 3.11.1 + sha256: 88a2a27dd9691ae448dfae4b26f59036be90c3c28757edd3553a29559d00859f + requires_dist: + - contourpy>=1.0.1 + - cycler>=0.10 + - fonttools>=4.28.2 + - kiwisolver>=1.3.1 + - numpy>=1.25 + - packaging>=20.0 + - pillow>=9 + - pyparsing>=3 + - python-dateutil>=2.7 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl name: nodeenv version: 1.10.0 @@ -9743,6 +9643,13 @@ packages: - changelist==0.5 ; extra == 'dev' - spin==0.15 ; extra == 'dev' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + name: coverage + version: 7.15.4 + sha256: 899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2 + requires_dist: + - tomli ; python_full_version <= '3.11' and extra == 'toml' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/8f/5e/f1e1dd319e35e962a4e00b33150a8868b6329cc1d19fd533436ba5488f09/uncertainties-3.2.3-py3-none-any.whl name: uncertainties version: 3.2.3 @@ -9758,11 +9665,6 @@ packages: - python-docs-theme ; extra == 'doc' - uncertainties[arrays,doc,test] ; extra == 'all' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl - name: numpy - version: 2.5.0 - sha256: 6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2 - requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl name: paginate version: 0.5.7 @@ -9879,93 +9781,24 @@ packages: requires_dist: - ukkonen ; extra == 'license' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/94/95/7f522393c88313336b20d70fc849555757b2e5febc22b83b3a3f0fd4bce9/matplotlib-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - name: matplotlib - version: 3.11.0 - sha256: be5f93a1d21981bfb802ded0d77a0caa92d4342a47d45754fac77e314a506344 +- pypi: https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl + name: python-socketio + version: 5.16.4 + sha256: 0eb9c7687e7fbf59e60d714fd62afba77dfaf8ef8a06a0bff05a86c351accc2f requires_dist: - - contourpy>=1.0.1 - - cycler>=0.10 - - fonttools>=4.22.0 - - kiwisolver>=1.3.1 - - numpy>=1.25 - - packaging>=20.0 - - pillow>=9 - - pyparsing>=3 - - python-dateutil>=2.7 - requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl - name: bidict - version: 0.23.1 - sha256: 5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5 + - bidict>=0.21.0 + - python-engineio>=4.13.2 + - requests>=2.21.0 ; extra == 'client' + - websocket-client>=0.54.0 ; extra == 'client' + - aiohttp>=3.4 ; extra == 'asyncio-client' + - tox ; extra == 'dev' + - sphinx ; extra == 'docs' + - furo ; extra == 'docs' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl - name: tabulate - version: 0.10.0 - sha256: f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3 - requires_dist: - - wcwidth ; extra == 'widechars' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl - name: kiwisolver - version: 1.5.0 - sha256: ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl - name: scipy - version: 1.18.0 - sha256: 9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9 - requires_dist: - - numpy>=2.0.0,<2.8 - - pytest>=8.0.0 ; extra == 'test' - - pytest-cov ; extra == 'test' - - pytest-timeout ; extra == 'test' - - pytest-xdist ; extra == 'test' - - asv ; extra == 'test' - - mpmath ; extra == 'test' - - gmpy2 ; extra == 'test' - - threadpoolctl ; extra == 'test' - - scikit-umfpack ; extra == 'test' - - pooch ; extra == 'test' - - hypothesis>=6.30 ; extra == 'test' - - array-api-strict>=2.3.1 ; extra == 'test' - - cython ; extra == 'test' - - meson ; extra == 'test' - - ninja ; sys_platform != 'emscripten' and extra == 'test' - - scipy-doctest>=2.0.0 ; extra == 'test' - - sphinx>=5.0.0,<8.2.0 ; extra == 'doc' - - intersphinx-registry ; extra == 'doc' - - pydata-sphinx-theme>=0.15.2 ; extra == 'doc' - - sphinx-copybutton ; extra == 'doc' - - sphinx-design>=0.4.0 ; extra == 'doc' - - matplotlib>=3.5 ; extra == 'doc' - - numpydoc ; extra == 'doc' - - jupytext ; extra == 'doc' - - myst-nb>=1.2.0 ; extra == 'doc' - - pooch ; extra == 'doc' - - jupyterlite-sphinx>=0.19.1 ; extra == 'doc' - - jupyterlite-pyodide-kernel ; extra == 'doc' - - linkify-it-py ; extra == 'doc' - - tabulate ; extra == 'doc' - - click<8.3.0 ; extra == 'dev' - - spin ; extra == 'dev' - - mypy==1.19.1 ; extra == 'dev' - - pyrefly==0.63.0 ; extra == 'dev' - - typing-extensions ; extra == 'dev' - - types-psutil ; extra == 'dev' - - pycodestyle ; extra == 'dev' - - ruff>=0.12.0 ; extra == 'dev' - - cython-lint>=0.12.2 ; extra == 'dev' - requires_python: '>=3.12' -- pypi: https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl - name: chardet - version: 7.4.3 - sha256: 29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl name: setuptools - version: 82.0.1 - sha256: a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb + version: 84.0.0 + sha256: 51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 requires_dist: - pytest>=6,!=8.1.* ; extra == 'test' - virtualenv>=13.0.0 ; extra == 'test' @@ -10008,24 +9841,191 @@ packages: - wheel>=0.43.0 ; extra == 'core' - jaraco-functools>=4 ; extra == 'core' - more-itertools ; extra == 'core' - - pytest-checkdocs>=2.4 ; extra == 'check' + - pytest-checkdocs>=2.14 ; extra == 'check' - pytest-ruff>=0.2.1 ; sys_platform != 'cygwin' and extra == 'check' - ruff>=0.13.0 ; sys_platform != 'cygwin' and extra == 'check' - pytest-cov ; extra == 'cover' - - pytest-enabler>=2.2 ; extra == 'enabler' - - pytest-mypy ; extra == 'type' + - pytest-enabler>=3.4 ; extra == 'enabler' + - pytest-mypy>=1.0.1 ; platform_python_implementation != 'PyPy' and extra == 'type' - mypy==1.18.* ; extra == 'type' - importlib-metadata>=7.0.2 ; python_full_version < '3.10' and extra == 'type' - jaraco-develop>=7.21 ; sys_platform != 'cygwin' and extra == 'type' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - name: pytest-cov - version: 7.1.0 - sha256: a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl + name: pandas + version: 3.0.5 + sha256: cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92 requires_dist: - - coverage[toml]>=7.10.6 - - pluggy>=1.2 - - pytest>=7 + - numpy>=1.26.0 ; python_full_version < '3.14' + - numpy>=2.3.3 ; python_full_version >= '3.14' + - python-dateutil>=2.8.2 + - tzdata ; sys_platform == 'win32' + - tzdata ; sys_platform == 'emscripten' + - hypothesis>=6.116.0 ; extra == 'test' + - pytest>=8.3.4,<9.1 ; extra == 'test' + - pytest-xdist>=3.6.1 ; extra == 'test' + - pyarrow>=13.0.0 ; extra == 'pyarrow' + - bottleneck>=1.4.2 ; extra == 'performance' + - numba>=0.60.0 ; extra == 'performance' + - numexpr>=2.10.2 ; extra == 'performance' + - scipy>=1.14.1 ; extra == 'computation' + - xarray>=2024.10.0 ; extra == 'computation' + - fsspec>=2024.10.0 ; extra == 'fss' + - s3fs>=2024.10.0 ; extra == 'aws' + - gcsfs>=2024.10.0 ; extra == 'gcp' + - odfpy>=1.4.1 ; extra == 'excel' + - openpyxl>=3.1.5 ; extra == 'excel' + - python-calamine>=0.3.0 ; extra == 'excel' + - pyxlsb>=1.0.10 ; extra == 'excel' + - xlrd>=2.0.1 ; extra == 'excel' + - xlsxwriter>=3.2.0 ; extra == 'excel' + - pyarrow>=13.0.0 ; extra == 'parquet' + - pyarrow>=13.0.0 ; extra == 'feather' + - pyiceberg>=0.8.1 ; extra == 'iceberg' + - tables>=3.10.1 ; extra == 'hdf5' + - pyreadstat>=1.2.8 ; extra == 'spss' + - sqlalchemy>=2.0.36 ; extra == 'postgresql' + - psycopg2>=2.9.10 ; extra == 'postgresql' + - adbc-driver-postgresql>=1.2.0 ; extra == 'postgresql' + - sqlalchemy>=2.0.36 ; extra == 'mysql' + - pymysql>=1.1.1 ; extra == 'mysql' + - sqlalchemy>=2.0.36 ; extra == 'sql-other' + - adbc-driver-postgresql>=1.2.0 ; extra == 'sql-other' + - adbc-driver-sqlite>=1.2.0 ; extra == 'sql-other' + - beautifulsoup4>=4.12.3 ; extra == 'html' + - html5lib>=1.1 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'xml' + - matplotlib>=3.9.3 ; extra == 'plot' + - jinja2>=3.1.5 ; extra == 'output-formatting' + - tabulate>=0.9.0 ; extra == 'output-formatting' + - pyqt5>=5.15.9 ; extra == 'clipboard' + - qtpy>=2.4.2 ; extra == 'clipboard' + - zstandard>=0.23.0 ; extra == 'compression' + - pytz>=2020.1 ; extra == 'timezone' + - adbc-driver-postgresql>=1.2.0 ; extra == 'all' + - adbc-driver-sqlite>=1.2.0 ; extra == 'all' + - beautifulsoup4>=4.12.3 ; extra == 'all' + - bottleneck>=1.4.2 ; extra == 'all' + - fastparquet>=2024.11.0 ; extra == 'all' + - fsspec>=2024.10.0 ; extra == 'all' + - gcsfs>=2024.10.0 ; extra == 'all' + - html5lib>=1.1 ; extra == 'all' + - hypothesis>=6.116.0 ; extra == 'all' + - jinja2>=3.1.5 ; extra == 'all' + - lxml>=5.3.0 ; extra == 'all' + - matplotlib>=3.9.3 ; extra == 'all' + - numba>=0.60.0 ; extra == 'all' + - numexpr>=2.10.2 ; extra == 'all' + - odfpy>=1.4.1 ; extra == 'all' + - openpyxl>=3.1.5 ; extra == 'all' + - psycopg2>=2.9.10 ; extra == 'all' + - pyarrow>=13.0.0 ; extra == 'all' + - pyiceberg>=0.8.1 ; extra == 'all' + - pymysql>=1.1.1 ; extra == 'all' + - pyqt5>=5.15.9 ; extra == 'all' + - pyreadstat>=1.2.8 ; extra == 'all' + - pytest>=8.3.4 ; extra == 'all' + - pytest-xdist>=3.6.1 ; extra == 'all' + - python-calamine>=0.3.0 ; extra == 'all' + - pytz>=2020.1 ; extra == 'all' + - pyxlsb>=1.0.10 ; extra == 'all' + - qtpy>=2.4.2 ; extra == 'all' + - scipy>=1.14.1 ; extra == 'all' + - s3fs>=2024.10.0 ; extra == 'all' + - sqlalchemy>=2.0.36 ; extra == 'all' + - tables>=3.10.1 ; extra == 'all' + - tabulate>=0.9.0 ; extra == 'all' + - xarray>=2024.10.0 ; extra == 'all' + - xlrd>=2.0.1 ; extra == 'all' + - xlsxwriter>=3.2.0 ; extra == 'all' + - zstandard>=0.23.0 ; extra == 'all' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl + name: bidict + version: 0.23.1 + sha256: 5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5 + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl + name: tabulate + version: 0.10.0 + sha256: f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3 + requires_dist: + - wcwidth ; extra == 'widechars' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl + name: annotated-types + version: 0.8.0 + sha256: f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl + name: kiwisolver + version: 1.5.0 + sha256: ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl + name: scipy + version: 1.18.0 + sha256: 9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9 + requires_dist: + - numpy>=2.0.0,<2.8 + - pytest>=8.0.0 ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-timeout ; extra == 'test' + - pytest-xdist ; extra == 'test' + - asv ; extra == 'test' + - mpmath ; extra == 'test' + - gmpy2 ; extra == 'test' + - threadpoolctl ; extra == 'test' + - scikit-umfpack ; extra == 'test' + - pooch ; extra == 'test' + - hypothesis>=6.30 ; extra == 'test' + - array-api-strict>=2.3.1 ; extra == 'test' + - cython ; extra == 'test' + - meson ; extra == 'test' + - ninja ; sys_platform != 'emscripten' and extra == 'test' + - scipy-doctest>=2.0.0 ; extra == 'test' + - sphinx>=5.0.0,<8.2.0 ; extra == 'doc' + - intersphinx-registry ; extra == 'doc' + - pydata-sphinx-theme>=0.15.2 ; extra == 'doc' + - sphinx-copybutton ; extra == 'doc' + - sphinx-design>=0.4.0 ; extra == 'doc' + - matplotlib>=3.5 ; extra == 'doc' + - numpydoc ; extra == 'doc' + - jupytext ; extra == 'doc' + - myst-nb>=1.2.0 ; extra == 'doc' + - pooch ; extra == 'doc' + - jupyterlite-sphinx>=0.19.1 ; extra == 'doc' + - jupyterlite-pyodide-kernel ; extra == 'doc' + - linkify-it-py ; extra == 'doc' + - tabulate ; extra == 'doc' + - click<8.3.0 ; extra == 'dev' + - spin ; extra == 'dev' + - mypy==1.19.1 ; extra == 'dev' + - pyrefly==0.63.0 ; extra == 'dev' + - typing-extensions ; extra == 'dev' + - types-psutil ; extra == 'dev' + - pycodestyle ; extra == 'dev' + - ruff>=0.12.0 ; extra == 'dev' + - cython-lint>=0.12.2 ; extra == 'dev' + requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: yarl + version: 1.24.5 + sha256: 66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9 + requires_dist: + - idna>=2.0 + - multidict>=4.0 + - propcache>=0.2.1 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl + name: pytest-cov + version: 7.1.0 + sha256: a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678 + requires_dist: + - coverage[toml]>=7.10.6 + - pluggy>=1.2 + - pytest>=7 - process-tests ; extra == 'testing' - pytest-xdist ; extra == 'testing' - virtualenv ; extra == 'testing' @@ -10045,30 +10045,6 @@ packages: requires_dist: - numpy>=1.21.2 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: aiohttp - version: 3.14.1 - sha256: 634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602 - requires_dist: - - aiohappyeyeballs>=2.5.0 - - aiosignal>=1.4.0 - - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' - - attrs>=17.3.0 - - frozenlist>=1.1.1 - - multidict>=4.5,<7.0 - - propcache>=0.2.0 - - typing-extensions>=4.4 ; python_full_version < '3.13' - - yarl>=1.17.0,<2.0 - - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - 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/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - name: numpy - version: 2.5.0 - sha256: 39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03 - requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl name: gitdb version: 4.0.12 @@ -10076,6 +10052,13 @@ packages: requires_dist: - smmap>=3.0.1,<6 requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + name: coverage + version: 7.15.4 + sha256: ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac + requires_dist: + - tomli ; python_full_version <= '3.11' and extra == 'toml' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl name: frozenlist version: 1.8.0 @@ -10127,14 +10110,10 @@ packages: - pytest-regressions ; extra == 'testing' - pytest-timeout ; extra == 'testing' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl - name: yarl - version: 1.24.2 - sha256: 7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad - requires_dist: - - idna>=2.0 - - multidict>=4.0 - - propcache>=0.2.1 +- 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 @@ -10156,16 +10135,6 @@ packages: requires_dist: - typing-extensions>=4.1.0 ; python_full_version < '3.11' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/aa/1d/72a57601a784f38096362badf6796acb519f47fd1d43071e1224e6960b4f/pydoclint-0.9.0-py3-none-any.whl - name: pydoclint - version: 0.9.0 - sha256: 2c2ba4c33bf5006d9bca333a2cf425ff45c49e837659458f67a042cae24008e1 - requires_dist: - - click>=8.1.0 - - docstring-parser-fork>=0.0.12 - - tomli>=2.0.1 ; python_full_version < '3.11' - - flake8>=4 ; extra == 'flake8' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl name: execnet version: 2.1.2 @@ -10188,20 +10157,35 @@ packages: requires_dist: - typing-extensions>=4.14.1 requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl + name: mkdocs-material + version: 9.7.7 + sha256: 8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f + requires_dist: + - babel>=2.10 + - backrefs>=5.7.post1 + - colorama>=0.4 + - jinja2>=3.1 + - markdown>=3.2 + - mkdocs-material-extensions>=1.3 + - mkdocs>=1.6,<2 + - paginate>=0.5 + - pygments>=2.16 + - pymdown-extensions>=10.2 + - requests>=2.30 + - mkdocs-git-committers-plugin-2>=1.1 ; extra == 'git' + - mkdocs-git-revision-date-localized-plugin>=1.2.4 ; extra == 'git' + - cairosvg>=2.6 ; extra == 'imaging' + - pillow>=10.2 ; extra == 'imaging' + - mkdocs-minify-plugin>=0.7 ; extra == 'recommended' + - mkdocs-redirects>=1.2 ; extra == 'recommended' + - mkdocs-rss-plugin>=1.6 ; extra == 'recommended' + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl name: kiwisolver version: 1.5.0 sha256: f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl - name: pymdown-extensions - version: '11.0' - sha256: fbc4acb641814fa9d17521bbd21a5240ef739a662f11c06330c4b78c93e954d6 - requires_dist: - - markdown>=3.6 - - pyyaml - - pygments>=2.19.1 ; extra == 'extra' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/b0/42/c84efcc1d4caebafb1ecd8be4643f39c85c47a80fe254d92b8b43b1eadaf/h5py-3.16.0-cp312-cp312-macosx_11_0_arm64.whl name: h5py version: 3.16.0 @@ -10209,11 +10193,21 @@ packages: requires_dist: - numpy>=1.21.2 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.whl - name: chardet - version: 7.4.3 - sha256: b95c934b9ad59e2ba8abb9be49df70d3ad1b0d95d864b9fdb7588d4fa8bd921c +- 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 @@ -10257,6 +10251,14 @@ packages: - pytest-timeout ; extra == 'testing' - requests ; extra == 'testing' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl + name: plumbum + version: 2.0.2 + sha256: a865771826c1d2cce0ca8b2a2c48aa0447ed7a518859e389a95f158e9c369547 + requires_dist: + - typing-extensions ; python_full_version < '3.13' + - paramiko ; extra == 'ssh' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl name: watchdog version: 6.0.0 @@ -10280,119 +10282,30 @@ packages: version: 1.8.0 sha256: 34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl - name: pandas - version: 3.0.3 - sha256: c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc +- pypi: https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl + name: numpy + version: 2.5.2 + sha256: 9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323 + requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/bc/35/e2fa913ba2d692ccefbf2e21337fe3fd44200efd9a6ee1ba65766eff7d14/pydoclint-0.9.1-py3-none-any.whl + name: pydoclint + version: 0.9.1 + sha256: 685b4a1c3c852045e4523b61d9c3f789672dfab3a454fe51a9e346c9e21dfcdb requires_dist: - - numpy>=1.26.0 ; python_full_version < '3.14' - - numpy>=2.3.3 ; python_full_version >= '3.14' - - python-dateutil>=2.8.2 - - tzdata ; sys_platform == 'win32' - - tzdata ; sys_platform == 'emscripten' - - hypothesis>=6.116.0 ; extra == 'test' - - pytest>=8.3.4 ; extra == 'test' - - pytest-xdist>=3.6.1 ; extra == 'test' - - pyarrow>=13.0.0 ; extra == 'pyarrow' - - bottleneck>=1.4.2 ; extra == 'performance' - - numba>=0.60.0 ; extra == 'performance' - - numexpr>=2.10.2 ; extra == 'performance' - - scipy>=1.14.1 ; extra == 'computation' - - xarray>=2024.10.0 ; extra == 'computation' - - fsspec>=2024.10.0 ; extra == 'fss' - - s3fs>=2024.10.0 ; extra == 'aws' - - gcsfs>=2024.10.0 ; extra == 'gcp' - - odfpy>=1.4.1 ; extra == 'excel' - - openpyxl>=3.1.5 ; extra == 'excel' - - python-calamine>=0.3.0 ; extra == 'excel' - - pyxlsb>=1.0.10 ; extra == 'excel' - - xlrd>=2.0.1 ; extra == 'excel' - - xlsxwriter>=3.2.0 ; extra == 'excel' - - pyarrow>=13.0.0 ; extra == 'parquet' - - pyarrow>=13.0.0 ; extra == 'feather' - - pyiceberg>=0.8.1 ; extra == 'iceberg' - - tables>=3.10.1 ; extra == 'hdf5' - - pyreadstat>=1.2.8 ; extra == 'spss' - - sqlalchemy>=2.0.36 ; extra == 'postgresql' - - psycopg2>=2.9.10 ; extra == 'postgresql' - - adbc-driver-postgresql>=1.2.0 ; extra == 'postgresql' - - sqlalchemy>=2.0.36 ; extra == 'mysql' - - pymysql>=1.1.1 ; extra == 'mysql' - - sqlalchemy>=2.0.36 ; extra == 'sql-other' - - adbc-driver-postgresql>=1.2.0 ; extra == 'sql-other' - - adbc-driver-sqlite>=1.2.0 ; extra == 'sql-other' - - beautifulsoup4>=4.12.3 ; extra == 'html' - - html5lib>=1.1 ; extra == 'html' - - lxml>=5.3.0 ; extra == 'html' - - lxml>=5.3.0 ; extra == 'xml' - - matplotlib>=3.9.3 ; extra == 'plot' - - jinja2>=3.1.5 ; extra == 'output-formatting' - - tabulate>=0.9.0 ; extra == 'output-formatting' - - pyqt5>=5.15.9 ; extra == 'clipboard' - - qtpy>=2.4.2 ; extra == 'clipboard' - - zstandard>=0.23.0 ; extra == 'compression' - - pytz>=2020.1 ; extra == 'timezone' - - adbc-driver-postgresql>=1.2.0 ; extra == 'all' - - adbc-driver-sqlite>=1.2.0 ; extra == 'all' - - beautifulsoup4>=4.12.3 ; extra == 'all' - - bottleneck>=1.4.2 ; extra == 'all' - - fastparquet>=2024.11.0 ; extra == 'all' - - fsspec>=2024.10.0 ; extra == 'all' - - gcsfs>=2024.10.0 ; extra == 'all' - - html5lib>=1.1 ; extra == 'all' - - hypothesis>=6.116.0 ; extra == 'all' - - jinja2>=3.1.5 ; extra == 'all' - - lxml>=5.3.0 ; extra == 'all' - - matplotlib>=3.9.3 ; extra == 'all' - - numba>=0.60.0 ; extra == 'all' - - numexpr>=2.10.2 ; extra == 'all' - - odfpy>=1.4.1 ; extra == 'all' - - openpyxl>=3.1.5 ; extra == 'all' - - psycopg2>=2.9.10 ; extra == 'all' - - pyarrow>=13.0.0 ; extra == 'all' - - pyiceberg>=0.8.1 ; extra == 'all' - - pymysql>=1.1.1 ; extra == 'all' - - pyqt5>=5.15.9 ; extra == 'all' - - pyreadstat>=1.2.8 ; extra == 'all' - - pytest>=8.3.4 ; extra == 'all' - - pytest-xdist>=3.6.1 ; extra == 'all' - - python-calamine>=0.3.0 ; extra == 'all' - - pytz>=2020.1 ; extra == 'all' - - pyxlsb>=1.0.10 ; extra == 'all' - - qtpy>=2.4.2 ; extra == 'all' - - scipy>=1.14.1 ; extra == 'all' - - s3fs>=2024.10.0 ; extra == 'all' - - sqlalchemy>=2.0.36 ; extra == 'all' - - tables>=3.10.1 ; extra == 'all' - - tabulate>=0.9.0 ; extra == 'all' - - xarray>=2024.10.0 ; extra == 'all' - - xlrd>=2.0.1 ; extra == 'all' - - xlsxwriter>=3.2.0 ; extra == 'all' - - zstandard>=0.23.0 ; extra == 'all' - requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl - name: yarl - version: 1.24.2 - sha256: 0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8 - requires_dist: - - idna>=2.0 - - multidict>=4.0 - - propcache>=0.2.1 + - click>=8.1.0 + - docstring-parser-fork==0.0.16 + - tomli>=2.0.1 ; python_full_version < '3.11' + - flake8>=4 ; extra == 'flake8' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - name: numpy - version: 2.5.0 - sha256: aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a - requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl name: pyproject-hooks version: 1.2.0 sha256: 9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/bf/50/98b146aea0f1cd7531d25f12bea69fa9ce8d1662124f93fb30dc4511b65e/docstring_parser_fork-0.0.14-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/c1/69/427c34e61827818942b48ececd7c892b8f58ba4ce4cfc89ba9fd8dbe8a8d/docstring_parser_fork-0.0.16-py3-none-any.whl name: docstring-parser-fork - version: 0.0.14 - sha256: 4c544f234ef2cc2749a3df32b70c437d77888b1099143a1ad5454452c574b9af + version: 0.0.16 + sha256: c024c97af582d6acf85a14f0b332b3a2ab08626648d251c25e47191f33064386 requires_dist: - docstring-parser[docs] ; extra == 'dev' - docstring-parser[test] ; extra == 'dev' @@ -10405,20 +10318,6 @@ packages: version: 5.0.3 sha256: c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: chardet - version: 7.4.3 - sha256: 6e3bd9f936e04bae89c254262af08d9e5b98f805175ba1e29d454e6cba3107b7 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: yarl - version: 1.24.2 - sha256: e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208 - requires_dist: - - idna>=2.0 - - multidict>=4.0 - - propcache>=0.2.1 - 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 @@ -10458,6 +10357,15 @@ packages: version: 1.5.0 sha256: bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl + name: yarl + version: 1.24.5 + sha256: a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b + requires_dist: + - idna>=2.0 + - multidict>=4.0 + - propcache>=0.2.1 + requires_python: '>=3.10' - 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 @@ -10465,6 +10373,16 @@ 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 + sha256: 318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee + requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/c7/a0/5ff05d1919ca249508012cad89f08fdc6cfbdaa15b41651c5fe6dffaf1d3/dfo_ls-1.6.5-py3-none-any.whl name: dfo-ls version: 1.6.5 @@ -10510,6 +10428,21 @@ packages: - trove-classifiers>=2024.10.12 ; extra == 'tests' - defusedxml ; extra == 'xmp' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/c9/cf/b4ad2cc81b6672ea29ea04e64e350a9f9b493b0908ccd884c67eeff8f7b2/matplotlib-3.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: matplotlib + version: 3.11.1 + sha256: ba8f811b8ddfac493734d6af0b2dff96919d0c28ca0d641858dab4262777c6ea + requires_dist: + - contourpy>=1.0.1 + - cycler>=0.10 + - fonttools>=4.28.2 + - kiwisolver>=1.3.1 + - numpy>=1.25 + - packaging>=20.0 + - pillow>=9 + - pyparsing>=3 + - python-dateutil>=2.7 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl name: pytest-xdist version: 3.8.0 @@ -10551,111 +10484,35 @@ packages: - pytest-xdist ; extra == 'test-no-images' - wurlitzer ; extra == 'test-no-images' requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl - name: pandas - version: 3.0.3 - sha256: b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4 +- pypi: https://files.pythonhosted.org/packages/ce/04/d719a0a36930ecc8dfc801ff340f9dcfc4223f8ca5d39d06b4020032fff8/matplotlib-3.11.1-cp314-cp314-win_amd64.whl + name: matplotlib + version: 3.11.1 + sha256: 9601a1e90be21e4884c53b4f3dc3ee0544654946f9975258d691f1c2e2f119c6 requires_dist: - - numpy>=1.26.0 ; python_full_version < '3.14' - - numpy>=2.3.3 ; python_full_version >= '3.14' - - python-dateutil>=2.8.2 - - tzdata ; sys_platform == 'win32' - - tzdata ; sys_platform == 'emscripten' - - hypothesis>=6.116.0 ; extra == 'test' - - pytest>=8.3.4 ; extra == 'test' - - pytest-xdist>=3.6.1 ; extra == 'test' - - pyarrow>=13.0.0 ; extra == 'pyarrow' - - bottleneck>=1.4.2 ; extra == 'performance' - - numba>=0.60.0 ; extra == 'performance' - - numexpr>=2.10.2 ; extra == 'performance' - - scipy>=1.14.1 ; extra == 'computation' - - xarray>=2024.10.0 ; extra == 'computation' - - fsspec>=2024.10.0 ; extra == 'fss' - - s3fs>=2024.10.0 ; extra == 'aws' - - gcsfs>=2024.10.0 ; extra == 'gcp' - - odfpy>=1.4.1 ; extra == 'excel' - - openpyxl>=3.1.5 ; extra == 'excel' - - python-calamine>=0.3.0 ; extra == 'excel' - - pyxlsb>=1.0.10 ; extra == 'excel' - - xlrd>=2.0.1 ; extra == 'excel' - - xlsxwriter>=3.2.0 ; extra == 'excel' - - pyarrow>=13.0.0 ; extra == 'parquet' - - pyarrow>=13.0.0 ; extra == 'feather' - - pyiceberg>=0.8.1 ; extra == 'iceberg' - - tables>=3.10.1 ; extra == 'hdf5' - - pyreadstat>=1.2.8 ; extra == 'spss' - - sqlalchemy>=2.0.36 ; extra == 'postgresql' - - psycopg2>=2.9.10 ; extra == 'postgresql' - - adbc-driver-postgresql>=1.2.0 ; extra == 'postgresql' - - sqlalchemy>=2.0.36 ; extra == 'mysql' - - pymysql>=1.1.1 ; extra == 'mysql' - - sqlalchemy>=2.0.36 ; extra == 'sql-other' - - adbc-driver-postgresql>=1.2.0 ; extra == 'sql-other' - - adbc-driver-sqlite>=1.2.0 ; extra == 'sql-other' - - beautifulsoup4>=4.12.3 ; extra == 'html' - - html5lib>=1.1 ; extra == 'html' - - lxml>=5.3.0 ; extra == 'html' - - lxml>=5.3.0 ; extra == 'xml' - - matplotlib>=3.9.3 ; extra == 'plot' - - jinja2>=3.1.5 ; extra == 'output-formatting' - - tabulate>=0.9.0 ; extra == 'output-formatting' - - pyqt5>=5.15.9 ; extra == 'clipboard' - - qtpy>=2.4.2 ; extra == 'clipboard' - - zstandard>=0.23.0 ; extra == 'compression' - - pytz>=2020.1 ; extra == 'timezone' - - adbc-driver-postgresql>=1.2.0 ; extra == 'all' - - adbc-driver-sqlite>=1.2.0 ; extra == 'all' - - beautifulsoup4>=4.12.3 ; extra == 'all' - - bottleneck>=1.4.2 ; extra == 'all' - - fastparquet>=2024.11.0 ; extra == 'all' - - fsspec>=2024.10.0 ; extra == 'all' - - gcsfs>=2024.10.0 ; extra == 'all' - - html5lib>=1.1 ; extra == 'all' - - hypothesis>=6.116.0 ; extra == 'all' - - jinja2>=3.1.5 ; extra == 'all' - - lxml>=5.3.0 ; extra == 'all' - - matplotlib>=3.9.3 ; extra == 'all' - - numba>=0.60.0 ; extra == 'all' - - numexpr>=2.10.2 ; extra == 'all' - - odfpy>=1.4.1 ; extra == 'all' - - openpyxl>=3.1.5 ; extra == 'all' - - psycopg2>=2.9.10 ; extra == 'all' - - pyarrow>=13.0.0 ; extra == 'all' - - pyiceberg>=0.8.1 ; extra == 'all' - - pymysql>=1.1.1 ; extra == 'all' - - pyqt5>=5.15.9 ; extra == 'all' - - pyreadstat>=1.2.8 ; extra == 'all' - - pytest>=8.3.4 ; extra == 'all' - - pytest-xdist>=3.6.1 ; extra == 'all' - - python-calamine>=0.3.0 ; extra == 'all' - - pytz>=2020.1 ; extra == 'all' - - pyxlsb>=1.0.10 ; extra == 'all' - - qtpy>=2.4.2 ; extra == 'all' - - scipy>=1.14.1 ; extra == 'all' - - s3fs>=2024.10.0 ; extra == 'all' - - sqlalchemy>=2.0.36 ; extra == 'all' - - tables>=3.10.1 ; extra == 'all' - - tabulate>=0.9.0 ; extra == 'all' - - xarray>=2024.10.0 ; extra == 'all' - - xlrd>=2.0.1 ; extra == 'all' - - xlsxwriter>=3.2.0 ; extra == 'all' - - zstandard>=0.23.0 ; extra == 'all' + - contourpy>=1.0.1 + - cycler>=0.10 + - fonttools>=4.28.2 + - kiwisolver>=1.3.1 + - numpy>=1.25 + - packaging>=20.0 + - pillow>=9 + - pyparsing>=3 + - python-dateutil>=2.7 requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl + name: yarl + version: 1.24.5 + sha256: 5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad + requires_dist: + - idna>=2.0 + - multidict>=4.0 + - propcache>=0.2.1 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl name: colorama 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 @@ -10669,6 +10526,43 @@ packages: name: funcy version: '2.0' sha256: 53df23c8bb1651b12f095df764bfb057935d49537a56de211b098f4c79614bb0 +- 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 + name: aiohttp + version: 3.14.3 + sha256: 18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147 + requires_dist: + - aiohappyeyeballs>=2.5.0 + - aiosignal>=1.4.0 + - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' + - attrs>=17.3.0 + - frozenlist>=1.1.1 + - multidict>=4.5,<7.0 + - propcache>=0.2.0 + - typing-extensions>=4.4 ; python_full_version < '3.13' + - yarl>=1.17.0,<2.0 + - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - 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/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl + name: pymdown-extensions + version: 11.0.1 + sha256: db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2 + requires_dist: + - markdown>=3.6 + - pyyaml + - pygments>=2.19.1 ; extra == 'extra' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: yarl + version: 1.24.5 + sha256: f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9 + requires_dist: + - idna>=2.0 + - multidict>=4.0 + - propcache>=0.2.1 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl name: pycodestyle version: 2.14.0 @@ -10705,17 +10599,66 @@ packages: - trove-classifiers>=2024.10.12 ; extra == 'tests' - defusedxml ; extra == 'xmp' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - name: cfgv - version: 3.5.0 - sha256: a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz - name: watchdog - version: 6.0.0 - sha256: 9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282 +- 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: - - pyyaml>=3.10 ; extra == 'watchmedo' + - 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 + sha256: a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz + name: watchdog + version: 6.0.0 + sha256: 9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282 + requires_dist: + - pyyaml>=3.10 ; extra == 'watchmedo' requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl name: watchdog @@ -10724,18 +10667,6 @@ packages: requires_dist: - pyyaml>=3.10 ; extra == 'watchmedo' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - name: typing-inspection - version: 0.4.2 - sha256: 4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 - requires_dist: - - typing-extensions>=4.12.0 - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl - name: numpy - version: 2.5.0 - sha256: c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be - requires_python: '>=3.12' - 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 @@ -10782,47 +10713,6 @@ packages: - ruff>=0.12.0 ; extra == 'dev' - cython-lint>=0.12.2 ; extra == 'dev' requires_python: '>=3.12' -- pypi: https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl - name: markdown - version: 3.10.2 - sha256: e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36 - requires_dist: - - coverage ; extra == 'testing' - - pyyaml ; extra == 'testing' - - mkdocs>=1.6 ; extra == 'docs' - - mkdocs-nature>=0.6 ; extra == 'docs' - - mdx-gh-links>=0.2 ; extra == 'docs' - - mkdocstrings[python]>=0.28.3 ; extra == 'docs' - - mkdocs-gen-files ; extra == 'docs' - - mkdocs-section-index ; extra == 'docs' - - mkdocs-literate-nav ; extra == 'docs' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl - name: coverage - version: 7.14.3 - sha256: 7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d - requires_dist: - - tomli ; python_full_version <= '3.11' and extra == 'toml' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl - name: aiohttp - version: 3.14.1 - sha256: 2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271 - requires_dist: - - aiohappyeyeballs>=2.5.0 - - aiosignal>=1.4.0 - - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' - - attrs>=17.3.0 - - frozenlist>=1.1.1 - - multidict>=4.5,<7.0 - - propcache>=0.2.0 - - typing-extensions>=4.4 ; python_full_version < '3.13' - - yarl>=1.17.0,<2.0 - - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - 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/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl name: multidict version: 6.7.1 @@ -10830,35 +10720,82 @@ packages: requires_dist: - typing-extensions>=4.1.0 ; python_full_version < '3.11' requires_python: '>=3.9' -- 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 +- 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 + name: scipp + version: 26.8.0 + sha256: b8f73e8d345cad610c2aa0d1fd23ddd409de949c0e58bb16c14012a73f62da70 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/e5/2f/a58a4443a4d052a4ea77557478336aefc26c7981f6408d37adba763aa758/matplotlib-3.11.0-cp312-cp312-win_amd64.whl - name: matplotlib - version: 3.11.0 - sha256: ac6f1ef39f3d0f9e2463303013094992cdbe0f85f43bc54155bc472b2042768e + - numpy>=2 + - pytest ; extra == 'test' + - matplotlib ; extra == 'test' + - beautifulsoup4 ; extra == 'test' + - ipython ; extra == 'test' + - h5py ; extra == 'extra' + - scipy>=1.7.0 ; extra == 'extra' + - graphviz ; extra == 'extra' + - pooch ; extra == 'extra' + - plopp ; extra == 'extra' + - matplotlib ; extra == 'extra' + - scipp[extra] ; extra == 'all' + - ipympl ; extra == 'all' + - ipython ; extra == 'all' + - ipywidgets ; extra == 'all' + - jupyterlab ; extra == 'all' + - jupyterlab-widgets ; extra == 'all' + - jupyter-nbextensions-configurator ; extra == 'all' + - nodejs ; extra == 'all' + - pythreejs ; extra == 'all' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/e6/90/90a65e6ae1b6e66183b48874d32509fc306c994beecc7924a6fa3d9f8955/easyscience-2.5.1-py3-none-any.whl + name: easyscience + version: 2.5.1 + sha256: e44c3efb10f8e040ba88523c6ba58e131e3174721f24801d036fdc5a24e622f2 requires_dist: - - contourpy>=1.0.1 - - cycler>=0.10 - - fonttools>=4.22.0 - - kiwisolver>=1.3.1 - - numpy>=1.25 - - packaging>=20.0 - - pillow>=9 - - pyparsing>=3 - - python-dateutil>=2.7 + - asteval + - bumps>=1.0.4 + - dfo-ls + - lmfit + - numpy + - scipp + - build ; extra == 'dev' + - copier ; extra == 'dev' + - docstripy ; extra == 'dev' + - format-docstring ; extra == 'dev' + - gitpython ; extra == 'dev' + - interrogate ; extra == 'dev' + - ipykernel ; extra == 'dev' + - ipympl ; extra == 'dev' + - ipython ; extra == 'dev' + - ipywidgets ; extra == 'dev' + - jinja2 ; extra == 'dev' + - jupyterlab ; extra == 'dev' + - jupyterquiz ; extra == 'dev' + - jupytext ; extra == 'dev' + - matplotlib ; 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' + - pooch ; 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.11' -- pypi: https://files.pythonhosted.org/packages/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: chardet - version: 7.4.3 - sha256: 9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl name: cycler version: 0.12.1 @@ -10877,24 +10814,14 @@ packages: version: 1.5.0 sha256: 80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl - name: aiohttp - version: 3.14.1 - sha256: 07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983 +- 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 + sha256: 9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c requires_dist: - - aiohappyeyeballs>=2.5.0 - - aiosignal>=1.4.0 - - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' - - attrs>=17.3.0 - - frozenlist>=1.1.1 - - multidict>=4.5,<7.0 - - propcache>=0.2.0 - - typing-extensions>=4.4 ; python_full_version < '3.13' - - yarl>=1.17.0,<2.0 - - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - 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' + - idna>=2.0 + - multidict>=4.0 + - propcache>=0.2.1 requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/eb/be/b257e12f9710819fde40adc972578bee6b72c5992da1bc8369bef2597756/nbmake-1.5.5-py3-none-any.whl name: nbmake @@ -10907,55 +10834,85 @@ packages: - pygments>=2.7.3 - pytest>=6.1.0 requires_python: '>=3.8.0' -- pypi: https://files.pythonhosted.org/packages/eb/d4/28a48cb4d7b77a33d4d7633de13c54cb0331d9fa71accb3ecbad20ce7196/plopp-26.6.0-py3-none-any.whl - name: plopp - version: 26.6.0 - sha256: 4bc54c80fa0926fe80da6ae34878a9afcc12f356bce406046fe4eb44b3a4a66e +- pypi: https://files.pythonhosted.org/packages/eb/f8/6d0c312c8d9738e7d9677f09fe5c986b3239e651a7b73a2deb38b65e4a71/matplotlib-3.11.1-cp312-cp312-macosx_11_0_arm64.whl + name: matplotlib + version: 3.11.1 + sha256: 21a67b961a6d597bca54fae826cd20695ba4a6e4d05424a08da6e13e3176fd6b requires_dist: - - lazy-loader>=0.4 - - matplotlib>=3.8 - - scipp>=25.11.0 - - anywidget>=0.9.0 ; extra == 'all' - - ipympl>0.8.4 ; extra == 'all' - - pythreejs>=2.4.1 ; extra == 'all' - - mpltoolbox>=24.6.0 ; extra == 'all' - - ipywidgets>=8.1.0 ; extra == 'all' - - graphviz>=0.20.3 ; extra == 'all' - - graphviz>=0.20.3 ; extra == 'test' - - h5py>=3.12 ; extra == 'test' - - ipympl>=0.8.4 ; extra == 'test' - - ipywidgets>=8.1.0 ; extra == 'test' - - ipykernel>=6.26,<7 ; extra == 'test' - - mpltoolbox>=24.6.0 ; extra == 'test' - - pandas>=2.2.2 ; extra == 'test' - - plotly>=5.15.0 ; extra == 'test' - - pooch>=1.5 ; extra == 'test' - - pyarrow>=13.0.0 ; extra == 'test' - - pytest>=8.0 ; extra == 'test' - - pythreejs>=2.4.1 ; extra == 'test' - - scipy>=1.10.0 ; extra == 'test' - - xarray>=2024.5.0 ; extra == 'test' - - anywidget>=0.9.0 ; extra == 'test' + - contourpy>=1.0.1 + - cycler>=0.10 + - fonttools>=4.28.2 + - kiwisolver>=1.3.1 + - numpy>=1.25 + - packaging>=20.0 + - pillow>=9 + - pyparsing>=3 + - python-dateutil>=2.7 requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl - name: aiohttp - version: 3.14.1 - sha256: af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719 +- pypi: https://files.pythonhosted.org/packages/ed/90/8e7ce41fc38f53d855c03f007df81297563b777f80c5d12a5c48f0455a22/scipp-26.8.0-cp314-cp314-win_amd64.whl + name: scipp + version: 26.8.0 + sha256: 04325f69f5951f17a8e3b9d85ef291e1e165bbf5a5661d404bec8dd627bcc9a2 requires_dist: - - aiohappyeyeballs>=2.5.0 - - aiosignal>=1.4.0 - - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' - - attrs>=17.3.0 - - frozenlist>=1.1.1 - - multidict>=4.5,<7.0 - - propcache>=0.2.0 - - typing-extensions>=4.4 ; python_full_version < '3.13' - - yarl>=1.17.0,<2.0 - - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - 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' + - numpy>=2 + - pytest ; extra == 'test' + - matplotlib ; extra == 'test' + - beautifulsoup4 ; extra == 'test' + - ipython ; extra == 'test' + - h5py ; extra == 'extra' + - scipy>=1.7.0 ; extra == 'extra' + - graphviz ; extra == 'extra' + - pooch ; extra == 'extra' + - plopp ; extra == 'extra' + - matplotlib ; extra == 'extra' + - scipp[extra] ; extra == 'all' + - ipympl ; extra == 'all' + - ipython ; extra == 'all' + - ipywidgets ; extra == 'all' + - jupyterlab ; extra == 'all' + - jupyterlab-widgets ; extra == 'all' + - jupyter-nbextensions-configurator ; extra == 'all' + - nodejs ; extra == 'all' + - pythreejs ; extra == 'all' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl + name: coverage + version: 7.15.4 + sha256: f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425 + requires_dist: + - tomli ; python_full_version <= '3.11' and extra == 'toml' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl + name: gitpython + version: 3.1.59 + sha256: 67a82f537384578643624c8b2c531938a9b82be431663e575dcf638526631d4c + requires_dist: + - gitdb>=4.0.1,<5 + - typing-extensions>=3.10.0.2 ; python_full_version < '3.10' + - coverage[toml] ; extra == 'test' + - basedpyright==1.39.9 ; python_full_version >= '3.9' and sys_platform != 'cygwin' and extra == 'test' + - ddt>=1.1.1,!=1.4.3 ; extra == 'test' + - mock ; python_full_version < '3.8' and extra == 'test' + - mypy==1.18.2 ; python_full_version >= '3.9' and extra == 'test' + - pre-commit ; extra == 'test' + - pytest>=7.3.1 ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-instafail ; extra == 'test' + - pytest-mock ; extra == 'test' + - pytest-sugar ; extra == 'test' + - typing-extensions ; python_full_version < '3.11' and extra == 'test' + - sphinx>=7.4.7,<8 ; extra == 'doc' + - sphinx-rtd-theme ; extra == 'doc' + - sphinx-autodoc-typehints ; extra == 'doc' + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/f0/31/2aaabe7d03f395c7b6d955f09a4d1440a2c49920abea42fde88cacc2ef97/dunamai-1.26.2-py3-none-any.whl + name: dunamai + version: 1.26.2 + sha256: 4234be3a90c3ec13ecf75d94a2248068e5a3018e8112892fcd9f3a1f287c9c32 + requires_dist: + - importlib-metadata>=1.6.0 ; python_full_version < '3.8' + - packaging>=20.9 + requires_python: '>=3.5' - pypi: https://files.pythonhosted.org/packages/f1/16/d905e7f53e661ce2c24686c38048d8e2b750ffc4350009d41c4e6c6c9826/h5py-3.16.0-cp314-cp314-manylinux_2_28_x86_64.whl name: h5py version: 3.16.0 @@ -11010,11 +10967,6 @@ packages: - trove-classifiers>=2024.10.12 ; extra == 'tests' - defusedxml ; extra == 'xmp' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl - name: numpy - version: 2.5.0 - sha256: ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73 - requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl name: darkdetect version: 0.8.0 @@ -11022,6 +10974,11 @@ packages: requires_dist: - pyobjc-framework-cocoa ; sys_platform == 'darwin' and extra == 'macos-listener' requires_python: '>=3.6' +- pypi: https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl + name: pip + version: 26.2.1 + sha256: 71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e + requires_python: '>=3.10' - 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 name: multidict version: 6.7.1 @@ -11029,43 +10986,54 @@ packages: requires_dist: - typing-extensions>=4.1.0 ; python_full_version < '3.11' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/f4/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.0-py3-none-any.whl - name: narwhals - version: 2.23.0 - sha256: 769e7b9ab102c93d8fa019f6b4cd1a657909b04a20bf6210e5a35aae06814ae9 - requires_dist: - - cudf-cu12>=24.10.0 ; sys_platform == 'linux' and extra == 'cudf' - - dask[dataframe]>=2024.8 ; extra == 'dask' - - duckdb>=1.1 ; extra == 'duckdb' - - ibis-framework>=6.0.0 ; extra == 'ibis' - - rich>=12.4.4 ; extra == 'ibis' - - packaging>=21.3 ; extra == 'ibis' - - pyarrow-hotfix>=0.7 ; extra == 'ibis' - - modin>=0.22.0 ; extra == 'modin' - - pandas>=1.3.4 ; extra == 'pandas' - - polars>=0.20.4 ; extra == 'polars' - - pyarrow>=13.0.0 ; extra == 'pyarrow' - - pyspark>=3.5.0 ; extra == 'pyspark' - - pyspark[connect]>=3.5.0 ; extra == 'pyspark-connect' - - narwhals[duckdb] ; extra == 'sql' - - sqlparse>=0.5.5 ; extra == 'sql' - - sqlframe>=3.22.0,!=3.39.3 ; extra == 'sqlframe' +- pypi: https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl + name: numpy + 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 sha256: 27b3b67cf898684e646d569f017cb27046774ad23866cb0bdf51d5f76a46476b requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl + name: aiohttp + version: 3.14.3 + sha256: 8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100 + requires_dist: + - aiohappyeyeballs>=2.5.0 + - aiosignal>=1.4.0 + - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' + - attrs>=17.3.0 + - frozenlist>=1.1.1 + - multidict>=4.5,<7.0 + - propcache>=0.2.0 + - typing-extensions>=4.4 ; python_full_version < '3.13' + - yarl>=1.17.0,<2.0 + - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - 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 sha256: 607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378 requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*' -- pypi: https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl - name: numpy - version: 2.5.0 - sha256: e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c - requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl name: ghp-import version: 2.1.0 @@ -11076,105 +11044,96 @@ packages: - markdown ; extra == 'dev' - flake8 ; extra == 'dev' - wheel ; extra == 'dev' -- pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl - name: plotly - version: 6.8.0 - sha256: 13c5c4a0f70b74cab1913eda0de49b826df5931708eb6f9c3010040614700ec8 - requires_dist: - - narwhals>=1.15.1 - - packaging - - anywidget ; extra == 'dev' - - build ; extra == 'dev' - - colorcet ; extra == 'dev' - - fiona<=1.9.6 ; python_full_version < '3.9' and extra == 'dev' - - geopandas ; extra == 'dev' - - inflect ; extra == 'dev' - - jupyterlab ; extra == 'dev' - - kaleido>=1.3.0 ; extra == 'dev' - - numpy>=1.22 ; extra == 'dev' - - orjson ; extra == 'dev' - - pandas ; extra == 'dev' - - pdfrw ; extra == 'dev' - - pillow ; extra == 'dev' - - plotly-geo ; extra == 'dev' - - polars[timezone] ; extra == 'dev' - - pyarrow ; extra == 'dev' - - pyshp ; extra == 'dev' - - pytest ; extra == 'dev' - - pytz ; extra == 'dev' - - requests ; extra == 'dev' - - ruff==0.11.12 ; extra == 'dev' - - scikit-image ; extra == 'dev' - - scipy ; extra == 'dev' - - shapely ; extra == 'dev' - - statsmodels ; extra == 'dev' - - vaex ; python_full_version < '3.10' and extra == 'dev' - - xarray ; extra == 'dev' - - build ; extra == 'dev-build' - - jupyterlab ; extra == 'dev-build' - - pytest ; extra == 'dev-build' - - requests ; extra == 'dev-build' - - ruff==0.11.12 ; extra == 'dev-build' - - pytest ; extra == 'dev-core' - - requests ; extra == 'dev-core' - - ruff==0.11.12 ; extra == 'dev-core' - - anywidget ; extra == 'dev-optional' - - build ; extra == 'dev-optional' - - colorcet ; extra == 'dev-optional' - - fiona<=1.9.6 ; python_full_version < '3.9' and extra == 'dev-optional' - - geopandas ; extra == 'dev-optional' - - inflect ; extra == 'dev-optional' - - jupyterlab ; extra == 'dev-optional' - - kaleido>=1.3.0 ; extra == 'dev-optional' - - numpy>=1.22 ; extra == 'dev-optional' - - orjson ; extra == 'dev-optional' - - pandas ; extra == 'dev-optional' - - pdfrw ; extra == 'dev-optional' - - pillow ; extra == 'dev-optional' - - plotly-geo ; extra == 'dev-optional' - - polars[timezone] ; extra == 'dev-optional' - - pyarrow ; extra == 'dev-optional' - - pyshp ; extra == 'dev-optional' - - pytest ; extra == 'dev-optional' - - pytz ; extra == 'dev-optional' - - requests ; extra == 'dev-optional' - - ruff==0.11.12 ; extra == 'dev-optional' - - scikit-image ; extra == 'dev-optional' - - scipy ; extra == 'dev-optional' - - shapely ; extra == 'dev-optional' - - statsmodels ; extra == 'dev-optional' - - vaex ; python_full_version < '3.10' and extra == 'dev-optional' - - xarray ; extra == 'dev-optional' - - numpy>=1,<2 ; extra == 'dev-pandas1' - - pandas>=1,<2 ; extra == 'dev-pandas1' - - setuptools<82 ; extra == 'dev-pandas1' - - pandas>=2,<3 ; extra == 'dev-pandas2' - - pandas>=3 ; python_full_version >= '3.11' and extra == 'dev-pandas3' - - numpy>=1.22 ; extra == 'express' - - kaleido>=1.3.0 ; extra == 'kaleido' - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-py3-none-any.whl - name: dunamai - version: 1.26.1 - sha256: 2727d939c5b4257cb01ea404372803b477f5176e5a347c43beaf89cd5072e853 - requires_dist: - - importlib-metadata>=1.6.0 ; python_full_version < '3.8' - - packaging>=20.9 - requires_python: '>=3.5' -- pypi: https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl - name: yarl - version: 1.24.2 - sha256: afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0 +- 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 + name: pandas + version: 3.0.5 + sha256: 9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b requires_dist: - - idna>=2.0 - - multidict>=4.0 - - propcache>=0.2.1 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl - name: ruff - version: 0.15.20 - sha256: a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415 - requires_python: '>=3.7' + - numpy>=1.26.0 ; python_full_version < '3.14' + - numpy>=2.3.3 ; python_full_version >= '3.14' + - python-dateutil>=2.8.2 + - tzdata ; sys_platform == 'win32' + - tzdata ; sys_platform == 'emscripten' + - hypothesis>=6.116.0 ; extra == 'test' + - pytest>=8.3.4,<9.1 ; extra == 'test' + - pytest-xdist>=3.6.1 ; extra == 'test' + - pyarrow>=13.0.0 ; extra == 'pyarrow' + - bottleneck>=1.4.2 ; extra == 'performance' + - numba>=0.60.0 ; extra == 'performance' + - numexpr>=2.10.2 ; extra == 'performance' + - scipy>=1.14.1 ; extra == 'computation' + - xarray>=2024.10.0 ; extra == 'computation' + - fsspec>=2024.10.0 ; extra == 'fss' + - s3fs>=2024.10.0 ; extra == 'aws' + - gcsfs>=2024.10.0 ; extra == 'gcp' + - odfpy>=1.4.1 ; extra == 'excel' + - openpyxl>=3.1.5 ; extra == 'excel' + - python-calamine>=0.3.0 ; extra == 'excel' + - pyxlsb>=1.0.10 ; extra == 'excel' + - xlrd>=2.0.1 ; extra == 'excel' + - xlsxwriter>=3.2.0 ; extra == 'excel' + - pyarrow>=13.0.0 ; extra == 'parquet' + - pyarrow>=13.0.0 ; extra == 'feather' + - pyiceberg>=0.8.1 ; extra == 'iceberg' + - tables>=3.10.1 ; extra == 'hdf5' + - pyreadstat>=1.2.8 ; extra == 'spss' + - sqlalchemy>=2.0.36 ; extra == 'postgresql' + - psycopg2>=2.9.10 ; extra == 'postgresql' + - adbc-driver-postgresql>=1.2.0 ; extra == 'postgresql' + - sqlalchemy>=2.0.36 ; extra == 'mysql' + - pymysql>=1.1.1 ; extra == 'mysql' + - sqlalchemy>=2.0.36 ; extra == 'sql-other' + - adbc-driver-postgresql>=1.2.0 ; extra == 'sql-other' + - adbc-driver-sqlite>=1.2.0 ; extra == 'sql-other' + - beautifulsoup4>=4.12.3 ; extra == 'html' + - html5lib>=1.1 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'xml' + - matplotlib>=3.9.3 ; extra == 'plot' + - jinja2>=3.1.5 ; extra == 'output-formatting' + - tabulate>=0.9.0 ; extra == 'output-formatting' + - pyqt5>=5.15.9 ; extra == 'clipboard' + - qtpy>=2.4.2 ; extra == 'clipboard' + - zstandard>=0.23.0 ; extra == 'compression' + - pytz>=2020.1 ; extra == 'timezone' + - adbc-driver-postgresql>=1.2.0 ; extra == 'all' + - adbc-driver-sqlite>=1.2.0 ; extra == 'all' + - beautifulsoup4>=4.12.3 ; extra == 'all' + - bottleneck>=1.4.2 ; extra == 'all' + - fastparquet>=2024.11.0 ; extra == 'all' + - fsspec>=2024.10.0 ; extra == 'all' + - gcsfs>=2024.10.0 ; extra == 'all' + - html5lib>=1.1 ; extra == 'all' + - hypothesis>=6.116.0 ; extra == 'all' + - jinja2>=3.1.5 ; extra == 'all' + - lxml>=5.3.0 ; extra == 'all' + - matplotlib>=3.9.3 ; extra == 'all' + - numba>=0.60.0 ; extra == 'all' + - numexpr>=2.10.2 ; extra == 'all' + - odfpy>=1.4.1 ; extra == 'all' + - openpyxl>=3.1.5 ; extra == 'all' + - psycopg2>=2.9.10 ; extra == 'all' + - pyarrow>=13.0.0 ; extra == 'all' + - pyiceberg>=0.8.1 ; extra == 'all' + - pymysql>=1.1.1 ; extra == 'all' + - pyqt5>=5.15.9 ; extra == 'all' + - pyreadstat>=1.2.8 ; extra == 'all' + - pytest>=8.3.4 ; extra == 'all' + - pytest-xdist>=3.6.1 ; extra == 'all' + - python-calamine>=0.3.0 ; extra == 'all' + - pytz>=2020.1 ; extra == 'all' + - pyxlsb>=1.0.10 ; extra == 'all' + - qtpy>=2.4.2 ; extra == 'all' + - scipy>=1.14.1 ; extra == 'all' + - s3fs>=2024.10.0 ; extra == 'all' + - sqlalchemy>=2.0.36 ; extra == 'all' + - tables>=3.10.1 ; extra == 'all' + - tabulate>=0.9.0 ; extra == 'all' + - xarray>=2024.10.0 ; extra == 'all' + - xlrd>=2.0.1 ; extra == 'all' + - xlsxwriter>=3.2.0 ; extra == 'all' + - zstandard>=0.23.0 ; extra == 'all' + requires_python: '>=3.11' - 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..406b2019b 100644 --- a/pixi.toml +++ b/pixi.toml @@ -3,14 +3,13 @@ ########### [workspace] - # Supported platforms for the lock file (pixi.lock) platforms = [ 'win-64', # Set minimum supported version for glibc to be 2.35 to ensure packages # like `crysfml` that only have wheels for glibc 2.35+ # (manylinux_2_35_x86_64) are used. - #libc = { family = 'glibc', version = '2.35' } + # libc = { family = 'glibc', version = '2.35' } { platform = 'linux-64', glibc = '2.35' }, # Set minimum supported version for macOS to be 14.0 to ensure packages # like `scipp` that only have wheels for macOS 14.0+ (macosx_14_0_arm64) @@ -45,10 +44,10 @@ python = '3.14.*' # editable installations. [feature.dev.dependencies] -nodejs = '*' # Required for Prettier (non-Python formatting) +nodejs = '*' # Required for Prettier (non-Python formatting) jupyterlab = '*' # Jupyter notebooks -ipython = '*' # Interactive Python shell -pixi-kernel = '*' # Pixi Jupyter kernel +ipython = '*' # Interactive Python shell +pixi-kernel = '*' # Pixi Jupyter kernel [feature.dev.pypi-dependencies] pip = '*' @@ -59,8 +58,8 @@ easydynamics = { path = '.', editable = true, extras = ['dev'] } [feature.user.dependencies] jupyterlab = '*' # Jupyter notebooks -ipython = '*' # Interactive Python shell -pixi-kernel = '*' # Pixi Jupyter kernel +ipython = '*' # Interactive Python shell +pixi-kernel = '*' # Pixi Jupyter kernel [feature.user.pypi-dependencies] pip = '*' @@ -71,7 +70,6 @@ easydynamics = '*' ############## [environments] - # The `default` feature is always included in all environments. # Additional features can be specified per environment. @@ -92,7 +90,6 @@ user = { features = ['py-max', 'user'] } ####### [tasks] - ################## # 🧪 Testing Tasks ################## @@ -100,7 +97,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 +116,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 +134,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,14 +180,16 @@ 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', + # 'notebook-convert', 'notebook-strip', - #'notebook-tweak', + # 'notebook-tweak', ] } ######################## @@ -264,14 +274,17 @@ 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 '{}' +" post-install = { depends-on = [ 'npm-config', 'prettier-install', - #'pre-commit-setup', + # 'pre-commit-setup', ] } ########################## diff --git a/prettierrc.toml b/prettierrc.toml index b98c86eb7..d8a956f5e 100644 --- a/prettierrc.toml +++ b/prettierrc.toml @@ -1,22 +1,24 @@ plugins = [ - "prettier-plugin-toml", # use the TOML plugin + 'prettier-plugin-toml', # use the TOML plugin ] -endOfLine = 'lf' # change line endings to LF -proseWrap = 'always' # change wrapping in Markdown files -semi = false # remove semicolons -singleQuote = true # use single quotes instead of double quotes -tabWidth = 2 # change tab width to 2 spaces -useTabs = false # use spaces instead of tabs +endOfLine = 'lf' # change line endings to LF +proseWrap = 'always' # change wrapping in Markdown files +semi = false # remove semicolons +singleQuote = true # use single quotes instead of double quotes +tabWidth = 2 # change tab width to 2 spaces +useTabs = false # use spaces instead of tabs -printWidth = 79 # wrap lines at 79 characters +printWidth = 79 # wrap lines at 79 characters [[overrides]] -files = ["*.md"] +files = ['*.md'] + [overrides.options] -printWidth = 72 # wrap Markdown files at 72 characters +printWidth = 72 # wrap Markdown files at 72 characters [[overrides]] -files = ["*.yml", "*.yaml"] +files = ['*.yml', '*.yaml'] + [overrides.options] -printWidth = 88 # wrap YAML files at 88 characters +printWidth = 88 # wrap YAML files at 88 characters diff --git a/pyproject.toml b/pyproject.toml index b39261f87..7f1f68d8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ [project] name = 'easydynamics' -dynamic = ['version'] # Use versioningit to manage the version +dynamic = ['version'] # Use versioningit to manage the version description = 'QENS data analysis' authors = [{ name = 'EasyScience contributors' }] readme = 'README.md' @@ -23,53 +23,58 @@ classifiers = [ ] requires-python = '>=3.12' dependencies = [ - 'easyscience', # The base library of the EasyScience framework - 'pooch', # Data downloader + '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 + 'plopp>=26.9.0', # Plotting library 'jupyterlab', # Jupyter notebooks - 'pixi-kernel', # Pixi Jupyter kernel - 'ipykernel', # Jupyter kernel (required for running 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) + 'ipympl', # Matplotlib Jupyter widget backend (%matplotlib widget) + 'IPython', # Interactive Python shell + 'sympy', # Symbolic mathematics (used for expression components) ] [project.optional-dependencies] dev = [ - 'GitPython', # Interact with Git repositories - 'build', # Building the package - 'pre-commit', # Pre-commit hooks - 'jinja2', # Templating - 'nbmake', # Building notebooks - 'nbstripout', # Strip output from notebooks - 'nbqa', # Linting and formatting notebooks - 'pytest', # Testing - 'pytest-cov', # Test coverage - 'pytest-xdist', # Enable parallel testing - 'ruff', # Linting and formatting code - 'radon', # Code complexity and maintainability - 'validate-pyproject[all]', # Validate pyproject.toml - 'versioningit', # Automatic versioning from git tags - 'jupytext', # Jupyter notebook text format support - 'jupyterquiz', # Quizzes in Jupyter notebooks - 'pydoclint', # Docstring linter - 'docstring-parser-fork!=0.0.15', # Parser used by pydoclint. 0.0.15 splits NumPy-style defaults -> DOC105 failures - 'format-docstring', # Docstring formatter - 'docstripy', # Convert docstrings to other formats - 'interrogate', # Docstring coverage checker - 'copier', # Template management - 'mike', # MkDocs: Versioned documentation support - 'mkdocs', # Static site generator - 'mkdocs-material', # Documentation framework on top of MkDocs - 'mkdocs-autorefs', # MkDocs: Auto-references support - 'mkdocs-jupyter', # MkDocs: Jupyter notebook support - 'mkdocs-plugin-inline-svg', # MkDocs: Inline SVG support - 'mkdocs-markdownextradata-plugin', # MkDocs: Markdown extra data support, such as global variables - 'mkdocstrings-python', # MkDocs: Python docstring support - 'pyyaml', # YAML parser - 'spdx-headers', # SPDX license header validation + 'GitPython', # Interact with Git repositories + 'build', # Building the package + 'pre-commit', # Pre-commit hooks + 'jinja2', # Templating + 'nbmake', # Building notebooks + 'nbstripout', # Strip output from notebooks + 'nbqa', # Linting and formatting notebooks + 'pytest', # Testing + 'pytest-cov', # Test coverage + 'pytest-xdist', # Enable parallel testing + 'ruff', # Linting and formatting code + 'radon', # Code complexity and maintainability + 'validate-pyproject[all]', # Validate pyproject.toml + 'versioningit', # Automatic versioning from git tags + 'jupytext', # Jupyter notebook text format support + 'jupyterquiz', # Quizzes in Jupyter notebooks + 'pydoclint', # Docstring linter + 'docstring-parser-fork!=0.0.15', # Parser used by pydoclint. 0.0.15 splits NumPy-style defaults -> DOC105 failures + 'format-docstring', # Docstring formatter + 'docstripy', # Convert docstrings to other formats + 'interrogate', # Docstring coverage checker + 'copier', # Template management + 'mike', # MkDocs: Versioned documentation support + 'mkdocs', # Static site generator + 'mkdocs-material', # Documentation framework on top of MkDocs + 'mkdocs-autorefs', # MkDocs: Auto-references support + 'mkdocs-jupyter', # MkDocs: Jupyter notebook support + 'mkdocs-plugin-inline-svg', # MkDocs: Inline SVG support + 'mkdocs-markdownextradata-plugin', # MkDocs: Markdown extra data support, such as global variables + 'mkdocstrings-python', # MkDocs: Python docstring support + 'pyyaml', # YAML parser + 'spdx-headers', # SPDX license header validation ] [project.urls] @@ -101,7 +106,7 @@ packages = ['src/easydynamics'] allow-direct-references = true [tool.hatch.version] -source = 'versioningit' # Use versioningit to manage the version +source = 'versioningit' # Use versioningit to manage the version ################################ # Configuration for versioningit @@ -117,9 +122,9 @@ source = 'versioningit' # Use versioningit to manage the version # pixi.lock update without any changes to the source code. [tool.versioningit.format] -distance = '{base_version}+dev{distance}' # example: 1.2.3.post4+dev3 -dirty = '{base_version}+dirty{distance}' # example: 0.5.8+dirty3 -distance-dirty = '{base_version}+devdirty{distance}' # example: 0.5.8+devdirty3 +distance = '{base_version}+dev{distance}' # example: 1.2.3.post4+dev3 +dirty = '{base_version}+dirty{distance}' # example: 0.5.8+dirty3 +distance-dirty = '{base_version}+devdirty{distance}' # example: 0.5.8+devdirty3 # Configure how versioningit detects versions from Git # - 'match' ensures it only considers tags starting with 'v' @@ -137,9 +142,9 @@ default-tag = 'v999.0.0' # https://interrogate.readthedocs.io/en/latest/ [tool.interrogate] -fail-under = 0 # Minimum docstring coverage percentage to pass +fail-under = 0 # Minimum docstring coverage percentage to pass verbose = 1 -#exclude = ['src/**/__init__.py'] +# exclude = ['src/**/__init__.py'] ####################################### # Configuration for coverage/pytest-cov @@ -149,13 +154,13 @@ verbose = 1 # https://coverage.readthedocs.io/en/latest/ [tool.coverage.run] -branch = true # Measure branch coverage as well -source = ['src'] # Limit coverage to the source code directory +branch = true # Measure branch coverage as well +source = ['src'] # Limit coverage to the source code directory [tool.coverage.report] show_missing = true # Show missing lines -skip_covered = false # Skip files with 100% coverage in the report -fail_under = 0 # Minimum coverage percentage to pass +skip_covered = false # Skip files with 100% coverage in the report +fail_under = 0 # Minimum coverage percentage to pass ########################## # Configuration for pytest @@ -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'] ######################## @@ -180,74 +188,74 @@ testpaths = ['tests'] exclude = ['tmp'] indent-width = 4 line-length = 99 # See also `max-line-length` in [tool.ruff.lint.pycodestyle] -preview = true # Enable new rules that are not yet stable, like DOC +preview = true # Enable new rules that are not yet stable, like DOC # Formatting options for Ruff [tool.ruff.format] -docstring-code-format = true # Whether to format code snippets in docstrings -docstring-code-line-length = 99 # Line length for code snippets in docstrings -indent-style = 'space' # PEP 8 recommends using spaces over tabs -quote-style = 'single' # But double quotes in docstrings (PEP 8, PEP 257) +docstring-code-format = true # Whether to format code snippets in docstrings +docstring-code-line-length = 99 # Line length for code snippets in docstrings +indent-style = 'space' # PEP 8 recommends using spaces over tabs +quote-style = 'single' # But double quotes in docstrings (PEP 8, PEP 257) # Linting rules to use with Ruff [tool.ruff.lint] select = [ # Various rules - #'C90', # https://docs.astral.sh/ruff/rules/#mccabe-c90 - #'D', # https://docs.astral.sh/ruff/rules/#pydocstyle-d - 'F', # https://docs.astral.sh/ruff/rules/#pyflakes-f - #'FLY', # https://docs.astral.sh/ruff/rules/#flynt-fly - #'FURB', # https://docs.astral.sh/ruff/rules/#refurb-furb - 'I', # https://docs.astral.sh/ruff/rules/#isort-i - #'N', # https://docs.astral.sh/ruff/rules/#pep8-naming-n - 'NPY', # https://docs.astral.sh/ruff/rules/#numpy-specific-rules-npy - #'PGH', # https://docs.astral.sh/ruff/rules/#pygrep-hooks-pgh - 'PERF', # https://docs.astral.sh/ruff/rules/#perflint-perf + # 'C90', # https://docs.astral.sh/ruff/rules/#mccabe-c90 + # 'D', # https://docs.astral.sh/ruff/rules/#pydocstyle-d + 'F', # https://docs.astral.sh/ruff/rules/#pyflakes-f + # 'FLY', # https://docs.astral.sh/ruff/rules/#flynt-fly + # 'FURB', # https://docs.astral.sh/ruff/rules/#refurb-furb + 'I', # https://docs.astral.sh/ruff/rules/#isort-i + # 'N', # https://docs.astral.sh/ruff/rules/#pep8-naming-n + 'NPY', # https://docs.astral.sh/ruff/rules/#numpy-specific-rules-npy + # 'PGH', # https://docs.astral.sh/ruff/rules/#pygrep-hooks-pgh + 'PERF', # https://docs.astral.sh/ruff/rules/#perflint-perf 'RUF', # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf - #'TRY', # https://docs.astral.sh/ruff/rules/#tryceratops-try # overly restrictive - 'UP', # https://docs.astral.sh/ruff/rules/#pyupgrade-up + # 'TRY', # https://docs.astral.sh/ruff/rules/#tryceratops-try # overly restrictive + 'UP', # https://docs.astral.sh/ruff/rules/#pyupgrade-up # pycodestyle (E, W) rules - 'E', # https://docs.astral.sh/ruff/rules/#error-e - 'W', # https://docs.astral.sh/ruff/rules/#warning-w + 'E', # https://docs.astral.sh/ruff/rules/#error-e + 'W', # https://docs.astral.sh/ruff/rules/#warning-w # Pylint (PL) rules - #'PLC', # https://docs.astral.sh/ruff/rules/#convention-plc - #'PLE', # https://docs.astral.sh/ruff/rules/#error-ple - #'PLR', # https://docs.astral.sh/ruff/rules/#refactor-plr - #'PLW', # https://docs.astral.sh/ruff/rules/#warning-plw # Good to enable + # 'PLC', # https://docs.astral.sh/ruff/rules/#convention-plc + # 'PLE', # https://docs.astral.sh/ruff/rules/#error-ple + # 'PLR', # https://docs.astral.sh/ruff/rules/#refactor-plr + # 'PLW', # https://docs.astral.sh/ruff/rules/#warning-plw # Good to enable # flake8 rules - 'A', # https://docs.astral.sh/ruff/rules/#flake8-builtins-a - 'ANN', # https://docs.astral.sh/ruff/rules/#flake8-annotations-ann - 'ARG', # https://docs.astral.sh/ruff/rules/#flake8-unused-arguments-arg - 'ASYNC', # https://docs.astral.sh/ruff/rules/#flake8-async-async - 'B', # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b - #'BLE', # https://docs.astral.sh/ruff/rules/#flake8-blind-except-ble # enable when base classes have been merged in + 'A', # https://docs.astral.sh/ruff/rules/#flake8-builtins-a + 'ANN', # https://docs.astral.sh/ruff/rules/#flake8-annotations-ann + 'ARG', # https://docs.astral.sh/ruff/rules/#flake8-unused-arguments-arg + 'ASYNC', # https://docs.astral.sh/ruff/rules/#flake8-async-async + 'B', # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b + # 'BLE', # https://docs.astral.sh/ruff/rules/#flake8-blind-except-ble # enable when base classes have been merged in 'C4', # https://docs.astral.sh/ruff/rules/#flake8-comprehensions-c4 - 'COM', # https://docs.astral.sh/ruff/rules/#flake8-commas-com - 'DTZ', # https://docs.astral.sh/ruff/rules/#flake8-datetimez-dtz - #'EM', # https://docs.astral.sh/ruff/rules/#flake8-errmsg-em # Unsure if I want it - requires all error messages to be rewritten - 'FA', # https://docs.astral.sh/ruff/rules/#flake8-future-annotations-fa - #'FBT', # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt # should eventually be enabled, but may break serialization - 'FIX', # https://docs.astral.sh/ruff/rules/#flake8-fixme-fix - 'G', # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g - 'ICN', # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn - 'INP', # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp - 'ISC', # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc - 'LOG', # https://docs.astral.sh/ruff/rules/#flake8-logging-log - 'PIE', # https://docs.astral.sh/ruff/rules/#flake8-pie-pie - #'PT', # https://docs.astral.sh/ruff/rules/#flake8-pytest-style-pt # Should eventually be enabled, but quite a few errors to fix + 'COM', # https://docs.astral.sh/ruff/rules/#flake8-commas-com + 'DTZ', # https://docs.astral.sh/ruff/rules/#flake8-datetimez-dtz + # 'EM', # https://docs.astral.sh/ruff/rules/#flake8-errmsg-em # Unsure if I want it - requires all error messages to be rewritten + 'FA', # https://docs.astral.sh/ruff/rules/#flake8-future-annotations-fa + # 'FBT', # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt # should eventually be enabled, but may break serialization + 'FIX', # https://docs.astral.sh/ruff/rules/#flake8-fixme-fix + 'G', # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g + 'ICN', # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn + 'INP', # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp + 'ISC', # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc + 'LOG', # https://docs.astral.sh/ruff/rules/#flake8-logging-log + 'PIE', # https://docs.astral.sh/ruff/rules/#flake8-pie-pie + # 'PT', # https://docs.astral.sh/ruff/rules/#flake8-pytest-style-pt # Should eventually be enabled, but quite a few errors to fix 'PTH', # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth 'PYI', # https://docs.astral.sh/ruff/rules/#flake8-pyi-pyi 'RET', # https://docs.astral.sh/ruff/rules/#flake8-return-ret 'RSE', # https://docs.astral.sh/ruff/rules/#flake8-raise-rse - 'S', # https://docs.astral.sh/ruff/rules/#flake8-bandit-s + 'S', # https://docs.astral.sh/ruff/rules/#flake8-bandit-s 'SIM', # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim 'SLF', # https://docs.astral.sh/ruff/rules/#flake8-self-slf - 'SLOT', # https://docs.astral.sh/ruff/rules/#flake8-slots-slot + 'SLOT', # https://docs.astral.sh/ruff/rules/#flake8-slots-slot 'T20', # https://docs.astral.sh/ruff/rules/#flake8-print-t20 - 'TC', # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tc - 'TD', # https://docs.astral.sh/ruff/rules/#flake8-todos-td + 'TC', # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tc + 'TD', # https://docs.astral.sh/ruff/rules/#flake8-todos-td 'TID', # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid ] @@ -255,28 +263,28 @@ select = [ # Ignore specific rules globally ignore = [ - 'COM812', # https://docs.astral.sh/ruff/rules/missing-trailing-comma/ + 'COM812', # https://docs.astral.sh/ruff/rules/missing-trailing-comma/ # The following is replaced by 'D'/[tool.ruff.lint.pydocstyle] and [tool.pydoclint] 'DOC', # https://docs.astral.sh/ruff/rules/#pydoclint-doc # Disable, as [tool.format_docstring] split one-line docstrings into the canonical multi-line layout - 'D200', # https://docs.astral.sh/ruff/rules/unnecessary-multiline-docstring/ + 'D200', # https://docs.astral.sh/ruff/rules/unnecessary-multiline-docstring/ ] # Ignore specific rules in certain files or directories [tool.ruff.lint.per-file-ignores] '*/__init__.py' = [ - 'F401', # re-exports are intentional in __init__.py + 'F401', # re-exports are intentional in __init__.py ] 'tests/**' = [ - 'ANN', # https://docs.astral.sh/ruff/rules/#flake8-annotations-ann - 'D', # https://docs.astral.sh/ruff/rules/#pydocstyle-d - 'DOC', # https://docs.astral.sh/ruff/rules/#pydoclint-doc - 'INP001', # https://docs.astral.sh/ruff/rules/implicit-namespace-package/ - 'S101', # https://docs.astral.sh/ruff/rules/assert/ - 'SLF', # https://docs.astral.sh/ruff/rules/#flake8-self-slf # may want to eventually enable it, but accessing private methods is quite useful in tests + 'ANN', # https://docs.astral.sh/ruff/rules/#flake8-annotations-ann + 'D', # https://docs.astral.sh/ruff/rules/#pydocstyle-d + 'DOC', # https://docs.astral.sh/ruff/rules/#pydoclint-doc + 'INP001', # https://docs.astral.sh/ruff/rules/implicit-namespace-package/ + 'S101', # https://docs.astral.sh/ruff/rules/assert/ + 'SLF', # https://docs.astral.sh/ruff/rules/#flake8-self-slf # may want to eventually enable it, but accessing private methods is quite useful in tests ] 'docs/**' = [ - 'INP001', # https://docs.astral.sh/ruff/rules/implicit-namespace-package/ - 'T201', # https://docs.astral.sh/ruff/rules/print/ + 'INP001', # https://docs.astral.sh/ruff/rules/implicit-namespace-package/ + 'T201', # https://docs.astral.sh/ruff/rules/print/ ] # Specific options for certain rules @@ -298,7 +306,7 @@ max-complexity = 10 # https://peps.python.org/pep-0008/#maximum-line-length # Use 99 characters as the project-wide maximum for regular code lines. # Use 99 characters for docstrings. -max-line-length = 99 # See also `line-length` in [tool.ruff] +max-line-length = 99 # See also `line-length` in [tool.ruff] max-doc-length = 99 [tool.ruff.lint.pydocstyle] @@ -325,7 +333,7 @@ max-positional-args = 6 # the parameter declarations in the code (in function's signature). [tool.pydoclint] -#exclude = '\.' # Temporarily disable pydoclint until we are ready +# exclude = '\.' # Temporarily disable pydoclint until we are ready style = 'numpy' check-style-mismatch = true check-arg-defaults = true @@ -339,7 +347,7 @@ allow-init-docstring = true # https://github.com/jsh9/format-docstring [tool.format_docstring] -#exclude = '\.' # Temporarily disable format-docstring until we are ready +# exclude = '\.' # Temporarily disable format-docstring until we are ready docstring_style = 'numpy' line_length = 99 fix_rst_backticks = true diff --git a/src/easydynamics/__init__.py b/src/easydynamics/__init__.py index f4c956e5b..1cac091e4 100644 --- a/src/easydynamics/__init__.py +++ b/src/easydynamics/__init__.py @@ -1,19 +1,97 @@ # 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 DiffusionDampedMittagLeffler +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 MittagLefflerDiffusion +from easydynamics.sample_model import Polynomial +from easydynamics.sample_model import ResolutionModel +from easydynamics.sample_model import SampleModel +from easydynamics.sample_model import StretchedExponential +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', + 'DiffusionDampedMittagLeffler', + 'EasyDynamicsBase', + 'EasyDynamicsModelBase', 'Experiment', + 'Exponential', + 'ExpressionComponent', 'FitBinding', + 'Gaussian', + 'InstrumentModel', + 'JumpTranslationalDiffusion', + 'Lorentzian', + 'MittagLefflerDiffusion', + 'MultiQPosteriorSampler', 'ParameterAnalysis', + 'ParameterLabels', + 'ParameterPosterior', + 'Polynomial', + 'PosteriorSampler', + 'PosteriorSummary', + 'ResolutionModel', + 'SampleModel', + 'StretchedExponential', + '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 ed1a2d44f..48910fb7f 100644 --- a/src/easydynamics/analysis/analysis.py +++ b/src/easydynamics/analysis/analysis.py @@ -1,19 +1,23 @@ # 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 from easyscience.variable import Parameter -from plopp.backends.matplotlib.figure import InteractiveFigure +from plopp.backends.matplotlib.figure import WidgetFigure from scipp import UnitError 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 ############# @@ -200,7 +276,7 @@ def rebin( If rebinning changes Q and ``confirm`` is not ``True``. """ old_Q = np.asarray(self.Q.values) if self.Q is not None else None - old_binned_data = self.experiment._binned_data # noqa: SLF001 + old_binned_data = self.experiment._binned_data # ruff: ignore[private-member-access] self.experiment.rebin(dimensions) new_Q = np.asarray(self.Q.values) if self.Q is not None else None @@ -212,7 +288,7 @@ def rebin( ) if q_changed and not confirm: - self.experiment._binned_data = old_binned_data # noqa: SLF001 + self.experiment._binned_data = old_binned_data # ruff: ignore[private-member-access] raise ValueError( 'Rebinning changed Q values, which requires clearing Q from sample_model and ' 'instrument_model (including resolution and background sub-models). ' @@ -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: @@ -310,7 +391,7 @@ def plot_data_and_model( plot_residuals: bool = False, energy: sc.Variable | None = None, **kwargs: dict[str, Any], - ) -> InteractiveFigure: + ) -> WidgetFigure: """ Plot the experimental data and the model prediction. @@ -345,8 +426,8 @@ def plot_data_and_model( Returns ------- - InteractiveFigure - A Plopp InteractiveFigure containing the plot of the data and model. + WidgetFigure + A Plopp WidgetFigure containing the plot of the data and model. """ verify_Q_index(Q_index=Q_index, Q=self.Q, allow_none=True) if Q_index is not 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), @@ -558,7 +647,7 @@ def plot_parameters( self, names: str | list[str] | None = None, **kwargs: dict[str, Any], - ) -> InteractiveFigure: + ) -> WidgetFigure: """ Plot fitted parameters as a function of Q. @@ -579,8 +668,8 @@ def plot_parameters( Returns ------- - InteractiveFigure - A Plopp InteractiveFigure containing the plot of the parameters. + WidgetFigure + A Plopp WidgetFigure containing the plot of the parameters. """ ds = self.parameters_to_dataset() @@ -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. @@ -756,7 +960,7 @@ def _fit_all_Q_simultaneously(self) -> FitResults: ys = [] ws = [] - # TODO: consider using scipp built-in masking instead of numpy boolean masks, # noqa: FIX002 TD002 TD003 + # TODO: consider using scipp built-in masking instead of numpy boolean masks, # ruff: ignore[line-contains-todo, missing-todo-author, missing-todo-link] for analysis1d in self.analysis_list: x, y, weight, mask = self.experiment.extract_x_y_weights_only_finite( @@ -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. @@ -812,7 +1035,11 @@ def _create_model_array(self, energy: sc.Variable | None = None) -> sc.DataArray """ if energy is None: energy = self.energy - model = sc.array(dims=['Q', 'energy'], values=self.calculate(energy=energy)) + model = sc.array( + dims=['Q', 'energy'], + values=self.calculate(energy=energy), + unit=self.sample_model.y_unit, + ) return sc.DataArray( data=model, coords={'Q': self.Q, 'energy': energy}, @@ -859,7 +1086,7 @@ def _create_components_dataset( energy = self.energy datasets = [ - analysis1d._create_components_dataset_single_Q( # noqa: SLF001 + analysis1d._create_components_dataset_single_Q( # ruff: ignore[private-member-access] add_background=add_background, energy=energy ) for analysis1d in self.analysis_list @@ -873,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 3a66ca6de..0e0acdab2 100644 --- a/src/easydynamics/analysis/analysis1d.py +++ b/src/easydynamics/analysis/analysis1d.py @@ -1,17 +1,21 @@ # 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 from easyscience.variable import DescriptorNumber from easyscience.variable import Parameter -from plopp.backends.matplotlib.figure import InteractiveFigure +from plopp.backends.matplotlib.figure import WidgetFigure 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,32 +257,146 @@ 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, _x: np.ndarray | sc.Variable | None = None, - **kwargs: dict[str, Any], # noqa: ARG002 + **kwargs: dict[str, Any], # ruff: ignore[unused-method-argument] ) -> callable: """ Return self._calculate as a fit function. @@ -294,7 +420,7 @@ def as_fit_function( def fit_function( _x: np.ndarray | sc.Variable | None = None, - **kwargs: dict[str, Any], # noqa: ARG001 + **kwargs: dict[str, Any], # ruff: ignore[unused-function-argument] ) -> np.ndarray: """Fit function.""" return self._calculate() @@ -326,7 +452,7 @@ def plot_data_and_model( plot_residuals: bool = False, energy: sc.Variable | None = None, **kwargs: dict[str, Any], - ) -> InteractiveFigure: + ) -> WidgetFigure: """ Plot the experimental data and the model prediction for the chosen Q index. Optionally also plot the individual components of the model. @@ -350,11 +476,9 @@ def plot_data_and_model( Returns ------- - InteractiveFigure + WidgetFigure 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 @@ -736,6 +887,8 @@ def _build_convolution( convolution_settings=self.convolution_settings, temperature=self.temperature, detailed_balance_settings=self.detailed_balance_settings, + x_unit=self.sample_model.x_unit, + y_unit=self.sample_model.y_unit, ) ############# @@ -854,7 +1007,7 @@ def _to_scipp_array( if energy is None: energy = self._masked_energy return sc.DataArray( - data=sc.array(dims=['energy'], values=values), + data=sc.array(dims=['energy'], values=values, unit=self.sample_model.y_unit), coords={ 'energy': energy, 'Q': self.Q[self.Q_index], 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..6413a963f 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 plopp.backends.matplotlib.figure import WidgetFigure 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,20 +324,95 @@ 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 plot( - self, names: str | list[str] | None = None, **kwargs: dict[str, Any] - ) -> InteractiveFigure: + 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]) -> WidgetFigure: """ Plot the parameters and fit results. @@ -233,7 +425,7 @@ def plot( Returns ------- - InteractiveFigure + WidgetFigure An interactive figure containing the plots of the parameters and fit results. Raises @@ -250,10 +442,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 +455,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 +599,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 +650,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 +812,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..e1187a9c0 --- /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 WidgetFigure + + 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 | WidgetFigure: + """ + 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 | WidgetFigure + 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], + ) -> WidgetFigure: + """ + 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 + ------- + WidgetFigure + 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 fbadba2ad..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. @@ -206,7 +278,7 @@ def _validate_type(self, value: object) -> None: if not isinstance(value, tuple(self._protected_types)): allowed = ', '.join(t.__name__ for t in self._protected_types) raise TypeError( - f'Value must be an instance of type: {allowed}. Got {type(value).__name__} instead.' # noqa: E501 + f'Value must be an instance of type: {allowed}. Got {type(value).__name__} instead.' # ruff: ignore[line-too-long] ) # ------------------------------------------------------------------ @@ -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 ccf37f474..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,14 +244,52 @@ 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. """ - analytical_sample_components = ComponentCollection() - delta_sample_components = ComponentCollection() - numerical_sample_components = ComponentCollection() + 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) for sample_component in self._sample_components: # If delta function, put in delta sample model and go to the @@ -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) # noqa: SLF001 + 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) # noqa: SLF001 + 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 83c0c2351..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__( @@ -96,22 +103,94 @@ def __init__( isinstance(sample_components, (ComponentCollection, ModelComponent)) ): raise TypeError( - f'`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 + f'`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent.' # ruff: ignore[line-too-long] ) if isinstance(sample_components, ModelComponent): - sample_components = ComponentCollection(components=[sample_components]) + sample_components = ComponentCollection( + components=[sample_components], + x_unit=sample_components.x_unit, + y_unit=sample_components.y_unit, + ) self._sample_components = sample_components if resolution_components is not None and not ( isinstance(resolution_components, (ComponentCollection, ModelComponent)) ): raise TypeError( - f'`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 + f'`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent.' # ruff: ignore[line-too-long] ) if isinstance(resolution_components, ModelComponent): - resolution_components = ComponentCollection(components=[resolution_components]) + resolution_components = ComponentCollection( + components=[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 + 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: """ @@ -184,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): @@ -202,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: """ @@ -229,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), @@ -241,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: """ @@ -314,11 +406,15 @@ def sample_components(self, sample_components: ComponentCollection | ModelCompon """ if not isinstance(sample_components, (ComponentCollection, ModelComponent)): raise TypeError( - f'`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 + f'`sample_components` is an instance of {type(sample_components).__name__}, but must be a ComponentCollection or ModelComponent.' # ruff: ignore[line-too-long] ) if isinstance(sample_components, ModelComponent): - sample_components = ComponentCollection(components=[sample_components]) + sample_components = ComponentCollection( + components=[sample_components], + x_unit=sample_components.x_unit, + y_unit=sample_components.y_unit, + ) self._sample_components = sample_components @property @@ -350,12 +446,22 @@ 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( - f'`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent.' # noqa: E501 + f'`resolution_components` is an instance of {type(resolution_components).__name__}, but must be a ComponentCollection or ModelComponent.' # ruff: ignore[line-too-long] ) if isinstance(resolution_components, ModelComponent): - resolution_components = ComponentCollection(components=[resolution_components]) + resolution_components = ComponentCollection( + components=[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 dc257fd36..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) # noqa: SLF001 + 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 # noqa: SLF001 + 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,13 +415,18 @@ 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) is_uniform = np.allclose(energy_diff, energy_diff[0]) if not is_uniform: raise ValueError( - 'Input array `energy` must be uniformly spaced if upsample_factor is not given.' # noqa: E501 + 'Input array `energy` must be uniformly spaced if upsample_factor is not given.' # ruff: ignore[line-too-long] ) energy_dense = 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 27df30315..3beeeb737 100644 --- a/src/easydynamics/experiment/experiment.py +++ b/src/easydynamics/experiment/experiment.py @@ -7,7 +7,7 @@ import numpy as np import plopp as pp import scipp as sc -from plopp.backends.matplotlib.figure import InteractiveFigure +from plopp.backends.matplotlib.figure import WidgetFigure from scipp.io import load_hdf5 as sc_load_hdf5 from scipp.io import save_hdf5 as sc_save_hdf5 @@ -404,7 +404,9 @@ def rebin(self, dimensions: dict[str, int | sc.Variable]) -> None: ) if self._data is None: raise ValueError('No data to rebin. Please load data first.') - binned_data = self._data.copy() + # sc.bin cannot handle multi-dimensional dense data with bin-edge + # coordinates, so convert them to bin centers first. + binned_data = self._convert_to_bin_centers(self._data.copy()) dim_copy = dimensions.copy() for dim, value in dim_copy.items(): if not isinstance(dim, str): @@ -418,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. ' @@ -441,7 +440,7 @@ def plot_data( slicer: bool = False, transpose_axes: bool = False, **kwargs: dict, - ) -> InteractiveFigure: + ) -> WidgetFigure: """ Plot the dataset using plopp: https://scipp.github.io/plopp/. @@ -457,7 +456,7 @@ def plot_data( Returns ------- - InteractiveFigure + WidgetFigure A plot of the data and model. Raises @@ -583,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/__init__.py b/src/easydynamics/sample_model/__init__.py index 07f434a18..4abe6f21c 100644 --- a/src/easydynamics/sample_model/__init__.py +++ b/src/easydynamics/sample_model/__init__.py @@ -7,11 +7,15 @@ DampedHarmonicOscillator, ) from easydynamics.sample_model.components.delta_function import DeltaFunction +from easydynamics.sample_model.components.diffusion_damped_mittag_leffler import ( + DiffusionDampedMittagLeffler, +) from easydynamics.sample_model.components.exponential import Exponential from easydynamics.sample_model.components.expression_component import ExpressionComponent from easydynamics.sample_model.components.gaussian import Gaussian from easydynamics.sample_model.components.lorentzian import Lorentzian from easydynamics.sample_model.components.polynomial import Polynomial +from easydynamics.sample_model.components.stretched_exponential import StretchedExponential from easydynamics.sample_model.components.voigt import Voigt from easydynamics.sample_model.diffusion_model.brownian_translational_diffusion import ( BrownianTranslationalDiffusion, @@ -20,6 +24,9 @@ from easydynamics.sample_model.diffusion_model.jump_translational_diffusion import ( JumpTranslationalDiffusion, ) +from easydynamics.sample_model.diffusion_model.mittag_leffler_diffusion import ( + MittagLefflerDiffusion, +) from easydynamics.sample_model.instrument_model import InstrumentModel from easydynamics.sample_model.resolution_model import ResolutionModel from easydynamics.sample_model.sample_model import SampleModel @@ -31,14 +38,17 @@ 'DampedHarmonicOscillator', 'DeltaFunction', 'DeltaLorentz', + 'DiffusionDampedMittagLeffler', 'Exponential', 'ExpressionComponent', 'Gaussian', 'InstrumentModel', 'JumpTranslationalDiffusion', 'Lorentzian', + 'MittagLefflerDiffusion', 'Polynomial', 'ResolutionModel', 'SampleModel', + 'StretchedExponential', 'Voigt', ] 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/__init__.py b/src/easydynamics/sample_model/components/__init__.py index 940f41a7e..6b1a201dc 100644 --- a/src/easydynamics/sample_model/components/__init__.py +++ b/src/easydynamics/sample_model/components/__init__.py @@ -5,20 +5,26 @@ DampedHarmonicOscillator, ) from easydynamics.sample_model.components.delta_function import DeltaFunction +from easydynamics.sample_model.components.diffusion_damped_mittag_leffler import ( + DiffusionDampedMittagLeffler, +) from easydynamics.sample_model.components.exponential import Exponential from easydynamics.sample_model.components.expression_component import ExpressionComponent from easydynamics.sample_model.components.gaussian import Gaussian from easydynamics.sample_model.components.lorentzian import Lorentzian from easydynamics.sample_model.components.polynomial import Polynomial +from easydynamics.sample_model.components.stretched_exponential import StretchedExponential from easydynamics.sample_model.components.voigt import Voigt __all__ = [ 'DampedHarmonicOscillator', 'DeltaFunction', + 'DiffusionDampedMittagLeffler', 'Exponential', 'ExpressionComponent', 'Gaussian', 'Lorentzian', 'Polynomial', + 'StretchedExponential', 'Voigt', ] 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/diffusion_damped_mittag_leffler.py b/src/easydynamics/sample_model/components/diffusion_damped_mittag_leffler.py new file mode 100644 index 000000000..9e04a60c7 --- /dev/null +++ b/src/easydynamics/sample_model/components/diffusion_damped_mittag_leffler.py @@ -0,0 +1,448 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import numpy as np +import scipp as sc +from easyscience.variable import Parameter + +from easydynamics.sample_model.components.mixins import CreateParametersMixin +from easydynamics.sample_model.components.model_component import ModelComponent +from easydynamics.utils.utils import Numeric + +MINIMUM_ALPHA = 1e-10 # To avoid a vanishing form parameter +MAXIMUM_ALPHA = 1.0 # Above 1 the Mittag-Leffler function is no longer a relaxation function + + +class DiffusionDampedMittagLeffler(CreateParametersMixin, ModelComponent): + r""" + Model of the diffusion-damped Mittag-Leffler relaxation spectrum, Eq. (42) of Hassani et al. + + This is the spectral (energy-domain) form of the multiscale relaxation model used for + intrinsically disordered proteins in + + A. N. Hassani, L. Haris, M. Appel, T. Seydel, A. M. Stadler and G. R. Kneller, *Multiscale + relaxation dynamics and diffusion of myelin basic protein in solution studied by quasielastic + neutron scattering*, J. Chem. Phys. **156**, 025102 (2022), + [doi:10.1063/5.0077100](https://doi.org/10.1063/5.0077100). + + In the time domain the internal dynamics is described by the Mittag-Leffler (ML) relaxation + function $\phi_{ML}(t) = E_\alpha(-(|t|/\tau_R)^\alpha)$ (their Eq. 28), damped by global + translational diffusion, $e^{-\epsilon |t|}$ with $\epsilon = D q^2$ (their Eqs. 38-39). The + Fourier transform of that product is the "generalised Lorentzian" of their Eq. (42), + + $$ \tilde{\phi}^{(\epsilon)}_{ML}(\omega) = \frac{1}{\pi} + \frac{\epsilon(\omega^2+\epsilon^2)^{\alpha/2} + + \omega\sin(\alpha\arg(\epsilon+i|\omega|)) + + \epsilon\cos(\alpha\arg(\epsilon+i|\omega|))} + {(\omega^2+\epsilon^2)\left(\left((\omega^2+\epsilon^2)^{\alpha}+1\right) + (\omega^2+\epsilon^2)^{-\alpha/2} + 2\cos(\alpha\arg(\epsilon+i|\omega|))\right)} $$ + + which this component evaluates as + + $$ I(x) = \frac{A}{\Gamma} \tilde{\phi}^{(\epsilon/\Gamma)}_{ML}\left(\frac{|x|}{\Gamma}\right) + $$ + + where $A$ is the scale factor (``scale``), $\alpha$ is the form parameter (``alpha``), $\Gamma + = \hbar/\tau_R$ is the ML relaxation rate expressed as an energy (``width``) and $\epsilon = + \hbar D q^2$ is the diffusion damping, also expressed as an energy (``damping``). scale has + unit = x_unit * y_unit; width and damping have unit = x_unit; alpha is dimensionless. + + Two remarks on the relation to the printed Eq. (42): + + - Eq. (42) is written for $\tau_R = 1$, i.e. for $\omega$ and $\epsilon$ measured in units of + the relaxation rate. Rescaling $\omega \to \omega/\Gamma$ and $\epsilon \to \epsilon/\Gamma$ + and dividing by $\Gamma$ restores a general relaxation time while leaving the printed + expression intact; that is the form implemented here. + - The $1/\pi$ prefactor is absent from the printed Eq. (42), but is required for the lineshape + to be normalised the way Eq. (41) of the same paper assumes, i.e. + $\int d\omega\, \tilde{\phi}^{(\epsilon)}_{ML}(\omega) = 1$. It is included here, so + ``scale`` is the integrated area of the profile, and the $\alpha \to 1$ limit is a Lorentzian + of area ``scale`` and half width at half maximum ``width + damping``. + + The profile is symmetric about $x = 0$, so there is no center parameter. Because a strictly + positive ``damping`` is enforced, the profile stays regular at $x = 0$, where the undamped ML + spectrum would diverge as $|\omega|^{\alpha - 1}$. + + Examples + -------- + **Creating a diffusion-damped Mittag-Leffler component** + + ```python + import numpy as np + import easydynamics as edyn + + ml = edyn.DiffusionDampedMittagLeffler(scale=1.0, alpha=0.8, width=0.05, damping=0.01) + x = np.linspace(-0.5, 0.5, 200) + values = ml.evaluate(x) + ``` + + **Modifying parameters after construction** + + ```python + import easydynamics as edyn + + ml = edyn.DiffusionDampedMittagLeffler(name='MBP internal dynamics') + ml.scale = 0.9 + ml.alpha = 0.75 + ml.width = 0.02 + ml.damping = 0.005 + ``` + """ + + def __init__( + self, + scale: Numeric = 1.0, + alpha: Numeric = 1.0, + width: Numeric = 1.0, + damping: Numeric = 1.0, + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', + name: str = 'DiffusionDampedMittagLeffler', + display_name: str | None = None, + unique_name: str | None = None, + ) -> None: + """ + Initialize the diffusion-damped Mittag-Leffler component. + + Parameters + ---------- + scale : Numeric, default=1.0 + Scale factor in front of the normalised profile, i.e. the integrated area of the + profile. Unit is ``x_unit * y_unit``. Must be non-negative. + alpha : Numeric, default=1.0 + Form parameter of the Mittag-Leffler relaxation function. Must lie in (0, 1]. alpha=1 + gives exponential relaxation, and hence a Lorentzian profile, while smaller values give + a broader relaxation rate spectrum. + width : Numeric, default=1.0 + Mittag-Leffler relaxation rate ``hbar / tau_R`` in x_unit. Must be strictly positive. + damping : Numeric, default=1.0 + Diffusion damping ``hbar * D * q**2`` in x_unit. Must be strictly positive; it is what + keeps the profile finite at x=0. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis. width and damping are stored in this unit. scale_unit = x_unit * + y_unit. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the y-axis (output). + name : str, default='DiffusionDampedMittagLeffler' + Name of the component. + display_name : str | None, default=None + Display name shown when plotting. Falls back to *name* if None. + unique_name : str | None, default=None + Globally unique identifier. Auto-generated if None. + """ + super().__init__( + x_unit=x_unit, + y_unit=y_unit, + name=name, + display_name=display_name, + unique_name=unique_name, + ) + + self._scale = self._create_scale_parameter( + scale=scale, name=name, x_unit=self.x_unit, y_unit=self.y_unit + ) + self._alpha = self._create_alpha_parameter(alpha=alpha, name=name) + # These methods live in CreateParametersMixin + self._width = self._create_width_parameter(width=width, name=name, x_unit=self.x_unit) + self._damping = self._create_width_parameter( + width=damping, name=name, param_name='damping', x_unit=self.x_unit + ) + + @staticmethod + def _create_scale_parameter( + scale: Numeric, + name: str, + x_unit: str | sc.Unit, + y_unit: str | sc.Unit, + ) -> Parameter: + """ + Create the scale Parameter with unit = x_unit * y_unit. + + Parameters + ---------- + scale : Numeric + Initial scale value. Must be non-negative. + name : str + Base name used to label the Parameter (``name + ' scale'``). + x_unit : str | sc.Unit + X-axis unit. The resulting scale unit is ``x_unit * y_unit``. + y_unit : str | sc.Unit + Y-axis unit. The resulting scale unit is ``x_unit * y_unit``. + + Returns + ------- + Parameter + Configured scale Parameter with ``unit = x_unit * y_unit`` and ``min = 0``. + + Raises + ------ + TypeError + If *scale* is not a numeric type. + ValueError + If *scale* is not finite, or is negative. + """ + if not isinstance(scale, Numeric): + raise TypeError('scale must be a number.') + if not np.isfinite(scale): + raise ValueError('scale must be a finite number.') + if float(scale) < 0: + raise ValueError('scale must be non-negative.') + return Parameter( + name=name + ' scale', + value=float(scale), + unit=str(sc.Unit(x_unit) * sc.Unit(y_unit)), + min=0.0, + ) + + @staticmethod + def _create_alpha_parameter(alpha: Numeric, name: str) -> Parameter: + """ + Create the dimensionless form parameter alpha, bounded to (0, 1]. + + Parameters + ---------- + alpha : Numeric + Initial value of the form parameter. + name : str + Base name used to label the Parameter (``name + ' alpha'``). + + Returns + ------- + Parameter + Configured alpha Parameter with ``unit = 'dimensionless'``. + + Raises + ------ + TypeError + If *alpha* is not a numeric type. + ValueError + If *alpha* is not finite, or does not lie in (0, 1]. + """ + if not isinstance(alpha, Numeric): + raise TypeError('alpha must be a number.') + if not np.isfinite(alpha): + raise ValueError('alpha must be a finite number.') + if not MINIMUM_ALPHA <= float(alpha) <= MAXIMUM_ALPHA: + raise ValueError('alpha must be greater than zero and at most one.') + return Parameter( + name=name + ' alpha', + value=float(alpha), + unit='dimensionless', + min=MINIMUM_ALPHA, + max=MAXIMUM_ALPHA, + ) + + @property + def scale(self) -> Parameter: + """ + Get the scale parameter. + + Returns + ------- + Parameter + The scale Parameter with unit ``x_unit * y_unit``. It is the integrated area of the + profile. + """ + return self._scale + + @scale.setter + def scale(self, value: Numeric) -> None: + """ + Parameters + ---------- + value : Numeric + New scale value (in current scale unit = x_unit * y_unit). + + 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 scale parameter's bounds, + e.g. a negative value against its ``min=0``. + """ + self._set_bounded_parameter_value(self._scale, value, 'scale') + + @property + def alpha(self) -> Parameter: + """ + Get the form parameter of the Mittag-Leffler relaxation function. + + Returns + ------- + Parameter + The dimensionless alpha Parameter, bounded to (0, 1]. + """ + return self._alpha + + @alpha.setter + def alpha(self, value: Numeric) -> None: + """ + Parameters + ---------- + value : Numeric + New form parameter. Must lie in (0, 1]. + + Notes + ----- + A ``TypeError`` propagates from the shared value setter if *value* is not a numeric type, + and a ``ValueError`` propagates from it if *value* falls outside (0, 1]. + """ + self._set_bounded_parameter_value(self._alpha, value, 'alpha') + + @property + def width(self) -> Parameter: + """ + Get the width parameter (Mittag-Leffler relaxation rate). + + Returns + ------- + Parameter + The relaxation rate ``hbar / tau_R`` Parameter with unit ``x_unit``. + """ + return self._width + + @width.setter + def width(self, value: Numeric) -> None: + """ + Parameters + ---------- + value : Numeric + New relaxation rate in x_unit. Must be strictly positive. + + Raises + ------ + TypeError + If *value* is not a numeric type. + ValueError + 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._set_bounded_parameter_value(self._width, value, 'width') + + @property + def damping(self) -> Parameter: + """ + Get the damping parameter (the diffusion damping ``hbar * D * q**2``). + + Returns + ------- + Parameter + The diffusion damping Parameter with unit ``x_unit``. + """ + return self._damping + + @damping.setter + def damping(self, value: Numeric) -> None: + """ + Parameters + ---------- + value : Numeric + New diffusion damping in x_unit. Must be strictly positive. + + Raises + ------ + TypeError + If *value* is not a numeric type. + ValueError + If *value* is not positive, or violates the damping parameter's bounds. + """ + if not isinstance(value, Numeric): + raise TypeError('damping must be a number') + if float(value) <= 0: + raise ValueError('damping must be positive') + self._set_bounded_parameter_value(self._damping, value, 'damping') + + def _evaluate_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndarray: + r""" + Evaluate the diffusion-damped Mittag-Leffler spectrum at x_vals. + + Eq. (42) of Hassani et al. is evaluated in the reduced variables $\omega = |x|/\Gamma$ and + $\epsilon' = \epsilon/\Gamma$, and the result divided by $\Gamma$, which turns the printed + $\tau_R = 1$ expression into the general one. Parameters in the model's own units are + temporarily converted to eval_unit for the computation. + + Parameters + ---------- + x_vals : np.ndarray + Raw x values expressed in eval_unit. + eval_unit : str | None + The unit of x_vals. + + Returns + ------- + np.ndarray + Evaluated Mittag-Leffler spectrum values at x_vals. + """ + width = self._resolve_param_value(self._width, eval_unit) + damping = self._resolve_param_value(self._damping, eval_unit) + scale = self._resolve_param_value(self._scale, self._eval_area_unit(eval_unit)) + alpha = self._alpha.value + + # Reduced (tau_R = 1) variables, so that Eq. (42) applies verbatim. + omega = np.abs(x_vals) / width + epsilon = damping / width + + modulus_squared = omega**2 + epsilon**2 + # arg(epsilon + i|omega|); damping > 0 is enforced, so modulus_squared is never zero. + phase = alpha * np.arctan2(omega, epsilon) + numerator = ( + epsilon * modulus_squared ** (alpha / 2) + + omega * np.sin(phase) + + epsilon * np.cos(phase) + ) + denominator = modulus_squared * ( + (modulus_squared**alpha + 1) * modulus_squared ** (-alpha / 2) + 2 * np.cos(phase) + ) + return scale * numerator / (np.pi * width * denominator) + + def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: + """ + Convert x-axis parameters (width, damping) and scale to new_x_unit. + + The dimensionless alpha is unaffected. + + Parameters + ---------- + new_x_unit : str | sc.Unit + Target x-axis unit. Must be dimensionally compatible with the current x_unit. + """ + self._convert_x_unit_area_based( + new_x_unit=new_x_unit, + x_params=[self._width, self._damping], + area_param=self._scale, + ) + + def convert_y_unit(self, new_y_unit: str | sc.Unit) -> None: + """ + Convert the y-axis (output) unit by rescaling the scale parameter. + + The scale is rescaled from ``x_unit * old_y_unit`` to ``x_unit * new_y_unit``. + + Parameters + ---------- + new_y_unit : str | sc.Unit + Target y-axis unit. + """ + self._convert_y_unit_area_based(new_y_unit=new_y_unit, area_param=self._scale) + + def __repr__(self) -> str: + """ + Return a string representation of the diffusion-damped Mittag-Leffler component. + + Returns + ------- + str + A string representation of the diffusion-damped Mittag-Leffler component. + """ + return ( + f'{self.__class__.__name__}(name = {self.name}, display_name = {self.display_name}, ' + f'x_unit = {self.x_unit}, y_unit = {self.y_unit},\n' + f' scale = {self.scale},\n' + f' alpha = {self.alpha},\n' + f' width = {self.width},\n' + f' damping = {self.damping})' + ) 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 8db32c4d3..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 ---------- @@ -474,13 +515,13 @@ def _relabel_parameter_unit(self, name: str, unit: str | sc.Unit) -> None: # Parameter.unit is read-only and convert_unit rescales the value, so a pure relabel # has to swap the underlying scipp scalars (value and bounds) directly. - param._scalar = sc.scalar( # noqa: SLF001 + param._scalar = sc.scalar( # ruff: ignore[private-member-access] param.value, unit=new_unit, - variance=param._scalar.variance, # noqa: SLF001 + variance=param._scalar.variance, # ruff: ignore[private-member-access] ) - param._min = sc.scalar(param._min.value, unit=new_unit) # noqa: SLF001 - param._max = sc.scalar(param._max.value, unit=new_unit) # noqa: SLF001 + param._min = sc.scalar(param._min.value, unit=new_unit) # ruff: ignore[private-member-access] + param._max = sc.scalar(param._max.value, unit=new_unit) # ruff: ignore[private-member-access] @classmethod def _create_physical_constant(cls, name: str) -> DescriptorNumber: @@ -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/stretched_exponential.py b/src/easydynamics/sample_model/components/stretched_exponential.py new file mode 100644 index 000000000..9e33fd5c0 --- /dev/null +++ b/src/easydynamics/sample_model/components/stretched_exponential.py @@ -0,0 +1,784 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +r""" +The stretched exponential (Kohlrausch-Williams-Watts) relaxation, Fourier transformed to energy. + +The model is defined in time, as $I(t) = A e^{-(|t| / \tau)^\beta}$. To evaluate the Fourier +transform, we need to evaluate the following integral: + +$$ G_\beta(w) = \int_0^\infty e^{-u^\beta} \cos(w u) \, du $$ + +The problem is that $\cos(w u)$ oscillates forever at constant amplitude, and $e^{-u^\beta}$ decays +extremely slowly once $\beta$ is small: at $\beta = 0.3$ it is still significant at $u \sim 10^5$. +Evaluating $G_\beta$ therefore means summing an enormous number of nearly cancelling oscillations. +Furthermore, large $w$ makes the oscillation fast, so the steps must be tiny, while small $\beta$ +stretches the tail over more decades, so the range must be huge. + +An FFT of the sampled relaxation cannot span that tail at small $\beta$ and, because $e^{-(t / +\tau)^\beta}$ has a cusp at $t = 0$, converges only as $(\Delta t)^{1 + \beta}$. +``scipy.stats.levy_stable`` is mathematically the same function, but it is 70-100x slower and not +accurate for $\alpha$ close to 1. + +Instead, the integral is evaluated in the complex plane described in :func:`_kww_shape`. + +The four helpers below are module-level functions rather than methods. :func:`_quadrature_nodes` +and :func:`_reduced_hwhm` must be, because ``@lru_cache`` on a method would key on ``self``: every +instance would rebuild its own grid and the cache would keep every component ever created alive. +:func:`_kww_shape` is a pure function of ``(w, beta)`` that touches no component state: area, +center and units are applied afterwards in ``_evaluate_values``, so it does not belong in the +class, and :func:`_hwhm_dependency_expression` only assembles a string from module constants. + +:func:`_reduced_hwhm` is not on any evaluation path. It solves the half width exactly, and the +closed form that ``width`` actually resolves through is fitted to it; it stays here as the +reference that defines `_HWHM_POLY_COEFFS` and as what the tests check that fit against. +""" + +from __future__ import annotations + +import contextlib +from functools import lru_cache +from typing import TYPE_CHECKING + +import numpy as np +from easyscience.variable import DescriptorNumber +from easyscience.variable import Parameter +from scipp import UnitError +from scipy.optimize import brentq + +from easydynamics.sample_model.components.mixins import CreateParametersMixin +from easydynamics.sample_model.components.model_component import ModelComponent +from easydynamics.utils.utils import Numeric +from easydynamics.utils.utils import convert_value_unit +from easydynamics.utils.utils import hbar + +if TYPE_CHECKING: + import scipp as sc + +MINIMUM_RELAXATION_TIME = 1e-10 # ps. Avoids a division by zero in hbar / tau +MINIMUM_BETA = 0.05 # Below this the quadrature grid can no longer resolve the time tail +MAXIMUM_BETA = 2.0 # Above this the transform is no longer positive, so unphysical + +# Tuning of the exp-sinh quadrature used by _kww_shape. The step and the half-range were chosen +# together so the transform reproduces its analytic special cases (beta = 1 and beta = 2) to +# ~1e-11 relative over w in [0, 1e6]; see test_stretched_exponential.py. +_QUAD_STEP = 0.03 +_QUAD_HALF_RANGE = 4.3 +# exp(-u**beta) has fallen below exp(-_QUAD_DECADES) at the far end of the grid. +_QUAD_DECADES = 50.0 +# The quadrature holds one (energies x nodes) temporary, so long energy axes are evaluated in +# blocks to keep that intermediate at a few tens of MB instead of scaling with the axis. +_MAX_BLOCK_ELEMENTS = 1 << 21 +# The reduced half width spans 1.67 at beta = 2 down to ~7e-27 at beta = 0.05, so it is +# bracketed in log10(w). The lower end stays clear of the subnormal range, where the +# 1 / (w sin theta) rescaling inside _kww_shape would overflow. +_HWHM_LOG_BRACKET = (-300.0, 3.0) + +# The width is exposed as a dependent Parameter, and easyscience resolves a dependency from a +# string expression, so the root solved by _reduced_hwhm has to be written in closed form. It +# very nearly is one: beta * ln(HWHM) - ln(beta) is a small, smooth function of beta alone, so +# HWHM = exp((ln(beta) + p(beta)) / beta) with p the polynomial below, in ascending powers. The +# coefficients are a degree 8 least-squares fit over 400 log-spaced beta in [MINIMUM_BETA, +# MAXIMUM_BETA], and reproduce _reduced_hwhm to 1.4e-5 relative for beta >= 0.1. Below that the +# fit degrades to ~1e-2, but so does the root it is fitted to, and both are tens of orders of +# magnitude under any energy grid step by then. Regenerate, or check these against the root they +# are fitted to, with tools/fit_kww_hwhm_coefficients.py. +_HWHM_POLY_COEFFS = ( + -5.5129123891501144e-05, + -0.24257355020449564, + 0.2875849387004671, + -0.04773226324334423, + -0.04385316873071495, + 0.11064865473723488, + -0.09366816058690182, + 0.03431170767881221, + -0.004659825239152873, +) + + +@lru_cache(maxsize=8) +def _quadrature_nodes(half_steps: int) -> tuple[np.ndarray, np.ndarray]: + r""" + Build the exp-sinh quadrature nodes and weights for ``2 * half_steps + 1`` points. + + The exp-sinh (double-exponential) rule maps the trapezoidal rule on the whole real line onto + the half line via $s = e^{(\pi / 2) \sinh t}$, which makes the node density follow the decades + of $s$ rather than its absolute size. The grids are small and reused across evaluations, so + they are cached rather than rebuilt on every call. + + Parameters + ---------- + half_steps : int + Number of steps of size ``_QUAD_STEP`` on each side of $t = 0$. + + Returns + ------- + tuple[np.ndarray, np.ndarray] + The node positions $s$ on the half line and their quadrature weights. + """ + t = np.arange(-half_steps, half_steps + 1) * _QUAD_STEP + nodes = np.exp(0.5 * np.pi * np.sinh(t)) + weights = _QUAD_STEP * nodes * (0.5 * np.pi) * np.cosh(t) + return nodes, weights + + +def _kww_shape(w: np.ndarray, beta: float) -> np.ndarray: + r""" + Evaluate the reduced Fourier cosine transform of a stretched exponential. + + $$ G_\beta(w) = \int_0^\infty e^{-u^\beta} \cos(w u) \, du $$ + + The physical spectrum is $\frac{A}{\pi \Gamma} G_\beta(x / \Gamma)$ with $\Gamma = \hbar / + \tau$, so $w$ is the reduced energy $x / \Gamma$ and everything else is a scale factor. + + The integrand oscillates without decaying, so it is integrated along the rotated ray $u = s + e^{i \theta}$ instead of the real axis. Writing $\cos(w u) = \mathrm{Re}\, e^{i w u}$ makes + the integrand analytic in a wedge around the positive real axis: $e^{-u^\beta}$ keeps decaying + as long as $\arg(u) < \pi / (2 \beta)$, and the arc at infinity vanishes, so by Cauchy's + theorem the ray may be swung up to that angle without changing the value. + + On the tilted ray $e^{i w u}$ becomes $e^{i w s \cos\theta} e^{-w s \sin\theta}$: the + oscillation now decays with increasing $w$. The faster the oscillation, the faster it is + damped, so the number of oscillations before the integrand dies is bounded *independently of* + $w$. + + The angle $\theta = \pi / (4 \beta)$ is half of the $\pi / (2 \beta)$ limit, a deliberate + safety margin; it is capped at $0.45 \pi$ because for $\beta < 0.5$ the formula would exceed + $\pi / 2$ and the ray would cross the imaginary axis, where $e^{-u^\beta}$ grows. + + Writing $e^{-u^\beta + i w u}$ out in real form on that ray, with the extra $\theta$ in the + phase coming from $du = e^{i \theta} ds$, turns the integral into + + $$ G_\beta(w) = \int_0^\infty e^{-P(s)} \cos(Q(s) + \theta) \, ds $$ + + with $P = \cos(\beta \theta) s^\beta + w \sin(\theta) s$ (the decay) and $Q = w \cos(\theta) s + - \sin(\beta \theta) s^\beta$ (what is left of the oscillation), which an exp-sinh rule on a + grid rescaled to the decay length of $P$ integrates to near machine precision for every $w$. + + The rescaling is the other half of the robustness. $P$ has two terms, and whichever dies first + sets the decay length: $\cos(\beta \theta)^{-1 / \beta}$ for the $s^\beta$ term, $1 / (w + \sin\theta)$ for the $w s$ term. Taking the smaller of the two and stretching the grid onto it + means the integrand always falls off around $s = 1$, so a single node layout serves every $w$ + and every $\beta$. + + Accuracy is therefore governed by $w$ and $\beta$ alone. For $\beta \le 1$ the result tracks + the exact power-law tail out to at least $w = 10^8$. For $\beta > 1$ the profile decays fast + enough to reach the floor of double precision, and values below roughly $10^{-11}$ of the peak + are noise. + + Parameters + ---------- + w : np.ndarray + Reduced energies. Only $|w|$ matters: the transform is even in $w$. + beta : float + Stretching exponent, in ``[MINIMUM_BETA, MAXIMUM_BETA]``. + + Returns + ------- + np.ndarray + The transform, clipped at zero. It is non-negative for every $\beta \le 2$; the clip only + removes rounding noise where the true value has underflowed. + """ + abs_w = np.abs(w) + + theta = min(0.45 * np.pi, np.pi / (4.0 * beta)) + cos_rotated = np.cos(beta * theta) + sin_rotated = np.sin(beta * theta) + sin_theta = np.sin(theta) + cos_theta = np.cos(theta) + + # Reach far enough out in s that exp(-cos(beta theta) s**beta) has died: small beta stretches + # the tail over many more decades, so the grid has to follow it. This inverts the node map + # s = exp(pi / 2 sinh(t)) at the s where cos(beta theta) s**beta equals _QUAD_DECADES, i.e. + # where the decaying factor has fallen to exp(-50). + half_range = np.arcsinh((2.0 / np.pi) * np.log(_QUAD_DECADES / cos_rotated) / beta) + half_steps = int(np.ceil(max(half_range, _QUAD_HALF_RANGE) / _QUAD_STEP)) + nodes, weights = _quadrature_nodes(half_steps) + nodes_beta = nodes**beta + + # Rescale the grid onto whichever of the two exponents in P decays first, so the integrand + # always falls off around s = 1 and the same node layout serves every w. beta_scale is where + # the s**beta term dies, w_scale where the w s term does; w_scale is infinite at w = 0, where + # there is no oscillation to damp and beta_scale is the only length in the problem. + beta_scale = cos_rotated ** (-1.0 / beta) + w_scale = np.divide(1.0, abs_w * sin_theta, out=np.full(abs_w.shape, np.inf), where=abs_w > 0) + scale = np.minimum(beta_scale, w_scale) + + out = np.empty(abs_w.shape) + block = max(1, _MAX_BLOCK_ELEMENTS // nodes.size) + for start in range(0, abs_w.size, block): + stop = start + block + block_w = abs_w[start:stop, np.newaxis] + block_scale = scale[start:stop, np.newaxis] + + s = block_scale * nodes + s_beta = block_scale**beta * nodes_beta + decaying = cos_rotated * s_beta + (block_w * sin_theta) * s + oscillating = (block_w * cos_theta) * s - sin_rotated * s_beta + + integral = (np.exp(-decaying) * np.cos(oscillating + theta) * weights).sum(axis=1) + out[start:stop] = integral * scale[start:stop] + + return np.maximum(out, 0.0) + + +@lru_cache(maxsize=128) +def _reduced_hwhm(beta: float) -> float: + r""" + Solve for the half width at half maximum of $G_\beta$ in reduced energy $w = x / \Gamma$. + + $\Gamma = \hbar / \tau$ is the HWHM only at $\beta = 1$. Below that the profile sharpens + dramatically: the peak $G_\beta(0)$ grows while the area stays fixed, so the half width + collapses far faster than $\Gamma$ suggests -- 0.22 $\Gamma$ at $\beta = 0.5$, 2.7e-4 $\Gamma$ + at $\beta = 0.2$, 8e-11 $\Gamma$ at $\beta = 0.1$. There is no closed form, so the crossing of + $G_\beta(w) = \tfrac{1}{2} G_\beta(0)$ is bracketed numerically. + + The search runs in $\log_{10} w$ because the root ranges over more than 25 decades across the + supported $\beta$; a linear bracket could not resolve the small-$\beta$ end. Each solve costs + a few tens of single-point quadratures, under a millisecond, and the result is cached because + it depends on $\beta$ alone. + + The root is well conditioned down to $\beta \approx 0.1$ and increasingly poorly below it: by + $\beta = 0.05$ the profile takes some 60 e-folds of $w$ to fall by half, so $G_\beta$ is nearly + flat in $\log w$ near the crossing and the result is only good to about a percent. That is + immaterial for the one thing the half width is used for -- comparing against the convolution's + energy grid -- because at those $\beta$ it is already tens of orders of magnitude below any + grid step, and the comparison lands the same way either way. + + Parameters + ---------- + beta : float + Stretching exponent, in ``[MINIMUM_BETA, MAXIMUM_BETA]``. + + Returns + ------- + float + The HWHM in units of $\Gamma$. Multiply by $\Gamma$ to get an energy. + """ + half_peak = 0.5 * _kww_shape(np.array([0.0]), beta)[0] + + def offset(log_w: float) -> float: + return _kww_shape(np.array([10.0**log_w]), beta)[0] - half_peak + + return float(10.0 ** brentq(offset, *_HWHM_LOG_BRACKET, xtol=1e-12)) + + +def _hwhm_dependency_expression() -> str: + r""" + Write the half width of :func:`_reduced_hwhm` as an easyscience dependency expression. + + The expression evaluates ``exp((log(beta) + p(beta)) / beta)`` from `_HWHM_POLY_COEFFS`, in + Horner form, and multiplies it by $\hbar / \tau$ to turn the reduced half width into an energy. + + ``exp`` and ``log`` are not defined on DescriptorNumbers, so the reduced half width is built + from ``b.value`` and is a plain float; the surrounding ``* hbar / tau`` is what makes the whole + expression return a DescriptorNumber, as easyscience requires. Referring to ``b.value`` rather + than ``b`` strips beta's uncertainty, which is why the width carries only the relaxation time's + contribution. When exp and log of DescriptorNumbers are supported, this can be rewritten to + propagate beta's uncertainty too. + + Returns + ------- + str + The dependency expression, over the mapped names ``b``, ``hbar`` and ``tau``. + """ + horner = repr(_HWHM_POLY_COEFFS[-1]) + for coefficient in _HWHM_POLY_COEFFS[-2::-1]: + horner = f'({horner}) * b.value + {coefficient!r}' + return f'exp((log(b.value) + ({horner})) / b.value) * hbar / tau' + + +class StretchedExponential(CreateParametersMixin, ModelComponent): + r""" + Model of a stretched exponential (Kohlrausch-Williams-Watts) relaxation, Fourier transformed + from time to energy. + + The model is defined by its intermediate scattering function + + $$ I(t) = A \exp\left[-\left(\frac{|t|}{\tau}\right)^\beta\right] $$ + + where $\tau$ is the relaxation time and $\beta$ the stretching exponent. Here we calculate the + Fourier transform: + + $$ S(x) = \frac{1}{2\pi\hbar} \int I(t)\, e^{-i (x - x_0) t / \hbar} \, \mathrm{d}t = + \frac{A}{\pi \Gamma} \, G_\beta\!\left(\frac{x - x_0}{\Gamma}\right), \qquad \Gamma = + \frac{\hbar}{\tau} $$ + + with $G_\beta(w) = \int_0^\infty e^{-u^\beta} \cos(w u)\, \mathrm{d}u$. $A$ is the area (the + profile integrates to $A$ over $x$), $x_0$ is the center, and $\Gamma$ is the energy scale set + by the relaxation time. area has unit = x_unit * y_unit; center has unit = x_unit; + relaxation_time has unit ps; beta is dimensionless. + + $\beta = 1$ recovers a Lorentzian of HWHM $\Gamma$ and $\beta = 2$ a Gaussian of standard + deviation $\sqrt{2}\,\Gamma$; in between there is no closed form and the transform is evaluated + numerically. $\beta \le 1$ is the physically usual range. $\beta$ is capped at 2 because + $\exp(-|t|^\beta)$ stops being positive definite beyond it, so the transform would go negative. + + Note that the x-axis has to be an energy, since the relaxation time is turned into an energy + scale through $\hbar$. + + Examples + -------- + **Creating a stretched exponential** + + By default the center is fixed at 0 like a Lorentzian:: + ```python + import numpy as np + import easydynamics as edyn + + kww = edyn.StretchedExponential(area=1.0, relaxation_time=5.0, beta=0.7) + x = np.linspace(-2, 2, 100) + values = kww.evaluate(x) + ``` + + **Modifying parameters after construction** + + ```python + import easydynamics as edyn + + kww = edyn.StretchedExponential(area=2.0, relaxation_time=10.0, beta=0.5, name='Polymer') + kww.relaxation_time = 20.0 + kww.beta = 0.6 + ``` + """ + + def __init__( + self, + area: Numeric = 1.0, + center: Numeric | None = None, + relaxation_time: Numeric = 1.0, + beta: Numeric = 1.0, + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', + name: str = 'StretchedExponential', + display_name: str | None = None, + unique_name: str | None = None, + ) -> None: + r""" + Initialize the StretchedExponential component. + + Parameters + ---------- + area : Numeric, default=1.0 + Integrated area under the transformed profile. Unit is ``x_unit * y_unit``. + center : Numeric | None, default=None + Peak position in x_unit. If None, defaults to 0 and the center parameter is fixed. + relaxation_time : Numeric, default=1.0 + Relaxation time tau in ps. Must be strictly positive. It enters the spectrum as the + energy scale $\Gamma = \hbar / \tau$. + beta : Numeric, default=1.0 + Stretching exponent. Must lie in ``[0.05, 2.0]``; $\beta = 1$ is a Lorentzian and + $\beta = 2$ a Gaussian. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis. Must be an energy, since $\hbar / \tau$ is converted into it. + center is stored in this unit. area_unit = x_unit * y_unit. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the y-axis (output). + name : str, default='StretchedExponential' + Name of the component. + display_name : str | None, default=None + Display name shown when plotting. Falls back to *name* if None. + unique_name : str | None, default=None + Globally unique identifier. Auto-generated if None. + """ + super().__init__( + x_unit=x_unit, + y_unit=y_unit, + name=name, + display_name=display_name, + unique_name=unique_name, + ) + + self._area = self._create_area_parameter( + area=area, name=name, x_unit=self.x_unit, y_unit=self.y_unit + ) + self._center = self._create_center_parameter( + center=center, name=name, fix_if_none=True, x_unit=self.x_unit + ) + + self._validate_relaxation_time(relaxation_time) + self._relaxation_time = Parameter( + name=name + ' relaxation_time', + value=float(relaxation_time), + unit='ps', + min=MINIMUM_RELAXATION_TIME, + ) + + self._validate_beta(beta) + self._beta = Parameter( + name=name + ' beta', + value=float(beta), + unit='dimensionless', + min=MINIMUM_BETA, + max=MAXIMUM_BETA, + ) + + # hbar has to be in the dependency map, because carrying J*s through the expression is + # what lets the unit algebra turn the reduced half width into an energy. It is a private + # copy rather than the shared ``utils.hbar`` deliberately: make_dependent_on attaches the + # width as an observer of every mapped variable, so mapping the module-level constant + # would append one observer per component to a list that is never emptied, keeping every + # StretchedExponential ever built alive and lengthening each of its notifications. + self._hbar = DescriptorNumber(name=name + ' hbar', value=hbar.value, unit=str(hbar.unit)) + # Built here rather than on first access so that get_all_parameters reports the same set + # whether or not the width has been read yet. A non-energy x_unit has no width to build + # and must still construct: the UnitError is deferred to the first read, as for evaluate. + self._width: Parameter | None = None + with contextlib.suppress(UnitError): + self._width = self._build_width() + + ################################ + # Validation + ################################ + + @staticmethod + def _validate_relaxation_time(value: Numeric) -> None: + """ + Check that a relaxation time is a finite, strictly positive number. + + Parameters + ---------- + value : Numeric + The candidate relaxation time. + + Raises + ------ + TypeError + If *value* is not a numeric type. + ValueError + If *value* is not finite, or is smaller than ``MINIMUM_RELAXATION_TIME``. + """ + if not isinstance(value, Numeric): + raise TypeError('relaxation_time must be a number.') + if not np.isfinite(value): + raise ValueError('relaxation_time must be a finite number.') + if float(value) < MINIMUM_RELAXATION_TIME: + raise ValueError('relaxation_time must be greater than zero.') + + @staticmethod + def _validate_beta(value: Numeric) -> None: + """ + Check that a stretching exponent is a finite number inside the supported range. + + Parameters + ---------- + value : Numeric + The candidate stretching exponent. + + Raises + ------ + TypeError + If *value* is not a numeric type. + ValueError + If *value* is not finite, or lies outside ``[MINIMUM_BETA, MAXIMUM_BETA]``. + """ + if not isinstance(value, Numeric): + raise TypeError('beta must be a number.') + if not np.isfinite(value): + raise ValueError('beta must be a finite number.') + if not MINIMUM_BETA <= float(value) <= MAXIMUM_BETA: + raise ValueError(f'beta must be between {MINIMUM_BETA} and {MAXIMUM_BETA}.') + + ################################ + # Properties + ################################ + + @property + def area(self) -> Parameter: + """ + Get the area parameter. + + Returns + ------- + Parameter + The area Parameter with unit ``x_unit * y_unit``. + """ + return self._area + + @area.setter + def area(self, value: Numeric) -> None: + """ + Parameters + ---------- + value : Numeric + New area value (in current area unit = x_unit * y_unit). + + 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``). + """ + self._set_bounded_parameter_value(self._area, value, 'area') + + @property + def center(self) -> Parameter: + """ + Get the center parameter. + + Returns + ------- + Parameter + The center (x_0) Parameter with unit ``x_unit``. + """ + return self._center + + @center.setter + def center(self, value: Numeric | None) -> None: + """ + Parameters + ---------- + value : Numeric | None + New center value in x_unit. If None, the center is set to 0 and the parameter is + fixed. + + Raises + ------ + TypeError + If *value* is not None and not a numeric type. + """ + if value is None: + value = 0.0 + self._center.fixed = True + if not isinstance(value, Numeric): + raise TypeError('center must be a number') + self._center.value = value + + @property + def relaxation_time(self) -> Parameter: + """ + Get the relaxation time parameter. + + Returns + ------- + Parameter + The relaxation time (tau) Parameter with unit ``ps``. + """ + return self._relaxation_time + + @relaxation_time.setter + def relaxation_time(self, value: Numeric) -> None: + """ + Parameters + ---------- + value : Numeric + New relaxation time in the parameter's current unit. Must be strictly positive. + + Notes + ----- + A ``TypeError`` propagates from the validator if *value* is not a numeric type, and a + ``ValueError`` propagates from it if *value* is not finite or not positive, or from the + shared value setter if *value* violates the parameter's bounds. + """ + self._validate_relaxation_time(value) + self._set_bounded_parameter_value(self._relaxation_time, value, 'relaxation_time') + + @property + def beta(self) -> Parameter: + """ + Get the stretching exponent parameter. + + Returns + ------- + Parameter + The stretching exponent (beta) Parameter, dimensionless. + """ + return self._beta + + @beta.setter + def beta(self, value: Numeric) -> None: + """ + Parameters + ---------- + value : Numeric + New stretching exponent. Must lie in ``[MINIMUM_BETA, MAXIMUM_BETA]``. + + Notes + ----- + A ``TypeError`` propagates from the validator if *value* is not a numeric type, and a + ``ValueError`` propagates from it if *value* is not finite or lies outside the supported + range, or from the shared value setter if *value* violates the parameter's bounds. + """ + self._validate_beta(value) + self._set_bounded_parameter_value(self._beta, value, 'beta') + + def _build_width(self) -> Parameter: + r""" + Build the dependent width Parameter in the component's current x_unit. + + Returns + ------- + Parameter + A Parameter made dependent on :attr:`beta` and :attr:`relaxation_time`. + + Raises + ------ + UnitError + If x_unit is not an energy, so $\hbar / \tau$ cannot be expressed in it. + """ + width = Parameter(name=self.name + ' width', value=1.0, unit=str(self.x_unit)) + try: + # easyscience propagates inf bounds through arithmetic, producing inf/inf=nan as a + # transient intermediate; suppress the spurious numpy warnings as elsewhere. + with np.errstate(invalid='ignore', divide='ignore'): + width.make_dependent_on( + dependency_expression=_hwhm_dependency_expression(), + dependency_map={ + 'b': self._beta, + 'hbar': self._hbar, + 'tau': self._relaxation_time, + }, + desired_unit=str(self.x_unit), + ) + except UnitError as e: + raise UnitError( + f'{self.__class__.__name__} needs an energy x_unit so that hbar / ' + f'relaxation_time can be expressed in it, but got {self.x_unit}.' + ) from e + return width + + @property + def width(self) -> Parameter: + r""" + Get the half width at half maximum of the peak. + + This is a *dependent* Parameter, derived from :attr:`relaxation_time` and :attr:`beta`. It + is accurate to 1.4e-5 relative for $\beta \ge 0.1$. For better accuracy, use + :func:`_reduced_hwhm` directly and multiply by $\hbar / \tau$ in the desired unit. Note + that the uncertainty of the width may be underestimated, because the dependency expression + uses ``b.value`` rather than ``b`` to compute the width, so the uncertainty of beta is not + propagated into the width. + + Returns + ------- + Parameter + The HWHM expressed in the component's own x_unit. + + Notes + ----- + A ``UnitError`` propagates from :meth:`_build_width` if x_unit is not an energy, so $\hbar + / \tau$ cannot be expressed in it. + """ + if self._width is None: + self._width = self._build_width() + return self._width + + ################################ + # Evaluation + ################################ + + def _energy_scale(self, eval_unit: str | None) -> float: + r""" + Get the energy scale $\Gamma = \hbar / \tau$ expressed in the evaluation unit. + + Parameters + ---------- + eval_unit : str | None + The unit x values are expressed in, or None for the component's own x_unit. + + Returns + ------- + float + Gamma in *eval_unit*. + + Raises + ------ + UnitError + If the target unit is not an energy, so $\hbar / \tau$ cannot be expressed in it. + """ + target_unit = eval_unit if eval_unit is not None else self.x_unit + tau_unit = self._relaxation_time.unit + try: + hbar_value = convert_value_unit(hbar.value, hbar.unit, f'({target_unit})*({tau_unit})') + except UnitError as e: + raise UnitError( + f'{self.__class__.__name__} needs an energy x_unit so that hbar / ' + f'relaxation_time can be expressed in it, but got {target_unit}.' + ) from e + return hbar_value / self._relaxation_time.value + + def _evaluate_values(self, x_vals: np.ndarray, eval_unit: str | None) -> np.ndarray: + r""" + Evaluate the transformed stretched exponential at x_vals. + + $$ S(x) = \frac{A}{\pi \Gamma} \, G_\beta\!\left(\frac{x - x_0}{\Gamma}\right), \qquad + \Gamma = \frac{\hbar}{\tau} $$ + + where *A* is ``area``, *x*₀ is ``center``, *tau* is ``relaxation_time``, *beta* is the + stretching exponent, and $G_\beta$ is the reduced transform computed by :func:`_kww_shape`. + Parameters in the model's own units are temporarily converted to eval_unit for the + computation. + + Parameters + ---------- + x_vals : np.ndarray + Raw x values expressed in eval_unit. + eval_unit : str | None + The unit of x_vals. + + Returns + ------- + np.ndarray + Evaluated values at x_vals. + """ + center = self._resolve_param_value(self._center, eval_unit) + area = self._resolve_param_value(self._area, self._eval_area_unit(eval_unit)) + energy_scale = self._energy_scale(eval_unit) + + shape = _kww_shape((x_vals - center) / energy_scale, self._beta.value) + return area / (np.pi * energy_scale) * shape + + ################################ + # Unit conversion + ################################ + + def convert_x_unit(self, new_x_unit: str | sc.Unit) -> None: + r""" + Convert the center and the area to new_x_unit. + + The relaxation time carries a time unit and beta is dimensionless, so neither is affected; + the energy scale $\hbar / \tau$ is re-derived in the new unit on every evaluation. + + Parameters + ---------- + new_x_unit : str | sc.Unit + Target x-axis unit. Must be dimensionally compatible with the current x_unit. + """ + self._convert_x_unit_area_based( + new_x_unit=new_x_unit, + x_params=[self._center], + area_param=self._area, + ) + # The dependency resolves into the unit it was built with, so rebuild it in the new one. + self._width = self._build_width() + + def convert_y_unit(self, new_y_unit: str | sc.Unit) -> None: + """ + Convert the y-axis (output) unit by rescaling the area parameter. + + The area is rescaled from ``x_unit * old_y_unit`` to ``x_unit * new_y_unit``. + + Parameters + ---------- + new_y_unit : str | sc.Unit + Target y-axis unit. + """ + self._convert_y_unit_area_based(new_y_unit=new_y_unit, area_param=self._area) + + def __repr__(self) -> str: + """ + Return a string representation of the StretchedExponential. + + Returns + ------- + str + A string representation of the StretchedExponential. + """ + return ( + f'{self.__class__.__name__}(name = {self.name}, display_name = {self.display_name}, ' + f'x_unit = {self.x_unit}, y_unit = {self.y_unit},\n ' + f' area = {self.area},\n ' + f' center = {self.center},\n ' + f' relaxation_time = {self.relaxation_time},\n ' + f' beta = {self.beta})' + ) 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..b8c079391 100644 --- a/src/easydynamics/sample_model/diffusion_model/__init__.py +++ b/src/easydynamics/sample_model/diffusion_model/__init__.py @@ -4,11 +4,17 @@ 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.sample_model.diffusion_model.mittag_leffler_diffusion import ( + MittagLefflerDiffusion, +) __all__ = [ 'BrownianTranslationalDiffusion', + 'DeltaLorentz', 'JumpTranslationalDiffusion', + 'MittagLefflerDiffusion', ] 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 de049c4bf..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, @@ -534,7 +538,7 @@ def create_component_collections( y_unit=self.y_unit, ) if self._allow_Q_variation['lorentzian_width'] is True: - lorz_component._width = self._lorentzian_width_list[i] # noqa: SLF001 + lorz_component._width = self._lorentzian_width_list[i] # ruff: ignore[private-member-access] # If the width is allowed to vary with Q it is independent. # If the width is not allowed to vary with Q it must be made @@ -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 abf071e7b..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 @@ -317,7 +320,7 @@ def _assert_convertible_to_mev(unit: str | sc.Unit) -> None: test.convert_unit('meV') except Exception as e: raise UnitError( - f'Invalid unit: {unit}. Unit must be a string or scipp Unit and convertible to meV.' # noqa: E501 + f'Invalid unit: {unit}. Unit must be a string or scipp Unit and convertible to meV.' # ruff: ignore[line-too-long] ) from e def convert_x_unit(self, unit: str | sc.Unit) -> None: 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/diffusion_model/mittag_leffler_diffusion.py b/src/easydynamics/sample_model/diffusion_model/mittag_leffler_diffusion.py new file mode 100644 index 000000000..1aa730d63 --- /dev/null +++ b/src/easydynamics/sample_model/diffusion_model/mittag_leffler_diffusion.py @@ -0,0 +1,1340 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + + +import numpy as np +import scipp as sc +from easyscience.variable import DescriptorNumber +from easyscience.variable import Parameter + +from easydynamics.sample_model.component_collection import ComponentCollection +from easydynamics.sample_model.components import DiffusionDampedMittagLeffler +from easydynamics.sample_model.components import Lorentzian +from easydynamics.sample_model.components.diffusion_damped_mittag_leffler import MAXIMUM_ALPHA +from easydynamics.sample_model.components.diffusion_damped_mittag_leffler import MINIMUM_ALPHA +from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase +from easydynamics.utils.fit_target import FitTarget +from easydynamics.utils.utils import CANONICAL_Q_UNIT +from easydynamics.utils.utils import Numeric +from easydynamics.utils.utils import Q_type +from easydynamics.utils.utils import angstrom +from easydynamics.utils.utils import convert_parameter_unit +from easydynamics.utils.utils import hbar +from easydynamics.utils.utils import verify_Q_index + +MINIMUM_WIDTH = 1e-10 # To avoid division by zero +DIFFUSION_COEFFICIENT_MIN = 1e-25 # To avoid division by zero in the diffusion damping calculation + + +class MittagLefflerDiffusion(DiffusionModelBase): + r""" + Multiscale relaxation model of Hassani et al.: Mittag-Leffler internal dynamics damped by + global translational diffusion. + + A. N. Hassani, L. Haris, M. Appel, T. Seydel, A. M. Stadler and G. R. Kneller, *Multiscale + relaxation dynamics and diffusion of myelin basic protein in solution studied by quasielastic + neutron scattering*, J. Chem. Phys. **156**, 025102 (2022), + [doi:10.1063/5.0077100](https://doi.org/10.1063/5.0077100). + + The intermediate scattering function is written as an internal relaxation, described by a + Mittag-Leffler function, times a global diffusion factor (their Eqs. 38-39), + + $$ F^{(+)}(t) = K e^{-\epsilon |t|} \left( EISF + (1 - EISF) E_\alpha(-(|t|/\tau_R)^\alpha) + \right), \qquad \epsilon = \hbar D q^2 $$ + + whose Fourier transform is their Eq. (41), + + $$ S^{(+)}(x) = K \left[ EISF \frac{1}{\pi} \frac{\epsilon}{x^2 + \epsilon^2} + + (1 - EISF) \tilde{\phi}^{(\epsilon)}_{ML}(|x|) \right]. $$ + + This model builds that sum at every Q as a + [`ComponentCollection`][easydynamics.sample_model.ComponentCollection] of two components: + + - a [`Lorentzian`][easydynamics.sample_model.Lorentzian] carrying the elastic term, with area + $K \cdot EISF$ and half width at half maximum $\epsilon = \hbar D q^2$. Note that the elastic + line is a Lorentzian and not a delta function, because global diffusion broadens it. + - a + [`DiffusionDampedMittagLeffler`][easydynamics.sample_model.DiffusionDampedMittagLeffler] + carrying the quasi-elastic term, with scale $K \cdot (1 - EISF)$, damping $\epsilon = \hbar D + q^2$, width $\hbar/\tau_R$ and form parameter $\alpha$. + + The diffusion coefficient $D$ is global: it is the single parameter that ties the Q values + together, exactly as in + [`BrownianTranslationalDiffusion`][easydynamics.sample_model.BrownianTranslationalDiffusion]. + In the paper $\tau_R$, $\alpha$ and the EISF are instead fitted independently at every Q (their + Fig. 5); pass ``allow_Q_variation={'A_0': True, 'relaxation_rate': True, 'alpha': True}`` to + reproduce that. Q is assumed to be in 1/angstrom and $D$ in m^2/s. + + Examples + -------- + **Creating a MittagLefflerDiffusion model with the paper's per-Q parameters** + + ```python + import numpy as np + import easydynamics as edyn + + Q = np.linspace(0.8, 1.8, 6) + model = edyn.MittagLefflerDiffusion( + scale=1.0, + diffusion_coefficient=3.3e-11, + A_0=0.05, + relaxation_rate=0.02, + alpha=0.85, + allow_Q_variation={'A_0': True, 'relaxation_rate': True, 'alpha': True}, + Q=Q, + ) + component_collections = model.get_component_collections() + ``` + + See also the tutorials. + """ + + def __init__( + self, + scale: Numeric = 1.0, + diffusion_coefficient: Numeric = 1.0, + A_0: Numeric = 0.0, + relaxation_rate: Numeric = 1.0, + alpha: Numeric = 1.0, + allow_Q_variation: dict | None = None, + Q: Q_type | None = None, + x_unit: str | sc.Unit = 'meV', + y_unit: str | sc.Unit = 'dimensionless', + name: str = 'MittagLefflerDiffusion', + display_name: str | None = None, + lorentzian_name: str = 'Elastic Lorentzian', + lorentzian_display_name: str | None = None, + mittag_leffler_name: str = 'Mittag-Leffler', + mittag_leffler_display_name: str | None = None, + unique_name: str | None = None, + ) -> None: + """ + Initialize a new MittagLefflerDiffusion model. + + Parameters + ---------- + scale : Numeric, default=1.0 + Scale factor K for the model. Must be non-negative. Its unit is ``x_unit * y_unit``. + diffusion_coefficient : Numeric, default=1.0 + Global translational diffusion coefficient D in m^2/s. Sets the diffusion damping + ``epsilon = hbar * D * Q**2`` shared by both components. Must be at least + ``DIFFUSION_COEFFICIENT_MIN``. + A_0 : Numeric, default=0.0 + Elastic incoherent structure factor (EISF), the elastic fraction of the intensity. Must + lie in [0, 1]. The paper finds it close to zero over most of its Q range. + relaxation_rate : Numeric, default=1.0 + Mittag-Leffler relaxation rate ``hbar / tau_R`` in x_unit. Must be strictly positive. + alpha : Numeric, default=1.0 + Form parameter of the Mittag-Leffler relaxation function. Must lie in (0, 1]; alpha=1 + reduces the quasi-elastic term to a Lorentzian. + allow_Q_variation : dict | None, default=None + Dict describing which of ``'A_0'``, ``'relaxation_rate'`` and ``'alpha'`` are free at + every Q instead of shared, with boolean values. If None, none of them vary with Q. The + paper's analysis corresponds to all three being True. + Q : Q_type | None, default=None + Q values for the model in 1/angstrom. If None, Q is not set. + x_unit : str | sc.Unit, default='meV' + Unit of the x-axis (energy). Must be convertible to meV. + y_unit : str | sc.Unit, default='dimensionless' + Unit of the model output (intensity). Determines scale.unit = x_unit * y_unit. + name : str, default='MittagLefflerDiffusion' + Name of the diffusion model. + display_name : str | None, default=None + Display name of the diffusion model. + lorentzian_name : str, default='Elastic Lorentzian' + Name of the elastic Lorentzian component. + lorentzian_display_name : str | None, default=None + Display name of the elastic Lorentzian component. If None, it falls back to + *lorentzian_name*. + mittag_leffler_name : str, default='Mittag-Leffler' + Name of the Mittag-Leffler component. + mittag_leffler_display_name : str | None, default=None + Display name of the Mittag-Leffler component. If None, it falls back to + *mittag_leffler_name*. + unique_name : str | None, default=None + Unique name of the diffusion model. If None, a unique name will be generated. + + Raises + ------ + TypeError + If mittag_leffler_name is not a string, or if mittag_leffler_display_name is not a + string or None. + """ + super().__init__( + scale=scale, + x_unit=x_unit, + y_unit=y_unit, + Q=Q, + lorentzian_name=lorentzian_name, + lorentzian_display_name=lorentzian_display_name, + name=name, + display_name=display_name, + unique_name=unique_name, + ) + + # -------------------------------------------------------------- + # Parameters + # -------------------------------------------------------------- + self._hbar = hbar + self._angstrom = angstrom + + self._diffusion_coefficient = self._create_diffusion_coefficient_parameter( + diffusion_coefficient + ) + self._A_0, self._A_1 = self._create_A0_A1_parameters(A_0) + self._relaxation_rate = self._create_relaxation_rate_parameter(relaxation_rate) + self._alpha = self._create_alpha_parameter(alpha) + + # -------------------------------------------------------------- + # names + # -------------------------------------------------------------- + if not isinstance(mittag_leffler_name, str): + raise TypeError('mittag_leffler_name must be a string.') + + if mittag_leffler_display_name is None: + mittag_leffler_display_name = mittag_leffler_name + + if not isinstance(mittag_leffler_display_name, str): + raise TypeError('mittag_leffler_display_name must be a string or None.') + + self._mittag_leffler_name = mittag_leffler_name + self._mittag_leffler_display_name = mittag_leffler_display_name + + # -------------------------------------------------------------- + # Q variation + # -------------------------------------------------------------- + self._allow_Q_variation = self._create_Q_variation_dict(allow_Q_variation) + + # create_component_collections creates the per-Q parameter lists itself, so the + # components it builds are backed by the very parameters stored in those lists. + self.create_component_collections() + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def diffusion_coefficient(self) -> Parameter: + """ + Get the global diffusion coefficient parameter D. + + Returns + ------- + Parameter + Diffusion coefficient D in m^2/s. + """ + return self._diffusion_coefficient + + @diffusion_coefficient.setter + def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None: + """ + Set the global diffusion coefficient parameter D. + + Parameters + ---------- + diffusion_coefficient : Numeric + The new value for D in m^2/s. + + Raises + ------ + TypeError + If diffusion_coefficient is not a number. + ValueError + If diffusion_coefficient is smaller than ``DIFFUSION_COEFFICIENT_MIN``. + """ + if not isinstance(diffusion_coefficient, Numeric): + raise TypeError('diffusion_coefficient must be a number.') + # Checked here rather than left to the Parameter's own bound, which would silently clamp + # a too-small value instead of rejecting it. + if float(diffusion_coefficient) < DIFFUSION_COEFFICIENT_MIN: + raise ValueError( + f'diffusion_coefficient must be at least {DIFFUSION_COEFFICIENT_MIN}.' + ) + self._diffusion_coefficient.value = float(diffusion_coefficient) + + @property + def A_0(self) -> Parameter: + """ + Get the elastic fraction parameter A_0 (the EISF). + + Returns + ------- + Parameter + The dimensionless A_0 parameter, bounded to [0, 1]. + """ + return self._A_0 + + @A_0.setter + def A_0(self, A_0: Numeric) -> None: + """ + Set the elastic fraction parameter A_0. + + Parameters + ---------- + A_0 : Numeric + The new value for A_0. Must lie in [0, 1]. + + Raises + ------ + TypeError + If A_0 is not a number. + ValueError + If A_0 is not between 0 and 1. + """ + if not isinstance(A_0, Numeric): + raise TypeError('A_0 must be a number.') + if float(A_0) < 0 or float(A_0) > 1: + raise ValueError('A_0 must be between 0 and 1.') + self._A_0.value = float(A_0) + + @property + def A_1(self) -> Parameter: + """ + Get the quasi-elastic fraction parameter A_1 = 1 - A_0. + + Returns + ------- + Parameter + The dependent A_1 parameter. + """ + return self._A_1 + + @A_1.setter + def A_1(self, _A_1: Numeric) -> None: + """ + Reject assignment to the dependent A_1 parameter. + + Parameters + ---------- + _A_1 : Numeric + Ignored. + + Raises + ------ + AttributeError + Always; A_1 is derived from A_0 and must be changed through A_0. + """ + raise AttributeError('A_1 is derived from A_0 and cannot be set directly. Set A_0.') + + @property + def relaxation_rate(self) -> Parameter: + """ + Get the Mittag-Leffler relaxation rate parameter hbar / tau_R. + + Returns + ------- + Parameter + The relaxation rate parameter with unit ``x_unit``. + """ + return self._relaxation_rate + + @relaxation_rate.setter + def relaxation_rate(self, relaxation_rate: Numeric) -> None: + """ + Set the Mittag-Leffler relaxation rate parameter. + + Parameters + ---------- + relaxation_rate : Numeric + The new relaxation rate in x_unit. Must be strictly positive. + + Raises + ------ + TypeError + If relaxation_rate is not a number. + ValueError + If relaxation_rate is smaller than the minimum width. + """ + if not isinstance(relaxation_rate, Numeric): + raise TypeError('relaxation_rate must be a number.') + if float(relaxation_rate) < MINIMUM_WIDTH: + raise ValueError(f'relaxation_rate must be at least {MINIMUM_WIDTH}.') + self._relaxation_rate.value = float(relaxation_rate) + + @property + def alpha(self) -> Parameter: + """ + Get the Mittag-Leffler form parameter alpha. + + Returns + ------- + Parameter + The dimensionless alpha parameter, bounded to (0, 1]. + """ + return self._alpha + + @alpha.setter + def alpha(self, alpha: Numeric) -> None: + """ + Set the Mittag-Leffler form parameter alpha. + + Parameters + ---------- + alpha : Numeric + The new form parameter. Must lie in (0, 1]. + + Raises + ------ + TypeError + If alpha is not a number. + ValueError + If alpha does not lie in (0, 1]. + """ + if not isinstance(alpha, Numeric): + raise TypeError('alpha must be a number.') + if not MINIMUM_ALPHA <= float(alpha) <= MAXIMUM_ALPHA: + raise ValueError('alpha must be greater than zero and at most one.') + self._alpha.value = float(alpha) + + @property + def mittag_leffler_name(self) -> str: + """ + Get the name of the Mittag-Leffler component. + + Returns + ------- + str + Name of the Mittag-Leffler component. + """ + return self._mittag_leffler_name + + @mittag_leffler_name.setter + def mittag_leffler_name(self, mittag_leffler_name: str) -> None: + """ + Set the name of the Mittag-Leffler component. + + Parameters + ---------- + mittag_leffler_name : str + The new name for the Mittag-Leffler component. + + Raises + ------ + TypeError + If mittag_leffler_name is not a string. + """ + if not isinstance(mittag_leffler_name, str): + raise TypeError('mittag_leffler_name must be a string.') + self._mittag_leffler_name = mittag_leffler_name + + @property + def mittag_leffler_display_name(self) -> str | None: + """ + Get the display name of the Mittag-Leffler component. + + Returns + ------- + str | None + Display name of the Mittag-Leffler component, or None if not set. + """ + return self._mittag_leffler_display_name + + @mittag_leffler_display_name.setter + def mittag_leffler_display_name(self, mittag_leffler_display_name: str | None) -> None: + """ + Set the display name of the Mittag-Leffler component. + + Parameters + ---------- + mittag_leffler_display_name : str | None + The new display name for the Mittag-Leffler component. + + Raises + ------ + TypeError + If mittag_leffler_display_name is not a string or None. + """ + if not isinstance(mittag_leffler_display_name, (str, type(None))): + raise TypeError('mittag_leffler_display_name must be a string or None.') + self._mittag_leffler_display_name = mittag_leffler_display_name + + # ------------------------------------------------------------------ + # Other methods + # ------------------------------------------------------------------ + + def calculate_width(self, Q: Q_type = None) -> np.ndarray: + """ + Calculate the diffusion damping epsilon = hbar * D * Q**2. + + This is both the half width at half maximum of the elastic Lorentzian and the ``damping`` + of the Mittag-Leffler component; the two share it by construction. + + Parameters + ---------- + Q : Q_type, default=None + Scattering vector in 1/angstrom. If None, the Q stored in the model is used. + + Returns + ------- + np.ndarray + Damping values in the unit of the model (e.g. meV). + """ + Q = self._ensure_Q(Q) + + unit_conversion_factor = self._hbar * self.diffusion_coefficient / (self._angstrom**2) + unit_conversion_factor.convert_unit(self.x_unit) + return Q**2 * unit_conversion_factor.value + + def calculate_relaxation_rate(self, Q: Q_type = None) -> np.ndarray: + """ + Calculate the Mittag-Leffler relaxation rate hbar / tau_R at each Q. + + If the relaxation rate is allowed to vary with Q, the requested Q values are matched + against the Q stored in the model and the corresponding per-Q rates are returned. Otherwise + the shared rate is returned for every Q. + + Parameters + ---------- + Q : Q_type, default=None + Scattering vector in 1/angstrom. If None, the Q stored in the model is used. + + Returns + ------- + np.ndarray + Relaxation rates in the unit of the model (e.g. meV). + + Raises + ------ + ValueError + If Q-variation is enabled but Q has not been set on the model yet. + """ + Q = self._ensure_Q(Q) + + if self._allow_Q_variation['relaxation_rate'] is True: + if not self._relaxation_rate_list: + raise ValueError( + 'Relaxation rate Q-variation list is empty. ' + 'Set Q before calling calculate_relaxation_rate.' + ) + indices = self._match_Q_indices(Q) + return np.array([self._relaxation_rate_list[i].value for i in indices]) + + return self.relaxation_rate.value * np.ones_like(Q) + + def calculate_alpha(self, Q: Q_type = None) -> np.ndarray: + """ + Calculate the Mittag-Leffler form parameter alpha at each Q. + + If alpha is allowed to vary with Q, the requested Q values are matched against the Q stored + in the model and the corresponding per-Q values are returned. Otherwise the shared alpha is + returned for every Q. + + Parameters + ---------- + Q : Q_type, default=None + Scattering vector in 1/angstrom. If None, the Q stored in the model is used. + + Returns + ------- + np.ndarray + Form parameters (dimensionless). + + Raises + ------ + ValueError + If Q-variation is enabled but Q has not been set on the model yet. + """ + Q = self._ensure_Q(Q) + + if self._allow_Q_variation['alpha'] is True: + if not self._alpha_list: + raise ValueError( + 'Alpha Q-variation list is empty. Set Q before calling calculate_alpha.' + ) + indices = self._match_Q_indices(Q) + return np.array([self._alpha_list[i].value for i in indices]) + + return self.alpha.value * np.ones_like(Q) + + def calculate_EISF(self, Q: Q_type = None) -> np.ndarray: + """ + Calculate the Elastic Incoherent Structure Factor (EISF), i.e. A_0. + + Parameters + ---------- + Q : Q_type, default=None + Scattering vector in 1/angstrom. + + Returns + ------- + np.ndarray + EISF values (dimensionless). + """ + Q = self._ensure_Q(Q) + + if self._allow_Q_variation['A_0'] is True: + indices = self._match_Q_indices(Q) + return np.array([self._A_0_list[i].value for i in indices]) + + return self.A_0.value * np.ones_like(Q) + + def calculate_QISF(self, Q: Q_type = None) -> np.ndarray: + """ + Calculate the Quasi-Elastic Incoherent Structure Factor (QISF), i.e. A_1 = 1 - A_0. + + Parameters + ---------- + Q : Q_type, default=None + Scattering vector in 1/angstrom. + + Returns + ------- + np.ndarray + QISF values (dimensionless). + """ + Q = self._ensure_Q(Q) + + if self._allow_Q_variation['A_0'] is True: + indices = self._match_Q_indices(Q) + return np.array([self._A_1_list[i].value for i in indices]) + + return self.A_1.value * np.ones_like(Q) + + def calculate_relaxation_rate_spectrum(self, rate: np.ndarray, Q: Q_type = None) -> np.ndarray: + r""" + Calculate the relaxation rate spectrum p(lambda) of Eq. (37) of Hassani et al. + + $$ p_{ML}(\lambda) = \frac{\sin(\pi\alpha)} {\pi\lambda((\lambda\tau_R)^{-\alpha} + + (\lambda\tau_R)^{\alpha} + 2\cos(\pi\alpha))} $$ + + This is the distribution of exponential relaxation rates whose superposition gives the + Mittag-Leffler relaxation function, and is what the paper plots in its Fig. 6. It is a + property of the internal dynamics alone, so the diffusion damping does not enter. + + Parameters + ---------- + rate : np.ndarray + Relaxation rates lambda at which to evaluate the spectrum, expressed as energies in + x_unit. Must be strictly positive. + Q : Q_type, default=None + Scattering vector in 1/angstrom. If None, the Q stored in the model is used. + + Returns + ------- + np.ndarray + Array of shape ``(len(Q), len(rate))`` holding p(lambda) for each Q, with unit + ``1/x_unit``. + + Raises + ------ + ValueError + If any requested rate is not strictly positive. + """ + rate = np.atleast_1d(np.asarray(rate, dtype=float)) + if np.any(rate <= 0): + raise ValueError('rate must be strictly positive.') + + Q = self._ensure_Q(Q) + alpha = self.calculate_alpha(Q)[:, np.newaxis] + # lambda * tau_R, with tau_R expressed through the relaxation rate hbar/tau_R + reduced_rate = rate[np.newaxis, :] / self.calculate_relaxation_rate(Q)[:, np.newaxis] + + numerator = np.sin(np.pi * alpha) + denominator = ( + np.pi + * rate[np.newaxis, :] + * (reduced_rate**-alpha + reduced_rate**alpha + 2 * np.cos(np.pi * alpha)) + ) + return numerator / denominator + + def calculate_energy_barrier_distribution( + self, barrier_height: np.ndarray, Q: Q_type = None + ) -> np.ndarray: + r""" + Calculate the energy barrier distribution P(h) of Eq. (48) of Hassani et al. + + $$ P_{ML}(h) = \frac{2h\sin(\pi\alpha)} {\pi(e^{-\alpha h^2} + e^{\alpha h^2} + + 2\cos(\pi\alpha))} $$ + + where $h = \Delta E / (k_B T)$ is the dimensionless barrier height of Zwanzig's rough + harmonic potential. This is what the paper plots in the right panel of its Fig. 7. As + $\alpha \to 1$ it collapses onto $\delta(h)$, a smooth potential; as $\alpha \to 0$ it + broadens to include arbitrarily high barriers. + + Parameters + ---------- + barrier_height : np.ndarray + Dimensionless barrier heights h at which to evaluate the distribution. + Q : Q_type, default=None + Scattering vector in 1/angstrom. If None, the Q stored in the model is used. + + Returns + ------- + np.ndarray + Array of shape ``(len(Q), len(barrier_height))`` holding P(h) for each Q. + """ + barrier_height = np.atleast_1d(np.asarray(barrier_height, dtype=float)) + + Q = self._ensure_Q(Q) + alpha = self.calculate_alpha(Q)[:, np.newaxis] + h = barrier_height[np.newaxis, :] + + # Factor exp(alpha * h**2) out of the denominator, so tall barriers underflow smoothly to + # zero instead of overflowing exp() and leaving inf/inf behind. + damping = np.exp(-alpha * h**2) + numerator = 2 * h * np.sin(np.pi * alpha) * damping + denominator = np.pi * (damping**2 + 1 + 2 * np.cos(np.pi * alpha) * damping) + return numerator / denominator + + def create_component_collections(self) -> list[ComponentCollection]: + r""" + Create ComponentCollections for the MittagLefflerDiffusion model at the given Q values. + + Each collection holds the elastic Lorentzian (area $K \cdot EISF$, HWHM $\hbar D q^2$) and + the Mittag-Leffler component (scale $K \cdot (1 - EISF)$, damping $\hbar D q^2$). The per-Q + parameter lists are recreated here so the built components are backed by the very + parameters stored in the lists, keeping ``calculate_*`` in sync with the components. The + created collections are installed on the model, so the returned list is the live one. + + Returns + ------- + list[ComponentCollection] + List of ComponentCollections with a Lorentzian and a Mittag-Leffler component for each + Q value. + """ + if self.Q is None: + self._A_0_list = [] + self._A_1_list = [] + self._relaxation_rate_list = [] + self._alpha_list = [] + self._component_collections = [] + return self._component_collections + + Q = self.Q.values + + if self._allow_Q_variation['A_0'] is True: + self._A_0_list, self._A_1_list = self._create_A0_A1_parameter_lists() + else: + self._A_0_list = [] + self._A_1_list = [] + + if self._allow_Q_variation['relaxation_rate'] is True: + self._relaxation_rate_list = self._create_relaxation_rate_parameter_list() + else: + self._relaxation_rate_list = [] + + if self._allow_Q_variation['alpha'] is True: + self._alpha_list = self._create_alpha_parameter_list() + else: + self._alpha_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, + ) + + # easyscience propagates inf bounds through arithmetic, producing inf/inf=nan + # as a transient intermediate. Python's min/max ignore nan so the final bounds + # are correct; suppress the spurious numpy RuntimeWarning. + with np.errstate(invalid='ignore'): + component_collection_list[i].append_component( + self._create_lorentzian_component(Q_value, i) + ) + component_collection_list[i].append_component( + self._create_mittag_leffler_component(Q_value, i) + ) + + self._component_collections = component_collection_list + return self._component_collections + + def get_fit_targets(self) -> list[FitTarget]: + """ + Get the fittable predictions of the MittagLefflerDiffusion model as FitTargets. + + The model predicts three Q-dependent quantities: ``'width'`` (the shared damping ``hbar * D + * Q**2``), ``'area'`` (the Mittag-Leffler weight ``scale * QISF(Q)``) and + ``'elastic_area'`` (the elastic Lorentzian weight ``scale * EISF(Q)``). The base class + implementation is replaced rather than extended, because here it is the *elastic* line that + is a Lorentzian, so the base's ``'area'`` key would point at the wrong component. + + Returns + ------- + list[FitTarget] + The fittable predictions of this model. + """ + return [ + FitTarget( + name='area', + dataset_key=f'{self.mittag_leffler_name} scale', + function=lambda Q, model=self, **_: model.calculate_QISF(Q) * model.scale.value, + label=f'{self.display_name} area', + x_unit=CANONICAL_Q_UNIT, + y_unit=str(self.scale.unit), + ), + FitTarget( + name='width', + dataset_key=f'{self.lorentzian_name} width', + function=lambda Q, model=self, **_: model.calculate_width(Q), + label=f'{self.display_name} width', + x_unit=CANONICAL_Q_UNIT, + y_unit=str(self.x_unit), + ), + FitTarget( + name='elastic_area', + dataset_key=f'{self.lorentzian_name} area', + function=lambda Q, model=self, **_: model.calculate_EISF(Q) * model.scale.value, + label=f'{self.display_name} elastic_area', + x_unit=CANONICAL_Q_UNIT, + y_unit=str(self.scale.unit), + ), + ] + + def get_global_variables(self) -> list[Parameter]: + """ + Get all global variables from the diffusion model. + + Returns + ------- + list[Parameter] + A list of all global variables from the diffusion model. + """ + variables = [self.scale, self.diffusion_coefficient] + + if self._allow_Q_variation['A_0'] is False: + variables.append(self.A_0) + variables.append(self.A_1) + + if self._allow_Q_variation['relaxation_rate'] is False: + variables.append(self.relaxation_rate) + + if self._allow_Q_variation['alpha'] is False: + variables.append(self.alpha) + + return variables + + def get_independent_variables(self, Q_index: int | None = None) -> list[Parameter]: + """ + Get the independent variables from the diffusion model. + + The per-Q relaxation rate and alpha parameters are the components' own parameters, so they + are reached through the component collections; only the per-Q A_0/A_1 pairs, which the + component areas merely depend on, are listed here. + + Parameters + ---------- + Q_index : int | None, default=None + The index of the Q value for which to get the independent variables. If None, + independent variables for all Q values will be included. + + Returns + ------- + list[Parameter] + List of independent variables in the model. + """ + verify_Q_index(Q_index=Q_index, Q=self.Q, allow_none=True) + + variables = [] + if self._allow_Q_variation['A_0'] is True: + if Q_index is None: + variables.extend(self._A_0_list) + variables.extend(self._A_1_list) + else: + variables.append(self._A_0_list[Q_index]) + variables.append(self._A_1_list[Q_index]) + + return variables + + def get_all_variables(self, Q_index: int | None = None) -> list[DescriptorNumber]: + """ + Get a list of all variables (Parameters and Descriptors) in the model. + + Parameters + ---------- + Q_index : int | None, default=None + The index of the Q value for which to get the variables. If None, variables for all Q + values will be included. + + Returns + ------- + list[DescriptorNumber] + List of all variables in the model. + """ + verify_Q_index(Q_index=Q_index, Q=self.Q, allow_none=True) + + variables = self.get_global_variables() + variables.extend(self.get_independent_variables(Q_index=Q_index)) + + if Q_index is None: + for component_collection in self._component_collections: + variables.extend(component_collection.get_all_variables()) + else: + variables.extend(self._component_collections[Q_index].get_all_variables()) + + return variables + + # ------------------------------------------------------------------ + # Private methods for init + # ------------------------------------------------------------------ + + def _create_Q_variation_dict(self, allow_Q_variation: dict | None) -> dict: + """ + Create the allow_Q_variation dict, ensuring it has the correct keys and default values. + + Parameters + ---------- + allow_Q_variation : dict | None + Dict describing whether to allow Q variation of A_0, relaxation_rate and alpha. + + Raises + ------ + TypeError + If allow_Q_variation is not a dict or None. + ValueError + If allow_Q_variation contains unknown keys. + + Returns + ------- + dict + A dict with keys 'A_0', 'relaxation_rate' and 'alpha'. + """ + allow_Q_variation_default = { + 'A_0': False, + 'relaxation_rate': False, + 'alpha': False, + } + allowed_keys = set(allow_Q_variation_default) + + if allow_Q_variation is None: + allow_Q_variation = {} + if not isinstance(allow_Q_variation, dict): + raise TypeError('allow_Q_variation must be a dict or None.') + + unknown_keys = set(allow_Q_variation) - allowed_keys + if unknown_keys: + raise ValueError(f'Unknown keys in allow_Q_variation: {unknown_keys}') + + return {**allow_Q_variation_default, **allow_Q_variation} + + @staticmethod + def _create_diffusion_coefficient_parameter( + diffusion_coefficient: Numeric, + ) -> Parameter: + """ + Create the global diffusion coefficient parameter. + + Parameters + ---------- + diffusion_coefficient : Numeric + The value for D in m^2/s. + + Raises + ------ + TypeError + If diffusion_coefficient is not a number. + ValueError + If diffusion_coefficient is smaller than ``DIFFUSION_COEFFICIENT_MIN``. + + Returns + ------- + Parameter + The created diffusion coefficient parameter. + """ + if not isinstance(diffusion_coefficient, Numeric): + raise TypeError('diffusion_coefficient must be a number.') + if float(diffusion_coefficient) < DIFFUSION_COEFFICIENT_MIN: + raise ValueError( + f'diffusion_coefficient must be at least {DIFFUSION_COEFFICIENT_MIN}.' + ) + return Parameter( + name='diffusion_coefficient', + value=float(diffusion_coefficient), + fixed=False, + unit='m**2/s', + min=DIFFUSION_COEFFICIENT_MIN, + ) + + @staticmethod + def _create_A0_A1_parameters(A_0: Numeric) -> tuple[Parameter, Parameter]: + """ + Create the shared A_0 and A_1 parameters. + + Parameters + ---------- + A_0 : Numeric + The value for the A_0 parameter. + + Raises + ------ + TypeError + If A_0 is not a number. + ValueError + If A_0 is not between 0 and 1. + + Returns + ------- + tuple[Parameter, Parameter] + A tuple containing the A_0 and A_1 parameters. + """ + if not isinstance(A_0, Numeric): + raise TypeError('A_0 must be a number.') + if float(A_0) < 0 or float(A_0) > 1: + raise ValueError('A_0 must be between 0 and 1.') + + A_0_parameter = Parameter(name='A_0', value=float(A_0), fixed=False, min=0.0, max=1.0) + A_1_parameter = Parameter.from_dependency( + name='A_1', + dependency_expression='1 - A_0', + dependency_map={'A_0': A_0_parameter}, + ) + return A_0_parameter, A_1_parameter + + def _create_relaxation_rate_parameter(self, relaxation_rate: Numeric) -> Parameter: + """ + Create the shared relaxation rate parameter. + + Parameters + ---------- + relaxation_rate : Numeric + The value for the relaxation rate in x_unit. + + Raises + ------ + TypeError + If relaxation_rate is not a number. + ValueError + If relaxation_rate is less than the minimum width. + + Returns + ------- + Parameter + The created relaxation rate parameter. + """ + if not isinstance(relaxation_rate, Numeric): + raise TypeError('relaxation_rate must be a number.') + if float(relaxation_rate) < MINIMUM_WIDTH: + raise ValueError(f'relaxation_rate must be at least {MINIMUM_WIDTH}.') + + return Parameter( + name='relaxation_rate', + value=float(relaxation_rate), + fixed=False, + min=MINIMUM_WIDTH, + unit=self.x_unit, + ) + + @staticmethod + def _create_alpha_parameter(alpha: Numeric) -> Parameter: + """ + Create the shared form parameter alpha. + + Parameters + ---------- + alpha : Numeric + The value for the form parameter. + + Raises + ------ + TypeError + If alpha is not a number. + ValueError + If alpha does not lie in (0, 1]. + + Returns + ------- + Parameter + The created alpha parameter. + """ + if not isinstance(alpha, Numeric): + raise TypeError('alpha must be a number.') + if not MINIMUM_ALPHA <= float(alpha) <= MAXIMUM_ALPHA: + raise ValueError('alpha must be greater than zero and at most one.') + + return Parameter( + name='alpha', + value=float(alpha), + fixed=False, + min=MINIMUM_ALPHA, + max=MAXIMUM_ALPHA, + unit='dimensionless', + ) + + def _create_A0_A1_parameter_lists(self) -> tuple[list[Parameter], list[Parameter]]: + """ + Create per-Q A_0 and A_1 parameters, seeded from the shared A_0. + + Returns + ------- + tuple[list[Parameter], list[Parameter]] + The per-Q A_0 parameters and the per-Q A_1 parameters derived from them. + """ + A_0_list = [] + A_1_list = [] + for _ in range(len(self.Q)): + # 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). + a_0 = Parameter( + name=f'{self.name} A_0', + display_name='A_0', + value=float(self.A_0.value), + fixed=False, + min=0.0, + max=1.0, + ) + a_1 = Parameter.from_dependency( + name=f'{self.name} A_1', + dependency_expression='1 - A_0', + dependency_map={'A_0': a_0}, + ) + A_0_list.append(a_0) + A_1_list.append(a_1) + + return A_0_list, A_1_list + + def _create_relaxation_rate_parameter_list(self) -> list[Parameter]: + """ + Create per-Q relaxation rate parameters, seeded from the shared relaxation rate. + + Returns + ------- + list[Parameter] + The per-Q relaxation rate parameters, named after the Mittag-Leffler component's own + width parameter so they slot straight into it. + """ + return [ + Parameter( + name=f'{self.mittag_leffler_name} width', + value=float(self.relaxation_rate.value), + fixed=False, + min=MINIMUM_WIDTH, + unit=self.x_unit, + ) + for _ in range(len(self.Q)) + ] + + def _create_alpha_parameter_list(self) -> list[Parameter]: + """ + Create per-Q alpha parameters, seeded from the shared alpha. + + Returns + ------- + list[Parameter] + The per-Q form parameters, named after the Mittag-Leffler component's own alpha + parameter so they slot straight into it. + """ + return [ + Parameter( + name=f'{self.mittag_leffler_name} alpha', + value=float(self.alpha.value), + fixed=False, + min=MINIMUM_ALPHA, + max=MAXIMUM_ALPHA, + unit='dimensionless', + ) + for _ in range(len(self.Q)) + ] + + # ------------------------------------------------------------------ + # Private methods + # ------------------------------------------------------------------ + + def _create_lorentzian_component(self, Q_value: float, Q_index: int) -> Lorentzian: + """ + Build the elastic Lorentzian for one Q value. + + Its width is the diffusion damping ``hbar * D * Q**2`` and its area is ``scale * A_0``; + both are always dependent parameters, since the elastic line is entirely determined by the + global diffusion coefficient and the elastic fraction. + + Parameters + ---------- + Q_value : float + Scattering vector in 1/angstrom. + Q_index : int + Index of this Q value in the stored Q. + + Returns + ------- + Lorentzian + The configured elastic Lorentzian component. + """ + component = Lorentzian( + name=self.lorentzian_name, + display_name=self.lorentzian_display_name, + x_unit=self.x_unit, + y_unit=self.y_unit, + ) + component.width.make_dependent_on( + dependency_expression=self._write_damping_dependency_expression(Q_value), + dependency_map=self._write_damping_dependency_map_expression(), + desired_unit=self.x_unit, + ) + component.area.make_dependent_on( + dependency_expression='scale * A_0', + dependency_map=self._write_amplitude_dependency_map_expression(Q_index, elastic=True), + ) + return component + + def _create_mittag_leffler_component( + self, Q_value: float, Q_index: int + ) -> DiffusionDampedMittagLeffler: + """ + Build the Mittag-Leffler component for one Q value. + + Its damping is the diffusion damping ``hbar * D * Q**2`` and its scale is ``scale * A_1``, + both always dependent. Its width and alpha are either the per-Q parameters from the + corresponding lists, or made dependent on the shared parameters. + + Parameters + ---------- + Q_value : float + Scattering vector in 1/angstrom. + Q_index : int + Index of this Q value in the stored Q. + + Returns + ------- + DiffusionDampedMittagLeffler + The configured Mittag-Leffler component. + """ + component = DiffusionDampedMittagLeffler( + name=self.mittag_leffler_name, + display_name=self.mittag_leffler_display_name, + x_unit=self.x_unit, + y_unit=self.y_unit, + ) + + if self._allow_Q_variation['relaxation_rate'] is True: + component._width = self._relaxation_rate_list[Q_index] # ruff: ignore[private-member-access] + else: + component.width.make_dependent_on( + dependency_expression='relaxation_rate', + dependency_map={'relaxation_rate': self.relaxation_rate}, + desired_unit=self.x_unit, + ) + + if self._allow_Q_variation['alpha'] is True: + component._alpha = self._alpha_list[Q_index] # ruff: ignore[private-member-access] + else: + component.alpha.make_dependent_on( + dependency_expression='alpha', + dependency_map={'alpha': self.alpha}, + desired_unit='dimensionless', + ) + + component.damping.make_dependent_on( + dependency_expression=self._write_damping_dependency_expression(Q_value), + dependency_map=self._write_damping_dependency_map_expression(), + desired_unit=self.x_unit, + ) + component.scale.make_dependent_on( + dependency_expression='scale * A_1', + dependency_map=self._write_amplitude_dependency_map_expression(Q_index, elastic=False), + ) + return component + + def _on_Q_change(self) -> None: + """ + Handle changes to the Q values. + + Rebuilds the component collections; the per-Q parameter lists are recreated inside + ``create_component_collections``. + """ + self.create_component_collections() + + def _convert_extra_x_unit_parameters(self, unit_str: str) -> None: + """ + Convert the shared relaxation rate template to the new x-axis unit. + + The per-Q relaxation rate list (when Q-variation is enabled) holds the very Parameter + objects used by the components, so those are converted in place with the collections. + + Parameters + ---------- + unit_str : str + The new x-axis unit as a string. + """ + convert_parameter_unit(self._relaxation_rate, unit_str) + + def _write_damping_dependency_expression(self, Q: float) -> str: + """ + Write the dependency expression for the diffusion damping ``hbar * D * Q**2``. + + Parameters + ---------- + Q : float + Scattering vector in 1/angstrom. + + Raises + ------ + TypeError + If Q is not a float. + + Returns + ------- + str + Dependency expression for the damping. + """ + if not isinstance(Q, (float)): + raise TypeError('Q must be a float.') + + # Q is given as a float, so we need to add the units + return f'hbar * D * {Q}**2 * 1/(angstrom**2)' + + def _write_damping_dependency_map_expression(self) -> dict[str, DescriptorNumber]: + """ + Write the dependency map for the diffusion damping. + + Returns + ------- + dict[str, DescriptorNumber] + Dependency map for the damping. + """ + return { + 'D': self.diffusion_coefficient, + 'hbar': self._hbar, + 'angstrom': self._angstrom, + } + + def _write_amplitude_dependency_map_expression( + self, Q_index: int, elastic: bool + ) -> dict[str, DescriptorNumber]: + """ + Write the dependency map for a component's amplitude. + + Parameters + ---------- + Q_index : int + Index of the Q value, used to pick the per-Q amplitude when Q-variation is enabled. + elastic : bool + True for the elastic Lorentzian's area (``scale * A_0``), False for the Mittag-Leffler + component's scale (``scale * A_1``). + + Returns + ------- + dict[str, DescriptorNumber] + Dependency map for the amplitude. + """ + if self._allow_Q_variation['A_0'] is True: + amplitude = self._A_0_list[Q_index] if elastic else self._A_1_list[Q_index] + else: + amplitude = self.A_0 if elastic else self.A_1 + + return {'scale': self.scale, 'A_0' if elastic else 'A_1': amplitude} + + # ------------------------------------------------------------------ + # dunder methods + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + """ + String representation of the MittagLefflerDiffusion model. + + Returns + ------- + str + String representation of the MittagLefflerDiffusion model. + """ + return ( + f'MittagLefflerDiffusion(display_name={self.display_name}, ' + f'x_unit={self.x_unit}, y_unit={self.y_unit}, \n' + f' diffusion_coefficient={self.diffusion_coefficient}, \n' + f' A_0={self.A_0}, A_1={self.A_1}, \n' + f' relaxation_rate={self.relaxation_rate}, \n' + f' alpha={self.alpha}, \n' + f' scale={self.scale})' + ) 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 9a20e53fc..15040a852 100644 --- a/src/easydynamics/sample_model/model_base.py +++ b/src/easydynamics/sample_model/model_base.py @@ -73,8 +73,15 @@ def __init__( f'got {type(components).__name__}' ) - self._components = ComponentCollection() + 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: @@ -313,7 +389,9 @@ def _convert_axis_unit(self, unit: str | sc.Unit, axis: str) -> None: method = f'convert_{axis}_unit' old_unit = self.x_unit if axis == 'x' else self.y_unit - children = [*self.components, *self._component_collections] + # Convert the template collection as a whole (not its unpacked components) so its own + # unit attribute is updated too; regenerated per-Q collections copy that attribute. + children = [self._components, *self._component_collections] convert_units_with_rollback([ (getattr(child, method), unit, old_unit) for child in children ]) @@ -394,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 c253590ec..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 # ------------------------------------------------------------------ @@ -181,7 +188,7 @@ def append_diffusion_model(self, diffusion_model: DiffusionModelBase) -> None: """ if not isinstance(diffusion_model, DiffusionModelBase): raise TypeError( - f'diffusion_model must be a DiffusionModelBase, got {type(diffusion_model).__name__}' # noqa: E501 + f'diffusion_model must be a DiffusionModelBase, got {type(diffusion_model).__name__}' # ruff: ignore[line-too-long] ) diffusion_model.Q = self.Q self._diffusion_models.append(diffusion_model) @@ -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 @@ -355,7 +362,7 @@ def temperature_unit(self, _value: str | sc.Unit) -> None: raise AttributeError( f'Temperature_unit is read-only. Use convert_temperature_unit to change the unit ' - f'between allowed types or create a new {self.__class__.__name__} with the desired unit.' # noqa: E501 + f'between allowed types or create a new {self.__class__.__name__} with the desired unit.' # ruff: ignore[line-too-long] ) def convert_temperature_unit(self, unit: str | sc.Unit) -> None: @@ -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..52360b518 100644 --- a/src/easydynamics/utils/plotting.py +++ b/src/easydynamics/utils/plotting.py @@ -4,7 +4,7 @@ import plopp as pp import scipp as sc -from plopp.backends.matplotlib.figure import InteractiveFigure +from plopp.backends.matplotlib.figure import WidgetFigure from plopp.plotting._slicer import SlicerPlot from plopp.plotting._slicer import _maybe_reduce_dim from plopp.widgets import slice_dims @@ -17,7 +17,7 @@ def slicerplot_with_residuals( keep: list[str] | str | None = None, operation: str = 'sum', **kwargs: object, -) -> InteractiveFigure: +) -> WidgetFigure: """ Create a SlicerPlot with an additional subplot for residuals. @@ -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 @@ -56,7 +56,7 @@ def slicerplot_with_residuals( Returns ------- - InteractiveFigure + WidgetFigure A figure containing the SlicerPlot and the residuals subplot. Raises diff --git a/src/easydynamics/utils/posterior_plotting.py b/src/easydynamics/utils/posterior_plotting.py new file mode 100644 index 000000000..dc930de5a --- /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 WidgetFigure + + +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], +) -> WidgetFigure: + """ + 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 + ------- + WidgetFigure + 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..a9d66dcf2 100644 --- a/src/easydynamics/utils/utils.py +++ b/src/easydynamics/utils/utils.py @@ -9,6 +9,7 @@ from easyscience.variable import DescriptorNumber from easyscience.variable import Parameter from numpy.typing import ArrayLike +from scipp import UnitError from scipp.constants import hbar as scipp_hbar from scipp.constants import k as scipp_k @@ -44,14 +45,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__}') @@ -182,7 +184,7 @@ def _validate_and_convert_Q( If Q is not a number, list, numpy array, or scipp Variable. ValueError If Q is a numpy array with more than 1 dimension, or if Q is a scipp Variable that does not - have a single dimension named 'Q'. + have a single dimension named 'Q', or if Q is not positive. Returns ------- @@ -208,6 +210,9 @@ def _validate_and_convert_Q( if Q.dims != ('Q',): raise ValueError("Q must have a single dimension named 'Q'.") Q = Q.to(unit=CANONICAL_Q_UNIT) + + if (Q.values <= 0).any(): + raise ValueError('Q values must be positive.') return Q @@ -222,7 +227,7 @@ def _validate_unit(unit: str | sc.Unit | None) -> sc.Unit | None: Raises ------ - TypeError + UnitError If unit is not None, a string, or a scipp Unit. Returns @@ -232,7 +237,7 @@ def _validate_unit(unit: str | sc.Unit | None) -> sc.Unit | None: """ if unit is not None and not isinstance(unit, (str, sc.Unit)): - raise TypeError(f'unit must be None, a string, or a scipp Unit, got {type(unit).__name__}') + raise UnitError(f'unit must be None, a string, or a scipp Unit, got {type(unit).__name__}') # if isinstance(unit, str): # unit = sc.Unit(unit) @@ -275,6 +280,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/integration/fitting/test_fitting_with_stretched_exponential.py b/tests/integration/fitting/test_fitting_with_stretched_exponential.py new file mode 100644 index 000000000..fbbac97dc --- /dev/null +++ b/tests/integration/fitting/test_fitting_with_stretched_exponential.py @@ -0,0 +1,112 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +""" +Round-trip fit of a StretchedExponential through Analysis1d. + +The transform of a stretched exponential has no closed form, so it is evaluated by quadrature. +That makes it worth checking not just that the numbers are right at fixed parameters — the unit +tests cover that — but that the profile stays smooth enough in the relaxation time and the +stretching exponent for a least-squares fitter to walk back to the truth from a poor start. +""" + +import numpy as np +import pytest +import scipp as sc + +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 import StretchedExponential + +TRUE_AREA = 1.0 +TRUE_RELAXATION_TIME = 8.0 +TRUE_BETA = 0.6 +NOISE_FRACTION = 0.01 + +TRUTHS = { + 'StretchedExponential area': TRUE_AREA, + 'StretchedExponential relaxation_time': TRUE_RELAXATION_TIME, + 'StretchedExponential beta': TRUE_BETA, +} + + +def build_analysis() -> Analysis1d: + """Fit a badly-started StretchedExponential against noisy data drawn from the true one.""" + energy_values = np.linspace(-1.5, 1.5, 301) + truth = StretchedExponential( + area=TRUE_AREA, + relaxation_time=TRUE_RELAXATION_TIME, + beta=TRUE_BETA, + ) + profile = truth.evaluate(energy_values) + noise = NOISE_FRACTION * profile.max() + observed = profile + np.random.default_rng(0).normal(0.0, noise, size=profile.shape) + + experiment = Experiment( + data=sc.DataArray( + data=sc.array( + dims=['Q', 'energy'], + values=observed[None, :], + variances=np.full_like(observed, noise**2)[None, :], + ), + coords={ + 'Q': sc.array(dims=['Q'], values=[1.0], unit='1/Angstrom'), + 'energy': sc.array(dims=['energy'], values=energy_values, unit='meV'), + }, + ) + ) + + # Start far from the truth: half the relaxation time, an almost unstretched exponent and half + # again the area. The centre is left at its default, fixed at zero. + analysis = Analysis1d( + display_name='StretchedExponentialIntegration', + experiment=experiment, + sample_model=SampleModel( + components=StretchedExponential(area=1.5, relaxation_time=4.0, beta=0.9) + ), + instrument_model=InstrumentModel(), + Q_index=0, + ) + # The energy offset shifts the spectrum exactly as the component's centre does, so leaving + # both free would make the model unidentifiable. + analysis.instrument_model.fix_energy_offset(Q_index=0) + return analysis + + +@pytest.fixture(scope='module') +def fitted_analysis(): + analysis = build_analysis() + analysis.fit() + return analysis + + +class TestFittingWithStretchedExponential: + def test_fit_describes_the_data(self): + # WHEN + analysis = build_analysis() + + # THEN + results = analysis.fit() + + # EXPECT + assert results.success + assert results.reduced_chi2 < 1.5 + + def test_the_free_parameters_are_the_three_shape_parameters(self, fitted_analysis): + # THEN + names = {parameter.name for parameter in fitted_analysis.get_free_parameters()} + + # EXPECT the centre and the energy offset stay fixed + assert names == set(TRUTHS) + + @pytest.mark.parametrize('name', list(TRUTHS)) + def test_fit_recovers_the_true_parameters(self, fitted_analysis, name): + # WHEN + parameter = next(p for p in fitted_analysis.get_free_parameters() if p.name == name) + + # THEN EXPECT the truth within a few standard errors, on a usable uncertainty + assert np.isfinite(parameter.error) + assert parameter.error > 0.0 + assert abs(parameter.value - TRUTHS[name]) < 4 * parameter.error diff --git a/tests/unit/easydynamics/analysis/test_analysis.py b/tests/unit/easydynamics/analysis/test_analysis.py index 2a67357ce..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') @@ -931,6 +1113,30 @@ def test_create_model_array(self, analysis): np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]), ) + def test_create_model_array_and_residuals_with_counts_data(self): + "Regression: model arrays must carry the sample model y_unit so counts data works" + # WHEN + Q = sc.array(dims=['Q'], values=[1.0, 2.0], unit='1/Angstrom') + energy = sc.linspace('energy', -5.0, 5.0, num=20, unit='meV') + values = np.ones((2, 20)) + data_array = sc.DataArray( + data=sc.array(dims=['Q', 'energy'], values=values, variances=values, unit='counts'), + coords={'Q': Q, 'energy': energy}, + ) + analysis = Analysis( + experiment=Experiment(data=data_array), + sample_model=SampleModel(y_unit='counts', components=Gaussian(y_unit='counts')), + instrument_model=InstrumentModel(), + ) + + # THEN + model_array = analysis._create_model_array() + residuals = analysis._create_residuals_array() + + # EXPECT + assert model_array.unit == sc.Unit('counts') + assert residuals.unit == sc.Unit('counts') + def test_create_residuals_array(self, analysis): # WHEN # Mock the _create_model_array method to return a specific model array @@ -1117,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 86e35fe8b..40ae70a0b 100644 --- a/tests/unit/easydynamics/analysis/test_analysis1d.py +++ b/tests/unit/easydynamics/analysis/test_analysis1d.py @@ -8,15 +8,23 @@ import numpy as np import pytest import scipp as sc +from easyscience.fitting import AvailableMinimizers from easyscience.variable import Parameter from easydynamics.analysis.analysis1d import Analysis1d from easydynamics.experiment import Experiment from easydynamics.sample_model import InstrumentModel +from easydynamics.sample_model import ResolutionModel from easydynamics.sample_model import SampleModel 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: @@ -131,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 @@ -340,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) @@ -354,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 @@ -770,6 +861,52 @@ def test_create_model_array(self, analysis1d): np.array([1.0, 2.0, 3.0]), ) + @pytest.fixture + def analysis1d_counts(self): + "Analysis1d with data and all models in counts, as loaded from a real experiment" + Q = sc.array(dims=['Q'], values=[1.0], unit='1/Angstrom') + energy = sc.linspace('energy', -5.0, 5.0, num=20, unit='meV') + values = np.ones((1, 20)) + data_array = sc.DataArray( + data=sc.array(dims=['Q', 'energy'], values=values, variances=values, unit='counts'), + coords={'Q': Q, 'energy': energy}, + ) + + return Analysis1d( + experiment=Experiment(data=data_array), + sample_model=SampleModel(y_unit='counts', components=Gaussian(y_unit='counts')), + instrument_model=InstrumentModel( + resolution_model=ResolutionModel(components=Gaussian(width=0.5)) + ), + Q_index=0, + ) + + def test_model_array_has_sample_model_y_unit(self, analysis1d_counts): + "Regression: model arrays must carry the sample model y_unit, not dimensionless" + # WHEN THEN + model_array = analysis1d_counts._create_model_array() + + # EXPECT + assert model_array.unit == sc.Unit('counts') + + def test_residuals_and_datagroup_with_counts_data(self, analysis1d_counts): + "Regression: residuals (data - model) must work when the data is not dimensionless" + # WHEN THEN + datagroup = analysis1d_counts.data_and_model_to_datagroup(include_residuals=True) + + # EXPECT + assert datagroup['Model'].unit == sc.Unit('counts') + assert datagroup['Residuals'].unit == sc.Unit('counts') + + def test_convolver_gets_sample_model_units(self, analysis1d_counts): + "Regression: the convolver must be built with the sample model's units" + # WHEN THEN + convolver = analysis1d_counts._create_convolver() + + # EXPECT + assert convolver.x_unit == analysis1d_counts.sample_model.x_unit + assert convolver.y_unit == 'counts' + @pytest.mark.parametrize( 'add_background', [True, False], @@ -935,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())), @@ -957,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): @@ -1045,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): @@ -1088,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) @@ -1109,41 +1397,47 @@ def test_repr(self, analysis1d): assert 'display_name=' in repr_str assert 'Q_index=' in repr_str + ############# + # Change handlers + ############# -def _coverage_analysis1d(): - Q = sc.array(dims=['Q'], values=[1, 2, 3], unit='1/Angstrom') - energy = sc.array(dims=['energy'], values=[10.0, 20.0, 30.0], unit='meV') - data = sc.array( - dims=['Q', 'energy'], - values=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], - variances=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], - ) - data_array = sc.DataArray(data=data, coords={'Q': Q, 'energy': energy}) - return Analysis1d( - display_name='CoverageAnalysis', - experiment=Experiment(data=data_array), - sample_model=SampleModel(components=Gaussian()), - instrument_model=InstrumentModel(), - Q_index=0, - ) + @staticmethod + def _coverage_analysis1d(): + Q = sc.array(dims=['Q'], values=[1, 2, 3], unit='1/Angstrom') + energy = sc.array(dims=['energy'], values=[10.0, 20.0, 30.0], unit='meV') + data = sc.array( + dims=['Q', 'energy'], + values=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], + variances=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], + ) + data_array = sc.DataArray(data=data, coords={'Q': Q, 'energy': energy}) + return Analysis1d( + display_name='CoverageAnalysis', + experiment=Experiment(data=data_array), + sample_model=SampleModel(components=Gaussian()), + instrument_model=InstrumentModel(), + Q_index=0, + ) + def test_on_Q_index_changed_with_none_clears_masked_energy(self): + # WHEN an analysis whose Q_index has been cleared + analysis1d = self._coverage_analysis1d() + analysis1d._Q_index = None + + # THEN the Q-index-changed handler runs + analysis1d._on_Q_index_changed() -def test_on_Q_index_changed_with_none_clears_masked_energy(): - # GIVEN an analysis whose Q_index has been cleared - analysis1d = _coverage_analysis1d() - analysis1d._Q_index = None - # WHEN the Q-index-changed handler runs - analysis1d._on_Q_index_changed() - # EXPECT masked energy cleared and convolver marked dirty - assert analysis1d._masked_energy is None - assert analysis1d._convolver_is_dirty is True - - -def test_on_experiment_changed_refreshes_masked_energy_when_Q_index_set(): - # GIVEN an analysis with a Q_index already set - analysis1d = _coverage_analysis1d() - # WHEN the experiment-changed handler runs - analysis1d._on_experiment_changed() - # EXPECT masked energy refreshed and convolver marked dirty - assert analysis1d._masked_energy is not None - assert analysis1d._convolver_is_dirty is True + # EXPECT masked energy 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_analytical_convolution.py b/tests/unit/easydynamics/convolution/test_analytical_convolution.py index 55c6cc297..54601dd29 100644 --- a/tests/unit/easydynamics/convolution/test_analytical_convolution.py +++ b/tests/unit/easydynamics/convolution/test_analytical_convolution.py @@ -114,7 +114,7 @@ def test_convolution( # THEN # Mock _convolute_analytic_pair to return 1.0 for any input pair - def mock_convolute_analytic_pair(sample_component, resolution_component): # noqa: ARG001 + def mock_convolute_analytic_pair(sample_component, resolution_component): # ruff: ignore[unused-function-argument] return np.full_like(default_analytical_convolution.energy.values, fill_value=1.0) with patch.object( @@ -166,7 +166,7 @@ def test_convolution_components( # THEN # Mock _convolute_analytic_pair to return 1.0 for any input pair - def mock_convolute_analytic_pair(sample_component, resolution_component): # noqa: ARG001 + def mock_convolute_analytic_pair(sample_component, resolution_component): # ruff: ignore[unused-function-argument] return np.full_like(default_analytical_convolution.energy.values, fill_value=1.0) with patch.object( diff --git a/tests/unit/easydynamics/convolution/test_convolution.py b/tests/unit/easydynamics/convolution/test_convolution.py index 818767983..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: @@ -137,6 +139,31 @@ def test_init_components(self, convolution_with_components): assert convolution_with_components._convolution_plan_is_current() is True assert convolution_with_components._reactions_enabled is True + def test_convolution_plan_collections_inherit_units(self): + "Regression: internal plan collections must carry the Convolution's units" + # WHEN + convolution = Convolution( + energy=np.linspace(-10, 10, 101), + sample_components=Gaussian( + name='Gaussian1', area=2.0, center=0.1, width=0.4, y_unit='counts' + ), + resolution_components=Gaussian(name='GaussianRes', area=3.0, center=0.2, width=0.5), + y_unit='counts', + ) + + # THEN EXPECT + for collection in ( + convolution._analytical_sample_components, + convolution._delta_sample_components, + convolution._numerical_sample_components, + ): + assert collection.x_unit == 'meV' + assert collection.y_unit == 'counts' + + # Wrapping a bare ModelComponent must mirror the component's own units + assert convolution._sample_components.y_unit == 'counts' + assert convolution._resolution_components.y_unit == 'dimensionless' + def test_convolution_plan_is_built_when_invalid(self, default_convolution): """ Test that convolution plan is built when invalid. @@ -377,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 @@ -387,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( @@ -607,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..c941899f0 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 @@ -73,7 +74,7 @@ def test_init_energy_numerical_none_offset(self): assert convolution_base._resolution_components is None @pytest.mark.parametrize( - 'kwargs, expected_message', + 'kwargs, expected_message, expected_error', [ ( { @@ -84,6 +85,7 @@ def test_init_energy_numerical_none_offset(self): 'energy_offset': 0, }, 'Energy must be', + TypeError, ), ( { @@ -97,6 +99,7 @@ def test_init_energy_numerical_none_offset(self): '`sample_components` is an instance of str, ' 'but must be a ComponentCollection or ModelComponent.' ), + TypeError, ), ( { @@ -110,6 +113,7 @@ def test_init_energy_numerical_none_offset(self): '`resolution_components` is an instance of str, ' 'but must be a ComponentCollection or ModelComponent.' ), + TypeError, ), ( { @@ -120,6 +124,7 @@ def test_init_energy_numerical_none_offset(self): 'energy_offset': 0, }, 'unit must be ', + UnitError, ), ( { @@ -130,6 +135,7 @@ def test_init_energy_numerical_none_offset(self): 'energy_offset': 0, }, 'unit must be ', + UnitError, ), ( { @@ -140,12 +146,13 @@ def test_init_energy_numerical_none_offset(self): 'energy_offset': 'invalid', }, 'Energy_offset must be ', + TypeError, ), ], ) - def test_input_type_validation_raises(self, kwargs, expected_message): + def test_input_type_validation_raises(self, kwargs, expected_message, expected_error): # WHEN THEN EXPECT - with pytest.raises(TypeError, match=expected_message): + with pytest.raises(expected_error, match=expected_message): ConvolutionBase(**kwargs) @pytest.mark.parametrize( @@ -403,3 +410,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.py b/tests/unit/easydynamics/convolution/test_numerical_convolution.py index 5db8bc15d..c36aff35a 100644 --- a/tests/unit/easydynamics/convolution/test_numerical_convolution.py +++ b/tests/unit/easydynamics/convolution/test_numerical_convolution.py @@ -218,17 +218,17 @@ def fake_check_width_thresholds(*args, **kwargs): monkeypatch.setattr( conv.sample_components, 'evaluate', - lambda x: np.ones_like(dense), # noqa: ARG005 + lambda x: np.ones_like(dense), # ruff: ignore[unused-lambda-argument] ) monkeypatch.setattr( conv.resolution_components, 'evaluate', - lambda x: np.ones_like(dense), # noqa: ARG005 + lambda x: np.ones_like(dense), # ruff: ignore[unused-lambda-argument] ) db_called = False - def fake_db(*args, **kwargs): # noqa: ARG001 + def fake_db(*args, **kwargs): # ruff: ignore[unused-function-argument] nonlocal db_called db_called = True return np.ones_like(dense) @@ -240,12 +240,12 @@ def fake_db(*args, **kwargs): # noqa: ARG001 monkeypatch.setattr( 'easydynamics.convolution.numerical_convolution.fftconvolve', - lambda a, b, mode: np.ones_like(dense), # noqa: ARG005 + lambda a, b, mode: np.ones_like(dense), # ruff: ignore[unused-lambda-argument] ) interp_called = False - def fake_interp(*args, **kwargs): # noqa: ARG001 + def fake_interp(*args, **kwargs): # ruff: ignore[unused-function-argument] nonlocal interp_called interp_called = True return np.ones_like(conv.energy.values) diff --git a/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py b/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py index 9f90ff98c..4ce8db631 100644 --- a/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py +++ b/tests/unit/easydynamics/convolution/test_numerical_convolution_base.py @@ -9,6 +9,8 @@ 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 StretchedExponential +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 @@ -371,7 +373,7 @@ def test_normalize_detailed_balance_setter_raises(self, default_numerical_convol """ # WHEN THEN EXPECT with pytest.raises(TypeError, match='normalize_detailed_balance must be'): - default_numerical_convolution_base.detailed_balance_settings.normalize_detailed_balance = ( # noqa: E501 + default_numerical_convolution_base.detailed_balance_settings.normalize_detailed_balance = ( # ruff: ignore[line-too-long] 'invalid' ) @@ -604,6 +606,30 @@ def test_check_width_small_threshold(self, default_numerical_convolution_base): model_name='ComponentCollection', ) + def test_check_width_small_threshold_for_stretched_exponential( + self, default_numerical_convolution_base + ): + """ + Regression: a stretched exponential at small beta is far narrower than its energy scale + hbar / tau, and used to report that scale as its width. The spike then fell between the + grid points with no warning at all. + """ + # WHEN tau = 5 ps puts hbar / tau at 0.13 meV, well above the grid step, while the true + # half width at beta = 0.2 is 3.5e-5 meV, well below it + narrow_kww = StretchedExponential( + name='ComponentCollection', area=1.0, relaxation_time=5.0, beta=0.2 + ) + + # THEN EXPECT + with pytest.warns( + UserWarning, + match='Increase upsample_factor to improve', + ): + default_numerical_convolution_base._check_width_thresholds( + model=narrow_kww, + model_name='ComponentCollection', + ) + def test_check_width_no_warnings(self, default_numerical_convolution_base): """ Test that _check_width_thresholds does not warn when model @@ -621,6 +647,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 +815,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 21e63d2da..cae367c77 100644 --- a/tests/unit/easydynamics/experiment/test_experiment.py +++ b/tests/unit/easydynamics/experiment/test_experiment.py @@ -238,6 +238,36 @@ def test_rebin(self, experiment, new_Q_bins, new_energy_bins): assert rebinned_data.sizes['Q'] == 6 assert rebinned_data.sizes['energy'] == 7 + def test_rebin_with_bin_edge_coordinate(self): + "Test rebinning data whose energy coordinate is at bin edges" + # WHEN + Q = sc.linspace('Q', 0.5, 1.5, num=10, unit='1/Angstrom') + energy_edges = sc.linspace('energy', -5, 5, num=12, unit='meV') + values = sc.array(dims=['Q', 'energy'], values=np.ones((10, 11))) + data = sc.DataArray(data=values, coords={'Q': Q, 'energy': energy_edges}) + experiment = Experiment(data=data) + + # THEN + experiment.rebin({'energy': 7}) + + # EXPECT + rebinned_data = experiment.binned_data + 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 @@ -620,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..e8b081ac4 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 @@ -46,23 +46,27 @@ def test_initialization(self, dho: DampedHarmonicOscillator): assert dho.x_unit == 'meV' @pytest.mark.parametrize( - 'kwargs, expected_message', + 'kwargs, expected_message, expected_error', [ ( {'area': 'invalid', 'center': 0.5, 'width': 0.6, 'x_unit': 'meV'}, 'area must be a number', + TypeError, ), ( {'area': 2.0, 'center': 'invalid', 'width': 0.6, 'x_unit': 'meV'}, 'center must be ', + TypeError, ), ( {'area': 2.0, 'center': 0.5, 'width': 'invalid', 'x_unit': 'meV'}, 'width must be a number', + TypeError, ), ( {'area': 2.0, 'center': 0.5, 'width': 0.6, 'x_unit': 123}, 'unit must be None, a string', + UnitError, ), ( { @@ -73,11 +77,12 @@ def test_initialization(self, dho: DampedHarmonicOscillator): 'y_unit': 123, }, 'unit must be None, a string', + UnitError, ), ], ) - def test_input_type_validation_raises(self, kwargs, expected_message): - with pytest.raises(TypeError, match=expected_message): + def test_input_type_validation_raises(self, kwargs, expected_message, expected_error): + with pytest.raises(expected_error, match=expected_message): DampedHarmonicOscillator(display_name='DampedHarmonicOscillator', **kwargs) def test_negative_width_raises(self): @@ -143,6 +148,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..14b933cbc 100644 --- a/tests/unit/easydynamics/sample_model/components/test_delta_function.py +++ b/tests/unit/easydynamics/sample_model/components/test_delta_function.py @@ -43,28 +43,28 @@ def test_initialization(self, delta_function: DeltaFunction): assert delta_function.x_unit == 'meV' @pytest.mark.parametrize( - 'kwargs, expected_message', + 'kwargs, expected_message, expected_error', [ ( {'area': 'invalid', 'center': 0.5, 'x_unit': 'meV'}, 'area must be a number', + TypeError, ), ( {'area': 2.0, 'center': 'invalid', 'x_unit': 'meV'}, 'center must be ', + TypeError, ), - ( - {'area': 2.0, 'center': 0.5, 'x_unit': 123}, - 'unit must be ', - ), + ({'area': 2.0, 'center': 0.5, 'x_unit': 123}, 'unit must be ', UnitError), ( {'area': 2.0, 'center': 0.5, 'x_unit': 'meV', 'y_unit': 123}, 'unit must be ', + UnitError, ), ], ) - def test_input_type_validation_raises(self, kwargs, expected_message): - with pytest.raises(TypeError, match=expected_message): + def test_input_type_validation_raises(self, kwargs, expected_message, expected_error): + with pytest.raises(expected_error, match=expected_message): DeltaFunction(display_name='TestDeltaFunction', **kwargs) def test_negative_area_warns(self): @@ -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_diffusion_damped_mittag_leffler.py b/tests/unit/easydynamics/sample_model/components/test_diffusion_damped_mittag_leffler.py new file mode 100644 index 000000000..3d4e2ce7c --- /dev/null +++ b/tests/unit/easydynamics/sample_model/components/test_diffusion_damped_mittag_leffler.py @@ -0,0 +1,400 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from copy import copy + +import numpy as np +import pytest +import scipp as sc +from easyscience.variable import Parameter +from scipp import UnitError +from scipy.integrate import quad + +from easydynamics.sample_model import DiffusionDampedMittagLeffler + + +def laplace_reference(x, scale, alpha, width, damping): + r""" + Reference lineshape built from the Laplace transform of the Mittag-Leffler function. + + Eqs. (32) and (40) of Hassani et al. give the spectrum as ``(1/pi) Re{phi_hat(damping + + i|x|)}``, with ``phi_hat(s) = s**(alpha-1)/(s**alpha + width**alpha)``. Evaluating that with + complex arithmetic is independent of the real-valued modulus/argument form of Eq. (42) that the + component implements. + """ + s = damping + 1j * np.abs(np.asarray(x, dtype=float)) + return scale * np.real(s ** (alpha - 1) / (s**alpha + width**alpha)) / np.pi + + +class TestDiffusionDampedMittagLeffler: + @pytest.fixture + def mittag_leffler(self): + return DiffusionDampedMittagLeffler( + name='TestMLName', + display_name='TestML', + scale=2.0, + alpha=0.7, + width=0.3, + damping=0.05, + x_unit='meV', + ) + + ############# + # Construction + ############# + + def test_init_no_inputs(self): + # WHEN THEN + ml = DiffusionDampedMittagLeffler() + + # EXPECT + assert ml.display_name == 'DiffusionDampedMittagLeffler' + assert ml.scale.value == pytest.approx(1.0) + assert ml.alpha.value == pytest.approx(1.0) + assert ml.width.value == pytest.approx(1.0) + assert ml.damping.value == pytest.approx(1.0) + assert ml.x_unit == 'meV' + assert ml.y_unit == 'dimensionless' + + def test_initialization(self, mittag_leffler: DiffusionDampedMittagLeffler): + # WHEN THEN EXPECT + assert mittag_leffler.display_name == 'TestML' + assert mittag_leffler.scale.value == pytest.approx(2.0) + assert mittag_leffler.alpha.value == pytest.approx(0.7) + assert mittag_leffler.width.value == pytest.approx(0.3) + assert mittag_leffler.damping.value == pytest.approx(0.05) + assert mittag_leffler.x_unit == 'meV' + + def test_parameter_units(self, mittag_leffler: DiffusionDampedMittagLeffler): + # WHEN THEN EXPECT scale = x_unit * y_unit, width/damping = x_unit, alpha dimensionless + assert mittag_leffler.scale.unit == 'meV' + assert mittag_leffler.alpha.unit == 'dimensionless' + assert mittag_leffler.width.unit == 'meV' + assert mittag_leffler.damping.unit == 'meV' + + @pytest.mark.parametrize( + 'kwargs, expected_message, expected_error', + [ + ({'scale': 'invalid'}, 'scale must be a number', TypeError), + ({'alpha': 'invalid'}, 'alpha must be a number', TypeError), + ({'width': 'invalid'}, 'width must be a number', TypeError), + ({'damping': 'invalid'}, 'damping must be a number', TypeError), + ({'x_unit': 123}, 'unit must be None, a string', UnitError), + ({'y_unit': 123}, 'unit must be None, a string', UnitError), + ], + ) + def test_input_type_validation_raises(self, kwargs, expected_message, expected_error): + # WHEN THEN EXPECT + with pytest.raises(expected_error, match=expected_message): + DiffusionDampedMittagLeffler(**kwargs) + + @pytest.mark.parametrize( + 'kwargs, expected_message', + [ + ({'scale': -1.0}, 'scale must be non-negative'), + ({'scale': np.nan}, 'scale must be a finite number'), + ({'alpha': 0.0}, 'alpha must be greater than zero and at most one'), + ({'alpha': 1.5}, 'alpha must be greater than zero and at most one'), + ({'alpha': np.nan}, 'alpha must be a finite number'), + ({'width': -0.6}, 'must be greater than zero'), + ({'damping': -0.6}, 'must be greater than zero'), + ], + ) + def test_input_value_validation_raises(self, kwargs, expected_message): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match=expected_message): + DiffusionDampedMittagLeffler(**kwargs) + + ############# + # Property setters + ############# + + @pytest.mark.parametrize( + 'prop, valid_value', + [ + ('scale', 3.0), + ('alpha', 0.5), + ('width', 0.7), + ('damping', 0.2), + ], + ) + def test_property_setters( + self, + mittag_leffler: DiffusionDampedMittagLeffler, + prop, + valid_value, + ): + # WHEN THEN: set a valid value + setattr(mittag_leffler, prop, valid_value) + + # EXPECT + assert getattr(mittag_leffler, prop).value == valid_value + + # WHEN: set an invalid value — THEN EXPECT + with pytest.raises(TypeError, match='must be a number'): + setattr(mittag_leffler, prop, 'invalid') + + @pytest.mark.parametrize( + 'prop, invalid_value, expected_message', + [ + ('scale', -1.0, 'violates the parameter bounds'), + ('alpha', 1.5, 'violates the parameter bounds'), + ('alpha', -0.1, 'violates the parameter bounds'), + ('width', -0.5, 'width must be positive'), + ('width', 1e-12, 'violates the parameter bounds'), + ('damping', -0.5, 'damping must be positive'), + ('damping', 1e-12, 'violates the parameter bounds'), + ], + ) + def test_setters_out_of_bounds_raise( + self, + mittag_leffler: DiffusionDampedMittagLeffler, + prop, + invalid_value, + expected_message, + ): + # WHEN + original = getattr(mittag_leffler, prop).value + + # THEN EXPECT the assignment raises instead of being silently clamped + with pytest.raises(ValueError, match=expected_message): + setattr(mittag_leffler, prop, invalid_value) + assert getattr(mittag_leffler, prop).value == pytest.approx(original) + + def test_get_all_parameters(self, mittag_leffler: DiffusionDampedMittagLeffler): + # WHEN THEN + params = mittag_leffler.get_all_parameters() + + # EXPECT + assert len(params) == 4 + assert all(isinstance(param, Parameter) for param in params) + expected_names = { + 'TestMLName scale', + 'TestMLName alpha', + 'TestMLName width', + 'TestMLName damping', + } + assert {param.name for param in params} == expected_names + + ############# + # Evaluation + ############# + + @pytest.mark.parametrize('alpha', [0.3, 0.5, 0.8, 1.0]) + @pytest.mark.parametrize('damping', [0.01, 0.5]) + def test_evaluate_matches_laplace_reference(self, alpha, damping): + # WHEN Eq. (42) is the real-valued form of (1/pi) Re{phi_hat(damping + i|omega|)} + ml = DiffusionDampedMittagLeffler(scale=2.0, alpha=alpha, width=0.4, damping=damping) + x = np.linspace(-5.0, 5.0, 401) + + # THEN + result = ml.evaluate(x) + + # EXPECT + expected = laplace_reference(x, scale=2.0, alpha=alpha, width=0.4, damping=damping) + np.testing.assert_allclose(result, expected, rtol=1e-10, atol=1e-14) + + def test_evaluate_reproduces_printed_equation_42(self): + # WHEN width=1 reduces the model to Eq. (42) as printed (tau_R = 1), up to the 1/pi that + # the printed equation omits but Eq. (41) requires + alpha, epsilon = 0.6, 0.2 + ml = DiffusionDampedMittagLeffler(scale=1.0, alpha=alpha, width=1.0, damping=epsilon) + omega = np.linspace(-4.0, 4.0, 201) + + # THEN + result = ml.evaluate(omega) + + # EXPECT: Eq. (42) transcribed term by term + abs_omega = np.abs(omega) + square = abs_omega**2 + epsilon**2 + arg = alpha * np.angle(epsilon + 1j * abs_omega) + numerator = ( + epsilon * square ** (alpha / 2) + abs_omega * np.sin(arg) + epsilon * np.cos(arg) + ) + denominator = square * ((square**alpha + 1) * square ** (-alpha / 2) + 2 * np.cos(arg)) + np.testing.assert_allclose(result, numerator / denominator / np.pi, rtol=1e-10) + + def test_evaluate_alpha_one_is_lorentzian(self): + # WHEN alpha=1 the Mittag-Leffler function reduces to a simple exponential, so the + # spectrum is a Lorentzian of HWHM width+damping (Eq. 31 of the paper) + ml = DiffusionDampedMittagLeffler(scale=2.0, alpha=1.0, width=0.3, damping=0.1) + x = np.linspace(-4.0, 4.0, 301) + + # THEN + result = ml.evaluate(x) + + # EXPECT + hwhm = 0.3 + 0.1 + expected = 2.0 * hwhm / np.pi / (x**2 + hwhm**2) + np.testing.assert_allclose(result, expected, rtol=1e-10) + + def test_evaluate_is_symmetric(self, mittag_leffler: DiffusionDampedMittagLeffler): + # WHEN + x = np.linspace(0.0, 3.0, 101) + + # THEN + positive = mittag_leffler.evaluate(x) + negative = mittag_leffler.evaluate(-x) + + # EXPECT + np.testing.assert_allclose(positive, negative, rtol=1e-12) + + def test_evaluate_finite_and_maximal_at_zero( + self, mittag_leffler: DiffusionDampedMittagLeffler + ): + # WHEN a strictly positive damping keeps the otherwise singular spectrum regular at x=0 + x = np.linspace(-1.0, 1.0, 201) + + # THEN + values = mittag_leffler.evaluate(x) + + # EXPECT + assert np.all(np.isfinite(values)) + assert np.argmax(values) == 100 + assert mittag_leffler.evaluate(0.0)[0] == pytest.approx(np.max(values)) + + @pytest.mark.parametrize('alpha', [0.4, 0.7, 1.0]) + def test_scale_is_the_integrated_area(self, alpha): + # WHEN THEN + ml = DiffusionDampedMittagLeffler(scale=2.5, alpha=alpha, width=0.3, damping=0.05) + integral, _ = quad(lambda x: ml.evaluate(x)[0], -np.inf, np.inf, limit=400) + + # EXPECT + assert integral == pytest.approx(2.5, rel=1e-4) + + def test_smaller_alpha_broadens_the_wings(self): + # WHEN two profiles differ only in alpha, at fixed scale + narrow = DiffusionDampedMittagLeffler(alpha=1.0, width=0.3, damping=0.05) + broad = DiffusionDampedMittagLeffler(alpha=0.5, width=0.3, damping=0.05) + + # THEN + far_out = np.array([20.0]) + + # EXPECT the sub-exponential relaxation leaves more intensity in the wings + assert broad.evaluate(far_out)[0] > narrow.evaluate(far_out)[0] + + def test_evaluate_scipp_input_converts_units(self): + # WHEN + ml = DiffusionDampedMittagLeffler(scale=1.0, alpha=0.7, width=0.3, damping=0.05) + x_mev = np.linspace(-2.0, 2.0, 51) + x_microev = sc.array(dims=['x'], values=x_mev * 1e3, unit='microeV') + + # THEN + from_mev = ml.evaluate(x_mev) + from_microev = ml.evaluate(x_microev) + + # EXPECT the same dimensionless intensities: scale, width and damping are all resolved to + # the unit of x, so the shape only depends on the ratios between them + np.testing.assert_allclose(from_microev, from_mev, rtol=1e-10) + + def test_evaluate_scipp_output(self, mittag_leffler: DiffusionDampedMittagLeffler): + # WHEN + x = np.linspace(-2.0, 2.0, 50) + + # THEN + result = mittag_leffler.evaluate(x, output='scipp') + + # EXPECT + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + np.testing.assert_allclose(result.values, mittag_leffler.evaluate(x, output='numpy')) + + ############# + # Unit conversion + ############# + + def test_convert_x_unit(self, mittag_leffler: DiffusionDampedMittagLeffler): + # WHEN THEN + mittag_leffler.convert_x_unit('microeV') + + # EXPECT + assert mittag_leffler.x_unit == 'microeV' + assert mittag_leffler.scale.value == pytest.approx(2.0 * 1e3) + assert mittag_leffler.width.value == pytest.approx(0.3 * 1e3) + assert mittag_leffler.damping.value == pytest.approx(0.05 * 1e3) + # EXPECT the dimensionless form parameter is untouched + assert mittag_leffler.alpha.value == pytest.approx(0.7) + assert mittag_leffler.alpha.unit == 'dimensionless' + + def test_convert_x_unit_invalid_type_raises( + self, mittag_leffler: DiffusionDampedMittagLeffler + ): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match=r'x_unit must be a string or sc\.Unit'): + mittag_leffler.convert_x_unit(123) + + def test_convert_x_unit_rollback_on_failure( + self, mittag_leffler: DiffusionDampedMittagLeffler + ): + # WHEN THEN + with pytest.raises(UnitError): + mittag_leffler.convert_x_unit('m') + + # EXPECT: state rolled back + assert mittag_leffler.x_unit == 'meV' + assert mittag_leffler.scale.value == pytest.approx(2.0) + assert mittag_leffler.width.value == pytest.approx(0.3) + assert mittag_leffler.damping.value == pytest.approx(0.05) + + def test_convert_y_unit(self): + # WHEN: x_unit='meV', y_unit='1/meV' → scale_unit='dimensionless' + ml = DiffusionDampedMittagLeffler(scale=1.0, width=0.3, damping=0.05, y_unit='1/meV') + + # THEN + ml.convert_y_unit('1/eV') + + # EXPECT + assert ml.y_unit == '1/eV' + assert ml.scale.value == pytest.approx(1e3) + + def test_convert_y_unit_invalid_type_raises( + self, mittag_leffler: DiffusionDampedMittagLeffler + ): + # WHEN THEN EXPECT + with pytest.raises(TypeError): + mittag_leffler.convert_y_unit(123) + + def test_convert_y_unit_rollback_on_failure( + self, mittag_leffler: DiffusionDampedMittagLeffler + ): + # WHEN THEN + with pytest.raises(UnitError): + mittag_leffler.convert_y_unit('K') + + # EXPECT: state rolled back + assert mittag_leffler.y_unit == 'dimensionless' + assert mittag_leffler.scale.value == pytest.approx(2.0) + + def test_y_unit_setter_raises(self, mittag_leffler: DiffusionDampedMittagLeffler): + # WHEN THEN EXPECT + with pytest.raises(AttributeError): + mittag_leffler.y_unit = '1/meV' + + ############# + # Copy and repr + ############# + + def test_copy(self, mittag_leffler: DiffusionDampedMittagLeffler): + # WHEN THEN + ml_copy = copy(mittag_leffler) + + # EXPECT + assert ml_copy is not mittag_leffler + assert ml_copy.display_name == mittag_leffler.display_name + for prop in ('scale', 'alpha', 'width', 'damping'): + assert getattr(ml_copy, prop).value == getattr(mittag_leffler, prop).value + assert getattr(ml_copy, prop).fixed == getattr(mittag_leffler, prop).fixed + assert ml_copy.x_unit == mittag_leffler.x_unit + + def test_repr(self, mittag_leffler: DiffusionDampedMittagLeffler): + # WHEN THEN + repr_str = repr(mittag_leffler) + + # EXPECT + assert 'DiffusionDampedMittagLeffler' in repr_str + assert 'name = TestMLName' in repr_str + assert 'x_unit = meV' in repr_str + assert 'scale =' in repr_str + assert 'alpha =' in repr_str + assert 'width =' in repr_str + assert 'damping =' in repr_str diff --git a/tests/unit/easydynamics/sample_model/components/test_exponential.py b/tests/unit/easydynamics/sample_model/components/test_exponential.py index 1b9de0988..9c49b6f2c 100644 --- a/tests/unit/easydynamics/sample_model/components/test_exponential.py +++ b/tests/unit/easydynamics/sample_model/components/test_exponential.py @@ -45,19 +45,22 @@ def test_initialization(self, exponential: Exponential): assert exponential.x_unit == 'meV' @pytest.mark.parametrize( - 'kwargs, expected_message', + 'kwargs, expected_message, expected_error', [ ( {'amplitude': 'invalid', 'center': 0.5, 'rate': 1.0, 'x_unit': 'meV'}, 'amplitude must be a number', + TypeError, ), ( {'amplitude': 2.0, 'center': 'invalid', 'rate': 1.0, 'x_unit': 'meV'}, 'center must be None or a number', + TypeError, ), ( {'amplitude': 2.0, 'center': 0.5, 'rate': 'invalid', 'x_unit': 'meV'}, 'rate must be a number', + TypeError, ), ( { @@ -68,11 +71,12 @@ def test_initialization(self, exponential: Exponential): 'y_unit': 123, }, 'unit must be None, a string', + UnitError, ), ], ) - def test_input_type_validation_raises(self, kwargs, expected_message): - with pytest.raises(TypeError, match=expected_message): + def test_input_type_validation_raises(self, kwargs, expected_message, expected_error): + with pytest.raises(expected_error, match=expected_message): Exponential(display_name='TestExponential', **kwargs) @pytest.mark.parametrize( 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..9193b3046 100644 --- a/tests/unit/easydynamics/sample_model/components/test_gaussian.py +++ b/tests/unit/easydynamics/sample_model/components/test_gaussian.py @@ -47,27 +47,38 @@ def test_initialization(self, gaussian: Gaussian): assert gaussian.x_unit == 'meV' @pytest.mark.parametrize( - 'kwargs, expected_message', + 'kwargs, expected_message, expected_error', [ ( {'area': 'invalid', 'center': 0.5, 'width': 0.6, 'x_unit': 'meV'}, 'area must be a number', + TypeError, ), ( {'area': 2.0, 'center': 'invalid', 'width': 0.6, 'x_unit': 'meV'}, 'center must be None or a number', + TypeError, ), ( {'area': 2.0, 'center': 0.5, 'width': 'invalid', 'x_unit': 'meV'}, 'width must be a number', + TypeError, ), ( {'area': 2.0, 'center': 0.5, 'width': 0.6, 'x_unit': 123}, 'unit must be None, a string', + UnitError, ), ( - {'area': 2.0, 'center': 0.5, 'width': 0.6, 'x_unit': 'meV', 'y_unit': 123}, + { + 'area': 2.0, + 'center': 0.5, + 'width': 0.6, + 'x_unit': 'meV', + 'y_unit': 123, + }, 'unit must be None, a string', + UnitError, ), ], ids=[ @@ -78,8 +89,8 @@ def test_initialization(self, gaussian: Gaussian): 'invalid y_unit', ], ) - def test_input_type_validation_raises(self, kwargs, expected_message): - with pytest.raises(TypeError, match=expected_message): + def test_input_type_validation_raises(self, kwargs, expected_message, expected_error): + with pytest.raises(expected_error, match=expected_message): Gaussian(display_name='TestGaussian', **kwargs) def test_negative_width_raises(self): @@ -131,6 +142,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..b73ba59a8 100644 --- a/tests/unit/easydynamics/sample_model/components/test_lorentzian.py +++ b/tests/unit/easydynamics/sample_model/components/test_lorentzian.py @@ -47,32 +47,43 @@ def test_initialization(self, lorentzian: Lorentzian): assert lorentzian.x_unit == 'meV' @pytest.mark.parametrize( - 'kwargs, expected_message', + 'kwargs, expected_message, expected_error', [ ( {'area': 'invalid', 'center': 0.5, 'width': 0.6, 'x_unit': 'meV'}, 'area must be a number', + TypeError, ), ( {'area': 2.0, 'center': 'invalid', 'width': 0.6, 'x_unit': 'meV'}, 'center must be None or a number', + TypeError, ), ( {'area': 2.0, 'center': 0.5, 'width': 'invalid', 'x_unit': 'meV'}, 'width must be a number', + TypeError, ), ( {'area': 2.0, 'center': 0.5, 'width': 0.6, 'x_unit': 123}, 'unit must be None, a string', + UnitError, ), ( - {'area': 2.0, 'center': 0.5, 'width': 0.6, 'x_unit': 'meV', 'y_unit': 123}, + { + 'area': 2.0, + 'center': 0.5, + 'width': 0.6, + 'x_unit': 'meV', + 'y_unit': 123, + }, 'unit must be None, a string', + UnitError, ), ], ) - def test_input_type_validation_raises(self, kwargs, expected_message): - with pytest.raises(TypeError, match=expected_message): + def test_input_type_validation_raises(self, kwargs, expected_message, expected_error): + with pytest.raises(expected_error, match=expected_message): Lorentzian(display_name='TestLorentzian', **kwargs) def test_negative_width_raises(self): @@ -124,6 +135,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..c9826f0c7 100644 --- a/tests/unit/easydynamics/sample_model/components/test_polynomial.py +++ b/tests/unit/easydynamics/sample_model/components/test_polynomial.py @@ -39,33 +39,30 @@ def test_initialization(self, polynomial: Polynomial): assert polynomial.coefficients[2].value == pytest.approx(3.0) @pytest.mark.parametrize( - 'kwargs, expected_message', + 'kwargs, expected_message, expected_error', [ - ( - {'coefficients': 'invalid'}, - 'coefficients must be ', - ), + ({'coefficients': 'invalid'}, 'coefficients must be ', TypeError), ( {'coefficients': [1.0, 'invalid', 3.0]}, 'Each coefficient must be ', + TypeError, ), ( {'coefficients': [1.0, -2.0, 3.0], 'x_unit': 123}, 'unit must be ', + UnitError, ), ( {'coefficients': [1.0, -2.0, 3.0], 'x_unit': 'meV', 'y_unit': 123}, 'unit must be ', + UnitError, ), - ( - {'coefficients': None}, - 'coefficients must be ', - ), + ({'coefficients': None}, 'coefficients must be ', TypeError), ], ) - def test_input_type_validation_raises(self, kwargs, expected_message): + def test_input_type_validation_raises(self, kwargs, expected_message, expected_error): # WHEN THEN EXPECT - with pytest.raises(TypeError, match=expected_message): + with pytest.raises(expected_error, match=expected_message): Polynomial(display_name='TestPolynomial', **kwargs) def test_init_no_coefficients_raises(self): @@ -184,6 +181,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 +324,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 +386,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 +450,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 +501,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 +566,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_stretched_exponential.py b/tests/unit/easydynamics/sample_model/components/test_stretched_exponential.py new file mode 100644 index 000000000..a1eebeff9 --- /dev/null +++ b/tests/unit/easydynamics/sample_model/components/test_stretched_exponential.py @@ -0,0 +1,704 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from copy import copy +from itertools import pairwise + +import numpy as np +import pytest +import scipp as sc +from easyscience.variable import Parameter +from scipp import UnitError +from scipy.integrate import simpson +from scipy.special import gamma + +from easydynamics.sample_model import Gaussian +from easydynamics.sample_model import Lorentzian +from easydynamics.sample_model import StretchedExponential +from easydynamics.sample_model.components.stretched_exponential import _kww_shape +from easydynamics.sample_model.components.stretched_exponential import _reduced_hwhm + +# hbar in meV*ps (CODATA), so the tests derive the energy scale independently of the library. +HBAR_MEV_PS = 0.6582119569509066 + + +def sinh_grid(reach: float, n_points: int = 20001) -> np.ndarray: + """A symmetric grid that is dense near zero and reaches *reach*, for the heavy KWW tails.""" + t = np.linspace(-np.arcsinh(reach), np.arcsinh(reach), n_points) + return np.sinh(t) + + +def naive_fft_transform( + area: float, tau: float, beta: float, n_time: int, t_max: float +) -> tuple[np.ndarray, np.ndarray]: + """ + Transform ``I(t) = area * exp(-(t / tau)**beta)`` to energy the obvious way, with an FFT. + + This is the textbook route the implementation deliberately does not take: sample the + relaxation on a uniform time grid and let ``np.fft`` do the cosine transform. It is an + independent check because it shares no code and no idea with the rotated-contour quadrature + in ``_kww_shape`` -- only the physics. + + The profile is even in *t*, so the two-sided transform is twice the one-sided one and + + S(x) = (1 / (pi hbar)) Re [ integral of I(t) exp(-i x t / hbar) dt over t >= 0 ], + + which on the uniform grid is a trapezoid sum (hence the half-weighted t = 0 sample) evaluated + at the FFT frequencies ``x_n = 2 pi hbar n / (n_time dt)``. + + Returns + ------- + tuple[np.ndarray, np.ndarray] + The energy axis in meV and the spectrum on it. + """ + dt = t_max / n_time + time = np.arange(n_time) * dt + intensity = area * np.exp(-((time / tau) ** beta)) + spectrum = (np.real(np.fft.rfft(intensity)) - 0.5 * intensity[0]) * dt / (np.pi * HBAR_MEV_PS) + energy = 2.0 * np.pi * HBAR_MEV_PS * np.arange(spectrum.size) / (n_time * dt) + return energy, spectrum + + +def fft_comparison(beta: float, n_time: int, tau: float = 5.0, area: float = 1.0) -> float: + """Largest relative gap between the component and the FFT, over the resolvable region.""" + # Reach far enough in time that exp(-(t / tau)**beta) has fallen by exp(-40). + energy, reference = naive_fft_transform(area, tau, beta, n_time, tau * 40.0 ** (1.0 / beta)) + inside = (energy > 0.0) & (energy < 2.0) + energy, reference = energy[inside], reference[inside] + + stretched = StretchedExponential(area=area, relaxation_time=tau, beta=beta) + values = stretched.evaluate(energy) + + # Below a thousandth of the peak the FFT reference is dominated by its own truncation error, + # so comparing there would measure the reference rather than the implementation. + resolvable = values > 1e-3 * stretched.evaluate(np.array([0.0]))[0] + return float(np.max(np.abs(values[resolvable] - reference[resolvable]) / values[resolvable])) + + +##################################### +# The Fourier transform: _kww_shape +##################################### + + +@pytest.mark.parametrize( + 'beta, rel', + # The smallest supported beta spreads exp(-u**beta) over so many decades that the grid only + # just reaches its tail, which costs a few digits; everything above it is at machine precision. + [ + (0.05, 1e-8), + (0.1, 1e-11), + (0.3, 1e-12), + (0.5, 1e-12), + (1.0, 1e-12), + (2.0, 1e-12), + ], +) +def test_kww_shape_at_zero(beta, rel): + # WHEN G(0) is the integral of exp(-u**beta), which is gamma(1 + 1/beta) + + # THEN + value = _kww_shape(np.array([0.0]), beta) + + # EXPECT + assert value[0] == pytest.approx(gamma(1.0 + 1.0 / beta), rel=rel) + + +def test_kww_shape_matches_lorentzian_at_beta_one(): + # WHEN beta = 1 the transform of exp(-u) is known in closed form + w = sinh_grid(1e6) + + # THEN + value = _kww_shape(w, 1.0) + + # EXPECT + np.testing.assert_allclose(value, 1.0 / (1.0 + w**2), rtol=1e-9) + + +def test_kww_shape_matches_gaussian_at_beta_two(): + # WHEN beta = 2 the transform of exp(-u**2) is again a Gaussian + w = np.linspace(-12.0, 12.0, 501) + + # THEN + value = _kww_shape(w, 2.0) + + # EXPECT + expected = 0.5 * np.sqrt(np.pi) * np.exp(-(w**2) / 4) + np.testing.assert_allclose(value, expected, atol=1e-14) + + +@pytest.mark.parametrize('beta', [0.3, 0.6, 0.9]) +def test_kww_shape_matches_the_large_w_asymptote(beta): + # WHEN the leading term of the large-w expansion is + # gamma(beta + 1) sin(pi beta / 2) / w**(beta + 1). The next term is smaller by w**-beta, so + # w has to be taken far out before the leading term alone is worth a few digits. + w = np.array([1e9, 1e11]) + + # THEN + value = _kww_shape(w, beta) + + # EXPECT + expected = gamma(beta + 1) * np.sin(np.pi * beta / 2) / w ** (beta + 1) + np.testing.assert_allclose(value, expected, rtol=3e-3) + + +@pytest.mark.parametrize('beta', [0.3, 0.5, 0.8, 1.0, 1.6, 2.0]) +def test_kww_shape_is_even_and_non_negative(beta): + # WHEN + w = sinh_grid(1e6, n_points=2001) + + # THEN + value = _kww_shape(w, beta) + + # EXPECT + np.testing.assert_allclose(value, _kww_shape(-w, beta), rtol=0, atol=0) + assert np.all(value >= 0.0) + + +@pytest.mark.parametrize('beta', [0.3, 0.5, 0.8, 1.0, 1.6, 2.0]) +def test_kww_shape_integrates_to_pi(beta): + # WHEN the transform of a function that is 1 at t = 0 integrates to pi over w + + # THEN + w = sinh_grid(1e8) + integral = simpson(_kww_shape(w, beta), x=w) + + # EXPECT + assert integral == pytest.approx(np.pi, rel=5e-3) + + +def test_kww_shape_is_blocked_without_changing_the_result(): + # WHEN a long energy axis is evaluated in blocks to bound the memory of the quadrature + w = np.linspace(-40.0, 40.0, 997) + reference = _kww_shape(w, 0.6) + + # THEN + blocked = np.concatenate([_kww_shape(chunk, 0.6) for chunk in np.array_split(w, 7)]) + + # EXPECT the block boundaries do not perturb any value + np.testing.assert_array_equal(blocked, reference) + + +class TestStretchedExponential: + @pytest.fixture + def stretched_exponential(self): + return StretchedExponential( + name='StretchedName', + display_name='TestStretched', + area=2.0, + center=0.5, + relaxation_time=3.0, + beta=0.7, + x_unit='meV', + ) + + ############# + # Creation + ############# + + def test_init_no_inputs(self): + # WHEN THEN + stretched = StretchedExponential() + + # EXPECT + assert stretched.display_name == 'StretchedExponential' + assert stretched.area.value == pytest.approx(1.0) + assert stretched.center.value == pytest.approx(0.0) + assert stretched.relaxation_time.value == pytest.approx(1.0) + assert stretched.beta.value == pytest.approx(1.0) + assert stretched.x_unit == 'meV' + assert stretched.y_unit == 'dimensionless' + assert stretched.relaxation_time.unit == 'ps' + assert stretched.beta.unit == 'dimensionless' + assert stretched.center.fixed is True + + def test_initialization(self, stretched_exponential: StretchedExponential): + # WHEN THEN EXPECT + assert stretched_exponential.display_name == 'TestStretched' + assert stretched_exponential.area.value == pytest.approx(2.0) + assert stretched_exponential.center.value == pytest.approx(0.5) + assert stretched_exponential.relaxation_time.value == pytest.approx(3.0) + assert stretched_exponential.beta.value == pytest.approx(0.7) + assert stretched_exponential.center.fixed is False + + @pytest.mark.parametrize( + 'kwargs, expected_message, expected_error', + [ + ({'area': 'invalid'}, 'area must be a number', TypeError), + ({'center': 'invalid'}, 'center must be None or a number', TypeError), + ( + {'relaxation_time': 'invalid'}, + 'relaxation_time must be a number', + TypeError, + ), + ({'beta': 'invalid'}, 'beta must be a number', TypeError), + ({'x_unit': 123}, 'unit must be None, a string', UnitError), + ({'y_unit': 123}, 'unit must be None, a string', UnitError), + ], + ) + def test_input_type_validation_raises(self, kwargs, expected_message, expected_error): + # WHEN THEN EXPECT + with pytest.raises(expected_error, match=expected_message): + StretchedExponential(**kwargs) + + @pytest.mark.parametrize( + 'kwargs, expected_message', + [ + ({'relaxation_time': 0.0}, 'relaxation_time must be greater than zero'), + ({'relaxation_time': -1.0}, 'relaxation_time must be greater than zero'), + ({'relaxation_time': np.inf}, 'relaxation_time must be a finite number'), + ({'beta': 0.0}, 'beta must be between'), + ({'beta': 2.5}, 'beta must be between'), + ({'beta': np.nan}, 'beta must be a finite number'), + ], + ) + def test_input_value_validation_raises(self, kwargs, expected_message): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match=expected_message): + StretchedExponential(**kwargs) + + def test_negative_area_warns(self): + # WHEN THEN EXPECT + with pytest.warns(UserWarning, match='may not be physically meaningful'): + StretchedExponential(area=-2.0) + + def test_get_all_parameters(self, stretched_exponential: StretchedExponential): + # WHEN THEN + params = stretched_exponential.get_all_parameters() + + # EXPECT + assert all(isinstance(param, Parameter) for param in params) + assert {param.name for param in params} == { + 'StretchedName area', + 'StretchedName center', + 'StretchedName relaxation_time', + 'StretchedName beta', + 'StretchedName width', + } + + def test_copy(self, stretched_exponential: StretchedExponential): + # WHEN THEN + stretched_copy = copy(stretched_exponential) + + # EXPECT + assert stretched_copy is not stretched_exponential + assert stretched_copy.display_name == stretched_exponential.display_name + assert stretched_copy.area.value == stretched_exponential.area.value + assert stretched_copy.center.value == stretched_exponential.center.value + assert stretched_copy.relaxation_time.value == stretched_exponential.relaxation_time.value + assert stretched_copy.beta.value == stretched_exponential.beta.value + assert stretched_copy.x_unit == stretched_exponential.x_unit + + def test_repr(self, stretched_exponential: StretchedExponential): + # WHEN THEN + repr_str = repr(stretched_exponential) + + # EXPECT + assert 'StretchedExponential' in repr_str + assert 'name = StretchedName' in repr_str + assert 'x_unit = meV' in repr_str + assert 'area =' in repr_str + assert 'center =' in repr_str + assert 'relaxation_time =' in repr_str + assert 'beta =' in repr_str + + ############# + # Parameters + ############# + + @pytest.mark.parametrize( + 'prop, valid_value', + [('area', 3.0), ('center', 0.6), ('relaxation_time', 4.0), ('beta', 0.9)], + ) + def test_property_setters( + self, stretched_exponential: StretchedExponential, prop, valid_value + ): + # WHEN: set a valid value + setattr(stretched_exponential, prop, valid_value) + # THEN EXPECT + assert getattr(stretched_exponential, prop).value == valid_value + + # WHEN: set an invalid value — THEN EXPECT + with pytest.raises(TypeError, match=' must be a number'): + setattr(stretched_exponential, prop, 'invalid') + + def test_relaxation_time_must_be_positive(self, stretched_exponential: StretchedExponential): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match='relaxation_time must be greater than zero'): + stretched_exponential.relaxation_time = -1.0 + assert stretched_exponential.relaxation_time.value == pytest.approx(3.0) + + @pytest.mark.parametrize('value', [0.01, 2.5]) + def test_beta_outside_the_supported_range_raises( + self, stretched_exponential: StretchedExponential, value + ): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match='beta must be between'): + stretched_exponential.beta = value + assert stretched_exponential.beta.value == pytest.approx(0.7) + + def test_area_setter_out_of_bounds_raises(self, stretched_exponential: StretchedExponential): + # WHEN the fixture's area was created non-negative, so it carries min=0 + + # THEN EXPECT a negative assignment raises instead of being silently clamped to 0 + with pytest.raises(ValueError, match='violates the parameter bounds'): + stretched_exponential.area = -1.0 + assert stretched_exponential.area.value == pytest.approx(2.0) + + def test_center_is_fixed_if_set_to_None(self, stretched_exponential: StretchedExponential): + # WHEN + assert stretched_exponential.center.fixed is False + + # THEN + stretched_exponential.center = None + + # EXPECT + assert stretched_exponential.center.value == pytest.approx(0.0) + assert stretched_exponential.center.fixed is True + + def test_width_is_the_half_width_at_half_maximum( + self, stretched_exponential: StretchedExponential + ): + # WHEN width is not stored but solved for as the half maximum crossing of the profile + # THEN + width = stretched_exponential.width + + # EXPECT the profile really has fallen to half its peak there, to the accuracy of the + # closed-form fit the dependency evaluates rather than to that of the root itself + center = stretched_exponential.center.value + peak = stretched_exponential.evaluate(np.array([center]))[0] + half = stretched_exponential.evaluate(np.array([center + width.value]))[0] + assert half == pytest.approx(0.5 * peak, rel=1e-4) + assert str(width.unit) == 'meV' + + def test_width_is_below_the_energy_scale_for_stretched_profiles(self): + # WHEN beta < 1 the peak sharpens well beyond Gamma = hbar / tau, so reporting Gamma would + # hide a sub-grid spike from the convolution's width-versus-grid checks + gamma = HBAR_MEV_PS / 5.0 + + # THEN + widths = { + beta: StretchedExponential(relaxation_time=5.0, beta=beta).width.value + for beta in (1.0, 0.5, 0.2) + } + + # EXPECT Gamma only at beta = 1, and orders of magnitude below it further down + assert widths[1.0] == pytest.approx(gamma, rel=2e-5) + assert widths[0.5] == pytest.approx(0.22355 * gamma, rel=1e-4) + assert widths[0.2] == pytest.approx(2.653e-4 * gamma, rel=1e-3) + + def test_width_is_a_dependent_parameter(self, stretched_exponential: StretchedExponential): + # WHEN width is resolved from beta and relaxation_time by a dependency expression + # THEN + width = stretched_exponential.width + + # EXPECT a Parameter, so the usual width attributes resolve, but a dependent one, so a fit + # can never pick it up as a free parameter + assert isinstance(width, Parameter) + assert width.independent is False + assert width not in stretched_exponential.get_fittable_parameters() + + def test_width_matches_the_solved_half_width(self): + # WHEN the dependency evaluates a closed-form fit rather than the root itself + # THEN EXPECT it tracks _reduced_hwhm over the range the fit targets + for beta in (2.0, 1.0, 0.7, 0.5, 0.3, 0.2, 0.1): + stretched = StretchedExponential(relaxation_time=5.0, beta=beta) + expected = _reduced_hwhm(beta) * HBAR_MEV_PS / 5.0 + assert stretched.width.value == pytest.approx(expected, rel=2e-5) + + def test_width_is_recomputed_after_a_raw_beta_write( + self, stretched_exponential: StretchedExponential + ): + # WHEN a minimizer writes the beta Parameter directly, bypassing the component setter + before = stretched_exponential.width.value + + # THEN + stretched_exponential.beta.value = 0.2 + + # EXPECT the dependency re-evaluates: beta is reached as b.value, but it is still a mapped + # variable, so a write to it retriggers the expression + assert stretched_exponential.width.value < before + + def test_width_can_be_chained_onto(self, stretched_exponential: StretchedExponential): + # WHEN another component's width is made to follow this one + lorentzian = Lorentzian(name='Chained', area=1.0, width=0.1) + with np.errstate(invalid='ignore', divide='ignore'): + lorentzian.width.make_dependent_on( + dependency_expression='w * 2', + dependency_map={'w': stretched_exponential.width}, + desired_unit='meV', + ) + + # THEN + stretched_exponential.beta.value = 0.2 + + # EXPECT the change reaches the chained parameter, not just this component + assert lorentzian.width.value == pytest.approx(2 * stretched_exponential.width.value) + + def test_width_tracks_beta(self, stretched_exponential: StretchedExponential): + # WHEN + before = stretched_exponential.width.value + + # THEN + stretched_exponential.beta = 0.3 + + # EXPECT a smaller beta gives a sharper peak, so a smaller half width + assert stretched_exponential.width.value < before + + def test_width_tracks_the_relaxation_time(self, stretched_exponential: StretchedExponential): + # WHEN + before = stretched_exponential.width.value + + # THEN + stretched_exponential.relaxation_time = 6.0 + + # EXPECT doubling the relaxation time halves the width + assert stretched_exponential.width.value == pytest.approx(before / 2.0) + + def test_width_is_read_only(self, stretched_exponential: StretchedExponential): + # WHEN THEN EXPECT the relaxation time is the fittable parameter, not the width + with pytest.raises(AttributeError): + stretched_exponential.width = 0.5 + + def test_width_follows_the_x_unit(self): + # WHEN the component measures energy in microeV + stretched = StretchedExponential(relaxation_time=5.0, beta=1.0, x_unit='ueV') + + # THEN + width = stretched.width + + # EXPECT the half width is expressed in that unit too + assert width.value == pytest.approx(1e3 * HBAR_MEV_PS / 5.0, rel=2e-5) + assert str(width.unit) == str(sc.Unit('ueV')) + + def test_width_follows_an_x_unit_conversion(self): + # WHEN + stretched = StretchedExponential(relaxation_time=5.0, beta=1.0, x_unit='meV') + before = stretched.width.value + + # THEN + stretched.convert_x_unit('ueV') + + # EXPECT the half width is re-derived in the new unit rather than keeping the old one + assert stretched.width.value == pytest.approx(1e3 * before) + assert str(stretched.width.unit) == str(sc.Unit('ueV')) + + def test_width_with_a_non_energy_x_unit_raises(self): + # WHEN THEN EXPECT hbar / relaxation_time cannot be expressed in metres + with pytest.raises(UnitError, match='needs an energy x_unit'): + _ = StretchedExponential(x_unit='m').width + + def test_width_is_the_lorentzian_hwhm_at_beta_one(self): + # WHEN beta = 1 the transform is a Lorentzian of HWHM Gamma = hbar / tau. Gamma is used + # directly rather than via width, so this pins the profile itself and stays independent of + # the closed-form fit the width dependency evaluates. + stretched = StretchedExponential(area=1.0, relaxation_time=4.0, beta=1.0) + + # THEN + lorentzian = Lorentzian(area=1.0, width=HBAR_MEV_PS / 4.0) + + # EXPECT + x = np.linspace(-2.0, 2.0, 101) + np.testing.assert_allclose(stretched.evaluate(x), lorentzian.evaluate(x), rtol=1e-12) + + ############# + # Evaluation + ############# + + def test_evaluate_reduces_to_a_lorentzian_at_beta_one(self): + # WHEN beta = 1, the transform is a Lorentzian of HWHM hbar / tau + stretched = StretchedExponential(area=2.5, relaxation_time=3.0, beta=1.0) + lorentzian = Lorentzian(area=2.5, width=HBAR_MEV_PS / 3.0) + x = np.linspace(-3.0, 3.0, 401) + + # THEN + result = stretched.evaluate(x) + + # EXPECT + np.testing.assert_allclose(result, lorentzian.evaluate(x), rtol=1e-10) + + def test_evaluate_reduces_to_a_gaussian_at_beta_two(self): + # WHEN beta = 2, the transform is a Gaussian of standard deviation sqrt(2) hbar / tau + stretched = StretchedExponential(area=2.5, relaxation_time=3.0, beta=2.0) + gaussian = Gaussian(area=2.5, width=np.sqrt(2) * HBAR_MEV_PS / 3.0) + x = np.linspace(-3.0, 3.0, 401) + + # THEN + result = stretched.evaluate(x) + + # EXPECT + np.testing.assert_allclose(result, gaussian.evaluate(x), atol=1e-13) + + def test_evaluate_peak_height(self, stretched_exponential: StretchedExponential): + # WHEN the peak sits at the center and equals area * gamma(1 + 1/beta) / (pi * Gamma) + energy_scale = HBAR_MEV_PS / 3.0 + + # THEN + result = stretched_exponential.evaluate(np.array([0.5])) + + # EXPECT + expected = 2.0 * gamma(1.0 + 1.0 / 0.7) / (np.pi * energy_scale) + assert result[0] == pytest.approx(expected, rel=1e-10) + + def test_evaluate_is_symmetric_about_the_center( + self, stretched_exponential: StretchedExponential + ): + # WHEN + offset = np.array([0.05, 0.3, 1.7]) + + # THEN + left = stretched_exponential.evaluate(0.5 - offset) + right = stretched_exponential.evaluate(0.5 + offset) + + # EXPECT + np.testing.assert_allclose(left, right, rtol=1e-12) + + @pytest.mark.parametrize('beta', [0.5, 0.7, 1.0, 1.5]) + def test_area_matches_parameter(self, beta): + # WHEN the transform integrates to the area parameter over the whole axis + stretched = StretchedExponential(area=2.0, relaxation_time=5.0, beta=beta) + # The tails are heavy, so integrate on a grid that is dense near the peak and reaches far + x = sinh_grid(1e8) * (HBAR_MEV_PS / 5.0) + + # THEN + numerical_area = simpson(stretched.evaluate(x), x=x) + + # EXPECT + assert numerical_area == pytest.approx(2.0, rel=5e-3) + + def test_relaxation_time_sets_the_width(self): + # WHEN a longer relaxation time means a narrower line + narrow = StretchedExponential(relaxation_time=20.0, beta=0.8) + wide = StretchedExponential(relaxation_time=2.0, beta=0.8) + + # THEN + narrow_peak = narrow.evaluate(np.array([0.0]))[0] + wide_peak = wide.evaluate(np.array([0.0]))[0] + + # EXPECT the peak scales as tau, since the area is conserved + assert narrow_peak == pytest.approx(10.0 * wide_peak, rel=1e-10) + + def test_evaluate_scipp_output(self, stretched_exponential: StretchedExponential): + # WHEN + x = np.linspace(-5, 5, 50) + + # THEN + result = stretched_exponential.evaluate(x, output='scipp') + + # EXPECT + assert isinstance(result, sc.Variable) + assert result.unit == sc.Unit('dimensionless') + np.testing.assert_allclose(result.values, stretched_exponential.evaluate(x)) + + def test_evaluate_with_scipp_x_in_another_energy_unit( + self, stretched_exponential: StretchedExponential + ): + # WHEN x carries microeV rather than the component's meV + x = np.linspace(-2.0, 2.0, 51) + + # THEN + result = stretched_exponential.evaluate( + sc.array(dims=['energy'], values=x * 1e3, unit='microeV') + ) + + # EXPECT the same profile, since the output carries y_unit either way + np.testing.assert_allclose(result, stretched_exponential.evaluate(x), rtol=1e-12) + + def test_evaluate_with_a_non_energy_x_unit_raises(self): + # WHEN hbar / relaxation_time cannot be expressed in the x unit + stretched = StretchedExponential(x_unit='m') + + # THEN EXPECT + with pytest.raises(UnitError, match='needs an energy x_unit'): + stretched.evaluate(np.array([0.0, 1.0])) + + @pytest.mark.parametrize('beta', [0.6, 0.8, 1.0, 1.5, 2.0]) + def test_evaluate_matches_a_naive_fft_transform(self, beta): + # WHEN the same physics is computed the obvious way, by FFT-ing the sampled relaxation + + # THEN + largest_gap = fft_comparison(beta, n_time=2**20) + + # EXPECT the two agree wherever the FFT itself is trustworthy + assert largest_gap < 1e-3 + + def test_the_naive_fft_converges_onto_evaluate(self): + # WHEN the FFT reference is limited by its own time step, not by the implementation. Its + # error comes from the cusp of exp(-(t / tau)**beta) at t = 0, so the trapezoid sum + # converges as dt**(1 + beta) -- refining dt by four should shrink it by 4**1.6 ~ 9. + gaps = [fft_comparison(0.6, n_time=n) for n in (2**16, 2**18, 2**20)] + + # THEN + ratios = [coarse / fine for coarse, fine in pairwise(gaps)] + + # EXPECT the gap closes at the predicted rate, so the FFT is converging onto our values + assert all(ratio > 4.0 for ratio in ratios), (gaps, ratios) + + ################## + # Unit conversion + ################## + + def test_convert_x_unit(self, stretched_exponential: StretchedExponential): + # WHEN THEN + stretched_exponential.convert_x_unit('microeV') + + # EXPECT the time and the exponent are untouched: they carry no x unit + assert stretched_exponential.x_unit == 'microeV' + assert stretched_exponential.area.value == pytest.approx(2.0 * 1e3) + assert stretched_exponential.center.value == pytest.approx(0.5 * 1e3) + assert stretched_exponential.relaxation_time.value == pytest.approx(3.0) + assert stretched_exponential.relaxation_time.unit == 'ps' + assert stretched_exponential.beta.value == pytest.approx(0.7) + + def test_convert_x_unit_keeps_the_profile(self, stretched_exponential: StretchedExponential): + # WHEN + x = np.linspace(-2.0, 2.0, 51) + before = stretched_exponential.evaluate(x) + + # THEN + stretched_exponential.convert_x_unit('microeV') + + # EXPECT the same curve, read off the rescaled axis + np.testing.assert_allclose(stretched_exponential.evaluate(x * 1e3), before, rtol=1e-12) + + def test_convert_x_unit_invalid_type_raises(self, stretched_exponential: StretchedExponential): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match=r'x_unit must be a string or sc\.Unit'): + stretched_exponential.convert_x_unit(123) + + def test_convert_x_unit_rollback_on_failure(self, stretched_exponential: StretchedExponential): + # WHEN THEN + with pytest.raises(UnitError): + stretched_exponential.convert_x_unit('m') + + # EXPECT: state rolled back + assert stretched_exponential.x_unit == 'meV' + assert stretched_exponential.area.value == pytest.approx(2.0) + assert stretched_exponential.center.value == pytest.approx(0.5) + + def test_convert_y_unit(self): + # WHEN: x_unit='meV', y_unit='1/meV' → area_unit='dimensionless' + stretched = StretchedExponential(area=1.0, x_unit='meV', y_unit='1/meV') + + # THEN: convert y_unit to '1/eV' (same dimension, different scale) + stretched.convert_y_unit('1/eV') + + # EXPECT: y_unit updated and area value rescaled (1e3 factor) + assert stretched.y_unit == '1/eV' + assert stretched.area.value == pytest.approx(1e3) + + def test_convert_y_unit_invalid_type_raises(self, stretched_exponential: StretchedExponential): + # WHEN THEN EXPECT + with pytest.raises(TypeError): + stretched_exponential.convert_y_unit(123) + + def test_convert_y_unit_rollback_on_failure(self): + # WHEN + stretched = StretchedExponential(area=1.0, x_unit='meV') + + # THEN + with pytest.raises(UnitError): + stretched.convert_y_unit('K') + + # EXPECT: state rolled back + assert stretched.y_unit == 'dimensionless' + assert stretched.area.value == pytest.approx(1.0) diff --git a/tests/unit/easydynamics/sample_model/components/test_voigt.py b/tests/unit/easydynamics/sample_model/components/test_voigt.py index 42eb5099c..8190af1f3 100644 --- a/tests/unit/easydynamics/sample_model/components/test_voigt.py +++ b/tests/unit/easydynamics/sample_model/components/test_voigt.py @@ -51,7 +51,7 @@ def test_initialization(self, voigt: Voigt): assert voigt.x_unit == 'meV' @pytest.mark.parametrize( - 'kwargs, expected_message', + 'kwargs, expected_message, expected_error', [ ( { @@ -62,6 +62,7 @@ def test_initialization(self, voigt: Voigt): 'x_unit': 'meV', }, 'area must be a number', + TypeError, ), ( { @@ -72,6 +73,7 @@ def test_initialization(self, voigt: Voigt): 'x_unit': 'meV', }, 'center must be None', + TypeError, ), ( { @@ -82,6 +84,7 @@ def test_initialization(self, voigt: Voigt): 'x_unit': 'meV', }, 'gaussian_width must be a number', + TypeError, ), ( { @@ -92,6 +95,7 @@ def test_initialization(self, voigt: Voigt): 'x_unit': 'meV', }, 'lorentzian_width must be a number', + TypeError, ), ( { @@ -102,6 +106,7 @@ def test_initialization(self, voigt: Voigt): 'x_unit': 123, }, 'unit must be None, a string', + UnitError, ), ( { @@ -113,12 +118,13 @@ def test_initialization(self, voigt: Voigt): 'y_unit': 123, }, 'unit must be None, a string', + UnitError, ), ], ) - def test_input_type_validation_raises(self, kwargs, expected_message): + def test_input_type_validation_raises(self, kwargs, expected_message, expected_error): # WHEN THEN EXPECT - with pytest.raises(TypeError, match=expected_message): + with pytest.raises(expected_error, match=expected_message): Voigt(display_name='TestVoigt', **kwargs) def test_negative_gaussian_width_raises(self): @@ -201,6 +207,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..476b0b11b 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 @@ -91,7 +86,7 @@ def test_convert_x_unit_persists_after_dependency_update(self): 'scale': 1.0, 'diffusion_coefficient': 1.0, }, - TypeError, + UnitError, None, ), ( @@ -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..af2eed4f0 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 @@ -52,7 +47,7 @@ def test_init_default(self, jump_diffusion_model): 'diffusion_coefficient': 1.0, 'relaxation_time': 1.0, }, - TypeError, + UnitError, None, ), ( @@ -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/diffusion_model/test_mittag_leffler_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_mittag_leffler_diffusion.py new file mode 100644 index 000000000..0090071e8 --- /dev/null +++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_mittag_leffler_diffusion.py @@ -0,0 +1,505 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import numpy as np +import pytest +import scipp as sc +from easyscience.variable import Parameter + +from easydynamics.sample_model import DiffusionDampedMittagLeffler +from easydynamics.sample_model import Lorentzian +from easydynamics.sample_model import MittagLefflerDiffusion +from easydynamics.sample_model.diffusion_model.mittag_leffler_diffusion import ( + DIFFUSION_COEFFICIENT_MIN, +) +from easydynamics.utils.utils import angstrom +from easydynamics.utils.utils import hbar + +ALL_Q_VARIATION = {'A_0': True, 'relaxation_rate': True, 'alpha': True} + + +class TestMittagLefflerDiffusion: + @pytest.fixture + def model(self): + return MittagLefflerDiffusion( + scale=2.0, + diffusion_coefficient=3.3e-11, + A_0=0.05, + relaxation_rate=0.02, + alpha=0.85, + ) + + @pytest.fixture + def model_with_Q(self): + return MittagLefflerDiffusion( + scale=2.0, + diffusion_coefficient=3.3e-11, + A_0=0.05, + relaxation_rate=0.02, + alpha=0.85, + allow_Q_variation=ALL_Q_VARIATION, + Q=np.linspace(0.8, 1.8, 5), + ) + + @pytest.fixture + def model_with_Q_no_variation(self): + return MittagLefflerDiffusion( + scale=2.0, + diffusion_coefficient=3.3e-11, + A_0=0.05, + relaxation_rate=0.02, + alpha=0.85, + Q=np.linspace(0.8, 1.8, 5), + ) + + ############# + # Construction + ############# + + def test_init_default(self): + # WHEN THEN + model = MittagLefflerDiffusion() + + # EXPECT + assert model.scale.value == pytest.approx(1.0) + assert model.diffusion_coefficient.value == pytest.approx(1.0) + assert model.A_0.value == pytest.approx(0.0) + assert model.A_1.value == pytest.approx(1.0) + assert model.relaxation_rate.value == pytest.approx(1.0) + assert model.alpha.value == pytest.approx(1.0) + assert model.x_unit == 'meV' + assert model.get_component_collections() == [] + + def test_init_with_Q_builds_two_components_per_Q(self, model_with_Q): + # WHEN THEN + collections = model_with_Q.get_component_collections() + + # EXPECT one elastic Lorentzian and one Mittag-Leffler component at every Q + assert len(collections) == 5 + for collection in collections: + assert collection.list_component_names() == ['Elastic Lorentzian', 'Mittag-Leffler'] + assert isinstance(collection[0], Lorentzian) + assert isinstance(collection[1], DiffusionDampedMittagLeffler) + + def test_parameter_units(self, model): + # WHEN THEN EXPECT + assert model.scale.unit == 'meV' + assert model.diffusion_coefficient.unit == 'm^2/s' + assert model.relaxation_rate.unit == 'meV' + assert model.alpha.unit == 'dimensionless' + + @pytest.mark.parametrize( + 'kwargs, expected_exception, expected_message', + [ + ({'diffusion_coefficient': 'invalid'}, TypeError, 'must be a number'), + ({'diffusion_coefficient': -1.0}, ValueError, 'must be at least'), + ({'diffusion_coefficient': 0.0}, ValueError, 'must be at least'), + ({'A_0': 'invalid'}, TypeError, 'A_0 must be a number'), + ({'A_0': 1.5}, ValueError, 'A_0 must be between 0 and 1'), + ({'relaxation_rate': 'invalid'}, TypeError, 'must be a number'), + ({'relaxation_rate': -1.0}, ValueError, 'relaxation_rate must be at least'), + ({'alpha': 'invalid'}, TypeError, 'alpha must be a number'), + ({'alpha': 1.5}, ValueError, 'greater than zero and at most one'), + ({'alpha': 0.0}, ValueError, 'greater than zero and at most one'), + ({'mittag_leffler_name': 123}, TypeError, 'mittag_leffler_name must be a string'), + ({'allow_Q_variation': 'invalid'}, TypeError, 'must be a dict or None'), + ({'allow_Q_variation': {'nope': True}}, ValueError, 'Unknown keys'), + ], + ) + def test_input_validation_raises(self, kwargs, expected_exception, expected_message): + # WHEN THEN EXPECT + with pytest.raises(expected_exception, match=expected_message): + MittagLefflerDiffusion(**kwargs) + + @pytest.mark.parametrize( + 'prop, valid_value', + [ + ('diffusion_coefficient', 5e-11), + ('A_0', 0.3), + ('relaxation_rate', 0.05), + ('alpha', 0.6), + ], + ) + def test_setters(self, model, prop, valid_value): + # WHEN THEN + setattr(model, prop, valid_value) + + # EXPECT + assert getattr(model, prop).value == pytest.approx(valid_value) + + # WHEN: an invalid value — THEN EXPECT + with pytest.raises(TypeError, match='must be a number'): + setattr(model, prop, 'invalid') + + def test_diffusion_coefficient_setter_rejects_instead_of_clamping(self, model): + # WHEN a diffusion coefficient below the parameter's own bound is assigned + original = model.diffusion_coefficient.value + + # THEN EXPECT it raises rather than being silently clamped up to the bound, which would + # leave the model holding a value nobody asked for + with pytest.raises(ValueError, match='must be at least'): + model.diffusion_coefficient = 0.0 + assert model.diffusion_coefficient.value == pytest.approx(original) + + def test_diffusion_coefficient_bound_keeps_the_damping_positive(self, model_with_Q): + # WHEN the smallest allowed diffusion coefficient is used + model_with_Q.diffusion_coefficient = DIFFUSION_COEFFICIENT_MIN + + # THEN + damping = model_with_Q.calculate_width() + + # EXPECT the damping stays strictly positive, so the lineshapes remain regular at x=0 + assert np.all(damping > 0) + values = model_with_Q.get_component_collections()[0].evaluate(np.linspace(-0.1, 0.1, 21)) + assert np.all(np.isfinite(values)) + + def test_A_1_setter_raises(self, model): + # WHEN THEN EXPECT A_1 is derived from A_0 + with pytest.raises(AttributeError, match='derived from A_0'): + model.A_1 = 0.5 + + def test_A_1_follows_A_0(self, model): + # WHEN THEN + model.A_0 = 0.25 + + # EXPECT + assert model.A_1.value == pytest.approx(0.75) + + ############# + # Q-dependent quantities + ############# + + def test_calculate_width_is_hbar_D_Q_squared(self, model_with_Q): + # WHEN THEN + Q = model_with_Q.Q.values + widths = model_with_Q.calculate_width() + + # EXPECT epsilon = hbar * D * Q**2, matching Eq. (39) of Hassani et al. + factor = hbar * model_with_Q.diffusion_coefficient / angstrom**2 + factor.convert_unit('meV') + np.testing.assert_allclose(widths, Q**2 * factor.value, rtol=1e-10) + + def test_calculate_EISF_and_QISF_sum_to_one(self, model_with_Q): + # WHEN THEN + eisf = model_with_Q.calculate_EISF() + qisf = model_with_Q.calculate_QISF() + + # EXPECT + np.testing.assert_allclose(eisf + qisf, np.ones(5), rtol=1e-12) + + def test_calculate_relaxation_rate_and_alpha_shared(self, model_with_Q_no_variation): + # WHEN THEN EXPECT the shared values are returned at every Q + np.testing.assert_allclose( + model_with_Q_no_variation.calculate_relaxation_rate(), np.full(5, 0.02) + ) + np.testing.assert_allclose(model_with_Q_no_variation.calculate_alpha(), np.full(5, 0.85)) + + def test_per_Q_parameters_are_independent(self, model_with_Q): + # WHEN a single Q gets its own alpha, relaxation rate and elastic fraction + model_with_Q._alpha_list[2].value = 0.6 + model_with_Q._relaxation_rate_list[2].value = 0.05 + model_with_Q._A_0_list[2].value = 0.2 + + # THEN + alphas = model_with_Q.calculate_alpha() + rates = model_with_Q.calculate_relaxation_rate() + eisf = model_with_Q.calculate_EISF() + + # EXPECT only that Q changed + np.testing.assert_allclose(alphas, [0.85, 0.85, 0.6, 0.85, 0.85]) + np.testing.assert_allclose(rates, [0.02, 0.02, 0.05, 0.02, 0.02]) + np.testing.assert_allclose(eisf, [0.05, 0.05, 0.2, 0.05, 0.05]) + + def test_per_Q_parameters_back_the_components(self, model_with_Q): + # WHEN + model_with_Q._alpha_list[2].value = 0.6 + model_with_Q._relaxation_rate_list[2].value = 0.05 + model_with_Q._A_0_list[2].value = 0.2 + + # THEN + collection = model_with_Q.get_component_collections()[2] + + # EXPECT the component parameters are the very ones in the per-Q lists + assert collection[1].alpha.value == pytest.approx(0.6) + assert collection[1].width.value == pytest.approx(0.05) + assert collection[0].area.value == pytest.approx(2.0 * 0.2) + assert collection[1].scale.value == pytest.approx(2.0 * 0.8) + + def test_calculate_raises_when_Q_variation_enabled_but_Q_unset(self): + # WHEN + model = MittagLefflerDiffusion(allow_Q_variation=ALL_Q_VARIATION) + + # THEN EXPECT + with pytest.raises(ValueError, match='Q must be provided'): + model.calculate_relaxation_rate() + with pytest.raises(ValueError, match='Q must be provided'): + model.calculate_alpha() + + ############# + # Parameter wiring + ############# + + def test_damping_is_shared_by_both_components(self, model_with_Q): + # WHEN THEN + collections = model_with_Q.get_component_collections() + widths = model_with_Q.calculate_width() + + # EXPECT the elastic Lorentzian's HWHM and the ML component's damping are both epsilon + for i, collection in enumerate(collections): + assert collection[0].width.value == pytest.approx(widths[i]) + assert collection[1].damping.value == pytest.approx(widths[i]) + + def test_global_diffusion_coefficient_drives_every_Q(self, model_with_Q): + # WHEN + collections = model_with_Q.get_component_collections() + before = [collection[1].damping.value for collection in collections] + + # THEN + model_with_Q.diffusion_coefficient = 2 * model_with_Q.diffusion_coefficient.value + + # EXPECT + after = [collection[1].damping.value for collection in collections] + np.testing.assert_allclose(after, np.array(before) * 2, rtol=1e-10) + + def test_shared_alpha_and_rate_drive_every_Q(self, model_with_Q_no_variation): + # WHEN + collections = model_with_Q_no_variation.get_component_collections() + + # THEN + model_with_Q_no_variation.alpha = 0.5 + model_with_Q_no_variation.relaxation_rate = 0.03 + + # EXPECT + for collection in collections: + assert collection[1].alpha.value == pytest.approx(0.5) + assert collection[1].width.value == pytest.approx(0.03) + + def test_scale_and_A_0_drive_the_component_amplitudes(self, model_with_Q_no_variation): + # WHEN + collections = model_with_Q_no_variation.get_component_collections() + + # THEN + model_with_Q_no_variation.scale = 4.0 + model_with_Q_no_variation.A_0 = 0.25 + + # EXPECT area = scale * EISF for the elastic line, scale * (1 - EISF) for the ML term + for collection in collections: + assert collection[0].area.value == pytest.approx(4.0 * 0.25) + assert collection[1].scale.value == pytest.approx(4.0 * 0.75) + + def test_matches_equation_41_by_hand(self, model_with_Q_no_variation): + # WHEN the collection is Eq. (41): EISF * Lorentzian(eps) + (1-EISF) * ML(eps) + collection = model_with_Q_no_variation.get_component_collections()[0] + epsilon = model_with_Q_no_variation.calculate_width()[0] + x = np.linspace(-0.3, 0.3, 201) + + # THEN + result = collection.evaluate(x) + + # EXPECT + elastic = Lorentzian(area=2.0 * 0.05, width=epsilon) + quasi_elastic = DiffusionDampedMittagLeffler( + scale=2.0 * 0.95, alpha=0.85, width=0.02, damping=epsilon + ) + expected = elastic.evaluate(x) + quasi_elastic.evaluate(x) + np.testing.assert_allclose(result, expected, rtol=1e-10) + + ############# + # Variables and fit targets + ############# + + def test_get_global_variables_with_Q_variation(self, model_with_Q): + # WHEN THEN + names = [variable.name for variable in model_with_Q.get_global_variables()] + + # EXPECT only scale and D are global once everything else varies with Q + assert names == ['scale', 'diffusion_coefficient'] + + def test_get_global_variables_without_Q_variation(self, model_with_Q_no_variation): + # WHEN THEN + names = [variable.name for variable in model_with_Q_no_variation.get_global_variables()] + + # EXPECT + assert names == [ + 'scale', + 'diffusion_coefficient', + 'A_0', + 'A_1', + 'relaxation_rate', + 'alpha', + ] + + def test_get_independent_variables_holds_the_per_Q_amplitudes(self, model_with_Q): + # WHEN THEN + for_all_Q = model_with_Q.get_independent_variables() + for_one_Q = model_with_Q.get_independent_variables(Q_index=1) + + # EXPECT one A_0/A_1 pair per Q + assert len(for_all_Q) == 10 + assert len(for_one_Q) == 2 + assert for_one_Q[0] is model_with_Q._A_0_list[1] + + def test_free_parameters_match_the_papers_fit(self, model_with_Q): + # WHEN the paper fits tau, alpha and the EISF per Q against a single global D + free = model_with_Q.get_free_parameters() + + # THEN + names = [parameter.name for parameter in free] + + # EXPECT scale + D + 5 * (A_0, alpha, width) + assert names.count('scale') == 1 + assert names.count('diffusion_coefficient') == 1 + assert names.count('MittagLefflerDiffusion A_0') == 5 + assert names.count('Mittag-Leffler alpha') == 5 + assert names.count('Mittag-Leffler width') == 5 + assert all(isinstance(parameter, Parameter) for parameter in free) + + def test_get_fit_targets(self, model_with_Q): + # WHEN THEN + targets = {target.name: target for target in model_with_Q.get_fit_targets()} + + # EXPECT the quasi-elastic weight points at the Mittag-Leffler component, and the + # Lorentzian keys at the elastic line + assert set(targets) == {'area', 'width', 'elastic_area'} + assert targets['area'].dataset_key == 'Mittag-Leffler scale' + assert targets['width'].dataset_key == 'Elastic Lorentzian width' + assert targets['elastic_area'].dataset_key == 'Elastic Lorentzian area' + + Q = model_with_Q.Q.values + np.testing.assert_allclose( + targets['area'].function(Q), model_with_Q.calculate_QISF(Q) * 2.0 + ) + np.testing.assert_allclose( + targets['elastic_area'].function(Q), model_with_Q.calculate_EISF(Q) * 2.0 + ) + + ############# + # Formulas from the paper + ############# + + def test_relaxation_rate_spectrum_is_normalised(self, model_with_Q_no_variation): + # WHEN Eq. (37) is a distribution over relaxation rates, so it integrates to 1 + rate = np.logspace(-8, 4, 400_001) + + # THEN + spectrum = model_with_Q_no_variation.calculate_relaxation_rate_spectrum(rate) + + # EXPECT + assert spectrum.shape == (5, rate.size) + assert np.trapezoid(spectrum[0], rate) == pytest.approx(1.0, rel=1e-3) + + def test_relaxation_rate_spectrum_alpha_one_is_peaked_at_the_rate(self): + # WHEN alpha=1 the relaxation is exponential, so p(lambda) collapses onto a delta at 1/tau + model = MittagLefflerDiffusion(alpha=1.0, relaxation_rate=0.02, Q=np.array([1.0])) + rate = np.linspace(0.001, 0.1, 2001) + + # THEN + spectrum = model.calculate_relaxation_rate_spectrum(rate) + + # EXPECT + assert rate[np.argmax(spectrum[0])] == pytest.approx(0.02, abs=1e-3) + + def test_relaxation_rate_spectrum_rejects_non_positive_rate(self, model_with_Q): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match='rate must be strictly positive'): + model_with_Q.calculate_relaxation_rate_spectrum(np.array([0.0, 1.0])) + + @pytest.mark.parametrize('alpha', [0.99, 0.7, 0.3]) + def test_energy_barrier_distribution_carries_half_the_rate_spectrum(self, alpha): + # WHEN h >= 0 maps onto lambda * tau_R <= 1 only, which by the lambda -> 1/lambda symmetry + # of Eq. (37) is half of p(lambda) + model = MittagLefflerDiffusion(alpha=alpha, Q=np.array([1.0])) + h = np.linspace(0.0, 30.0, 60_001) + + # THEN + distribution = model.calculate_energy_barrier_distribution(h)[0] + + # EXPECT + assert np.all(np.isfinite(distribution)) + assert np.trapezoid(distribution, h) == pytest.approx(0.5, rel=1e-3) + + def test_energy_barrier_distribution_narrows_as_alpha_goes_to_one(self): + # WHEN Eq. (48) tends to delta(h) as alpha -> 1 and broadens as alpha -> 0 + h = np.linspace(0.0, 10.0, 2001) + smooth = MittagLefflerDiffusion(alpha=0.99, Q=np.array([1.0])) + rough = MittagLefflerDiffusion(alpha=0.3, Q=np.array([1.0])) + + # THEN + smooth_distribution = smooth.calculate_energy_barrier_distribution(h)[0] + rough_distribution = rough.calculate_energy_barrier_distribution(h)[0] + + # EXPECT the rough landscape peaks at, and reaches, much higher barriers + assert h[np.argmax(smooth_distribution)] < h[np.argmax(rough_distribution)] + assert smooth_distribution[-1] < rough_distribution[-1] + + def test_energy_barrier_distribution_vanishes_at_zero_barrier(self, model_with_Q): + # WHEN THEN + distribution = model_with_Q.calculate_energy_barrier_distribution(np.array([0.0])) + + # EXPECT + np.testing.assert_allclose(distribution, np.zeros((5, 1)), atol=1e-15) + + ############# + # Units and Q handling + ############# + + def test_convert_x_unit(self, model_with_Q): + # WHEN + widths_before = model_with_Q.calculate_width() + + # THEN + model_with_Q.convert_x_unit('microeV') + + # EXPECT the shared template, the per-Q rates and the components all follow + assert model_with_Q.x_unit == 'microeV' + assert sc.Unit(model_with_Q.relaxation_rate.unit) == sc.Unit('microeV') + np.testing.assert_allclose(model_with_Q.calculate_width(), widths_before * 1e3, rtol=1e-10) + for collection in model_with_Q.get_component_collections(): + assert collection[1].width.value == pytest.approx(20.0) + assert collection[1].alpha.value == pytest.approx(0.85) + + def test_convert_y_unit_rescales_the_scale(self): + # WHEN scale_unit = x_unit * y_unit = meV/s + model = MittagLefflerDiffusion(scale=2.0, y_unit='1/s', Q=np.linspace(0.8, 1.8, 3)) + + # THEN + model.convert_y_unit('1/ms') + + # EXPECT the scale follows the y-unit, 1/s -> 1/ms being a factor 1e-3 + assert model.y_unit == '1/ms' + assert model.scale.value == pytest.approx(2.0e-3) + assert sc.Unit(model.scale.unit) == sc.Unit('meV/ms') + + def test_on_Q_change_rebuilds_the_per_Q_lists(self, model): + # WHEN + model._allow_Q_variation = dict(ALL_Q_VARIATION) + model.Q = np.linspace(0.5, 1.5, 3) + + # THEN + collections = model.get_component_collections() + + # EXPECT + assert len(collections) == 3 + assert len(model._alpha_list) == 3 + assert len(model._relaxation_rate_list) == 3 + assert collections[1][1].alpha is model._alpha_list[1] + + def test_clear_Q_empties_the_model(self, model_with_Q): + # WHEN THEN + model_with_Q.clear_Q(confirm=True) + + # EXPECT + assert model_with_Q.get_component_collections() == [] + assert model_with_Q._alpha_list == [] + assert model_with_Q._relaxation_rate_list == [] + assert model_with_Q._A_0_list == [] + + def test_repr(self, model): + # WHEN THEN + repr_str = repr(model) + + # EXPECT + assert 'MittagLefflerDiffusion' in repr_str + assert 'diffusion_coefficient=' in repr_str + assert 'relaxation_rate=' in repr_str + assert 'alpha=' in repr_str diff --git a/tests/unit/easydynamics/sample_model/test_component_collection.py b/tests/unit/easydynamics/sample_model/test_component_collection.py index 233117031..a9480de8b 100644 --- a/tests/unit/easydynamics/sample_model/test_component_collection.py +++ b/tests/unit/easydynamics/sample_model/test_component_collection.py @@ -7,8 +7,10 @@ import pytest import scipp as sc from easyscience.variable import Parameter +from scipp import UnitError 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 @@ -110,10 +112,12 @@ def test_init_with_invalid_list_of_components_raises(self): def test_init_with_invalid_unit_raises(self): # WHEN THEN EXPECT - with pytest.raises(TypeError, match='unit must be'): + with pytest.raises(UnitError, match='unit must be'): ComponentCollection(x_unit=123) - # ───── Component Management ───── + ############# + # Component Management + ############# def test_append_component(self, component_collection): # WHEN @@ -308,7 +312,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 @@ -354,7 +360,8 @@ def test_normalize_area_non_area_component_warns(self, component_collection): def test_convert_x_unit_rollback_skipped_when_old_unit_none(self): # WHEN: a collection without an x_unit of its own collection = ComponentCollection( - components=Gaussian(name='G', area=1.0, width=0.5, x_unit='meV'), x_unit=None + components=Gaussian(name='G', area=1.0, width=0.5, x_unit='meV'), + x_unit=None, ) # THEN: an incompatible unit fails; the outer rollback is skipped (no old unit to @@ -676,7 +683,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..5268f2fc6 100644 --- a/tests/unit/easydynamics/sample_model/test_instrument_model.py +++ b/tests/unit/easydynamics/sample_model/test_instrument_model.py @@ -7,6 +7,7 @@ import numpy as np import pytest import scipp as sc +from scipp import UnitError from easydynamics.sample_model import Gaussian from easydynamics.sample_model import Polynomial @@ -115,7 +116,7 @@ def test_init_sample_model_as_resolution_model(self, sample_model): ), ( {'x_unit': 123}, - TypeError, + UnitError, 'unit must be', ), ], @@ -551,36 +552,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 0b19950b8..5bd222185 100644 --- a/tests/unit/easydynamics/sample_model/test_model_base.py +++ b/tests/unit/easydynamics/sample_model/test_model_base.py @@ -281,6 +281,36 @@ def test_convert_x_unit(self, model_base): for component in collection: assert component.x_unit == 'eV' + def test_init_propagates_units_to_template_collection(self): + "Regression: the template ComponentCollection must carry the model's units" + # WHEN + model = ModelBase( + display_name='M', + x_unit='ueV', + y_unit='counts', + components=Gaussian(name='G', x_unit='ueV', y_unit='counts'), + Q=np.array([1.0, 2.0]), + ) + + # THEN EXPECT: template and per-Q collections carry the model units + assert model._components.x_unit == 'ueV' + assert model._components.y_unit == 'counts' + collection = model.get_component_collection(0) + assert collection.x_unit == 'ueV' + assert collection.y_unit == 'counts' + assert collection.get_fit_targets()[0].y_unit == 'counts' + + def test_convert_x_unit_updates_template_collection_unit(self, model_base): + "Regression: conversion must update the template collection's own unit attribute" + # WHEN + model_base.convert_x_unit('eV') + + # THEN EXPECT: the template collection follows, so per-Q collections regenerated + # later (e.g. after a Q change) carry the new unit + assert model_base._components.x_unit == 'eV' + model_base._component_collections_is_dirty = True + assert model_base.get_component_collection(0).x_unit == 'eV' + def test_convert_x_unit_invalid_raises(self, model_base): # WHEN / THEN / EXPECT with pytest.raises(UnitError): @@ -513,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 d2ffe77ba..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 # noqa: F401 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 7899f249c..11faa0716 100644 --- a/tests/unit/easydynamics/utils/test_utils.py +++ b/tests/unit/easydynamics/utils/test_utils.py @@ -7,6 +7,7 @@ import pytest import scipp as sc from easyscience.variable import Parameter +from scipp import UnitError from easydynamics.utils.utils import _assert_valid_unit from easydynamics.utils.utils import _in_notebook @@ -54,6 +55,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): @@ -160,6 +177,24 @@ def test_validate_and_convert_Q_none(self): # WHEN THEN EXPECT assert _validate_and_convert_Q(None) is None + @pytest.mark.parametrize( + 'Q_input', + [ + -1.0, + 0.0, + [1.0, 0.0], + [1.0, -2.0], + np.array([0.0, 1.0]), + sc.array(dims=['Q'], values=[0.0, 1.0], unit='1/angstrom'), + sc.array(dims=['Q'], values=[-1.0, 1.0], unit='1/nm'), + ], + ) + def test_validate_and_convert_Q_non_positive(self, Q_input): + # WHEN THEN EXPECT zero is rejected along with negative Q: models derive their widths + # from Q**2, so a zero Q collapses them + with pytest.raises(ValueError, match='Q values must be positive'): + _validate_and_convert_Q(Q_input) + @pytest.mark.parametrize( 'Q_input', [ @@ -237,7 +272,7 @@ def test_validate_unit_string_conversion(self): ) def test_validate_unit_invalid_type(self, unit_input): # WHEN THEN EXPECT - with pytest.raises(TypeError, match='unit must be None, a string, or a scipp Unit'): + with pytest.raises(UnitError, match='unit must be None, a string, or a scipp Unit'): _validate_unit(unit_input) @@ -292,7 +327,7 @@ def test_in_notebook_returns_false_when_no_ipython(self, monkeypatch): available.""" # WHEN - def raise_import_error(*args, **kwargs): # noqa: ARG001 + def raise_import_error(*args, **kwargs): # ruff: ignore[unused-function-argument] raise ImportError # THEN @@ -302,40 +337,37 @@ def raise_import_error(*args, **kwargs): # noqa: ARG001 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') + # 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_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() +class TestEnergyToScipp: + def test_returns_variable_with_unit(self): + # THEN + result = energy_to_scipp(np.array([1.0, 2.0, 3.0]), 'meV') -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]) - + # 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/fit_kww_hwhm_coefficients.py b/tools/fit_kww_hwhm_coefficients.py new file mode 100644 index 000000000..3056d2ee9 --- /dev/null +++ b/tools/fit_kww_hwhm_coefficients.py @@ -0,0 +1,193 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +""" +Refit ``_HWHM_POLY_COEFFS`` in ``easydynamics.sample_model.components.stretched_exponential``. + +``StretchedExponential.width`` is a dependent easyscience ``Parameter``, and easyscience resolves +a dependency from a string expression, so the half width has to be written in closed form. The +half width is not available in closed form: it is the ``w`` solving + + G_beta(w) = 0.5 * G_beta(0), G_beta(w) = integral of exp(-u**beta) cos(w u) du, + +which ``_reduced_hwhm`` brackets numerically. It is very nearly closed form, though, because + + eps(beta) := beta * ln(HWHM(beta)) - ln(beta) + +is small, smooth and O(0.06), so that + + HWHM(beta) = exp((ln(beta) + eps(beta)) / beta) + +with ``eps`` a low-order polynomial. This script fits that polynomial against ``_reduced_hwhm`` +and prints the coefficient block to paste back into the module. + +The fit degrades below beta = 0.1, but so does the root it is fitted to: by beta = 0.05 the +profile takes some 60 e-folds of ``w`` to fall by half, so ``G_beta`` is nearly flat in ``log w`` +near the crossing. Wuttke's reference implementation libkww (arXiv:0911.4796) likewise supports +only ``0.1 <= beta <= 1.9``. Both are far below any energy grid step by then, so the accuracy +that matters is the one reported for ``beta >= 0.1``. + +Usage +----- +``` +pixi run python tools/fit_kww_hwhm_coefficients.py # refit and print the block +pixi run python tools/fit_kww_hwhm_coefficients.py --check # verify the committed values +pixi run python tools/fit_kww_hwhm_coefficients.py --degree 10 --samples 800 +``` +""" + +import argparse +import sys + +import numpy as np +from numpy.polynomial import polynomial as P + +from easydynamics.sample_model.components.stretched_exponential import _HWHM_POLY_COEFFS +from easydynamics.sample_model.components.stretched_exponential import MAXIMUM_BETA +from easydynamics.sample_model.components.stretched_exponential import MINIMUM_BETA +from easydynamics.sample_model.components.stretched_exponential import _reduced_hwhm + +# The committed coefficients were produced with these settings; changing them changes the fit. +DEFAULT_DEGREE = 8 +DEFAULT_SAMPLES = 400 +# Accuracy is quoted over the range the fit is meant to serve, and where libkww also stops. +REPORTING_FLOOR = 0.1 +# Loose enough to absorb a scipy root-finder nudge, tight enough to catch a real regression. +CHECK_TOLERANCE = 5e-5 + + +def sample_grid(samples: int) -> np.ndarray: + """ + Build the beta grid the fit is evaluated on. + + Log spacing matches how the half width actually varies: it spans some 26 decades across the + supported beta, almost all of that below beta = 0.5. + + Parameters + ---------- + samples : int + Number of grid points. + + Returns + ------- + np.ndarray + Log-spaced beta values over ``[MINIMUM_BETA, MAXIMUM_BETA]``. + """ + return np.exp(np.linspace(np.log(MINIMUM_BETA), np.log(MAXIMUM_BETA), samples)) + + +def fit_coefficients(beta: np.ndarray, degree: int) -> np.ndarray: + """ + Least-squares fit the eps polynomial against the solved half width. + + Parameters + ---------- + beta : np.ndarray + Beta values to fit over. + degree : int + Polynomial degree. + + Returns + ------- + np.ndarray + Coefficients in ascending powers of beta. + """ + hwhm = np.array([_reduced_hwhm(b) for b in beta]) + eps = beta * np.log(hwhm) - np.log(beta) + return P.polyfit(beta, eps, degree) + + +def relative_error(beta: np.ndarray, coefficients: np.ndarray) -> np.ndarray: + """ + Compare the closed form against the solved half width. + + Parameters + ---------- + beta : np.ndarray + Beta values to evaluate at. + coefficients : np.ndarray + Polynomial coefficients in ascending powers of beta. + + Returns + ------- + np.ndarray + Relative error of the closed form at each beta. + """ + exact = np.array([_reduced_hwhm(b) for b in beta]) + approx = np.exp((np.log(beta) + P.polyval(beta, coefficients)) / beta) + return np.abs(approx / exact - 1.0) + + +def report(beta: np.ndarray, coefficients: np.ndarray) -> float: + """ + Print the accuracy of a coefficient set and return the error over the reporting range. + + Parameters + ---------- + beta : np.ndarray + Beta values to evaluate at. + coefficients : np.ndarray + Polynomial coefficients in ascending powers of beta. + + Returns + ------- + float + Maximum relative error for ``beta >= REPORTING_FLOOR``. + """ + error = relative_error(beta, coefficients) + above_floor = error[beta >= REPORTING_FLOOR].max() + print(f' max relative error, all beta : {error.max():.2e}') + print(f' max relative error, beta >= {REPORTING_FLOOR} : {above_floor:.2e}') + for probe in (2.0, 1.0, 0.5, 0.2, 0.1, MINIMUM_BETA): + single = relative_error(np.array([probe]), coefficients)[0] + print(f' beta = {probe:<5} -> {single:.2e}') + return float(above_floor) + + +def main() -> int: + """ + Refit the coefficients, or check the committed ones. + + Returns + ------- + int + Process exit status. + """ + parser = argparse.ArgumentParser(description=__doc__.splitlines()[1]) + parser.add_argument( + '--check', + action='store_true', + help='verify the committed coefficients instead of refitting', + ) + parser.add_argument('--degree', type=int, default=DEFAULT_DEGREE) + parser.add_argument('--samples', type=int, default=DEFAULT_SAMPLES) + args = parser.parse_args() + + beta = sample_grid(args.samples) + + if args.check: + print(f'Checking committed _HWHM_POLY_COEFFS ({len(_HWHM_POLY_COEFFS) - 1} degree):') + above_floor = report(beta, np.array(_HWHM_POLY_COEFFS)) + if above_floor > CHECK_TOLERANCE: + print(f'\nFAIL: {above_floor:.2e} exceeds the {CHECK_TOLERANCE:.0e} tolerance.') + print('Rerun without --check and paste the new block into the module.') + return 1 + print(f'\nOK: within the {CHECK_TOLERANCE:.0e} tolerance.') + return 0 + + coefficients = fit_coefficients(beta, args.degree) + print( + f'Fitted degree {args.degree} over {args.samples} log-spaced beta in ' + f'[{MINIMUM_BETA}, {MAXIMUM_BETA}]:' + ) + report(beta, coefficients) + + print('\nPaste into stretched_exponential.py:\n') + print('_HWHM_POLY_COEFFS = (') + for coefficient in coefficients: + print(f' {float(coefficient)!r},') + print(')') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) 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())